xref: /aosp_15_r20/development/tools/winscope/src/viewers/components/rects/canvas_test.ts (revision 90c8c64db3049935a07c6143d7fd006e26f8ecca)
1/*
2 * Copyright (C) 2024 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 */
16
17import {assertDefined} from 'common/assert_utils';
18import {Box3D} from 'common/geometry/box3d';
19import {Distance} from 'common/geometry/distance';
20import {Point3D} from 'common/geometry/point3d';
21import {IDENTITY_MATRIX} from 'common/geometry/transform_matrix';
22import {
23  TransformType,
24  TransformTypeFlags,
25} from 'parsers/surface_flinger/transform_utils';
26import * as THREE from 'three';
27import {CSS2DObject} from 'three/examples/jsm/renderers/CSS2DRenderer';
28import {ViewerEvents} from 'viewers/common/viewer_events';
29import {Camera} from './camera';
30import {Canvas} from './canvas';
31import {ColorType} from './color_type';
32import {RectLabel} from './rect_label';
33import {UiRect3D} from './ui_rect3d';
34
35describe('Canvas', () => {
36  const rectId = 'rect1';
37
38  describe('updateViewPosition', () => {
39    let canvasRects: HTMLCanvasElement;
40    let canvasLabels: HTMLElement;
41    let canvas: Canvas;
42    let canvasWidthSpy: jasmine.Spy;
43    let canvasHeightSpy: jasmine.Spy;
44    let camera: Camera;
45    let boundingBox: Box3D;
46    let graphicsScene: THREE.Scene;
47    let graphicsCamera: THREE.OrthographicCamera;
48
49    beforeEach(() => {
50      canvasRects = document.createElement('canvas');
51      canvasWidthSpy = spyOnProperty(
52        canvasRects,
53        'clientWidth',
54      ).and.returnValue(100);
55      canvasHeightSpy = spyOnProperty(
56        canvasRects,
57        'clientHeight',
58      ).and.returnValue(100);
59      canvasLabels = document.createElement('canvas');
60      canvas = new Canvas(canvasRects, canvasLabels);
61      camera = makeCamera();
62      boundingBox = makeBoundingBox();
63      [graphicsScene, graphicsCamera] = canvas.renderView();
64    });
65
66    it('handles zero size canvas element', () => {
67      const canvasRendererSetSizeSpy = spyOn(canvas.renderer, 'setSize');
68      canvasWidthSpy.and.returnValue(0);
69      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
70      expect(canvasRendererSetSizeSpy).not.toHaveBeenCalled();
71
72      canvasWidthSpy.and.returnValue(100);
73      canvasHeightSpy.and.returnValue(0);
74      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
75      expect(canvasRendererSetSizeSpy).not.toHaveBeenCalled();
76    });
77
78    it('changes camera lrtb and maintains scene translated position based on canvas aspect ratio', () => {
79      camera.panScreenDistance = new Distance(2, 2);
80
81      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
82      const [l, r, t, b] = [
83        graphicsCamera.left,
84        graphicsCamera.right,
85        graphicsCamera.top,
86        graphicsCamera.bottom,
87      ];
88      const prevPosition = graphicsScene.position.clone();
89
90      canvasWidthSpy.and.returnValue(200);
91      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
92
93      expect(graphicsCamera.left).toBeLessThan(l);
94      expect(graphicsCamera.right).toBeGreaterThan(r);
95      expect(graphicsCamera.top).toEqual(t);
96      expect(graphicsCamera.bottom).toEqual(b);
97      expect(graphicsScene.position).toEqual(prevPosition);
98
99      canvasWidthSpy.and.returnValue(100);
100      canvasHeightSpy.and.returnValue(200);
101      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
102
103      expect(graphicsCamera.left).toEqual(l);
104      expect(graphicsCamera.right).toEqual(r);
105      expect(graphicsCamera.top).toBeGreaterThan(t);
106      expect(graphicsCamera.bottom).toBeLessThan(b);
107      expect(graphicsScene.position).toEqual(prevPosition);
108    });
109
110    it('changes scene translated position on change in pan screen distance', () => {
111      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
112      const prevPosition = graphicsScene.position.clone();
113      const prevScale = graphicsScene.scale.clone();
114
115      camera.panScreenDistance = new Distance(2, 2);
116      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
117
118      expect(graphicsScene.position.x).toBeGreaterThan(prevPosition.x);
119      expect(graphicsScene.position.y).toBeLessThan(prevPosition.y);
120      expect(graphicsScene.position.z).toEqual(prevPosition.z);
121      expect(graphicsScene.scale).toEqual(prevScale);
122    });
123
124    it('changes scene scale and scene translated position on change in zoom factor', () => {
125      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
126      const prevPosition = graphicsScene.position.clone();
127      const sceneScale = graphicsScene.scale.clone();
128
129      camera.zoomFactor = 2;
130      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
131
132      expect(graphicsScene.scale).toEqual(sceneScale.multiplyScalar(2));
133      expect(graphicsScene.position).toEqual(prevPosition.multiplyScalar(2));
134    });
135
136    it('changes camera position and scene translated x-position on change in rotation angle x', () => {
137      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
138      const prevScenePos = graphicsScene.position.clone();
139      const prevCameraPos = graphicsCamera.position.clone();
140
141      camera.rotationAngleX = 1.5;
142      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
143
144      expect(graphicsScene.position.x).toBeLessThan(prevScenePos.x);
145      expect(graphicsScene.position.y).toEqual(prevScenePos.y);
146      expect(graphicsScene.position.z).toEqual(prevScenePos.z);
147
148      expect(graphicsCamera.position.x).toEqual(prevCameraPos.x);
149      expect(graphicsCamera.position.y).toBeGreaterThan(prevCameraPos.y);
150      expect(graphicsCamera.position.z).toBeLessThan(prevCameraPos.z);
151    });
152
153    it('changes camera position and scene translated y-position on change in rotation angle y', () => {
154      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
155      const prevScenePos = graphicsScene.position.clone();
156      const prevCameraPos = graphicsCamera.position.clone();
157
158      camera.rotationAngleY = 1.5;
159      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
160
161      expect(graphicsScene.position.x).toEqual(prevScenePos.x);
162      expect(graphicsScene.position.y).toBeLessThan(prevScenePos.y);
163      expect(graphicsScene.position.z).toEqual(prevScenePos.z);
164
165      expect(graphicsCamera.position.x).toBeGreaterThan(prevCameraPos.x);
166      expect(graphicsCamera.position.y).toEqual(prevCameraPos.y);
167      expect(graphicsCamera.position.z).toBeLessThan(prevCameraPos.z);
168    });
169
170    it('changes scene scale and translated position on change in box diagonal', () => {
171      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
172      const prevPosition = graphicsScene.position.clone();
173      const sceneScale = graphicsScene.scale.clone();
174
175      boundingBox.diagonal = 2;
176      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
177
178      expect(graphicsScene.scale).toEqual(sceneScale.multiplyScalar(0.5));
179      expect(graphicsScene.position).toEqual(prevPosition.multiplyScalar(0.5));
180    });
181
182    it('changes translated position on change in box depth or zDepth', () => {
183      camera.rotationAngleX = 1;
184      camera.rotationAngleY = 1;
185      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
186      const prevPosition = graphicsScene.position.clone();
187      const prevScale = graphicsScene.scale.clone();
188
189      boundingBox.depth = 2;
190      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
191      expect(graphicsScene.position).toEqual(prevPosition.multiplyScalar(2));
192      expect(graphicsScene.scale).toEqual(prevScale);
193
194      canvas.updateViewPosition(camera, boundingBox, 4);
195      expect(graphicsScene.position).toEqual(
196        prevPosition.multiply(new THREE.Vector3(1, 1, 2)),
197      );
198      expect(graphicsScene.scale).toEqual(prevScale);
199    });
200
201    it('changes translated position on change in box center', () => {
202      camera.rotationAngleX = 1;
203      camera.rotationAngleY = 1;
204      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
205      const prevPosition = graphicsScene.position.clone();
206      const prevScale = graphicsScene.scale.clone();
207
208      boundingBox.center = new Point3D(3, 3, 3);
209      canvas.updateViewPosition(camera, boundingBox, boundingBox.depth);
210      expect(graphicsScene.position).not.toEqual(prevPosition);
211      expect(graphicsScene.scale).toEqual(prevScale);
212    });
213
214    it('robust to no labels canvas', () => {
215      const canvas = new Canvas(canvasRects);
216      const box = makeBoundingBox();
217      canvas.updateViewPosition(makeCamera(), box, box.depth);
218    });
219  });
220
221  describe('updateRects', () => {
222    let canvas: Canvas;
223    let isDarkMode: boolean;
224    let graphicsScene: THREE.Scene;
225
226    beforeEach(() => {
227      isDarkMode = false;
228      const canvasRects = document.createElement('canvas');
229      const canvasLabels = document.createElement('canvas');
230      canvas = new Canvas(canvasRects, canvasLabels, () => isDarkMode);
231      graphicsScene = canvas.renderView()[0];
232    });
233
234    it('adds and removes rects', () => {
235      const mapDeleteSpy = spyOn(Map.prototype, 'delete').and.callThrough();
236      canvas.updateRects([]);
237      expect(graphicsScene.getObjectByName(rectId)).toBeUndefined();
238      canvas.updateRects([makeUiRect3D(rectId)]);
239      expect(graphicsScene.getObjectByName(rectId)).toBeDefined();
240      canvas.updateRects([]);
241      expect(graphicsScene.getObjectByName(rectId)).toBeUndefined();
242      expect(mapDeleteSpy).toHaveBeenCalledOnceWith(rectId);
243    });
244
245    it('updates existing rects instead of adding new rect', () => {
246      const rect = makeUiRect3D(rectId);
247      canvas.updateRects([rect]);
248      const rectMesh = getRectMesh(rectId);
249      expect(rectMesh.position.z).toEqual(0);
250
251      const newRect = makeUiRect3D(rectId);
252      newRect.topLeft = new Point3D(0, 0, 1);
253      canvas.updateRects([newRect]);
254      expect(rectMesh.position.z).toEqual(1);
255      expect(getRectMesh('rect1')).toEqual(rectMesh);
256    });
257
258    it('makes rect with correct position and borders', () => {
259      const rect = makeUiRect3D(rectId);
260      rect.topLeft = new Point3D(1, 1, 5);
261      rect.bottomRight = new Point3D(2, 2, 5);
262      canvas.updateRects([rect]);
263      const rectMesh = getRectMesh(rectId);
264      expect(rectMesh.position.z).toEqual(5);
265      checkBorderColor(rectId, Canvas.RECT_EDGE_COLOR_LIGHT_MODE);
266
267      isDarkMode = true;
268      canvas.updateRects([rect]);
269      checkBorderColor(rectId, Canvas.RECT_EDGE_COLOR_DARK_MODE);
270    });
271
272    it('makes rect with correct fill material', () => {
273      const rect = makeUiRect3D(rectId);
274      canvas.updateRects([rect]);
275      const rectMesh = getRectMesh(rectId);
276      const defaultVisibleRectColor = new THREE.Color(
277        200 / 255,
278        232 / 255,
279        183 / 255,
280      );
281      checkMaterialColorAndOpacity(
282        rectMesh,
283        defaultVisibleRectColor,
284        Canvas.OPACITY_REGULAR,
285      );
286
287      const visibleWithOpacity = makeUiRect3D(rectId);
288      visibleWithOpacity.colorType = ColorType.VISIBLE_WITH_OPACITY;
289      canvas.updateRects([visibleWithOpacity]);
290      const material = rectMesh.material as THREE.MeshBasicMaterial;
291      expect(material.color).not.toEqual(defaultVisibleRectColor);
292      expect(material.opacity).toEqual(1);
293
294      const nonVisible = makeUiRect3D(rectId);
295      nonVisible.colorType = ColorType.NOT_VISIBLE;
296      canvas.updateRects([nonVisible]);
297      checkMaterialColorAndOpacity(
298        rectMesh,
299        new THREE.Color(220 / 255, 220 / 255, 220 / 255),
300        Canvas.OPACITY_REGULAR,
301      );
302
303      const highlighted = makeUiRect3D(rectId);
304      highlighted.colorType = ColorType.HIGHLIGHTED;
305      canvas.updateRects([highlighted]);
306      checkMaterialColorAndOpacity(
307        rectMesh,
308        Canvas.RECT_COLOR_HIGHLIGHTED_LIGHT_MODE,
309        Canvas.OPACITY_REGULAR,
310      );
311      isDarkMode = true;
312      canvas.updateRects([highlighted]);
313      checkMaterialColorAndOpacity(
314        rectMesh,
315        Canvas.RECT_COLOR_HIGHLIGHTED_DARK_MODE,
316        Canvas.OPACITY_REGULAR,
317      );
318
319      const contentAndOpacity = makeUiRect3D(rectId);
320      contentAndOpacity.colorType = ColorType.HAS_CONTENT_AND_OPACITY;
321      canvas.updateRects([contentAndOpacity]);
322      checkMaterialColorAndOpacity(rectMesh, Canvas.RECT_COLOR_HAS_CONTENT, 1);
323
324      const content = makeUiRect3D(rectId);
325      content.colorType = ColorType.HAS_CONTENT;
326      canvas.updateRects([content]);
327      checkMaterialColorAndOpacity(
328        rectMesh,
329        Canvas.RECT_COLOR_HAS_CONTENT,
330        Canvas.OPACITY_REGULAR,
331      );
332
333      const oversized = makeUiRect3D(rectId);
334      oversized.colorType = ColorType.HAS_CONTENT;
335      oversized.isOversized = true;
336      canvas.updateRects([oversized]);
337      checkMaterialColorAndOpacity(
338        rectMesh,
339        Canvas.RECT_COLOR_HAS_CONTENT,
340        Canvas.OPACITY_OVERSIZED,
341      );
342
343      const empty = makeUiRect3D(rectId);
344      empty.colorType = ColorType.EMPTY;
345      canvas.updateRects([empty]);
346      expect(rectMesh.material).toEqual(Canvas.TRANSPARENT_MATERIAL);
347    });
348
349    it('makes rect with fill region', () => {
350      const rect = makeUiRect3D(rectId);
351      rect.fillRegion = [];
352      rect.colorType = ColorType.HAS_CONTENT;
353      canvas.updateRects([rect]);
354      const rectMesh = getRectMesh(rectId);
355      expect(rectMesh.material).toEqual(Canvas.TRANSPARENT_MATERIAL);
356
357      const fillRegionMesh = getFillRegionMesh(rectId);
358      expect(fillRegionMesh.position.z).toEqual(1);
359      checkMaterialColorAndOpacity(
360        fillRegionMesh,
361        Canvas.RECT_COLOR_HAS_CONTENT,
362        Canvas.OPACITY_REGULAR,
363      );
364    });
365
366    it('makes rect with pinned borders', () => {
367      const rect = makeUiRect3D(rectId);
368      rect.topLeft = new Point3D(1, 1, 5);
369      rect.bottomRight = new Point3D(2, 2, 5);
370      rect.isPinned = true;
371
372      const rect2 = makeUiRect3D('rect2');
373      rect2.topLeft = new Point3D(1, 1, 5);
374      rect2.bottomRight = new Point3D(2, 2, 5);
375      rect2.isPinned = true;
376      canvas.updateRects([rect, rect2]);
377
378      checkBorderColor(rect.id, Canvas.RECT_EDGE_COLOR_PINNED);
379      checkBorderColor(rect2.id, Canvas.RECT_EDGE_COLOR_PINNED_ALT);
380    });
381
382    it('handles changes in geometry', () => {
383      const rect = makeUiRect3D(rectId);
384      canvas.updateRects([rect]);
385      const rectMesh = getRectMesh(rectId);
386      let rectGeometryId = rectMesh.geometry.id;
387
388      // no change
389      canvas.updateRects([rect]);
390      expect(rectMesh.geometry.id).toEqual(rectGeometryId);
391
392      // geometry object replaced
393      const roundRect = makeUiRect3D(rectId);
394      roundRect.cornerRadius = 5;
395      updateRectsAndCheckGeometryId(roundRect, rectMesh, rectGeometryId);
396      rectGeometryId = rectMesh.geometry.id;
397
398      const bottomRightChanged = makeUiRect3D(rectId);
399      bottomRightChanged.cornerRadius = 5;
400      bottomRightChanged.bottomRight = new Point3D(5, 5, 5);
401      updateRectsAndCheckGeometryId(
402        bottomRightChanged,
403        rectMesh,
404        rectGeometryId,
405      );
406      rectGeometryId = rectMesh.geometry.id;
407
408      const topLeftChanged = makeUiRect3D(rectId);
409      topLeftChanged.cornerRadius = 5;
410      topLeftChanged.bottomRight = new Point3D(5, 5, 5);
411      topLeftChanged.topLeft = new Point3D(0, 0, 5);
412      updateRectsAndCheckGeometryId(topLeftChanged, rectMesh, rectGeometryId);
413      rectGeometryId = rectMesh.geometry.id;
414
415      const rotated = makeUiRect3D(rectId);
416      rotated.cornerRadius = 5;
417      rotated.bottomRight = new Point3D(5, 5, 5);
418      rotated.topLeft = new Point3D(0, 0, 5);
419      rotated.transform = TransformType.getDefaultTransform(
420        TransformTypeFlags.ROT_90_VAL,
421        2,
422        2,
423      ).matrix;
424      const prevRotation = rectMesh.rotation.clone();
425      canvas.updateRects([rotated]);
426      expect(rectMesh.geometry.id).toEqual(rectGeometryId);
427      expect(rectMesh.rotation.equals(prevRotation)).toBeFalse();
428    });
429
430    it('handles changes in fill region', () => {
431      const noFillRegion = makeUiRect3D(rectId);
432      canvas.updateRects([noFillRegion]);
433      const rectMesh = getRectMesh(rectId);
434      expect(rectMesh.getObjectByName(rectId + 'fillRegion')).toBeUndefined();
435      expect(
436        (rectMesh.material as THREE.MeshBasicMaterial).color.getHex(),
437      ).toEqual(13166775);
438
439      const emptyFillRegion = makeUiRect3D(rectId);
440      emptyFillRegion.fillRegion = [];
441      canvas.updateRects([emptyFillRegion]);
442      const fillRegionMesh = getFillRegionMesh(rectId);
443      expect(rectMesh.material).toEqual(Canvas.TRANSPARENT_MATERIAL);
444      expect(
445        (fillRegionMesh.material as THREE.MeshBasicMaterial).color.getHex(),
446      ).toEqual(13166775);
447      let fillRegionGeometryId = fillRegionMesh.geometry.id;
448
449      const emptyFillRegionWithContent = makeUiRect3D(rectId);
450      emptyFillRegionWithContent.fillRegion = [];
451      emptyFillRegionWithContent.colorType = ColorType.HAS_CONTENT;
452      canvas.updateRects([emptyFillRegionWithContent]);
453      expect(rectMesh.material).toEqual(Canvas.TRANSPARENT_MATERIAL);
454      checkMaterialColorAndOpacity(
455        fillRegionMesh,
456        Canvas.RECT_COLOR_HAS_CONTENT,
457        Canvas.OPACITY_REGULAR,
458      );
459      let newGeometry = getFillRegionMesh(rectId).geometry;
460      expect(newGeometry.id).toEqual(fillRegionGeometryId);
461
462      const validFillRegion = makeUiRect3D(rectId);
463      validFillRegion.fillRegion = [
464        {
465          topLeft: emptyFillRegion.topLeft,
466          bottomRight: emptyFillRegion.bottomRight,
467        },
468      ];
469      canvas.updateRects([validFillRegion]);
470      newGeometry = getFillRegionMesh(rectId).geometry;
471      expect(newGeometry.id).not.toEqual(fillRegionGeometryId);
472      fillRegionGeometryId = newGeometry.id;
473
474      const differentFillRegion = makeUiRect3D(rectId);
475      differentFillRegion.fillRegion = [
476        {
477          topLeft: validFillRegion.fillRegion[0].topLeft,
478          bottomRight: new Point3D(4, 4, 2),
479        },
480      ];
481      canvas.updateRects([differentFillRegion]);
482      newGeometry = getFillRegionMesh(rectId).geometry;
483      expect(newGeometry.id).not.toEqual(fillRegionGeometryId);
484      fillRegionGeometryId = newGeometry.id;
485
486      canvas.updateRects([noFillRegion]);
487      expect(
488        getRectMesh(rectId).getObjectByName(rectId + 'fillRegion'),
489      ).toBeUndefined();
490    });
491
492    it('handles change from normal to pinned borders', () => {
493      const rect = makeUiRect3D(rectId);
494      rect.topLeft = new Point3D(1, 1, 5);
495      rect.bottomRight = new Point3D(2, 2, 5);
496      canvas.updateRects([rect]);
497      checkBorderColor(rect.id, Canvas.RECT_EDGE_COLOR_LIGHT_MODE);
498
499      const pinnedRect = makeUiRect3D(rectId);
500      pinnedRect.topLeft = new Point3D(1, 1, 5);
501      pinnedRect.bottomRight = new Point3D(2, 2, 5);
502      pinnedRect.isPinned = true;
503      canvas.updateRects([pinnedRect]);
504      checkBorderColor(rect.id, Canvas.RECT_EDGE_COLOR_PINNED);
505    });
506
507    function checkMaterialColorAndOpacity(
508      mesh: THREE.Mesh,
509      color: THREE.Color | number,
510      opacity: number,
511    ) {
512      const material = mesh.material as THREE.MeshBasicMaterial;
513      if (color instanceof THREE.Color) {
514        expect(material.color).toEqual(color);
515      } else {
516        expect(material.color.getHex()).toEqual(color);
517      }
518      expect(material.opacity).toEqual(opacity);
519    }
520
521    function checkBorderColor(id: string, color: number) {
522      const rectMesh = getRectMesh(id);
523
524      const borderMesh = assertDefined(
525        rectMesh.getObjectByName(id + 'border'),
526      ) as THREE.Mesh;
527      expect(
528        (borderMesh.material as THREE.LineBasicMaterial).color.getHex(),
529      ).toEqual(color);
530    }
531
532    function getRectMesh(id: string) {
533      return assertDefined(graphicsScene.getObjectByName(id)) as THREE.Mesh;
534    }
535
536    function getFillRegionMesh(id: string) {
537      const rectMesh = getRectMesh(id);
538      return assertDefined(
539        rectMesh.getObjectByName(id + 'fillRegion'),
540      ) as THREE.Mesh;
541    }
542
543    function updateRectsAndCheckGeometryId(
544      rect: UiRect3D,
545      rectMesh: THREE.Mesh,
546      prevId: number,
547    ) {
548      canvas.updateRects([rect]);
549      expect(rectMesh.geometry.id).not.toEqual(prevId);
550    }
551  });
552
553  describe('updateLabels', () => {
554    let canvas: Canvas;
555    let isDarkMode: boolean;
556    let graphicsScene: THREE.Scene;
557
558    beforeEach(() => {
559      isDarkMode = false;
560      const canvasRects = document.createElement('canvas');
561      const canvasLabels = document.createElement('canvas');
562      canvas = new Canvas(canvasRects, canvasLabels, () => isDarkMode);
563      graphicsScene = canvas.renderView()[0];
564    });
565
566    it('adds and removes labels', () => {
567      const mapDeleteSpy = spyOn(Map.prototype, 'delete').and.callThrough();
568      canvas.updateLabels([]);
569      expect(graphicsScene.getObjectByName(rectId + 'circle')).toBeUndefined();
570      expect(graphicsScene.getObjectByName(rectId + 'line')).toBeUndefined();
571      expect(graphicsScene.getObjectByName(rectId + 'text')).toBeUndefined();
572
573      canvas.updateLabels([makeRectLabel(rectId)]);
574      expect(graphicsScene.getObjectByName(rectId + 'circle')).toBeDefined();
575      expect(graphicsScene.getObjectByName(rectId + 'line')).toBeDefined();
576      expect(graphicsScene.getObjectByName(rectId + 'text')).toBeDefined();
577
578      canvas.updateLabels([]);
579      expect(graphicsScene.getObjectByName(rectId + 'circle')).toBeUndefined();
580      expect(graphicsScene.getObjectByName(rectId + 'line')).toBeUndefined();
581      expect(graphicsScene.getObjectByName(rectId + 'text')).toBeUndefined();
582      expect(mapDeleteSpy).toHaveBeenCalledOnceWith(rectId);
583    });
584
585    it('updates existing labels instead of adding new labels', () => {
586      const label = makeRectLabel(rectId);
587      canvas.updateLabels([label]);
588      const circleMesh = getCircleMesh(rectId);
589      const geometryId = circleMesh.geometry.id;
590
591      const newLabel = makeRectLabel(rectId);
592      newLabel.circle.radius = 2;
593      canvas.updateLabels([newLabel]);
594      expect(getCircleMesh(rectId)).toEqual(circleMesh);
595      expect(circleMesh.geometry.id).not.toEqual(geometryId);
596    });
597
598    it('makes label with correct circle and text geometry', () => {
599      const label = makeRectLabel(rectId);
600      canvas.updateLabels([label]);
601      const circleMesh = getCircleMesh(rectId);
602      expect(
603        (circleMesh.geometry as THREE.CircleGeometry).parameters.radius,
604      ).toEqual(label.circle.radius);
605      checkVectorEqualToPoint(circleMesh.position, label.circle.center);
606      const text = getText(rectId);
607      checkVectorEqualToPoint(text.position, label.textCenter);
608    });
609
610    it('handles change in circle radius', () => {
611      const label = makeRectLabel(rectId);
612      canvas.updateLabels([label]);
613      const circleMesh = getCircleMesh(rectId);
614
615      const newLabel = makeRectLabel(rectId);
616      newLabel.circle.radius = 2;
617      canvas.updateLabels([newLabel]);
618      expect(
619        (circleMesh.geometry as THREE.CircleGeometry).parameters.radius,
620      ).toEqual(2);
621    });
622
623    it('handles change in circle center', () => {
624      const label = makeRectLabel(rectId);
625      canvas.updateLabels([label]);
626      const circleMesh = getCircleMesh(rectId);
627
628      const newLabel = makeRectLabel(rectId);
629      newLabel.circle.center = new Point3D(1, 1, 1);
630      canvas.updateLabels([newLabel]);
631      checkVectorEqualToPoint(circleMesh.position, newLabel.circle.center);
632    });
633
634    it('applies colors based on highlighted or dark mode state', () => {
635      const label = makeRectLabel(rectId);
636      canvas.updateLabels([label]);
637      const circleMesh = getCircleMesh(rectId);
638      const line = getLine(rectId);
639      const text = getText(rectId);
640      expect(
641        (circleMesh.material as THREE.LineBasicMaterial).color.getHex(),
642      ).toEqual(Canvas.LABEL_LINE_COLOR);
643      expect((line.material as THREE.LineBasicMaterial).color.getHex()).toEqual(
644        Canvas.LABEL_LINE_COLOR,
645      );
646      expect(text.element.style.color).toEqual('gray');
647
648      const highlighted = makeRectLabel(rectId);
649      highlighted.isHighlighted = true;
650      canvas.updateLabels([highlighted]);
651      expect(
652        (circleMesh.material as THREE.LineBasicMaterial).color.getHex(),
653      ).toEqual(Canvas.RECT_EDGE_COLOR_LIGHT_MODE);
654      expect((line.material as THREE.LineBasicMaterial).color.getHex()).toEqual(
655        Canvas.RECT_EDGE_COLOR_LIGHT_MODE,
656      );
657      expect(text.element.style.color).toEqual('');
658
659      isDarkMode = true;
660      canvas.updateLabels([highlighted]);
661      expect(
662        (circleMesh.material as THREE.LineBasicMaterial).color.getHex(),
663      ).toEqual(Canvas.RECT_EDGE_COLOR_DARK_MODE);
664      expect((line.material as THREE.LineBasicMaterial).color.getHex()).toEqual(
665        Canvas.RECT_EDGE_COLOR_DARK_MODE,
666      );
667      expect(text.element.style.color).toEqual('');
668
669      canvas.updateLabels([label]);
670      expect(
671        (circleMesh.material as THREE.LineBasicMaterial).color.getHex(),
672      ).toEqual(Canvas.LABEL_LINE_COLOR);
673      expect((line.material as THREE.LineBasicMaterial).color.getHex()).toEqual(
674        Canvas.LABEL_LINE_COLOR,
675      );
676      expect(text.element.style.color).toEqual('gray');
677    });
678
679    it('handles change in line points', () => {
680      const label = makeRectLabel(rectId);
681      canvas.updateLabels([label]);
682      const line = getLine(rectId);
683      const geometryId = line.geometry.id;
684
685      const newLabel = makeRectLabel(rectId);
686      newLabel.linePoints = [new Point3D(1, 1, 1), new Point3D(1, 2, 1)];
687      canvas.updateLabels([newLabel]);
688      expect(line.geometry.id).not.toEqual(geometryId);
689    });
690
691    it('handles change in text center', () => {
692      const label = makeRectLabel(rectId);
693      canvas.updateLabels([label]);
694      const text = getText(rectId);
695
696      const newLabel = makeRectLabel(rectId);
697      newLabel.textCenter = new Point3D(1, 15, 1);
698      canvas.updateLabels([newLabel]);
699      checkVectorEqualToPoint(text.position, newLabel.textCenter);
700    });
701
702    it('robust to no labels canvas', () => {
703      const canvasRects = document.createElement('canvas');
704      const canvas = new Canvas(canvasRects);
705      canvas.updateLabels([]);
706    });
707
708    it('propagates highlighted item on text click', () => {
709      const label = makeRectLabel(rectId);
710      canvas.updateLabels([label]);
711      const text = getText(rectId);
712
713      let id: string | undefined;
714      text.element.addEventListener(
715        ViewerEvents.HighlightedIdChange,
716        (event) => {
717          id = (event as CustomEvent).detail.id;
718        },
719      );
720      text.element.click();
721      expect(id).toEqual(rectId);
722    });
723
724    function getCircleMesh(id: string): THREE.Mesh {
725      return assertDefined(
726        graphicsScene.getObjectByName(id + 'circle'),
727      ) as THREE.Mesh;
728    }
729
730    function getLine(id: string): THREE.Line {
731      return assertDefined(
732        graphicsScene.getObjectByName(id + 'line'),
733      ) as THREE.Line;
734    }
735
736    function getText(id: string): CSS2DObject {
737      return assertDefined(
738        graphicsScene.getObjectByName(id + 'text'),
739      ) as CSS2DObject;
740    }
741
742    function checkVectorEqualToPoint(vector: THREE.Vector3, point: Point3D) {
743      expect(
744        vector.equals(new THREE.Vector3(point.x, point.y, point.z)),
745      ).toBeTrue();
746    }
747  });
748
749  describe('renderView', () => {
750    let canvas: Canvas;
751    let rectsCompileSpy: jasmine.Spy;
752    let renderingSpies: jasmine.Spy[];
753
754    beforeEach(() => {
755      const canvasRects = document.createElement('canvas');
756      const canvasLabels = document.createElement('canvas');
757      canvas = new Canvas(canvasRects, canvasLabels);
758      rectsCompileSpy = spyOn(assertDefined(canvas.renderer), 'compile');
759      renderingSpies = [
760        spyOn(assertDefined(canvas.renderer), 'setPixelRatio'),
761        spyOn(assertDefined(canvas.renderer), 'render'),
762        spyOn(assertDefined(canvas.labelRenderer), 'render'),
763      ];
764    });
765
766    it('sets pixel ratio and renders rects and labels', () => {
767      canvas.renderView();
768      checkRenderSpiesCalled(1);
769    });
770
771    it('only compiles on first call', () => {
772      canvas.renderView();
773      expect(rectsCompileSpy).toHaveBeenCalledTimes(1);
774      checkRenderSpiesCalled(1);
775
776      canvas.renderView();
777      expect(rectsCompileSpy).toHaveBeenCalledTimes(1);
778      checkRenderSpiesCalled(2);
779    });
780
781    it('robust to no labels canvas', () => {
782      const canvasRects = document.createElement('canvas');
783      const canvas = new Canvas(canvasRects);
784      canvas.renderView();
785    });
786
787    function checkRenderSpiesCalled(times: number) {
788      renderingSpies.forEach((spy) => expect(spy).toHaveBeenCalledTimes(times));
789    }
790  });
791
792  describe('getClickedRectId', () => {
793    let canvas: Canvas;
794
795    beforeEach(() => {
796      const canvasRects = document.createElement('canvas');
797      const canvasLabels = document.createElement('canvas');
798      canvas = new Canvas(canvasRects, canvasLabels);
799      const box = makeBoundingBox();
800      canvas.updateViewPosition(makeCamera(), box, box.depth);
801      canvas.renderView();
802    });
803
804    it('identifies clicked rect', () => {
805      const rect = makeUiRect3D(rectId);
806      rect.isClickable = true;
807      canvas.updateRects([rect]);
808      canvas.renderView();
809
810      const id = canvas.getClickedRectId(0.1, 0.1, 0);
811      expect(id).toEqual('rect1');
812    });
813
814    it('does not identify rect if not clickable', () => {
815      const rect = makeUiRect3D(rectId);
816      canvas.updateRects([rect]);
817      expect(canvas.getClickedRectId(0.1, 0.1, 0)).toBeUndefined();
818    });
819
820    it('does not identify rect out of click area', () => {
821      const rect = makeUiRect3D(rectId);
822      rect.isClickable = true;
823      canvas.updateRects([rect]);
824      expect(canvas.getClickedRectId(2, 2, 0)).toBeUndefined();
825    });
826  });
827
828  function makeCamera(): Camera {
829    return {
830      rotationAngleX: 0,
831      rotationAngleY: 0,
832      zoomFactor: 1,
833      panScreenDistance: new Distance(0, 0),
834    };
835  }
836
837  function makeBoundingBox(): Box3D {
838    return {
839      width: 1,
840      height: 1,
841      depth: 1,
842      center: new Point3D(0, 0, 0),
843      diagonal: 1,
844    };
845  }
846
847  function makeUiRect3D(id: string): UiRect3D {
848    return {
849      id,
850      topLeft: new Point3D(0, 0, 0),
851      bottomRight: new Point3D(1, 1, 0),
852      cornerRadius: 0,
853      darkFactor: 1,
854      colorType: ColorType.VISIBLE,
855      isClickable: false,
856      transform: IDENTITY_MATRIX,
857      isOversized: false,
858      fillRegion: undefined,
859      isPinned: false,
860    };
861  }
862
863  function makeRectLabel(id: string): RectLabel {
864    return {
865      circle: {radius: 1, center: new Point3D(0, 0, 0)},
866      linePoints: [new Point3D(0, 0, 0), new Point3D(0, 1, 0)],
867      textCenter: new Point3D(0, 12, 0),
868      text: id,
869      isHighlighted: false,
870      rectId: id,
871    };
872  }
873});
874