xref: /aosp_15_r20/development/tools/winscope/src/viewers/components/rects/canvas.ts (revision 90c8c64db3049935a07c6143d7fd006e26f8ecca)
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