1/* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16import * as THREE from 'three'; 17import {CSS2DObject, CSS2DRenderer} from 'three/examples/jsm/renderers/CSS2DRenderer'; 18import {Rectangle} from 'viewers/common/rectangle'; 19import {ViewerEvents} from 'viewers/common/viewer_events'; 20import {Circle3D, ColorType, Label3D, Point3D, Rect3D, Scene3D, Transform3D} from './types3d'; 21 22export class Canvas { 23 private static readonly TARGET_SCENE_DIAGONAL = 4; 24 private static readonly RECT_COLOR_HIGHLIGHTED = new THREE.Color(0xd2e3fc); 25 private static readonly RECT_EDGE_COLOR = 0x000000; 26 private static readonly RECT_EDGE_COLOR_ROUNDED = 0x848884; 27 private static readonly LABEL_CIRCLE_COLOR = 0x000000; 28 private static readonly LABEL_LINE_COLOR = 0x000000; 29 private static readonly LABEL_LINE_COLOR_HIGHLIGHTED = 0x808080; 30 private static readonly OPACITY_REGULAR = 0.75; 31 private static readonly OPACITY_OVERSIZED = 0.25; 32 33 private canvasRects: HTMLCanvasElement; 34 private canvasLabels: HTMLElement; 35 private camera?: THREE.OrthographicCamera; 36 private scene?: THREE.Scene; 37 private renderer?: THREE.WebGLRenderer; 38 private labelRenderer?: CSS2DRenderer; 39 private rects: Rectangle[] = []; 40 private clickableObjects: THREE.Object3D[] = []; 41 42 constructor(canvasRects: HTMLCanvasElement, canvasLabels: HTMLElement) { 43 this.canvasRects = canvasRects; 44 this.canvasLabels = canvasLabels; 45 } 46 47 draw(scene: Scene3D) { 48 // Must set 100% width and height so the HTML element expands to the parent's 49 // boundaries and the correct clientWidth and clientHeight values can be read 50 this.canvasRects.style.width = '100%'; 51 this.canvasRects.style.height = '100%'; 52 let widthAspectRatioAdjustFactor: number; 53 let heightAspectRatioAdjustFactor: number; 54 55 if (this.canvasRects.clientWidth > this.canvasRects.clientHeight) { 56 heightAspectRatioAdjustFactor = 1; 57 widthAspectRatioAdjustFactor = this.canvasRects.clientWidth / this.canvasRects.clientHeight; 58 } else { 59 heightAspectRatioAdjustFactor = this.canvasRects.clientHeight / this.canvasRects.clientWidth; 60 widthAspectRatioAdjustFactor = 1; 61 } 62 63 const cameraWidth = Canvas.TARGET_SCENE_DIAGONAL * widthAspectRatioAdjustFactor; 64 const cameraHeight = Canvas.TARGET_SCENE_DIAGONAL * heightAspectRatioAdjustFactor; 65 66 const panFactorX = scene.camera.panScreenDistance.dx / this.canvasRects.clientWidth; 67 const panFactorY = scene.camera.panScreenDistance.dy / this.canvasRects.clientHeight; 68 69 this.scene = new THREE.Scene(); 70 const scaleFactor = 71 (Canvas.TARGET_SCENE_DIAGONAL / scene.boundingBox.diagonal) * scene.camera.zoomFactor; 72 this.scene.scale.set(scaleFactor, -scaleFactor, scaleFactor); 73 this.scene.translateX(scaleFactor * -scene.boundingBox.center.x + cameraWidth * panFactorX); 74 this.scene.translateY(scaleFactor * scene.boundingBox.center.y - cameraHeight * panFactorY); 75 this.scene.translateZ(scaleFactor * -scene.boundingBox.center.z); 76 77 this.camera = new THREE.OrthographicCamera( 78 -cameraWidth / 2, 79 cameraWidth / 2, 80 cameraHeight / 2, 81 -cameraHeight / 2, 82 0, 83 100 84 ); 85 86 const rotationAngleX = (scene.camera.rotationFactor * Math.PI * 45) / 360; 87 const rotationAngleY = rotationAngleX * 1.5; 88 const cameraPosition = new THREE.Vector3(0, 0, Canvas.TARGET_SCENE_DIAGONAL); 89 cameraPosition.applyAxisAngle(new THREE.Vector3(1, 0, 0), -rotationAngleX); 90 cameraPosition.applyAxisAngle(new THREE.Vector3(0, 1, 0), rotationAngleY); 91 92 this.camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z); 93 this.camera.lookAt(0, 0, 0); 94 95 this.renderer = new THREE.WebGLRenderer({ 96 antialias: true, 97 canvas: this.canvasRects, 98 alpha: true, 99 }); 100 101 this.labelRenderer = new CSS2DRenderer({element: this.canvasLabels}); 102 103 // set various factors for shading and shifting 104 const numberOfRects = this.rects.length; 105 this.drawRects(scene.rects); 106 this.drawLabels(scene.labels); 107 108 this.renderer.setSize(this.canvasRects!.clientWidth, this.canvasRects!.clientHeight); 109 this.renderer.setPixelRatio(window.devicePixelRatio); 110 this.renderer.compile(this.scene, this.camera); 111 this.renderer.render(this.scene, this.camera); 112 113 this.labelRenderer.setSize(this.canvasRects!.clientWidth, this.canvasRects!.clientHeight); 114 this.labelRenderer.render(this.scene, this.camera); 115 } 116 117 getClickedRectId(x: number, y: number, z: number): undefined | string { 118 const clickPosition = new THREE.Vector3(x, y, z); 119 const raycaster = new THREE.Raycaster(); 120 raycaster.setFromCamera(clickPosition, this.camera!); 121 const intersected = raycaster.intersectObjects(this.clickableObjects); 122 if (intersected.length > 0) { 123 return intersected[0].object.name; 124 } 125 return undefined; 126 } 127 128 private drawRects(rects: Rect3D[]) { 129 this.clickableObjects = []; 130 rects.forEach((rect) => { 131 const rectMesh = this.makeRectMesh(rect); 132 const transform = this.toMatrix4(rect.transform); 133 rectMesh.applyMatrix4(transform); 134 135 this.scene?.add(rectMesh); 136 137 if (rect.isClickable) { 138 this.clickableObjects.push(rectMesh); 139 } 140 }); 141 } 142 143 private drawLabels(labels: Label3D[]) { 144 this.clearLabels(); 145 labels.forEach((label) => { 146 const circleMesh = this.makeLabelCircleMesh(label.circle); 147 this.scene?.add(circleMesh); 148 149 const linePoints = label.linePoints.map((point: Point3D) => { 150 return new THREE.Vector3(point.x, point.y, point.z); 151 }); 152 const lineGeometry = new THREE.BufferGeometry().setFromPoints(linePoints); 153 const lineMaterial = new THREE.LineBasicMaterial({ 154 color: label.isHighlighted ? Canvas.LABEL_LINE_COLOR_HIGHLIGHTED : Canvas.LABEL_LINE_COLOR, 155 }); 156 const line = new THREE.Line(lineGeometry, lineMaterial); 157 this.scene?.add(line); 158 159 this.drawLabelTextHtml(label); 160 }); 161 } 162 163 private drawLabelTextHtml(label: Label3D) { 164 // Add rectangle label 165 const spanText: HTMLElement = document.createElement('span'); 166 spanText.innerText = label.text; 167 spanText.className = 'mat-body-1'; 168 169 // Hack: transparent/placeholder text used to push the visible text towards left 170 // (towards negative x) and properly align it with the label's vertical segment 171 const spanPlaceholder: HTMLElement = document.createElement('span'); 172 spanPlaceholder.innerText = label.text; 173 spanPlaceholder.className = 'mat-body-1'; 174 spanPlaceholder.style.opacity = '0'; 175 176 const div: HTMLElement = document.createElement('div'); 177 div.className = 'rect-label'; 178 div.style.display = 'inline'; 179 div.appendChild(spanText); 180 div.appendChild(spanPlaceholder); 181 182 div.style.marginTop = '5px'; 183 if (label.isHighlighted) { 184 div.style.color = 'gray'; 185 } 186 div.style.pointerEvents = 'auto'; 187 div.style.cursor = 'pointer'; 188 div.addEventListener('click', (event) => 189 this.propagateUpdateHighlightedItems(event, label.rectId) 190 ); 191 192 const labelCss = new CSS2DObject(div); 193 labelCss.position.set(label.textCenter.x, label.textCenter.y, label.textCenter.z); 194 195 this.scene?.add(labelCss); 196 } 197 198 private toMatrix4(transform: Transform3D): THREE.Matrix4 { 199 return new THREE.Matrix4().set( 200 transform.dsdx, 201 transform.dsdy, 202 0, 203 transform.tx, 204 transform.dtdx, 205 transform.dtdy, 206 0, 207 transform.ty, 208 0, 209 0, 210 1, 211 0, 212 0, 213 0, 214 0, 215 1 216 ); 217 } 218 219 private makeRectMesh(rect: Rect3D): THREE.Mesh { 220 const rectShape = this.createRectShape(rect); 221 const rectGeometry = new THREE.ShapeGeometry(rectShape); 222 const rectBorders = this.createRectBorders(rect, rectGeometry); 223 224 let opacity = Canvas.OPACITY_REGULAR; 225 if (rect.isOversized) { 226 opacity = Canvas.OPACITY_OVERSIZED; 227 } 228 229 // Crate mesh to draw 230 const mesh = new THREE.Mesh( 231 rectGeometry, 232 new THREE.MeshBasicMaterial({ 233 color: this.getColor(rect), 234 opacity, 235 transparent: true, 236 }) 237 ); 238 239 mesh.add(rectBorders); 240 mesh.position.x = 0; 241 mesh.position.y = 0; 242 mesh.position.z = rect.topLeft.z; 243 mesh.name = rect.id; 244 245 return mesh; 246 } 247 248 private createRectShape(rect: Rect3D): THREE.Shape { 249 const bottomLeft: Point3D = {x: rect.topLeft.x, y: rect.bottomRight.y, z: rect.topLeft.z}; 250 const topRight: Point3D = {x: rect.bottomRight.x, y: rect.topLeft.y, z: rect.bottomRight.z}; 251 252 // Limit corner radius if larger than height/2 (or width/2) 253 const height = rect.bottomRight.y - rect.topLeft.y; 254 const width = rect.bottomRight.x - rect.topLeft.x; 255 const minEdge = Math.min(height, width); 256 let cornerRadius = Math.min(rect.cornerRadius, minEdge / 2); 257 258 // Force radius > 0, because radius === 0 could result in weird triangular shapes 259 // being drawn instead of rectangles. Seems like quadraticCurveTo() doesn't 260 // always handle properly the case with radius === 0. 261 cornerRadius = Math.max(cornerRadius, 0.01); 262 263 // Create (rounded) rect shape 264 return new THREE.Shape() 265 .moveTo(rect.topLeft.x, rect.topLeft.y + cornerRadius) 266 .lineTo(bottomLeft.x, bottomLeft.y - cornerRadius) 267 .quadraticCurveTo(bottomLeft.x, bottomLeft.y, bottomLeft.x + cornerRadius, bottomLeft.y) 268 .lineTo(rect.bottomRight.x - cornerRadius, rect.bottomRight.y) 269 .quadraticCurveTo( 270 rect.bottomRight.x, 271 rect.bottomRight.y, 272 rect.bottomRight.x, 273 rect.bottomRight.y - cornerRadius 274 ) 275 .lineTo(topRight.x, topRight.y + cornerRadius) 276 .quadraticCurveTo(topRight.x, topRight.y, topRight.x - cornerRadius, topRight.y) 277 .lineTo(rect.topLeft.x + cornerRadius, rect.topLeft.y) 278 .quadraticCurveTo( 279 rect.topLeft.x, 280 rect.topLeft.y, 281 rect.topLeft.x, 282 rect.topLeft.y + cornerRadius 283 ); 284 } 285 286 private getColor(rect: Rect3D): THREE.Color { 287 switch (rect.colorType) { 288 case ColorType.VISIBLE: { 289 // green (darkness depends on z order) 290 const red = ((200 - 45) * rect.darkFactor + 45) / 255; 291 const green = ((232 - 182) * rect.darkFactor + 182) / 255; 292 const blue = ((183 - 44) * rect.darkFactor + 44) / 255; 293 return new THREE.Color(red, green, blue); 294 } 295 case ColorType.NOT_VISIBLE: { 296 // gray (darkness depends on z order) 297 const lower = 120; 298 const upper = 220; 299 const darkness = ((upper - lower) * rect.darkFactor + lower) / 255; 300 return new THREE.Color(darkness, darkness, darkness); 301 } 302 case ColorType.HIGHLIGHTED: { 303 return Canvas.RECT_COLOR_HIGHLIGHTED; 304 } 305 default: { 306 throw new Error(`Unexpected color type: ${rect.colorType}`); 307 } 308 } 309 } 310 311 private createRectBorders(rect: Rect3D, rectGeometry: THREE.ShapeGeometry): THREE.LineSegments { 312 // create line edges for rect 313 const edgeGeo = new THREE.EdgesGeometry(rectGeometry); 314 let edgeMaterial: THREE.Material; 315 if (rect.cornerRadius) { 316 edgeMaterial = new THREE.LineBasicMaterial({ 317 color: Canvas.RECT_EDGE_COLOR_ROUNDED, 318 linewidth: 1, 319 }); 320 } else { 321 edgeMaterial = new THREE.LineBasicMaterial({ 322 color: Canvas.RECT_EDGE_COLOR, 323 linewidth: 1, 324 }); 325 } 326 const lineSegments = new THREE.LineSegments(edgeGeo, edgeMaterial); 327 lineSegments.computeLineDistances(); 328 return lineSegments; 329 } 330 331 private makeLabelCircleMesh(circle: Circle3D): THREE.Mesh { 332 const geometry = new THREE.CircleGeometry(circle.radius, 20); 333 const material = new THREE.MeshBasicMaterial({color: Canvas.LABEL_CIRCLE_COLOR}); 334 const mesh = new THREE.Mesh(geometry, material); 335 mesh.position.set(circle.center.x, circle.center.y, circle.center.z); 336 return mesh; 337 } 338 339 private propagateUpdateHighlightedItems(event: MouseEvent, newId: string) { 340 event.preventDefault(); 341 const highlightedChangeEvent: CustomEvent = new CustomEvent(ViewerEvents.HighlightedChange, { 342 bubbles: true, 343 detail: {id: newId}, 344 }); 345 event.target?.dispatchEvent(highlightedChangeEvent); 346 } 347 348 private clearLabels() { 349 this.canvasLabels.innerHTML = ''; 350 } 351} 352