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