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 */
16
17import {assertDefined} from 'common/assert_utils';
18import {IDENTITY_MATRIX, TransformMatrix} from 'common/geometry_types';
19import {Size, UiRect} from 'viewers/components/rects/types2d';
20import {
21  Box3D,
22  ColorType,
23  Distance2D,
24  Label3D,
25  Point3D,
26  Rect3D,
27  Scene3D,
28  ShadingMode,
29} from './types3d';
30
31class Mapper3D {
32  private static readonly CAMERA_ROTATION_FACTOR_INIT = 1;
33  private static readonly Z_FIGHTING_EPSILON = 5;
34  private static readonly Z_SPACING_FACTOR_INIT = 1;
35  private static readonly Z_SPACING_MAX = 200;
36  private static readonly LABEL_FIRST_Y_OFFSET = 100;
37  private static readonly LABEL_TEXT_Y_SPACING = 200;
38  private static readonly LABEL_CIRCLE_RADIUS = 15;
39  private static readonly ZOOM_FACTOR_INIT = 1;
40  private static readonly ZOOM_FACTOR_MIN = 0.1;
41  private static readonly ZOOM_FACTOR_MAX = 30;
42  private static readonly ZOOM_FACTOR_STEP = 0.2;
43
44  private rects: UiRect[] = [];
45  private highlightedRectId = '';
46  private cameraRotationFactor = Mapper3D.CAMERA_ROTATION_FACTOR_INIT;
47  private zSpacingFactor = Mapper3D.Z_SPACING_FACTOR_INIT;
48  private zoomFactor = Mapper3D.ZOOM_FACTOR_INIT;
49  private panScreenDistance = new Distance2D(0, 0);
50  private currentGroupId = 0; // default stack id is usually 0
51  private shadingModeIndex = 0;
52  private allowedShadingModes: ShadingMode[] = [ShadingMode.GRADIENT];
53
54  setRects(rects: UiRect[]) {
55    this.rects = rects;
56  }
57
58  setHighlightedRectId(id: string) {
59    this.highlightedRectId = id;
60  }
61
62  getCameraRotationFactor(): number {
63    return this.cameraRotationFactor;
64  }
65
66  setCameraRotationFactor(factor: number) {
67    this.cameraRotationFactor = Math.min(Math.max(factor, 0), 1);
68  }
69
70  getZSpacingFactor(): number {
71    return this.zSpacingFactor;
72  }
73
74  setZSpacingFactor(factor: number) {
75    this.zSpacingFactor = Math.min(Math.max(factor, 0), 1);
76  }
77
78  increaseZoomFactor(ratio = 1) {
79    this.zoomFactor += Mapper3D.ZOOM_FACTOR_STEP * ratio;
80    this.zoomFactor = Math.min(this.zoomFactor, Mapper3D.ZOOM_FACTOR_MAX);
81  }
82
83  decreaseZoomFactor(ratio = 1) {
84    this.zoomFactor -= Mapper3D.ZOOM_FACTOR_STEP * ratio;
85    this.zoomFactor = Math.max(this.zoomFactor, Mapper3D.ZOOM_FACTOR_MIN);
86  }
87
88  addPanScreenDistance(distance: Distance2D) {
89    this.panScreenDistance.dx += distance.dx;
90    this.panScreenDistance.dy += distance.dy;
91  }
92
93  resetToOrthogonalState() {
94    this.cameraRotationFactor = Mapper3D.CAMERA_ROTATION_FACTOR_INIT;
95    this.zSpacingFactor = Mapper3D.Z_SPACING_FACTOR_INIT;
96  }
97
98  resetCamera() {
99    this.resetToOrthogonalState();
100    this.zoomFactor = Mapper3D.ZOOM_FACTOR_INIT;
101    this.panScreenDistance.dx = 0;
102    this.panScreenDistance.dy = 0;
103  }
104
105  getCurrentGroupId(): number {
106    return this.currentGroupId;
107  }
108
109  setCurrentGroupId(id: number) {
110    this.currentGroupId = id;
111  }
112
113  setAllowedShadingModes(modes: ShadingMode[]) {
114    this.allowedShadingModes = modes;
115  }
116
117  setShadingMode(newMode: ShadingMode) {
118    const newModeIndex = this.allowedShadingModes.findIndex(
119      (m) => m === newMode,
120    );
121    if (newModeIndex !== -1) {
122      this.shadingModeIndex = newModeIndex;
123    }
124  }
125
126  getShadingMode(): ShadingMode {
127    return this.allowedShadingModes[this.shadingModeIndex];
128  }
129
130  updateShadingMode() {
131    this.shadingModeIndex =
132      this.shadingModeIndex < this.allowedShadingModes.length - 1
133        ? this.shadingModeIndex + 1
134        : 0;
135  }
136
137  isWireFrame(): boolean {
138    return (
139      this.allowedShadingModes.at(this.shadingModeIndex) ===
140      ShadingMode.WIRE_FRAME
141    );
142  }
143
144  isShadedByGradient(): boolean {
145    return (
146      this.allowedShadingModes.at(this.shadingModeIndex) ===
147      ShadingMode.GRADIENT
148    );
149  }
150
151  isShadedByOpacity(): boolean {
152    return (
153      this.allowedShadingModes.at(this.shadingModeIndex) === ShadingMode.OPACITY
154    );
155  }
156
157  computeScene(): Scene3D {
158    const rects2d = this.selectRectsToDraw(this.rects);
159    rects2d.sort(this.compareDepth);
160    const rects3d = this.computeRects(rects2d);
161    const labels3d = this.computeLabels(rects2d, rects3d);
162    const boundingBox = this.computeBoundingBox(rects3d, labels3d);
163
164    const scene: Scene3D = {
165      boundingBox,
166      camera: {
167        rotationFactor: this.cameraRotationFactor,
168        zoomFactor: this.zoomFactor,
169        panScreenDistance: this.panScreenDistance,
170      },
171      rects: rects3d,
172      labels: labels3d,
173    };
174
175    return scene;
176  }
177
178  private compareDepth(a: UiRect, b: UiRect): number {
179    return b.depth - a.depth;
180  }
181
182  private selectRectsToDraw(rects: UiRect[]): UiRect[] {
183    return rects.filter((rect) => rect.groupId === this.currentGroupId);
184  }
185
186  private computeRects(rects2d: UiRect[]): Rect3D[] {
187    let visibleRectsSoFar = 0;
188    let visibleRectsTotal = 0;
189    let nonVisibleRectsSoFar = 0;
190    let nonVisibleRectsTotal = 0;
191
192    rects2d.forEach((rect) => {
193      if (rect.isVisible) {
194        ++visibleRectsTotal;
195      } else {
196        ++nonVisibleRectsTotal;
197      }
198    });
199
200    const maxDisplaySize = this.getMaxDisplaySize(rects2d);
201
202    const depthToCountOfRects = new Map<number, number>();
203    const computeAntiZFightingOffset = (rectDepth: number) => {
204      // Rendering overlapping rects with equal Z value causes Z-fighting (b/307951779).
205      // Here we compute a Z-offset to be applied to the rect to guarantee that
206      // eventually all rects will have unique Z-values.
207      const countOfRectsAtSameDepth = depthToCountOfRects.get(rectDepth) ?? 0;
208      const antiZFightingOffset =
209        countOfRectsAtSameDepth * Mapper3D.Z_FIGHTING_EPSILON;
210      depthToCountOfRects.set(rectDepth, countOfRectsAtSameDepth + 1);
211      return antiZFightingOffset;
212    };
213
214    let z = 0;
215    const rects3d = rects2d.map((rect2d): Rect3D => {
216      z =
217        this.zSpacingFactor *
218        (Mapper3D.Z_SPACING_MAX * rect2d.depth +
219          computeAntiZFightingOffset(rect2d.depth));
220
221      let darkFactor = 0;
222      if (rect2d.isVisible) {
223        darkFactor = this.isShadedByOpacity()
224          ? assertDefined(rect2d.opacity)
225          : (visibleRectsTotal - visibleRectsSoFar++) / visibleRectsTotal;
226      } else {
227        darkFactor =
228          (nonVisibleRectsTotal - nonVisibleRectsSoFar++) /
229          nonVisibleRectsTotal;
230      }
231
232      const rect = {
233        id: rect2d.id,
234        topLeft: {
235          x: rect2d.x,
236          y: rect2d.y,
237          z,
238        },
239        bottomRight: {
240          x: rect2d.x + rect2d.w,
241          y: rect2d.y + rect2d.h,
242          z,
243        },
244        isOversized: false,
245        cornerRadius: rect2d.cornerRadius,
246        darkFactor,
247        colorType: this.getColorType(rect2d),
248        isClickable: rect2d.isClickable,
249        transform: rect2d.transform ?? IDENTITY_MATRIX,
250      };
251      return this.cropOversizedRect(rect, maxDisplaySize);
252    });
253
254    return rects3d;
255  }
256
257  private getColorType(rect2d: UiRect): ColorType {
258    if (this.highlightedRectId === rect2d.id && rect2d.isClickable) {
259      return ColorType.HIGHLIGHTED;
260    }
261    if (this.isWireFrame()) {
262      return ColorType.EMPTY;
263    }
264    if (rect2d.hasContent === true) {
265      if (this.isShadedByOpacity()) {
266        return ColorType.HAS_CONTENT_AND_OPACITY;
267      }
268      return ColorType.HAS_CONTENT;
269    }
270    if (rect2d.isVisible) {
271      if (this.isShadedByOpacity()) {
272        return ColorType.VISIBLE_WITH_OPACITY;
273      }
274      return ColorType.VISIBLE;
275    }
276    return ColorType.NOT_VISIBLE;
277  }
278
279  private getMaxDisplaySize(rects2d: UiRect[]): Size {
280    const displays = rects2d.filter((rect2d) => rect2d.isDisplay);
281
282    let maxWidth = 0;
283    let maxHeight = 0;
284    if (displays.length > 0) {
285      maxWidth = Math.max(
286        ...displays.map((rect2d): number => Math.abs(rect2d.w)),
287      );
288
289      maxHeight = Math.max(
290        ...displays.map((rect2d): number => Math.abs(rect2d.h)),
291      );
292    }
293    return {
294      width: maxWidth,
295      height: maxHeight,
296    };
297  }
298
299  private cropOversizedRect(rect3d: Rect3D, maxDisplaySize: Size): Rect3D {
300    // Arbitrary max size for a rect (2x the maximum display)
301    let maxDimension = Number.MAX_VALUE;
302    if (maxDisplaySize.height > 0) {
303      maxDimension = Math.max(maxDisplaySize.width, maxDisplaySize.height) * 2;
304    }
305
306    const height = Math.abs(rect3d.topLeft.y - rect3d.bottomRight.y);
307    const width = Math.abs(rect3d.topLeft.x - rect3d.bottomRight.x);
308
309    if (width > maxDimension) {
310      rect3d.isOversized = true;
311      (rect3d.topLeft.x = (maxDimension - maxDisplaySize.width / 2) * -1),
312        (rect3d.bottomRight.x = maxDimension);
313    }
314    if (height > maxDimension) {
315      rect3d.isOversized = true;
316      rect3d.topLeft.y = (maxDimension - maxDisplaySize.height / 2) * -1;
317      rect3d.bottomRight.y = maxDimension;
318    }
319
320    return rect3d;
321  }
322
323  private computeLabels(rects2d: UiRect[], rects3d: Rect3D[]): Label3D[] {
324    const labels3d: Label3D[] = [];
325
326    let labelY =
327      Math.max(
328        ...rects3d.map((rect) => {
329          return this.matMultiply(rect.transform, rect.bottomRight).y;
330        }),
331      ) + Mapper3D.LABEL_FIRST_Y_OFFSET;
332
333    rects2d.forEach((rect2d, index) => {
334      if (!rect2d.label) {
335        return;
336      }
337
338      const rect3d = rects3d[index];
339
340      const bottomLeft: Point3D = {
341        x: rect3d.topLeft.x,
342        y: rect3d.topLeft.y,
343        z: rect3d.topLeft.z,
344      };
345      const topRight: Point3D = {
346        x: rect3d.bottomRight.x,
347        y: rect3d.bottomRight.y,
348        z: rect3d.bottomRight.z,
349      };
350      const lineStarts = [
351        this.matMultiply(rect3d.transform, rect3d.topLeft),
352        this.matMultiply(rect3d.transform, rect3d.bottomRight),
353        this.matMultiply(rect3d.transform, bottomLeft),
354        this.matMultiply(rect3d.transform, topRight),
355      ];
356      let maxIndex = 0;
357      for (let i = 1; i < lineStarts.length; i++) {
358        if (lineStarts[i].x > lineStarts[maxIndex].x) {
359          maxIndex = i;
360        }
361      }
362      const lineStart = lineStarts[maxIndex];
363      lineStart.x += Mapper3D.LABEL_CIRCLE_RADIUS / 2;
364
365      const lineEnd: Point3D = {
366        x: lineStart.x,
367        y: labelY,
368        z: lineStart.z,
369      };
370
371      const isHighlighted =
372        rect2d.isClickable && this.highlightedRectId === rect2d.id;
373
374      const label3d: Label3D = {
375        circle: {
376          radius: Mapper3D.LABEL_CIRCLE_RADIUS,
377          center: {
378            x: lineStart.x,
379            y: lineStart.y,
380            z: lineStart.z + 0.5,
381          },
382        },
383        linePoints: [lineStart, lineEnd],
384        textCenter: lineEnd,
385        text: rect2d.label,
386        isHighlighted,
387        rectId: rect2d.id,
388      };
389      labels3d.push(label3d);
390
391      labelY += Mapper3D.LABEL_TEXT_Y_SPACING;
392    });
393
394    return labels3d;
395  }
396
397  private matMultiply(mat: TransformMatrix, point: Point3D): Point3D {
398    return {
399      x: mat.dsdx * point.x + mat.dsdy * point.y + mat.tx,
400      y: mat.dtdx * point.x + mat.dtdy * point.y + mat.ty,
401      z: point.z,
402    };
403  }
404
405  private computeBoundingBox(rects: Rect3D[], labels: Label3D[]): Box3D {
406    if (rects.length === 0) {
407      return {
408        width: 1,
409        height: 1,
410        depth: 1,
411        center: {x: 0, y: 0, z: 0},
412        diagonal: Math.sqrt(3),
413      };
414    }
415
416    let minX = Number.MAX_VALUE;
417    let maxX = Number.MIN_VALUE;
418    let minY = Number.MAX_VALUE;
419    let maxY = Number.MIN_VALUE;
420    let minZ = Number.MAX_VALUE;
421    let maxZ = Number.MIN_VALUE;
422
423    const updateMinMaxCoordinates = (point: Point3D) => {
424      minX = Math.min(minX, point.x);
425      maxX = Math.max(maxX, point.x);
426      minY = Math.min(minY, point.y);
427      maxY = Math.max(maxY, point.y);
428      minZ = Math.min(minZ, point.z);
429      maxZ = Math.max(maxZ, point.z);
430    };
431
432    rects.forEach((rect) => {
433      /*const topLeft: Point3D = {
434        x: rect.center.x - rect.width / 2,
435        y: rect.center.y + rect.height / 2,
436        z: rect.center.z
437      };
438      const bottomRight: Point3D = {
439        x: rect.center.x + rect.width / 2,
440        y: rect.center.y - rect.height / 2,
441        z: rect.center.z
442      };*/
443      updateMinMaxCoordinates(rect.topLeft);
444      updateMinMaxCoordinates(rect.bottomRight);
445    });
446
447    labels.forEach((label) => {
448      label.linePoints.forEach((point) => {
449        updateMinMaxCoordinates(point);
450      });
451    });
452
453    const center: Point3D = {
454      x: (minX + maxX) / 2,
455      y: (minY + maxY) / 2,
456      z: (minZ + maxZ) / 2,
457    };
458
459    const width = maxX - minX;
460    const height = maxY - minY;
461    const depth = maxZ - minZ;
462
463    return {
464      width,
465      height,
466      depth,
467      center,
468      diagonal: Math.sqrt(width * width + height * height + depth * depth),
469    };
470  }
471}
472
473export {Mapper3D};
474