@smarterplan/ngx-smarterplan-core 1.4.20 → 1.4.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/esm2022/lib/components/splat-viewer/splat-viewer.component.mjs +361 -0
  2. package/esm2022/lib/matterport-extensions/security-camera/SecurityCamera.mjs +28 -7
  3. package/esm2022/lib/ngx-smarterplan-core.module.mjs +9 -4
  4. package/esm2022/lib/services/matterport-object3d.service.mjs +657 -133
  5. package/esm2022/lib/services/matterport-tag.service.mjs +12 -7
  6. package/esm2022/lib/services/matterport.service.mjs +330 -9
  7. package/esm2022/lib/services/obj-to-glb.mjs +649 -0
  8. package/esm2022/lib/services/s3.service.mjs +43 -3
  9. package/esm2022/lib/services/threejs-import.service.mjs +6 -4
  10. package/esm2022/lib/services/threejs.service.mjs +867 -95
  11. package/esm2022/lib/services/utils/matterport-framing.util.mjs +63 -0
  12. package/esm2022/lib/services/viewer.service.mjs +189 -7
  13. package/esm2022/lib/types.service.mjs +3 -1
  14. package/esm2022/public-api.mjs +3 -1
  15. package/fesm2022/smarterplan-ngx-smarterplan-core.mjs +3217 -282
  16. package/fesm2022/smarterplan-ngx-smarterplan-core.mjs.map +1 -1
  17. package/lib/components/splat-viewer/splat-viewer.component.d.ts +98 -0
  18. package/lib/ngx-smarterplan-core.module.d.ts +7 -6
  19. package/lib/services/matterport-object3d.service.d.ts +125 -5
  20. package/lib/services/matterport-tag.service.d.ts +5 -5
  21. package/lib/services/matterport.service.d.ts +106 -7
  22. package/lib/services/obj-to-glb.d.ts +66 -0
  23. package/lib/services/s3.service.d.ts +7 -0
  24. package/lib/services/threejs.service.d.ts +336 -16
  25. package/lib/services/utils/matterport-framing.util.d.ts +40 -0
  26. package/lib/services/viewer.service.d.ts +110 -8
  27. package/lib/types.service.d.ts +42 -1
  28. package/package.json +1 -1
  29. package/public-api.d.ts +2 -0
  30. package/src/assets/viewers/splat/chunk-BYLI4YBI.js +17037 -0
  31. package/src/assets/viewers/splat/chunk-LDV6YPMG.js +1 -0
  32. package/src/assets/viewers/splat/chunk-WZ5Z4TU3.js +105719 -0
  33. package/src/assets/viewers/splat/convert-4DRLH77X.js +1 -0
  34. package/src/assets/viewers/splat/convert.html +11 -0
  35. package/src/assets/viewers/splat/converter-SLBB5MHG.js +1 -0
  36. package/src/assets/viewers/splat/index.html +90 -0
  37. package/src/assets/viewers/splat/spz-DEYRea_v-QE6YSGIB.js +5292 -0
  38. package/src/assets/viewers/splat/viewer-W4X3FWQJ.js +2704 -0
  39. package/src/assets/viewers/splat/viewer.css +1096 -0
  40. package/src/assets/viewers/splat/webp.wasm +0 -0
  41. package/src/assets/viewers/splat/worker.mjs +1383 -0
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Injectable, Input, Component, InjectionToken, Inject, EventEmitter, isDevMode, Output, Pipe, ViewChild, NgModule } from '@angular/core';
2
+ import { Injectable, Input, Component, InjectionToken, Inject, EventEmitter, isDevMode, Output, Pipe, ViewChild, HostListener, NgModule } from '@angular/core';
3
3
  import * as i1 from '@ngx-translate/core';
4
4
  import { TranslateModule } from '@ngx-translate/core';
5
5
  import { Subject, BehaviorSubject, takeUntil, Observable } from 'rxjs';
@@ -8,7 +8,7 @@ import { NgbDate, NgbModule } from '@ng-bootstrap/ng-bootstrap';
8
8
  import * as i1$1 from '@angular/router';
9
9
  import { RouterModule } from '@angular/router';
10
10
  import * as THREE from 'three';
11
- import { Matrix4, Vector3, Color, Object3D, Euler, PerspectiveCamera, CylinderBufferGeometry, BoxGeometry, MeshBasicMaterial, NormalBlending, BackSide, Mesh, EdgesGeometry, AdditiveBlending, ShaderMaterial, AnimationMixer, VectorKeyframeTrack, InterpolateSmooth, AnimationClip, LoopOnce, Group, PlaneBufferGeometry, CanvasTexture, Texture, LinearFilter } from 'three';
11
+ import { Matrix4, Vector3, Color, Object3D, Euler, PerspectiveCamera, CylinderBufferGeometry, BoxGeometry, MeshBasicMaterial, NormalBlending, BackSide, Mesh, EdgesGeometry, AdditiveBlending, ShaderMaterial, AnimationMixer, VectorKeyframeTrack, InterpolateSmooth, AnimationClip, LoopOnce, Group, PlaneBufferGeometry, CanvasTexture, Texture, LinearFilter, Box3, MathUtils } from 'three';
12
12
  import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js';
13
13
  import { Storage, Auth, API, graphqlOperation, Cache } from 'aws-amplify';
14
14
  import { DateTime } from 'luxon';
@@ -386,6 +386,8 @@ var ViewerInteractions;
386
386
  ViewerInteractions[ViewerInteractions["DEFAULT"] = 0] = "DEFAULT";
387
387
  ViewerInteractions[ViewerInteractions["ADDING"] = 1] = "ADDING";
388
388
  ViewerInteractions[ViewerInteractions["POSITIONING"] = 2] = "POSITIONING";
389
+ /** A loaded 3D object follows the cursor until the user clicks (validate) or cancels */
390
+ ViewerInteractions[ViewerInteractions["PLACING_OBJECT3D"] = 3] = "PLACING_OBJECT3D";
389
391
  })(ViewerInteractions || (ViewerInteractions = {}));
390
392
  class ProfileEntity {
391
393
  id;
@@ -633,7 +635,9 @@ class SecurityCamera extends SceneComponent {
633
635
  this.makeHighlight();
634
636
  // attach highighting to the mesh it represents
635
637
  // TODO (scene query): this is very brittle but is the only way to attach something to a room of the model
636
- this.outputs.objectRoot.parent.parent.add(this.highlight);
638
+ if (this.highlight) {
639
+ this.outputs.objectRoot.parent.parent.add(this.highlight);
640
+ }
637
641
  // create the animation mixer to rotate the camera
638
642
  this.makeAnimation();
639
643
  }
@@ -672,6 +676,12 @@ class SecurityCamera extends SceneComponent {
672
676
  this.edges = null;
673
677
  }
674
678
  function edgesToCylinders(edgesGeometry, thickness) {
679
+ // API to be updated in bundle - JSSDK-1222
680
+ const merge = THREE.mergeBufferGeometries ?? THREE.BufferGeometryUtils?.mergeBufferGeometries;
681
+ // not exported by the three.js of recent SDK bundles: the caller falls back to plain lines
682
+ if (!merge)
683
+ return null;
684
+ const Cylinder = THREE.CylinderGeometry ?? THREE.CylinderBufferGeometry;
675
685
  const { position } = edgesGeometry.attributes;
676
686
  const { array, count } = position;
677
687
  const r = thickness / 2;
@@ -681,15 +691,14 @@ class SecurityCamera extends SceneComponent {
681
691
  const b = new THREE.Vector3(array[i + 3], array[i + 4], array[i + 5]);
682
692
  const vec = new THREE.Vector3().subVectors(b, a);
683
693
  const len = vec.length();
684
- const geom = new THREE.CylinderBufferGeometry(r, r, len, 8);
694
+ const geom = new Cylinder(r, r, len, 8);
685
695
  geom.translate(0, len / 2, 0);
686
696
  geom.rotateX(Math.PI / 2);
687
697
  geom.lookAt(vec);
688
698
  geom.translate(a.x, a.y, a.z);
689
699
  geoms.push(geom);
690
700
  }
691
- // API to be updated in bundle - JSSDK-1222
692
- return THREE.mergeBufferGeometries(geoms);
701
+ return merge(geoms);
693
702
  }
694
703
  const frustumLength = this.inputs.farPlane - this.inputs.nearPlane;
695
704
  const boxGeometry = new THREE.BoxGeometry(2, 2, frustumLength);
@@ -725,6 +734,16 @@ class SecurityCamera extends SceneComponent {
725
734
  });
726
735
  this.box = new THREE.Mesh(boxGeometry, boxMaterial);
727
736
  const edgesGeometry = edgesToCylinders(new THREE.EdgesGeometry(boxGeometry), 0.015);
