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