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 {assertDefined} from 'common/assert_utils';
17import {TransformMatrix} from 'common/geometry_types';
18import * as THREE from 'three';
19import {
20  CSS2DObject,
21  CSS2DRenderer,
22} from 'three/examples/jsm/renderers/CSS2DRenderer';
23import {ViewerEvents} from 'viewers/common/viewer_events';
24import {
25  Circle3D,
26  ColorType,
27  Label3D,
28  Point3D,
29  Rect3D,
30  Scene3D,
31} from './types3d';
32
33export class Canvas {
34  static readonly TARGET_SCENE_DIAGONAL = 4;
35  private static readonly RECT_COLOR_HIGHLIGHTED_LIGHT_MODE = new THREE.Color(
36    0xd2e3fc, // Keep in sync with :not(.dark-mode) --selected-element-color in material-theme.scss
37  );
38  private static readonly RECT_COLOR_HIGHLIGHTED_DARK_MODE = new THREE.Color(
39    0x5f718a, // Keep in sync with .dark-mode --selected-element-color in material-theme.scss
40  );
41  private static readonly RECT_COLOR_HAS_CONTENT = new THREE.Color(0xad42f5);
42
43  private static readonly RECT_EDGE_COLOR_LIGHT_MODE = 0x000000;
44  private static readonly RECT_EDGE_COLOR_DARK_MODE = 0xffffff;
45  private static readonly RECT_EDGE_COLOR_ROUNDED = 0x848884;
46
47  private static readonly LABEL_LINE_COLOR = 0x808080;
48
49  private static readonly OPACITY_REGULAR = 0.75;
50  private static readonly OPACITY_OVERSIZED = 0.25;
51
52  private camera?: THREE.OrthographicCamera;
53  private scene?: THREE.Scene;
54  private renderer?: THREE.WebGLRenderer;
55  private labelRenderer?: CSS2DRenderer;
56  private clickableObjects: THREE.Object3D[] = [];
57
58  constructor(
59    private canvasRects: HTMLCanvasElement,
60    private canvasLabels?: HTMLElement,
61    private isDarkMode = () => false,
62  ) {}
63
64  draw(scene: Scene3D) {
65    // Must set 100% width and height so the HTML element expands to the parent's
66    // boundaries and the correct clientWidth and clientHeight values can be read
67    this.canvasRects.style.width = '100%';
68    this.canvasRects.style.height = '100%';
69    let widthAspectRatioAdjustFactor: number;
70    let heightAspectRatioAdjustFactor: number;
71
72    if (this.canvasRects.clientWidth > this.canvasRects.clientHeight) {
73      heightAspectRatioAdjustFactor = 1;
74      widthAspectRatioAdjustFactor =
75        this.canvasRects.clientWidth / this.canvasRects.clientHeight;
76    } else {
77      heightAspectRatioAdjustFactor =
78        this.canvasRects.clientHeight / this.canvasRects.clientWidth;
79      widthAspectRatioAdjustFactor = 1;
80    }
81
82    const cameraWidth =
83      Canvas.TARGET_SCENE_DIAGONAL * widthAspectRatioAdjustFactor;
84    const cameraHeight =
85      Canvas.TARGET_SCENE_DIAGONAL * heightAspectRatioAdjustFactor;
86
87    const panFactorX =
88      scene.camera.panScreenDistance.dx / this.canvasRects.clientWidth;
89    const panFactorY =
90      scene.camera.panScreenDistance.dy / this.canvasRects.clientHeight;
91
92    this.scene = new THREE.Scene();
93    const scaleFactor =
94      (Canvas.TARGET_SCENE_DIAGONAL / scene.boundingBox.diagonal) *
95      scene.camera.zoomFactor;
96    this.scene.scale.set(scaleFactor, -scaleFactor, scaleFactor);
97    this.scene.translateX(
98      scaleFactor * -scene.boundingBox.center.x + cameraWidth * panFactorX,
99    );
100    this.scene.translateY(
101      scaleFactor * scene.boundingBox.center.y - cameraHeight * panFactorY,
102    );
103    this.scene.translateZ(scaleFactor * -scene.boundingBox.center.z);
104
105    this.camera = new THREE.OrthographicCamera(
106      -cameraWidth / 2,
107      cameraWidth / 2,
108      cameraHeight / 2,
109      -cameraHeight / 2,
110      0,
111      100,
112    );
113
114    const rotationAngleX = (scene.camera.rotationFactor * Math.PI * 45) / 360;
115    const rotationAngleY = rotationAngleX * 1.5;
116    const cameraPosition = new THREE.Vector3(
117      0,
118      0,
119      Canvas.TARGET_SCENE_DIAGONAL,
120    );
121    cameraPosition.applyAxisAngle(new THREE.Vector3(1, 0, 0), -rotationAngleX);
122    cameraPosition.applyAxisAngle(new THREE.Vector3(0, 1, 0), rotationAngleY);
123
124    this.camera.position.set(
125      cameraPosition.x,
126      cameraPosition.y,
127      cameraPosition.z,
128    );
129    this.camera.lookAt(0, 0, 0);
130
131    this.renderer = new THREE.WebGLRenderer({
132      antialias: true,
133      canvas: this.canvasRects,
134      alpha: true,
135    });
136
137    // set various factors for shading and shifting
138    this.drawRects(scene.rects);
139    const canvasRects = assertDefined(this.canvasRects);
140    if (this.canvasLabels) {
141      this.drawLabels(scene.labels, this.isDarkMode());
142
143      this.labelRenderer = new CSS2DRenderer({element: this.canvasLabels});
144      this.labelRenderer.setSize(
145        canvasRects.clientWidth,
146        canvasRects.clientHeight,
147      );
148      this.labelRenderer.render(this.scene, this.camera);
149    }
150
151    this.renderer.setSize(canvasRects.clientWidth, canvasRects.clientHeight);
152    this.renderer.setPixelRatio(window.devicePixelRatio);
153    this.renderer.compile(this.scene, this.camera);
154    this.renderer.render(this.scene, this.camera);
155  }
156
157  getClickedRectId(x: number, y: number, z: number): undefined | string {
158    const clickPosition = new THREE.Vector3(x, y, z);
159    const raycaster = new THREE.Raycaster();
160    raycaster.setFromCamera(clickPosition, assertDefined(this.camera));
161    const intersected = raycaster.intersectObjects(this.clickableObjects);
162    if (intersected.length > 0) {
163      return intersected[0].object.name;
164    }
165    return undefined;
166  }
167
168  private drawRects(rects: Rect3D[]) {
169    this.clickableObjects = [];
170    rects.forEach((rect) => {
171      const rectMesh = Canvas.makeRectMesh(rect, this.isDarkMode());
172      const transform = Canvas.toMatrix4(rect.transform);
173      rectMesh.applyMatrix4(transform);
174
175      this.scene?.add(rectMesh);
176
177      if (rect.isClickable) {
178        this.clickableObjects.push(rectMesh);
179      }
180    });
181  }
182
183  private drawLabels(labels: Label3D[], isDarkMode: boolean) {
184    this.clearLabels();
185    labels.forEach((label) => {
186      const circleMesh = this.makeLabelCircleMesh(label.circle, isDarkMode);
187      this.scene?.add(circleMesh);
188
189      const linePoints = label.linePoints.map((point: Point3D) => {
190        return new THREE.Vector3(point.x, point.y, point.z);
191      });
192      const lineGeometry = new THREE.BufferGeometry().setFromPoints(linePoints);
193      const lineMaterial = new THREE.LineBasicMaterial({
194        color: label.isHighlighted
195          ? isDarkMode
196            ? Canvas.RECT_EDGE_COLOR_DARK_MODE
197            : Canvas.RECT_EDGE_COLOR_LIGHT_MODE
198          : Canvas.LABEL_LINE_COLOR,
199      });
200      const line = new THREE.Line(lineGeometry, lineMaterial);
201      this.scene?.add(line);
202
203      this.drawLabelTextHtml(label);
204    });
205  }
206
207  private drawLabelTextHtml(label: Label3D) {
208    // Add rectangle label
209    const spanText: HTMLElement = document.createElement('span');
210    spanText.innerText = label.text;
211    spanText.className = 'mat-body-1';
212
213    // Hack: transparent/placeholder text used to push the visible text towards left
214    // (towards negative x) and properly align it with the label's vertical segment
215    const spanPlaceholder: HTMLElement = document.createElement('span');
216    spanPlaceholder.innerText = label.text;
217    spanPlaceholder.className = 'mat-body-1';
218    spanPlaceholder.style.opacity = '0';
219
220    const div: HTMLElement = document.createElement('div');
221    div.className = 'rect-label';
222    div.style.display = 'inline';
223    div.appendChild(spanText);
224    div.appendChild(spanPlaceholder);
225
226    div.style.marginTop = '5px';
227    if (!label.isHighlighted) {
228      div.style.color = 'gray';
229    }
230    div.style.pointerEvents = 'auto';
231    div.style.cursor = 'pointer';
232    div.addEventListener('click', (event) =>
233      this.propagateUpdateHighlightedItem(event, label.rectId),
234    );
235
236    const labelCss = new CSS2DObject(div);
237    labelCss.position.set(
238      label.textCenter.x,
239      label.textCenter.y,
240      label.textCenter.z,
241    );
242
243    this.scene?.add(labelCss);
244  }
245
246  private static toMatrix4(transform: TransformMatrix): THREE.Matrix4 {
247    return new THREE.Matrix4().set(
248      transform.dsdx,
249      transform.dsdy,
250      0,
251      transform.tx,
252      transform.dtdx,
253      transform.dtdy,
254      0,
255      transform.ty,
256      0,
257      0,
258      1,
259      0,
260      0,
261      0,
262      0,
263      1,
264    );
265  }
266
267  private static makeRectMesh(rect: Rect3D, isDarkMode: boolean): THREE.Mesh {
268    const rectShape = Canvas.createRectShape(rect);
269    const rectGeometry = new THREE.ShapeGeometry(rectShape);
270    const rectBorders = Canvas.createRectBorders(
271      rect,
272      rectGeometry,
273      isDarkMode,
274    );
275
276    const color = Canvas.getColor(rect, isDarkMode);
277    let mesh: THREE.Mesh | undefined;
278    if (color === undefined) {
279      mesh = new THREE.Mesh(
280        rectGeometry,
281        new THREE.MeshBasicMaterial({
282          opacity: 0,
283          transparent: true,
284        }),
285      );
286    } else {
287      let opacity: number | undefined;
288      if (
289        rect.colorType === ColorType.VISIBLE_WITH_OPACITY ||
290        rect.colorType === ColorType.HAS_CONTENT_AND_OPACITY
291      ) {
292        opacity = rect.darkFactor;
293      } else {
294        opacity = rect.isOversized
295          ? Canvas.OPACITY_OVERSIZED
296          : Canvas.OPACITY_REGULAR;
297      }
298      mesh = new THREE.Mesh(
299        rectGeometry,
300        new THREE.MeshBasicMaterial({
301          color,
302          opacity,
303          transparent: true,
304        }),
305      );
306    }
307
308    mesh.add(rectBorders);
309    mesh.position.x = 0;
310    mesh.position.y = 0;
311    mesh.position.z = rect.topLeft.z;
312    mesh.name = rect.id;
313
314    return mesh;
315  }
316
317  private static createRectShape(rect: Rect3D): THREE.Shape {
318    const bottomLeft: Point3D = {
319      x: rect.topLeft.x,
320      y: rect.bottomRight.y,
321      z: rect.topLeft.z,
322    };
323    const topRight: Point3D = {
324      x: rect.bottomRight.x,
325      y: rect.topLeft.y,
326      z: rect.bottomRight.z,
327    };
328
329    // Limit corner radius if larger than height/2 (or width/2)
330    const height = rect.bottomRight.y - rect.topLeft.y;
331    const width = rect.bottomRight.x - rect.topLeft.x;
332    const minEdge = Math.min(height, width);
333    let cornerRadius = Math.min(rect.cornerRadius, minEdge / 2);
334
335    // Force radius > 0, because radius === 0 could result in weird triangular shapes
336    // being drawn instead of rectangles. Seems like quadraticCurveTo() doesn't
337    // always handle properly the case with radius === 0.
338    cornerRadius = Math.max(cornerRadius, 0.01);
339
340    // Create (rounded) rect shape
341    return new THREE.Shape()
342      .moveTo(rect.topLeft.x, rect.topLeft.y + cornerRadius)
343      .lineTo(bottomLeft.x, bottomLeft.y - cornerRadius)
344      .quadraticCurveTo(
345        bottomLeft.x,
346        bottomLeft.y,
347        bottomLeft.x + cornerRadius,
348        bottomLeft.y,
349      )
350      .lineTo(rect.bottomRight.x - cornerRadius, rect.bottomRight.y)
351      .quadraticCurveTo(
352        rect.bottomRight.x,
353        rect.bottomRight.y,
354        rect.bottomRight.x,
355        rect.bottomRight.y - cornerRadius,
356      )
357      .lineTo(topRight.x, topRight.y + cornerRadius)
358      .quadraticCurveTo(
359        topRight.x,
360        topRight.y,
361        topRight.x - cornerRadius,
362        topRight.y,
363      )
364      .lineTo(rect.topLeft.x + cornerRadius, rect.topLeft.y)
365      .quadraticCurveTo(
366        rect.topLeft.x,
367        rect.topLeft.y,
368        rect.topLeft.x,
369        rect.topLeft.y + cornerRadius,
370      );
371  }
372
373  private static getVisibleRectColor(darkFactor: number) {
374    const red = ((200 - 45) * darkFactor + 45) / 255;
375    const green = ((232 - 182) * darkFactor + 182) / 255;
376    const blue = ((183 - 44) * darkFactor + 44) / 255;
377    return new THREE.Color(red, green, blue);
378  }
379
380  private static getColor(
381    rect: Rect3D,
382    isDarkMode: boolean,
383  ): THREE.Color | undefined {
384    switch (rect.colorType) {
385      case ColorType.VISIBLE: {
386        // green (darkness depends on z order)
387        return Canvas.getVisibleRectColor(rect.darkFactor);
388      }
389      case ColorType.VISIBLE_WITH_OPACITY: {
390        // same green for all rects - rect.darkFactor determines opacity
391        return Canvas.getVisibleRectColor(0.7);
392      }
393      case ColorType.NOT_VISIBLE: {
394        // gray (darkness depends on z order)
395        const lower = 120;
396        const upper = 220;
397        const darkness = ((upper - lower) * rect.darkFactor + lower) / 255;
398        return new THREE.Color(darkness, darkness, darkness);
399      }
400      case ColorType.HIGHLIGHTED: {
401        return isDarkMode
402          ? Canvas.RECT_COLOR_HIGHLIGHTED_DARK_MODE
403          : Canvas.RECT_COLOR_HIGHLIGHTED_LIGHT_MODE;
404      }
405      case ColorType.HAS_CONTENT_AND_OPACITY: {
406        return Canvas.RECT_COLOR_HAS_CONTENT;
407      }
408      case ColorType.HAS_CONTENT: {
409        return Canvas.RECT_COLOR_HAS_CONTENT;
410      }
411      case ColorType.EMPTY: {
412        return undefined;
413      }
414      default: {
415        throw new Error(`Unexpected color type: ${rect.colorType}`);
416      }
417    }
418  }
419
420  private static createRectBorders(
421    rect: Rect3D,
422    rectGeometry: THREE.ShapeGeometry,
423    isDarkMode: boolean,
424  ): THREE.LineSegments {
425    // create line edges for rect
426    const edgeGeo = new THREE.EdgesGeometry(rectGeometry);
427    let edgeMaterial: THREE.Material;
428    if (rect.cornerRadius) {
429      edgeMaterial = new THREE.LineBasicMaterial({
430        color: Canvas.RECT_EDGE_COLOR_ROUNDED,
431        linewidth: 1,
432      });
433    } else {
434      edgeMaterial = new THREE.LineBasicMaterial({
435        color: isDarkMode
436          ? Canvas.RECT_EDGE_COLOR_DARK_MODE
437          : Canvas.RECT_EDGE_COLOR_LIGHT_MODE,
438        linewidth: 1,
439      });
440    }
441    const lineSegments = new THREE.LineSegments(edgeGeo, edgeMaterial);
442    lineSegments.computeLineDistances();
443    return lineSegments;
444  }
445
446  private makeLabelCircleMesh(
447    circle: Circle3D,
448    isDarkMode: boolean,
449  ): THREE.Mesh {
450    const geometry = new THREE.CircleGeometry(circle.radius, 20);
451    const material = new THREE.MeshBasicMaterial({
452      color: isDarkMode
453        ? Canvas.RECT_EDGE_COLOR_DARK_MODE
454        : Canvas.RECT_EDGE_COLOR_LIGHT_MODE,
455    });
456    const mesh = new THREE.Mesh(geometry, material);
457    mesh.position.set(circle.center.x, circle.center.y, circle.center.z);
458    return mesh;
459  }
460
461  private propagateUpdateHighlightedItem(event: MouseEvent, newId: string) {
462    event.preventDefault();
463    const highlightedChangeEvent = new CustomEvent(
464      ViewerEvents.HighlightedIdChange,
465      {
466        bubbles: true,
467        detail: {id: newId},
468      },
469    );
470    event.target?.dispatchEvent(highlightedChangeEvent);
471  }
472
473  private clearLabels() {
474    if (this.canvasLabels) {
475      this.canvasLabels.innerHTML = '';
476    }
477  }
478}
479