737
+ if (!edgesGeometry) {
738
+ // thick edges need mergeBufferGeometries: draw the frustum with lines
739
+ this.edges = new THREE.LineSegments(new THREE.EdgesGeometry(boxGeometry), new THREE.LineBasicMaterial({
740
+ color: this.inputs.color,
741
+ opacity: 0.8,
742
+ transparent: true,
743
+ depthWrite: false,
744
+ }));
745
+ return;
746
+ }
728
747
  this.edges = new THREE.Mesh(edgesGeometry, new THREE.MeshBasicMaterial({
729
748
  color: 0xffffff,
730
749
  opacity: 0.25,
@@ -745,8 +764,12 @@ class SecurityCamera extends SceneComponent {
745
764
  makeHighlight() {
746
765
  const THREE = this.context.three;
747
766
  // TODO (scene query): this is very brittle and hardcoded but is the only way to get room geometries until we have a better way to query the scene graph
748
- const floorMesh = this.outputs.objectRoot.parent.parent.getObjectByName('FloorMesh:0');
749
- const roomMesh = floorMesh.children[0];
767
+ const floorMesh = this.outputs.objectRoot.parent?.parent?.getObjectByName('FloorMesh:0');
768
+ const roomMesh = floorMesh?.children[0];
769
+ if (!roomMesh?.geometry) {
770
+ // no room mesh found in the scene graph: the frustum is shown without the highlight
771
+ return;
772
+ }
750
773
  updateHighlightUniforms$1(this.projector, this.highlightUniforms);
751
774
  this.highlightUniforms.color.value.setHex(this.inputs.color);
752
775
  const shader = new THREE.ShaderMaterial({
@@ -1072,22 +1095,62 @@ const uploadFileToS3 = async (path, file, objectId) => {
1072
1095
  return result.key;
1073
1096
  });
1074
1097
  };
1098
+ /**
1099
+ * Fetches fresh credentials, and says in the console how long the ones held had left: when an
1100
+ * upload is refused, this is what tells why.
1101
+ */
1102
+ const renewCredentials = async () => {
1103
+ try {
1104
+ const held = await Auth.currentCredentials();
1105
+ const left = held?.expiration ? new Date(held.expiration).getTime() - Date.now() : 0;
1106
+ console.info(`[upload] credentials held expire in ${Math.round(left / 60000)} min, fetching new ones`);
1107
+ // What Amplify itself does on signing in. Nobody signs in here through a federated provider,
1108
+ // whose stored sign-in this would also drop.
1109
+ await Auth.Credentials.clear();
1110
+ await Auth.currentCredentials();
1111
+ }
1112
+ catch (error) {
1113
+ // The upload will say what is wrong, if anything is.
1114
+ console.warn("[upload] could not renew the credentials:", error);
1115
+ }
1116
+ };
1117
+ const isExpiredTokenError = (error) => /ExpiredToken|token has expired/i.test(`${error?.name ?? ""} ${error?.Code ?? ""} ${error?.message ?? error ?? ""}`);
1075
1118
  /**
1076
1119
  * Uploads a file, reporting progress as a 0-1 fraction.
1077
1120
  *
1078
1121
  * uploadFileToS3 above swallows progress, which is fine for a photo and unusable for a 3D model:
1079
1122
  * these run to hundreds of megabytes, and an upload with no feedback is indistinguishable from a
1080
1123
  * frozen page. Returns the stored key, which is what gets saved on the record.
1124
+ *
1125
+ * A large file is sent in parts, each signed when it is sent. Left to itself, Storage.put signs
1126
+ * them all with whatever credentials Amplify last handed the S3 provider, for any call - credentials
1127
+ * that last an hour at most, and an upload of a few gigabytes takes longer: every part after they
1128
+ * expire is refused with "The provided token has expired", and fetching new ones does not help, the
1129
+ * upload never asks for them. Passing no credentials at all makes it ask Amplify for them part by
1130
+ * part, and Amplify renews them as they age. Should they run out anyway, it starts once more.
1081
1131
  */
1082
1132
  const uploadFileToS3WithProgress = async (path, file, onProgress) => {
1083
- return Storage.put(path + file.name, file, {
1133
+ const put = () => Storage.put(path + file.name, file, {
1084
1134
  contentType: file.type || "application/octet-stream",
1135
+ // Present and empty, so that it replaces the provider's own; see above.
1136
+ credentials: undefined,
1085
1137
  progressCallback(progress) {
1086
1138
  if (onProgress && progress.total > 0) {
1087
1139
  onProgress(progress.loaded / progress.total);
1088
1140
  }
1089
1141
  },
1090
1142
  }).then((result) => result.key);
1143
+ try {
1144
+ return await put();
1145
+ }
1146
+ catch (error) {
1147
+ if (!isExpiredTokenError(error)) {
1148
+ throw error;
1149
+ }
1150
+ console.warn("[upload] refused, the token had expired; starting again with new credentials", error);
1151
+ await renewCredentials();
1152
+ return put();
1153
+ }
1091
1154
  };
1092
1155
  const getSignedImageUrlForEquipment = async (equip) => {
1093
1156
  if (!equip.photo) {
@@ -2293,6 +2356,69 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2293
2356
  args: ['currentAwsKinesisAnalyticsService']
2294
2357
  }] }, { type: BaseUserService }, { type: ZoneChangeService }] });
2295
2358
 
2359
+ // matterport-framing.util.ts
2360
+ /**
2361
+ * Camera rotation that shows `target` from `camera`, at a given horizontal place of the viewer.
2362
+ * Matterport yaw is 0 when looking toward -Z and grows when turning left; pitch is positive upward.
2363
+ * @param horizontalTanHalfFov tan(horizontal FOV / 2), i.e. 1 / projection[0] of the camera pose
2364
+ * @param screenX where the target must appear, from -1 (left edge) to 1 (right edge); 0 = center.
2365
+ * Used when part of the viewer is covered (side panel), to frame the visible area.
2366
+ */
2367
+ function computeFramingRotation(camera, target, horizontalTanHalfFov = 1, screenX = 0) {
2368
+ const dx = target.x - camera.x;
2369
+ const dy = target.y - camera.y;
2370
+ const dz = target.z - camera.z;
2371
+ const yawToTarget = Math.atan2(-dx, -dz);
2372
+ // the target is seen at screenX when the camera looks that much to its side
2373
+ const offset = Math.atan(screenX * horizontalTanHalfFov);
2374
+ return {
2375
+ x: toDegrees(Math.atan2(dy, Math.hypot(dx, dz))),
2376
+ y: toDegrees(yawToTarget + offset),
2377
+ };
2378
+ }
2379
+ /**
2380
+ * Horizontal place (-1..1) of the center of the visible part of the viewer.
2381
+ * @param viewer client rect of the viewer iframe
2382
+ * @param visibleLeft / visibleRight client X bounds of the part not covered by other panels
2383
+ */
2384
+ function visibleCenterScreenX(viewer, visibleLeft, visibleRight) {
2385
+ if (!viewer.width)
2386
+ return 0;
2387
+ const left = Math.max(viewer.left, visibleLeft);
2388
+ const right = Math.min(viewer.left + viewer.width, visibleRight);
2389
+ if (right <= left)
2390
+ return 0;
2391
+ const center = (left + right) / 2;
2392
+ return ((center - viewer.left) / viewer.width) * 2 - 1;
2393
+ }
2394
+ function toDegrees(rad) {
2395
+ return (rad * 180) / Math.PI;
2396
+ }
2397
+ /** Height of the floor under a sweep: its puck, or 1.5 m under the camera when the SDK gives none */
2398
+ function sweepFloorY(sweep) {
2399
+ return sweep.puckPosition?.y ?? sweep.floorPosition?.y ?? sweep.position.y - 1.5;
2400
+ }
2401
+ /**
2402
+ * Sweep closest to an object placed at `position`, among the sweeps of the same storey: the camera
2403
+ * of the storey below is often nearer in 3D than a sweep of the same room. A sweep is on the same
2404
+ * storey when the object stands between 0.5 m under its floor and 3 m above it; without any such
2405
+ * sweep, all of them are considered. Distances are measured horizontally.
2406
+ */
2407
+ function pickNearestSweepOnSameStorey(sweeps, position) {
2408
+ const sameStorey = sweeps.filter((s) => position.y >= s.floorY - 0.5 && position.y < s.floorY + 3);
2409
+ const candidates = sameStorey.length > 0 ? sameStorey : sweeps;
2410
+ let best = null;
2411
+ let bestDist = Infinity;
2412
+ for (const sweep of candidates) {
2413
+ const dist = Math.hypot(sweep.position.x - position.x, sweep.position.z - position.z);
2414
+ if (dist < bestDist) {
2415
+ bestDist = dist;
2416
+ best = sweep.sid;
2417
+ }
2418
+ }
2419
+ return best;
2420
+ }
2421
+
2296
2422
  /* eslint-disable unicorn/filename-case */
2297
2423
  class MattertagData {
2298
2424
  object;
@@ -3463,6 +3589,28 @@ class MatterportObject3DService {
3463
3589
  azimuthalCrown;
3464
3590
  threeJSScene;
3465
3591
  noLightForObjects = true;
3592
+ /** Max time (ms) to wait for a model to be loaded by the SDK */
3593
+ loadTimeoutMs = 15000;
3594
+ /** Max distance (m) an object can travel from where a translate drag started */
3595
+ maxDragDistance = 2;
3596
+ /** Max vertical travel (m, up and down) during a translate drag, so an object cannot jump to another floor */
3597
+ maxDragHeight = 1.5;
3598
+ /** Emits on every gizmo change and on setObject3DTransform */
3599
+ object3DTransformChanged$ = new Subject();
3600
+ /** Security camera animators by object uuid (`securityCameraAnimator` stays the last loaded one) */
3601
+ securityCameraAnimators = new Map();
3602
+ viewFrustumVisible = new Map();
3603
+ lastSecurityCameraUuid = null;
3604
+ /** Loader component of each model (outputs.objectRoot is set once loaded) */
3605
+ loaderComponents = new Map();
3606
+ gizmos = new Map();
3607
+ activeGizmoUuid = null;
3608
+ gizmoSnap = null;
3609
+ /** Sweeps disabled by setNavigationLocked(true), re-enabled on unlock */
3610
+ sweepsDisabledByLock = [];
3611
+ /** Sweeps re-enabled by the last unlock (the SDK may still report them disabled for a moment) */
3612
+ sweepsEnabledByUnlock = [];
3613
+ tmpBox = new Box3();
3466
3614
  constructor() { }
3467
3615
  async init3DObjectViewer(sdk) {
3468
3616
  const [sceneObject] = await sdk.Scene.createObjects(1);
@@ -3473,112 +3621,184 @@ class MatterportObject3DService {
3473
3621
  this.objectControl = nodeControl.addComponent('mp.transformControls');
3474
3622
  nodeControl.start();
3475
3623
  }
3476
- async add3DObject(sdk, obj, mode) {
3477
- return new Promise(async (resolve) => {
3478
- const [sceneObject] = await sdk.Scene.createObjects(1);
3479
- let isAnimatedSecurityCamera = (obj.object === "security_camera");
3480
- let isNestThermostat = (obj.object === "nest_thermostat");
3481
- let isSmarterplanPromotionalVideo = (obj.object === 'video');
3482
- let isAzimuthalCrown = (obj.object === 'azimuth');
3483
- const modelNode = sceneObject.addNode();
3484
- let component = null;
3485
- const initial = {
3486
- url: `/assets/3Dobjects/objects/${obj.object}${obj.format.indexOf('.') === -1 ? '.' + obj.format : obj.format}`,
3487
- localRotation: { "x": 0, "y": 0, "z": 0 },
3488
- localPosition: { "x": 0, "y": 0, "z": 0 },
3489
- visible: true,
3490
- colliderEnabled: true
3491
- };
3492
- switch (obj.format) {
3493
- case '.obj':
3494
- case 'obj':
3495
- component = modelNode.addComponent('mp.objLoader', initial);
3496
- break;
3497
- case '.fbx':
3498
- case 'fbx':
3499
- component = modelNode.addComponent('mp.fbxLoader', initial);
3500
- break;
3501
- case '.gltf':
3502
- case 'gltf':
3503
- case '.glb':
3504
- case 'glb':
3505
- component = modelNode.addComponent('mp.gltfLoader', initial);
3506
- break;
3507
- case '.dae':
3508
- case 'dae':
3509
- component = modelNode.addComponent('mp.daeLoader', initial);
3510
- break;
3511
- default:
3512
- console.log('Format not supported');
3513
- break;
3624
+ /** SDK loader component name for a model format, null when the format is not supported */
3625
+ getLoaderComponentName(format) {
3626
+ switch ((format || '').toLowerCase()) {
3627
+ case '.obj':
3628
+ case 'obj': return 'mp.objLoader';
3629
+ case '.fbx':
3630
+ case 'fbx': return 'mp.fbxLoader';
3631
+ case '.gltf':
3632
+ case 'gltf':
3633
+ case '.glb':
3634
+ case 'glb': return 'mp.gltfLoader';
3635
+ case '.dae':
3636
+ case 'dae': return 'mp.daeLoader';
3637
+ default: return null;
3638
+ }
3639
+ }
3640
+ /** Resolves once the loader component has produced its objectRoot, rejects after `timeoutMs` */
3641
+ waitForModelLoaded(component, timeoutMs) {
3642
+ return new Promise((resolve, reject) => {
3643
+ const isLoaded = () => !!component?.outputs?.objectRoot;
3644
+ if (isLoaded()) {
3645
+ resolve();
3646
+ return;
3514
3647
  }
3515
- if (this.noLightForObjects) {
3516
- const lightsNode = sceneObject.addNode();
3517
- lightsNode.addComponent('mp.ambientLight', {
3518
- intensity: 1,
3519
- color: { r: 1.0, g: 1.0, b: 1.0 },
3520
- });
3521
- this.noLightForObjects = false;
3522
- }
3523
- modelNode.obj3D.position.set(obj.position.x, obj.position.y, obj.position.z);
3524
- modelNode.obj3D.rotation.set(obj.rotation.x, obj.rotation.y, obj.rotation.z);
3525
- modelNode.obj3D.scale.set(obj.scale.x, obj.scale.y, obj.scale.z);
3526
- if (isAzimuthalCrown) {
3527
- this.azimuthalCrown = modelNode;
3528
- }
3529
- if (mode && !isNestThermostat) {
3530
- this.attachGizmoControlTo3DObject(sdk, modelNode, sceneObject, mode, true, true).catch((e) => console.log(e));
3531
- }
3532
- if (this.lastObject3D && typeof this.lastObject3D.id === 'string') {
3533
- modelNode.obj3D.uuid = this.lastObject3D.id;
3534
- }
3535
- else if (obj.id) {
3536
- modelNode.obj3D.uuid = obj.id;
3537
- }
3538
- this.lastObject3D = modelNode.obj3D;
3539
- this.dictionnaryObjects3D.set(modelNode.obj3D.uuid, modelNode);
3540
- this.dictionnarySceneObjects3D.set(modelNode.obj3D.uuid, sceneObject);
3541
- if (isAnimatedSecurityCamera) {
3542
- const animator = modelNode.addComponent('mp.securityCamera', {
3543
- "nearPlane": 0.1, "farPlane": 10, "horizontalFOV": 52, "aspect": 1.7777777777777777,
3544
- "localPosition": { "x": 0.3, "y": 0.18, "z": 0 },
3545
- "localRotation": { "x": -15, "y": -90, "z": 0 },
3546
- "color": 65280, "panPeriod": 5, "panAngle": -45
3547
- });
3548
- this.securityCameraAnimator = animator;
3549
- if (!obj.viewFrustum) {
3550
- setTimeout(() => { animator.toggleViewFrustum(); }, 1000);
3648
+ const poll = setInterval(() => {
3649
+ if (isLoaded()) {
3650
+ clearInterval(poll);
3651
+ clearTimeout(timer);
3652
+ resolve();
3551
3653
  }
3552
- }
3553
- if (isNestThermostat) {
3554
- const cv = new CanvasRenderer();
3555
- cv.onInit();
3556
- const plane = new PlaneRenderer();
3557
- plane.outputs = { objectRoot: new Object3D(), collider: new Object3D() };
3558
- const inputTexture = cv.outputs.texture;
3559
- plane.setRootScene(this.threeJSScene);
3560
- plane.onInit(modelNode, inputTexture);
3561
- const sc = new NestThermostat();
3562
- sc.setComponent(component, plane, cv);
3563
- sc.setRootScene(this.threeJSScene);
3564
- sc.onInit(modelNode, plane, inputTexture);
3565
- cv.setCanvasNestThermostatPainter(sc);
3566
- setTimeout(() => { sc.inputs.loadingState = "Loaded"; sc.onInputsUpdated(); }, 5000);
3567
- }
3568
- if (isSmarterplanPromotionalVideo) {
3569
- const sc = new TvPlayer();
3570
- sc.setComponent(component);
3571
- sc.onInit(modelNode);
3572
- setTimeout(() => { sc.inputs.loadingState = "Loaded"; sc.onInputsUpdated(); }, 5000);
3573
- }
3574
- sceneObject.start();
3575
- resolve(this.lastObject3D);
3654
+ }, 50);
3655
+ const timer = setTimeout(() => {
3656
+ clearInterval(poll);
3657
+ reject(new Error(`3D model not loaded after ${timeoutMs} ms`));
3658
+ }, timeoutMs);
3576
3659
  });
3577
3660
  }
3661
+ /**
3662
+ * Adds a 3D model to the scene.
3663
+ * The promise resolves with the three.js object once the model is actually loaded, and rejects
3664
+ * (after cleaning the scene) when the format is unknown or the load times out.
3665
+ * The position / rotation / scale of `obj` are applied to the model origin, as stored in DB.
3666
+ */
3667
+ async add3DObject(sdk, obj, mode) {
3668
+ const loaderName = this.getLoaderComponentName(obj.format);
3669
+ if (!loaderName) {
3670
+ throw new Error(`3D object format not supported: ${obj.format}`);
3671
+ }
3672
+ const [sceneObject] = await sdk.Scene.createObjects(1);
3673
+ const isAnimatedSecurityCamera = (!obj.url && obj.object === 'security_camera');
3674
+ const isNestThermostat = (!obj.url && obj.object === 'nest_thermostat');
3675
+ const isSmarterplanPromotionalVideo = (!obj.url && obj.object === 'video');
3676
+ const isAzimuthalCrown = (!obj.url && obj.object === 'azimuth');
3677
+ const modelNode = sceneObject.addNode();
3678
+ const initial = {
3679
+ url: obj.url || `/assets/3Dobjects/objects/${obj.object}${obj.format.indexOf('.') === -1 ? '.' + obj.format : obj.format}`,
3680
+ localRotation: { 'x': 0, 'y': 0, 'z': 0 },
3681
+ localPosition: { 'x': 0, 'y': 0, 'z': 0 },
3682
+ visible: true,
3683
+ colliderEnabled: true
3684
+ };
3685
+ const component = modelNode.addComponent(loaderName, initial);
3686
+ if (this.noLightForObjects) {
3687
+ const lightsNode = sceneObject.addNode();
3688
+ lightsNode.addComponent('mp.ambientLight', {
3689
+ intensity: 1,
3690
+ color: { r: 1.0, g: 1.0, b: 1.0 },
3691
+ });
3692
+ this.noLightForObjects = false;
3693
+ }
3694
+ modelNode.obj3D.position.set(obj.position.x, obj.position.y, obj.position.z);
3695
+ modelNode.obj3D.rotation.set(obj.rotation.x, obj.rotation.y, obj.rotation.z);
3696
+ modelNode.obj3D.scale.set(obj.scale.x, obj.scale.y, obj.scale.z);
3697
+ // The DB id replaces the three.js uuid. The id pre-set through setLastObject3D is only a fallback
3698
+ // for callers without id: taking it first gave the same id to every object loaded meanwhile
3699
+ // (several cameras or cubes loading while an add is starting), which then overwrote each other.
3700
+ if (obj.id) {
3701
+ modelNode.obj3D.uuid = obj.id;
3702
+ }
3703
+ else if (this.lastObject3D && typeof this.lastObject3D.id === 'string') {
3704
+ modelNode.obj3D.uuid = this.lastObject3D.id;
3705
+ }
3706
+ const uuid = modelNode.obj3D.uuid;
3707
+ this.lastObject3D = modelNode.obj3D;
3708
+ this.dictionnaryObjects3D.set(uuid, modelNode);
3709
+ this.dictionnarySceneObjects3D.set(uuid, sceneObject);
3710
+ this.loaderComponents.set(uuid, component);
3711
+ if (isAzimuthalCrown) {
3712
+ this.azimuthalCrown = modelNode;
3713
+ }
3714
+ if (mode && !isNestThermostat) {
3715
+ this.attachGizmo(uuid, mode).catch(() => { });
3716
+ }
3717
+ let animator = null;
3718
+ if (isAnimatedSecurityCamera) {
3719
+ animator = modelNode.addComponent('mp.securityCamera', {
3720
+ 'nearPlane': 0.1, 'farPlane': 10, 'horizontalFOV': 52, 'aspect': 1.7777777777777777,
3721
+ 'localPosition': { 'x': 0.3, 'y': 0.18, 'z': 0 },
3722
+ 'localRotation': { 'x': -15, 'y': -90, 'z': 0 },
3723
+ 'color': 65280, 'panPeriod': 5, 'panAngle': -45
3724
+ });
3725
+ this.securityCameraAnimator = animator;
3726
+ this.securityCameraAnimators.set(uuid, animator);
3727
+ this.viewFrustumVisible.set(uuid, true); // the component shows its frustum by default
3728
+ this.lastSecurityCameraUuid = uuid;
3729
+ }
3730
+ if (isNestThermostat) {
3731
+ const cv = new CanvasRenderer();
3732
+ cv.onInit();
3733
+ const plane = new PlaneRenderer();
3734
+ plane.outputs = { objectRoot: new Object3D(), collider: new Object3D() };
3735
+ const inputTexture = cv.outputs.texture;
3736
+ plane.setRootScene(this.threeJSScene);
3737
+ plane.onInit(modelNode, inputTexture);
3738
+ const sc = new NestThermostat();
3739
+ sc.setComponent(component, plane, cv);
3740
+ sc.setRootScene(this.threeJSScene);
3741
+ sc.onInit(modelNode, plane, inputTexture);
3742
+ cv.setCanvasNestThermostatPainter(sc);
3743
+ setTimeout(() => { sc.inputs.loadingState = 'Loaded'; sc.onInputsUpdated(); }, 5000);
3744
+ }
3745
+ if (isSmarterplanPromotionalVideo) {
3746
+ const sc = new TvPlayer();
3747
+ sc.setComponent(component);
3748
+ sc.onInit(modelNode);
3749
+ setTimeout(() => { sc.inputs.loadingState = 'Loaded'; sc.onInputsUpdated(); }, 5000);
3750
+ }
3751
+ sceneObject.start();
3752
+ try {
3753
+ await this.waitForModelLoaded(component, this.loadTimeoutMs);
3754
+ }
3755
+ catch (e) {
3756
+ await this.deleteObject3D(uuid);
3757
+ throw e;
3758
+ }
3759
+ if (animator && !obj.viewFrustum) {
3760
+ // The camera component starts with the model node, so it is ready now that the model is loaded
3761
+ this.applyViewFrustum(uuid, false);
3762
+ }
3763
+ return modelNode.obj3D;
3764
+ }
3765
+ /**
3766
+ * Removes a 3D object for good: detaches its gizmo, stops its scene object and forgets it.
3767
+ */
3578
3768
  async deleteObject3D(uuid) {
3579
- const obj = this.dictionnaryObjects3D.get(uuid);
3580
- if (obj)
3581
- obj.obj3D.visible = false;
3769
+ this.detachGizmo(uuid);
3770
+ const node = this.dictionnaryObjects3D.get(uuid);
3771
+ const sceneObject = this.dictionnarySceneObjects3D.get(uuid);
3772
+ if (node) {
3773
+ try {
3774
+ node.obj3D.visible = false;
3775
+ }
3776
+ catch { /* already destroyed */ }
3777
+ }
3778
+ if (sceneObject && typeof sceneObject.stop === 'function') {
3779
+ try {
3780
+ sceneObject.stop();
3781
+ }
3782
+ catch { /* already stopped */ }
3783
+ }
3784
+ this.dictionnaryObjects3D.delete(uuid);
3785
+ this.dictionnarySceneObjects3D.delete(uuid);
3786
+ this.loaderComponents.delete(uuid);
3787
+ if (this.lastObject3D && (this.lastObject3D.uuid === uuid || this.lastObject3D.id === uuid)) {
3788
+ this.lastObject3D = null;
3789
+ }
3790
+ if (node && this.azimuthalCrown === node) {
3791
+ this.azimuthalCrown = null;
3792
+ }
3793
+ const animator = this.securityCameraAnimators.get(uuid);
3794
+ if (animator && this.securityCameraAnimator === animator) {
3795
+ this.securityCameraAnimator = null;
3796
+ }
3797
+ this.securityCameraAnimators.delete(uuid);
3798
+ this.viewFrustumVisible.delete(uuid);
3799
+ if (this.lastSecurityCameraUuid === uuid) {
3800
+ this.lastSecurityCameraUuid = null;
3801
+ }
3582
3802
  }
3583
3803
  getObject3DModelNodeFromDictionnary(uuid) {
3584
3804
  let obj = this.dictionnaryObjects3D.get(uuid);
@@ -3598,10 +3818,32 @@ class MatterportObject3DService {
3598
3818
  let obj = this.dictionnaryObjects3D.get(objectId);
3599
3819
  return obj ? obj.obj3D.visible : false;
3600
3820
  }
3821
+ /** Shows / hides a model (used by the placement preview) */
3822
+ setObject3DVisible(uuid, visible) {
3823
+ const node = this.dictionnaryObjects3D.get(uuid);
3824
+ if (node)
3825
+ node.obj3D.visible = visible;
3826
+ }
3827
+ /** Enables / disables the pointer collider of a model (disabled while it follows the cursor) */
3828
+ setObject3DColliderEnabled(uuid, enabled) {
3829
+ const component = this.loaderComponents.get(uuid);
3830
+ if (component?.inputs) {
3831
+ component.inputs.colliderEnabled = enabled;
3832
+ }
3833
+ }
3834
+ /**
3835
+ * Moves the camera next to a 3D object by creating a temporary tag on the object centre.
3836
+ * @returns false when the object is unknown or no tag could be reached
3837
+ */
3601
3838
  async pointCameraTo3DObject(sdk, objectId, createTagCallback, goToTagCallback, deleteLastTagCallback, getTagFromElementId) {
3602
- let obj = this.dictionnaryObjects3D.get(objectId);
3839
+ const obj = this.dictionnaryObjects3D.get(objectId);
3840
+ if (!obj)
3841
+ return false;
3842
+ // Tag on the bounding box centre (the model origin is a corner for most models)
3843
+ const box = this.getObject3DBoundingBox(objectId);
3844
+ const anchor = box ? box.center : this.toPoint(obj.obj3D.position);
3603
3845
  const poiObject = {
3604
- coordinate: JSON.stringify(obj.obj3D.position),
3846
+ coordinate: JSON.stringify(anchor),
3605
3847
  type: PoiType.OBJECT3D,
3606
3848
  elementID: objectId,
3607
3849
  };
@@ -3610,9 +3852,16 @@ class MatterportObject3DService {
3610
3852
  await createTagCallback(PoiType.OBJECT3D, objectDb, poiObject);
3611
3853
  }
3612
3854
  catch (err) { }
3613
- let result = getTagFromElementId(objectId);
3614
- await goToTagCallback(result.tag);
3615
- deleteLastTagCallback();
3855
+ try {
3856
+ const result = getTagFromElementId(objectId);
3857
+ if (!result?.tag)
3858
+ return false;
3859
+ await goToTagCallback(result.tag);
3860
+ return true;
3861
+ }
3862
+ finally {
3863
+ deleteLastTagCallback();
3864
+ }
3616
3865
  }
3617
3866
  getSceneNodeFromObject3DId(uuid) {
3618
3867
  return this.dictionnarySceneObjects3D.get(uuid);
@@ -3622,49 +3871,449 @@ class MatterportObject3DService {
3622
3871
  this.azimuthalCrown.obj3D.position.set(poseCamera.position.x, poseCamera.position.y, poseCamera.position.z);
3623
3872
  }
3624
3873
  }
3874
+ //
3875
+ // ---------- Geometry helpers ----------
3876
+ //
3877
+ toPoint(v) {
3878
+ return { x: v.x, y: v.y, z: v.z };
3879
+ }
3880
+ /**
3881
+ * Geometry of the model itself (the loader output): the node also holds the helpers added by other
3882
+ * components, like the view frustum of a security camera, which must not count in its size.
3883
+ */
3884
+ modelRoot(uuid, node) {
3885
+ return this.loaderComponents.get(uuid)?.outputs?.objectRoot ?? node.obj3D;
3886
+ }
3887
+ /** World-space bounding box of the model, null when unknown or when nothing is loaded yet */
3888
+ getObject3DBoundingBox(uuid) {
3889
+ const node = this.dictionnaryObjects3D.get(uuid);
3890
+ if (!node)
3891
+ return null;
3892
+ node.obj3D.updateWorldMatrix(true, true);
3893
+ const box = this.tmpBox.setFromObject(this.modelRoot(uuid, node));
3894
+ if (box.isEmpty())
3895
+ return null;
3896
+ const size = box.getSize(new Vector3());
3897
+ const center = box.getCenter(new Vector3());
3898
+ return {
3899
+ min: this.toPoint(box.min),
3900
+ max: this.toPoint(box.max),
3901
+ size: this.toPoint(size),
3902
+ center: this.toPoint(center),
3903
+ };
3904
+ }
3905
+ /**
3906
+ * Real dimensions of the model in meters (width x, height y, depth z), scale included but not
3907
+ * its rotation: the world bounding box of a rotated object is larger than the object itself.
3908
+ */
3909
+ getObject3DDimensions(uuid) {
3910
+ const node = this.dictionnaryObjects3D.get(uuid);
3911
+ if (!node)
3912
+ return null;
3913
+ const rotation = node.obj3D.rotation.clone();
3914
+ node.obj3D.rotation.set(0, 0, 0);
3915
+ try {
3916
+ node.obj3D.updateWorldMatrix(true, true);
3917
+ const box = this.tmpBox.setFromObject(this.modelRoot(uuid, node));
3918
+ return box.isEmpty() ? null : this.toPoint(box.getSize(new Vector3()));
3919
+ }
3920
+ finally {
3921
+ node.obj3D.rotation.copy(rotation);
3922
+ node.obj3D.updateWorldMatrix(true, true);
3923
+ }
3924
+ }
3925
+ /**
3926
+ * Uniform scale so that the largest bounding box dimension equals `targetSizeMeters`.
3927
+ * The node position is untouched, so call placeObject3DOnSurface afterwards.
3928
+ * @returns the new scale, null when the object is unknown / not loaded / `targetSizeMeters` invalid
3929
+ */
3930
+ normalizeObject3DSize(uuid, targetSizeMeters) {
3931
+ const node = this.dictionnaryObjects3D.get(uuid);
3932
+ const box = this.getObject3DBoundingBox(uuid);
3933
+ if (!node || !box || !(targetSizeMeters > 0))
3934
+ return null;
3935
+ const largest = Math.max(box.size.x, box.size.y, box.size.z);
3936
+ if (!(largest > 0))
3937
+ return null;
3938
+ const factor = targetSizeMeters / largest;
3939
+ node.obj3D.scale.multiplyScalar(factor);
3940
+ this.emitTransformChanged(uuid);
3941
+ return this.toPoint(node.obj3D.scale);
3942
+ }
3943
+ /**
3944
+ * Places the model on a surface point without touching its pivot: the node position is
3945
+ * adjusted so that the bounding box bottom-center lies on `point`.
3946
+ * - floor (normal pointing up, or no normal): bottom-center on the point
3947
+ * - wall (mostly horizontal normal): model turned around Y so its +Z faces away from the wall,
3948
+ * and pushed along the normal by half its depth so it does not cut the wall
3949
+ * - ceiling (normal pointing down): the model hangs, top-center on the point
3950
+ * @returns the new transform, null when the object is unknown or not loaded
3951
+ */
3952
+ /**
3953
+ * @param frontYawOffset rotation (radians, around Y) that turns the front of the model toward its +Z
3954
+ * axis, for models whose front is another axis (e.g. -PI/2 when it is +X)
3955
+ */
3956
+ placeObject3DOnSurface(uuid, point, normal, frontYawOffset = 0) {
3957
+ const node = this.dictionnaryObjects3D.get(uuid);
3958
+ if (!node || !this.getObject3DBoundingBox(uuid))
3959
+ return null;
3960
+ const obj = node.obj3D;
3961
+ const n = normal ? new Vector3(normal.x, normal.y, normal.z) : new Vector3(0, 1, 0);
3962
+ if (n.lengthSq() === 0)
3963
+ n.set(0, 1, 0);
3964
+ n.normalize();
3965
+ const isWall = Math.abs(n.y) < 0.5;
3966
+ const isCeiling = !isWall && n.y < 0;
3967
+ const target = new Vector3(point.x, point.y, point.z);
3968
+ if (isWall) {
3969
+ // Face away from the wall; the box has to be measured after the rotation
3970
+ obj.rotation.y = Math.atan2(n.x, n.z) + frontYawOffset;
3971
+ }
3972
+ const box = this.getObject3DBoundingBox(uuid);
3973
+ if (isWall) {
3974
+ const horizontal = new Vector3(n.x, 0, n.z).normalize();
3975
+ const halfDepth = 0.5 * (Math.abs(horizontal.x) * box.size.x + Math.abs(horizontal.z) * box.size.z);
3976
+ target.addScaledVector(horizontal, halfDepth);
3977
+ }
3978
+ const anchorY = isCeiling ? box.max.y : box.min.y;
3979
+ obj.position.x += target.x - box.center.x;
3980
+ obj.position.y += target.y - anchorY;
3981
+ obj.position.z += target.z - box.center.z;
3982
+ this.emitTransformChanged(uuid);
3983
+ return this.getObject3DTransform(uuid);
3984
+ }
3985
+ getObject3DTransform(uuid) {
3986
+ const node = this.dictionnaryObjects3D.get(uuid);
3987
+ if (!node)
3988
+ return null;
3989
+ const o = node.obj3D;
3990
+ return {
3991
+ position: this.toPoint(o.position),
3992
+ rotation: { x: o.rotation.x, y: o.rotation.y, z: o.rotation.z },
3993
+ scale: this.toPoint(o.scale),
3994
+ };
3995
+ }
3996
+ /** Applies a (partial) transform. Rotation is in radians. */
3997
+ setObject3DTransform(uuid, transform) {
3998
+ const node = this.dictionnaryObjects3D.get(uuid);
3999
+ if (!node)
4000
+ return false;
4001
+ const o = node.obj3D;
4002
+ if (transform.position)
4003
+ o.position.set(transform.position.x, transform.position.y, transform.position.z);
4004
+ if (transform.rotation)
4005
+ o.rotation.set(transform.rotation.x, transform.rotation.y, transform.rotation.z);
4006
+ if (transform.scale)
4007
+ o.scale.set(transform.scale.x, transform.scale.y, transform.scale.z);
4008
+ this.emitTransformChanged(uuid);
4009
+ return true;
4010
+ }
4011
+ emitTransformChanged(uuid) {
4012
+ const transform = this.getObject3DTransform(uuid);
4013
+ if (transform) {
4014
+ this.object3DTransformChanged$.next({ uuid, ...transform });
4015
+ }
4016
+ }
4017
+ //
4018
+ // ---------- Gizmo ----------
4019
+ //
4020
+ /** Creates the mp.transformControls node of a model and wires it (events, snap) */
4021
+ createGizmo(uuid, modelNode, sceneObject, mode, visible, node) {
4022
+ const gizmoNode = node ?? sceneObject.addNode();
4023
+ const component = gizmoNode.addComponent('mp.transformControls');
4024
+ gizmoNode.start();
4025
+ const tc = component.transformControls;
4026
+ tc.visible = visible;
4027
+ component.inputs.selection = modelNode;
4028
+ component.inputs.mode = mode;
4029
+ modelNode.obj3D.controls = component;
4030
+ const entry = {
4031
+ uuid,
4032
+ node: gizmoNode,
4033
+ component,
4034
+ mode,
4035
+ dragStart: null,
4036
+ lastPosition: modelNode.obj3D.position.clone(),
4037
+ onDraggingChanged: (event) => {
4038
+ entry.dragStart = event?.value ? modelNode.obj3D.position.clone() : null;
4039
+ entry.lastPosition = modelNode.obj3D.position.clone();
4040
+ },
4041
+ onObjectChange: () => this.onGizmoObjectChange(entry, modelNode),
4042
+ };
4043
+ if (typeof tc.addEventListener === 'function') {
4044
+ tc.addEventListener('dragging-changed', entry.onDraggingChanged);
4045
+ tc.addEventListener('objectChange', entry.onObjectChange);
4046
+ }
4047
+ this.applySnap(tc);
4048
+ this.gizmos.set(uuid, entry);
4049
+ this.activeGizmoUuid = uuid;
4050
+ return entry;
4051
+ }
4052
+ /** Keeps a translate drag within maxDragDistance / maxDragHeight of where it started, then notifies */
4053
+ onGizmoObjectChange(entry, modelNode) {
4054
+ const pos = modelNode.obj3D.position;
4055
+ if (entry.mode === 'translate') {
4056
+ const start = entry.dragStart ?? entry.lastPosition;
4057
+ const delta = pos.clone().sub(start);
4058
+ delta.y = MathUtils.clamp(delta.y, -this.maxDragHeight, this.maxDragHeight);
4059
+ const length = delta.length();
4060
+ if (length > this.maxDragDistance) {
4061
+ delta.multiplyScalar(this.maxDragDistance / length);
4062
+ }
4063
+ pos.copy(start).add(delta);
4064
+ }
4065
+ entry.lastPosition = pos.clone();
4066
+ this.emitTransformChanged(entry.uuid);
4067
+ }
4068
+ applySnap(tc) {
4069
+ if (!tc)
4070
+ return;
4071
+ const snap = this.gizmoSnap;
4072
+ const translation = snap?.translation ?? null;
4073
+ const rotation = snap?.rotationDeg != null ? MathUtils.degToRad(snap.rotationDeg) : null;
4074
+ const scale = snap?.scale ?? null;
4075
+ if (typeof tc.setTranslationSnap === 'function')
4076
+ tc.setTranslationSnap(translation);
4077
+ else
4078
+ tc.translationSnap = translation;
4079
+ if (typeof tc.setRotationSnap === 'function')
4080
+ tc.setRotationSnap(rotation);
4081
+ else
4082
+ tc.rotationSnap = rotation;
4083
+ if (typeof tc.setScaleSnap === 'function')
4084
+ tc.setScaleSnap(scale);
4085
+ else
4086
+ tc.scaleSnap = scale;
4087
+ }
4088
+ /**
4089
+ * Shows the transform gizmo on an object (any previous gizmo is detached first).
4090
+ * Re-attaching to the object that already has the gizmo only changes its mode.
4091
+ */
4092
+ async attachGizmo(uuid, mode, sdk) {
4093
+ const modelNode = this.dictionnaryObjects3D.get(uuid);
4094
+ const sceneObject = this.dictionnarySceneObjects3D.get(uuid);
4095
+ if (!modelNode || !sceneObject)
4096
+ return false;
4097
+ const existing = this.gizmos.get(uuid);
4098
+ if (existing && this.activeGizmoUuid === uuid) {
4099
+ this.setGizmoMode(mode);
4100
+ return true;
4101
+ }
4102
+ // A started scene object refuses new nodes (addNode returns null): use a dedicated one
4103
+ let node = sceneObject.addNode();
4104
+ let gizmoSceneObject = null;
4105
+ if (!node) {
4106
+ if (!sdk?.Scene)
4107
+ return false;
4108
+ [gizmoSceneObject] = await sdk.Scene.createObjects(1);
4109
+ node = gizmoSceneObject.addNode();
4110
+ }
4111
+ this.detachGizmo();
4112
+ const entry = this.createGizmo(uuid, modelNode, sceneObject, mode, true, node);
4113
+ entry.sceneObject = gizmoSceneObject;
4114
+ this.lastObject3D = modelNode.obj3D;
4115
+ return true;
4116
+ }
4117
+ /** Switches the active gizmo between translate / rotate / scale without re-attaching it */
4118
+ setGizmoMode(mode) {
4119
+ const entry = this.activeGizmoUuid ? this.gizmos.get(this.activeGizmoUuid) : null;
4120
+ if (!entry)
4121
+ return;
4122
+ entry.mode = mode;
4123
+ entry.dragStart = null;
4124
+ entry.component.inputs.mode = mode;
4125
+ const tc = entry.component.transformControls;
4126
+ if (tc && typeof tc.setMode === 'function')
4127
+ tc.setMode(mode);
4128
+ }
4129
+ /** Really removes the gizmo from the scene (default: the active one) */
4130
+ detachGizmo(uuid) {
4131
+ const id = uuid ?? this.activeGizmoUuid;
4132
+ if (!id)
4133
+ return;
4134
+ const entry = this.gizmos.get(id);
4135
+ if (!entry)
4136
+ return;
4137
+ const tc = entry.component?.transformControls;
4138
+ if (tc) {
4139
+ if (typeof tc.removeEventListener === 'function') {
4140
+ tc.removeEventListener('dragging-changed', entry.onDraggingChanged);
4141
+ tc.removeEventListener('objectChange', entry.onObjectChange);
4142
+ }
4143
+ tc.visible = false;
4144
+ if (typeof tc.detach === 'function')
4145
+ tc.detach();
4146
+ if (typeof tc.dispose === 'function')
4147
+ tc.dispose();
4148
+ if (tc.parent && typeof tc.parent.remove === 'function')
4149
+ tc.parent.remove(tc);
4150
+ }
4151
+ try {
4152
+ entry.node.stop();
4153
+ }
4154
+ catch { /* already stopped */ }
4155
+ try {
4156
+ entry.sceneObject?.stop();
4157
+ }
4158
+ catch { /* already stopped */ }
4159
+ const modelNode = this.dictionnaryObjects3D.get(id);
4160
+ if (modelNode?.obj3D)
4161
+ modelNode.obj3D.controls = null;
4162
+ this.gizmos.delete(id);
4163
+ if (this.activeGizmoUuid === id)
4164
+ this.activeGizmoUuid = null;
4165
+ }
4166
+ isGizmoAttached(uuid) {
4167
+ return uuid ? this.gizmos.has(uuid) : this.activeGizmoUuid !== null;
4168
+ }
4169
+ /** Snap steps for all gizmos (null / missing value = no snap) */
4170
+ setGizmoSnap(snap) {
4171
+ this.gizmoSnap = snap ? { ...snap } : null;
4172
+ this.gizmos.forEach((entry) => this.applySnap(entry.component?.transformControls));
4173
+ }
4174
+ setGizmoMaxDragDistance(maxDistance, maxHeight) {
4175
+ if (maxDistance > 0)
4176
+ this.maxDragDistance = maxDistance;
4177
+ if (maxHeight !== undefined && maxHeight > 0)
4178
+ this.maxDragHeight = maxHeight;
4179
+ }
4180
+ /**
4181
+ * Prevents camera navigation to other sweeps while a gizmo is edited.
4182
+ * The SDK has no "freeze camera" switch, so every enabled sweep except the current one is
4183
+ * disabled (Sweep.disable); exactly those sweeps are re-enabled on unlock. Sweeps already
4184
+ * disabled (forbidden sweeps, edit mode) are never touched, and `keepDisabled` lists sweeps
4185
+ * that must stay disabled on unlock.
4186
+ * @returns false when the lock could not be applied (SDK without Sweep.disable, unknown current sweep)
4187
+ */
4188
+ async setNavigationLocked(sdk, locked, sweepCollection, currentSweepId, keepDisabled = []) {
4189
+ if (!sdk?.Sweep || typeof sdk.Sweep.disable !== 'function' || typeof sdk.Sweep.enable !== 'function') {
4190
+ return false;
4191
+ }
4192
+ if (locked) {
4193
+ if (this.sweepsDisabledByLock.length > 0)
4194
+ return true;
4195
+ if (!sweepCollection || !currentSweepId)
4196
+ return false;
4197
+ // a sweep this lock re-enabled just before may still be reported disabled by the SDK
4198
+ const ids = Object.keys(sweepCollection).filter((sid) => sid !== currentSweepId &&
4199
+ (sweepCollection[sid]?.enabled !== false || this.sweepsEnabledByUnlock.includes(sid)));
4200
+ if (ids.length === 0)
4201
+ return true;
4202
+ try {
4203
+ await sdk.Sweep.disable(...ids);
4204
+ this.sweepsDisabledByLock = ids;
4205
+ }
4206
+ catch {
4207
+ // fall back to one call per sweep to know exactly which ones were disabled
4208
+ const results = await Promise.allSettled(ids.map((sid) => sdk.Sweep.disable(sid)));
4209
+ this.sweepsDisabledByLock = ids.filter((_, i) => results[i].status === 'fulfilled');
4210
+ }
4211
+ return true;
4212
+ }
4213
+ const toEnable = this.sweepsDisabledByLock.filter((sid) => !keepDisabled.includes(sid));
4214
+ this.sweepsDisabledByLock = [];
4215
+ this.sweepsEnabledByUnlock = toEnable;
4216
+ if (toEnable.length > 0) {
4217
+ try {
4218
+ await sdk.Sweep.enable(...toEnable);
4219
+ }
4220
+ catch {
4221
+ await Promise.allSettled(toEnable.map((sid) => sdk.Sweep.enable(sid)));
4222
+ }
4223
+ }
4224
+ return true;
4225
+ }
4226
+ isNavigationLocked() {
4227
+ return this.sweepsDisabledByLock.length > 0;
4228
+ }
4229
+ /** Sweeps currently disabled by the navigation lock */
4230
+ getSweepsDisabledByLock() {
4231
+ return [...this.sweepsDisabledByLock];
4232
+ }
4233
+ /**
4234
+ * Legacy API: attaches a gizmo to a model node. Prefer attachGizmo(uuid, mode).
4235
+ */
3625
4236
  async attachGizmoControlTo3DObject(sdk, modelNode, sceneObject, mode, visible, isNewObject) {
3626
4237
  let node = sceneObject.addNode();
3627
4238
  if (!node) {
3628
4239
  const [newSceneObject] = await sdk.Scene.createObjects(1);
3629
4240
  node = newSceneObject.addNode();
3630
4241
  }
3631
- const myControl = node.addComponent('mp.transformControls');
3632
- node.start();
3633
- myControl.transformControls.visible = visible;
3634
- myControl.inputs.selection = modelNode;
3635
- myControl.inputs.mode = mode;
3636
- modelNode.obj3D.controls = myControl;
3637
- if (isNewObject) {
3638
- if (!this.lastObject3D || !this.lastObject3D.controls) {
3639
- try {
3640
- modelNode.obj3D.uuid = this.lastObject3D.uuid || this.lastObject3D.id;
3641
- }
3642
- catch (e) {
3643
- console.log(`id obj in Scene was not assigned to id from DB`);
3644
- }
3645
- this.lastObject3D = modelNode.obj3D;
4242
+ if (isNewObject && (!this.lastObject3D || !this.lastObject3D.controls)) {
4243
+ try {
4244
+ modelNode.obj3D.uuid = this.lastObject3D.uuid || this.lastObject3D.id;
4245
+ }
4246
+ catch (e) {
4247
+ // lastObject3D is not set: the model keeps its own uuid
3646
4248
  }
3647
4249
  }
3648
- else {
3649
- this.lastObject3D = modelNode.obj3D;
3650
- }
4250
+ this.detachGizmo();
4251
+ this.createGizmo(modelNode.obj3D.uuid, modelNode, sceneObject, mode, visible, node);
4252
+ this.lastObject3D = modelNode.obj3D;
3651
4253
  return modelNode;
3652
4254
  }
4255
+ /** Removes the gizmo of the last added / edited object */
3653
4256
  removeGizmoFromLastObject() {
3654
- if (this.lastObject3D && this.lastObject3D.controls) {
3655
- this.lastObject3D.controls.transformControls.visible = false;
3656
- this.lastObject3D.controls.transformControls.dispose();
3657
- this.lastObject3D.controls = null;
4257
+ const last = this.lastObject3D;
4258
+ if (!last)
4259
+ return;
4260
+ const uuid = last.uuid ?? last.id;
4261
+ if (uuid && this.gizmos.has(uuid)) {
4262
+ this.detachGizmo(uuid);
4263
+ }
4264
+ else if (last.controls) {
4265
+ // gizmo not created by this service: best effort
4266
+ last.controls.transformControls.visible = false;
4267
+ last.controls.transformControls.dispose();
4268
+ last.controls = null;
3658
4269
  }
3659
4270
  }
3660
- toggleViewFrustum() {
3661
- if (this.securityCameraAnimator) {
4271
+ //
4272
+ // ---------- Security camera ----------
4273
+ //
4274
+ applyViewFrustum(uuid, visible) {
4275
+ const animator = this.securityCameraAnimators.get(uuid);
4276
+ if (!animator)
4277
+ return false;
4278
+ const current = this.viewFrustumVisible.get(uuid) ?? true;
4279
+ if (current === visible)
4280
+ return true;
4281
+ try {
4282
+ animator.toggleViewFrustum();
4283
+ }
4284
+ catch {
4285
+ return false;
4286
+ }
4287
+ this.viewFrustumVisible.set(uuid, visible);
4288
+ return true;
4289
+ }
4290
+ /** Shows / hides the view frustum of the security camera `uuid` */
4291
+ setViewFrustum(uuid, visible) {
4292
+ return this.applyViewFrustum(uuid, visible);
4293
+ }
4294
+ isViewFrustumVisible(uuid) {
4295
+ return this.viewFrustumVisible.get(uuid) ?? false;
4296
+ }
4297
+ /** Toggles the frustum of `uuid`, or of the last loaded security camera when omitted */
4298
+ toggleViewFrustum(uuid) {
4299
+ const id = uuid ?? this.lastSecurityCameraUuid;
4300
+ if (id && this.securityCameraAnimators.has(id)) {
4301
+ this.applyViewFrustum(id, !(this.viewFrustumVisible.get(id) ?? true));
4302
+ }
4303
+ else if (!uuid && this.securityCameraAnimator) {
4304
+ // animator set by hand (legacy)
3662
4305
  this.securityCameraAnimator.toggleViewFrustum();
3663
4306
  }
3664
4307
  }
3665
4308
  clear() {
4309
+ Array.from(this.gizmos.keys()).forEach((id) => this.detachGizmo(id));
3666
4310
  this.dictionnaryObjects3D.clear();
3667
4311
  this.dictionnarySceneObjects3D.clear();
4312
+ this.loaderComponents.clear();
4313
+ this.securityCameraAnimators.clear();
4314
+ this.viewFrustumVisible.clear();
4315
+ this.lastSecurityCameraUuid = null;
4316
+ this.sweepsDisabledByLock = [];
3668
4317
  this.lastObject3D = null;
3669
4318
  this.azimuthalCrown = null;
3670
4319
  this.securityCameraAnimator = null;
@@ -4039,11 +4688,11 @@ class MatterportTagService {
4039
4688
  * floor and room context. Must be called after each sweep, floor or mode change.
4040
4689
  *
4041
4690
  * Rules:
4042
- * - DOLLHOUSE / FLOORPLAN → all tags hidden (opacity 0).
4043
- * - INSIDE → only tags whose sweep belongs to the current floor are shown;
4044
- * proximity-based opacity is applied via computeTagOpacity().
4045
- * Tags on other floors are hidden (opacity 0).
4046
- * - TRANSITIONING → no change (skip update to avoid flickering).
4691
+ * - A tag shows when its sweep is on the floor being displayed, in every mode. Tags on
4692
+ * other floors are hidden (opacity 0) and cannot be opened.
4693
+ * - While all floors are displayed - the usual state of DOLLHOUSE and FLOORPLAN - every
4694
+ * tag shows.
4695
+ * - A tag with no sweep belongs to no floor and always shows.
4047
4696
  *
4048
4697
  * @param sdk Matterport SDK instance.
4049
4698
  * @param poseCamera Current camera pose.
@@ -4055,7 +4704,12 @@ class MatterportTagService {
4055
4704
  if (!sdk || this.dictionnaryTags.size === 0)
4056
4705
  return;
4057
4706
  const isInteriorMode = currentCameraMode === CameraMode.INSIDE;
4058
- const currentFloorId = currentFloorSequence !== null ? currentFloorSequence.toString() : null;
4707
+ // The SDK reports sequence -1 while all floors are displayed, which is the dollhouse's and the
4708
+ // floor plan's normal state. That is "no floor in particular", not a floor that no tag is on:
4709
+ // read as a floor id, it matched nothing and hid every tag that has a sweep.
4710
+ const currentFloorId = currentFloorSequence !== null && currentFloorSequence >= 0
4711
+ ? currentFloorSequence.toString()
4712
+ : null;
4059
4713
  const promises = [];
4060
4714
  for (const [tagId, mattertagData] of this.dictionnaryTags) {
4061
4715
  if (tagId === mattertagToFollow)
@@ -4361,6 +5015,8 @@ class Config {
4361
5015
  constructor() { }
4362
5016
  }
4363
5017
 
5018
+ /** Pointer intersections a 3D object can be placed on (Pointer.Colliders MODEL and SWEEP) */
5019
+ const PLACEMENT_SURFACES = ['intersectedobject.model', 'intersectedobject.sweep'];
4364
5020
  class MatterportService {
4365
5021
  sdkKey;
4366
5022
  router;
@@ -4381,6 +5037,18 @@ class MatterportService {
4381
5037
  isMeasureModeOn = false;
4382
5038
  interactionMode = ViewerInteractions.DEFAULT;
4383
5039
  cameraMode = 'INSIDE';
5040
+ /** Emitted when a placement (tag following the cursor, or 3D object preview) is cancelled by right click or Escape */
5041
+ placementCancelled$ = new Subject();
5042
+ /** Pending interactive placement of a 3D object (see startObject3DPlacement) */
5043
+ object3DPlacement = null;
5044
+ /** Sweeps snapshot taken when navigation is locked (disabled sweeps vanish from Sweep.data) */
5045
+ lockedSweepsSnapshot = {};
5046
+ /**
5047
+ * Every sweep seen in Sweep.data. After an unlock the SDK re-emits the re-enabled sweeps little by
5048
+ * little: a lock asked meanwhile must still know all of them (it found nothing to disable).
5049
+ */
5050
+ knownSweeps = {};
5051
+ keydownTargets = [];
4384
5052
  get mattertagIDs() {
4385
5053
  return this.matterportTagService.mattertagIDs;
4386
5054
  }
@@ -4522,6 +5190,10 @@ class MatterportService {
4522
5190
  * Actions on left click when positioning mattertag in visit
4523
5191
  */
4524
5192
  pointerLeftClickHandler = (evt) => {
5193
+ if (this.object3DPlacement) {
5194
+ this.validateObject3DPlacement();
5195
+ return;
5196
+ }
4525
5197
  if (this.mattertagToFollow) {
4526
5198
  const mattertagData = this.dictionnaryTags.get(this.mattertagToFollow);
4527
5199
  if (mattertagData) {
@@ -4535,8 +5207,7 @@ class MatterportService {
4535
5207
  };
4536
5208
  pointerRightClickHandler = (e) => {
4537
5209
  e.preventDefault();
4538
- this.cancelFollowingCursor();
4539
- alert('action cancelled');
5210
+ this.cancelCurrentPlacement();
4540
5211
  };
4541
5212
  pointerMiddleClickHandler = (e) => {
4542
5213
  this.pointerService.updateCursorDisplay(this.poseMatterport);
@@ -4583,6 +5254,7 @@ class MatterportService {
4583
5254
  onRightClick: this.pointerRightClickHandler.bind(this),
4584
5255
  onMiddleClick: this.pointerMiddleClickHandler.bind(this),
4585
5256
  });
5257
+ this.registerEscapeListener(iframe);
4586
5258
  // Load SDK
4587
5259
  const showcaseWindow = iframe.contentWindow;
4588
5260
  iframe.addEventListener('load', async () => {
@@ -4659,6 +5331,9 @@ class MatterportService {
4659
5331
  // (we are about to validate, but it exists in sdk already)
4660
5332
  this.enable_following_tag(this.mattertagToFollow);
4661
5333
  }
5334
+ if (this.object3DPlacement) {
5335
+ this.updateObject3DPlacementPreview(intersection);
5336
+ }
4662
5337
  if (this.pointerService) {
4663
5338
  this.pointerService.setCursorPositionButtonDisplayed(false);
4664
5339
  }
@@ -4684,6 +5359,11 @@ class MatterportService {
4684
5359
  // subscribe to sweeps
4685
5360
  this.sdk.Sweep.data.subscribe({
4686
5361
  onCollectionUpdated: function subscr(collection) {
5362
+ this.knownSweeps = { ...this.knownSweeps, ...collection };
5363
+ if (this.object3DService.isNavigationLocked()) {
5364
+ // sweeps disabled by the lock are no longer in the collection: keep them known
5365
+ collection = { ...this.lockedSweepsSnapshot, ...collection };
5366
+ }
4687
5367
  this.sweeps = Object.keys(collection);
4688
5368
  this.sweepCollection = collection;
4689
5369
  // Rebuild sid ↔ uuid maps whenever the collection updates
@@ -4795,7 +5475,7 @@ class MatterportService {
4795
5475
  // Pointer trick
4796
5476
  // Create a div that will appear when the cursor is still
4797
5477
  // It will intercept the click of the mouse before it trigerring the Matterport's tour navigation
4798
- this.pointerService.startPointerTrick(this.sdk, this.container, () => this.poseMatterport, () => this.poseCamera, () => this.interactionMode, () => this.mattertagToFollow);
5478
+ this.pointerService.startPointerTrick(this.sdk, this.container, () => this.poseMatterport, () => this.poseCamera, () => this.interactionMode, () => this.getFollowTarget());
4799
5479
  /**
4800
5480
  * Transitions
4801
5481
  */
@@ -4867,7 +5547,7 @@ class MatterportService {
4867
5547
  * Styling of pointer
4868
5548
  */
4869
5549
  updatePointerTrick() {
4870
- this.pointerService.updatePointerTrick(this.sdk, this.container, this.poseMatterport, this.poseCamera, this.interactionMode, this.mattertagToFollow);
5550
+ this.pointerService.updatePointerTrick(this.sdk, this.container, this.poseMatterport, this.poseCamera, this.interactionMode, this.getFollowTarget());
4871
5551
  }
4872
5552
  /**
4873
5553
  * Triggers a tag visibility refresh based on current camera mode, floor and sweep.
@@ -5296,6 +5976,9 @@ class MatterportService {
5296
5976
  * @param uuid
5297
5977
  */
5298
5978
  async deleteObject3D(uuid) {
5979
+ if (this.object3DPlacement?.uuid === uuid) {
5980
+ this.cancelObject3DPlacement();
5981
+ }
5299
5982
  return this.object3DService.deleteObject3D(uuid);
5300
5983
  }
5301
5984
  getObject3DModelNodeFromDictionnary(uuid) {
@@ -5340,6 +6023,11 @@ class MatterportService {
5340
6023
  * Cancels following of cursor (meaning deleting last Mattertag)
5341
6024
  */
5342
6025
  cancelFollowingCursor() {
6026
+ if (this.object3DPlacement) {
6027
+ // generic cancel (toolbox buttons...) while a 3D object follows the cursor
6028
+ this.cancelObject3DPlacement();
6029
+ return;
6030
+ }
5343
6031
  this.deleteLastMattertag();
5344
6032
  this.setInteractionMode(ViewerInteractions.DEFAULT);
5345
6033
  }
@@ -5708,7 +6396,10 @@ class MatterportService {
5708
6396
  * Clear all variables, deletes all tags (when viewer is remover or model changed=reload needed)
5709
6397
  */
5710
6398
  async clearAll() {
5711
- console.log('removing viewer');
6399
+ this.cancelObject3DPlacement();
6400
+ this.removeEscapeListener();
6401
+ this.lockedSweepsSnapshot = {};
6402
+ this.knownSweeps = {};
5712
6403
  this.clearViewSubscriptions();
5713
6404
  this.viewsByType.clear();
5714
6405
  this.currentView = null;
@@ -5732,6 +6423,9 @@ class MatterportService {
5732
6423
  async init3DObjectViewer() {
5733
6424
  return this.object3DService.init3DObjectViewer(this.sdk);
5734
6425
  }
6426
+ /**
6427
+ * Adds a 3D model; resolves once it is loaded, rejects on unknown format or load timeout (~15 s).
6428
+ */
5735
6429
  async add3DObject(obj, mode) {
5736
6430
  return this.object3DService.add3DObject(this.sdk, obj, mode);
5737
6431
  }
@@ -5741,7 +6435,43 @@ class MatterportService {
5741
6435
  isObjectVisible(objectId) {
5742
6436
  return this.object3DService.isObjectVisible(objectId);
5743
6437
  }
5744
- async pointCameraTo3DObject(objectId) {
6438
+ /** Moves the camera to the object (centered on its bounding box). Resolves false when the object is unknown. */
6439
+ /**
6440
+ * Moves the camera to the sweep closest to a 3D object and turns it toward the object.
6441
+ * @param visibleArea client X bounds of the part of the viewer not covered by other panels
6442
+ * (the lateral menu covers the right of the viewer): the object is centered there.
6443
+ * Falls back to the temporary tag navigation when no sweep can be used.
6444
+ */
6445
+ async pointCameraTo3DObject(objectId, visibleArea) {
6446
+ const box = this.object3DService.getObject3DBoundingBox(objectId);
6447
+ const sweep = box ? this.findNearestSweep(box.center) : null;
6448
+ const sweepData = sweep ? { ...this.lockedSweepsSnapshot, ...(this.sweepCollection ?? {}) }[sweep.sid] : null;
6449
+ if (box && sweepData?.position && this.sdk?.Sweep) {
6450
+ const projection = this.poseCamera?.projection;
6451
+ const tanHalfFov = projection?.[0] ? 1 / projection[0] : 1;
6452
+ let screenX = 0;
6453
+ const viewer = document.querySelector('#viewer-module');
6454
+ if (visibleArea && viewer) {
6455
+ const rect = viewer.getBoundingClientRect();
6456
+ screenX = visibleCenterScreenX(rect, visibleArea.left, visibleArea.right);
6457
+ }
6458
+ const rotation = computeFramingRotation(sweepData.position, box.center, tanHalfFov, screenX);
6459
+ try {
6460
+ if (this.poseCamera?.sweep === sweep.sid) {
6461
+ await this.sdk.Camera.setRotation(rotation, { speed: 80 });
6462
+ }
6463
+ else {
6464
+ await this.sdk.Sweep.moveTo(sweep.sid, {
6465
+ rotation,
6466
+ transition: this.sdk.Sweep.Transition?.FLY,
6467
+ });
6468
+ }
6469
+ return true;
6470
+ }
6471
+ catch (error) {
6472
+ // disabled or unreachable sweep: use the tag navigation below
6473
+ }
6474
+ }
5745
6475
  return this.object3DService.pointCameraTo3DObject(this.sdk, objectId, this.createMattertagFromPOI.bind(this), this.goToTag.bind(this), this.deleteLastMattertag.bind(this), this.getTagFromElementId.bind(this));
5746
6476
  }
5747
6477
  getSceneNodeFromObject3DId(uuid) {
@@ -5756,31 +6486,275 @@ class MatterportService {
5756
6486
  removeGizmoFromLastObject() {
5757
6487
  this.object3DService.removeGizmoFromLastObject();
5758
6488
  }
5759
- toggleViewFrustum() {
5760
- this.object3DService.toggleViewFrustum();
6489
+ /** Toggles the frustum of the security camera `uuid` (default: the last loaded one) */
6490
+ toggleViewFrustum(uuid) {
6491
+ this.object3DService.toggleViewFrustum(uuid);
5761
6492
  }
5762
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportService, deps: [{ token: 'config' }, { token: MATTERPORT_SDK_KEY }, { token: i1$1.Router }, { token: i1$1.ActivatedRoute }, { token: BaseVisibilityService }, { token: i0.NgZone }, { token: MatterportMeasurementService }, { token: MatterportNavigationService }, { token: MatterportPointerService }, { token: MatterportObject3DService }, { token: MatterportTagService }], target: i0.ɵɵFactoryTarget.Injectable });
5763
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportService, providedIn: 'root' });
5764
- }
5765
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportService, decorators: [{
5766
- type: Injectable,
5767
- args: [{
5768
- providedIn: 'root',
5769
- }]
5770
- }], ctorParameters: () => [{ type: Config, decorators: [{
5771
- type: Inject,
5772
- args: ['config']
5773
- }] }, { type: undefined, decorators: [{
5774
- type: Inject,
5775
- args: [MATTERPORT_SDK_KEY]
5776
- }] }, { type: i1$1.Router }, { type: i1$1.ActivatedRoute }, { type: BaseVisibilityService }, { type: i0.NgZone }, { type: MatterportMeasurementService }, { type: MatterportNavigationService }, { type: MatterportPointerService }, { type: MatterportObject3DService }, { type: MatterportTagService }] });
5777
-
5778
- /**
5779
- * Builds an orthonormal frame from three points, or null when they are (nearly) collinear.
5780
- *
5781
- * Three points define a plane, and a plane plus an origin defines an orientation. Collinear points
5782
- * do not: they leave the rotation about their common line undetermined, which is why the caller has
5783
- * to reject them rather than produce a confident wrong answer.
6493
+ setViewFrustum(uuid, visible) {
6494
+ return this.object3DService.setViewFrustum(uuid, visible);
6495
+ }
6496
+ isViewFrustumVisible(uuid) {
6497
+ return this.object3DService.isViewFrustumVisible(uuid);
6498
+ }
6499
+ //
6500
+ // ---------- 3D objects: geometry, transform, gizmo ----------
6501
+ //
6502
+ /** Emits on gizmo changes and on setObject3DTransform / placeObject3DOnSurface / normalizeObject3DSize */
6503
+ get object3DTransformChanged$() {
6504
+ return this.object3DService.object3DTransformChanged$;
6505
+ }
6506
+ /** World-space bounding box, null when the object is unknown or not loaded */
6507
+ getObject3DBoundingBox(uuid) {
6508
+ return this.object3DService.getObject3DBoundingBox(uuid);
6509
+ }
6510
+ /** Dimensions of the model in meters, without its rotation */
6511
+ getObject3DDimensions(uuid) {
6512
+ return this.object3DService.getObject3DDimensions(uuid);
6513
+ }
6514
+ /** Uniform scale so the largest dimension equals `targetSizeMeters`; returns the new scale or null */
6515
+ normalizeObject3DSize(uuid, targetSizeMeters) {
6516
+ return this.object3DService.normalizeObject3DSize(uuid, targetSizeMeters);
6517
+ }
6518
+ /** Moves the object so its bounding box bottom-center (top-center on a ceiling) lies on `point` */
6519
+ placeObject3DOnSurface(uuid, point, normal, frontYawOffset = 0) {
6520
+ return this.object3DService.placeObject3DOnSurface(uuid, point, normal, frontYawOffset);
6521
+ }
6522
+ getObject3DTransform(uuid) {
6523
+ return this.object3DService.getObject3DTransform(uuid);
6524
+ }
6525
+ /** Rotation in radians */
6526
+ setObject3DTransform(uuid, transform) {
6527
+ return this.object3DService.setObject3DTransform(uuid, transform);
6528
+ }
6529
+ attachGizmo(uuid, mode) {
6530
+ return this.object3DService.attachGizmo(uuid, mode, this.sdk);
6531
+ }
6532
+ setGizmoMode(mode) {
6533
+ this.object3DService.setGizmoMode(mode);
6534
+ }
6535
+ detachGizmo(uuid) {
6536
+ this.object3DService.detachGizmo(uuid);
6537
+ }
6538
+ isGizmoAttached(uuid) {
6539
+ return this.object3DService.isGizmoAttached(uuid);
6540
+ }
6541
+ /** null disables snapping (default) */
6542
+ setGizmoSnap(snap) {
6543
+ this.object3DService.setGizmoSnap(snap);
6544
+ }
6545
+ /** Limits of a translate drag from its start (default 2 m, +/- 1.5 m vertically) */
6546
+ setGizmoMaxDragDistance(maxDistance, maxHeight) {
6547
+ this.object3DService.setGizmoMaxDragDistance(maxDistance, maxHeight);
6548
+ }
6549
+ /**
6550
+ * Locks / unlocks camera navigation to other sweeps while a gizmo is edited: every enabled sweep
6551
+ * except the current one is disabled, and exactly those are re-enabled on unlock.
6552
+ * @returns false when the lock could not be applied (SDK not ready, current sweep unknown)
6553
+ */
6554
+ async setNavigationLocked(locked) {
6555
+ if (!this.sdk)
6556
+ return false;
6557
+ const currentSweep = this.poseCamera?.sweep ?? null;
6558
+ const sweeps = { ...this.knownSweeps, ...(this.sweepCollection ?? {}) };
6559
+ if (locked && !this.object3DService.isNavigationLocked()) {
6560
+ this.lockedSweepsSnapshot = sweeps;
6561
+ }
6562
+ const keepDisabled = (this.forbiddenSweeps ?? []).map((s) => this.navigationService.resolveSweepSid(s));
6563
+ const done = await this.object3DService.setNavigationLocked(this.sdk, locked, sweeps, currentSweep, keepDisabled);
6564
+ if (!locked || !done) {
6565
+ this.lockedSweepsSnapshot = {};
6566
+ }
6567
+ return done;
6568
+ }
6569
+ isNavigationLocked() {
6570
+ return this.object3DService.isNavigationLocked();
6571
+ }
6572
+ //
6573
+ // ---------- 3D objects: interactive placement ----------
6574
+ //
6575
+ /**
6576
+ * Makes an already loaded model follow the cursor on the surfaces of the visit.
6577
+ * Left click resolves with the surface point / normal under the cursor (read the object origin
6578
+ * with getObject3DTransform); right click or Escape hides the model and resolves null
6579
+ * (deleting it is up to the caller). Resolves null right away if another interaction is running.
6580
+ */
6581
+ startObject3DPlacement(uuid, options = {}) {
6582
+ if (!this.object3DService.getObject3DModelNodeFromDictionnary(uuid)) {
6583
+ return Promise.resolve(null);
6584
+ }
6585
+ if (this.object3DPlacement) {
6586
+ this.cancelObject3DPlacement();
6587
+ }
6588
+ if (this.interactionMode !== ViewerInteractions.DEFAULT) {
6589
+ return Promise.resolve(null);
6590
+ }
6591
+ return new Promise((resolve) => {
6592
+ this.object3DPlacement = { uuid, resolve, lastPoint: null, lastNormal: null, frontYawOffset: options.frontYawOffset ?? 0 };
6593
+ this.object3DService.setObject3DColliderEnabled(uuid, false);
6594
+ this.object3DService.setObject3DVisible(uuid, false);
6595
+ this.setInteractionMode(ViewerInteractions.PLACING_OBJECT3D);
6596
+ if (this.poseMatterport?.position) {
6597
+ this.updateObject3DPlacementPreview(this.poseMatterport);
6598
+ }
6599
+ });
6600
+ }
6601
+ isPlacingObject3D() {
6602
+ return !!this.object3DPlacement;
6603
+ }
6604
+ /** Cancels the running object placement (model hidden, promise resolves null) */
6605
+ cancelObject3DPlacement() {
6606
+ const placement = this.object3DPlacement;
6607
+ if (!placement)
6608
+ return;
6609
+ this.endObject3DPlacement();
6610
+ this.object3DService.setObject3DVisible(placement.uuid, false);
6611
+ placement.resolve(null);
6612
+ }
6613
+ /**
6614
+ * Drops the object where it is previewed (same as a left click). Touch screens have no hover: each
6615
+ * tap moves the preview, and the app confirms with a button.
6616
+ * @returns false when no surface was pointed yet
6617
+ */
6618
+ confirmObject3DPlacement() {
6619
+ if (!this.object3DPlacement?.lastPoint)
6620
+ return false;
6621
+ this.validateObject3DPlacement();
6622
+ return true;
6623
+ }
6624
+ /** True once the preview stands on a surface (the placement can be confirmed) */
6625
+ hasObject3DPlacementPoint() {
6626
+ return !!this.object3DPlacement?.lastPoint;
6627
+ }
6628
+ validateObject3DPlacement() {
6629
+ const placement = this.object3DPlacement;
6630
+ if (!placement || !placement.lastPoint)
6631
+ return; // no surface under the cursor yet
6632
+ this.endObject3DPlacement();
6633
+ placement.resolve({
6634
+ position: placement.lastPoint,
6635
+ normal: placement.lastNormal ?? { x: 0, y: 1, z: 0 },
6636
+ });
6637
+ }
6638
+ endObject3DPlacement() {
6639
+ const placement = this.object3DPlacement;
6640
+ if (!placement)
6641
+ return;
6642
+ this.object3DPlacement = null;
6643
+ this.object3DService.setObject3DColliderEnabled(placement.uuid, true);
6644
+ this.setInteractionMode(ViewerInteractions.DEFAULT);
6645
+ }
6646
+ updateObject3DPlacementPreview(intersection) {
6647
+ const placement = this.object3DPlacement;
6648
+ if (!placement || !intersection?.position)
6649
+ return;
6650
+ // Only the scanned surfaces: hitting the preview itself (reported as "unknown") would push it away
6651
+ // along the ray at each move, until it lands kilometers from the camera
6652
+ if (typeof intersection.object === 'string' && !PLACEMENT_SURFACES.includes(intersection.object))
6653
+ return;
6654
+ const point = { x: intersection.position.x, y: intersection.position.y, z: intersection.position.z };
6655
+ const normal = intersection.normal
6656
+ ? { x: intersection.normal.x, y: intersection.normal.y, z: intersection.normal.z }
6657
+ : { x: 0, y: 1, z: 0 };
6658
+ if (!placement.lastPoint) {
6659
+ // hidden until it is on a surface (it was loaded at the camera position)
6660
+ this.object3DService.setObject3DVisible(placement.uuid, true);
6661
+ }
6662
+ placement.lastPoint = point;
6663
+ placement.lastNormal = normal;
6664
+ this.object3DService.placeObject3DOnSurface(placement.uuid, point, normal, placement.frontYawOffset);
6665
+ }
6666
+ /** Token for the pointer trick: truthy while something follows the cursor (tag or 3D object) */
6667
+ getFollowTarget() {
6668
+ return this.mattertagToFollow ?? this.object3DPlacement?.uuid ?? null;
6669
+ }
6670
+ /**
6671
+ * Cancels whatever follows the cursor (right click / Escape): the 3D object preview, or the tag.
6672
+ * Emits placementCancelled$ when something was actually running.
6673
+ */
6674
+ cancelCurrentPlacement() {
6675
+ const wasRunning = this.interactionMode !== ViewerInteractions.DEFAULT || !!this.object3DPlacement;
6676
+ // nothing follows the cursor: a plain right click must not remove the last (existing) tag
6677
+ if (!wasRunning)
6678
+ return;
6679
+ if (this.object3DPlacement) {
6680
+ this.cancelObject3DPlacement();
6681
+ }
6682
+ else {
6683
+ this.cancelFollowingCursor();
6684
+ }
6685
+ this.placementCancelled$.next();
6686
+ }
6687
+ /** Escape cancels any placement. Listens on the page and on the (same-origin) Showcase iframe, which takes the focus on click. */
6688
+ registerEscapeListener(iframe) {
6689
+ this.removeEscapeListener();
6690
+ const handler = (e) => {
6691
+ if (e.key !== 'Escape')
6692
+ return;
6693
+ if (this.interactionMode !== ViewerInteractions.DEFAULT || this.object3DPlacement) {
6694
+ this.cancelCurrentPlacement();
6695
+ }
6696
+ };
6697
+ const targets = [document];
6698
+ try {
6699
+ if (iframe?.contentWindow)
6700
+ targets.push(iframe.contentWindow);
6701
+ }
6702
+ catch { /* cross-origin iframe: page listener only */ }
6703
+ targets.forEach((target) => {
6704
+ target.addEventListener('keydown', handler);
6705
+ this.keydownTargets.push({ target, handler });
6706
+ });
6707
+ }
6708
+ removeEscapeListener() {
6709
+ this.keydownTargets.forEach(({ target, handler }) => target.removeEventListener('keydown', handler));
6710
+ this.keydownTargets = [];
6711
+ }
6712
+ ngOnDestroy() {
6713
+ this.cancelObject3DPlacement();
6714
+ this.removeEscapeListener();
6715
+ }
6716
+ //
6717
+ // ---------- Sweeps ----------
6718
+ //
6719
+ /**
6720
+ * Closest enabled sweep to a world position (for instance to re-assign the zone of a moved object).
6721
+ * `sweepId` is the DB-format id (like currentSweep), `sid` the SDK id. null when no sweep is known.
6722
+ */
6723
+ findNearestSweep(position) {
6724
+ const collection = { ...this.lockedSweepsSnapshot, ...(this.sweepCollection ?? {}) };
6725
+ const sids = Object.keys(collection).filter((sid) => collection[sid]?.position && collection[sid].enabled !== false);
6726
+ const best = pickNearestSweepOnSameStorey(sids.map((sid) => ({ sid, position: collection[sid].position, floorY: sweepFloorY(collection[sid]) })), position);
6727
+ const data = best ? collection[best] : null;
6728
+ if (!best)
6729
+ return null;
6730
+ return {
6731
+ sweepId: this.navigationService.resolveSweepUuid(best),
6732
+ sid: best,
6733
+ floorSequence: data.floorInfo?.sequence ?? (typeof data.floor === 'number' ? data.floor : null),
6734
+ };
6735
+ }
6736
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportService, deps: [{ token: 'config' }, { token: MATTERPORT_SDK_KEY }, { token: i1$1.Router }, { token: i1$1.ActivatedRoute }, { token: BaseVisibilityService }, { token: i0.NgZone }, { token: MatterportMeasurementService }, { token: MatterportNavigationService }, { token: MatterportPointerService }, { token: MatterportObject3DService }, { token: MatterportTagService }], target: i0.ɵɵFactoryTarget.Injectable });
6737
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportService, providedIn: 'root' });
6738
+ }
6739
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportService, decorators: [{
6740
+ type: Injectable,
6741
+ args: [{
6742
+ providedIn: 'root',
6743
+ }]
6744
+ }], ctorParameters: () => [{ type: Config, decorators: [{
6745
+ type: Inject,
6746
+ args: ['config']
6747
+ }] }, { type: undefined, decorators: [{
6748
+ type: Inject,
6749
+ args: [MATTERPORT_SDK_KEY]
6750
+ }] }, { type: i1$1.Router }, { type: i1$1.ActivatedRoute }, { type: BaseVisibilityService }, { type: i0.NgZone }, { type: MatterportMeasurementService }, { type: MatterportNavigationService }, { type: MatterportPointerService }, { type: MatterportObject3DService }, { type: MatterportTagService }] });
6751
+
6752
+ /**
6753
+ * Builds an orthonormal frame from three points, or null when they are (nearly) collinear.
6754
+ *
6755
+ * Three points define a plane, and a plane plus an origin defines an orientation. Collinear points
6756
+ * do not: they leave the rotation about their common line undetermined, which is why the caller has
6757
+ * to reject them rather than produce a confident wrong answer.
5784
6758
  */
5785
6759
  function frameFromPoints(a, b, c) {
5786
6760
  const e1 = b.clone().sub(a);
@@ -5895,6 +6869,15 @@ function solveReducedAlignment(pairs, up) {
5895
6869
  scale,
5896
6870
  };
5897
6871
  }
6872
+ /**
6873
+ * The 3D viewers' background where there is nothing to draw, unless the app sets its own as
6874
+ * --smarterplan-viewer-background: the SmarterPlan app's light blue, from the POC.
6875
+ */
6876
+ const VIEWER_BACKGROUND = '#e0f7ff';
6877
+ /** The 3D viewers' background as the app sets it, where the element is. */
6878
+ function viewerBackground(element) {
6879
+ return getComputedStyle(element).getPropertyValue('--smarterplan-viewer-background').trim() || VIEWER_BACKGROUND;
6880
+ }
5898
6881
  class ThreeJsService {
5899
6882
  ngZone;
5900
6883
  scene;
@@ -5913,6 +6896,17 @@ class ThreeJsService {
5913
6896
  objectClicked$ = new Subject();
5914
6897
  /** Emits when the user clicks a tag, mirroring Matterport's tag click. */
5915
6898
  tagClicked$ = new Subject();
6899
+ /**
6900
+ * Emits the tag the camera is sent to (goToTag): for a viewer drawing the model elsewhere, the
6901
+ * splat viewer, to point it out.
6902
+ */
6903
+ tagFocused$ = new Subject();
6904
+ /**
6905
+ * Emits when the tags change - added, moved, removed, filtered, calibrated differently - at most
6906
+ * once per turn of the event loop, however many changed in it.
6907
+ */
6908
+ tagsChanged$ = new Subject();
6909
+ tagsChangeQueued = false;
5916
6910
  /** Emits a tag's label while the pointer is over it, and null when it leaves. */
5917
6911
  tagHovered$ = new Subject();
5918
6912
  // Tags live under the model rather than the scene, so the centering and scaling applied when the
@@ -5922,6 +6916,22 @@ class ThreeJsService {
5922
6916
  canvasResizeObserver;
5923
6917
  /** Aborts the in-flight model download. Parsing cannot be interrupted, only not started. */
5924
6918
  loadAbortController;
6919
+ /**
6920
+ * Which load is the wanted one. Every load takes the next number as it starts, and anything that
6921
+ * makes it unwanted - another load, the scene being rebuilt, the viewer closing - moves the
6922
+ * number on.
6923
+ *
6924
+ * Aborting the download is not enough to stop a load. A model read from the cache has no
6925
+ * download to abort, and one that has finished downloading still has seconds of work ahead of it
6926
+ * before it reaches the screen. A load that finds the number has moved on drops what it has.
6927
+ */
6928
+ loadGeneration = 0;
6929
+ /** Whether the wanted load is still on its way, so abandoning it can say that it stopped. */
6930
+ loadInProgress = false;
6931
+ /** Whether a scene is up: started by init() and not yet taken down by destroy(). */
6932
+ live = false;
6933
+ /** Takes off each listener init() put on the canvas. */
6934
+ canvasListeners = [];
5925
6935
  // The loaded base model, kept so tag placement can raycast against its geometry.
5926
6936
  modelRoot;
5927
6937
  /** True while the next click on the model places a tag instead of orbiting. */
@@ -5943,6 +6953,12 @@ class ThreeJsService {
5943
6953
  * placed in this one are told from those that came from elsewhere.
5944
6954
  */
5945
6955
  currentCaptureID = null;
6956
+ /**
6957
+ * Whether the visit on screen is the only one the site has. A tag records the visit it was placed
6958
+ * in, but some do not - placed before that was recorded, or in the development model. With
6959
+ * another visit around, such a tag is taken to come from it. With none, it can only be this one's.
6960
+ */
6961
+ soleVisit = false;
5946
6962
  /**
5947
6963
  * The model's surface as a grid: every cell some part of the surface passes through.
5948
6964
  *
@@ -5987,6 +7003,17 @@ class ThreeJsService {
5987
7003
  modelUp = 'y';
5988
7004
  /** While measuring: how many points and each segment's length. Null when it is abandoned. */
5989
7005
  measurementProgress$ = new Subject();
7006
+ /**
7007
+ * True when a measurement starts taking clicks, false when it stops - finished or abandoned. For a
7008
+ * viewer drawing the model elsewhere, which takes the clicks itself; see addMeasurePointAtModelPoint.
7009
+ */
7010
+ measuringChanged$ = new Subject();
7011
+ /**
7012
+ * A measurement - under way or finished - was taken off the model. For a viewer drawing it
7013
+ * elsewhere, the splat viewer: a finished one stays drawn while its form is open, which
7014
+ * measurementProgress$ does not say the end of.
7015
+ */
7016
+ measurementCleared$ = new Subject();
5990
7017
  /**
5991
7018
  * Layer carrying the model alone, so the depth pick can render it without the tags.
5992
7019
  *
@@ -5994,8 +7021,18 @@ class ThreeJsService {
5994
7021
  * their parent passed, so marking the model's own meshes is enough - no re-parenting.
5995
7022
  */
5996
7023
  static PICK_LAYER = 1;
7024
+ /** How large a tag is drawn, in CSS pixels. About what Matterport draws its own at. */
7025
+ static TAG_SCREEN_PX = 28;
5997
7026
  /** The app's highlight colour, so a measurement reads as the active thing on screen. */
5998
7027
  static MEASURE_COLOR = '#FFB103';
7028
+ /** Green for the points of a calibration that are settled, as opposed to the one in hand. */
7029
+ static CALIBRATION_DONE_COLOR = '#1E9E5A';
7030
+ /** Size on screen of the numbered pin left where a tag was matched: a little under a tag's. */
7031
+ static CALIBRATION_PIN_PX = 24;
7032
+ /** How long a mark just dropped takes to settle to its size, in milliseconds. */
7033
+ static CONFIRM_PULSE_MS = 350;
7034
+ /** How many matched tags a calibration takes. Three settle everything there is to settle. */
7035
+ static CALIBRATION_PAIRS = 3;
5999
7036
  /** Cache Storage bucket holding downloaded models, and how many to keep. */
6000
7037
  static MODEL_CACHE = 'smarterplan-3d-models';
6001
7038
  static MODEL_CACHE_SIZE = 3;
@@ -6010,12 +7047,36 @@ class ThreeJsService {
6010
7047
  /** Translucent marker shown under the cursor while placing. */
6011
7048
  placementPreview;
6012
7049
  aligning = false;
7050
+ /**
7051
+ * True from the moment the calibration panel opens, before the walkthrough is armed: the tags
7052
+ * from elsewhere are all on show, as they are while calibrating, so that what there is to
7053
+ * calibrate with can be seen while reading what calibrating involves.
7054
+ */
7055
+ revealingCalibrationTags = false;
6013
7056
  alignmentPairs = [];
6014
7057
  alignmentSource = null;
6015
7058
  /** The tag `alignmentSource` was read from, kept so the same tag is not matched twice. */
6016
7059
  alignmentSourceTagId;
7060
+ /**
7061
+ * Everything drawn for the calibration under way: a numbered pin where each tag was matched, a
7062
+ * line from the pin to its tag, and a ring round the tag being matched.
7063
+ *
7064
+ * A sibling of the tag group rather than a child of it. The points clicked are where the tags are
7065
+ * to end up, in the frame the calibration maps onto (see pickAlignmentTarget) - not in the tags'
7066
+ * own frame, which is the one about to change.
7067
+ */
7068
+ calibrationGroup = new THREE.Group();
7069
+ /** Marks still settling after being dropped, advanced by the render loop. */
7070
+ pulses = [];
6017
7071
  /** Drives the alignment walkthrough's UI: how many pairs are done and what it wants next. */
6018
7072
  alignmentProgress$ = new Subject();
7073
+ /**
7074
+ * Emits when a click during calibration did nothing, with what it was expected to land on.
7075
+ *
7076
+ * A click that changes nothing looks the same as a click that was not received. This lets the
7077
+ * panel point at what it is waiting for instead.
7078
+ */
7079
+ alignmentClickIgnored$ = new Subject();
6019
7080
  /** In-flight camera move started by goToTag, advanced by the render loop. */
6020
7081
  cameraTween = null;
6021
7082
  /** Emits true while a heavy OBJ file is being parsed (in a worker, when available), false once done. */
@@ -6127,15 +7188,30 @@ class ThreeJsService {
6127
7188
  // by then, so the model being abandoned would be handed to the scene built below.
6128
7189
  // - The tag group, which is a field and keeps its sprites. They would be re-parented under the
6129
7190
  // new model and show the previous visit's tags on it.
7191
+ // Usually destroy() ran before this and there is nothing left to stop. When it did not, the
7192
+ // scene before would carry on beside the one started below: its render loop drawing every
7193
+ // frame as well, its model still on the graphics card, its listeners answering every click a
7194
+ // second time.
7195
+ if (this.live) {
7196
+ this.destroy();
7197
+ }
6130
7198
  this.discardCurrentLoad();
6131
7199
  this.clearTags();
6132
7200
  this.measuring = false;
6133
7201
  this.clearMeasurement();
7202
+ // A calibration left half-done belongs to the scene it was started in: its points mean nothing
7203
+ // in the next one, and left armed it would take that scene's first clicks for itself.
7204
+ this.aligning = false;
7205
+ this.revealingCalibrationTags = false;
7206
+ this.soleVisit = false;
7207
+ this.alignmentPairs = [];
7208
+ this.clearAlignmentSource();
7209
+ this.clearCalibrationMarks();
7210
+ this.pulses = [];
6134
7211
  this.cameraTween = null;
6135
- this.modelRoot = undefined;
6136
7212
  // 1. Setup Scene
6137
7213
  this.scene = new THREE.Scene();
6138
- this.scene.background = new THREE.Color(0xe0f7ff); // from POC
7214
+ this.scene.background = new THREE.Color(viewerBackground(canvas));
6139
7215
  // 2. Setup Camera
6140
7216
  this.camera = new THREE.PerspectiveCamera(75, canvas.clientWidth / canvas.clientHeight, 0.1, 10000);
6141
7217
  this.camera.position.set(0, 5, 10); // will be overridden later if passed
@@ -6180,7 +7256,14 @@ class ThreeJsService {
6180
7256
  // A light only contributes while it is part of the scene graph, and this one hangs off the camera.
6181
7257
  this.scene.add(this.camera);
6182
7258
  // 5. Setup Controls (from POC)
6183
- this.controls = new OrbitControls(this.camera, this.renderer.domElement);
7259
+ //
7260
+ // Created outside Angular's zone, as is the pointer-move listener further down. The controls
7261
+ // listen to every pointer move and wheel tick, and inside the zone each of those events had
7262
+ // Angular re-check the whole page - dozens of times a second while the camera is dragged,
7263
+ // competing with the frames being drawn - for a change that only ever concerns the canvas.
7264
+ this.ngZone.runOutsideAngular(() => {
7265
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
7266
+ });
6184
7267
  this.controls.rotateSpeed = 1.0;
6185
7268
  this.controls.dampingFactor = 0.1;
6186
7269
  this.controls.enableDamping = true;
@@ -6204,9 +7287,18 @@ class ThreeJsService {
6204
7287
  }, 250);
6205
7288
  }
6206
7289
  });
6207
- // Interaction Event Listeners on canvas
6208
- canvas.addEventListener('click', this.onMouseClick.bind(this), false);
6209
- canvas.addEventListener('mousemove', this.onMouseMove.bind(this), false);
7290
+ // Interaction Event Listeners on canvas. Kept so that destroy() can take them off again: the
7291
+ // same canvas is used again when one 3D model is swapped for another, and a second set of
7292
+ // listeners would answer every click twice.
7293
+ const listen = (type, handler) => {
7294
+ canvas.addEventListener(type, handler, false);
7295
+ this.canvasListeners.push(() => canvas.removeEventListener(type, handler, false));
7296
+ };
7297
+ // A click changes what the page shows - a panel opens, a point is recorded - so Angular has to
7298
+ // hear of it, wherever init() itself was called from.
7299
+ listen('click', (event) => this.ngZone.run(() => this.onMouseClick(event)));
7300
+ // Moving the pointer does not: it moves the ghost and the tooltip, both drawn here.
7301
+ this.ngZone.runOutsideAngular(() => listen('mousemove', (event) => this.onMouseMove(event)));
6210
7302
  // Escape backs out of placement and calibration. On the window rather than the canvas, which
6211
7303
  // would need focus the user has no reason to have given it - they are aiming with the mouse.
6212
7304
  // Removed first because this service is a singleton: init() runs again for every canvas, and
@@ -6219,6 +7311,7 @@ class ThreeJsService {
6219
7311
  this.pickCamera = undefined;
6220
7312
  window.addEventListener('keydown', this.onKeyDown);
6221
7313
  // 6. Start the render loop
7314
+ this.live = true;
6222
7315
  this.ngZone.runOutsideAngular(() => {
6223
7316
  this.animate();
6224
7317
  });
@@ -6255,33 +7348,25 @@ class ThreeJsService {
6255
7348
  }
6256
7349
  // Lets gltfpack/gltf-transform output using EXT_meshopt_compression load without extra setup.
6257
7350
  loader.setMeshoptDecoder(MeshoptDecoder);
6258
- this.modelLoading$.next(true);
7351
+ const wanted = this.beginLoad();
6259
7352
  this.modelLoadProgress$.next(0);
6260
7353
  // Order matters: normals must exist before anisotropy, and the Phong conversion has to run
6261
7354
  // before side/flatShading are touched so those settings land on the materials we keep.
6262
7355
  const onLoaded = (gltf) => {
7356
+ if (!wanted()) {
7357
+ // Built for a request that has since been replaced or abandoned. It was never on screen;
7358
+ // what it holds is freed here because nothing else ever will.
7359
+ this.disposeModel(gltf.scene);
7360
+ return;
7361
+ }
6263
7362
  this.convertToPhongMaterials(gltf.scene);
6264
7363
  this.forceDoubleSidedMaterials(gltf.scene);
6265
7364
  this.ensureVertexNormals(gltf.scene);
6266
7365
  this.tuneTextureSampling(gltf.scene);
6267
7366
  this.placeLoadedObjectInScene(gltf.scene, cameraPosition, rotation, up);
6268
- this.modelLoading$.next(false);
6269
- };
6270
- const onError = (error) => {
6271
- // Cancelling is a decision, not a failure: clear the overlay without an error in the console.
6272
- if (error?.name === 'AbortError') {
6273
- // Only a cancel of the load that is still current came from the user; anything else is this
6274
- // load having been superseded or discarded.
6275
- if (this.loadAbortController?.signal.aborted) {
6276
- this.modelLoadCancelled$.next();
6277
- }
6278
- }
6279
- else {
6280
- console.error('Error loading GLTF model:', error);
6281
- }
6282
- this.modelLoading$.next(false);
6283
- this.modelLoadProgress$.next(null);
7367
+ this.endLoad();
6284
7368
  };
7369
+ const onError = (error) => this.loadFailed(wanted, error, 'GLTF model');
6285
7370
  // GLTFLoader decodes embedded images with createImageBitmap when the browser offers it, and on
6286
7371
  // large texture atlases that path returns visibly corrupt data - scattered white blocks across
6287
7372
  // the model. The exact same bytes decoded through an HTMLImageElement, which is what the .obj
@@ -6299,7 +7384,8 @@ class ThreeJsService {
6299
7384
  .then((buffer) => {
6300
7385
  // parse() cannot be interrupted, so a cancel that lands during the paint yield has to be
6301
7386
  // caught here - the last moment before the main thread is committed for several seconds.
6302
- if (this.loadAbortController?.signal.aborted) {
7387
+ // The same goes for a load nobody is waiting for any more.
7388
+ if (!wanted() || this.loadAbortController?.signal.aborted) {
6303
7389
  throw new DOMException('Model load cancelled', 'AbortError');
6304
7390
  }
6305
7391
  const globalScope = globalThis;
@@ -6314,6 +7400,28 @@ class ThreeJsService {
6314
7400
  })
6315
7401
  .catch(onError);
6316
7402
  }
7403
+ /** Ends a load that failed, unless another load has replaced it in the meantime. */
7404
+ loadFailed(wanted, error, what) {
7405
+ if (!wanted()) {
7406
+ // Whatever replaced this load owns the loading indicator now; ending it from here would
7407
+ // switch the indicator off while that one is still on its way.
7408
+ return;
7409
+ }
7410
+ this.loadInProgress = false;
7411
+ // Cancelling is a decision, not a failure: clear the overlay without an error in the console.
7412
+ if (error?.name === 'AbortError') {
7413
+ // Only a cancel of the load that is still current came from the user; anything else is this
7414
+ // load having been superseded or discarded.
7415
+ if (this.loadAbortController?.signal.aborted) {
7416
+ this.modelLoadCancelled$.next();
7417
+ }
7418
+ }
7419
+ else {
7420
+ console.error(`Error loading ${what}:`, error);
7421
+ }
7422
+ this.modelLoading$.next(false);
7423
+ this.modelLoadProgress$.next(null);
7424
+ }
6317
7425
  /**
6318
7426
  * Stops the model download in progress, if any.
6319
7427
  *
@@ -6332,8 +7440,30 @@ class ThreeJsService {
6332
7440
  * scene is not something the person asked for, so it must not surface as "loading cancelled".
6333
7441
  */
6334
7442
  discardCurrentLoad() {
7443
+ this.loadGeneration += 1;
6335
7444
  this.loadAbortController?.abort();
6336
7445
  this.loadAbortController = undefined;
7446
+ if (this.loadInProgress) {
7447
+ this.loadInProgress = false;
7448
+ this.modelLoading$.next(false);
7449
+ this.modelLoadProgress$.next(null);
7450
+ }
7451
+ }
7452
+ /**
7453
+ * Starts a load, replacing whichever was under way, and returns the question it has to keep
7454
+ * asking: is it still the one that is wanted?
7455
+ */
7456
+ beginLoad() {
7457
+ this.loadGeneration += 1;
7458
+ const generation = this.loadGeneration;
7459
+ this.loadInProgress = true;
7460
+ this.modelLoading$.next(true);
7461
+ return () => generation === this.loadGeneration;
7462
+ }
7463
+ /** Marks the wanted load as over, whichever way it ended. */
7464
+ endLoad() {
7465
+ this.loadInProgress = false;
7466
+ this.modelLoading$.next(false);
6337
7467
  }
6338
7468
  /**
6339
7469
  * Resolves once the browser has painted at least one frame.
@@ -6364,6 +7494,11 @@ class ThreeJsService {
6364
7494
  this.loadAbortController = new AbortController();
6365
7495
  const { signal } = this.loadAbortController;
6366
7496
  const cached = await this.readModelFromCache(cacheKey);
7497
+ if (signal.aborted) {
7498
+ // Replaced or abandoned while the cache was being read. With nothing being downloaded, this
7499
+ // is the only place a load served from the cache can find that out.
7500
+ throw new DOMException('Model load cancelled', 'AbortError');
7501
+ }
6367
7502
  if (cached) {
6368
7503
  this.modelLoadProgress$.next(1);
6369
7504
  return cached;
@@ -6704,12 +7839,25 @@ class ThreeJsService {
6704
7839
  const basePath = mtlUrl.substring(0, mtlUrl.lastIndexOf('/') + 1);
6705
7840
  mtlLoader.setPath(basePath);
6706
7841
  const mtlFileName = mtlUrl.substring(mtlUrl.lastIndexOf('/') + 1);
6707
- this.modelLoading$.next(true);
7842
+ // These loaders cannot be interrupted, so a load that is no longer wanted runs to its end and
7843
+ // is dropped there - see loadGeneration.
7844
+ const wanted = this.beginLoad();
7845
+ const finish = () => {
7846
+ if (wanted()) {
7847
+ this.endLoad();
7848
+ }
7849
+ };
6708
7850
  mtlLoader.load(mtlFileName, (materials) => {
7851
+ if (!wanted()) {
7852
+ return;
7853
+ }
6709
7854
  materials.preload();
6710
7855
  if (this.objParserWorker) {
6711
7856
  this.requestObjParseInWorker(objUrl)
6712
7857
  .then((meshes) => {
7858
+ if (!wanted()) {
7859
+ return;
7860
+ }
6713
7861
  const group = new THREE.Group();
6714
7862
  meshes.forEach((meshData) => {
6715
7863
  group.add(this.meshFromParsedData(meshData, materials));
@@ -6719,25 +7867,42 @@ class ThreeJsService {
6719
7867
  .catch((error) => {
6720
7868
  console.error('Error parsing OBJ model in worker:', error);
6721
7869
  })
6722
- .finally(() => this.modelLoading$.next(false));
7870
+ .finally(finish);
6723
7871
  return;
6724
7872
  }
6725
7873
  const objLoader = new OBJLoader();
6726
7874
  objLoader.setMaterials(materials);
6727
7875
  objLoader.load(objUrl, (object) => {
7876
+ if (!wanted()) {
7877
+ this.disposeModel(object);
7878
+ return;
7879
+ }
6728
7880
  this.placeLoadedObjectInScene(object, cameraPosition, rotation);
6729
- this.modelLoading$.next(false);
7881
+ finish();
6730
7882
  }, undefined, (error) => {
6731
7883
  console.error('Error loading OBJ model:', error);
6732
- this.modelLoading$.next(false);
7884
+ finish();
6733
7885
  });
6734
7886
  }, undefined, (error) => {
6735
7887
  console.error('Error loading MTL file:', error);
6736
- this.modelLoading$.next(false);
7888
+ finish();
6737
7889
  });
6738
7890
  }
6739
7891
  /** Centers, scales and adds a loaded object to the scene, then frames the camera on it. */
6740
7892
  placeLoadedObjectInScene(object, cameraPosition, rotation, up) {
7893
+ // A scene shows one model. Whatever was there is taken out and freed first, or the two would be
7894
+ // drawn through each other, every frame, for as long as the viewer stays open.
7895
+ if (this.modelRoot) {
7896
+ // Points being clicked for a calibration or a measurement were clicked on the model that is
7897
+ // about to go; they say nothing about the one arriving.
7898
+ if (this.aligning) {
7899
+ this.cancelTagAlignment();
7900
+ }
7901
+ if (this.measuring) {
7902
+ this.cancelMeasuring();
7903
+ }
7904
+ }
7905
+ this.removeModel();
6741
7906
  const box = new THREE.Box3().setFromObject(object);
6742
7907
  const center = box.getCenter(new THREE.Vector3());
6743
7908
  const size = box.getSize(new THREE.Vector3());
@@ -6774,8 +7939,9 @@ class ThreeJsService {
6774
7939
  group.add(centered);
6775
7940
  group.scale.setScalar(scale);
6776
7941
  this.scene.add(group);
6777
- // Tags registered before the model finished loading were sized against the old scale.
6778
- this.tagSprites.forEach((sprite) => this.scaleTagForModel(sprite));
7942
+ this.scene.updateMatrixWorld(true);
7943
+ // Markers registered before the model finished loading were sized against the old scale.
7944
+ this.refreshScreenSizes();
6779
7945
  this.adjustClippingPlanes((size.length() / 2) * scale);
6780
7946
  if (cameraPosition) {
6781
7947
  this.camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z);
@@ -6791,16 +7957,25 @@ class ThreeJsService {
6791
7957
  * not clear enough to act on.
6792
7958
  *
6793
7959
  * Models arrive in two conventions: Y up (glTF, and what this viewer assumes) and Z up (most CAD
6794
- * and scanning exports). A Z-up model loads lying on its side. Two independent cues say which
6795
- * axis is the vertical one, and both have to agree before anything is turned:
7960
+ * and scanning exports). A Z-up model loads lying on its side. Three independent cues say which
7961
+ * axis is the vertical one:
6796
7962
  *
6797
7963
  * - Surface area. Floors and ceilings all face along the vertical, while walls are shared out
6798
7964
  * between the two horizontal axes, so the vertical usually collects the most area.
6799
7965
  * - Extent. A building is normally wider than it is tall, so the vertical is its shortest side.
7966
+ * - Openness. A capture made from outside has ground and roofs but nothing underneath them, so
7967
+ * along the vertical nearly all of its surface faces one way; walls face both ways evenly.
7968
+ *
7969
+ * Each is wrong often enough on its own - a long corridor has more wall than floor, a tower is
7970
+ * taller than it is wide - so a cue only counts when its answer is clear-cut, and the model is
7971
+ * turned when the clear answers agree. It used to take area and extent agreeing with each other,
7972
+ * which failed on exactly the models that need this most: in a whole building or a city block
7973
+ * there is as much wall as floor, the area cue comes out a three-way tie, and a model 130 m wide
7974
+ * and 34 m tall with 86% of its surface facing one way was left lying on its side.
6800
7975
  *
6801
- * Either alone is wrong often enough to matter - a long corridor has more wall than floor, a
6802
- * tower is taller than it is wide - and turning a model that was fine is worse than leaving one
6803
- * that was not. A model that already has Y as its vertical is never touched.
7976
+ * Turning a model that was fine is worse than leaving one that was not: when the clear answers
7977
+ * contradict each other, or there is none, nothing is turned. A model that already has Y as its
7978
+ * vertical is never touched.
6804
7979
  */
6805
7980
  detectModelUp(object, size) {
6806
7981
  const axes = ['x', 'y', 'z'];
@@ -6815,6 +7990,7 @@ class ThreeJsService {
6815
7990
  }
6816
7991
  });
6817
7992
  if (triangleCount === 0) {
7993
+ // Nothing with faces to weigh: the convention.
6818
7994
  return 'y';
6819
7995
  }
6820
7996
  // A sample is plenty to see where the area lies, and keeps this cheap on models with millions
@@ -6845,16 +8021,45 @@ class ThreeJsService {
6845
8021
  }
6846
8022
  });
6847
8023
  const total = { x: area.x[0] + area.x[1], y: area.y[0] + area.y[1], z: area.z[0] + area.z[1] };
6848
- const byArea = axes.reduce((best, axis) => (total[axis] > total[best] ? axis : best), 'y');
6849
- const byExtent = axes.reduce((best, axis) => (size[axis] < size[best] ? axis : best), 'y');
6850
- if (byArea === 'y' || byArea !== byExtent || total[byArea] < 1.25 * total.y) {
8024
+ const extent = { x: size.x, y: size.y, z: size.z };
8025
+ // How one-sided the surface is along each axis: 0 when it faces both ways evenly, 1 when it
8026
+ // all faces the same way.
8027
+ const lopsided = { x: 0, y: 0, z: 0 };
8028
+ axes.forEach((axis) => {
8029
+ lopsided[axis] = total[axis] > 0 ? Math.abs(area[axis][0] - area[axis][1]) / total[axis] : 0;
8030
+ });
8031
+ /** The axes ordered by a measure, largest first. */
8032
+ const ranked = (measure) => [...axes].sort((first, second) => measure[second] - measure[first]);
8033
+ const [mostArea, nextArea] = ranked(total);
8034
+ const [, middleExtent, shortest] = ranked(extent);
8035
+ const [mostOpen, nextOpen] = ranked(lopsided);
8036
+ // What each cue says, when it says it clearly; null when it has no clear answer.
8037
+ const areaSays = total[mostArea] >= 1.25 * total[nextArea] ? mostArea : null;
8038
+ const extentSays = extent[middleExtent] >= 1.5 * extent[shortest] ? shortest : null;
8039
+ const opennessSays = lopsided[mostOpen] >= 0.5
8040
+ && lopsided[mostOpen] >= 2 * lopsided[nextOpen]
8041
+ // Not on the strength of a few stray triangles: the surface along that axis has to matter.
8042
+ && total[mostOpen] >= 0.25 * total[mostArea]
8043
+ ? mostOpen
8044
+ : null;
8045
+ const clear = [areaSays, extentSays, opennessSays].filter((axis) => axis !== null);
8046
+ let vertical = 'y';
8047
+ if (clear.length > 0) {
8048
+ // Clear answers that contradict each other are no answer.
8049
+ vertical = clear.every((axis) => axis === clear[0]) ? clear[0] : 'y';
8050
+ }
8051
+ else if (mostArea === shortest && total[mostArea] >= 1.25 * total.y) {
8052
+ // Nothing clear-cut, but area and extent lean the same way and away from Y.
8053
+ vertical = mostArea;
8054
+ }
8055
+ if (vertical === 'y') {
6851
8056
  return 'y';
6852
8057
  }
6853
8058
  // Which way along that axis is up. Roofs and ground make an outdoor model face mostly upward,
6854
8059
  // which settles it; indoors floors and ceilings balance out, and the convention - positive is
6855
8060
  // up - is the better guess.
6856
- const [positive, negative] = area[byArea];
6857
- const up = `${negative > 0.65 * (positive + negative) ? '-' : ''}${byArea}`;
8061
+ const [positive, negative] = area[vertical];
8062
+ const up = `${negative > 0.65 * (positive + negative) ? '-' : ''}${vertical}`;
6858
8063
  console.info(`ThreeJsService: this model's vertical axis looks like ${up}, not y - standing it upright.`);
6859
8064
  return up;
6860
8065
  }
@@ -6904,6 +8109,7 @@ class ThreeJsService {
6904
8109
  this.camera.updateProjectionMatrix();
6905
8110
  this.renderer.setSize(width, height);
6906
8111
  this.updateMeasureLineResolution();
8112
+ this.refreshScreenSizes();
6907
8113
  });
6908
8114
  this.canvasResizeObserver.observe(container);
6909
8115
  }
@@ -6914,6 +8120,34 @@ class ThreeJsService {
6914
8120
  * only has to supply the matching spot on the model. No new records are written; nothing is
6915
8121
  * applied until solveAlignmentFromPairs() succeeds.
6916
8122
  */
8123
+ /**
8124
+ * Shows every tag that came from elsewhere, whether or not it lands on the model, without arming
8125
+ * anything: clicks still do what they always do.
8126
+ *
8127
+ * Calibrating shows them too, and ending a calibration - saved or abandoned - puts them back
8128
+ * under the usual rule, so this only ever has to be switched on.
8129
+ */
8130
+ revealCalibrationTags(revealed = true) {
8131
+ this.revealingCalibrationTags = revealed;
8132
+ this.refreshTagVisibility();
8133
+ // Another viewer showing the tags shows these as well now; see showsCalibrationTags.
8134
+ this.announceTagsChanged();
8135
+ }
8136
+ /**
8137
+ * Whether the tags a calibration works with are on show - while one is under way, or its panel
8138
+ * open - including those that do not land on the model. For another viewer showing the tags,
8139
+ * which otherwise leaves out the ones from elsewhere until the model is calibrated.
8140
+ */
8141
+ showsCalibrationTags() {
8142
+ return this.aligning || this.revealingCalibrationTags;
8143
+ }
8144
+ /** Says a tag was clicked in a viewer drawing the model elsewhere, as a click on it here would. */
8145
+ clickTag(elementId) {
8146
+ const sprite = this.tagSprites.get(elementId);
8147
+ if (sprite) {
8148
+ this.tagClicked$.next({ elementId, poiType: sprite.userData.poiType ?? null });
8149
+ }
8150
+ }
6917
8151
  startTagAlignment() {
6918
8152
  // One pointer mode at a time, the same rule startMeasuring() applies the other way round.
6919
8153
  if (this.measuring) {
@@ -6921,27 +8155,28 @@ class ThreeJsService {
6921
8155
  }
6922
8156
  this.aligning = true;
6923
8157
  this.alignmentPairs = [];
6924
- this.alignmentSource = null;
8158
+ this.clearAlignmentSource();
6925
8159
  // While calibrating, the tags from elsewhere are all shown - including the ones off the model,
6926
8160
  // which are exactly the ones a calibration is for.
6927
8161
  this.refreshTagVisibility();
6928
8162
  if (this.renderer) {
6929
8163
  this.renderer.domElement.style.cursor = 'crosshair';
6930
8164
  }
6931
- this.alignmentProgress$.next({ pairs: 0, awaiting: 'tag' });
8165
+ this.alignmentChanged();
8166
+ this.announceTagsChanged();
6932
8167
  }
6933
8168
  cancelTagAlignment() {
6934
8169
  const wasAligning = this.aligning;
6935
8170
  this.aligning = false;
8171
+ this.revealingCalibrationTags = false;
6936
8172
  this.alignmentPairs = [];
6937
- this.alignmentSource = null;
6938
- this.pendingPreviewEvent = null;
6939
- this.removePlacementPreview();
8173
+ this.clearAlignmentSource();
6940
8174
  this.refreshTagVisibility();
6941
8175
  if (this.renderer) {
6942
8176
  this.renderer.domElement.style.cursor = 'default';
6943
8177
  }
6944
- this.alignmentProgress$.next({ pairs: 0, awaiting: null });
8178
+ this.alignmentChanged();
8179
+ this.announceTagsChanged();
6945
8180
  if (wasAligning) {
6946
8181
  this.alignmentCancelled$.next();
6947
8182
  }
@@ -6989,11 +8224,10 @@ class ThreeJsService {
6989
8224
  // Out of calibrating mode first: applying the result re-decides which tags show, and from here
6990
8225
  // on that is the ones on the model again, not all of them.
6991
8226
  this.aligning = false;
8227
+ this.revealingCalibrationTags = false;
6992
8228
  this.applyAlignmentKeepingOwnTags(alignment);
6993
- this.pendingPreviewEvent = null;
6994
- this.removePlacementPreview();
6995
8229
  this.renderer.domElement.style.cursor = 'default';
6996
- this.alignmentProgress$.next({ pairs: this.alignmentPairs.length, awaiting: null });
8230
+ this.alignmentChanged();
6997
8231
  return alignment;
6998
8232
  }
6999
8233
  /**
@@ -7052,7 +8286,8 @@ class ThreeJsService {
7052
8286
  */
7053
8287
  selectAlignmentTag(elementId) {
7054
8288
  const sprite = this.tagSprites.get(elementId);
7055
- if (!this.aligning || this.alignmentSource || !sprite || !this.calibrationCandidates().includes(sprite)) {
8289
+ if (!this.aligning || this.alignmentSource || !this.canMatchAnotherTag()
8290
+ || !sprite || !this.calibrationCandidates().includes(sprite)) {
7056
8291
  return false;
7057
8292
  }
7058
8293
  this.setAlignmentSource(sprite);
@@ -7064,18 +8299,28 @@ class ThreeJsService {
7064
8299
  const stored = sprite.position;
7065
8300
  this.alignmentSource = { x: stored.x, y: stored.y, z: stored.z };
7066
8301
  this.alignmentSourceTagId = sprite.name;
7067
- this.alignmentProgress$.next({
7068
- pairs: this.alignmentPairs.length,
7069
- awaiting: 'point',
7070
- tagId: sprite.name,
7071
- });
8302
+ this.alignmentChanged('tag');
8303
+ }
8304
+ clearAlignmentSource() {
8305
+ this.alignmentSource = null;
8306
+ this.alignmentSourceTagId = undefined;
8307
+ }
8308
+ /** Whether the calibration still has room for a pair and a tag to make it from. */
8309
+ canMatchAnotherTag() {
8310
+ return this.alignmentPairs.length < ThreeJsService.CALIBRATION_PAIRS
8311
+ && this.calibrationCandidates().length > 0;
7072
8312
  }
7073
8313
  /** Handles a click while the alignment walkthrough is running. */
7074
8314
  handleAlignmentClick(event) {
7075
8315
  if (!this.alignmentSource) {
7076
8316
  // First half of a pair: which tag are we talking about?
8317
+ if (!this.canMatchAnotherTag()) {
8318
+ // Nothing left to match: what remains is to save, or to take a pair back.
8319
+ return;
8320
+ }
7077
8321
  const hit = this.raycaster.intersectObjects(this.calibrationCandidates(), false)[0];
7078
8322
  if (!hit) {
8323
+ this.alignmentClickIgnored$.next('tag');
7079
8324
  return;
7080
8325
  }
7081
8326
  this.setAlignmentSource(hit.object);
@@ -7084,18 +8329,220 @@ class ThreeJsService {
7084
8329
  // Second half: where that tag actually belongs on the model.
7085
8330
  const target = this.pickAlignmentTarget(event);
7086
8331
  if (!target) {
8332
+ this.alignmentClickIgnored$.next('point');
7087
8333
  return;
7088
8334
  }
7089
8335
  this.alignmentPairs.push({ tagId: this.alignmentSourceTagId, source: this.alignmentSource, target });
7090
- this.alignmentSource = null;
7091
- // The ghost belongs to the click that just happened; the next half is a click on a tag.
8336
+ this.clearAlignmentSource();
8337
+ this.alignmentChanged('point');
8338
+ }
8339
+ /**
8340
+ * Records where the tag being matched really is, from a spot another viewer picked on the model -
8341
+ * the splat viewer - in the model's own coordinates: the frame pickAlignmentTarget measures a
8342
+ * click in, as placeTagAtModelPoint takes it. False when no tag is waiting for its spot.
8343
+ */
8344
+ matchAlignmentTagAtModelPoint(point) {
8345
+ if (!this.aligning || !this.alignmentSource) {
8346
+ return false;
8347
+ }
8348
+ this.alignmentPairs.push({
8349
+ tagId: this.alignmentSourceTagId,
8350
+ source: this.alignmentSource,
8351
+ target: { x: point.x, y: point.y, z: point.z },
8352
+ });
8353
+ this.clearAlignmentSource();
8354
+ this.alignmentChanged('point');
8355
+ return true;
8356
+ }
8357
+ /**
8358
+ * Takes back the last thing done in the calibration under way: the choice of tag while its place
8359
+ * on the model is being asked for, the last matched pair otherwise.
8360
+ *
8361
+ * One step at a time, so a click made by mistake costs that click and not the whole calibration.
8362
+ * Returns false when there is nothing to take back.
8363
+ */
8364
+ undoAlignmentStep() {
8365
+ if (!this.aligning) {
8366
+ return false;
8367
+ }
8368
+ if (this.alignmentSource) {
8369
+ this.clearAlignmentSource();
8370
+ }
8371
+ else if (this.alignmentPairs.length > 0) {
8372
+ this.alignmentPairs.pop();
8373
+ }
8374
+ else {
8375
+ return false;
8376
+ }
8377
+ this.alignmentChanged();
8378
+ return true;
8379
+ }
8380
+ /**
8381
+ * Removes one matched pair, whichever it is, putting its tag back on offer.
8382
+ *
8383
+ * For the point noticed to be wrong only after others were matched: taking steps back one by one
8384
+ * to reach it would throw away the good ones made since.
8385
+ */
8386
+ removeAlignmentPair(index) {
8387
+ if (!this.aligning || !Number.isInteger(index) || index < 0 || index >= this.alignmentPairs.length) {
8388
+ return false;
8389
+ }
8390
+ this.alignmentPairs.splice(index, 1);
8391
+ this.alignmentChanged();
8392
+ return true;
8393
+ }
8394
+ /** The tags matched so far in the calibration under way, in the order they are numbered in. */
8395
+ getAlignmentPairs() {
8396
+ return this.alignmentPairs.map((pair) => {
8397
+ const sprite = this.tagSprites.get(pair.tagId);
8398
+ return {
8399
+ tagId: pair.tagId,
8400
+ label: sprite?.userData.label ?? null,
8401
+ poiType: sprite?.userData.poiType ?? null,
8402
+ target: { ...pair.target },
8403
+ };
8404
+ });
8405
+ }
8406
+ /**
8407
+ * Brings the scene and the panel in line with the calibration's state, after anything changed it.
8408
+ *
8409
+ * `dropped` names the mark the change put down, if it put one down, so that it can be shown
8410
+ * arriving: that movement is what tells someone their click was taken.
8411
+ */
8412
+ alignmentChanged(dropped) {
8413
+ // The ghost previews the pin about to be dropped, number included, so it is never carried over
8414
+ // from one state to the next; the next pointer move draws the right one.
7092
8415
  this.pendingPreviewEvent = null;
7093
8416
  this.removePlacementPreview();
8417
+ if (this.aligning && this.renderer) {
8418
+ this.renderer.domElement.style.cursor = 'crosshair';
8419
+ }
8420
+ this.redrawCalibrationMarks(dropped);
8421
+ let awaiting = null;
8422
+ if (this.aligning) {
8423
+ if (this.alignmentSource) {
8424
+ awaiting = 'point';
8425
+ }
8426
+ else if (this.canMatchAnotherTag()) {
8427
+ // Otherwise done at three, or sooner when there are no more tags to match: fewer pairs
8428
+ // still calibrate, only less completely.
8429
+ awaiting = 'tag';
8430
+ }
8431
+ }
7094
8432
  this.alignmentProgress$.next({
7095
8433
  pairs: this.alignmentPairs.length,
7096
- // Done at three, or sooner when there are no more tags to match: fewer pairs still calibrate,
7097
- // only less completely.
7098
- awaiting: this.alignmentPairs.length >= 3 || this.calibrationCandidates().length === 0 ? null : 'tag',
8434
+ awaiting,
8435
+ tagId: awaiting === 'point' ? this.alignmentSourceTagId : undefined,
8436
+ matched: this.getAlignmentPairs(),
8437
+ });
8438
+ }
8439
+ /**
8440
+ * Draws the calibration as it stands: what has been matched, and what is being matched.
8441
+ *
8442
+ * Redrawn whole on every change rather than patched. There are at most three pairs, and undoing
8443
+ * or removing one renumbers the others.
8444
+ */
8445
+ redrawCalibrationMarks(dropped) {
8446
+ this.clearCalibrationMarks();
8447
+ if (!this.aligning) {
8448
+ return;
8449
+ }
8450
+ const frame = this.tagGroup.parent ?? this.scene;
8451
+ if (this.calibrationGroup.parent !== frame) {
8452
+ frame.add(this.calibrationGroup);
8453
+ }
8454
+ this.tagGroup.updateMatrix();
8455
+ // Where a tag is drawn at the moment, in the frame the marks are drawn in.
8456
+ const drawnAt = (stored) => new THREE.Vector3(stored.x, stored.y, stored.z).applyMatrix4(this.tagGroup.matrix);
8457
+ this.alignmentPairs.forEach((pair, index) => {
8458
+ const target = new THREE.Vector3(pair.target.x, pair.target.y, pair.target.z);
8459
+ // From where the tag is to where it was said to belong: the correction, made visible.
8460
+ const link = this.createMeasureLine(drawnAt(pair.source), target, ThreeJsService.CALIBRATION_DONE_COLOR, 2);
8461
+ link.material.opacity = 0.75;
8462
+ // The same green as the pin it leads to; see createCalibrationMark.
8463
+ link.material.color.convertSRGBToLinear();
8464
+ link.material.toneMapped = false;
8465
+ // Behind the tags: it sets off from the middle of one, and should come out from under its
8466
+ // icon rather than run across it.
8467
+ link.renderOrder = 997;
8468
+ this.calibrationGroup.add(link);
8469
+ const pin = this.createCalibrationMark(this.createNumberedTexture(index + 1, ThreeJsService.CALIBRATION_DONE_COLOR), 1001);
8470
+ pin.position.copy(target);
8471
+ this.calibrationGroup.add(pin);
8472
+ this.setScreenSize(pin, ThreeJsService.CALIBRATION_PIN_PX);
8473
+ if (dropped === 'point' && index === this.alignmentPairs.length - 1) {
8474
+ this.pulse(pin);
8475
+ }
8476
+ // Its tag is spoken for; it stays in view to show what the pin refers to, but stands back.
8477
+ const matched = this.tagSprites.get(pair.tagId);
8478
+ if (matched) {
8479
+ matched.material.opacity = 0.4;
8480
+ }
8481
+ });
8482
+ if (this.alignmentSource) {
8483
+ const ring = this.createCalibrationMark(this.createRingTexture(ThreeJsService.MEASURE_COLOR), 998);
8484
+ ring.position.copy(drawnAt(this.alignmentSource));
8485
+ this.calibrationGroup.add(ring);
8486
+ this.setScreenSize(ring, ThreeJsService.TAG_SCREEN_PX * 1.6);
8487
+ if (dropped === 'tag') {
8488
+ this.pulse(ring);
8489
+ }
8490
+ }
8491
+ }
8492
+ /** Removes everything drawn for a calibration, and gives the tags it dimmed their look back. */
8493
+ clearCalibrationMarks() {
8494
+ [...this.calibrationGroup.children].forEach((child) => {
8495
+ this.calibrationGroup.remove(child);
8496
+ child.geometry?.dispose?.();
8497
+ child.material?.map?.dispose?.();
8498
+ child.material?.dispose?.();
8499
+ });
8500
+ this.tagSprites.forEach((sprite) => {
8501
+ sprite.material.opacity = 1;
8502
+ });
8503
+ }
8504
+ /**
8505
+ * A calibration mark: a fixed size on screen and showing through the model, as the tags do, and
8506
+ * drawn in the colours it was painted in.
8507
+ *
8508
+ * The renderer's tone mapping and output conversion are there for the model's lighting. Put
8509
+ * through them a mark comes out pale - the amber nearer to cream - and a mark that has to be
8510
+ * picked out against a photograph of a building is the last thing that should be.
8511
+ */
8512
+ createCalibrationMark(map, renderOrder) {
8513
+ // Says the canvas holds sRGB, which it does, so the output conversion gives it back unchanged.
8514
+ if ('colorSpace' in map) {
8515
+ map.colorSpace = 'srgb'; // THREE.SRGBColorSpace, r152+
8516
+ }
8517
+ else {
8518
+ map.encoding = 3001; // THREE.sRGBEncoding
8519
+ }
8520
+ const mark = new THREE.Sprite(new THREE.SpriteMaterial({
8521
+ map, depthTest: false, transparent: true, sizeAttenuation: false, toneMapped: false,
8522
+ }));
8523
+ mark.renderOrder = renderOrder;
8524
+ return mark;
8525
+ }
8526
+ /** Shows a mark arriving: it starts oversized and settles. */
8527
+ pulse(sprite) {
8528
+ this.pulses.push({ sprite, startedAt: performance.now() });
8529
+ }
8530
+ stepPulses() {
8531
+ if (this.pulses.length === 0) {
8532
+ return;
8533
+ }
8534
+ const now = performance.now();
8535
+ this.pulses = this.pulses.filter(({ sprite, startedAt }) => {
8536
+ const size = sprite.userData.screenSize;
8537
+ if (!sprite.parent || !size) {
8538
+ // Redrawn or cleared in the meantime.
8539
+ return false;
8540
+ }
8541
+ const progress = Math.min(1, (now - startedAt) / ThreeJsService.CONFIRM_PULSE_MS);
8542
+ this.setScreenSize(sprite, size[0], size[1]);
8543
+ // Eased out, so most of the settling happens while the eye is still on the click.
8544
+ sprite.scale.multiplyScalar(1 + 0.9 * (1 - progress) ** 2);
8545
+ return progress < 1;
7099
8546
  });
7100
8547
  }
7101
8548
  /**
@@ -7120,6 +8567,7 @@ class ThreeJsService {
7120
8567
  if (this.renderer) {
7121
8568
  this.renderer.domElement.style.cursor = 'crosshair';
7122
8569
  }
8570
+ this.measuringChanged$.next(true);
7123
8571
  this.measurementProgress$.next({ points: 0, distances: [] });
7124
8572
  }
7125
8573
  /**
@@ -7140,8 +8588,10 @@ class ThreeJsService {
7140
8588
  this.renderer.domElement.style.cursor = 'default';
7141
8589
  }
7142
8590
  if (wasMeasuring) {
8591
+ this.measuringChanged$.next(false);
7143
8592
  this.measurementProgress$.next(null);
7144
8593
  }
8594
+ this.measurementCleared$.next();
7145
8595
  }
7146
8596
  /**
7147
8597
  * Ends the measurement and returns it, or null when there are not yet two points to measure
@@ -7150,11 +8600,12 @@ class ThreeJsService {
7150
8600
  * The drawing stays on the model so it is still visible while the person names the measurement;
7151
8601
  * cancelMeasuring() takes it away afterwards.
7152
8602
  */
7153
- finishMeasuring() {
8603
+ finishMeasuring(screenshot) {
7154
8604
  if (!this.measuring || this.measurePoints.length < 2) {
7155
8605
  return null;
7156
8606
  }
7157
8607
  this.measuring = false;
8608
+ this.measuringChanged$.next(false);
7158
8609
  this.pendingPreviewEvent = null;
7159
8610
  this.removePlacementPreview();
7160
8611
  this.updateMeasureRubberBand(null);
@@ -7162,10 +8613,32 @@ class ThreeJsService {
7162
8613
  this.lastMeasurement = {
7163
8614
  points: this.measurePoints.map((point) => ({ x: point.x, y: point.y, z: point.z })),
7164
8615
  distances: this.measureDistances(),
7165
- screenshot: this.captureMeasurementScreenshot(),
8616
+ // From the viewer the model is drawn in, when it is not this scene: the splat viewer.
8617
+ screenshot: screenshot ?? this.captureMeasurementScreenshot(),
7166
8618
  };
7167
8619
  return this.lastMeasurement;
7168
8620
  }
8621
+ /**
8622
+ * Adds a point to the measurement under way from a spot another viewer picked on the model - the
8623
+ * splat viewer - in the model's own coordinates. Into the frame the measurement is kept in, the
8624
+ * tags', through the calibration, as placeTagAtModelPoint does. False when not measuring.
8625
+ */
8626
+ addMeasurePointAtModelPoint(point) {
8627
+ if (!this.measuring) {
8628
+ return false;
8629
+ }
8630
+ this.tagGroup.updateMatrix();
8631
+ this.addMeasurePoint(new THREE.Vector3(point.x, point.y, point.z).applyMatrix4(this.tagGroup.matrix.clone().invert()));
8632
+ return true;
8633
+ }
8634
+ /** The measurement's points, under way or finished, in the model's own coordinates: for a viewer drawing it elsewhere. */
8635
+ getMeasurePointsInModelFrame() {
8636
+ this.tagGroup.updateMatrix();
8637
+ return this.measurePoints.map((point) => {
8638
+ const { x, y, z } = point.clone().applyMatrix4(this.tagGroup.matrix);
8639
+ return { x, y, z };
8640
+ });
8641
+ }
7169
8642
  isMeasuring() {
7170
8643
  return this.measuring;
7171
8644
  }
@@ -7194,10 +8667,10 @@ class ThreeJsService {
7194
8667
  // anything sized in metres would cover the very thing being measured.
7195
8668
  sizeAttenuation: false,
7196
8669
  }));
7197
- marker.scale.set(0.022, 0.022, 1);
7198
8670
  marker.position.copy(point);
7199
8671
  marker.renderOrder = 1001;
7200
8672
  this.measureGroup.add(marker);
8673
+ this.setScreenSize(marker, 12);
7201
8674
  if (previous) {
7202
8675
  this.measureGroup.add(this.createMeasureLine(previous, point));
7203
8676
  const distance = Math.round(previous.distanceTo(point) * 100) / 100;
@@ -7207,10 +8680,10 @@ class ThreeJsService {
7207
8680
  transparent: true,
7208
8681
  sizeAttenuation: false,
7209
8682
  }));
7210
- label.scale.set(0.15, 0.05, 1);
7211
8683
  label.position.copy(previous).add(point).multiplyScalar(0.5);
7212
8684
  label.renderOrder = 1002;
7213
8685
  this.measureGroup.add(label);
8686
+ this.setScreenSize(label, 84, 28);
7214
8687
  }
7215
8688
  this.measurementProgress$.next({
7216
8689
  points: this.measurePoints.length,
@@ -7223,12 +8696,12 @@ class ThreeJsService {
7223
8696
  * WebGL's own lines are one pixel wide whatever is asked for, which all but disappears over a
7224
8697
  * photographic texture. This kind is drawn as screen-facing geometry, so the width holds.
7225
8698
  */
7226
- createMeasureLine(from, to) {
8699
+ createMeasureLine(from, to, color = ThreeJsService.MEASURE_COLOR, width = 3) {
7227
8700
  const geometry = new LineGeometry();
7228
8701
  geometry.setPositions([from.x, from.y, from.z, to.x, to.y, to.z]);
7229
8702
  const material = new LineMaterial({
7230
- color: new THREE.Color(ThreeJsService.MEASURE_COLOR).getHex(),
7231
- linewidth: 3,
8703
+ color: new THREE.Color(color).getHex(),
8704
+ linewidth: width,
7232
8705
  depthTest: false,
7233
8706
  transparent: true,
7234
8707
  });
@@ -7244,7 +8717,7 @@ class ThreeJsService {
7244
8717
  return;
7245
8718
  }
7246
8719
  const size = this.renderer.getSize(new THREE.Vector2());
7247
- this.measureGroup.children.forEach((child) => {
8720
+ [...this.measureGroup.children, ...this.calibrationGroup.children].forEach((child) => {
7248
8721
  if (child.material?.resolution) {
7249
8722
  child.material.resolution.set(size.x, size.y);
7250
8723
  }
@@ -7330,6 +8803,12 @@ class ThreeJsService {
7330
8803
  });
7331
8804
  return canvas.toDataURL('image/jpeg', 0.85);
7332
8805
  }
8806
+ /**
8807
+ * True when tag placement is armed, false when it ends - placed or abandoned. For a viewer drawing
8808
+ * the model instead of this scene, the splat viewer, which picks the spot itself; see
8809
+ * placeTagAtModelPoint.
8810
+ */
8811
+ placementChanged$ = new Subject();
7333
8812
  /** Arms tag placement: the next click on the model drops a tag instead of being ignored. */
7334
8813
  startTagPlacement(poiType = null) {
7335
8814
  this.placingTag = true;
@@ -7338,9 +8817,40 @@ class ThreeJsService {
7338
8817
  if (this.renderer) {
7339
8818
  this.renderer.domElement.style.cursor = 'crosshair';
7340
8819
  }
8820
+ this.placementChanged$.next(true);
8821
+ }
8822
+ /**
8823
+ * Places the armed tag at a spot another viewer picked on the model - the splat viewer - given
8824
+ * in the model's own coordinates, as a click on the model in this scene would. Into the frame tag
8825
+ * coordinates are stored in, through the calibration, as pickPlacementOnModel does.
8826
+ */
8827
+ placeTagAtModelPoint(point, normal = null) {
8828
+ if (!this.placingTag) {
8829
+ return null;
8830
+ }
8831
+ this.tagGroup.updateMatrix();
8832
+ const position = new THREE.Vector3(point.x, point.y, point.z).applyMatrix4(this.tagGroup.matrix.clone().invert());
8833
+ // Turned only: the calibration's scale changes no direction.
8834
+ const facing = normal
8835
+ ? new THREE.Vector3(normal.x, normal.y, normal.z).applyQuaternion(this.tagGroup.quaternion.clone().invert()).normalize()
8836
+ : new THREE.Vector3(0, 1, 0);
8837
+ const placement = {
8838
+ position: { x: position.x, y: position.y, z: position.z },
8839
+ normal: { x: facing.x, y: facing.y, z: facing.z },
8840
+ poiType: this.placementPoiType,
8841
+ };
8842
+ this.pendingTagPlacement = placement;
8843
+ this.placingTag = false;
8844
+ this.removePlacementPreview();
8845
+ this.placementChanged$.next(false);
8846
+ this.tagPlaced$.next(placement);
8847
+ return placement;
7341
8848
  }
7342
8849
  /** Leaves placement mode without placing anything. */
7343
8850
  cancelTagPlacement() {
8851
+ if (this.placingTag) {
8852
+ this.placementChanged$.next(false);
8853
+ }
7344
8854
  this.placingTag = false;
7345
8855
  this.pendingTagPlacement = null;
7346
8856
  this.pendingPreviewEvent = null;
@@ -7365,15 +8875,20 @@ class ThreeJsService {
7365
8875
  }
7366
8876
  this.renderer.domElement.style.cursor = 'crosshair';
7367
8877
  if (!this.placementPreview) {
7368
- this.placementPreview = new THREE.Sprite(new THREE.SpriteMaterial({
7369
- map: this.createTagTexture('#9E9E9E'),
7370
- depthTest: false,
7371
- transparent: true,
7372
- opacity: 0.6,
7373
- }));
8878
+ // While calibrating, the ghost is the pin the click will leave, number and all: what is
8879
+ // under the pointer is what stays there.
8880
+ this.placementPreview = this.aligning
8881
+ ? this.createCalibrationMark(this.createNumberedTexture(this.alignmentPairs.length + 1, ThreeJsService.CALIBRATION_DONE_COLOR), 999)
8882
+ : new THREE.Sprite(new THREE.SpriteMaterial({
8883
+ map: this.createTagTexture('#9E9E9E'),
8884
+ depthTest: false,
8885
+ transparent: true,
8886
+ sizeAttenuation: false,
8887
+ }));
7374
8888
  this.placementPreview.renderOrder = 999;
8889
+ this.placementPreview.material.opacity = 0.6;
7375
8890
  this.tagGroup.add(this.placementPreview);
7376
- this.scaleTagForModel(this.placementPreview);
8891
+ this.setScreenSize(this.placementPreview, this.aligning ? ThreeJsService.CALIBRATION_PIN_PX : ThreeJsService.TAG_SCREEN_PX);
7377
8892
  }
7378
8893
  this.placementPreview.position.copy(position);
7379
8894
  this.updateMeasureRubberBand(position);
@@ -7480,6 +8995,16 @@ class ThreeJsService {
7480
8995
  setVisibleTagTypes(types) {
7481
8996
  this.visibleTagTypes = types;
7482
8997
  this.tagSprites.forEach((sprite) => this.applyTagVisibility(sprite));
8998
+ this.announceTagsChanged();
8999
+ }
9000
+ /** Says whether the visit on screen is the site's only one; see soleVisit. */
9001
+ setSoleVisit(sole) {
9002
+ this.soleVisit = sole;
9003
+ this.tagSprites.forEach((sprite) => {
9004
+ this.parentTag(sprite);
9005
+ this.applyTagVisibility(sprite);
9006
+ });
9007
+ this.announceTagsChanged();
7483
9008
  }
7484
9009
  /** Says which capture is on screen, so the tags placed in it can be told from the rest. */
7485
9010
  setCurrentCapture(captureID) {
@@ -7488,6 +9013,7 @@ class ThreeJsService {
7488
9013
  this.parentTag(sprite);
7489
9014
  this.applyTagVisibility(sprite);
7490
9015
  });
9016
+ this.announceTagsChanged();
7491
9017
  }
7492
9018
  /** Every tag registered in the scene, shown or not. */
7493
9019
  getTags() {
@@ -7563,8 +9089,8 @@ class ThreeJsService {
7563
9089
  }
7564
9090
  /** Whether the tag was placed in the visit on screen. */
7565
9091
  isOwnedByVisit(sprite) {
7566
- return !!sprite.userData.originCaptureID
7567
- && sprite.userData.originCaptureID === this.currentCaptureID;
9092
+ const origin = sprite.userData.originCaptureID;
9093
+ return origin ? origin === this.currentCaptureID : this.soleVisit;
7568
9094
  }
7569
9095
  /**
7570
9096
  * A tag shows when its type is not filtered out and it belongs on this model.
@@ -7593,8 +9119,9 @@ class ThreeJsService {
7593
9119
  if (sprite.userData.frame === 'model') {
7594
9120
  return false;
7595
9121
  }
7596
- // Calibrating is matching tags to where they belong, so they all have to be on offer.
7597
- if (this.aligning) {
9122
+ // Calibrating is matching tags to where they belong, so they all have to be on offer - from
9123
+ // the moment the panel opens, not only once the first click is being waited for.
9124
+ if (this.aligning || this.revealingCalibrationTags) {
7598
9125
  return true;
7599
9126
  }
7600
9127
  return this.isOnModel(sprite);
@@ -7732,6 +9259,9 @@ class ThreeJsService {
7732
9259
  */
7733
9260
  goToTag(elementId) {
7734
9261
  const sprite = this.tagSprites.get(elementId);
9262
+ if (sprite) {
9263
+ this.tagFocused$.next(elementId);
9264
+ }
7735
9265
  if (!sprite || !this.camera) {
7736
9266
  return;
7737
9267
  }
@@ -7853,10 +9383,11 @@ class ThreeJsService {
7853
9383
  this.tagGroup.rotation.set(rotation?.x ?? 0, rotation?.y ?? 0, rotation?.z ?? 0);
7854
9384
  this.tagGroup.scale.setScalar(scale ?? 1);
7855
9385
  this.tagGroup.updateMatrix();
7856
- // Tag size is derived from the world scale it inherits, which this call just changed - and so
7857
- // is which tags land on the model.
7858
- this.tagSprites.forEach((sprite) => this.scaleTagForModel(sprite));
9386
+ // Marker sizes are worked back from the scale they inherit, which this call just changed - and
9387
+ // so is which tags land on the model.
9388
+ this.refreshScreenSizes();
7859
9389
  this.refreshTagVisibility();
9390
+ this.announceTagsChanged();
7860
9391
  }
7861
9392
  /**
7862
9393
  * Adds a tag, or moves/restyles the one already registered for this element.
@@ -7873,22 +9404,29 @@ class ThreeJsService {
7873
9404
  existing.userData.label = tag.label ?? existing.userData.label;
7874
9405
  existing.userData.originCaptureID = tag.originCaptureID ?? existing.userData.originCaptureID;
7875
9406
  existing.userData.frame = tag.frame ?? existing.userData.frame;
9407
+ existing.userData.iconUrl = tag.iconUrl ?? existing.userData.iconUrl;
9408
+ existing.userData.normal = tag.normal ?? existing.userData.normal;
7876
9409
  this.parentTag(existing);
7877
9410
  this.applyTagVisibility(existing);
9411
+ this.announceTagsChanged();
7878
9412
  return;
7879
9413
  }
7880
9414
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
7881
9415
  map: this.createTagTexture(tag.color),
7882
9416
  // Tags read as UI rather than geometry, so they stay visible through the model instead of
7883
- // being occluded by whatever wall happens to be between them and the camera.
9417
+ // being occluded by whatever wall happens to be between them and the camera - and, for
9418
+ // the same reason, keep one size on screen rather than a size in the model.
7884
9419
  depthTest: false,
7885
9420
  transparent: true,
9421
+ sizeAttenuation: false,
7886
9422
  }));
7887
9423
  sprite.name = tag.id;
7888
9424
  sprite.userData.poiType = tag.poiType ?? null;
7889
9425
  sprite.userData.label = tag.label ?? null;
7890
9426
  sprite.userData.originCaptureID = tag.originCaptureID ?? null;
7891
9427
  sprite.userData.frame = tag.frame ?? null;
9428
+ sprite.userData.iconUrl = tag.iconUrl ?? null;
9429
+ sprite.userData.normal = tag.normal ?? null;
7892
9430
  sprite.position.set(tag.position.x, tag.position.y, tag.position.z);
7893
9431
  sprite.renderOrder = 999;
7894
9432
  this.parentTag(sprite);
@@ -7898,6 +9436,52 @@ class ThreeJsService {
7898
9436
  if (tag.iconUrl) {
7899
9437
  this.applyTagIcon(sprite, tag.iconUrl);
7900
9438
  }
9439
+ this.announceTagsChanged();
9440
+ }
9441
+ /**
9442
+ * The tags another viewer should show, and the calibration they are drawn under.
9443
+ *
9444
+ * The same ones this scene would offer, short of the test against the model's surface, which needs
9445
+ * the model here: the type filter applies, and a tag placed in another model before it was
9446
+ * calibrated is left out, its coordinates meaning nothing in this one.
9447
+ */
9448
+ getTagSnapshot() {
9449
+ const tags = [];
9450
+ this.tagSprites.forEach((sprite) => {
9451
+ const owned = this.isOwnedByVisit(sprite);
9452
+ const typeAllowed = this.visibleTagTypes === null || this.visibleTagTypes.includes(sprite.userData.poiType);
9453
+ if (!typeAllowed || (sprite.userData.frame === 'model' && !owned)) {
9454
+ return;
9455
+ }
9456
+ tags.push({
9457
+ id: sprite.name,
9458
+ position: { x: sprite.position.x, y: sprite.position.y, z: sprite.position.z },
9459
+ label: sprite.userData.label ?? null,
9460
+ poiType: sprite.userData.poiType ?? null,
9461
+ iconUrl: sprite.userData.iconUrl ?? null,
9462
+ inModelFrame: sprite.userData.frame === 'model' && owned,
9463
+ normal: sprite.userData.normal ?? null,
9464
+ });
9465
+ });
9466
+ const group = this.tagGroup;
9467
+ return {
9468
+ tags,
9469
+ alignment: {
9470
+ translation: { x: group.position.x, y: group.position.y, z: group.position.z },
9471
+ rotation: { x: group.rotation.x, y: group.rotation.y, z: group.rotation.z },
9472
+ scale: group.scale.x,
9473
+ },
9474
+ };
9475
+ }
9476
+ announceTagsChanged() {
9477
+ if (this.tagsChangeQueued) {
9478
+ return;
9479
+ }
9480
+ this.tagsChangeQueued = true;
9481
+ queueMicrotask(() => {
9482
+ this.tagsChangeQueued = false;
9483
+ this.tagsChanged$.next();
9484
+ });
7901
9485
  }
7902
9486
  /** Removes the tag registered for an element, if there is one. */
7903
9487
  removeTag(elementId) {
@@ -7909,6 +9493,7 @@ class ThreeJsService {
7909
9493
  sprite.material.map?.dispose();
7910
9494
  sprite.material.dispose();
7911
9495
  this.tagSprites.delete(elementId);
9496
+ this.announceTagsChanged();
7912
9497
  }
7913
9498
  /** Removes every tag from the scene. */
7914
9499
  clearTags() {
@@ -7938,18 +9523,78 @@ class ThreeJsService {
7938
9523
  context.stroke();
7939
9524
  return new THREE.CanvasTexture(canvas);
7940
9525
  }
9526
+ /** The default marker with a number on it: the nth point of a calibration. */
9527
+ createNumberedTexture(number, color) {
9528
+ const texture = this.createTagTexture(color);
9529
+ const canvas = texture.image;
9530
+ const context = canvas.getContext('2d');
9531
+ context.fillStyle = '#FFFFFF';
9532
+ context.font = 'bold 76px sans-serif';
9533
+ context.textAlign = 'center';
9534
+ context.textBaseline = 'middle';
9535
+ context.fillText(String(number), canvas.width / 2, canvas.height / 2 + 5);
9536
+ return texture;
9537
+ }
9538
+ /** A ring with nothing in the middle, to go round a tag and leave it showing. */
9539
+ createRingTexture(color) {
9540
+ const size = 128;
9541
+ const canvas = document.createElement('canvas');
9542
+ canvas.width = size;
9543
+ canvas.height = size;
9544
+ const context = canvas.getContext('2d');
9545
+ // White under the colour, a little wider, for the same reason the tags have a white edge.
9546
+ context.beginPath();
9547
+ context.arc(size / 2, size / 2, size / 2 - 10, 0, Math.PI * 2);
9548
+ context.lineWidth = 16;
9549
+ context.strokeStyle = '#FFFFFF';
9550
+ context.stroke();
9551
+ context.lineWidth = 10;
9552
+ context.strokeStyle = color;
9553
+ context.stroke();
9554
+ return new THREE.CanvasTexture(canvas);
9555
+ }
7941
9556
  /**
7942
- * Sizes a tag relative to the model.
9557
+ * Gives a tag its size: the same number of pixels on screen whatever the model.
7943
9558
  *
7944
- * A sprite inherits the model's scale and the alignment's, so a fixed size would come out tiny on
7945
- * a large model and huge on a small one. Dividing by the group's world scale keeps tags the same
7946
- * size on screen whatever the model's units are.
9559
+ * It used to be a fixed fraction of the model instead. That reads fine on a room, but on a model
9560
+ * the size of a city block it is a tag several metres across, which fills the view as soon as
9561
+ * the camera moves in to look at anything.
7947
9562
  */
7948
9563
  scaleTagForModel(sprite) {
7949
- // Its own parent's scale: tags in the model's coordinates do not inherit the calibration's.
7950
- const worldScale = (sprite.parent ?? this.tagGroup).getWorldScale(new THREE.Vector3()).x || 1;
7951
- const size = 0.4 / worldScale;
7952
- sprite.scale.set(size, size, 1);
9564
+ this.setScreenSize(sprite, ThreeJsService.TAG_SCREEN_PX);
9565
+ }
9566
+ /**
9567
+ * Fixes a sprite's size on screen, in CSS pixels.
9568
+ *
9569
+ * The sprite must be drawn without size attenuation, which makes its size a proportion of the
9570
+ * viewport instead of something that shrinks with distance. Its scale is then worked back from
9571
+ * the pixels wanted, the viewport's height and the scale it inherits from its parents - the
9572
+ * model's normalisation and, for tags, the calibration - so none of those show in the result.
9573
+ * The size asked for is kept on the sprite, so it can be worked out again when any of them changes.
9574
+ */
9575
+ setScreenSize(sprite, widthPx, heightPx = widthPx) {
9576
+ sprite.userData.screenSize = [widthPx, heightPx];
9577
+ const inherited = (sprite.parent ?? this.tagGroup).getWorldScale(new THREE.Vector3()).x || 1;
9578
+ const perPixel = this.worldUnitsPerPixel() / inherited;
9579
+ sprite.scale.set(widthPx * perPixel, heightPx * perPixel, 1);
9580
+ }
9581
+ /** What one screen pixel is worth in the scale of a sprite drawn without size attenuation. */
9582
+ worldUnitsPerPixel() {
9583
+ const height = this.renderer?.getSize?.(new THREE.Vector2()).y || 800;
9584
+ const fieldOfView = THREE.MathUtils.degToRad(this.camera?.fov ?? 75);
9585
+ return (2 * Math.tan(fieldOfView / 2)) / height;
9586
+ }
9587
+ /** Re-applies every marker's on-screen size: the viewport, the model or the calibration changed. */
9588
+ refreshScreenSizes() {
9589
+ const resize = (object) => {
9590
+ if (object?.userData?.screenSize) {
9591
+ this.setScreenSize(object, object.userData.screenSize[0], object.userData.screenSize[1]);
9592
+ }
9593
+ };
9594
+ this.tagSprites.forEach(resize);
9595
+ this.measureGroup.children.forEach(resize);
9596
+ this.calibrationGroup.children.forEach(resize);
9597
+ resize(this.placementPreview);
7953
9598
  }
7954
9599
  /**
7955
9600
  * Adds secondary interactive objects (e.g., POIs, sensors) to the scene.
@@ -8063,6 +9708,7 @@ class ThreeJsService {
8063
9708
  this.placingTag = false;
8064
9709
  this.removePlacementPreview();
8065
9710
  this.renderer.domElement.style.cursor = 'default';
9711
+ this.placementChanged$.next(false);
8066
9712
  this.tagPlaced$.next(placement);
8067
9713
  }
8068
9714
  return;
@@ -8090,12 +9736,19 @@ class ThreeJsService {
8090
9736
  }
8091
9737
  }
8092
9738
  /**
8093
- * Escape cancels whatever the pointer is currently committed to.
9739
+ * Escape cancels whatever the pointer is currently committed to. While calibrating, the undo keys
9740
+ * take back one step of it instead of all of it.
8094
9741
  *
8095
9742
  * Emits through the same subjects the explicit Cancel buttons use, so the UI driving them follows
8096
9743
  * along instead of being left showing a mode that is no longer armed.
8097
9744
  */
8098
9745
  onKeyDown = (event) => {
9746
+ if (this.aligning && this.isUndoKey(event)) {
9747
+ if (this.undoAlignmentStep()) {
9748
+ event.preventDefault();
9749
+ }
9750
+ return;
9751
+ }
8099
9752
  if (event.key !== 'Escape') {
8100
9753
  return;
8101
9754
  }
@@ -8112,9 +9765,22 @@ class ThreeJsService {
8112
9765
  this.cancelTagAlignment();
8113
9766
  }
8114
9767
  };
9768
+ /**
9769
+ * Ctrl+Z (Cmd+Z on a Mac) or Backspace, unless typed into a field - where they mean something
9770
+ * else, to whatever the field is.
9771
+ */
9772
+ isUndoKey(event) {
9773
+ const target = event.target;
9774
+ if (target?.isContentEditable || ['INPUT', 'TEXTAREA'].includes(target?.tagName ?? '')) {
9775
+ return false;
9776
+ }
9777
+ return event.key === 'Backspace'
9778
+ || ((event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === 'z');
9779
+ }
8115
9780
  animate = () => {
8116
9781
  this.animationFrameId = requestAnimationFrame(this.animate);
8117
9782
  this.stepCameraTween();
9783
+ this.stepPulses();
8118
9784
  this.controls.update();
8119
9785
  // One pick per frame, against the pointer's latest position - see onMouseMove.
8120
9786
  if (this.pendingPreviewEvent) {
@@ -8124,11 +9790,84 @@ class ThreeJsService {
8124
9790
  }
8125
9791
  this.renderer.render(this.scene, this.camera);
8126
9792
  };
8127
- destroy() {
9793
+ /**
9794
+ * Stops drawing the scene, or starts again. For while another viewer covers it - the splat viewer
9795
+ * - where it would otherwise go on drawing, unseen, every frame, on the graphics card the other
9796
+ * viewer needs. The scene keeps its tags and calibration all the same.
9797
+ */
9798
+ setRenderingPaused(paused) {
9799
+ if (paused) {
9800
+ this.stopRendering();
9801
+ }
9802
+ else if (this.live && !this.animationFrameId) {
9803
+ this.ngZone.runOutsideAngular(() => this.animate());
9804
+ }
9805
+ }
9806
+ /** Stops the render loop, if one is running. */
9807
+ stopRendering() {
8128
9808
  if (this.animationFrameId) {
8129
9809
  cancelAnimationFrame(this.animationFrameId);
9810
+ this.animationFrameId = 0;
8130
9811
  }
9812
+ }
9813
+ /**
9814
+ * Takes the model out of the scene and frees what it holds on the graphics card.
9815
+ *
9816
+ * The tags are kept. They hang from the model's group so as to share its frame, and are moved out
9817
+ * of it first: they belong to the visit, not to the file that was drawn under them.
9818
+ */
9819
+ removeModel() {
9820
+ const group = this.modelGroup;
9821
+ const model = this.modelRoot;
9822
+ this.modelGroup = undefined;
9823
+ this.modelRoot = undefined;
9824
+ this.surfaceCells = null;
9825
+ this.modelWorldRadius = 0;
9826
+ [this.tagGroup, this.modelTagGroup, this.calibrationGroup].forEach((kept) => kept.parent?.remove(kept));
9827
+ group?.parent?.remove(group);
9828
+ if (model) {
9829
+ this.disposeModel(model);
9830
+ }
9831
+ }
9832
+ /**
9833
+ * Frees a model's geometry, materials and textures.
9834
+ *
9835
+ * Dropping the last reference to a model does not do this. What it uploaded stays on the graphics
9836
+ * card until the page is closed - hundreds of megabytes for a captured building - and on a
9837
+ * machine whose graphics memory is shared with the rest of the system, everything slows down
9838
+ * long before anything fails.
9839
+ */
9840
+ disposeModel(model) {
9841
+ const freed = new Set();
9842
+ const free = (resource) => {
9843
+ if (resource && typeof resource.dispose === 'function' && !freed.has(resource)) {
9844
+ freed.add(resource);
9845
+ resource.dispose();
9846
+ }
9847
+ };
9848
+ model.traverse((child) => {
9849
+ free(child.geometry);
9850
+ const materials = Array.isArray(child.material) ? child.material : [child.material];
9851
+ materials.forEach((material) => {
9852
+ if (!material) {
9853
+ return;
9854
+ }
9855
+ // Whichever slots this kind of material has: map, normalMap, emissiveMap...
9856
+ Object.values(material).forEach((value) => {
9857
+ if (value?.isTexture) {
9858
+ free(value);
9859
+ }
9860
+ });
9861
+ free(material);
9862
+ });
9863
+ });
9864
+ }
9865
+ destroy() {
9866
+ this.live = false;
9867
+ this.stopRendering();
8131
9868
  window.removeEventListener('keydown', this.onKeyDown);
9869
+ this.canvasListeners.forEach((remove) => remove());
9870
+ this.canvasListeners = [];
8132
9871
  if (this.canvasResizeObserver) {
8133
9872
  this.canvasResizeObserver.disconnect();
8134
9873
  this.canvasResizeObserver = undefined;
@@ -8137,6 +9876,13 @@ class ThreeJsService {
8137
9876
  this.clearTags();
8138
9877
  this.measuring = false;
8139
9878
  this.clearMeasurement();
9879
+ this.clearCalibrationMarks();
9880
+ this.pulses = [];
9881
+ // While the renderer is still there to hand the memory back through.
9882
+ this.removeModel();
9883
+ // They were in the scene that is going. Kept, the next scene would go on testing every pointer
9884
+ // move against objects nobody can see, and would skip loading them again as "already there".
9885
+ this.clickableObjects = [];
8140
9886
  if (this.renderer) {
8141
9887
  this.renderer.dispose();
8142
9888
  }
@@ -8622,6 +10368,39 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
8622
10368
  /* eslint-disable camelcase */
8623
10369
  /* eslint-disable no-underscore-dangle */
8624
10370
  /* eslint-disable class-methods-use-this */
10371
+ /**
10372
+ * What a Gaussian splat file is named with, when it is a .ply: the content is what tells it from
10373
+ * another .ply, and the key is all a visit stores (see isGaussianSplatPly).
10374
+ */
10375
+ const GAUSSIAN_SPLAT_PLY_SUFFIX = '.splat.ply';
10376
+ /**
10377
+ * Whether a 3D model file holds Gaussian splats, which the Three.js viewer cannot draw: .spz,
10378
+ * .splat, .ksplat, SuperSplat's .sog, a .ply named with GAUSSIAN_SPLAT_PLY_SUFFIX, or the
10379
+ * lod-meta.json of a Streamed SOG - SuperSplat's levels of detail, in pieces beside it.
10380
+ */
10381
+ function isGaussianSplatFile(name) {
10382
+ return /\.(spz|splat|ksplat|sog|splat\.ply)($|\?)/i.test(name ?? '') || isStreamedSplatFile(name);
10383
+ }
10384
+ /**
10385
+ * Whether a .ply holds Gaussian splats: what splat tools export carries, for each splat, its colour
10386
+ * as spherical harmonics (f_dc_0...) and its size (scale_0...). Only the header is read.
10387
+ */
10388
+ async function isGaussianSplatPly(file) {
10389
+ const start = await file.slice(0, 64 * 1024).text();
10390
+ const end = start.indexOf('end_header');
10391
+ if (!start.startsWith('ply') || end < 0) {
10392
+ return false;
10393
+ }
10394
+ const properties = start.slice(0, end).split(/\r?\n/)
10395
+ .map((line) => line.trim().split(/\s+/))
10396
+ .filter((words) => words[0] === 'property')
10397
+ .map((words) => words[words.length - 1]);
10398
+ return properties.includes('f_dc_0') && properties.includes('scale_0');
10399
+ }
10400
+ /** Whether a 3D model file is the lod-meta.json of a Streamed SOG; see isGaussianSplatFile. */
10401
+ function isStreamedSplatFile(name) {
10402
+ return /(^|\/)lod-meta\.json($|\?)/i.test(name ?? '');
10403
+ }
8625
10404
  class ViewerService {
8626
10405
  sdkKey;
8627
10406
  config;
@@ -8898,15 +10677,35 @@ class ViewerService {
8898
10677
  if (![position?.x, position?.y, position?.z].every((value) => typeof value === 'number')) {
8899
10678
  return;
8900
10679
  }
10680
+ // A tag is its position; the icon is how it is dressed. One that cannot be fetched - a
10681
+ // signature that expired, a file gone from the bucket - must not cost the tag itself.
10682
+ let iconUrl;
10683
+ try {
10684
+ iconUrl = await this.resolveTagIconUrl(poi);
10685
+ }
10686
+ catch (error) {
10687
+ console.warn(`ViewerService: no icon for the tag of element ${object.id}, using the default one.`, error);
10688
+ iconUrl = this.defaultTagIconUrl(poi.type);
10689
+ }
8901
10690
  const origin = getThreeJsPoiOrigin(poi);
10691
+ // Matterport's stem, kept with the POI by both viewers that place tags.
10692
+ let normal = null;
10693
+ try {
10694
+ const stored = poi.metadata ? JSON.parse(poi.metadata)?.normal : null;
10695
+ normal = [stored?.x, stored?.y, stored?.z].every((value) => typeof value === 'number') ? stored : null;
10696
+ }
10697
+ catch {
10698
+ normal = null;
10699
+ }
8902
10700
  this.threeJsService.upsertTag({
8903
10701
  id: object.id,
8904
10702
  position,
8905
10703
  poiType: poi.type,
8906
10704
  label: object.name ?? object.title ?? null,
8907
- iconUrl: await this.resolveTagIconUrl(poi),
10705
+ iconUrl,
8908
10706
  originCaptureID: origin?.captureID ?? null,
8909
10707
  frame: origin?.frame ?? null,
10708
+ normal,
8910
10709
  });
8911
10710
  }
8912
10711
  /**
@@ -9024,6 +10823,40 @@ class ViewerService {
9024
10823
  this.threeJsService.goToTag(elementId);
9025
10824
  }
9026
10825
  }
10826
+ /** The tag the Three.js camera is sent to, for the splat viewer to point out; see goToThreeJsTag. */
10827
+ get threeJsTagFocused$() {
10828
+ return this.threeJsService.tagFocused$;
10829
+ }
10830
+ /** A tag clicked in the splat viewer, said as threeJsTagClicked$ says one clicked in the scene. */
10831
+ clickThreeJsTag(elementId) {
10832
+ if (this.checkMode('threejs')) {
10833
+ this.threeJsService.clickTag(elementId);
10834
+ }
10835
+ }
10836
+ /** Whether the visit's 3D model has a calibration, from its capture's metadata. */
10837
+ isThreeJsCalibrated() {
10838
+ return this.threeJsCalibrated;
10839
+ }
10840
+ /** Whether the tags a calibration works with are on show; see ThreeJsService.showsCalibrationTags. */
10841
+ isThreeJsShowingCalibrationTags() {
10842
+ return this.checkMode('threejs') && this.threeJsService.showsCalibrationTags();
10843
+ }
10844
+ /** Whether a calibration is under way in the Three.js scene. */
10845
+ isThreeJsAligningTags() {
10846
+ return this.checkMode('threejs') && this.threeJsService.isAligningTags();
10847
+ }
10848
+ /** Whether a measurement is taking clicks in the Three.js scene. */
10849
+ isThreeJsMeasuring() {
10850
+ return this.checkMode('threejs') && this.threeJsService.isMeasuring();
10851
+ }
10852
+ /** True when a measurement starts taking clicks, false when it stops; see ThreeJsService.measuringChanged$. */
10853
+ get threeJsMeasuringChanged$() {
10854
+ return this.threeJsService.measuringChanged$;
10855
+ }
10856
+ /** Says a click in the splat viewer was of no use to the calibration, as one in the scene would. */
10857
+ ignoreThreeJsAlignmentClick(expected) {
10858
+ this.threeJsService.alignmentClickIgnored$.next(expected);
10859
+ }
9027
10860
  /** Shows only tags of the given POI types; null clears the filter. */
9028
10861
  setVisibleThreeJsTagTypes(types) {
9029
10862
  if (this.checkMode('threejs')) {
@@ -9046,6 +10879,24 @@ class ViewerService {
9046
10879
  this.threeJsService.cancelTagAlignment();
9047
10880
  }
9048
10881
  }
10882
+ /**
10883
+ * Says whether the 3D model on screen is the site's only visit. Then there is nowhere else for a
10884
+ * tag to have come from, and the tags that do not record where they were placed are its own.
10885
+ */
10886
+ setThreeJsSoleVisit(sole) {
10887
+ if (this.checkMode('threejs')) {
10888
+ this.threeJsService.setSoleVisit(sole);
10889
+ }
10890
+ }
10891
+ /**
10892
+ * Shows the tags a calibration would work with, including those hidden for not landing on the
10893
+ * model, before anything is armed. Cancelling or saving the calibration hides them again.
10894
+ */
10895
+ revealThreeJsCalibrationTags() {
10896
+ if (this.checkMode('threejs')) {
10897
+ this.threeJsService.revealCalibrationTags(true);
10898
+ }
10899
+ }
9049
10900
  get threeJsAlignmentProgress$() {
9050
10901
  return this.threeJsService.alignmentProgress$;
9051
10902
  }
@@ -9220,17 +11071,35 @@ class ViewerService {
9220
11071
  get threeJsMeasurementProgress$() {
9221
11072
  return this.threeJsService.measurementProgress$;
9222
11073
  }
9223
- /** Ends the Three.js measurement in progress, or returns null when it has fewer than two points. */
9224
- finishThreeJsMeasurement() {
11074
+ /**
11075
+ * Ends the Three.js measurement in progress, or returns null when it has fewer than two points.
11076
+ * `screenshot` is the picture of it, when the model is drawn by another viewer than the scene.
11077
+ */
11078
+ finishThreeJsMeasurement(screenshot) {
9225
11079
  if (!this.checkMode('threejs')) {
9226
11080
  return null;
9227
11081
  }
9228
- const measurement = this.threeJsService.finishMeasuring();
11082
+ const measurement = this.threeJsService.finishMeasuring(screenshot);
9229
11083
  if (measurement) {
9230
11084
  this.toolboxStates.mesure.stat = false;
9231
11085
  }
9232
11086
  return measurement;
9233
11087
  }
11088
+ /** A measurement was taken off the model, finished or not; see ThreeJsService.measurementCleared$. */
11089
+ get threeJsMeasurementCleared$() {
11090
+ return this.threeJsService.measurementCleared$;
11091
+ }
11092
+ /**
11093
+ * Adds a point to the measurement under way, picked on the model in another viewer - the splat
11094
+ * viewer - in the model's own coordinates. False when no measurement is under way.
11095
+ */
11096
+ addThreeJsMeasurePointAtModelPoint(point) {
11097
+ return this.checkMode('threejs') && this.threeJsService.addMeasurePointAtModelPoint(point);
11098
+ }
11099
+ /** The points of the measurement on the model, in the model's own coordinates, for another viewer to draw. */
11100
+ getThreeJsMeasurePointsInModelFrame() {
11101
+ return this.checkMode('threejs') ? this.threeJsService.getMeasurePointsInModelFrame() : [];
11102
+ }
9234
11103
  /** The measurement last finished in the Three.js viewer, for the form that saves it. */
9235
11104
  getThreeJsLastMeasurement() {
9236
11105
  return this.checkMode('threejs') ? this.threeJsService.getLastMeasurement() : null;
@@ -9292,6 +11161,24 @@ class ViewerService {
9292
11161
  selectThreeJsAlignmentTag(elementId) {
9293
11162
  return this.checkMode('threejs') && this.threeJsService.selectAlignmentTag(elementId);
9294
11163
  }
11164
+ /**
11165
+ * Gives the tag being matched in the calibration under way its real place, picked on the model in
11166
+ * another viewer - the splat viewer - in the model's own coordinates. False when no tag was waiting.
11167
+ */
11168
+ matchThreeJsAlignmentTagAtModelPoint(point) {
11169
+ return this.checkMode('threejs') && this.threeJsService.matchAlignmentTagAtModelPoint(point);
11170
+ }
11171
+ /**
11172
+ * Takes back the last step of the calibration under way: the tag just chosen, or else the last
11173
+ * pair matched. False when there was nothing to take back.
11174
+ */
11175
+ undoThreeJsAlignmentStep() {
11176
+ return this.checkMode('threejs') && this.threeJsService.undoAlignmentStep();
11177
+ }
11178
+ /** Removes one matched pair of the calibration under way, by its place in the list. */
11179
+ removeThreeJsAlignmentPair(index) {
11180
+ return this.checkMode('threejs') && this.threeJsService.removeAlignmentPair(index);
11181
+ }
9295
11182
  /** How far the visible tags sit from the 3D model, in model radii. Null without both. */
9296
11183
  getThreeJsTagDistanceFromModel() {
9297
11184
  return this.checkMode('threejs') ? this.threeJsService.getTagDistanceFromModel() : null;
@@ -9366,6 +11253,11 @@ class ViewerService {
9366
11253
  position: placement.position,
9367
11254
  poiType: updatedPoi?.type ?? poi.type,
9368
11255
  iconUrl: updatedPoi?.tagIcon ? await getSignedFile(updatedPoi.tagIcon) : null,
11256
+ normal: placement.normal,
11257
+ // As it is stored (see buildThreeJsPoiMetadata): placed in this model, and in its own
11258
+ // coordinates while it is not calibrated - so that it shows as the model's own straight away.
11259
+ ...(this.currentCaptureID ? { originCaptureID: this.currentCaptureID } : {}),
11260
+ ...(this.currentCaptureID && !this.threeJsCalibrated ? { frame: 'model' } : {}),
9369
11261
  });
9370
11262
  this.threeJsService.cancelTagPlacement();
9371
11263
  return updatedPoi;
@@ -9380,7 +11272,7 @@ class ViewerService {
9380
11272
  *
9381
11273
  * Returns null when there is no pending placement, which is the normal case in Matterport mode.
9382
11274
  */
9383
- async createPoiForThreeJsPlacement(element, poiType) {
11275
+ async createPoiForThreeJsPlacement(element, poiType, extra = {}) {
9384
11276
  if (!this.checkMode('threejs')) {
9385
11277
  return null;
9386
11278
  }
@@ -9392,6 +11284,8 @@ class ViewerService {
9392
11284
  // sweep - the floor plan most visibly, which only draws tags whose sweep belongs to it.
9393
11285
  const matterportSweepID = await this.findNearestSweepForThreeJs(placement.position);
9394
11286
  const poi = await this.poiService.createPOIForSpaceID({
11287
+ // What a form asked for besides the place - the museum's, say - under what the placement says.
11288
+ ...extra,
9395
11289
  coordinate: JSON.stringify(placement.position),
9396
11290
  type: poiType,
9397
11291
  elementID: element.id,
@@ -9413,6 +11307,35 @@ class ViewerService {
9413
11307
  get threeJsAlignmentCancelled$() {
9414
11308
  return this.threeJsService.alignmentCancelled$;
9415
11309
  }
11310
+ /** Emits when the Three.js tags change; see getThreeJsTagSnapshot. */
11311
+ get threeJsTagsChanged$() {
11312
+ return this.threeJsService.tagsChanged$;
11313
+ }
11314
+ /** The tags, and their calibration, as a viewer drawing the scene elsewhere needs them. */
11315
+ getThreeJsTagSnapshot() {
11316
+ return this.threeJsService.getTagSnapshot();
11317
+ }
11318
+ /** Stops drawing the Three.js scene while another viewer covers it, or starts again. */
11319
+ setThreeJsRenderingPaused(paused) {
11320
+ this.threeJsService.setRenderingPaused(paused);
11321
+ }
11322
+ /** Emits when a click during calibration landed on nothing it could use: a tag, or the model. */
11323
+ get threeJsAlignmentClickIgnored$() {
11324
+ return this.threeJsService.alignmentClickIgnored$;
11325
+ }
11326
+ /** Emits when Three.js tag placement is armed (true) and when it ends (false). */
11327
+ get threeJsPlacementChanged$() {
11328
+ return this.threeJsService.placementChanged$;
11329
+ }
11330
+ /**
11331
+ * Completes an armed Three.js tag placement at a spot the splat viewer picked, in the model's own
11332
+ * coordinates: the creation form then opens as for a click in the Three.js scene.
11333
+ */
11334
+ placeThreeJsTagAtModelPoint(point, normal = null) {
11335
+ if (this.checkMode('threejs')) {
11336
+ this.threeJsService.placeTagAtModelPoint(point, normal);
11337
+ }
11338
+ }
9416
11339
  /** Leaves Three.js tag placement mode without placing anything. */
9417
11340
  cancelThreeJsTagPlacement() {
9418
11341
  if (this.checkMode('threejs')) {
@@ -9563,7 +11486,12 @@ class ViewerService {
9563
11486
  }
9564
11487
  }
9565
11488
  async initMatterportSdk(spaceID, model3D, tagService, module = SpModule.IMMO) {
9566
- this.isLoaded = false;
11489
+ // A Three.js visit is announced as loaded by initThreeJs, and when one visit is swapped for
11490
+ // another that has already happened by the time this runs. Clearing the flag here left the
11491
+ // rest of the page believing no viewer was up - "show in 3D" greyed out on every panel.
11492
+ if (!this.checkMode('threejs')) {
11493
+ this.isLoaded = false;
11494
+ }
9567
11495
  this.matterportService.currentSpaceID = spaceID;
9568
11496
  if (this.matterportService.sdk) {
9569
11497
  await this.clearAll();
@@ -16626,6 +18554,358 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
16626
18554
  type: Input
16627
18555
  }] } });
16628
18556
 
18557
+ /**
18558
+ * Gaussian splats, drawn by SuperSplat's viewer (PlayCanvas) in an iframe, as Matterport's showcase
18559
+ * is: the page built from projects/splat-viewer, which each app serves from assets/viewers/splat.
18560
+ *
18561
+ * The visit's Three.js scene stays underneath, unseen, holding the tags, their calibration and the
18562
+ * measurement, as it does for a model it draws itself; this hands the viewer what the scene holds,
18563
+ * and turns what is clicked in it into what a click in the scene does - through ViewerService, so
18564
+ * that whatever drives the scene (the add buttons, the calibration panel, the measuring tool, the
18565
+ * tag lists) drives this as well. Tags clicked come out of ViewerService.threeJsTagClicked$, as
18566
+ * clicks in the scene do.
18567
+ *
18568
+ * What it says of loading is for the app's own loading overlay; see the outputs.
18569
+ */
18570
+ class SplatViewerComponent {
18571
+ viewerService;
18572
+ host;
18573
+ sanitizer;
18574
+ translate;
18575
+ /** The model: the S3 key - or assets/ path - of a splat file, or of a Streamed SOG's lod-meta.json. */
18576
+ modelKey = null;
18577
+ /** Which of the model's axes is up, from its capture's placement; null for the viewer's own idea. */
18578
+ up = null;
18579
+ /** How far loading has got, from 0 to 1, or null when the size is not known. 0 when it starts. */
18580
+ loadProgress = new EventEmitter();
18581
+ loaded = new EventEmitter();
18582
+ loadFailed = new EventEmitter();
18583
+ /** Said whenever the mode changes, the viewer's own buttons included. */
18584
+ modeChange = new EventEmitter();
18585
+ frame;
18586
+ /**
18587
+ * The viewer's page, made once, in ngOnInit: a new object, even for the same address, would make
18588
+ * Angular set the iframe's src again, which loads the viewer afresh with nothing in it.
18589
+ */
18590
+ page;
18591
+ /** Whether the page has said it is ready; it says so again each time it starts over. */
18592
+ ready = false;
18593
+ /** The model handed to the page, so the same one is not downloaded twice. */
18594
+ loadedKey = null;
18595
+ /** Set by cancelLoad: the page, starting over empty, waits to be asked again (reload). */
18596
+ cancelled = false;
18597
+ /** Whether the page was last asked to take a click for a calibration. */
18598
+ pickingForCalibration = false;
18599
+ /** The calibration under way, as ViewerService last said. */
18600
+ alignment = null;
18601
+ /** Each segment's length of the measurement on the model, as ViewerService last said. */
18602
+ measureDistances = [];
18603
+ /** The last lod-meta.json handed to the page, as a link of this page's own. */
18604
+ streamedMeta = null;
18605
+ /** Pictures asked of the page, by the number they were asked with, until it answers. */
18606
+ snapshots = new Map();
18607
+ snapshotCount = 0;
18608
+ destroyed$ = new Subject();
18609
+ constructor(viewerService, host, sanitizer, translate) {
18610
+ this.viewerService = viewerService;
18611
+ this.host = host;
18612
+ this.sanitizer = sanitizer;
18613
+ this.translate = translate;
18614
+ }
18615
+ ngOnInit() {
18616
+ // In the app's language, which the viewer's controls speak, and in its colours, as this element's
18617
+ // styles take them from the app's - by now in the page, where they reach it.
18618
+ const style = getComputedStyle(this.host.nativeElement);
18619
+ const query = new URLSearchParams({
18620
+ lang: this.translate.currentLang || 'en',
18621
+ accent: style.color,
18622
+ background: style.backgroundColor,
18623
+ });
18624
+ this.page = this.sanitizer.bypassSecurityTrustResourceUrl(`assets/viewers/splat/index.html?${query}`);
18625
+ // The Three.js scene underneath has nothing to draw while this covers it, and the splat viewer
18626
+ // needs all the graphics card there is.
18627
+ this.viewerService.setThreeJsRenderingPaused(true);
18628
+ const on = (source, next) => source.pipe(takeUntil(this.destroyed$)).subscribe(next);
18629
+ on(this.viewerService.threeJsTagsChanged$, () => this.postTags());
18630
+ // Placing a tag starts from the add buttons, the same in every viewer; the page picks the spot
18631
+ // and says where ('picked').
18632
+ on(this.viewerService.threeJsPlacementChanged$, (placing) => this.post({ type: 'placing', on: placing }));
18633
+ on(this.viewerService.threeJsAlignmentProgress$, (progress) => {
18634
+ this.alignment = progress;
18635
+ this.postCalibration();
18636
+ });
18637
+ on(this.viewerService.threeJsAlignmentCancelled$, () => {
18638
+ this.alignment = null;
18639
+ this.postCalibration();
18640
+ });
18641
+ on(this.viewerService.threeJsMeasuringChanged$, (measuring) => this.post({ type: 'measuring', on: measuring }));
18642
+ on(this.viewerService.threeJsMeasurementProgress$, (progress) => {
18643
+ this.measureDistances = progress?.distances ?? [];
18644
+ this.postMeasurement(!!progress);
18645
+ });
18646
+ // A finished measurement stays drawn while its form is open; this is when it goes.
18647
+ on(this.viewerService.threeJsMeasurementCleared$, () => {
18648
+ this.measureDistances = [];
18649
+ this.postMeasurement(false);
18650
+ });
18651
+ // The camera sent to a tag, from a list: the page points it out.
18652
+ on(this.viewerService.threeJsTagFocused$, (id) => this.post({ type: 'goToTag', id }));
18653
+ }
18654
+ ngOnChanges(changes) {
18655
+ if (changes['modelKey'] && this.ready && !this.cancelled) {
18656
+ void this.load();
18657
+ }
18658
+ }
18659
+ ngOnDestroy() {
18660
+ this.destroyed$.next();
18661
+ this.destroyed$.complete();
18662
+ this.viewerService.setThreeJsRenderingPaused(false);
18663
+ if (this.streamedMeta) {
18664
+ URL.revokeObjectURL(this.streamedMeta);
18665
+ }
18666
+ this.snapshots.forEach((resolve) => resolve(null));
18667
+ this.snapshots.clear();
18668
+ }
18669
+ /** Stops the model loading; nothing is loaded again until reload(). */
18670
+ cancelLoad() {
18671
+ this.cancelled = true;
18672
+ this.loadedKey = null;
18673
+ this.post({ type: 'cancel-load' });
18674
+ }
18675
+ /** Loads the model again, after cancelLoad. */
18676
+ reload() {
18677
+ this.cancelled = false;
18678
+ this.loadedKey = null;
18679
+ if (this.ready) {
18680
+ void this.load();
18681
+ }
18682
+ }
18683
+ setMode(mode) {
18684
+ this.post({ type: 'mode', mode });
18685
+ }
18686
+ /**
18687
+ * The scene as it is on screen, with the measurement drawn on it and no tags, as a JPEG data URL;
18688
+ * null when there is no scene to take, or the page does not answer within ten seconds.
18689
+ */
18690
+ snapshot() {
18691
+ const id = ++this.snapshotCount;
18692
+ return new Promise((resolve) => {
18693
+ this.snapshots.set(id, resolve);
18694
+ this.post({ type: 'snapshot', id });
18695
+ setTimeout(() => {
18696
+ if (this.snapshots.delete(id)) {
18697
+ resolve(null);
18698
+ }
18699
+ }, 10000);
18700
+ });
18701
+ }
18702
+ /** What the page says: that it is ready, how loading goes, what was clicked in it. */
18703
+ onMessage(event) {
18704
+ // Only this component's page, served from this same origin.
18705
+ if (event.source !== this.frame?.nativeElement.contentWindow || event.origin !== window.location.origin) {
18706
+ return;
18707
+ }
18708
+ const message = event.data;
18709
+ const aligning = this.viewerService.isThreeJsAligningTags();
18710
+ switch (message?.type) {
18711
+ case 'ready':
18712
+ // A new page, with nothing loaded or armed in it: the first, or the viewer starting over
18713
+ // for the next model or after a cancelled load.
18714
+ this.ready = true;
18715
+ this.loadedKey = null;
18716
+ this.pickingForCalibration = false;
18717
+ if (!this.cancelled) {
18718
+ void this.load();
18719
+ }
18720
+ this.postTags();
18721
+ this.postCalibration();
18722
+ this.post({ type: 'measuring', on: this.viewerService.isThreeJsMeasuring() });
18723
+ this.postMeasurement(this.viewerService.getThreeJsMeasurePointsInModelFrame().length > 0);
18724
+ break;
18725
+ case 'progress':
18726
+ this.loadProgress.emit(message.fraction ?? null);
18727
+ break;
18728
+ case 'loaded':
18729
+ this.loaded.emit();
18730
+ break;
18731
+ case 'error':
18732
+ console.error('Splat viewer:', message.message);
18733
+ this.loadFailed.emit(String(message.message ?? ''));
18734
+ break;
18735
+ case 'mode':
18736
+ this.modeChange.emit(message.mode);
18737
+ break;
18738
+ case 'picked':
18739
+ if (aligning && this.alignment?.awaiting === 'point') {
18740
+ // Where the tag being matched really is, as a click on the model in the scene would say.
18741
+ if (!this.viewerService.matchThreeJsAlignmentTagAtModelPoint(message.position)) {
18742
+ this.viewerService.ignoreThreeJsAlignmentClick('point');
18743
+ }
18744
+ break;
18745
+ }
18746
+ // As a click on the model in the scene: the app opens the creation form from threeJsTagPlaced$.
18747
+ this.viewerService.placeThreeJsTagAtModelPoint(message.position, message.normal ?? null);
18748
+ break;
18749
+ case 'placing-cancelled':
18750
+ // Escape, as in the scene: it ends a measurement or a calibration as it ends placing a tag.
18751
+ if (this.viewerService.isThreeJsMeasuring()) {
18752
+ this.viewerService.action_toolbox_cancel_mesure();
18753
+ }
18754
+ else if (aligning) {
18755
+ this.viewerService.cancelThreeJsTagAlignment();
18756
+ }
18757
+ else {
18758
+ this.viewerService.cancelThreeJsTagPlacement();
18759
+ }
18760
+ break;
18761
+ case 'calibration-undo':
18762
+ // Ctrl+Z or Backspace in the page, as in the scene.
18763
+ this.viewerService.undoThreeJsAlignmentStep();
18764
+ break;
18765
+ case 'measure-point':
18766
+ this.viewerService.addThreeJsMeasurePointAtModelPoint(message.position);
18767
+ break;
18768
+ case 'tagClick':
18769
+ if (aligning) {
18770
+ // Calibrating: a tag clicked is the one to match, as in the scene, and opens nothing.
18771
+ if (this.alignment?.awaiting !== 'tag' || !this.viewerService.selectThreeJsAlignmentTag(message.id)) {
18772
+ this.viewerService.ignoreThreeJsAlignmentClick('tag');
18773
+ }
18774
+ break;
18775
+ }
18776
+ this.viewerService.clickThreeJsTag(message.id);
18777
+ break;
18778
+ case 'snapshot':
18779
+ this.snapshots.get(message.id)?.(message.image ?? null);
18780
+ this.snapshots.delete(message.id);
18781
+ break;
18782
+ case 'sign': {
18783
+ // A piece of the Streamed SOG open, by its name beside lod-meta.json.
18784
+ const key = this.loadedKey;
18785
+ const folder = key ? key.slice(0, key.lastIndexOf('/') + 1) : '';
18786
+ const file = String(message.file ?? '');
18787
+ const allowed = key && isStreamedSplatFile(key) && /^[\w.-]+$/.test(file);
18788
+ (allowed ? getSignedFile(folder + file) : Promise.resolve(null)).then((url) => {
18789
+ this.post({ type: 'signed', id: message.id, url });
18790
+ });
18791
+ break;
18792
+ }
18793
+ default:
18794
+ break;
18795
+ }
18796
+ }
18797
+ async load() {
18798
+ const key = this.modelKey;
18799
+ if (!key || key === this.loadedKey) {
18800
+ return;
18801
+ }
18802
+ this.loadedKey = key;
18803
+ this.loadProgress.emit(0);
18804
+ let url = key.startsWith('assets/') ? key : await getSignedFile(key);
18805
+ if (url && isStreamedSplatFile(key)) {
18806
+ url = await this.streamedEntry(url);
18807
+ }
18808
+ if (this.loadedKey !== key) {
18809
+ // Another model was asked for, or the load cancelled, while the link was being signed.
18810
+ return;
18811
+ }
18812
+ this.post({ type: 'load', url, fileName: key.split('/').pop(), up: this.up });
18813
+ }
18814
+ /**
18815
+ * The lod-meta.json of a Streamed SOG, with its pieces named so that the page asks for each of
18816
+ * them when it needs it ('sign').
18817
+ *
18818
+ * The pieces sit beside lod-meta.json in S3 and are named relative to it, which a signed link -
18819
+ * its signature in the query - does not survive. And they are fetched while the visit lasts, as the
18820
+ * camera reaches them, long after any link signed up front has run out.
18821
+ */
18822
+ async streamedEntry(signedMeta) {
18823
+ const meta = await (await fetch(signedMeta)).json();
18824
+ meta.filenames = (meta.filenames ?? []).map((name) => `https://splat.invalid/${name}`);
18825
+ if (this.streamedMeta) {
18826
+ URL.revokeObjectURL(this.streamedMeta);
18827
+ }
18828
+ this.streamedMeta = URL.createObjectURL(new Blob([JSON.stringify(meta)], { type: 'application/json' }));
18829
+ return this.streamedMeta;
18830
+ }
18831
+ postTags() {
18832
+ const { tags, alignment } = this.viewerService.getThreeJsTagSnapshot();
18833
+ // Tags placed elsewhere - in Matterport, in another model - are in that place's coordinates,
18834
+ // and only a calibration says where they are in this one. Without it they float off the model
18835
+ // and seem to slide as the camera moves: they are left out until it is calibrated. A
18836
+ // calibration under way, or its panel open, shows them all: they are what it matches.
18837
+ const all = this.viewerService.isThreeJsCalibrated() || this.viewerService.isThreeJsShowingCalibrationTags();
18838
+ const shown = all ? tags : tags.filter((tag) => tag.inModelFrame);
18839
+ this.post({
18840
+ type: 'tags',
18841
+ tags: shown.map(({ id, position, label, iconUrl, inModelFrame, normal }) => ({ id, position, label, iconUrl, inModelFrame, normal })),
18842
+ alignment,
18843
+ });
18844
+ }
18845
+ /**
18846
+ * The calibration under way, for the page to draw - the pins dropped, the tag being matched - and,
18847
+ * while the place of a tag is asked for, to take the click on the model that says where ('picked').
18848
+ */
18849
+ postCalibration() {
18850
+ const on = this.viewerService.isThreeJsAligningTags() && !!this.alignment;
18851
+ const awaiting = on ? this.alignment.awaiting : null;
18852
+ this.post({
18853
+ type: 'calibration',
18854
+ calibration: on
18855
+ ? {
18856
+ selected: awaiting === 'point' ? this.alignment.tagId ?? null : null,
18857
+ pairs: this.alignment.matched.map(({ tagId, target }) => ({ tagId, target })),
18858
+ }
18859
+ : null,
18860
+ });
18861
+ const picking = awaiting === 'point';
18862
+ // Only when it changes: placing a tag uses the same click, and is not to be switched off here.
18863
+ if (picking !== this.pickingForCalibration) {
18864
+ this.pickingForCalibration = picking;
18865
+ this.post({ type: 'placing', on: picking });
18866
+ }
18867
+ }
18868
+ /** The measurement for the page to draw - its points, each segment's length - or nothing. */
18869
+ postMeasurement(drawn) {
18870
+ this.post({
18871
+ type: 'measurement',
18872
+ measurement: drawn
18873
+ ? { points: this.viewerService.getThreeJsMeasurePointsInModelFrame(), distances: this.measureDistances }
18874
+ : null,
18875
+ });
18876
+ }
18877
+ post(command) {
18878
+ const page = this.frame?.nativeElement.contentWindow;
18879
+ if (page && this.ready) {
18880
+ page.postMessage(command, window.location.origin);
18881
+ }
18882
+ }
18883
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SplatViewerComponent, deps: [{ token: ViewerService }, { token: i0.ElementRef }, { token: i1$3.DomSanitizer }, { token: i1.TranslateService }], target: i0.ɵɵFactoryTarget.Component });
18884
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SplatViewerComponent, selector: "lib-splat-viewer", inputs: { modelKey: "modelKey", up: "up" }, outputs: { loadProgress: "loadProgress", loaded: "loaded", loadFailed: "loadFailed", modeChange: "modeChange" }, host: { listeners: { "window:message": "onMessage($event)" } }, viewQueries: [{ propertyName: "frame", first: true, predicate: ["frame"], descendants: true, static: true }], usesOnChanges: true, ngImport: i0, template: '<iframe #frame [src]="page" title="3D" allow="fullscreen; xr-spatial-tracking" allowfullscreen></iframe>', isInline: true, styles: [":host{display:block;color:var(--smarterplan-primary, rgb(83, 182, 187));background-color:var(--smarterplan-viewer-background, #e0f7ff)}iframe{display:block;width:100%;height:100%;border:0}\n"] });
18885
+ }
18886
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SplatViewerComponent, decorators: [{
18887
+ type: Component,
18888
+ args: [{ selector: 'lib-splat-viewer', template: '<iframe #frame [src]="page" title="3D" allow="fullscreen; xr-spatial-tracking" allowfullscreen></iframe>', styles: [":host{display:block;color:var(--smarterplan-primary, rgb(83, 182, 187));background-color:var(--smarterplan-viewer-background, #e0f7ff)}iframe{display:block;width:100%;height:100%;border:0}\n"] }]
18889
+ }], ctorParameters: () => [{ type: ViewerService }, { type: i0.ElementRef }, { type: i1$3.DomSanitizer }, { type: i1.TranslateService }], propDecorators: { modelKey: [{
18890
+ type: Input
18891
+ }], up: [{
18892
+ type: Input
18893
+ }], loadProgress: [{
18894
+ type: Output
18895
+ }], loaded: [{
18896
+ type: Output
18897
+ }], loadFailed: [{
18898
+ type: Output
18899
+ }], modeChange: [{
18900
+ type: Output
18901
+ }], frame: [{
18902
+ type: ViewChild,
18903
+ args: ['frame', { static: true }]
18904
+ }], onMessage: [{
18905
+ type: HostListener,
18906
+ args: ['window:message', ['$event']]
18907
+ }] } });
18908
+
16629
18909
  class NgxSmarterplanCoreModule {
16630
18910
  static forRoot(config) {
16631
18911
  return {
@@ -16652,7 +18932,8 @@ class NgxSmarterplanCoreModule {
16652
18932
  NavigationBarComponent,
16653
18933
  LoaderComponent,
16654
18934
  SupportModalComponent,
16655
- FormatDateNumberToDigitsPipe], imports: [FormsModule,
18935
+ FormatDateNumberToDigitsPipe,
18936
+ SplatViewerComponent], imports: [FormsModule,
16656
18937
  ReactiveFormsModule,
16657
18938
  CommonModule,
16658
18939
  RouterModule,
@@ -16666,7 +18947,8 @@ class NgxSmarterplanCoreModule {
16666
18947
  MenuBarComponent,
16667
18948
  LoaderComponent,
16668
18949
  SupportModalComponent,
16669
- AvatarComponent] });
18950
+ AvatarComponent,
18951
+ SplatViewerComponent] });
16670
18952
  static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NgxSmarterplanCoreModule, imports: [FormsModule,
16671
18953
  ReactiveFormsModule,
16672
18954
  CommonModule,
@@ -16693,7 +18975,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
16693
18975
  NavigationBarComponent,
16694
18976
  LoaderComponent,
16695
18977
  SupportModalComponent,
16696
- FormatDateNumberToDigitsPipe
18978
+ FormatDateNumberToDigitsPipe,
18979
+ SplatViewerComponent
16697
18980
  ],
16698
18981
  imports: [
16699
18982
  FormsModule,
@@ -16717,6 +19000,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
16717
19000
  LoaderComponent,
16718
19001
  SupportModalComponent,
16719
19002
  AvatarComponent,
19003
+ SplatViewerComponent,
16720
19004
  ]
16721
19005
  }]
16722
19006
  }] });
@@ -17373,7 +19657,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
17373
19657
  }] }, { type: NavigationService }, { type: ZoneService }, { type: ViewerService }, { type: LayerService }, { type: BaseUserService }, { type: PlanService }] });
17374
19658
 
17375
19659
  /** File extensions the Three.js viewer can load as a base model. */
17376
- const SUPPORTED_MODEL_EXTENSIONS = ['.glb', '.gltf'];
19660
+ // The splat formats open in the splat viewer, which runs in an iframe.
19661
+ const SUPPORTED_MODEL_EXTENSIONS = ['.glb', '.gltf', '.splat.ply', '.spz', '.splat', '.ksplat', '.sog', 'lod-meta.json'];
17377
19662
  class ThreeJsImportService {
17378
19663
  navigationService;
17379
19664
  zoneService;
@@ -17403,11 +19688,12 @@ class ThreeJsImportService {
17403
19688
  */
17404
19689
  validateModelKey(key) {
17405
19690
  if (!key || !key.trim()) {
17406
- return 'A 3D model file (.glb) is required for a Three.js visit.';
19691
+ return 'A 3D model file is required.';
17407
19692
  }
17408
19693
  const trimmed = key.trim();
17409
19694
  if (!SUPPORTED_MODEL_EXTENSIONS.some((extension) => trimmed.toLowerCase().endsWith(extension))) {
17410
- return `The 3D model must be a ${SUPPORTED_MODEL_EXTENSIONS.join(' or ')} file.`;
19695
+ const listed = SUPPORTED_MODEL_EXTENSIONS.slice(0, -1).join(', ');
19696
+ return `The 3D model must be a ${listed} or ${SUPPORTED_MODEL_EXTENSIONS[SUPPORTED_MODEL_EXTENSIONS.length - 1]} file.`;
17411
19697
  }
17412
19698
  // A full https:// URL is a signed link that expires; the stored value has to be the key itself
17413
19699
  // so a fresh URL can be signed on every visit.
@@ -17513,6 +19799,655 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
17513
19799
  }]
17514
19800
  }], ctorParameters: () => [{ type: NavigationService }, { type: ZoneService }, { type: LayerService }, { type: BaseUserService }, { type: PlanService }] });
17515
19801
 
19802
+ /**
19803
+ * Converts an .obj model, with its .mtl and textures, into the .glb the Three.js viewer loads - in
19804
+ * the browser, as the model is added, with nothing to install.
19805
+ *
19806
+ * It does what obj2gltf did in converte-modelo.sh, and the reason is the same. An .obj lists every
19807
+ * corner of every triangle; loaded as it is, a captured model's 2.6 million distinct vertices become
19808
+ * 12.6 million, one per corner. Here each position and texture coordinate pair becomes one vertex,
19809
+ * shared by every triangle that uses it, so what reaches the graphics card is the 2.6 million.
19810
+ *
19811
+ * Textures go in as they are, their bytes untouched: a JPEG re-encoded would lose quality and gain
19812
+ * nothing. Normals are only written when the .obj has them; without, the viewer works them out as
19813
+ * it loads the model. The axes are left as the .obj has them - the viewer finds which way is up.
19814
+ *
19815
+ * The .obj is read as a stream, so a file of hundreds of megabytes is never held whole.
19816
+ */
19817
+ /** Whether a set of files holds an .obj model to convert. */
19818
+ function hasObjModel(files) {
19819
+ return files.some((file) => /\.obj$/i.test(file.name));
19820
+ }
19821
+ /**
19822
+ * Converts the .obj among `files` into a .glb, using the .mtl and the images found with it.
19823
+ */
19824
+ async function convertObjToGlb(files, options = {}) {
19825
+ const objs = files.filter((file) => /\.obj$/i.test(file.name));
19826
+ if (objs.length !== 1) {
19827
+ throw new Error(objs.length === 0 ? 'No .obj file was given.' : 'Give one .obj file at a time.');
19828
+ }
19829
+ const obj = objs[0];
19830
+ const byName = new Map(files.map((file) => [baseName(file.name).toLowerCase(), file]));
19831
+ // The .obj's own reading is most of the time; the rest is shared out over what follows.
19832
+ const parsed = await readObj(obj.stream(), obj.size, {
19833
+ ...options,
19834
+ onProgress: (fraction) => options.onProgress?.(fraction * 0.85),
19835
+ });
19836
+ if (parsed.triangleCount === 0) {
19837
+ throw new Error(`${obj.name} has no faces.`);
19838
+ }
19839
+ // Materials: from the .mtl files the .obj names, or failing that any .mtl given with it.
19840
+ const named = parsed.materialLibraries.map((library) => byName.get(baseName(library).toLowerCase())).filter(Boolean);
19841
+ const libraries = named.length > 0 ? named : files.filter((file) => /\.mtl$/i.test(file.name));
19842
+ const materials = new Map();
19843
+ for (const library of libraries) {
19844
+ parseMtl(await library.text()).forEach((material) => materials.set(material.name, material));
19845
+ }
19846
+ await pause(options.signal);
19847
+ const breaks = new Breaks(options.signal, options.onProgress);
19848
+ const geometry = await indexGeometry(parsed, breaks);
19849
+ options.onProgress?.(0.95);
19850
+ await pause(options.signal);
19851
+ const missing = new Set();
19852
+ const glb = await writeGlb(parsed, geometry, materials, byName, missing, obj.name, breaks);
19853
+ options.onProgress?.(1);
19854
+ return {
19855
+ file: new File([glb], obj.name.replace(/\.obj$/i, '') + '.glb', { type: 'model/gltf-binary' }),
19856
+ vertices: geometry.vertexCount,
19857
+ triangles: parsed.triangleCount,
19858
+ materials: parsed.groups.length,
19859
+ missingTextures: [...missing],
19860
+ };
19861
+ }
19862
+ /** The file name without its folders, which is how an .mtl or a texture is looked up. */
19863
+ function baseName(path) {
19864
+ return path.trim().replace(/^"|"$/g, '').split(/[\\/]/).pop() ?? path;
19865
+ }
19866
+ /** Lets the page draw and answer for a moment, and stops here if the conversion was cancelled. */
19867
+ async function pause(signal) {
19868
+ if (signal?.aborted) {
19869
+ throw new DOMException('Model conversion cancelled', 'AbortError');
19870
+ }
19871
+ await new Promise((resolve) => setTimeout(resolve, 0));
19872
+ }
19873
+ /**
19874
+ * Stops a long loop now and then so the page can draw and answer clicks. Checked only every few
19875
+ * thousand turns of the loop: waiting on a promise every turn would cost more than the loop.
19876
+ */
19877
+ class Breaks {
19878
+ signal;
19879
+ onProgress;
19880
+ last = performance.now();
19881
+ constructor(signal, onProgress) {
19882
+ this.signal = signal;
19883
+ this.onProgress = onProgress;
19884
+ }
19885
+ /** Whether this turn of the loop should stop for a moment. Synchronous: it is asked every turn. */
19886
+ due(turn) {
19887
+ return (turn & 0x3fff) === 0 && performance.now() - this.last > 40;
19888
+ }
19889
+ /** Stops for a moment, reporting how far the conversion has got. */
19890
+ async take(fraction) {
19891
+ this.onProgress?.(fraction);
19892
+ await pause(this.signal);
19893
+ this.last = performance.now();
19894
+ }
19895
+ }
19896
+ /** A growable typed array: an .obj does not say up front how many of anything it holds. */
19897
+ class Growable {
19898
+ data;
19899
+ length = 0;
19900
+ constructor(data) {
19901
+ this.data = data;
19902
+ }
19903
+ push(value) {
19904
+ if (this.length === this.data.length) {
19905
+ const bigger = new this.data.constructor(this.data.length * 2);
19906
+ bigger.set(this.data);
19907
+ this.data = bigger;
19908
+ }
19909
+ this.data[this.length] = value;
19910
+ this.length += 1;
19911
+ }
19912
+ view() {
19913
+ return this.data.subarray(0, this.length);
19914
+ }
19915
+ }
19916
+ /**
19917
+ * Reads an .obj from a stream of its bytes: positions, texture coordinates, normals, and triangles
19918
+ * grouped by material. Polygons are split into triangles; negative indices, counting back from the
19919
+ * end, are resolved; anything else (objects, groups, smoothing) is skipped.
19920
+ */
19921
+ async function readObj(stream, size, options = {}) {
19922
+ const parser = new ObjParser();
19923
+ const reader = stream.getReader();
19924
+ let read = 0;
19925
+ let lastBreak = performance.now();
19926
+ try {
19927
+ for (;;) {
19928
+ if (options.signal?.aborted) {
19929
+ throw new DOMException('Model conversion cancelled', 'AbortError');
19930
+ }
19931
+ const { done, value } = await reader.read();
19932
+ if (done) {
19933
+ break;
19934
+ }
19935
+ parser.push(value);
19936
+ read += value.length;
19937
+ if (performance.now() - lastBreak > 40) {
19938
+ options.onProgress?.(size > 0 ? Math.min(read / size, 1) : 0);
19939
+ await new Promise((resolve) => setTimeout(resolve, 0));
19940
+ lastBreak = performance.now();
19941
+ }
19942
+ }
19943
+ }
19944
+ finally {
19945
+ reader.cancel().catch(() => undefined);
19946
+ }
19947
+ parser.end();
19948
+ options.onProgress?.(1);
19949
+ return parser.result();
19950
+ }
19951
+ /**
19952
+ * Line by line, straight from the bytes. Building a string for each of the millions of lines of a
19953
+ * captured model would be most of the work; this reads the numbers off the bytes instead.
19954
+ */
19955
+ class ObjParser {
19956
+ positions = new Growable(new Float64Array(1 << 16));
19957
+ uvs = new Growable(new Float64Array(1 << 16));
19958
+ normals = new Growable(new Float64Array(1 << 16));
19959
+ groups = [];
19960
+ groupByMaterial = new Map();
19961
+ current = null;
19962
+ materialLibraries = [];
19963
+ triangleCount = 0;
19964
+ /** The bytes of the line being read, kept across chunks. */
19965
+ line = new Uint8Array(256);
19966
+ lineLength = 0;
19967
+ // The corners of the face being read, as position/texture/normal indices.
19968
+ face = new Int32Array(3 * 64);
19969
+ push(bytes) {
19970
+ for (let index = 0; index < bytes.length; index += 1) {
19971
+ const byte = bytes[index];
19972
+ if (byte === 10) {
19973
+ this.readLine();
19974
+ this.lineLength = 0;
19975
+ }
19976
+ else {
19977
+ if (this.lineLength === this.line.length) {
19978
+ const longer = new Uint8Array(this.line.length * 2);
19979
+ longer.set(this.line);
19980
+ this.line = longer;
19981
+ }
19982
+ this.line[this.lineLength] = byte;
19983
+ this.lineLength += 1;
19984
+ }
19985
+ }
19986
+ }
19987
+ end() {
19988
+ this.readLine();
19989
+ this.lineLength = 0;
19990
+ }
19991
+ result() {
19992
+ return {
19993
+ positions: this.positions.view(),
19994
+ uvs: this.uvs.view(),
19995
+ normals: this.normals.view(),
19996
+ groups: this.groups.filter((group) => group.corners.length > 0),
19997
+ materialLibraries: this.materialLibraries,
19998
+ triangleCount: this.triangleCount,
19999
+ };
20000
+ }
20001
+ readLine() {
20002
+ const line = this.line;
20003
+ const length = this.lineLength;
20004
+ let at = 0;
20005
+ while (at < length && (line[at] === 32 || line[at] === 9)) {
20006
+ at += 1;
20007
+ }
20008
+ if (at + 1 >= length) {
20009
+ return;
20010
+ }
20011
+ const first = line[at];
20012
+ const second = line[at + 1];
20013
+ if (first === 118 && (second === 32 || second === 9)) {
20014
+ // v x y z
20015
+ this.readNumbers(at + 2, 3, this.positions);
20016
+ }
20017
+ else if (first === 118 && second === 116) {
20018
+ // vt u v
20019
+ this.readNumbers(at + 3, 2, this.uvs);
20020
+ }
20021
+ else if (first === 118 && second === 110) {
20022
+ // vn x y z
20023
+ this.readNumbers(at + 3, 3, this.normals);
20024
+ }
20025
+ else if (first === 102 && (second === 32 || second === 9)) {
20026
+ this.readFace(at + 2);
20027
+ }
20028
+ else if (this.startsWith(at, 'usemtl')) {
20029
+ this.useMaterial(this.restOfLine(at + 6));
20030
+ }
20031
+ else if (this.startsWith(at, 'mtllib')) {
20032
+ this.materialLibraries.push(...this.restOfLine(at + 6).split(/\s+(?=\S+\.mtl\b)/i).filter(Boolean));
20033
+ }
20034
+ }
20035
+ startsWith(at, word) {
20036
+ for (let index = 0; index < word.length; index += 1) {
20037
+ if (this.line[at + index] !== word.charCodeAt(index)) {
20038
+ return false;
20039
+ }
20040
+ }
20041
+ const after = this.line[at + word.length];
20042
+ return after === 32 || after === 9;
20043
+ }
20044
+ restOfLine(at) {
20045
+ let end = this.lineLength;
20046
+ while (end > at && (this.line[end - 1] === 13 || this.line[end - 1] === 32 || this.line[end - 1] === 9)) {
20047
+ end -= 1;
20048
+ }
20049
+ return new TextDecoder().decode(this.line.subarray(at, end)).trim();
20050
+ }
20051
+ useMaterial(name) {
20052
+ let group = this.groupByMaterial.get(name);
20053
+ if (!group) {
20054
+ group = { material: name, corners: new Growable(new Int32Array(1 << 12)) };
20055
+ this.groupByMaterial.set(name, group);
20056
+ this.groups.push(group);
20057
+ }
20058
+ this.current = group;
20059
+ }
20060
+ /** Reads up to `count` numbers from `at` onwards into `target`, padding a short line with 0. */
20061
+ readNumbers(at, count, target) {
20062
+ let read = 0;
20063
+ let position = at;
20064
+ while (read < count) {
20065
+ const [value, next] = this.readNumber(position);
20066
+ if (next === position) {
20067
+ break;
20068
+ }
20069
+ target.push(value);
20070
+ read += 1;
20071
+ position = next;
20072
+ }
20073
+ for (; read < count; read += 1) {
20074
+ target.push(0);
20075
+ }
20076
+ }
20077
+ /** Reads one number starting at or after `at`; returns it and where it ended. */
20078
+ readNumber(at) {
20079
+ const line = this.line;
20080
+ const length = this.lineLength;
20081
+ let index = at;
20082
+ while (index < length && (line[index] === 32 || line[index] === 9 || line[index] === 13)) {
20083
+ index += 1;
20084
+ }
20085
+ const start = index;
20086
+ let negative = false;
20087
+ if (line[index] === 45 || line[index] === 43) {
20088
+ negative = line[index] === 45;
20089
+ index += 1;
20090
+ }
20091
+ let whole = 0;
20092
+ let fraction = 0;
20093
+ let scale = 1;
20094
+ let digits = 0;
20095
+ while (index < length && line[index] >= 48 && line[index] <= 57) {
20096
+ whole = whole * 10 + (line[index] - 48);
20097
+ index += 1;
20098
+ digits += 1;
20099
+ }
20100
+ if (line[index] === 46) {
20101
+ index += 1;
20102
+ while (index < length && line[index] >= 48 && line[index] <= 57) {
20103
+ fraction = fraction * 10 + (line[index] - 48);
20104
+ scale *= 10;
20105
+ index += 1;
20106
+ digits += 1;
20107
+ }
20108
+ }
20109
+ if (digits === 0) {
20110
+ return [0, at];
20111
+ }
20112
+ let value = whole + fraction / scale;
20113
+ if (line[index] === 101 || line[index] === 69) {
20114
+ index += 1;
20115
+ let negativeExponent = false;
20116
+ if (line[index] === 45 || line[index] === 43) {
20117
+ negativeExponent = line[index] === 45;
20118
+ index += 1;
20119
+ }
20120
+ let exponent = 0;
20121
+ while (index < length && line[index] >= 48 && line[index] <= 57) {
20122
+ exponent = exponent * 10 + (line[index] - 48);
20123
+ index += 1;
20124
+ }
20125
+ value *= 10 ** (negativeExponent ? -exponent : exponent);
20126
+ }
20127
+ return [negative ? -value : value, index === start ? at : index];
20128
+ }
20129
+ /** f v/vt/vn ... - any of the three forms, any number of corners, split into a fan. */
20130
+ readFace(at) {
20131
+ const line = this.line;
20132
+ const length = this.lineLength;
20133
+ let corners = 0;
20134
+ let index = at;
20135
+ while (index < length) {
20136
+ while (index < length && (line[index] === 32 || line[index] === 9 || line[index] === 13)) {
20137
+ index += 1;
20138
+ }
20139
+ if (index >= length) {
20140
+ break;
20141
+ }
20142
+ // One corner: up to three integers separated by '/', any of the last two possibly empty.
20143
+ const values = [0, 0, 0];
20144
+ for (let slot = 0; slot < 3; slot += 1) {
20145
+ let negative = false;
20146
+ if (line[index] === 45) {
20147
+ negative = true;
20148
+ index += 1;
20149
+ }
20150
+ let value = 0;
20151
+ let digits = 0;
20152
+ while (index < length && line[index] >= 48 && line[index] <= 57) {
20153
+ value = value * 10 + (line[index] - 48);
20154
+ index += 1;
20155
+ digits += 1;
20156
+ }
20157
+ values[slot] = digits === 0 ? 0 : negative ? -value : value;
20158
+ if (line[index] !== 47) {
20159
+ break;
20160
+ }
20161
+ index += 1;
20162
+ }
20163
+ if (corners < 64 && values[0] !== 0) {
20164
+ // To zero-based indices, counting a negative one back from what has been read so far; -1
20165
+ // means "none" for the texture and normal.
20166
+ this.face[corners * 3] = this.resolve(values[0], this.positions.length / 3);
20167
+ this.face[corners * 3 + 1] = values[1] === 0 ? -1 : this.resolve(values[1], this.uvs.length / 2);
20168
+ this.face[corners * 3 + 2] = values[2] === 0 ? -1 : this.resolve(values[2], this.normals.length / 3);
20169
+ corners += 1;
20170
+ }
20171
+ // Skip to the next separator, past anything unexpected in this token.
20172
+ while (index < length && line[index] !== 32 && line[index] !== 9) {
20173
+ index += 1;
20174
+ }
20175
+ }
20176
+ if (corners < 3) {
20177
+ return;
20178
+ }
20179
+ if (!this.current) {
20180
+ this.useMaterial(null);
20181
+ }
20182
+ const target = this.current.corners;
20183
+ for (let corner = 1; corner + 1 < corners; corner += 1) {
20184
+ for (const which of [0, corner, corner + 1]) {
20185
+ target.push(this.face[which * 3]);
20186
+ target.push(this.face[which * 3 + 1]);
20187
+ target.push(this.face[which * 3 + 2]);
20188
+ }
20189
+ this.triangleCount += 1;
20190
+ }
20191
+ }
20192
+ resolve(index, count) {
20193
+ return index > 0 ? index - 1 : count + index;
20194
+ }
20195
+ }
20196
+ /**
20197
+ * Turns corners into shared vertices: one vertex per distinct position, texture and normal triple.
20198
+ *
20199
+ * Corners are grouped by position first, and the few corners that share a position are compared
20200
+ * with one another. That needs no table of every triple ever seen - which, over millions of
20201
+ * corners, would cost far more memory than the model itself.
20202
+ */
20203
+ async function indexGeometry(parsed, breaks) {
20204
+ const positionCount = parsed.positions.length / 3;
20205
+ const groups = parsed.groups.map((group) => group.corners.view());
20206
+ const cornerCount = groups.reduce((sum, corners) => sum + corners.length / 3, 0);
20207
+ // Every corner, numbered across all groups, listed under its position.
20208
+ const perPosition = new Int32Array(positionCount + 1);
20209
+ groups.forEach((corners) => {
20210
+ for (let at = 0; at < corners.length; at += 3) {
20211
+ perPosition[corners[at] + 1] += 1;
20212
+ }
20213
+ });
20214
+ for (let position = 0; position < positionCount; position += 1) {
20215
+ perPosition[position + 1] += perPosition[position];
20216
+ }
20217
+ const filled = perPosition.slice(0, positionCount);
20218
+ const byPosition = new Int32Array(cornerCount);
20219
+ const cornerStart = [];
20220
+ let numbered = 0;
20221
+ groups.forEach((corners) => {
20222
+ cornerStart.push(numbered);
20223
+ for (let at = 0; at < corners.length; at += 3) {
20224
+ byPosition[filled[corners[at]]] = numbered;
20225
+ filled[corners[at]] += 1;
20226
+ numbered += 1;
20227
+ }
20228
+ });
20229
+ // Which group each corner is in, so its triple can be read back where it already is rather than
20230
+ // from a copy of every corner.
20231
+ const groupOf = new Uint16Array(cornerCount);
20232
+ groups.forEach((corners, group) => groupOf.fill(group, cornerStart[group], cornerStart[group] + corners.length / 3));
20233
+ const vertexOfCorner = new Uint32Array(cornerCount);
20234
+ const sources = new Growable(new Int32Array(Math.max(16, positionCount * 3 * 1.3) | 0));
20235
+ let vertexCount = 0;
20236
+ for (let position = 0; position < positionCount; position += 1) {
20237
+ if (breaks.due(position)) {
20238
+ await breaks.take(0.85 + 0.07 * (position / positionCount));
20239
+ }
20240
+ const first = vertexCount;
20241
+ for (let at = perPosition[position]; at < perPosition[position + 1]; at += 1) {
20242
+ const corner = byPosition[at];
20243
+ const group = groupOf[corner];
20244
+ const triple = (corner - cornerStart[group]) * 3;
20245
+ const uv = groups[group][triple + 1];
20246
+ const normal = groups[group][triple + 2];
20247
+ let found = -1;
20248
+ for (let vertex = first; vertex < vertexCount; vertex += 1) {
20249
+ if (sources.data[vertex * 3 + 1] === uv && sources.data[vertex * 3 + 2] === normal) {
20250
+ found = vertex;
20251
+ break;
20252
+ }
20253
+ }
20254
+ if (found < 0) {
20255
+ found = vertexCount;
20256
+ sources.push(position);
20257
+ sources.push(uv);
20258
+ sources.push(normal);
20259
+ vertexCount += 1;
20260
+ }
20261
+ vertexOfCorner[corner] = found;
20262
+ }
20263
+ }
20264
+ return {
20265
+ vertexCount,
20266
+ sources: sources.view(),
20267
+ indices: groups.map((corners, group) => vertexOfCorner.slice(cornerStart[group], cornerStart[group] + corners.length / 3)),
20268
+ };
20269
+ }
20270
+ /** The parts of an .mtl that matter here: the colour, the opacity and the colour texture. */
20271
+ function parseMtl(text) {
20272
+ const materials = [];
20273
+ let current = null;
20274
+ text.split(/\r?\n/).forEach((raw) => {
20275
+ const line = raw.trim();
20276
+ const [keyword, ...rest] = line.split(/\s+/);
20277
+ if (keyword === 'newmtl') {
20278
+ current = { name: rest.join(' '), colour: [1, 1, 1], opacity: 1, texture: null };
20279
+ materials.push(current);
20280
+ }
20281
+ else if (!current) {
20282
+ return;
20283
+ }
20284
+ else if (keyword === 'Kd' && rest.length >= 3) {
20285
+ current.colour = [Number(rest[0]), Number(rest[1]), Number(rest[2])];
20286
+ }
20287
+ else if (keyword === 'd' && rest.length >= 1) {
20288
+ current.opacity = Number(rest[0]);
20289
+ }
20290
+ else if (keyword === 'map_Kd' && rest.length >= 1) {
20291
+ // Options (-s 1 1 1, -o ..., -bm 0.5) come before the file name, which is what is left.
20292
+ current.texture = rest[rest.length - 1];
20293
+ }
20294
+ });
20295
+ return materials;
20296
+ }
20297
+ /**
20298
+ * Writes the .glb. The binary part is assembled from the arrays and the texture files as they are,
20299
+ * into a Blob - so the textures are never read into memory, nor the whole file built in one buffer.
20300
+ */
20301
+ async function writeGlb(parsed, geometry, materials, files, missing, name, breaks) {
20302
+ const { vertexCount, sources } = geometry;
20303
+ const hasUv = parsed.uvs.length > 0 && hasAny(sources, 1);
20304
+ const hasNormals = parsed.normals.length > 0 && hasAny(sources, 2);
20305
+ const positions = new Float32Array(vertexCount * 3);
20306
+ const min = [Infinity, Infinity, Infinity];
20307
+ const max = [-Infinity, -Infinity, -Infinity];
20308
+ const uvs = hasUv ? new Float32Array(vertexCount * 2) : null;
20309
+ const normals = hasNormals ? new Float32Array(vertexCount * 3) : null;
20310
+ for (let vertex = 0; vertex < vertexCount; vertex += 1) {
20311
+ if (breaks.due(vertex)) {
20312
+ await breaks.take(0.95 + 0.04 * (vertex / vertexCount));
20313
+ }
20314
+ const position = sources[vertex * 3];
20315
+ for (let axis = 0; axis < 3; axis += 1) {
20316
+ const value = parsed.positions[position * 3 + axis];
20317
+ positions[vertex * 3 + axis] = value;
20318
+ min[axis] = Math.min(min[axis], Math.fround(value));
20319
+ max[axis] = Math.max(max[axis], Math.fround(value));
20320
+ }
20321
+ if (uvs) {
20322
+ const uv = sources[vertex * 3 + 1];
20323
+ // An .obj counts v up from the bottom of the image, glTF down from the top.
20324
+ uvs[vertex * 2] = uv >= 0 ? parsed.uvs[uv * 2] : 0;
20325
+ uvs[vertex * 2 + 1] = uv >= 0 ? 1 - parsed.uvs[uv * 2 + 1] : 0;
20326
+ }
20327
+ if (normals) {
20328
+ const normal = sources[vertex * 3 + 2];
20329
+ for (let axis = 0; axis < 3; axis += 1) {
20330
+ normals[vertex * 3 + axis] = normal >= 0 ? parsed.normals[normal * 3 + axis] : 0;
20331
+ }
20332
+ }
20333
+ }
20334
+ const parts = [];
20335
+ const bufferViews = [];
20336
+ let byteLength = 0;
20337
+ const addView = (part, size, target) => {
20338
+ const padding = (4 - (byteLength % 4)) % 4;
20339
+ if (padding) {
20340
+ parts.push(new Uint8Array(padding));
20341
+ byteLength += padding;
20342
+ }
20343
+ parts.push(part);
20344
+ bufferViews.push({ buffer: 0, byteOffset: byteLength, byteLength: size, ...(target ? { target } : {}) });
20345
+ byteLength += size;
20346
+ return bufferViews.length - 1;
20347
+ };
20348
+ const accessors = [];
20349
+ const addAccessor = (data, type, count, extra = {}, target) => {
20350
+ const view = addView(data, data.byteLength, target);
20351
+ accessors.push({ bufferView: view, componentType: data instanceof Float32Array ? 5126 : 5125, count, type, ...extra });
20352
+ return accessors.length - 1;
20353
+ };
20354
+ const attributes = {
20355
+ POSITION: addAccessor(positions, 'VEC3', vertexCount, { min, max }, 34962),
20356
+ };
20357
+ if (normals) {
20358
+ attributes['NORMAL'] = addAccessor(normals, 'VEC3', vertexCount, {}, 34962);
20359
+ }
20360
+ if (uvs) {
20361
+ attributes['TEXCOORD_0'] = addAccessor(uvs, 'VEC2', vertexCount, {}, 34962);
20362
+ }
20363
+ // Materials, each texture file stored once however many materials use it.
20364
+ const images = [];
20365
+ const textures = [];
20366
+ const textureOf = new Map();
20367
+ const textureIndex = (fileName) => {
20368
+ const key = baseName(fileName).toLowerCase();
20369
+ if (textureOf.has(key)) {
20370
+ return textureOf.get(key);
20371
+ }
20372
+ const file = files.get(key);
20373
+ if (!file) {
20374
+ missing.add(baseName(fileName));
20375
+ return null;
20376
+ }
20377
+ const mimeType = /\.png$/i.test(file.name) ? 'image/png' : 'image/jpeg';
20378
+ images.push({ bufferView: addView(file, file.size), mimeType, name: baseName(file.name) });
20379
+ textures.push({ source: images.length - 1, sampler: 0 });
20380
+ textureOf.set(key, textures.length - 1);
20381
+ return textures.length - 1;
20382
+ };
20383
+ const gltfMaterials = [];
20384
+ const primitives = parsed.groups.map((group, index) => {
20385
+ const material = group.material !== null ? materials.get(group.material) : undefined;
20386
+ const texture = material?.texture ? textureIndex(material.texture) : null;
20387
+ gltfMaterials.push({
20388
+ name: group.material ?? 'default',
20389
+ pbrMetallicRoughness: {
20390
+ ...(texture !== null ? { baseColorTexture: { index: texture } } : {}),
20391
+ baseColorFactor: texture !== null ? [1, 1, 1, material?.opacity ?? 1] : [...(material?.colour ?? [0.8, 0.8, 0.8]), material?.opacity ?? 1],
20392
+ metallicFactor: 0,
20393
+ roughnessFactor: 1,
20394
+ },
20395
+ ...(material && material.opacity < 1 ? { alphaMode: 'BLEND' } : {}),
20396
+ });
20397
+ const triangles = geometry.indices[index];
20398
+ return {
20399
+ attributes,
20400
+ indices: addAccessor(triangles, 'SCALAR', triangles.length, {}, 34963),
20401
+ material: gltfMaterials.length - 1,
20402
+ };
20403
+ });
20404
+ const json = {
20405
+ asset: { version: '2.0', generator: 'SmarterPlan obj-to-glb' },
20406
+ scene: 0,
20407
+ scenes: [{ nodes: [0] }],
20408
+ nodes: [{ mesh: 0, name: name.replace(/\.obj$/i, '') }],
20409
+ meshes: [{ primitives }],
20410
+ materials: gltfMaterials,
20411
+ ...(textures.length ? {
20412
+ textures,
20413
+ images,
20414
+ // Linear filtering with mipmaps, repeating: what a captured model's atlas expects.
20415
+ samplers: [{ magFilter: 9729, minFilter: 9987, wrapS: 10497, wrapT: 10497 }],
20416
+ } : {}),
20417
+ accessors,
20418
+ bufferViews,
20419
+ buffers: [{ byteLength }],
20420
+ };
20421
+ let jsonBytes = new TextEncoder().encode(JSON.stringify(json));
20422
+ const jsonPadding = (4 - (jsonBytes.length % 4)) % 4;
20423
+ if (jsonPadding) {
20424
+ const padded = new Uint8Array(jsonBytes.length + jsonPadding).fill(32);
20425
+ padded.set(jsonBytes);
20426
+ jsonBytes = padded;
20427
+ }
20428
+ const binPadding = (4 - (byteLength % 4)) % 4;
20429
+ const binLength = byteLength + binPadding;
20430
+ const header = new DataView(new ArrayBuffer(12 + 8));
20431
+ header.setUint32(0, 0x46546c67, true); // "glTF"
20432
+ header.setUint32(4, 2, true);
20433
+ header.setUint32(8, 12 + 8 + jsonBytes.length + 8 + binLength, true);
20434
+ header.setUint32(12, jsonBytes.length, true);
20435
+ header.setUint32(16, 0x4e4f534a, true); // "JSON"
20436
+ const binHeader = new DataView(new ArrayBuffer(8));
20437
+ binHeader.setUint32(0, binLength, true);
20438
+ binHeader.setUint32(4, 0x004e4942, true); // "BIN"
20439
+ return new Blob([header, jsonBytes, binHeader, ...parts, new Uint8Array(binPadding)]);
20440
+ }
20441
+ /** Whether any vertex uses the given slot of its source triple (texture or normal). */
20442
+ function hasAny(sources, slot) {
20443
+ for (let at = slot; at < sources.length; at += 3) {
20444
+ if (sources[at] >= 0) {
20445
+ return true;
20446
+ }
20447
+ }
20448
+ return false;
20449
+ }
20450
+
17516
20451
  // matterport-transform.util.ts
17517
20452
  /**
17518
20453
  * Computes the rigid transform (Y-axis rotation + translation) between two
@@ -20487,5 +23422,5 @@ function floatValidator() {
20487
23422
  * Generated bundle index. Do not edit.
20488
23423
  */
20489
23424
 
20490
- export { AffectationService, AvatarComponent, BaseLateralTabService, BaseTagService, BaseUserService, BaseVisibilityService, CameraMode, CaptureService, CaptureViewer, CommentService, CommentType, Config, ContentService, CsvExportComponent, DomainService, DomainType, DurationToStringPipe, EmailStatus, EquipmentService, EventService, EventStatus, EventType, FeatureService, FeatureType, FilterService, HashtagFromIdPipe, HashtagService, InterventionService, InventoryStatus, LayerService, LevelStatus, LoaderComponent, Locale, LocaleService, MATTERPORT_SDK_KEY, MatterportImportService, MatterportService, MatterportTagCopyService, MatterportViewType, MattertagActionMode, MattertagData, MeasurementService, MenuBarComponent, MissionService, NavigationService, NavigatorService, NgxSmarterplanCoreModule, NgxSmarterplanCoreService, NodeService, Object3DService, OperationService, OrganisationService, PaymentStatus, PlanService, PoiService, PoiType, ProfileEntity, ProfileService, ProfileStatus, PropertyService, PropertyType, RoleStatus, SafeUrlPipe, SearchBarComponent, SearchObjectType, SearchService, SpModule, SpaceService, SpaceStatus, StatusEquipment, SupportModalComponent, SupportService, SweepLocatorService, THREEJS_MODEL_UP_OPTIONS, TagAction, TemplateService, ThreeJsImportService, ThreeJsService, TicketPriority, TicketStatus, TicketType, TicketsService, TimeDateToLocalStringPipe, TypeNote, UsernameFromIdPipe, ValidatorsService, ViewerInteractions, ViewerService, VisitService, ZoneChangeService, ZoneService, applyRigidTransformToDirection, applyRigidTransformToPoint, applyRigidTransformToYRotationDeg, arraysContainSameElements, checkElementById, computeRigidTransform, convertTimestampToLocalZone, copyFileInS3, copyFileInS3ViaDownload, dateHasExpired, dateTimeToLocalString, deleteFromS3, distance, downloadBlob, downloadEquipmentDocument, downloadFile, downloadFileAsObject, durationToString, emailValidator, enumToArray, filterUniqueArrayByID, floatValidator, getBufferForFileFromS3, getCoefficientsForImage, getCurrentLang, getDistanceBetweenTwoPoints, getHighestLevelForMissions, getHighestRoleForMissions, getLevelsBelow, getLocaleLong, getLocaleShort, getMetaForImage, getRolesBelowForManager, getSignedFile, getSignedImageUrlForEquipment, getSignedImageUrlForProfile, getSignedImageUrlForSpace, getSpaceIDFromUrl, getThreeJsPoiOrigin, getVisitUrl, isEmptyObject, listFilesInFolder, mean, noEmptyValidator, numberToDateString, openDocument, openModalForVisitSwitch, parseThreeJsPlacement, poiTypeToString, pointToString, removeAllFilesFromFolderS3, removeNullKeysFromObject, showScanPointsOnPlanInDiv, shuffleArray, solveThreeJsAlignment, sortAlphabeticallyOnName, stringLocaleToEnum, stringToLowercaseNoSpaces, styleButton, textValidator, translateDatePeriod, uploadBase64Image, uploadBase64ImageWithRetry, uploadFileToS3, uploadFileToS3WithProgress, uploadJsonToS3, validEmail, wait, waitUntil };
23425
+ export { AffectationService, AvatarComponent, BaseLateralTabService, BaseTagService, BaseUserService, BaseVisibilityService, CameraMode, CaptureService, CaptureViewer, CommentService, CommentType, Config, ContentService, CsvExportComponent, DomainService, DomainType, DurationToStringPipe, EmailStatus, EquipmentService, EventService, EventStatus, EventType, FeatureService, FeatureType, FilterService, GAUSSIAN_SPLAT_PLY_SUFFIX, HashtagFromIdPipe, HashtagService, InterventionService, InventoryStatus, LayerService, LevelStatus, LoaderComponent, Locale, LocaleService, MATTERPORT_SDK_KEY, MatterportImportService, MatterportService, MatterportTagCopyService, MatterportViewType, MattertagActionMode, MattertagData, MeasurementService, MenuBarComponent, MissionService, NavigationService, NavigatorService, NgxSmarterplanCoreModule, NgxSmarterplanCoreService, NodeService, Object3DService, OperationService, OrganisationService, PaymentStatus, PlanService, PoiService, PoiType, ProfileEntity, ProfileService, ProfileStatus, PropertyService, PropertyType, RoleStatus, SafeUrlPipe, SearchBarComponent, SearchObjectType, SearchService, SpModule, SpaceService, SpaceStatus, SplatViewerComponent, StatusEquipment, SupportModalComponent, SupportService, SweepLocatorService, THREEJS_MODEL_UP_OPTIONS, TagAction, TemplateService, ThreeJsImportService, ThreeJsService, TicketPriority, TicketStatus, TicketType, TicketsService, TimeDateToLocalStringPipe, TypeNote, UsernameFromIdPipe, VIEWER_BACKGROUND, ValidatorsService, ViewerInteractions, ViewerService, VisitService, ZoneChangeService, ZoneService, applyRigidTransformToDirection, applyRigidTransformToPoint, applyRigidTransformToYRotationDeg, arraysContainSameElements, checkElementById, computeRigidTransform, convertObjToGlb, convertTimestampToLocalZone, copyFileInS3, copyFileInS3ViaDownload, dateHasExpired, dateTimeToLocalString, deleteFromS3, distance, downloadBlob, downloadEquipmentDocument, downloadFile, downloadFileAsObject, durationToString, emailValidator, enumToArray, filterUniqueArrayByID, floatValidator, getBufferForFileFromS3, getCoefficientsForImage, getCurrentLang, getDistanceBetweenTwoPoints, getHighestLevelForMissions, getHighestRoleForMissions, getLevelsBelow, getLocaleLong, getLocaleShort, getMetaForImage, getRolesBelowForManager, getSignedFile, getSignedImageUrlForEquipment, getSignedImageUrlForProfile, getSignedImageUrlForSpace, getSpaceIDFromUrl, getThreeJsPoiOrigin, getVisitUrl, hasObjModel, isEmptyObject, isGaussianSplatFile, isGaussianSplatPly, isStreamedSplatFile, listFilesInFolder, mean, noEmptyValidator, numberToDateString, openDocument, openModalForVisitSwitch, parseThreeJsPlacement, poiTypeToString, pointToString, readObj, removeAllFilesFromFolderS3, removeNullKeysFromObject, showScanPointsOnPlanInDiv, shuffleArray, solveThreeJsAlignment, sortAlphabeticallyOnName, stringLocaleToEnum, stringToLowercaseNoSpaces, styleButton, textValidator, translateDatePeriod, uploadBase64Image, uploadBase64ImageWithRetry, uploadFileToS3, uploadFileToS3WithProgress, uploadJsonToS3, validEmail, wait, waitUntil };
20491
23426
  //# sourceMappingURL=smarterplan-ngx-smarterplan-core.mjs.map