| /* |
| * Copyright (C) 2024 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| import {equal} from 'common/array_utils'; |
| import {assertDefined} from 'common/assert_utils'; |
| import {Box3D} from 'common/geometry/box3d'; |
| import {CornerRadii} from 'common/geometry/corner_radii'; |
| import {Point3D} from 'common/geometry/point3d'; |
| import {TransformMatrix} from 'common/geometry/transform_matrix'; |
| import { |
| getDefaultTransform, |
| TransformTypeFlags, |
| } from 'common/geometry/transform'; |
| import * as THREE from 'three'; |
| import {CSS2DObject} from 'three/examples/jsm/renderers/CSS2DRenderer'; |
| import {ViewerEvents} from 'viewers/common/viewer_events'; |
| import {Camera} from './camera'; |
| import {Canvas} from './canvas'; |
| import {ColorType} from './color_type'; |
| import {RectLabel} from './rect_label'; |
| import {UiRect3D} from './ui_rect3d'; |
| |
| describe('Canvas', () => { |
| const rectId = 'rect1'; |
| |
| describe('updateViewPosition', () => { |
| let canvasRects: HTMLCanvasElement; |
| let canvasLabels: HTMLElement; |
| let canvas: Canvas; |
| let canvasWidthSpy: jasmine.Spy; |
| let canvasHeightSpy: jasmine.Spy; |
| let camera: Camera; |
| let boundingBox: Box3D; |
| let graphicsScene: THREE.Scene; |
| let graphicsCamera: THREE.OrthographicCamera; |
| |
| beforeEach(() => { |
| canvasRects = document.createElement('canvas'); |
| canvasWidthSpy = spyOnProperty( |
| canvasRects, |
| 'clientWidth', |
| ).and.returnValue(100); |
| canvasHeightSpy = spyOnProperty( |
| canvasRects, |
| 'clientHeight', |
| ).and.returnValue(100); |
| canvasLabels = document.createElement('canvas'); |
| canvas = new Canvas(canvasRects, canvasLabels); |
| camera = makeCamera(); |
| boundingBox = makeBoundingBox(); |
| [graphicsScene, graphicsCamera] = canvas.renderView(); |
| }); |
| |
| it('handles zero size canvas element', () => { |
| const canvasRendererSetSizeSpy = spyOn(canvas.renderer, 'setSize'); |
| canvasWidthSpy.and.returnValue(0); |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| expect(canvasRendererSetSizeSpy).not.toHaveBeenCalled(); |
| |
| canvasWidthSpy.and.returnValue(100); |
| canvasHeightSpy.and.returnValue(0); |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| expect(canvasRendererSetSizeSpy).not.toHaveBeenCalled(); |
| }); |
| |
| it('changes camera lrtb and maintains scene translated position based on canvas aspect ratio', () => { |
| camera.panScreenDistance = {dx: 2, dy: 2}; |
| |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| const [l, r, t, b] = [ |
| graphicsCamera.left, |
| graphicsCamera.right, |
| graphicsCamera.top, |
| graphicsCamera.bottom, |
| ]; |
| const prevPosition = graphicsScene.position.clone(); |
| |
| canvasWidthSpy.and.returnValue(200); |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| |
| expect(graphicsCamera.left).toBeLessThan(l); |
| expect(graphicsCamera.right).toBeGreaterThan(r); |
| expect(graphicsCamera.top).toEqual(t); |
| expect(graphicsCamera.bottom).toEqual(b); |
| expect(graphicsScene.position).toEqual(prevPosition); |
| |
| canvasWidthSpy.and.returnValue(100); |
| canvasHeightSpy.and.returnValue(200); |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| |
| expect(graphicsCamera.left).toEqual(l); |
| expect(graphicsCamera.right).toEqual(r); |
| expect(graphicsCamera.top).toBeGreaterThan(t); |
| expect(graphicsCamera.bottom).toBeLessThan(b); |
| expect(graphicsScene.position).toEqual(prevPosition); |
| }); |
| |
| it('changes scene translated position on change in pan screen distance', () => { |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| const prevPosition = graphicsScene.position.clone(); |
| const prevScale = graphicsScene.scale.clone(); |
| |
| camera.panScreenDistance = {dx: 2, dy: 2}; |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| |
| expect(graphicsScene.position.x).toBeGreaterThan(prevPosition.x); |
| expect(graphicsScene.position.y).toBeLessThan(prevPosition.y); |
| expect(graphicsScene.position.z).toEqual(prevPosition.z); |
| expect(graphicsScene.scale).toEqual(prevScale); |
| }); |
| |
| it('changes scene scale and scene translated position on change in zoom factor', () => { |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| const prevPosition = graphicsScene.position.clone(); |
| const sceneScale = graphicsScene.scale.clone(); |
| |
| camera.zoomFactor = 2; |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| |
| expect(graphicsScene.scale).toEqual(sceneScale.multiplyScalar(2)); |
| expect(graphicsScene.position).toEqual(prevPosition.multiplyScalar(2)); |
| }); |
| |
| it('changes camera position and scene translated x-position on change in rotation angle x', () => { |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| const prevScenePos = graphicsScene.position.clone(); |
| const prevCameraPos = graphicsCamera.position.clone(); |
| |
| camera.rotationAngleX = 1.5; |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| |
| expect(graphicsScene.position.x).toBeLessThan(prevScenePos.x); |
| expect(graphicsScene.position.y).toEqual(prevScenePos.y); |
| expect(graphicsScene.position.z).toEqual(prevScenePos.z); |
| |
| expect(graphicsCamera.position.x).toEqual(prevCameraPos.x); |
| expect(graphicsCamera.position.y).toBeGreaterThan(prevCameraPos.y); |
| expect(graphicsCamera.position.z).toBeLessThan(prevCameraPos.z); |
| }); |
| |
| it('changes camera position and scene translated y-position on change in rotation angle y', () => { |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| const prevScenePos = graphicsScene.position.clone(); |
| const prevCameraPos = graphicsCamera.position.clone(); |
| |
| camera.rotationAngleY = 1.5; |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| |
| expect(graphicsScene.position.x).toEqual(prevScenePos.x); |
| expect(graphicsScene.position.y).toBeLessThan(prevScenePos.y); |
| expect(graphicsScene.position.z).toEqual(prevScenePos.z); |
| |
| expect(graphicsCamera.position.x).toBeGreaterThan(prevCameraPos.x); |
| expect(graphicsCamera.position.y).toEqual(prevCameraPos.y); |
| expect(graphicsCamera.position.z).toBeLessThan(prevCameraPos.z); |
| }); |
| |
| it('changes scene scale and translated position on change in box diagonal', () => { |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| const prevPosition = graphicsScene.position.clone(); |
| const sceneScale = graphicsScene.scale.clone(); |
| |
| boundingBox.diagonal = 2; |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| |
| expect(graphicsScene.scale).toEqual(sceneScale.multiplyScalar(0.5)); |
| expect(graphicsScene.position).toEqual(prevPosition.multiplyScalar(0.5)); |
| }); |
| |
| it('changes translated position on change in box depth or zDepth', () => { |
| camera.rotationAngleX = 1; |
| camera.rotationAngleY = 1; |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| const prevPosition = graphicsScene.position.clone(); |
| const prevScale = graphicsScene.scale.clone(); |
| |
| boundingBox.depth = 2; |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| expect(graphicsScene.position).toEqual(prevPosition.multiplyScalar(2)); |
| expect(graphicsScene.scale).toEqual(prevScale); |
| |
| canvas.updateViewPosition(camera, boundingBox, 4); |
| expect(graphicsScene.position).toEqual( |
| prevPosition.multiply(new THREE.Vector3(1, 1, 2)), |
| ); |
| expect(graphicsScene.scale).toEqual(prevScale); |
| }); |
| |
| it('changes translated position on change in box center', () => { |
| camera.rotationAngleX = 1; |
| camera.rotationAngleY = 1; |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| const prevPosition = graphicsScene.position.clone(); |
| const prevScale = graphicsScene.scale.clone(); |
| |
| boundingBox.center = new Point3D(3, 3, 3); |
| canvas.updateViewPosition(camera, boundingBox, boundingBox.depth); |
| expect(graphicsScene.position).not.toEqual(prevPosition); |
| expect(graphicsScene.scale).toEqual(prevScale); |
| }); |
| |
| it('robust to no labels canvas', () => { |
| const canvas = new Canvas(canvasRects); |
| const box = makeBoundingBox(); |
| canvas.updateViewPosition(makeCamera(), box, box.depth); |
| }); |
| }); |
| |
| describe('updateRects', () => { |
| let canvas: Canvas; |
| let isDarkMode: boolean; |
| let graphicsScene: THREE.Scene; |
| |
| beforeEach(() => { |
| isDarkMode = false; |
| const canvasRects = document.createElement('canvas'); |
| const canvasLabels = document.createElement('canvas'); |
| canvas = new Canvas(canvasRects, canvasLabels, () => isDarkMode); |
| graphicsScene = canvas.renderView()[0]; |
| }); |
| |
| it('adds and removes rects', () => { |
| const mapDeleteSpy = spyOn(Map.prototype, 'delete').and.callThrough(); |
| canvas.updateRects([]); |
| expect(graphicsScene.getObjectByName(rectId)).toBeUndefined(); |
| canvas.updateRects([makeUiRect3D(rectId)]); |
| expect(graphicsScene.getObjectByName(rectId)).toBeDefined(); |
| canvas.updateRects([]); |
| expect(graphicsScene.getObjectByName(rectId)).toBeUndefined(); |
| expect(mapDeleteSpy).toHaveBeenCalledOnceWith(rectId); |
| }); |
| |
| it('updates existing rects instead of adding new rect', () => { |
| const rect = makeUiRect3D(rectId); |
| canvas.updateRects([rect]); |
| const rectMesh = getRectMesh(rectId); |
| expect(rectMesh.position.z).toBe(0); |
| |
| const newRect = makeUiRect3D(rectId); |
| newRect.topLeft = new Point3D(0, 0, 1); |
| canvas.updateRects([newRect]); |
| expect(rectMesh.position.z).toBe(1); |
| expect(getRectMesh('rect1')).toEqual(rectMesh); |
| }); |
| |
| it('makes rect with correct position and borders', () => { |
| const rect = makeUiRect3D(rectId); |
| rect.topLeft = new Point3D(1, 1, 5); |
| rect.bottomRight = new Point3D(2, 2, 5); |
| canvas.updateRects([rect]); |
| const rectMesh = getRectMesh(rectId); |
| expect(rectMesh.position.z).toBe(5); |
| checkBorderColor(rectId, Canvas.RECT_EDGE_COLOR_LIGHT_MODE); |
| |
| isDarkMode = true; |
| canvas.updateRects([rect]); |
| checkBorderColor(rectId, Canvas.RECT_EDGE_COLOR_DARK_MODE); |
| }); |
| |
| it('makes rect with correct fill material', () => { |
| const rect = makeUiRect3D(rectId); |
| canvas.updateRects([rect]); |
| const rectMesh = getRectMesh(rectId); |
| const defaultVisibleRectColor = new THREE.Color(0xc8e8b7); |
| checkMaterialColorAndOpacity( |
| rectMesh, |
| defaultVisibleRectColor, |
| Canvas.OPACITY_REGULAR, |
| ); |
| |
| const visibleWithOpacity = makeUiRect3D(rectId); |
| visibleWithOpacity.colorType = ColorType.VISIBLE_WITH_OPACITY; |
| canvas.updateRects([visibleWithOpacity]); |
| const material = rectMesh.material as THREE.MeshBasicMaterial; |
| expect(material.color).not.toEqual(defaultVisibleRectColor); |
| expect(material.opacity).toBe(1); |
| |
| const nonVisible = makeUiRect3D(rectId); |
| nonVisible.colorType = ColorType.NOT_VISIBLE; |
| canvas.updateRects([nonVisible]); |
| checkMaterialColorAndOpacity( |
| rectMesh, |
| new THREE.Color(0xdcdcdc), |
| Canvas.OPACITY_REGULAR, |
| ); |
| |
| const highlighted = makeUiRect3D(rectId); |
| highlighted.colorType = ColorType.HIGHLIGHTED; |
| canvas.updateRects([highlighted]); |
| checkMaterialColorAndOpacity( |
| rectMesh, |
| Canvas.RECT_COLOR_HIGHLIGHTED_LIGHT_MODE, |
| Canvas.OPACITY_REGULAR, |
| ); |
| isDarkMode = true; |
| canvas.updateRects([highlighted]); |
| checkMaterialColorAndOpacity( |
| rectMesh, |
| Canvas.RECT_COLOR_HIGHLIGHTED_DARK_MODE, |
| Canvas.OPACITY_REGULAR, |
| ); |
| |
| isDarkMode = false; |
| const highlightedWithOpacity = makeUiRect3D(rectId); |
| highlightedWithOpacity.colorType = ColorType.HIGHLIGHTED_WITH_OPACITY; |
| canvas.updateRects([highlightedWithOpacity]); |
| checkMaterialColorAndOpacity( |
| rectMesh, |
| Canvas.RECT_COLOR_HIGHLIGHTED_LIGHT_MODE, |
| highlightedWithOpacity.darkFactor, |
| ); |
| isDarkMode = true; |
| canvas.updateRects([highlightedWithOpacity]); |
| checkMaterialColorAndOpacity( |
| rectMesh, |
| Canvas.RECT_COLOR_HIGHLIGHTED_DARK_MODE, |
| highlightedWithOpacity.darkFactor, |
| ); |
| |
| const contentAndOpacity = makeUiRect3D(rectId); |
| contentAndOpacity.colorType = ColorType.HAS_CONTENT_AND_OPACITY; |
| canvas.updateRects([contentAndOpacity]); |
| checkMaterialColorAndOpacity(rectMesh, Canvas.RECT_COLOR_HAS_CONTENT, 1); |
| |
| const content = makeUiRect3D(rectId); |
| content.colorType = ColorType.HAS_CONTENT; |
| canvas.updateRects([content]); |
| checkMaterialColorAndOpacity( |
| rectMesh, |
| Canvas.RECT_COLOR_HAS_CONTENT, |
| Canvas.OPACITY_REGULAR, |
| ); |
| |
| const oversized = makeUiRect3D(rectId); |
| oversized.colorType = ColorType.HAS_CONTENT; |
| oversized.isOversized = true; |
| canvas.updateRects([oversized]); |
| checkMaterialColorAndOpacity( |
| rectMesh, |
| Canvas.RECT_COLOR_HAS_CONTENT, |
| Canvas.OPACITY_OVERSIZED, |
| ); |
| |
| const empty = makeUiRect3D(rectId); |
| empty.colorType = ColorType.EMPTY; |
| canvas.updateRects([empty]); |
| expect(rectMesh.material).toEqual(Canvas.TRANSPARENT_MATERIAL); |
| }); |
| |
| it('makes rect with fill region', () => { |
| const rect = makeUiRect3D(rectId); |
| rect.fillRegion = []; |
| rect.colorType = ColorType.HAS_CONTENT; |
| canvas.updateRects([rect]); |
| const rectMesh = getRectMesh(rectId); |
| expect(rectMesh.material).toEqual(Canvas.TRANSPARENT_MATERIAL); |
| |
| const fillRegionMesh = getFillRegionMesh(rectId); |
| expect(fillRegionMesh.position.z).toBe(1); |
| checkMaterialColorAndOpacity( |
| fillRegionMesh, |
| Canvas.RECT_COLOR_HAS_CONTENT, |
| Canvas.OPACITY_REGULAR, |
| ); |
| }); |
| |
| it('makes rect with pinned borders', () => { |
| const rect = makeUiRect3D(rectId); |
| rect.topLeft = new Point3D(1, 1, 5); |
| rect.bottomRight = new Point3D(2, 2, 5); |
| rect.isPinned = true; |
| |
| const rect2 = makeUiRect3D('rect2'); |
| rect2.topLeft = new Point3D(1, 1, 5); |
| rect2.bottomRight = new Point3D(2, 2, 5); |
| rect2.isPinned = true; |
| canvas.updateRects([rect, rect2]); |
| |
| checkBorderColor(rect.id, Canvas.RECT_EDGE_COLOR_PINNED); |
| checkBorderColor(rect2.id, Canvas.RECT_EDGE_COLOR_PINNED_ALT); |
| }); |
| |
| it('handles changes in geometry', () => { |
| const rect = makeUiRect3D(rectId); |
| canvas.updateRects([rect]); |
| const rectMesh = getRectMesh(rectId); |
| let rectGeometryId = rectMesh.geometry.id; |
| |
| // no change |
| canvas.updateRects([rect]); |
| expect(rectMesh.geometry.id).toEqual(rectGeometryId); |
| |
| // geometry object replaced |
| const roundRect = makeUiRect3D(rectId); |
| roundRect.cornerRadii = new CornerRadii(0, 0.4, 0.3, 0.2); |
| updateRectsAndCheckGeometryId(roundRect, rectMesh, rectGeometryId); |
| rectGeometryId = rectMesh.geometry.id; |
| |
| const diffRadii = makeUiRect3D(rectId); |
| diffRadii.cornerRadii = new CornerRadii(0.5, 0.4, 0.3, 0.2); |
| updateRectsAndCheckGeometryId(diffRadii, rectMesh, rectGeometryId); |
| rectGeometryId = rectMesh.geometry.id; |
| |
| const bottomRightChanged = makeUiRect3D(rectId); |
| bottomRightChanged.cornerRadii = new CornerRadii(0.5, 0.4, 0.3, 0.2); |
| bottomRightChanged.bottomRight = new Point3D(5, 5, 5); |
| updateRectsAndCheckGeometryId( |
| bottomRightChanged, |
| rectMesh, |
| rectGeometryId, |
| ); |
| rectGeometryId = rectMesh.geometry.id; |
| |
| const topLeftChanged = makeUiRect3D(rectId); |
| topLeftChanged.cornerRadii = new CornerRadii(0.5, 0.4, 0.3, 0.2); |
| topLeftChanged.bottomRight = new Point3D(5, 5, 5); |
| topLeftChanged.topLeft = new Point3D(0, 0, 5); |
| updateRectsAndCheckGeometryId(topLeftChanged, rectMesh, rectGeometryId); |
| rectGeometryId = rectMesh.geometry.id; |
| |
| const noRadii = makeUiRect3D(rectId); |
| noRadii.bottomRight = new Point3D(5, 5, 5); |
| noRadii.topLeft = new Point3D(0, 0, 5); |
| updateRectsAndCheckGeometryId(noRadii, rectMesh, rectGeometryId); |
| |
| const prevRectMeshId = rectMesh.id; |
| const rotated = makeUiRect3D(rectId); |
| rotated.bottomRight = new Point3D(5, 5, 5); |
| rotated.topLeft = new Point3D(0, 0, 5); |
| rotated.transform = getDefaultTransform( |
| TransformTypeFlags.ROT_90_VAL, |
| 2, |
| 2, |
| ).matrix; |
| canvas.updateRects([rotated]); |
| expect(getRectMesh(rectId).id).not.toEqual(prevRectMeshId); |
| }); |
| |
| it('handles changes in fill region', () => { |
| const noFillRegion = makeUiRect3D(rectId); |
| canvas.updateRects([noFillRegion]); |
| const rectMesh = getRectMesh(rectId); |
| expect( |
| rectMesh.getObjectByName(rectId + Canvas.GRAPHICS_NAMES.fillRegion), |
| ).toBeUndefined(); |
| expect( |
| (rectMesh.material as THREE.MeshBasicMaterial).color.getHex(), |
| ).toBe(13166775); |
| |
| const emptyFillRegion = makeUiRect3D(rectId); |
| emptyFillRegion.fillRegion = []; |
| canvas.updateRects([emptyFillRegion]); |
| const fillRegionMesh = getFillRegionMesh(rectId); |
| expect(rectMesh.material).toEqual(Canvas.TRANSPARENT_MATERIAL); |
| expect( |
| (fillRegionMesh.material as THREE.MeshBasicMaterial).color.getHex(), |
| ).toBe(13166775); |
| let fillRegionGeometryId = fillRegionMesh.geometry.id; |
| |
| const emptyFillRegionWithContent = makeUiRect3D(rectId); |
| emptyFillRegionWithContent.fillRegion = []; |
| emptyFillRegionWithContent.colorType = ColorType.HAS_CONTENT; |
| canvas.updateRects([emptyFillRegionWithContent]); |
| expect(rectMesh.material).toEqual(Canvas.TRANSPARENT_MATERIAL); |
| checkMaterialColorAndOpacity( |
| fillRegionMesh, |
| Canvas.RECT_COLOR_HAS_CONTENT, |
| Canvas.OPACITY_REGULAR, |
| ); |
| let newGeometry = getFillRegionMesh(rectId).geometry; |
| expect(newGeometry.id).toEqual(fillRegionGeometryId); |
| |
| const validFillRegion = makeUiRect3D(rectId); |
| validFillRegion.fillRegion = [ |
| { |
| topLeft: emptyFillRegion.topLeft, |
| bottomRight: emptyFillRegion.bottomRight, |
| }, |
| ]; |
| canvas.updateRects([validFillRegion]); |
| newGeometry = getFillRegionMesh(rectId).geometry; |
| expect(newGeometry.id).not.toEqual(fillRegionGeometryId); |
| fillRegionGeometryId = newGeometry.id; |
| |
| const differentFillRegion = makeUiRect3D(rectId); |
| differentFillRegion.fillRegion = [ |
| { |
| topLeft: validFillRegion.fillRegion[0].topLeft, |
| bottomRight: new Point3D(4, 4, 2), |
| }, |
| ]; |
| canvas.updateRects([differentFillRegion]); |
| newGeometry = getFillRegionMesh(rectId).geometry; |
| expect(newGeometry.id).not.toEqual(fillRegionGeometryId); |
| fillRegionGeometryId = newGeometry.id; |
| |
| canvas.updateRects([noFillRegion]); |
| expect( |
| getRectMesh(rectId).getObjectByName( |
| rectId + Canvas.GRAPHICS_NAMES.fillRegion, |
| ), |
| ).toBeUndefined(); |
| }); |
| |
| it('handles change from normal to pinned borders', () => { |
| const rect = makeUiRect3D(rectId); |
| rect.topLeft = new Point3D(1, 1, 5); |
| rect.bottomRight = new Point3D(2, 2, 5); |
| canvas.updateRects([rect]); |
| checkBorderColor(rect.id, Canvas.RECT_EDGE_COLOR_LIGHT_MODE); |
| |
| const pinnedRect = makeUiRect3D(rectId); |
| pinnedRect.topLeft = new Point3D(1, 1, 5); |
| pinnedRect.bottomRight = new Point3D(2, 2, 5); |
| pinnedRect.isPinned = true; |
| canvas.updateRects([pinnedRect]); |
| checkBorderColor(rect.id, Canvas.RECT_EDGE_COLOR_PINNED); |
| }); |
| |
| it('adds pointers', () => { |
| const rect = makeUiRect3D(rectId); |
| rect.pointerLocationsInRect = [ |
| new Point3D(2, 2, 2), |
| new Point3D(4, 4, 4), |
| ]; |
| canvas.updateRects([rect]); |
| checkNextPointer(rect.pointerLocationsInRect[0], new Point3D(2, 2, 3)); |
| checkNextPointer(new Point3D(4, 4, 2), new Point3D(4, 4, 3)); |
| }); |
| |
| it('handles changes in number of pointers', () => { |
| const rect = makeUiRect3D(rectId); |
| rect.pointerLocationsInRect = [new Point3D(2, 2, 2)]; |
| canvas.updateRects([rect]); |
| const circleId = getPointerCircle(rectId).id; |
| const crosshairsId = getPointerCrosshairs(rectId).id; |
| |
| const rect2 = makeUiRect3D(rectId); |
| rect2.pointerLocationsInRect = [new Point3D(2, 2, 2)]; |
| canvas.updateRects([rect2]); |
| expect(getPointerCircle(rectId).id).toBe(circleId); |
| expect(getPointerCrosshairs(rectId).id).toBe(crosshairsId); |
| expect(countPointers(rectId)).toBe(1); |
| |
| const rect3 = makeUiRect3D(rectId); |
| rect3.pointerLocationsInRect = [ |
| new Point3D(2, 2, 2), |
| new Point3D(1, 2, 2), |
| ]; |
| canvas.updateRects([rect3]); |
| expect(getPointerCircle(rectId).id).not.toBe(circleId); |
| expect(getPointerCrosshairs(rectId).id).not.toBe(crosshairsId); |
| expect(countPointers(rectId)).toBe(2); |
| |
| const rect4 = makeUiRect3D(rectId); |
| rect4.pointerLocationsInRect = [new Point3D(1, 2, 2)]; |
| canvas.updateRects([rect4]); |
| const newCircle = getPointerCircle(rectId); |
| expect(newCircle.id).not.toBe(circleId); |
| expect(getPointerCrosshairs(rectId).id).not.toBe(crosshairsId); |
| expect(countPointers(rectId)).toBe(1); |
| checkVectorEqualToPoint(newCircle.position, new Point3D(1, 2, 2)); |
| }); |
| |
| it('changes pointer thickness', () => { |
| const rect = makeUiRect3D(rectId); |
| rect.pointerLocationsInRect = [new Point3D(2, 2, 2)]; |
| canvas.updateRects([rect]); |
| const crosshairs = getPointerCrosshairs(rectId); |
| checkGeometryPosition(crosshairs, [-40, -1.5, 0, -40, 1.5, 0]); |
| |
| const rect2 = makeUiRect3D(rectId); |
| rect2.colorType = ColorType.HIGHLIGHTED; |
| rect2.pointerLocationsInRect = [new Point3D(2, 2, 2)]; |
| canvas.updateRects([rect2]); |
| const crosshairs2 = getPointerCrosshairs(rectId); |
| checkGeometryPosition(crosshairs2, [-40, -5.5, 0, -40, 5.5, 0]); |
| |
| const rect3 = makeUiRect3D(rectId); |
| rect3.colorType = ColorType.HIGHLIGHTED_WITH_OPACITY; |
| rect3.pointerLocationsInRect = [new Point3D(2, 2, 2)]; |
| canvas.updateRects([rect3]); |
| const crosshairs3 = getPointerCrosshairs(rectId); |
| checkGeometryPosition(crosshairs3, [-40, -5.5, 0, -40, 5.5, 0]); |
| }); |
| |
| it('adds rays', () => { |
| const rect = makeUiRect3D(rectId); |
| rect.rayLocationsInScene = [new Point3D(2, 2, 2), new Point3D(4, 4, 4)]; |
| canvas.updateRects([rect]); |
| checkNextRay(rect.rayLocationsInScene[0]); |
| checkNextRay(rect.rayLocationsInScene[1]); |
| }); |
| |
| it('handles changes in number of rays', () => { |
| const rect = makeUiRect3D(rectId); |
| rect.rayLocationsInScene = [new Point3D(2, 3, 4)]; |
| canvas.updateRects([rect]); |
| const rayId = getRayLine(rectId).id; |
| const rayName = rectId + Canvas.GRAPHICS_NAMES.ray; |
| |
| const rect2 = makeUiRect3D(rectId); |
| rect2.rayLocationsInScene = [new Point3D(2, 3, 4)]; |
| canvas.updateRects([rect2]); |
| expect(getRayLine(rectId).id).toBe(rayId); |
| expect(countObject(rayName, graphicsScene)).toBe(1); |
| |
| const rect3 = makeUiRect3D(rectId); |
| rect3.rayLocationsInScene = [new Point3D(2, 3, 4), new Point3D(4, 4, 4)]; |
| canvas.updateRects([rect3]); |
| expect(getRayLine(rectId).id).not.toBe(rayId); |
| expect(countObject(rayName, graphicsScene)).toBe(2); |
| |
| const rect4 = makeUiRect3D(rectId); |
| rect4.rayLocationsInScene = [new Point3D(4, 4, 4)]; |
| canvas.updateRects([rect4]); |
| const newRay = getRayLine(rectId); |
| expect(newRay.id).not.toBe(rayId); |
| expect(countObject(rayName, graphicsScene)).toBe(1); |
| checkVectorEqualToPoint(newRay.position, rect4.rayLocationsInScene[0]); |
| }); |
| |
| function checkMaterialColorAndOpacity( |
| mesh: THREE.Mesh | THREE.Line, |
| color: THREE.Color | number, |
| opacity: number, |
| ) { |
| const material = mesh.material as THREE.MeshBasicMaterial; |
| expect( |
| color instanceof THREE.Color ? material.color : material.color.getHex(), |
| ).toEqual(color); |
| expect(material.opacity).toEqual(opacity); |
| } |
| |
| function checkBorderColor(id: string, color: THREE.Color | number) { |
| const rectMesh = getRectMesh(id); |
| const border = getBorders(id + Canvas.GRAPHICS_NAMES.border, rectMesh); |
| const meshColor = border.material.color; |
| expect( |
| color instanceof THREE.Color ? meshColor : meshColor.getHex(), |
| ).toEqual(color); |
| } |
| |
| function getBorders(id: string, root: THREE.Object3D) { |
| const segments = assertDefined(root.getObjectByName(id)); |
| return segments as THREE.LineSegments< |
| THREE.EdgesGeometry, |
| THREE.LineBasicMaterial |
| >; |
| } |
| |
| function getRectMesh(id: string): THREE.Mesh { |
| return getMesh(id, graphicsScene); |
| } |
| |
| function getFillRegionMesh(id: string): THREE.Mesh { |
| const rectMesh = getRectMesh(id); |
| return getMesh(id + Canvas.GRAPHICS_NAMES.fillRegion, rectMesh); |
| } |
| |
| function getPointerCircle(id: string): THREE.Mesh { |
| const rectMesh = getRectMesh(id); |
| return getMesh(rectId + Canvas.GRAPHICS_NAMES.pointerCircle, rectMesh); |
| } |
| |
| function getPointerCrosshairs(id: string): THREE.Mesh { |
| const rectMesh = getRectMesh(id); |
| return getMesh( |
| rectId + Canvas.GRAPHICS_NAMES.pointerCrosshairs, |
| rectMesh, |
| ); |
| } |
| |
| function checkNextPointer(expCircle: Point3D, expCrosshairs: Point3D) { |
| const expectedColor = Canvas.RECT_EDGE_COLOR_LIGHT_MODE; |
| |
| const circle = getPointerCircle(rectId); |
| expect((circle.geometry as THREE.CircleGeometry).parameters.radius).toBe( |
| 10, |
| ); |
| checkVectorEqualToPoint(circle.position, expCircle); |
| checkMaterialColorAndOpacity(circle, expectedColor, 1); |
| |
| const crosshairs = getPointerCrosshairs(rectId); |
| checkVectorEqualToPoint(crosshairs.position, expCrosshairs); |
| checkMaterialColorAndOpacity(circle, expectedColor, 1); |
| |
| // change names so next objects can be retrieved from mesh |
| circle.name = 'circle'; |
| crosshairs.name = 'crosshairs'; |
| } |
| |
| function checkNextRay(expectedPosition: Point3D) { |
| const ray = getRayLine(rectId); |
| checkGeometryPosition(ray, [0, 0, 0, 0, 0, 1500]); |
| checkVectorEqualToPoint(ray.position, expectedPosition); |
| checkMaterialColorAndOpacity(ray, Canvas.RECT_EDGE_COLOR_LIGHT_MODE, 1); |
| ray.name = 'ray'; // change name so next can be retrieved from scene |
| } |
| |
| function countPointers(id: string) { |
| const rectMesh = getRectMesh(id); |
| const circleCount = countObject( |
| id + Canvas.GRAPHICS_NAMES.pointerCircle, |
| rectMesh, |
| ); |
| const crosshairsCount = countObject( |
| id + Canvas.GRAPHICS_NAMES.pointerCrosshairs, |
| rectMesh, |
| ); |
| expect(circleCount).toEqual(crosshairsCount); |
| return circleCount; |
| } |
| |
| function countObject(name: string, root: THREE.Object3D) { |
| let obj = root.getObjectByName(name); |
| let count = 0; |
| while (obj) { |
| count++; |
| obj.name = 'obj'; |
| obj = root.getObjectByName(name); |
| } |
| return count; |
| } |
| |
| function getRayLine(id: string): THREE.Line { |
| return getLine(id + Canvas.GRAPHICS_NAMES.ray, graphicsScene); |
| } |
| |
| function checkGeometryPosition( |
| obj: THREE.Mesh | THREE.Line, |
| expected: number[], |
| ) { |
| expect( |
| equal( |
| Array.from(obj.geometry.getAttribute('position').array).slice( |
| 0, |
| expected.length, |
| ), |
| expected, |
| ), |
| ).toBeTrue(); |
| } |
| |
| function updateRectsAndCheckGeometryId( |
| rect: UiRect3D, |
| rectMesh: THREE.Mesh, |
| prevId: number, |
| ) { |
| canvas.updateRects([rect]); |
| expect(rectMesh.geometry.id).not.toEqual(prevId); |
| } |
| }); |
| |
| describe('updateLabels', () => { |
| let canvas: Canvas; |
| let isDarkMode: boolean; |
| let graphicsScene: THREE.Scene; |
| |
| beforeEach(() => { |
| isDarkMode = false; |
| const canvasRects = document.createElement('canvas'); |
| const canvasLabels = document.createElement('canvas'); |
| canvas = new Canvas(canvasRects, canvasLabels, () => isDarkMode); |
| graphicsScene = canvas.renderView()[0]; |
| }); |
| |
| it('adds and removes labels', () => { |
| const mapDeleteSpy = spyOn(Map.prototype, 'delete').and.callThrough(); |
| canvas.updateLabels([]); |
| expect( |
| graphicsScene.getObjectByName(rectId + Canvas.GRAPHICS_NAMES.circle), |
| ).toBeUndefined(); |
| expect( |
| graphicsScene.getObjectByName(rectId + Canvas.GRAPHICS_NAMES.line), |
| ).toBeUndefined(); |
| expect( |
| graphicsScene.getObjectByName(rectId + Canvas.GRAPHICS_NAMES.text), |
| ).toBeUndefined(); |
| |
| canvas.updateLabels([makeRectLabel(rectId)]); |
| expect( |
| graphicsScene.getObjectByName(rectId + Canvas.GRAPHICS_NAMES.circle), |
| ).toBeDefined(); |
| expect( |
| graphicsScene.getObjectByName(rectId + Canvas.GRAPHICS_NAMES.line), |
| ).toBeDefined(); |
| expect( |
| graphicsScene.getObjectByName(rectId + Canvas.GRAPHICS_NAMES.text), |
| ).toBeDefined(); |
| |
| canvas.updateLabels([]); |
| expect( |
| graphicsScene.getObjectByName(rectId + Canvas.GRAPHICS_NAMES.circle), |
| ).toBeUndefined(); |
| expect( |
| graphicsScene.getObjectByName(rectId + Canvas.GRAPHICS_NAMES.line), |
| ).toBeUndefined(); |
| expect( |
| graphicsScene.getObjectByName(rectId + Canvas.GRAPHICS_NAMES.text), |
| ).toBeUndefined(); |
| expect(mapDeleteSpy).toHaveBeenCalledOnceWith(rectId); |
| }); |
| |
| it('updates existing labels instead of adding new labels', () => { |
| const label = makeRectLabel(rectId); |
| canvas.updateLabels([label]); |
| const circleMesh = getCircleMesh(rectId); |
| const geometryId = circleMesh.geometry.id; |
| |
| const newLabel = makeRectLabel(rectId); |
| newLabel.circle.radius = 2; |
| canvas.updateLabels([newLabel]); |
| expect(getCircleMesh(rectId)).toEqual(circleMesh); |
| expect(circleMesh.geometry.id).not.toEqual(geometryId); |
| }); |
| |
| it('makes label with correct circle and text geometry', () => { |
| const label = makeRectLabel(rectId); |
| canvas.updateLabels([label]); |
| const circleMesh = getCircleMesh(rectId); |
| expect( |
| (circleMesh.geometry as THREE.CircleGeometry).parameters.radius, |
| ).toEqual(label.circle.radius); |
| checkVectorEqualToPoint(circleMesh.position, label.circle.center); |
| const text = getText(rectId); |
| checkVectorEqualToPoint(text.position, label.textCenter); |
| }); |
| |
| it('handles change in circle radius', () => { |
| const label = makeRectLabel(rectId); |
| canvas.updateLabels([label]); |
| const circleMesh = getCircleMesh(rectId); |
| |
| const newLabel = makeRectLabel(rectId); |
| newLabel.circle.radius = 2; |
| canvas.updateLabels([newLabel]); |
| expect( |
| (circleMesh.geometry as THREE.CircleGeometry).parameters.radius, |
| ).toBe(2); |
| }); |
| |
| it('handles change in circle center', () => { |
| const label = makeRectLabel(rectId); |
| canvas.updateLabels([label]); |
| const circleMesh = getCircleMesh(rectId); |
| |
| const newLabel = makeRectLabel(rectId); |
| newLabel.circle.center = new Point3D(1, 1, 1); |
| canvas.updateLabels([newLabel]); |
| checkVectorEqualToPoint(circleMesh.position, newLabel.circle.center); |
| }); |
| |
| it('applies colors based on highlighted or dark mode state', () => { |
| const label = makeRectLabel(rectId); |
| canvas.updateLabels([label]); |
| const circleMesh = getCircleMesh(rectId); |
| const line = getLabelLine(rectId); |
| const text = getText(rectId); |
| expect( |
| (circleMesh.material as THREE.LineBasicMaterial).color.getHex(), |
| ).toEqual(Canvas.LABEL_LINE_COLOR); |
| expect((line.material as THREE.LineBasicMaterial).color.getHex()).toEqual( |
| Canvas.LABEL_LINE_COLOR, |
| ); |
| expect(text.element.style.color).toBe('gray'); |
| |
| const highlighted = makeRectLabel(rectId); |
| highlighted.isHighlighted = true; |
| canvas.updateLabels([highlighted]); |
| expect( |
| (circleMesh.material as THREE.LineBasicMaterial).color.getHex(), |
| ).toEqual(Canvas.RECT_EDGE_COLOR_LIGHT_MODE); |
| expect((line.material as THREE.LineBasicMaterial).color.getHex()).toEqual( |
| Canvas.RECT_EDGE_COLOR_LIGHT_MODE, |
| ); |
| expect(text.element.style.color).toBe(''); |
| |
| isDarkMode = true; |
| canvas.updateLabels([highlighted]); |
| expect( |
| (circleMesh.material as THREE.LineBasicMaterial).color.getHex(), |
| ).toEqual(Canvas.RECT_EDGE_COLOR_DARK_MODE); |
| expect((line.material as THREE.LineBasicMaterial).color.getHex()).toEqual( |
| Canvas.RECT_EDGE_COLOR_DARK_MODE, |
| ); |
| expect(text.element.style.color).toBe(''); |
| |
| canvas.updateLabels([label]); |
| expect( |
| (circleMesh.material as THREE.LineBasicMaterial).color.getHex(), |
| ).toEqual(Canvas.LABEL_LINE_COLOR); |
| expect((line.material as THREE.LineBasicMaterial).color.getHex()).toEqual( |
| Canvas.LABEL_LINE_COLOR, |
| ); |
| expect(text.element.style.color).toBe('gray'); |
| }); |
| |
| it('handles change in line points', () => { |
| const label = makeRectLabel(rectId); |
| canvas.updateLabels([label]); |
| const line = getLabelLine(rectId); |
| const geometryId = line.geometry.id; |
| |
| const newLabel = makeRectLabel(rectId); |
| newLabel.linePoints = [new Point3D(1, 1, 1), new Point3D(1, 2, 1)]; |
| canvas.updateLabels([newLabel]); |
| expect(line.geometry.id).not.toEqual(geometryId); |
| }); |
| |
| it('handles change in text center', () => { |
| const label = makeRectLabel(rectId); |
| canvas.updateLabels([label]); |
| const text = getText(rectId); |
| |
| const newLabel = makeRectLabel(rectId); |
| newLabel.textCenter = new Point3D(1, 15, 1); |
| canvas.updateLabels([newLabel]); |
| checkVectorEqualToPoint(text.position, newLabel.textCenter); |
| }); |
| |
| it('robust to no labels canvas', () => { |
| const canvasRects = document.createElement('canvas'); |
| const canvas = new Canvas(canvasRects); |
| canvas.updateLabels([]); |
| }); |
| |
| it('propagates highlighted item on text click', () => { |
| const label = makeRectLabel(rectId); |
| canvas.updateLabels([label]); |
| const text = getText(rectId); |
| |
| let id: string | undefined; |
| text.element.addEventListener( |
| ViewerEvents.HighlightedIdChange, |
| (event) => { |
| id = (event as CustomEvent).detail.id; |
| }, |
| ); |
| text.element.click(); |
| expect(id).toEqual(rectId); |
| }); |
| |
| function getCircleMesh(id: string): THREE.Mesh { |
| return getMesh(id + Canvas.GRAPHICS_NAMES.circle, graphicsScene); |
| } |
| |
| function getLabelLine(id: string): THREE.Line { |
| return getLine(id + Canvas.GRAPHICS_NAMES.line, graphicsScene); |
| } |
| |
| function getText(id: string): CSS2DObject { |
| return assertDefined( |
| graphicsScene.getObjectByName(id + Canvas.GRAPHICS_NAMES.text), |
| ) as CSS2DObject; |
| } |
| }); |
| |
| describe('renderView', () => { |
| let canvas: Canvas; |
| let rectsCompileSpy: jasmine.Spy; |
| let renderingSpies: jasmine.Spy[]; |
| |
| beforeEach(() => { |
| const canvasRects = document.createElement('canvas'); |
| const canvasLabels = document.createElement('canvas'); |
| canvas = new Canvas(canvasRects, canvasLabels); |
| rectsCompileSpy = spyOn(assertDefined(canvas.renderer), 'compile'); |
| renderingSpies = [ |
| spyOn(assertDefined(canvas.renderer), 'setPixelRatio'), |
| spyOn(assertDefined(canvas.renderer), 'render'), |
| spyOn(assertDefined(canvas.labelRenderer), 'render'), |
| ]; |
| }); |
| |
| it('sets pixel ratio and renders rects and labels', () => { |
| canvas.renderView(); |
| checkRenderSpiesCalled(1); |
| }); |
| |
| it('only compiles on first call', () => { |
| canvas.renderView(); |
| expect(rectsCompileSpy).toHaveBeenCalledTimes(1); |
| checkRenderSpiesCalled(1); |
| |
| canvas.renderView(); |
| expect(rectsCompileSpy).toHaveBeenCalledTimes(1); |
| checkRenderSpiesCalled(2); |
| }); |
| |
| it('robust to no labels canvas', () => { |
| const canvasRects = document.createElement('canvas'); |
| const canvas = new Canvas(canvasRects); |
| canvas.renderView(); |
| }); |
| |
| function checkRenderSpiesCalled(times: number) { |
| renderingSpies.forEach((spy) => expect(spy).toHaveBeenCalledTimes(times)); |
| } |
| }); |
| |
| describe('getClickedRectId', () => { |
| let canvas: Canvas; |
| |
| beforeEach(() => { |
| const canvasRects = document.createElement('canvas'); |
| const canvasLabels = document.createElement('canvas'); |
| canvas = new Canvas(canvasRects, canvasLabels); |
| const box = makeBoundingBox(); |
| canvas.updateViewPosition(makeCamera(), box, box.depth); |
| canvas.renderView(); |
| }); |
| |
| it('identifies clicked rect', () => { |
| const rect = makeUiRect3D(rectId); |
| rect.isClickable = true; |
| canvas.updateRects([rect]); |
| canvas.renderView(); |
| |
| const id = canvas.getClickedRectId(0.1, 0.1); |
| expect(id).toBe('rect1'); |
| }); |
| |
| it('identifies clicked rect from fill region', () => { |
| const rect = makeUiRect3D(rectId); |
| rect.fillRegion = [ |
| {topLeft: rect.topLeft, bottomRight: rect.bottomRight}, |
| ]; |
| rect.isClickable = true; |
| canvas.updateRects([rect]); |
| canvas.renderView(); |
| |
| const id = canvas.getClickedRectId(0.1, 0.1); |
| expect(id).toBe('rect1'); |
| }); |
| |
| it('does not identify rect if not clickable', () => { |
| const rect = makeUiRect3D(rectId); |
| canvas.updateRects([rect]); |
| expect(canvas.getClickedRectId(0.1, 0.1)).toBeUndefined(); |
| }); |
| |
| it('does not identify rect out of click area', () => { |
| const rect = makeUiRect3D(rectId); |
| rect.isClickable = true; |
| canvas.updateRects([rect]); |
| expect(canvas.getClickedRectId(2, 2)).toBeUndefined(); |
| }); |
| }); |
| |
| function makeCamera(): Camera { |
| return { |
| rotationAngleX: 0, |
| rotationAngleY: 0, |
| zoomFactor: 1, |
| panScreenDistance: {dx: 0, dy: 0}, |
| }; |
| } |
| |
| function makeBoundingBox(): Box3D { |
| return { |
| width: 1, |
| height: 1, |
| depth: 1, |
| center: new Point3D(0, 0, 0), |
| diagonal: 1, |
| }; |
| } |
| |
| function makeUiRect3D(id: string): UiRect3D { |
| return { |
| id, |
| topLeft: new Point3D(0, 0, 0), |
| bottomRight: new Point3D(1, 1, 0), |
| cornerRadii: undefined, |
| darkFactor: 1, |
| colorType: ColorType.VISIBLE, |
| isClickable: false, |
| transform: TransformMatrix.IDENTITY, |
| isOversized: false, |
| fillRegion: undefined, |
| isPinned: false, |
| pointerLocationsInRect: [], |
| rayLocationsInScene: [], |
| }; |
| } |
| |
| function makeRectLabel(id: string): RectLabel { |
| return { |
| circle: {radius: 1, center: new Point3D(0, 0, 0)}, |
| linePoints: [new Point3D(0, 0, 0), new Point3D(0, 1, 0)], |
| textCenter: new Point3D(0, 12, 0), |
| text: id, |
| isHighlighted: false, |
| rectId: id, |
| }; |
| } |
| |
| function getMesh(id: string, root: THREE.Object3D): THREE.Mesh { |
| const mesh = assertDefined(root.getObjectByName(id)); |
| expect(mesh).toBeInstanceOf(THREE.Mesh); |
| return mesh as THREE.Mesh; |
| } |
| |
| function getLine(id: string, root: THREE.Object3D): THREE.Line { |
| const mesh = assertDefined(root.getObjectByName(id)); |
| expect(mesh).toBeInstanceOf(THREE.Line); |
| return mesh as THREE.Line; |
| } |
| |
| function checkVectorEqualToPoint(vector: THREE.Vector3, point: Point3D) { |
| expect( |
| vector.equals(new THREE.Vector3(point.x, point.y, point.z)), |
| ).toBeTrue(); |
| } |
| }); |