• 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 */
16
17import {assertDefined} from 'common/assert_utils';
18import {Box3D} from 'common/geometry/box3d';
19import {Distance} from 'common/geometry/distance';
20import {Point3D} from 'common/geometry/point3d';
21import {Rect3D} from 'common/geometry/rect3d';
22import {Size} from 'common/geometry/size';
23import {
24  IDENTITY_MATRIX,
25  TransformMatrix,
26} from 'common/geometry/transform_matrix';
27import {UiHierarchyTreeNode} from 'viewers/common/ui_hierarchy_tree_node';
28import {UiRect} from 'viewers/components/rects/ui_rect';
29import {ColorType} from './color_type';
30import {RectLabel} from './rect_label';
31import {Scene} from './scene';
32import {ShadingMode} from './shading_mode';
33import {UiRect3D} from './ui_rect3d';
34
35class Mapper3D {
36  private static readonly CAMERA_ROTATION_FACTOR_INIT = 1;
37  private static readonly DISPLAY_CLUSTER_SPACING = 750;
38  private static readonly LABEL_FIRST_Y_OFFSET = 100;
39  private static readonly LABEL_CIRCLE_RADIUS = 15;
40  private static readonly LABEL_SPACING_INIT_FACTOR = 12.5;
41  private static readonly LABEL_SPACING_PER_RECT_FACTOR = 5;
42  private static readonly LABEL_SPACING_MIN = 200;
43  private static readonly MAX_RENDERED_LABELS = 30;
44  private static readonly SINGLE_LABEL_SPACING_FACTOR = 1.75;
45  private static readonly Y_AXIS_ROTATION_FACTOR = 1.5;
46  private static readonly Z_FIGHTING_EPSILON = 5;
47  private static readonly ZOOM_FACTOR_INIT = 1;
48  private static readonly ZOOM_FACTOR_MIN = 0.1;
49  private static readonly ZOOM_FACTOR_MAX = 30;
50  private static readonly ZOOM_FACTOR_STEP = 0.2;
51  private static readonly Z_SPACING_FACTOR_INIT = 1;
52  private static readonly Z_SPACING_MAX = 200;
53
54  private rects: UiRect[] = [];
55  private highlightedRectId = '';
56  private cameraRotationFactor = Mapper3D.CAMERA_ROTATION_FACTOR_INIT;
57  private zSpacingFactor = Mapper3D.Z_SPACING_FACTOR_INIT;
58  private zoomFactor = Mapper3D.ZOOM_FACTOR_INIT;
59  private panScreenDistance = new Distance(0, 0);
60  private currentGroupIds = [0]; // default stack id is usually 0
61  private shadingModeIndex = 0;
62  private allowedShadingModes: ShadingMode[] = [ShadingMode.GRADIENT];
63  private pinnedItems: UiHierarchyTreeNode[] = [];
64  private previousBoundingBox: Box3D | undefined;
65
66  setRects(rects: UiRect[]) {
67    this.rects = rects;
68  }
69
70  setPinnedItems(value: UiHierarchyTreeNode[]) {
71    this.pinnedItems = value;
72  }
73
74  setHighlightedRectId(id: string) {
75    this.highlightedRectId = id;
76  }
77
78  getCameraRotationFactor(): number {
79    return this.cameraRotationFactor;
80  }
81
82  setCameraRotationFactor(factor: number) {
83    this.cameraRotationFactor = Math.min(Math.max(factor, 0), 1);
84  }
85
86  getZSpacingFactor(): number {
87    return this.zSpacingFactor;
88  }
89
90  setZSpacingFactor(factor: number) {
91    this.zSpacingFactor = Math.min(Math.max(factor, 0), 1);
92  }
93
94  increaseZoomFactor(ratio: number) {
95    this.zoomFactor += Mapper3D.ZOOM_FACTOR_STEP * ratio;
96    this.zoomFactor = Math.min(this.zoomFactor, Mapper3D.ZOOM_FACTOR_MAX);
97  }
98
99  decreaseZoomFactor(ratio: number) {
100    this.zoomFactor -= Mapper3D.ZOOM_FACTOR_STEP * ratio;
101    this.zoomFactor = Math.max(this.zoomFactor, Mapper3D.ZOOM_FACTOR_MIN);
102  }
103
104  addPanScreenDistance(distance: Distance) {
105    this.panScreenDistance.dx += distance.dx;
106    this.panScreenDistance.dy += distance.dy;
107  }
108
109  resetToOrthogonalState() {
110    this.cameraRotationFactor = Mapper3D.CAMERA_ROTATION_FACTOR_INIT;
111    this.zSpacingFactor = Mapper3D.Z_SPACING_FACTOR_INIT;
112  }
113
114  resetCamera() {
115    this.resetToOrthogonalState();
116    this.zoomFactor = Mapper3D.ZOOM_FACTOR_INIT;
117    this.panScreenDistance.dx = 0;
118    this.panScreenDistance.dy = 0;
119  }
120
121  getCurrentGroupIds(): number[] {
122    return this.currentGroupIds;
123  }
124
125  setCurrentGroupIds(ids: number[]) {
126    this.currentGroupIds = ids;
127  }
128
129  setAllowedShadingModes(modes: ShadingMode[]) {
130    this.allowedShadingModes = modes;
131  }
132
133  setShadingMode(newMode: ShadingMode) {
134    const newModeIndex = this.allowedShadingModes.findIndex(
135      (m) => m === newMode,
136    );
137    if (newModeIndex !== -1) {
138      this.shadingModeIndex = newModeIndex;
139    }
140  }
141
142  getShadingMode(): ShadingMode {
143    return this.allowedShadingModes[this.shadingModeIndex];
144  }
145
146  updateShadingMode() {
147    this.shadingModeIndex =
148      this.shadingModeIndex < this.allowedShadingModes.length - 1
149        ? this.shadingModeIndex + 1
150        : 0;
151  }
152
153  isWireFrame(): boolean {
154    return (
155      this.allowedShadingModes.at(this.shadingModeIndex) ===
156      ShadingMode.WIRE_FRAME
157    );
158  }
159
160  isShadedByGradient(): boolean {
161    return (
162      this.allowedShadingModes.at(this.shadingModeIndex) ===
163      ShadingMode.GRADIENT
164    );
165  }
166
167  isShadedByOpacity(): boolean {
168    return (
169      this.allowedShadingModes.at(this.shadingModeIndex) === ShadingMode.OPACITY
170    );
171  }
172
173  computeScene(updateBoundingBox: boolean): Scene {
174    const rects3d: UiRect3D[] = [];
175    const labels3d: RectLabel[] = [];
176    let clusterYOffset = 0;
177    let boundingBox: Box3D | undefined;
178
179    for (const groupId of this.currentGroupIds) {
180      const rects2dForGroupId = this.selectRectsToDraw(this.rects, groupId);
181      rects2dForGroupId.sort(this.compareDepth); // decreasing order of depth
182      const rects3dForGroupId = this.computeRects(
183        rects2dForGroupId,
184        clusterYOffset,
185      );
186      const labels3dForGroupId = this.computeLabels(
187        rects2dForGroupId,
188        rects3dForGroupId,
189      );
190      rects3d.push(...rects3dForGroupId);
191      labels3d.push(...labels3dForGroupId);
192
193      boundingBox = this.computeBoundingBox(rects3d, labels3d);
194      clusterYOffset += boundingBox.height + Mapper3D.DISPLAY_CLUSTER_SPACING;
195    }
196
197    const newBoundingBox =
198      boundingBox ?? this.computeBoundingBox(rects3d, labels3d);
199    if (!this.previousBoundingBox || updateBoundingBox) {
200      this.previousBoundingBox = newBoundingBox;
201    }
202
203    const angleX = this.getCameraXAxisAngle();
204    const scene: Scene = {
205      boundingBox: this.previousBoundingBox,
206      camera: {
207        rotationAngleX: angleX,
208        rotationAngleY: angleX * Mapper3D.Y_AXIS_ROTATION_FACTOR,
209        zoomFactor: this.zoomFactor,
210        panScreenDistance: this.panScreenDistance,
211      },
212      rects: rects3d,
213      labels: labels3d,
214      zDepth: newBoundingBox.depth,
215    };
216    return scene;
217  }
218
219  private getCameraXAxisAngle(): number {
220    return (this.cameraRotationFactor * Math.PI * 45) / 360;
221  }
222
223  private compareDepth(a: UiRect, b: UiRect): number {
224    if (a.isDisplay && !b.isDisplay) return 1;
225    if (!a.isDisplay && b.isDisplay) return -1;
226    return b.depth - a.depth;
227  }
228
229  private selectRectsToDraw(rects: UiRect[], groupId: number): UiRect[] {
230    return rects.filter((rect) => rect.groupId === groupId);
231  }
232
233  private computeRects(rects2d: UiRect[], clusterYOffset: number): UiRect3D[] {
234    let visibleRectsSoFar = 0;
235    let visibleRectsTotal = 0;
236    let nonVisibleRectsSoFar = 0;
237    let nonVisibleRectsTotal = 0;
238
239    rects2d.forEach((rect) => {
240      if (rect.isVisible) {
241        ++visibleRectsTotal;
242      } else {
243        ++nonVisibleRectsTotal;
244      }
245    });
246
247    const maxDisplaySize = this.getMaxDisplaySize(rects2d);
248
249    const depthToCountOfRects = new Map<number, number>();
250    const computeAntiZFightingOffset = (rectDepth: number) => {
251      // Rendering overlapping rects with equal Z value causes Z-fighting (b/307951779).
252      // Here we compute a Z-offset to be applied to the rect to guarantee that
253      // eventually all rects will have unique Z-values.
254      const countOfRectsAtSameDepth = depthToCountOfRects.get(rectDepth) ?? 0;
255      const antiZFightingOffset =
256        countOfRectsAtSameDepth * Mapper3D.Z_FIGHTING_EPSILON;
257      depthToCountOfRects.set(rectDepth, countOfRectsAtSameDepth + 1);
258      return antiZFightingOffset;
259    };
260
261    let z = 0;
262    const rects3d = rects2d.map((rect2d, i): UiRect3D => {
263      const j = rects2d.length - 1 - i; // rects sorted in decreasing order of depth; increment z by L - 1 - i
264      z =
265        this.zSpacingFactor *
266        (Mapper3D.Z_SPACING_MAX * j + computeAntiZFightingOffset(j));
267
268      let darkFactor = 0;
269      if (rect2d.isVisible) {
270        darkFactor = this.isShadedByOpacity()
271          ? assertDefined(rect2d.opacity)
272          : (visibleRectsTotal - visibleRectsSoFar++) / visibleRectsTotal;
273      } else {
274        darkFactor = this.isShadedByOpacity()
275          ? 0.5
276          : (nonVisibleRectsTotal - nonVisibleRectsSoFar++) /
277            nonVisibleRectsTotal;
278      }
279      let fillRegion: Rect3D[] | undefined;
280      if (rect2d.fillRegion) {
281        fillRegion = rect2d.fillRegion.rects.map((r) => {
282          return {
283            topLeft: new Point3D(r.x, r.y, z),
284            bottomRight: new Point3D(r.x + r.w, r.y + r.h, z),
285          };
286        });
287      }
288      const transform = rect2d.transform ?? IDENTITY_MATRIX;
289
290      const rect: UiRect3D = {
291        id: rect2d.id,
292        topLeft: new Point3D(rect2d.x, rect2d.y, z),
293        bottomRight: new Point3D(rect2d.x + rect2d.w, rect2d.y + rect2d.h, z),
294        isOversized: false,
295        cornerRadius: rect2d.cornerRadius,
296        darkFactor,
297        colorType: this.getColorType(rect2d),
298        isClickable: rect2d.isClickable,
299        transform: clusterYOffset ? transform.addTy(clusterYOffset) : transform,
300        fillRegion,
301        isPinned: this.pinnedItems.some((node) => node.id === rect2d.id),
302      };
303      return this.cropOversizedRect(rect, maxDisplaySize);
304    });
305
306    return rects3d;
307  }
308
309  private getColorType(rect2d: UiRect): ColorType {
310    if (this.isHighlighted(rect2d)) {
311      if (this.isShadedByOpacity()) {
312        return ColorType.HIGHLIGHTED_WITH_OPACITY;
313      }
314      return ColorType.HIGHLIGHTED;
315    }
316    if (this.isWireFrame()) {
317      return ColorType.EMPTY;
318    }
319    if (rect2d.hasContent === true) {
320      if (this.isShadedByOpacity()) {
321        return ColorType.HAS_CONTENT_AND_OPACITY;
322      }
323      return ColorType.HAS_CONTENT;
324    }
325    if (rect2d.isVisible) {
326      if (this.isShadedByOpacity()) {
327        return ColorType.VISIBLE_WITH_OPACITY;
328      }
329      return ColorType.VISIBLE;
330    }
331    return ColorType.NOT_VISIBLE;
332  }
333
334  private getMaxDisplaySize(rects2d: UiRect[]): Size {
335    const displays = rects2d.filter((rect2d) => rect2d.isDisplay);
336
337    let maxWidth = 0;
338    let maxHeight = 0;
339    if (displays.length > 0) {
340      maxWidth = Math.max(
341        ...displays.map((rect2d): number => Math.abs(rect2d.w)),
342      );
343
344      maxHeight = Math.max(
345        ...displays.map((rect2d): number => Math.abs(rect2d.h)),
346      );
347    }
348    return {
349      width: maxWidth,
350      height: maxHeight,
351    };
352  }
353
354  private cropOversizedRect(rect3d: UiRect3D, maxDisplaySize: Size): UiRect3D {
355    // Arbitrary max size for a rect (1.5x the maximum display)
356    let maxDimension = Number.MAX_VALUE;
357    if (maxDisplaySize.height > 0) {
358      maxDimension =
359        Math.max(maxDisplaySize.width, maxDisplaySize.height) * 1.5;
360    }
361
362    const height = Math.abs(rect3d.topLeft.y - rect3d.bottomRight.y);
363    const width = Math.abs(rect3d.topLeft.x - rect3d.bottomRight.x);
364
365    if (width > maxDimension) {
366      rect3d.isOversized = true;
367      (rect3d.topLeft.x = (maxDimension - maxDisplaySize.width / 2) * -1),
368        (rect3d.bottomRight.x = maxDimension);
369    }
370    if (height > maxDimension) {
371      rect3d.isOversized = true;
372      rect3d.topLeft.y = (maxDimension - maxDisplaySize.height / 2) * -1;
373      rect3d.bottomRight.y = maxDimension;
374    }
375
376    return rect3d;
377  }
378
379  private computeLabels(rects2d: UiRect[], rects3d: UiRect3D[]): RectLabel[] {
380    const labels3d: RectLabel[] = [];
381
382    const bottomRightCorners = rects3d.map((rect) =>
383      rect.transform.transformPoint3D(rect.bottomRight),
384    );
385    const lowestYPoint = Math.max(...bottomRightCorners.map((p) => p.y));
386    const rightmostXPoint = Math.max(...bottomRightCorners.map((p) => p.x));
387
388    const cameraTiltFactor =
389      Math.sin(this.getCameraXAxisAngle()) / Mapper3D.Y_AXIS_ROTATION_FACTOR;
390    const labelTextYSpacing = Math.max(
391      ((this.onlyRenderSelectedLabel(rects2d) ? rects2d.length : 1) *
392        Mapper3D.LABEL_SPACING_MIN) /
393        Mapper3D.LABEL_SPACING_PER_RECT_FACTOR,
394      lowestYPoint / Mapper3D.LABEL_SPACING_INIT_FACTOR,
395    );
396
397    const scaleFactor = Math.max(
398      Math.min(this.zoomFactor ** 2, 1 + (8 - rects2d.length) * 0.05),
399      0.5,
400    );
401
402    let labelY = lowestYPoint + Mapper3D.LABEL_FIRST_Y_OFFSET / scaleFactor;
403    let lastDepth: number | undefined;
404
405    rects2d.forEach((rect2d, index) => {
406      if (!rect2d.label) {
407        return;
408      }
409      const j = rects2d.length - 1 - index; // rects sorted in decreasing order of depth; increment labelY by depth at L - 1 - i
410      if (this.onlyRenderSelectedLabel(rects2d)) {
411        // only render the selected rect label
412        if (!this.isHighlighted(rect2d)) {
413          return;
414        }
415        labelY +=
416          ((rects2d[j].depth / rects2d[0].depth) *
417            labelTextYSpacing *
418            Mapper3D.SINGLE_LABEL_SPACING_FACTOR *
419            this.zSpacingFactor) /
420          Math.sqrt(scaleFactor);
421      } else {
422        if (lastDepth !== undefined) {
423          labelY += ((lastDepth - j) * labelTextYSpacing) / scaleFactor;
424        }
425        lastDepth = j;
426      }
427
428      const rect3d = rects3d[index];
429
430      const bottomLeft = new Point3D(
431        rect3d.topLeft.x,
432        rect3d.topLeft.y,
433        rect3d.topLeft.z,
434      );
435      const topRight = new Point3D(
436        rect3d.bottomRight.x,
437        rect3d.bottomRight.y,
438        rect3d.bottomRight.z,
439      );
440      const lineStarts = [
441        rect3d.transform.transformPoint3D(rect3d.topLeft),
442        rect3d.transform.transformPoint3D(rect3d.bottomRight),
443        rect3d.transform.transformPoint3D(bottomLeft),
444        rect3d.transform.transformPoint3D(topRight),
445      ];
446      let maxIndex = 0;
447      for (let i = 1; i < lineStarts.length; i++) {
448        if (lineStarts[i].x > lineStarts[maxIndex].x) {
449          maxIndex = i;
450        }
451      }
452      const lineStart = lineStarts[maxIndex];
453
454      const xDiff = rightmostXPoint - lineStart.x;
455
456      lineStart.x += Mapper3D.LABEL_CIRCLE_RADIUS / 2;
457
458      const lineEnd = new Point3D(
459        lineStart.x,
460        labelY + xDiff * cameraTiltFactor,
461        lineStart.z,
462      );
463
464      const isHighlighted = this.isHighlighted(rect2d);
465
466      const RectLabel: RectLabel = {
467        circle: {
468          radius: Mapper3D.LABEL_CIRCLE_RADIUS,
469          center: new Point3D(lineStart.x, lineStart.y, lineStart.z + 0.5),
470        },
471        linePoints: [lineStart, lineEnd],
472        textCenter: lineEnd,
473        text: rect2d.label,
474        isHighlighted,
475        rectId: rect2d.id,
476      };
477      labels3d.push(RectLabel);
478    });
479
480    return labels3d;
481  }
482
483  private computeBoundingBox(rects: UiRect3D[], labels: RectLabel[]): Box3D {
484    if (rects.length === 0) {
485      return {
486        width: 1,
487        height: 1,
488        depth: 1,
489        center: new Point3D(0, 0, 0),
490        diagonal: Math.sqrt(3),
491      };
492    }
493
494    let minX = Number.MAX_VALUE;
495    let maxX = Number.MIN_VALUE;
496    let minY = Number.MAX_VALUE;
497    let maxY = Number.MIN_VALUE;
498    let minZ = Number.MAX_VALUE;
499    let maxZ = Number.MIN_VALUE;
500
501    const updateMinMaxCoordinates = (
502      point: Point3D,
503      transform?: TransformMatrix,
504    ) => {
505      const transformedPoint = transform?.transformPoint3D(point) ?? point;
506      minX = Math.min(minX, transformedPoint.x);
507      maxX = Math.max(maxX, transformedPoint.x);
508      minY = Math.min(minY, transformedPoint.y);
509      maxY = Math.max(maxY, transformedPoint.y);
510      minZ = Math.min(minZ, transformedPoint.z);
511      maxZ = Math.max(maxZ, transformedPoint.z);
512    };
513
514    rects.forEach((rect) => {
515      /*const topLeft: Point3D = {
516        x: rect.center.x - rect.width / 2,
517        y: rect.center.y + rect.height / 2,
518        z: rect.center.z
519      };
520      const bottomRight: Point3D = {
521        x: rect.center.x + rect.width / 2,
522        y: rect.center.y - rect.height / 2,
523        z: rect.center.z
524      };*/
525      updateMinMaxCoordinates(rect.topLeft, rect.transform);
526      updateMinMaxCoordinates(rect.bottomRight, rect.transform);
527    });
528
529    // if multiple labels rendered, include first 10 in bounding box
530    if (!this.onlyRenderSelectedLabel(rects)) {
531      labels.slice(0, 10).forEach((label) => {
532        label.linePoints.forEach((point) => {
533          updateMinMaxCoordinates(point);
534        });
535      });
536    }
537
538    const center = new Point3D(
539      (minX + maxX) / 2,
540      (minY + maxY) / 2,
541      (minZ + maxZ) / 2,
542    );
543
544    const width = (maxX - minX) * 1.1;
545    const height = (maxY - minY) * 1.1;
546    const depth = (maxZ - minZ) * 1.1;
547
548    return {
549      width,
550      height,
551      depth,
552      center,
553      diagonal: Math.sqrt(width * width + height * height + depth * depth),
554    };
555  }
556
557  isHighlighted(rect: UiRect): boolean {
558    return rect.isClickable && this.highlightedRectId === rect.id;
559  }
560
561  private onlyRenderSelectedLabel(rects: Array<UiRect | UiRect3D>): boolean {
562    return (
563      rects.length > Mapper3D.MAX_RENDERED_LABELS ||
564      this.currentGroupIds.length > 1
565    );
566  }
567}
568
569export {Mapper3D};
570