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