1/* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16import {assertDefined} from 'common/assert_utils'; 17import {TransformMatrix} from 'common/geometry_types'; 18import * as THREE from 'three'; 19import { 20 CSS2DObject, 21 CSS2DRenderer, 22} from 'three/examples/jsm/renderers/CSS2DRenderer'; 23import {ViewerEvents} from 'viewers/common/viewer_events'; 24import { 25 Circle3D, 26 ColorType, 27 Label3D, 28 Point3D, 29 Rect3D, 30 Scene3D, 31} from './types3d'; 32 33export class Canvas { 34 static readonly TARGET_SCENE_DIAGONAL = 4; 35 private static readonly RECT_COLOR_HIGHLIGHTED_LIGHT_MODE = new THREE.Color( 36 0xd2e3fc, // Keep in sync with :not(.dark-mode) --selected-element-color in material-theme.scss 37 ); 38 private static readonly RECT_COLOR_HIGHLIGHTED_DARK_MODE = new THREE.Color( 39 0x5f718a, // Keep in sync with .dark-mode --selected-element-color in material-theme.scss 40 ); 41 private static readonly RECT_COLOR_HAS_CONTENT = new THREE.Color(0xad42f5); 42 43 private static readonly RECT_EDGE_COLOR_LIGHT_MODE = 0x000000; 44 private static readonly RECT_EDGE_COLOR_DARK_MODE = 0xffffff; 45 private static readonly RECT_EDGE_COLOR_ROUNDED = 0x848884; 46 47 private static readonly LABEL_LINE_COLOR = 0x808080; 48 49 private static readonly OPACITY_REGULAR = 0.75; 50 private static readonly OPACITY_OVERSIZED = 0.25; 51 52 private camera?: THREE.OrthographicCamera; 53 private scene?: THREE.Scene; 54 private renderer?: THREE.WebGLRenderer; 55 private labelRenderer?: CSS2DRenderer; 56 private clickableObjects: THREE.Object3D[] = []; 57 58 constructor( 59 private canvasRects: HTMLCanvasElement, 60 private canvasLabels?: HTMLElement, 61 private isDarkMode = () => false, 62 ) {} 63 64 draw(scene: Scene3D) { 65 // Must set 100% width and height so the HTML element expands to the parent's 66 // boundaries and the correct clientWidth and clientHeight values can be read 67 this.canvasRects.style.width = '100%'; 68 this.canvasRects.style.height = '100%'; 69 let widthAspectRatioAdjustFactor: number; 70 let heightAspectRatioAdjustFactor: number; 71 72 if (this.canvasRects.clientWidth > this.canvasRects.clientHeight) { 73 heightAspectRatioAdjustFactor = 1; 74 widthAspectRatioAdjustFactor = 75 this.canvasRects.clientWidth / this.canvasRects.clientHeight; 76 } else { 77 heightAspectRatioAdjustFactor = 78 this.canvasRects.clientHeight / this.canvasRects.clientWidth; 79 widthAspectRatioAdjustFactor = 1; 80 } 81 82 const cameraWidth = 83 Canvas.TARGET_SCENE_DIAGONAL * widthAspectRatioAdjustFactor; 84 const cameraHeight = 85 Canvas.TARGET_SCENE_DIAGONAL * heightAspectRatioAdjustFactor; 86 87 const panFactorX = 88 scene.camera.panScreenDistance.dx / this.canvasRects.clientWidth; 89 const panFactorY = 90 scene.camera.panScreenDistance.dy / this.canvasRects.clientHeight; 91 92 this.scene = new THREE.Scene(); 93 const scaleFactor = 94 (Canvas.TARGET_SCENE_DIAGONAL / scene.boundingBox.diagonal) * 95 scene.camera.zoomFactor; 96 this.scene.scale.set(scaleFactor, -scaleFactor, scaleFactor); 97 this.scene.translateX( 98 scaleFactor * -scene.boundingBox.center.x + cameraWidth * panFactorX, 99 ); 100 this.scene.translateY( 101 scaleFactor * scene.boundingBox.center.y - cameraHeight * panFactorY, 102 ); 103 this.scene.translateZ(scaleFactor * -scene.boundingBox.center.z); 104 105 this.camera = new THREE.OrthographicCamera( 106 -cameraWidth / 2, 107 cameraWidth / 2, 108 cameraHeight / 2, 109 -cameraHeight / 2, 110 0, 111 100, 112 ); 113 114 const rotationAngleX = (scene.camera.rotationFactor * Math.PI * 45) / 360; 115 const rotationAngleY = rotationAngleX * 1.5; 116 const cameraPosition = new THREE.Vector3( 117 0, 118 0, 119 Canvas.TARGET_SCENE_DIAGONAL, 120 ); 121 cameraPosition.applyAxisAngle(new THREE.Vector3(1, 0, 0), -rotationAngleX); 122 cameraPosition.applyAxisAngle(new THREE.Vector3(0, 1, 0), rotationAngleY); 123 124 this.camera.position.set( 125 cameraPosition.x, 126 cameraPosition.y, 127 cameraPosition.z, 128 ); 129 this.camera.lookAt(0, 0, 0); 130 131 this.renderer = new THREE.WebGLRenderer({ 132 antialias: true, 133 canvas: this.canvasRects, 134 alpha: true, 135 }); 136 137 // set various factors for shading and shifting 138 this.drawRects(scene.rects); 139 const canvasRects = assertDefined(this.canvasRects); 140 if (this.canvasLabels) { 141 this.drawLabels(scene.labels, this.isDarkMode()); 142 143 this.labelRenderer = new CSS2DRenderer({element: this.canvasLabels}); 144 this.labelRenderer.setSize( 145 canvasRects.clientWidth, 146 canvasRects.clientHeight, 147 ); 148 this.labelRenderer.render(this.scene, this.camera); 149 } 150 151 this.renderer.setSize(canvasRects.clientWidth, canvasRects.clientHeight); 152 this.renderer.setPixelRatio(window.devicePixelRatio); 153 this.renderer.compile(this.scene, this.camera); 154 this.renderer.render(this.scene, this.camera); 155 } 156 157 getClickedRectId(x: number, y: number, z: number): undefined | string { 158 const clickPosition = new THREE.Vector3(x, y, z); 159 const raycaster = new THREE.Raycaster(); 160 raycaster.setFromCamera(clickPosition, assertDefined(this.camera)); 161 const intersected = raycaster.intersectObjects(this.clickableObjects); 162 if (intersected.length > 0) { 163 return intersected[0].object.name; 164 } 165 return undefined; 166 } 167 168 private drawRects(rects: Rect3D[]) { 169 this.clickableObjects = []; 170 rects.forEach((rect) => { 171 const rectMesh = Canvas.makeRectMesh(rect, this.isDarkMode()); 172 const transform = Canvas.toMatrix4(rect.transform); 173 rectMesh.applyMatrix4(transform); 174 175 this.scene?.add(rectMesh); 176 177 if (rect.isClickable) { 178 this.clickableObjects.push(rectMesh); 179 } 180 }); 181 } 182 183 private drawLabels(labels: Label3D[], isDarkMode: boolean) { 184 this.clearLabels(); 185 labels.forEach((label) => { 186 const circleMesh = this.makeLabelCircleMesh(label.circle, isDarkMode); 187 this.scene?.add(circleMesh); 188 189 const linePoints = label.linePoints.map((point: Point3D) => { 190 return new THREE.Vector3(point.x, point.y, point.z); 191 }); 192 const lineGeometry = new THREE.BufferGeometry().setFromPoints(linePoints); 193 const lineMaterial = new THREE.LineBasicMaterial({ 194 color: label.isHighlighted 195 ? isDarkMode 196 ? Canvas.RECT_EDGE_COLOR_DARK_MODE 197 : Canvas.RECT_EDGE_COLOR_LIGHT_MODE 198 : Canvas.LABEL_LINE_COLOR, 199 }); 200 const line = new THREE.Line(lineGeometry, lineMaterial); 201 this.scene?.add(line); 202 203 this.drawLabelTextHtml(label); 204 }); 205 } 206 207 private drawLabelTextHtml(label: Label3D) { 208 // Add rectangle label 209 const spanText: HTMLElement = document.createElement('span'); 210 spanText.innerText = label.text; 211 spanText.className = 'mat-body-1'; 212 213 // Hack: transparent/placeholder text used to push the visible text towards left 214 // (towards negative x) and properly align it with the label's vertical segment 215 const spanPlaceholder: HTMLElement = document.createElement('span'); 216 spanPlaceholder.innerText = label.text; 217 spanPlaceholder.className = 'mat-body-1'; 218 spanPlaceholder.style.opacity = '0'; 219 220 const div: HTMLElement = document.createElement('div'); 221 div.className = 'rect-label'; 222 div.style.display = 'inline'; 223 div.appendChild(spanText); 224 div.appendChild(spanPlaceholder); 225 226 div.style.marginTop = '5px'; 227 if (!label.isHighlighted) { 228 div.style.color = 'gray'; 229 } 230 div.style.pointerEvents = 'auto'; 231 div.style.cursor = 'pointer'; 232 div.addEventListener('click', (event) => 233 this.propagateUpdateHighlightedItem(event, label.rectId), 234 ); 235 236 const labelCss = new CSS2DObject(div); 237 labelCss.position.set( 238 label.textCenter.x, 239 label.textCenter.y, 240 label.textCenter.z, 241 ); 242 243 this.scene?.add(labelCss); 244 } 245 246 private static toMatrix4(transform: TransformMatrix): THREE.Matrix4 { 247 return new THREE.Matrix4().set( 248 transform.dsdx, 249 transform.dsdy, 250 0, 251 transform.tx, 252 transform.dtdx, 253 transform.dtdy, 254 0, 255 transform.ty, 256 0, 257 0, 258 1, 259 0, 260 0, 261 0, 262 0, 263 1, 264 ); 265 } 266 267 private static makeRectMesh(rect: Rect3D, isDarkMode: boolean): THREE.Mesh { 268 const rectShape = Canvas.createRectShape(rect); 269 const rectGeometry = new THREE.ShapeGeometry(rectShape); 270 const rectBorders = Canvas.createRectBorders( 271 rect, 272 rectGeometry, 273 isDarkMode, 274 ); 275 276 const color = Canvas.getColor(rect, isDarkMode); 277 let mesh: THREE.Mesh | undefined; 278 if (color === undefined) { 279 mesh = new THREE.Mesh( 280 rectGeometry, 281 new THREE.MeshBasicMaterial({ 282 opacity: 0, 283 transparent: true, 284 }), 285 ); 286 } else { 287 let opacity: number | undefined; 288 if ( 289 rect.colorType === ColorType.VISIBLE_WITH_OPACITY || 290 rect.colorType === ColorType.HAS_CONTENT_AND_OPACITY 291 ) { 292 opacity = rect.darkFactor; 293 } else { 294 opacity = rect.isOversized 295 ? Canvas.OPACITY_OVERSIZED 296 : Canvas.OPACITY_REGULAR; 297 } 298 mesh = new THREE.Mesh( 299 rectGeometry, 300 new THREE.MeshBasicMaterial({ 301 color, 302 opacity, 303 transparent: true, 304 }), 305 ); 306 } 307 308 mesh.add(rectBorders); 309 mesh.position.x = 0; 310 mesh.position.y = 0; 311 mesh.position.z = rect.topLeft.z; 312 mesh.name = rect.id; 313 314 return mesh; 315 } 316 317 private static createRectShape(rect: Rect3D): THREE.Shape { 318 const bottomLeft: Point3D = { 319 x: rect.topLeft.x, 320 y: rect.bottomRight.y, 321 z: rect.topLeft.z, 322 }; 323 const topRight: Point3D = { 324 x: rect.bottomRight.x, 325 y: rect.topLeft.y, 326 z: rect.bottomRight.z, 327 }; 328 329 // Limit corner radius if larger than height/2 (or width/2) 330 const height = rect.bottomRight.y - rect.topLeft.y; 331 const width = rect.bottomRight.x - rect.topLeft.x; 332 const minEdge = Math.min(height, width); 333 let cornerRadius = Math.min(rect.cornerRadius, minEdge / 2); 334 335 // Force radius > 0, because radius === 0 could result in weird triangular shapes 336 // being drawn instead of rectangles. Seems like quadraticCurveTo() doesn't 337 // always handle properly the case with radius === 0. 338 cornerRadius = Math.max(cornerRadius, 0.01); 339 340 // Create (rounded) rect shape 341 return new THREE.Shape() 342 .moveTo(rect.topLeft.x, rect.topLeft.y + cornerRadius) 343 .lineTo(bottomLeft.x, bottomLeft.y - cornerRadius) 344 .quadraticCurveTo( 345 bottomLeft.x, 346 bottomLeft.y, 347 bottomLeft.x + cornerRadius, 348 bottomLeft.y, 349 ) 350 .lineTo(rect.bottomRight.x - cornerRadius, rect.bottomRight.y) 351 .quadraticCurveTo( 352 rect.bottomRight.x, 353 rect.bottomRight.y, 354 rect.bottomRight.x, 355 rect.bottomRight.y - cornerRadius, 356 ) 357 .lineTo(topRight.x, topRight.y + cornerRadius) 358 .quadraticCurveTo( 359 topRight.x, 360 topRight.y, 361 topRight.x - cornerRadius, 362 topRight.y, 363 ) 364 .lineTo(rect.topLeft.x + cornerRadius, rect.topLeft.y) 365 .quadraticCurveTo( 366 rect.topLeft.x, 367 rect.topLeft.y, 368 rect.topLeft.x, 369 rect.topLeft.y + cornerRadius, 370 ); 371 } 372 373 private static getVisibleRectColor(darkFactor: number) { 374 const red = ((200 - 45) * darkFactor + 45) / 255; 375 const green = ((232 - 182) * darkFactor + 182) / 255; 376 const blue = ((183 - 44) * darkFactor + 44) / 255; 377 return new THREE.Color(red, green, blue); 378 } 379 380 private static getColor( 381 rect: Rect3D, 382 isDarkMode: boolean, 383 ): THREE.Color | undefined { 384 switch (rect.colorType) { 385 case ColorType.VISIBLE: { 386 // green (darkness depends on z order) 387 return Canvas.getVisibleRectColor(rect.darkFactor); 388 } 389 case ColorType.VISIBLE_WITH_OPACITY: { 390 // same green for all rects - rect.darkFactor determines opacity 391 return Canvas.getVisibleRectColor(0.7); 392 } 393 case ColorType.NOT_VISIBLE: { 394 // gray (darkness depends on z order) 395 const lower = 120; 396 const upper = 220; 397 const darkness = ((upper - lower) * rect.darkFactor + lower) / 255; 398 return new THREE.Color(darkness, darkness, darkness); 399 } 400 case ColorType.HIGHLIGHTED: { 401 return isDarkMode 402 ? Canvas.RECT_COLOR_HIGHLIGHTED_DARK_MODE 403 : Canvas.RECT_COLOR_HIGHLIGHTED_LIGHT_MODE; 404 } 405 case ColorType.HAS_CONTENT_AND_OPACITY: { 406 return Canvas.RECT_COLOR_HAS_CONTENT; 407 } 408 case ColorType.HAS_CONTENT: { 409 return Canvas.RECT_COLOR_HAS_CONTENT; 410 } 411 case ColorType.EMPTY: { 412 return undefined; 413 } 414 default: { 415 throw new Error(`Unexpected color type: ${rect.colorType}`); 416 } 417 } 418 } 419 420 private static createRectBorders( 421 rect: Rect3D, 422 rectGeometry: THREE.ShapeGeometry, 423 isDarkMode: boolean, 424 ): THREE.LineSegments { 425 // create line edges for rect 426 const edgeGeo = new THREE.EdgesGeometry(rectGeometry); 427 let edgeMaterial: THREE.Material; 428 if (rect.cornerRadius) { 429 edgeMaterial = new THREE.LineBasicMaterial({ 430 color: Canvas.RECT_EDGE_COLOR_ROUNDED, 431 linewidth: 1, 432 }); 433 } else { 434 edgeMaterial = new THREE.LineBasicMaterial({ 435 color: isDarkMode 436 ? Canvas.RECT_EDGE_COLOR_DARK_MODE 437 : Canvas.RECT_EDGE_COLOR_LIGHT_MODE, 438 linewidth: 1, 439 }); 440 } 441 const lineSegments = new THREE.LineSegments(edgeGeo, edgeMaterial); 442 lineSegments.computeLineDistances(); 443 return lineSegments; 444 } 445 446 private makeLabelCircleMesh( 447 circle: Circle3D, 448 isDarkMode: boolean, 449 ): THREE.Mesh { 450 const geometry = new THREE.CircleGeometry(circle.radius, 20); 451 const material = new THREE.MeshBasicMaterial({ 452 color: isDarkMode 453 ? Canvas.RECT_EDGE_COLOR_DARK_MODE 454 : Canvas.RECT_EDGE_COLOR_LIGHT_MODE, 455 }); 456 const mesh = new THREE.Mesh(geometry, material); 457 mesh.position.set(circle.center.x, circle.center.y, circle.center.z); 458 return mesh; 459 } 460 461 private propagateUpdateHighlightedItem(event: MouseEvent, newId: string) { 462 event.preventDefault(); 463 const highlightedChangeEvent = new CustomEvent( 464 ViewerEvents.HighlightedIdChange, 465 { 466 bubbles: true, 467 detail: {id: newId}, 468 }, 469 ); 470 event.target?.dispatchEvent(highlightedChangeEvent); 471 } 472 473 private clearLabels() { 474 if (this.canvasLabels) { 475 this.canvasLabels.innerHTML = ''; 476 } 477 } 478} 479