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