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 {ArrayUtils} from 'common/array_utils'; 17import {assertDefined, assertUnreachable} from 'common/assert_utils'; 18import {Box3D} from 'common/geometry/box3d'; 19import {Point3D} from 'common/geometry/point3d'; 20import {Rect3D} from 'common/geometry/rect3d'; 21import {TransformMatrix} from 'common/geometry/transform_matrix'; 22import * as THREE from 'three'; 23import { 24 CSS2DObject, 25 CSS2DRenderer, 26} from 'three/examples/jsm/renderers/CSS2DRenderer'; 27import {ViewerEvents} from 'viewers/common/viewer_events'; 28import {Camera} from './camera'; 29import {ColorType} from './color_type'; 30import {RectLabel} from './rect_label'; 31import {UiRect3D} from './ui_rect3d'; 32 33export function colorToCss(color: THREE.Color): string { 34 return '#' + color.getHexString(); 35} 36 37export class Canvas { 38 static readonly TARGET_SCENE_DIAGONAL = 4; 39 static readonly RECT_COLOR_HIGHLIGHTED_LIGHT_MODE = new THREE.Color( 40 0xd2e3fc, // Keep in sync with :not(.dark-mode) --selected-element-color in material-theme.scss 41 ); 42 static readonly RECT_COLOR_HIGHLIGHTED_DARK_MODE = new THREE.Color( 43 0x5f718a, // Keep in sync with .dark-mode --selected-element-color in material-theme.scss 44 ); 45 static readonly RECT_COLOR_VISIBLE = new THREE.Color( 46 200 / 255, 47 232 / 255, 48 183 / 255, 49 ); 50 static readonly RECT_COLOR_NOT_VISIBLE = new THREE.Color( 51 220 / 255, 52 220 / 255, 53 220 / 255, 54 ); 55 static readonly RECT_COLOR_HAS_CONTENT = new THREE.Color(0xad42f5); 56 static readonly RECT_EDGE_COLOR_LIGHT_MODE = 0x000000; 57 static readonly RECT_EDGE_COLOR_DARK_MODE = 0xffffff; 58 static readonly RECT_EDGE_COLOR_ROUNDED = 0x848884; 59 static readonly RECT_EDGE_COLOR_PINNED = new THREE.Color(0xffc24b); // Keep in sync with Color#PINNED_ITEM_BORDER 60 static readonly RECT_EDGE_COLOR_PINNED_ALT = new THREE.Color(0xb34a24); 61 static readonly LABEL_LINE_COLOR = 0x808080; 62 static readonly OPACITY_REGULAR = 0.75; 63 static readonly OPACITY_OVERSIZED = 0.25; 64 static readonly TRANSPARENT_MATERIAL = new THREE.MeshBasicMaterial({ 65 opacity: 0, 66 transparent: true, 67 }); 68 static readonly GRAPHICS_NAMES = { 69 border: 'graphics_border', 70 circle: 'graphics_circle', 71 fillRegion: 'graphics_fill_region', 72 line: 'graphics_line', 73 text: 'graphics_text', 74 }; 75 private static readonly RECT_EDGE_BOLD_WIDTH = 10; 76 77 renderer = new THREE.WebGLRenderer({ 78 antialias: true, 79 canvas: this.canvasRects, 80 alpha: true, 81 }); 82 labelRenderer?: CSS2DRenderer; 83 84 private camera = new THREE.OrthographicCamera( 85 -Canvas.TARGET_SCENE_DIAGONAL / 2, 86 Canvas.TARGET_SCENE_DIAGONAL / 2, 87 Canvas.TARGET_SCENE_DIAGONAL / 2, 88 -Canvas.TARGET_SCENE_DIAGONAL / 2, 89 0, 90 100, 91 ); 92 private scene = new THREE.Scene(); 93 private pinnedIdToColorMap = new Map<string, THREE.Color>(); 94 private lastAssignedDefaultPinnedColor = false; 95 private firstDraw = true; 96 private lastScene: SceneState = { 97 isDarkMode: this.isDarkMode(), 98 translatedPos: undefined, 99 rectIdToRectGraphics: new Map<string, RectGraphics>(), 100 rectIdToLabelGraphics: new Map<string, LabelGraphics>(), 101 }; 102 103 constructor( 104 private canvasRects: HTMLElement, 105 private canvasLabels?: HTMLElement, 106 private isDarkMode = () => false, 107 ) { 108 if (this.canvasLabels) { 109 this.labelRenderer = new CSS2DRenderer({element: this.canvasLabels}); 110 } 111 } 112 113 updateViewPosition(camera: Camera, bounds: Box3D, zDepth: number) { 114 // Must set 100% width and height so the HTML element expands to the parent's 115 // boundaries and the correct clientWidth and clientHeight values can be read 116 this.canvasRects.style.width = '100%'; 117 this.canvasRects.style.height = '100%'; 118 const [maxWidth, maxHeight] = [ 119 this.canvasRects.clientWidth, 120 this.canvasRects.clientHeight, 121 ]; 122 if (maxWidth === 0 || maxHeight === 0) { 123 return; 124 } 125 126 let widthAspectRatioAdjustFactor = 1; 127 let heightAspectRatioAdjustFactor = 1; 128 if (maxWidth > maxHeight) { 129 widthAspectRatioAdjustFactor = maxWidth / maxHeight; 130 } else { 131 heightAspectRatioAdjustFactor = maxHeight / maxWidth; 132 } 133 const cameraWidth = 134 Canvas.TARGET_SCENE_DIAGONAL * widthAspectRatioAdjustFactor; 135 const cameraHeight = 136 Canvas.TARGET_SCENE_DIAGONAL * heightAspectRatioAdjustFactor; 137 138 const panFactorX = camera.panScreenDistance.dx / maxWidth; 139 const panFactorY = camera.panScreenDistance.dy / maxHeight; 140 141 const scaleFactor = 142 (Canvas.TARGET_SCENE_DIAGONAL / bounds.diagonal) * camera.zoomFactor; 143 this.scene.scale.set(scaleFactor, -scaleFactor, scaleFactor); 144 145 const translatedPos = new Point3D( 146 scaleFactor * -(bounds.depth * camera.rotationAngleX + bounds.center.x) + 147 cameraWidth * panFactorX, 148 scaleFactor * 149 ((-bounds.depth * camera.rotationAngleY ** 2) / 2 + bounds.center.y) - 150 cameraHeight * panFactorY, 151 scaleFactor * -zDepth, // keeps camera in front of first rect 152 ); 153 this.scene 154 .translateX(translatedPos.x - (this.lastScene.translatedPos?.x ?? 0)) 155 .translateY(translatedPos.y - (this.lastScene.translatedPos?.y ?? 0)) 156 .translateZ(translatedPos.z - (this.lastScene.translatedPos?.z ?? 0)); 157 this.lastScene.translatedPos = translatedPos; 158 159 this.camera.left = -cameraWidth / 2; 160 this.camera.right = cameraWidth / 2; 161 this.camera.top = cameraHeight / 2; 162 this.camera.bottom = -cameraHeight / 2; 163 const cPos = new THREE.Vector3(0, 0, Canvas.TARGET_SCENE_DIAGONAL) 164 .applyAxisAngle(new THREE.Vector3(1, 0, 0), -camera.rotationAngleX) 165 .applyAxisAngle(new THREE.Vector3(0, 1, 0), camera.rotationAngleY); 166 this.camera.position.set(cPos.x, cPos.y, cPos.z); 167 this.camera.lookAt(0, 0, 0); 168 this.camera.updateProjectionMatrix(); 169 170 this.renderer.setSize(maxWidth, maxHeight); 171 this.labelRenderer?.setSize(maxWidth, maxHeight); 172 } 173 174 updateRects(rects: UiRect3D[]) { 175 for (const key of this.lastScene.rectIdToRectGraphics.keys()) { 176 if (!rects.some((rect) => rect.id === key)) { 177 this.lastScene.rectIdToRectGraphics.delete(key); 178 this.scene.remove(assertDefined(this.scene.getObjectByName(key))); 179 } 180 } 181 rects.forEach((rect) => { 182 const existingGraphics = this.lastScene.rectIdToRectGraphics.get(rect.id); 183 const mesh = !existingGraphics 184 ? this.makeAndAddRectMesh(rect) 185 : this.updateExistingRectMesh( 186 rect, 187 existingGraphics.rect, 188 existingGraphics.mesh, 189 ); 190 this.lastScene.rectIdToRectGraphics.set(rect.id, {rect, mesh}); 191 }); 192 } 193 194 updateLabels(labels: RectLabel[]) { 195 if (this.labelRenderer) { 196 this.updateLabelGraphics(labels); 197 } 198 } 199 200 renderView(): [THREE.Scene, THREE.OrthographicCamera] { 201 this.labelRenderer?.render(this.scene, this.camera); 202 this.renderer.setPixelRatio(window.devicePixelRatio); 203 if (this.firstDraw) { 204 this.renderer.compile(this.scene, this.camera); 205 this.firstDraw = false; 206 } 207 this.renderer.render(this.scene, this.camera); 208 this.lastScene.isDarkMode = this.isDarkMode(); 209 return [this.scene, this.camera]; 210 } 211 212 getClickedRectId(x: number, y: number, z: number): undefined | string { 213 const clickPosition = new THREE.Vector3(x, y, z); 214 const raycaster = new THREE.Raycaster(); 215 raycaster.setFromCamera(clickPosition, assertDefined(this.camera)); 216 const intersected = raycaster.intersectObjects( 217 Array.from(this.lastScene.rectIdToRectGraphics.values()) 218 .filter((graphics) => graphics.rect.isClickable) 219 .map((graphics) => graphics.mesh), 220 ); 221 const name = intersected.at(0)?.object.name; 222 if (!name) { 223 return undefined; 224 } 225 for (const suffix of Object.values(Canvas.GRAPHICS_NAMES)) { 226 if (name.endsWith(suffix)) { 227 return name.substring(0, name.length - suffix.length); 228 } 229 } 230 return name; 231 } 232 233 private toMatrix4(transform: TransformMatrix): THREE.Matrix4 { 234 return new THREE.Matrix4().set( 235 transform.dsdx, 236 transform.dtdx, 237 0, 238 transform.tx, 239 transform.dtdy, 240 transform.dsdy, 241 0, 242 transform.ty, 243 0, 244 0, 245 1, 246 0, 247 0, 248 0, 249 0, 250 1, 251 ); 252 } 253 254 private makeAndAddRectMesh(rect: UiRect3D): THREE.Mesh { 255 const color = this.getColor(rect); 256 const fillMaterial = this.getFillMaterial(rect, color); 257 const mesh = new THREE.Mesh( 258 this.makeRoundedRectGeometry(rect), 259 rect.fillRegion ? Canvas.TRANSPARENT_MATERIAL : fillMaterial, 260 ); 261 262 if (rect.fillRegion) { 263 this.addFillRegionMesh(rect, fillMaterial, mesh); 264 } 265 this.addRectBorders(rect, mesh); 266 267 mesh.position.x = 0; 268 mesh.position.y = 0; 269 mesh.position.z = rect.topLeft.z; 270 mesh.name = rect.id; 271 mesh.applyMatrix4(this.toMatrix4(rect.transform)); 272 this.scene.add(mesh); 273 return mesh; 274 } 275 276 private makeRoundedRectGeometry(rect: UiRect3D): THREE.ShapeGeometry { 277 const bottomLeft = new Point3D( 278 rect.topLeft.x, 279 rect.bottomRight.y, 280 rect.topLeft.z, 281 ); 282 const topRight = new Point3D( 283 rect.bottomRight.x, 284 rect.topLeft.y, 285 rect.bottomRight.z, 286 ); 287 const cornerRadius = this.getAdjustedCornerRadius(rect); 288 289 // Create (rounded) rect shape 290 const shape = new THREE.Shape() 291 .moveTo(rect.topLeft.x, rect.topLeft.y + cornerRadius) 292 .lineTo(bottomLeft.x, bottomLeft.y - cornerRadius) 293 .quadraticCurveTo( 294 bottomLeft.x, 295 bottomLeft.y, 296 bottomLeft.x + cornerRadius, 297 bottomLeft.y, 298 ) 299 .lineTo(rect.bottomRight.x - cornerRadius, rect.bottomRight.y) 300 .quadraticCurveTo( 301 rect.bottomRight.x, 302 rect.bottomRight.y, 303 rect.bottomRight.x, 304 rect.bottomRight.y - cornerRadius, 305 ) 306 .lineTo(topRight.x, topRight.y + cornerRadius) 307 .quadraticCurveTo( 308 topRight.x, 309 topRight.y, 310 topRight.x - cornerRadius, 311 topRight.y, 312 ) 313 .lineTo(rect.topLeft.x + cornerRadius, rect.topLeft.y) 314 .quadraticCurveTo( 315 rect.topLeft.x, 316 rect.topLeft.y, 317 rect.topLeft.x, 318 rect.topLeft.y + cornerRadius, 319 ); 320 return new THREE.ShapeGeometry(shape); 321 } 322 323 private makeRectShape(topLeft: Point3D, bottomRight: Point3D): THREE.Shape { 324 const bottomLeft = new Point3D(topLeft.x, bottomRight.y, topLeft.z); 325 const topRight = new Point3D(bottomRight.x, topLeft.y, bottomRight.z); 326 327 // Create rect shape 328 return new THREE.Shape() 329 .moveTo(topLeft.x, topLeft.y) 330 .lineTo(bottomLeft.x, bottomLeft.y) 331 .lineTo(bottomRight.x, bottomRight.y) 332 .lineTo(topRight.x, topRight.y) 333 .lineTo(topLeft.x, topLeft.y); 334 } 335 336 private getColor(rect: UiRect3D): THREE.Color | undefined { 337 switch (rect.colorType) { 338 case ColorType.VISIBLE: { 339 // green (darkness depends on z order) 340 return this.getVisibleRectColor(rect.darkFactor); 341 } 342 case ColorType.VISIBLE_WITH_OPACITY: { 343 // same green for all rects - rect.darkFactor determines opacity 344 return this.getVisibleRectColor(0.7); 345 } 346 case ColorType.NOT_VISIBLE: { 347 // gray (darkness depends on z order) 348 return Canvas.RECT_COLOR_NOT_VISIBLE.clone().multiplyScalar( 349 this.getColorScalingValue( 350 120, 351 Canvas.RECT_COLOR_NOT_VISIBLE.r, 352 rect.darkFactor, 353 ), 354 ); 355 } 356 case ColorType.HIGHLIGHTED: 357 case ColorType.HIGHLIGHTED_WITH_OPACITY: { 358 return this.isDarkMode() 359 ? Canvas.RECT_COLOR_HIGHLIGHTED_DARK_MODE 360 : Canvas.RECT_COLOR_HIGHLIGHTED_LIGHT_MODE; 361 } 362 case ColorType.HAS_CONTENT_AND_OPACITY: { 363 return Canvas.RECT_COLOR_HAS_CONTENT; 364 } 365 case ColorType.HAS_CONTENT: { 366 return Canvas.RECT_COLOR_HAS_CONTENT; 367 } 368 case ColorType.EMPTY: { 369 return undefined; 370 } 371 default: { 372 assertUnreachable(rect.colorType); 373 } 374 } 375 } 376 377 private getVisibleRectColor(darkFactor: number): THREE.Color { 378 const color = Canvas.RECT_COLOR_VISIBLE.clone(); 379 color.r *= this.getColorScalingValue(45, color.r, darkFactor); 380 color.g *= this.getColorScalingValue(182, color.g, darkFactor); 381 color.b *= this.getColorScalingValue(44, color.b, darkFactor); 382 return color; 383 } 384 385 private getColorScalingValue( 386 l: number, 387 u: number, 388 darkFactor: number, 389 ): number { 390 const scale = l / u / 255; 391 return darkFactor * (1 - scale) + scale; 392 } 393 394 private makeRectBorders( 395 rect: UiRect3D, 396 rectGeometry: THREE.ShapeGeometry, 397 ): THREE.LineSegments { 398 // create line edges for rect 399 const edgeGeo = new THREE.EdgesGeometry(rectGeometry); 400 let color: number; 401 if (rect.cornerRadius) { 402 color = Canvas.RECT_EDGE_COLOR_ROUNDED; 403 } else { 404 color = this.isDarkMode() 405 ? Canvas.RECT_EDGE_COLOR_DARK_MODE 406 : Canvas.RECT_EDGE_COLOR_LIGHT_MODE; 407 } 408 const edgeMaterial = new THREE.LineBasicMaterial({color}); 409 const lineSegments = new THREE.LineSegments(edgeGeo, edgeMaterial); 410 lineSegments.computeLineDistances(); 411 return lineSegments; 412 } 413 414 private getAdjustedCornerRadius(rect: UiRect3D): number { 415 // Limit corner radius if larger than height/2 (or width/2) 416 const height = rect.bottomRight.y - rect.topLeft.y; 417 const width = rect.bottomRight.x - rect.topLeft.x; 418 const minEdge = Math.min(height, width); 419 const cornerRadius = Math.min(rect.cornerRadius, minEdge / 2); 420 421 // Force radius > 0, because radius === 0 could result in weird triangular shapes 422 // being drawn instead of rectangles. Seems like quadraticCurveTo() doesn't 423 // always handle properly the case with radius === 0. 424 return Math.max(cornerRadius, 0.01); 425 } 426 427 private makePinnedRectBorders(rect: UiRect3D): THREE.Mesh { 428 const pinnedBorders = this.createPinnedBorderRects(rect); 429 let color = this.pinnedIdToColorMap.get(rect.id); 430 if (color === undefined) { 431 color = this.lastAssignedDefaultPinnedColor 432 ? Canvas.RECT_EDGE_COLOR_PINNED_ALT 433 : Canvas.RECT_EDGE_COLOR_PINNED; 434 this.pinnedIdToColorMap.set(rect.id, color); 435 this.lastAssignedDefaultPinnedColor = 436 !this.lastAssignedDefaultPinnedColor; 437 } 438 const pinnedBorderMesh = new THREE.Mesh( 439 new THREE.ShapeGeometry(pinnedBorders), 440 new THREE.MeshBasicMaterial({color}), 441 ); 442 // Prevent z-fighting with the parent mesh 443 pinnedBorderMesh.position.z = 2; 444 return pinnedBorderMesh; 445 } 446 447 private createPinnedBorderRects(rect: UiRect3D): THREE.Shape[] { 448 const cornerRadius = this.getAdjustedCornerRadius(rect); 449 const xBoldWidth = Canvas.RECT_EDGE_BOLD_WIDTH / rect.transform.dsdx; 450 const yBorderWidth = Canvas.RECT_EDGE_BOLD_WIDTH / rect.transform.dsdy; 451 const borderRects = [ 452 // left and bottom borders 453 new THREE.Shape() 454 .moveTo(rect.topLeft.x, rect.topLeft.y + cornerRadius) 455 .lineTo(rect.topLeft.x, rect.bottomRight.y - cornerRadius) 456 .quadraticCurveTo( 457 rect.topLeft.x, 458 rect.bottomRight.y, 459 rect.topLeft.x + cornerRadius, 460 rect.bottomRight.y, 461 ) 462 .lineTo(rect.bottomRight.x - cornerRadius, rect.bottomRight.y) 463 .quadraticCurveTo( 464 rect.bottomRight.x, 465 rect.bottomRight.y, 466 rect.bottomRight.x, 467 rect.bottomRight.y - cornerRadius, 468 ) 469 .lineTo( 470 rect.bottomRight.x - xBoldWidth, 471 rect.bottomRight.y - cornerRadius, 472 ) 473 .quadraticCurveTo( 474 rect.bottomRight.x - xBoldWidth, 475 rect.bottomRight.y - yBorderWidth, 476 rect.bottomRight.x - cornerRadius, 477 rect.bottomRight.y - yBorderWidth, 478 ) 479 .lineTo( 480 rect.topLeft.x + cornerRadius, 481 rect.bottomRight.y - yBorderWidth, 482 ) 483 .quadraticCurveTo( 484 rect.topLeft.x + xBoldWidth, 485 rect.bottomRight.y - yBorderWidth, 486 rect.topLeft.x + xBoldWidth, 487 rect.bottomRight.y - cornerRadius, 488 ) 489 .lineTo(rect.topLeft.x + xBoldWidth, rect.topLeft.y + cornerRadius) 490 .lineTo(rect.topLeft.x, rect.topLeft.y + cornerRadius), 491 492 // right and top borders 493 new THREE.Shape() 494 .moveTo(rect.bottomRight.x, rect.bottomRight.y - cornerRadius) 495 .lineTo(rect.bottomRight.x, rect.topLeft.y + cornerRadius) 496 .quadraticCurveTo( 497 rect.bottomRight.x, 498 rect.topLeft.y, 499 rect.bottomRight.x - cornerRadius, 500 rect.topLeft.y, 501 ) 502 .lineTo(rect.topLeft.x + cornerRadius, rect.topLeft.y) 503 .quadraticCurveTo( 504 rect.topLeft.x, 505 rect.topLeft.y, 506 rect.topLeft.x, 507 rect.topLeft.y + cornerRadius, 508 ) 509 .lineTo(rect.topLeft.x + xBoldWidth, rect.topLeft.y + cornerRadius) 510 .quadraticCurveTo( 511 rect.topLeft.x + xBoldWidth, 512 rect.topLeft.y + yBorderWidth, 513 rect.topLeft.x + cornerRadius, 514 rect.topLeft.y + yBorderWidth, 515 ) 516 .lineTo( 517 rect.bottomRight.x - cornerRadius, 518 rect.topLeft.y + yBorderWidth, 519 ) 520 .quadraticCurveTo( 521 rect.bottomRight.x - xBoldWidth, 522 rect.topLeft.y + yBorderWidth, 523 rect.bottomRight.x - xBoldWidth, 524 rect.topLeft.y + cornerRadius, 525 ) 526 .lineTo( 527 rect.bottomRight.x - xBoldWidth, 528 rect.bottomRight.y - cornerRadius, 529 ) 530 .lineTo(rect.bottomRight.x, rect.bottomRight.y - cornerRadius), 531 ]; 532 return borderRects; 533 } 534 535 private getFillMaterial( 536 rect: UiRect3D, 537 color: THREE.Color | undefined, 538 ): THREE.MeshBasicMaterial { 539 if (color !== undefined) { 540 let opacity: number | undefined; 541 if ( 542 rect.colorType === ColorType.VISIBLE_WITH_OPACITY || 543 rect.colorType === ColorType.HAS_CONTENT_AND_OPACITY || 544 rect.colorType === ColorType.HIGHLIGHTED_WITH_OPACITY 545 ) { 546 opacity = rect.darkFactor; 547 } else { 548 opacity = rect.isOversized 549 ? Canvas.OPACITY_OVERSIZED 550 : Canvas.OPACITY_REGULAR; 551 } 552 return new THREE.MeshBasicMaterial({ 553 color, 554 opacity, 555 transparent: true, 556 }); 557 } 558 return Canvas.TRANSPARENT_MATERIAL; 559 } 560 561 private addFillRegionMesh( 562 rect: UiRect3D, 563 fillMaterial: THREE.MeshBasicMaterial, 564 mesh: THREE.Mesh, 565 ) { 566 const fillShapes = assertDefined(rect.fillRegion).map((fillRect) => 567 this.makeRectShape(fillRect.topLeft, fillRect.bottomRight), 568 ); 569 const fillMesh = new THREE.Mesh( 570 new THREE.ShapeGeometry(fillShapes), 571 fillMaterial, 572 ); 573 // Prevent z-fighting with the parent mesh 574 fillMesh.position.z = 1; 575 fillMesh.name = rect.id + Canvas.GRAPHICS_NAMES.fillRegion; 576 mesh.add(fillMesh); 577 } 578 579 private updateExistingRectMesh( 580 newRect: UiRect3D, 581 existingRect: UiRect3D, 582 existingMesh: THREE.Mesh, 583 ): THREE.Mesh { 584 this.updateRectMeshFillMaterial(newRect, existingRect, existingMesh); 585 this.updateRectMeshGeometry(newRect, existingRect, existingMesh); 586 return existingMesh; 587 } 588 589 private updateRectMeshFillMaterial( 590 newRect: UiRect3D, 591 existingRect: UiRect3D, 592 existingMesh: THREE.Mesh, 593 ) { 594 const fillMaterial = this.getFillMaterial(newRect, this.getColor(newRect)); 595 const fillChanged = 596 newRect.colorType !== existingRect.colorType || 597 this.lastScene.isDarkMode !== this.isDarkMode() || 598 newRect.darkFactor !== existingRect.darkFactor || 599 newRect.isOversized !== existingRect.isOversized; 600 601 if (!newRect.fillRegion && existingRect.fillRegion) { 602 existingMesh.material = fillMaterial; 603 existingMesh.remove( 604 assertDefined( 605 existingMesh.getObjectByName( 606 existingRect.id + Canvas.GRAPHICS_NAMES.fillRegion, 607 ), 608 ), 609 ); 610 } else if (newRect.fillRegion && !existingRect.fillRegion) { 611 existingMesh.material = Canvas.TRANSPARENT_MATERIAL; 612 this.addFillRegionMesh(newRect, fillMaterial, existingMesh); 613 } else if (newRect.fillRegion && existingRect.fillRegion) { 614 const fillRegionChanged = !ArrayUtils.equal( 615 newRect.fillRegion, 616 existingRect.fillRegion, 617 (a, b) => { 618 const [r, o] = [a as Rect3D, b as Rect3D]; 619 return ( 620 r.topLeft.isEqual(o.topLeft) && r.bottomRight.isEqual(o.bottomRight) 621 ); 622 }, 623 ); 624 if (fillRegionChanged) { 625 existingMesh.remove( 626 assertDefined( 627 existingMesh.getObjectByName( 628 existingRect.id + Canvas.GRAPHICS_NAMES.fillRegion, 629 ), 630 ), 631 ); 632 this.addFillRegionMesh(newRect, fillMaterial, existingMesh); 633 } 634 } 635 636 if (fillChanged) { 637 if (newRect.fillRegion === undefined) { 638 existingMesh.material = fillMaterial; 639 } else { 640 const fillMesh = assertDefined( 641 existingMesh.getObjectByName( 642 existingRect.id + Canvas.GRAPHICS_NAMES.fillRegion, 643 ), 644 ) as THREE.Mesh; 645 fillMesh.material = fillMaterial; 646 } 647 } 648 } 649 650 private updateRectMeshGeometry( 651 newRect: UiRect3D, 652 existingRect: UiRect3D, 653 existingMesh: THREE.Mesh, 654 ) { 655 const isGeometryChanged = 656 !newRect.bottomRight.isEqual(existingRect.bottomRight) || 657 !newRect.topLeft.isEqual(existingRect.topLeft) || 658 newRect.cornerRadius !== existingRect.cornerRadius; 659 660 if (isGeometryChanged) { 661 existingMesh.geometry = this.makeRoundedRectGeometry(newRect); 662 existingMesh.position.z = newRect.topLeft.z; 663 } 664 665 const isColorChanged = 666 this.isDarkMode() !== this.lastScene.isDarkMode || 667 newRect.isPinned !== existingRect.isPinned; 668 if (isGeometryChanged || isColorChanged) { 669 existingMesh.remove( 670 assertDefined( 671 existingMesh.getObjectByName( 672 existingRect.id + Canvas.GRAPHICS_NAMES.border, 673 ), 674 ), 675 ); 676 this.addRectBorders(newRect, existingMesh); 677 } 678 679 if (!newRect.transform.isEqual(existingRect.transform)) { 680 existingMesh.applyMatrix4( 681 this.toMatrix4(existingRect.transform.inverse()), 682 ); 683 existingMesh.applyMatrix4(this.toMatrix4(newRect.transform)); 684 } 685 } 686 687 private addRectBorders(newRect: UiRect3D, mesh: THREE.Mesh) { 688 let borderMesh: THREE.Object3D; 689 if (newRect.isPinned) { 690 borderMesh = this.makePinnedRectBorders(newRect); 691 } else { 692 borderMesh = this.makeRectBorders(newRect, mesh.geometry); 693 } 694 borderMesh.name = newRect.id + Canvas.GRAPHICS_NAMES.border; 695 mesh.add(borderMesh); 696 } 697 698 private updateLabelGraphics(labels: RectLabel[]) { 699 this.clearLabels(labels); 700 labels.forEach((label) => { 701 let graphics: LabelGraphics; 702 if (this.lastScene.rectIdToLabelGraphics.get(label.rectId)) { 703 graphics = this.updateExistingLabelGraphics(label); 704 } else { 705 const circle = this.makeLabelCircleMesh(label); 706 this.scene.add(circle); 707 const line = this.makeLabelLine(label); 708 this.scene.add(line); 709 const text = this.makeLabelCssObject(label); 710 this.scene.add(text); 711 graphics = {label, circle, line, text}; 712 } 713 this.lastScene.rectIdToLabelGraphics.set(label.rectId, graphics); 714 }); 715 } 716 717 private makeLabelCircleMesh(label: RectLabel): THREE.Mesh { 718 const geometry = new THREE.CircleGeometry(label.circle.radius, 20); 719 const material = this.makeLabelMaterial(label); 720 const mesh = new THREE.Mesh(geometry, material); 721 mesh.position.set( 722 label.circle.center.x, 723 label.circle.center.y, 724 label.circle.center.z, 725 ); 726 mesh.name = label.rectId + Canvas.GRAPHICS_NAMES.circle; 727 return mesh; 728 } 729 730 private makeLabelLine(label: RectLabel): THREE.Line { 731 const lineGeometry = this.makeLabelLineGeometry(label); 732 const lineMaterial = this.makeLabelMaterial(label); 733 const line = new THREE.Line(lineGeometry, lineMaterial); 734 line.name = label.rectId + Canvas.GRAPHICS_NAMES.line; 735 return line; 736 } 737 738 private makeLabelLineGeometry(label: RectLabel): THREE.BufferGeometry { 739 const linePoints = label.linePoints.map((point: Point3D) => { 740 return new THREE.Vector3(point.x, point.y, point.z); 741 }); 742 return new THREE.BufferGeometry().setFromPoints(linePoints); 743 } 744 745 private makeLabelMaterial(label: RectLabel): THREE.LineBasicMaterial { 746 return new THREE.LineBasicMaterial({ 747 color: label.isHighlighted 748 ? this.isDarkMode() 749 ? Canvas.RECT_EDGE_COLOR_DARK_MODE 750 : Canvas.RECT_EDGE_COLOR_LIGHT_MODE 751 : Canvas.LABEL_LINE_COLOR, 752 }); 753 } 754 755 private makeLabelCssObject(label: RectLabel): CSS2DObject { 756 // Add rectangle label 757 const spanText: HTMLElement = document.createElement('span'); 758 spanText.innerText = label.text; 759 spanText.className = 'mat-body-1'; 760 spanText.style.backgroundColor = 'var(--background-color)'; 761 762 // Hack: transparent/placeholder text used to push the visible text towards left 763 // (towards negative x) and properly align it with the label's vertical segment 764 const spanPlaceholder: HTMLElement = document.createElement('span'); 765 spanPlaceholder.innerText = label.text; 766 spanPlaceholder.className = 'mat-body-1'; 767 spanPlaceholder.style.opacity = '0'; 768 769 const div: HTMLElement = document.createElement('div'); 770 div.className = 'rect-label'; 771 div.style.display = 'inline'; 772 div.style.whiteSpace = 'nowrap'; 773 div.appendChild(spanText); 774 div.appendChild(spanPlaceholder); 775 776 div.style.marginTop = '5px'; 777 if (!label.isHighlighted) { 778 div.style.color = 'gray'; 779 } 780 div.style.pointerEvents = 'auto'; 781 div.style.cursor = 'pointer'; 782 div.addEventListener('click', (event) => 783 this.propagateUpdateHighlightedItem(event, label.rectId), 784 ); 785 786 const labelCss = new CSS2DObject(div); 787 labelCss.position.set( 788 label.textCenter.x, 789 label.textCenter.y, 790 label.textCenter.z, 791 ); 792 labelCss.name = label.rectId + Canvas.GRAPHICS_NAMES.text; 793 return labelCss; 794 } 795 796 private updateExistingLabelGraphics(newLabel: RectLabel): LabelGraphics { 797 const { 798 label: existingLabel, 799 circle, 800 line, 801 text, 802 } = assertDefined( 803 this.lastScene.rectIdToLabelGraphics.get(newLabel.rectId), 804 ); 805 806 if (newLabel.circle.radius !== existingLabel.circle.radius) { 807 circle.geometry = new THREE.CircleGeometry(newLabel.circle.radius, 20); 808 } 809 if (!newLabel.circle.center.isEqual(existingLabel.circle.center)) { 810 circle.position.set( 811 newLabel.circle.center.x, 812 newLabel.circle.center.y, 813 newLabel.circle.center.z, 814 ); 815 } 816 817 if ( 818 newLabel.isHighlighted !== existingLabel.isHighlighted || 819 this.isDarkMode() !== this.lastScene.isDarkMode 820 ) { 821 const lineMaterial = this.makeLabelMaterial(newLabel); 822 circle.material = lineMaterial; 823 line.material = lineMaterial; 824 text.element.style.color = newLabel.isHighlighted ? '' : 'gray'; 825 } 826 827 if ( 828 !ArrayUtils.equal(newLabel.linePoints, existingLabel.linePoints, (a, b) => 829 (a as Point3D).isEqual(b as Point3D), 830 ) 831 ) { 832 line.geometry = this.makeLabelLineGeometry(newLabel); 833 } 834 835 if (!newLabel.textCenter.isEqual(existingLabel.textCenter)) { 836 text.position.set( 837 newLabel.textCenter.x, 838 newLabel.textCenter.y, 839 newLabel.textCenter.z, 840 ); 841 } 842 843 return {label: newLabel, circle, line, text}; 844 } 845 846 private propagateUpdateHighlightedItem(event: MouseEvent, newId: string) { 847 event.preventDefault(); 848 const highlightedChangeEvent = new CustomEvent( 849 ViewerEvents.HighlightedIdChange, 850 { 851 bubbles: true, 852 detail: {id: newId}, 853 }, 854 ); 855 event.target?.dispatchEvent(highlightedChangeEvent); 856 } 857 858 private clearLabels(labels: RectLabel[]) { 859 if (this.canvasLabels) { 860 this.canvasLabels.textContent = ''; 861 } 862 for (const [rectId, graphics] of this.lastScene.rectIdToLabelGraphics) { 863 if (!labels.some((label) => label.rectId === rectId)) { 864 this.scene.remove(graphics.circle); 865 this.scene.remove(graphics.line); 866 this.scene.remove(graphics.text); 867 this.lastScene.rectIdToLabelGraphics.delete(rectId); 868 } 869 } 870 } 871} 872 873interface SceneState { 874 isDarkMode: boolean; 875 translatedPos?: Point3D | undefined; 876 rectIdToRectGraphics: Map<string, RectGraphics>; 877 rectIdToLabelGraphics: Map<string, LabelGraphics>; 878} 879 880interface RectGraphics { 881 rect: UiRect3D; 882 mesh: THREE.Mesh; 883} 884 885interface LabelGraphics { 886 label: RectLabel; 887 circle: THREE.Mesh; 888 line: THREE.Line; 889 text: CSS2DObject; 890} 891