• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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