xref: /aosp_15_r20/development/tools/winscope/src/viewers/components/rects/mapper3d.ts (revision 90c8c64db3049935a07c6143d7fd006e26f8ecca)
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 =
275          (nonVisibleRectsTotal - nonVisibleRectsSoFar++) /
276          nonVisibleRectsTotal;
277      }
278      let fillRegion: Rect3D[] | undefined;
279      if (rect2d.fillRegion) {
280        fillRegion = rect2d.fillRegion.rects.map((r) => {
281          return {
282            topLeft: new Point3D(r.x, r.y, z),
283            bottomRight: new Point3D(r.x + r.w, r.y + r.h, z),
284          };
285        });
286      }
287      const transform = rect2d.transform ?? IDENTITY_MATRIX;
288
289      const rect: UiRect3D = {
290        id: rect2d.id,
291        topLeft: new Point3D(rect2d.x, rect2d.y, z),
292        bottomRight: new Point3D(rect2d.x + rect2d.w, rect2d.y + rect2d.h, z),
293        isOversized: false,
294        cornerRadius: rect2d.cornerRadius,
295        darkFactor,
296        colorType: this.getColorType(rect2d),
297        isClickable: rect2d.isClickable,
298        transform: clusterYOffset ? transform.addTy(clusterYOffset) : transform,
299        fillRegion,
300        isPinned: this.pinnedItems.some((node) => node.id === rect2d.id),
301      };
302      return this.cropOversizedRect(rect, maxDisplaySize);
303    });
304
305    return rects3d;
306  }
307
308  private getColorType(rect2d: UiRect): ColorType {
309    if (this.isHighlighted(rect2d)) {
310      return ColorType.HIGHLIGHTED;
311    }
312    if (this.isWireFrame()) {
313      return ColorType.EMPTY;
314    }
315    if (rect2d.hasContent === true) {
316      if (this.isShadedByOpacity()) {
317        return ColorType.HAS_CONTENT_AND_OPACITY;
318      }
319      return ColorType.HAS_CONTENT;
320    }
321    if (rect2d.isVisible) {
322      if (this.isShadedByOpacity()) {
323        return ColorType.VISIBLE_WITH_OPACITY;
324      }
325      return ColorType.VISIBLE;
326    }
327    return ColorType.NOT_VISIBLE;
328  }
329
330  private getMaxDisplaySize(rects2d: UiRect[]): Size {
331    const displays = rects2d.filter((rect2d) => rect2d.isDisplay);
332
333    let maxWidth = 0;
334    let maxHeight = 0;
335    if (displays.length > 0) {
336      maxWidth = Math.max(
337        ...displays.map((rect2d): number => Math.abs(rect2d.w)),
338      );
339
340      maxHeight = Math.max(
341        ...displays.map((rect2d): number => Math.abs(rect2d.h)),
342      );
343    }
344    return {
345      width: maxWidth,
346      height: maxHeight,
347    };
348  }
349
350  private cropOversizedRect(rect3d: UiRect3D, maxDisplaySize: Size): UiRect3D {
351    // Arbitrary max size for a rect (2x the maximum display)
352    let maxDimension = Number.MAX_VALUE;
353    if (maxDisplaySize.height > 0) {
354      maxDimension = Math.max(maxDisplaySize.width, maxDisplaySize.height) * 2;
355    }
356
357    const height = Math.abs(rect3d.topLeft.y - rect3d.bottomRight.y);
358    const width = Math.abs(rect3d.topLeft.x - rect3d.bottomRight.x);
359
360    if (width > maxDimension) {
361      rect3d.isOversized = true;
362      (rect3d.topLeft.x = (maxDimension - maxDisplaySize.width / 2) * -1),
363        (rect3d.bottomRight.x = maxDimension);
364    }
365    if (height > maxDimension) {
366      rect3d.isOversized = true;
367      rect3d.topLeft.y = (maxDimension - maxDisplaySize.height / 2) * -1;
368      rect3d.bottomRight.y = maxDimension;
369    }
370
371    return rect3d;
372  }
373
374  private computeLabels(rects2d: UiRect[], rects3d: UiRect3D[]): RectLabel[] {
375    const labels3d: RectLabel[] = [];
376
377    const bottomRightCorners = rects3d.map((rect) =>
378      rect.transform.transformPoint3D(rect.bottomRight),
379    );
380    const lowestYPoint = Math.max(...bottomRightCorners.map((p) => p.y));
381    const rightmostXPoint = Math.max(...bottomRightCorners.map((p) => p.x));
382
383    const cameraTiltFactor =
384      Math.sin(this.getCameraXAxisAngle()) / Mapper3D.Y_AXIS_ROTATION_FACTOR;
385    const labelTextYSpacing = Math.max(
386      ((this.onlyRenderSelectedLabel(rects2d) ? rects2d.length : 1) *
387        Mapper3D.LABEL_SPACING_MIN) /
388        Mapper3D.LABEL_SPACING_PER_RECT_FACTOR,
389      lowestYPoint / Mapper3D.LABEL_SPACING_INIT_FACTOR,
390    );
391
392    const scaleFactor = Math.max(
393      Math.min(this.zoomFactor ** 2, 1 + (8 - rects2d.length) * 0.05),
394      0.5,
395    );
396
397    let labelY = lowestYPoint + Mapper3D.LABEL_FIRST_Y_OFFSET / scaleFactor;
398    let lastDepth: number | undefined;
399
400    rects2d.forEach((rect2d, index) => {
401      if (!rect2d.label) {
402        return;
403      }
404      const j = rects2d.length - 1 - index; // rects sorted in decreasing order of depth; increment labelY by depth at L - 1 - i
405      if (this.onlyRenderSelectedLabel(rects2d)) {
406        // only render the selected rect label
407        if (!this.isHighlighted(rect2d)) {
408          return;
409        }
410        labelY +=
411          ((rects2d[j].depth / rects2d[0].depth) *
412            labelTextYSpacing *
413            Mapper3D.SINGLE_LABEL_SPACING_FACTOR *
414            this.zSpacingFactor) /
415          Math.sqrt(scaleFactor);
416      } else {
417        if (lastDepth !== undefined) {
418          labelY += ((lastDepth - j) * labelTextYSpacing) / scaleFactor;
419        }
420        lastDepth = j;
421      }
422
423      const rect3d = rects3d[index];
424
425      const bottomLeft = new Point3D(
426        rect3d.topLeft.x,
427        rect3d.topLeft.y,
428        rect3d.topLeft.z,
429      );
430      const topRight = new Point3D(
431        rect3d.bottomRight.x,
432        rect3d.bottomRight.y,
433        rect3d.bottomRight.z,
434      );
435      const lineStarts = [
436        rect3d.transform.transformPoint3D(rect3d.topLeft),
437        rect3d.transform.transformPoint3D(rect3d.bottomRight),
438        rect3d.transform.transformPoint3D(bottomLeft),
439        rect3d.transform.transformPoint3D(topRight),
440      ];
441      let maxIndex = 0;
442      for (let i = 1; i < lineStarts.length; i++) {
443        if (lineStarts[i].x > lineStarts[maxIndex].x) {
444          maxIndex = i;
445        }
446      }
447      const lineStart = lineStarts[maxIndex];
448
449      const xDiff = rightmostXPoint - lineStart.x;
450
451      lineStart.x += Mapper3D.LABEL_CIRCLE_RADIUS / 2;
452
453      const lineEnd = new Point3D(
454        lineStart.x,
455        labelY + xDiff * cameraTiltFactor,
456        lineStart.z,
457      );
458
459      const isHighlighted = this.isHighlighted(rect2d);
460
461      const RectLabel: RectLabel = {
462        circle: {
463          radius: Mapper3D.LABEL_CIRCLE_RADIUS,
464          center: new Point3D(lineStart.x, lineStart.y, lineStart.z + 0.5),
465        },
466        linePoints: [lineStart, lineEnd],
467        textCenter: lineEnd,
468        text: rect2d.label,
469        isHighlighted,
470        rectId: rect2d.id,
471      };
472      labels3d.push(RectLabel);
473    });
474
475    return labels3d;
476  }
477
478  private computeBoundingBox(rects: UiRect3D[], labels: RectLabel[]): Box3D {
479    if (rects.length === 0) {
480      return {
481        width: 1,
482        height: 1,
483        depth: 1,
484        center: new Point3D(0, 0, 0),
485        diagonal: Math.sqrt(3),
486      };
487    }
488
489    let minX = Number.MAX_VALUE;
490    let maxX = Number.MIN_VALUE;
491    let minY = Number.MAX_VALUE;
492    let maxY = Number.MIN_VALUE;
493    let minZ = Number.MAX_VALUE;
494    let maxZ = Number.MIN_VALUE;
495
496    const updateMinMaxCoordinates = (
497      point: Point3D,
498      transform?: TransformMatrix,
499    ) => {
500      const transformedPoint = transform?.transformPoint3D(point) ?? point;
501      minX = Math.min(minX, transformedPoint.x);
502      maxX = Math.max(maxX, transformedPoint.x);
503      minY = Math.min(minY, transformedPoint.y);
504      maxY = Math.max(maxY, transformedPoint.y);
505      minZ = Math.min(minZ, transformedPoint.z);
506      maxZ = Math.max(maxZ, transformedPoint.z);
507    };
508
509    rects.forEach((rect) => {
510      /*const topLeft: Point3D = {
511        x: rect.center.x - rect.width / 2,
512        y: rect.center.y + rect.height / 2,
513        z: rect.center.z
514      };
515      const bottomRight: Point3D = {
516        x: rect.center.x + rect.width / 2,
517        y: rect.center.y - rect.height / 2,
518        z: rect.center.z
519      };*/
520      updateMinMaxCoordinates(rect.topLeft, rect.transform);
521      updateMinMaxCoordinates(rect.bottomRight, rect.transform);
522    });
523
524    // if multiple labels rendered, include first 10 in bounding box
525    if (!this.onlyRenderSelectedLabel(rects)) {
526      labels.slice(0, 10).forEach((label) => {
527        label.linePoints.forEach((point) => {
528          updateMinMaxCoordinates(point);
529        });
530      });
531    }
532
533    const center = new Point3D(
534      (minX + maxX) / 2,
535      (minY + maxY) / 2,
536      (minZ + maxZ) / 2,
537    );
538
539    const width = (maxX - minX) * 1.1;
540    const height = (maxY - minY) * 1.1;
541    const depth = (maxZ - minZ) * 1.1;
542
543    return {
544      width,
545      height,
546      depth,
547      center,
548      diagonal: Math.sqrt(width * width + height * height + depth * depth),
549    };
550  }
551
552  isHighlighted(rect: UiRect): boolean {
553    return rect.isClickable && this.highlightedRectId === rect.id;
554  }
555
556  private onlyRenderSelectedLabel(rects: Array<UiRect | UiRect3D>): boolean {
557    return (
558      rects.length > Mapper3D.MAX_RENDERED_LABELS ||
559      this.currentGroupIds.length > 1
560    );
561  }
562}
563
564export {Mapper3D};
565