• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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