@smarterplan/ngx-smarterplan-core 1.4.19 → 1.4.21

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 (29) hide show
  1. package/esm2022/lib/helpers.service.mjs +28 -2
  2. package/esm2022/lib/matterport-extensions/security-camera/SecurityCamera.mjs +28 -7
  3. package/esm2022/lib/services/filter.service.mjs +8 -3
  4. package/esm2022/lib/services/matterport-object3d.service.mjs +657 -133
  5. package/esm2022/lib/services/matterport.service.mjs +330 -9
  6. package/esm2022/lib/services/models/base-object.service.mjs +10 -9
  7. package/esm2022/lib/services/models/equipment.service.mjs +27 -2
  8. package/esm2022/lib/services/models/feature.service.mjs +14 -1
  9. package/esm2022/lib/services/models/measurement.service.mjs +17 -1
  10. package/esm2022/lib/services/sweep-locator.service.mjs +114 -0
  11. package/esm2022/lib/services/threejs.service.mjs +840 -35
  12. package/esm2022/lib/services/utils/matterport-framing.util.mjs +63 -0
  13. package/esm2022/lib/services/viewer.service.mjs +193 -10
  14. package/esm2022/lib/types.service.mjs +3 -1
  15. package/esm2022/public-api.mjs +2 -1
  16. package/fesm2022/smarterplan-ngx-smarterplan-core.mjs +2886 -776
  17. package/fesm2022/smarterplan-ngx-smarterplan-core.mjs.map +1 -1
  18. package/lib/helpers.service.d.ts +17 -0
  19. package/lib/services/matterport-object3d.service.d.ts +125 -5
  20. package/lib/services/matterport.service.d.ts +106 -7
  21. package/lib/services/models/base-object.service.d.ts +3 -4
  22. package/lib/services/models/measurement.service.d.ts +1 -0
  23. package/lib/services/sweep-locator.service.d.ts +54 -0
  24. package/lib/services/threejs.service.d.ts +304 -10
  25. package/lib/services/utils/matterport-framing.util.d.ts +40 -0
  26. package/lib/services/viewer.service.d.ts +87 -2
  27. package/lib/types.service.d.ts +42 -1
  28. package/package.json +1 -1
  29. package/public-api.d.ts +1 -0
@@ -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';
@@ -20,6 +20,9 @@ import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader';
20
20
  import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
21
21
  import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader';
22
22
  import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
23
+ import { Line2 } from 'three/examples/jsm/lines/Line2';
24
+ import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry';
25
+ import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial';
23
26
  import * as FileSaver from 'file-saver';
24
27
  import levenshtein from 'js-levenshtein';
25
28
  import { GlobalWorkerOptions, getDocument } from 'pdfjs-dist';
@@ -383,6 +386,8 @@ var ViewerInteractions;
383
386
  ViewerInteractions[ViewerInteractions["DEFAULT"] = 0] = "DEFAULT";
384
387
  ViewerInteractions[ViewerInteractions["ADDING"] = 1] = "ADDING";
385
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";
386
391
  })(ViewerInteractions || (ViewerInteractions = {}));
387
392
  class ProfileEntity {
388
393
  id;
@@ -630,7 +635,9 @@ class SecurityCamera extends SceneComponent {
630
635
  this.makeHighlight();
631
636
  // attach highighting to the mesh it represents
632
637
  // TODO (scene query): this is very brittle but is the only way to attach something to a room of the model
633
- this.outputs.objectRoot.parent.parent.add(this.highlight);
638
+ if (this.highlight) {
639
+ this.outputs.objectRoot.parent.parent.add(this.highlight);
640
+ }
634
641
  // create the animation mixer to rotate the camera
635
642
  this.makeAnimation();
636
643
  }
@@ -669,6 +676,12 @@ class SecurityCamera extends SceneComponent {
669
676
  this.edges = null;
670
677
  }
671
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;
672
685
  const { position } = edgesGeometry.attributes;
673
686
  const { array, count } = position;
674
687
  const r = thickness / 2;
@@ -678,15 +691,14 @@ class SecurityCamera extends SceneComponent {
678
691
  const b = new THREE.Vector3(array[i + 3], array[i + 4], array[i + 5]);
679
692
  const vec = new THREE.Vector3().subVectors(b, a);
680
693
  const len = vec.length();
681
- const geom = new THREE.CylinderBufferGeometry(r, r, len, 8);
694
+ const geom = new Cylinder(r, r, len, 8);
682
695
  geom.translate(0, len / 2, 0);
683
696
  geom.rotateX(Math.PI / 2);
684
697
  geom.lookAt(vec);
685
698
  geom.translate(a.x, a.y, a.z);
686
699
  geoms.push(geom);
687
700
  }
688
- // API to be updated in bundle - JSSDK-1222
689
- return THREE.mergeBufferGeometries(geoms);
701
+ return merge(geoms);
690
702
  }
691
703
  const frustumLength = this.inputs.farPlane - this.inputs.nearPlane;
692
704
  const boxGeometry = new THREE.BoxGeometry(2, 2, frustumLength);
@@ -722,6 +734,16 @@ class SecurityCamera extends SceneComponent {
722
734
  });
723
735
  this.box = new THREE.Mesh(boxGeometry, boxMaterial);
724
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
+ }
725
747
  this.edges = new THREE.Mesh(edgesGeometry, new THREE.MeshBasicMaterial({
726
748
  color: 0xffffff,
727
749
  opacity: 0.25,
@@ -742,8 +764,12 @@ class SecurityCamera extends SceneComponent {
742
764
  makeHighlight() {
743
765
  const THREE = this.context.three;
744
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
745
- const floorMesh = this.outputs.objectRoot.parent.parent.getObjectByName('FloorMesh:0');
746
- 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
+ }
747
773
  updateHighlightUniforms$1(this.projector, this.highlightUniforms);
748
774
  this.highlightUniforms.color.value.setHex(this.inputs.color);
749
775
  const shader = new THREE.ShaderMaterial({
@@ -2290,6 +2316,69 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2290
2316
  args: ['currentAwsKinesisAnalyticsService']
2291
2317
  }] }, { type: BaseUserService }, { type: ZoneChangeService }] });
2292
2318
 
2319
+ // matterport-framing.util.ts
2320
+ /**
2321
+ * Camera rotation that shows `target` from `camera`, at a given horizontal place of the viewer.
2322
+ * Matterport yaw is 0 when looking toward -Z and grows when turning left; pitch is positive upward.
2323
+ * @param horizontalTanHalfFov tan(horizontal FOV / 2), i.e. 1 / projection[0] of the camera pose
2324
+ * @param screenX where the target must appear, from -1 (left edge) to 1 (right edge); 0 = center.
2325
+ * Used when part of the viewer is covered (side panel), to frame the visible area.
2326
+ */
2327
+ function computeFramingRotation(camera, target, horizontalTanHalfFov = 1, screenX = 0) {
2328
+ const dx = target.x - camera.x;
2329
+ const dy = target.y - camera.y;
2330
+ const dz = target.z - camera.z;
2331
+ const yawToTarget = Math.atan2(-dx, -dz);
2332
+ // the target is seen at screenX when the camera looks that much to its side
2333
+ const offset = Math.atan(screenX * horizontalTanHalfFov);
2334
+ return {
2335
+ x: toDegrees(Math.atan2(dy, Math.hypot(dx, dz))),
2336
+ y: toDegrees(yawToTarget + offset),
2337
+ };
2338
+ }
2339
+ /**
2340
+ * Horizontal place (-1..1) of the center of the visible part of the viewer.
2341
+ * @param viewer client rect of the viewer iframe
2342
+ * @param visibleLeft / visibleRight client X bounds of the part not covered by other panels
2343
+ */
2344
+ function visibleCenterScreenX(viewer, visibleLeft, visibleRight) {
2345
+ if (!viewer.width)
2346
+ return 0;
2347
+ const left = Math.max(viewer.left, visibleLeft);
2348
+ const right = Math.min(viewer.left + viewer.width, visibleRight);
2349
+ if (right <= left)
2350
+ return 0;
2351
+ const center = (left + right) / 2;
2352
+ return ((center - viewer.left) / viewer.width) * 2 - 1;
2353
+ }
2354
+ function toDegrees(rad) {
2355
+ return (rad * 180) / Math.PI;
2356
+ }
2357
+ /** Height of the floor under a sweep: its puck, or 1.5 m under the camera when the SDK gives none */
2358
+ function sweepFloorY(sweep) {
2359
+ return sweep.puckPosition?.y ?? sweep.floorPosition?.y ?? sweep.position.y - 1.5;
2360
+ }
2361
+ /**
2362
+ * Sweep closest to an object placed at `position`, among the sweeps of the same storey: the camera
2363
+ * of the storey below is often nearer in 3D than a sweep of the same room. A sweep is on the same
2364
+ * storey when the object stands between 0.5 m under its floor and 3 m above it; without any such
2365
+ * sweep, all of them are considered. Distances are measured horizontally.
2366
+ */
2367
+ function pickNearestSweepOnSameStorey(sweeps, position) {
2368
+ const sameStorey = sweeps.filter((s) => position.y >= s.floorY - 0.5 && position.y < s.floorY + 3);
2369
+ const candidates = sameStorey.length > 0 ? sameStorey : sweeps;
2370
+ let best = null;
2371
+ let bestDist = Infinity;
2372
+ for (const sweep of candidates) {
2373
+ const dist = Math.hypot(sweep.position.x - position.x, sweep.position.z - position.z);
2374
+ if (dist < bestDist) {
2375
+ bestDist = dist;
2376
+ best = sweep.sid;
2377
+ }
2378
+ }
2379
+ return best;
2380
+ }
2381
+
2293
2382
  /* eslint-disable unicorn/filename-case */
2294
2383
  class MattertagData {
2295
2384
  object;
@@ -3460,6 +3549,28 @@ class MatterportObject3DService {
3460
3549
  azimuthalCrown;
3461
3550
  threeJSScene;
3462
3551
  noLightForObjects = true;
3552
+ /** Max time (ms) to wait for a model to be loaded by the SDK */
3553
+ loadTimeoutMs = 15000;
3554
+ /** Max distance (m) an object can travel from where a translate drag started */
3555
+ maxDragDistance = 2;
3556
+ /** Max vertical travel (m, up and down) during a translate drag, so an object cannot jump to another floor */
3557
+ maxDragHeight = 1.5;
3558
+ /** Emits on every gizmo change and on setObject3DTransform */
3559
+ object3DTransformChanged$ = new Subject();
3560
+ /** Security camera animators by object uuid (`securityCameraAnimator` stays the last loaded one) */
3561
+ securityCameraAnimators = new Map();
3562
+ viewFrustumVisible = new Map();
3563
+ lastSecurityCameraUuid = null;
3564
+ /** Loader component of each model (outputs.objectRoot is set once loaded) */
3565
+ loaderComponents = new Map();
3566
+ gizmos = new Map();
3567
+ activeGizmoUuid = null;
3568
+ gizmoSnap = null;
3569
+ /** Sweeps disabled by setNavigationLocked(true), re-enabled on unlock */
3570
+ sweepsDisabledByLock = [];
3571
+ /** Sweeps re-enabled by the last unlock (the SDK may still report them disabled for a moment) */
3572
+ sweepsEnabledByUnlock = [];
3573
+ tmpBox = new Box3();
3463
3574
  constructor() { }
3464
3575
  async init3DObjectViewer(sdk) {
3465
3576
  const [sceneObject] = await sdk.Scene.createObjects(1);
@@ -3470,112 +3581,184 @@ class MatterportObject3DService {
3470
3581
  this.objectControl = nodeControl.addComponent('mp.transformControls');
3471
3582
  nodeControl.start();
3472
3583
  }
3473
- async add3DObject(sdk, obj, mode) {
3474
- return new Promise(async (resolve) => {
3475
- const [sceneObject] = await sdk.Scene.createObjects(1);
3476
- let isAnimatedSecurityCamera = (obj.object === "security_camera");
3477
- let isNestThermostat = (obj.object === "nest_thermostat");
3478
- let isSmarterplanPromotionalVideo = (obj.object === 'video');
3479
- let isAzimuthalCrown = (obj.object === 'azimuth');
3480
- const modelNode = sceneObject.addNode();
3481
- let component = null;
3482
- const initial = {
3483
- url: `/assets/3Dobjects/objects/${obj.object}${obj.format.indexOf('.') === -1 ? '.' + obj.format : obj.format}`,
3484
- localRotation: { "x": 0, "y": 0, "z": 0 },
3485
- localPosition: { "x": 0, "y": 0, "z": 0 },
3486
- visible: true,
3487
- colliderEnabled: true
3488
- };
3489
- switch (obj.format) {
3490
- case '.obj':
3491
- case 'obj':
3492
- component = modelNode.addComponent('mp.objLoader', initial);
3493
- break;
3494
- case '.fbx':
3495
- case 'fbx':
3496
- component = modelNode.addComponent('mp.fbxLoader', initial);
3497
- break;
3498
- case '.gltf':
3499
- case 'gltf':
3500
- case '.glb':
3501
- case 'glb':
3502
- component = modelNode.addComponent('mp.gltfLoader', initial);
3503
- break;
3504
- case '.dae':
3505
- case 'dae':
3506
- component = modelNode.addComponent('mp.daeLoader', initial);
3507
- break;
3508
- default:
3509
- console.log('Format not supported');
3510
- break;
3584
+ /** SDK loader component name for a model format, null when the format is not supported */
3585
+ getLoaderComponentName(format) {
3586
+ switch ((format || '').toLowerCase()) {
3587
+ case '.obj':
3588
+ case 'obj': return 'mp.objLoader';
3589
+ case '.fbx':
3590
+ case 'fbx': return 'mp.fbxLoader';
3591
+ case '.gltf':
3592
+ case 'gltf':
3593
+ case '.glb':
3594
+ case 'glb': return 'mp.gltfLoader';
3595
+ case '.dae':
3596
+ case 'dae': return 'mp.daeLoader';
3597
+ default: return null;
3598
+ }
3599
+ }
3600
+ /** Resolves once the loader component has produced its objectRoot, rejects after `timeoutMs` */
3601
+ waitForModelLoaded(component, timeoutMs) {
3602
+ return new Promise((resolve, reject) => {
3603
+ const isLoaded = () => !!component?.outputs?.objectRoot;
3604
+ if (isLoaded()) {
3605
+ resolve();
3606
+ return;
3511
3607
  }
3512
- if (this.noLightForObjects) {
3513
- const lightsNode = sceneObject.addNode();
3514
- lightsNode.addComponent('mp.ambientLight', {
3515
- intensity: 1,
3516
- color: { r: 1.0, g: 1.0, b: 1.0 },
3517
- });
3518
- this.noLightForObjects = false;
3519
- }
3520
- modelNode.obj3D.position.set(obj.position.x, obj.position.y, obj.position.z);
3521
- modelNode.obj3D.rotation.set(obj.rotation.x, obj.rotation.y, obj.rotation.z);
3522
- modelNode.obj3D.scale.set(obj.scale.x, obj.scale.y, obj.scale.z);
3523
- if (isAzimuthalCrown) {
3524
- this.azimuthalCrown = modelNode;
3525
- }
3526
- if (mode && !isNestThermostat) {
3527
- this.attachGizmoControlTo3DObject(sdk, modelNode, sceneObject, mode, true, true).catch((e) => console.log(e));
3528
- }
3529
- if (this.lastObject3D && typeof this.lastObject3D.id === 'string') {
3530
- modelNode.obj3D.uuid = this.lastObject3D.id;
3531
- }
3532
- else if (obj.id) {
3533
- modelNode.obj3D.uuid = obj.id;
3534
- }
3535
- this.lastObject3D = modelNode.obj3D;
3536
- this.dictionnaryObjects3D.set(modelNode.obj3D.uuid, modelNode);
3537
- this.dictionnarySceneObjects3D.set(modelNode.obj3D.uuid, sceneObject);
3538
- if (isAnimatedSecurityCamera) {
3539
- const animator = modelNode.addComponent('mp.securityCamera', {
3540
- "nearPlane": 0.1, "farPlane": 10, "horizontalFOV": 52, "aspect": 1.7777777777777777,
3541
- "localPosition": { "x": 0.3, "y": 0.18, "z": 0 },
3542
- "localRotation": { "x": -15, "y": -90, "z": 0 },
3543
- "color": 65280, "panPeriod": 5, "panAngle": -45
3544
- });
3545
- this.securityCameraAnimator = animator;
3546
- if (!obj.viewFrustum) {
3547
- setTimeout(() => { animator.toggleViewFrustum(); }, 1000);
3608
+ const poll = setInterval(() => {
3609
+ if (isLoaded()) {
3610
+ clearInterval(poll);
3611
+ clearTimeout(timer);
3612
+ resolve();
3548
3613
  }
3549
- }
3550
- if (isNestThermostat) {
3551
- const cv = new CanvasRenderer();
3552
- cv.onInit();
3553
- const plane = new PlaneRenderer();
3554
- plane.outputs = { objectRoot: new Object3D(), collider: new Object3D() };
3555
- const inputTexture = cv.outputs.texture;
3556
- plane.setRootScene(this.threeJSScene);
3557
- plane.onInit(modelNode, inputTexture);
3558
- const sc = new NestThermostat();
3559
- sc.setComponent(component, plane, cv);
3560
- sc.setRootScene(this.threeJSScene);
3561
- sc.onInit(modelNode, plane, inputTexture);
3562
- cv.setCanvasNestThermostatPainter(sc);
3563
- setTimeout(() => { sc.inputs.loadingState = "Loaded"; sc.onInputsUpdated(); }, 5000);
3564
- }
3565
- if (isSmarterplanPromotionalVideo) {
3566
- const sc = new TvPlayer();
3567
- sc.setComponent(component);
3568
- sc.onInit(modelNode);
3569
- setTimeout(() => { sc.inputs.loadingState = "Loaded"; sc.onInputsUpdated(); }, 5000);
3570
- }
3571
- sceneObject.start();
3572
- resolve(this.lastObject3D);
3614
+ }, 50);
3615
+ const timer = setTimeout(() => {
3616
+ clearInterval(poll);
3617
+ reject(new Error(`3D model not loaded after ${timeoutMs} ms`));
3618
+ }, timeoutMs);
3573
3619
  });
3574
3620
  }
3621
+ /**
3622
+ * Adds a 3D model to the scene.
3623
+ * The promise resolves with the three.js object once the model is actually loaded, and rejects
3624
+ * (after cleaning the scene) when the format is unknown or the load times out.
3625
+ * The position / rotation / scale of `obj` are applied to the model origin, as stored in DB.
3626
+ */
3627
+ async add3DObject(sdk, obj, mode) {
3628
+ const loaderName = this.getLoaderComponentName(obj.format);
3629
+ if (!loaderName) {
3630
+ throw new Error(`3D object format not supported: ${obj.format}`);
3631
+ }
3632
+ const [sceneObject] = await sdk.Scene.createObjects(1);
3633
+ const isAnimatedSecurityCamera = (!obj.url && obj.object === 'security_camera');
3634
+ const isNestThermostat = (!obj.url && obj.object === 'nest_thermostat');
3635
+ const isSmarterplanPromotionalVideo = (!obj.url && obj.object === 'video');
3636
+ const isAzimuthalCrown = (!obj.url && obj.object === 'azimuth');
3637
+ const modelNode = sceneObject.addNode();
3638
+ const initial = {
3639
+ url: obj.url || `/assets/3Dobjects/objects/${obj.object}${obj.format.indexOf('.') === -1 ? '.' + obj.format : obj.format}`,
3640
+ localRotation: { 'x': 0, 'y': 0, 'z': 0 },
3641
+ localPosition: { 'x': 0, 'y': 0, 'z': 0 },
3642
+ visible: true,
3643
+ colliderEnabled: true
3644
+ };
3645
+ const component = modelNode.addComponent(loaderName, initial);
3646
+ if (this.noLightForObjects) {
3647
+ const lightsNode = sceneObject.addNode();
3648
+ lightsNode.addComponent('mp.ambientLight', {
3649
+ intensity: 1,
3650
+ color: { r: 1.0, g: 1.0, b: 1.0 },
3651
+ });
3652
+ this.noLightForObjects = false;
3653
+ }
3654
+ modelNode.obj3D.position.set(obj.position.x, obj.position.y, obj.position.z);
3655
+ modelNode.obj3D.rotation.set(obj.rotation.x, obj.rotation.y, obj.rotation.z);
3656
+ modelNode.obj3D.scale.set(obj.scale.x, obj.scale.y, obj.scale.z);
3657
+ // The DB id replaces the three.js uuid. The id pre-set through setLastObject3D is only a fallback
3658
+ // for callers without id: taking it first gave the same id to every object loaded meanwhile
3659
+ // (several cameras or cubes loading while an add is starting), which then overwrote each other.
3660
+ if (obj.id) {
3661
+ modelNode.obj3D.uuid = obj.id;
3662
+ }
3663
+ else if (this.lastObject3D && typeof this.lastObject3D.id === 'string') {
3664
+ modelNode.obj3D.uuid = this.lastObject3D.id;
3665
+ }
3666
+ const uuid = modelNode.obj3D.uuid;
3667
+ this.lastObject3D = modelNode.obj3D;
3668
+ this.dictionnaryObjects3D.set(uuid, modelNode);
3669
+ this.dictionnarySceneObjects3D.set(uuid, sceneObject);
3670
+ this.loaderComponents.set(uuid, component);
3671
+ if (isAzimuthalCrown) {
3672
+ this.azimuthalCrown = modelNode;
3673
+ }
3674
+ if (mode && !isNestThermostat) {
3675
+ this.attachGizmo(uuid, mode).catch(() => { });
3676
+ }
3677
+ let animator = null;
3678
+ if (isAnimatedSecurityCamera) {
3679
+ animator = modelNode.addComponent('mp.securityCamera', {
3680
+ 'nearPlane': 0.1, 'farPlane': 10, 'horizontalFOV': 52, 'aspect': 1.7777777777777777,
3681
+ 'localPosition': { 'x': 0.3, 'y': 0.18, 'z': 0 },
3682
+ 'localRotation': { 'x': -15, 'y': -90, 'z': 0 },
3683
+ 'color': 65280, 'panPeriod': 5, 'panAngle': -45
3684
+ });
3685
+ this.securityCameraAnimator = animator;
3686
+ this.securityCameraAnimators.set(uuid, animator);
3687
+ this.viewFrustumVisible.set(uuid, true); // the component shows its frustum by default
3688
+ this.lastSecurityCameraUuid = uuid;
3689
+ }
3690
+ if (isNestThermostat) {
3691
+ const cv = new CanvasRenderer();
3692
+ cv.onInit();
3693
+ const plane = new PlaneRenderer();
3694
+ plane.outputs = { objectRoot: new Object3D(), collider: new Object3D() };
3695
+ const inputTexture = cv.outputs.texture;
3696
+ plane.setRootScene(this.threeJSScene);
3697
+ plane.onInit(modelNode, inputTexture);
3698
+ const sc = new NestThermostat();
3699
+ sc.setComponent(component, plane, cv);
3700
+ sc.setRootScene(this.threeJSScene);
3701
+ sc.onInit(modelNode, plane, inputTexture);
3702
+ cv.setCanvasNestThermostatPainter(sc);
3703
+ setTimeout(() => { sc.inputs.loadingState = 'Loaded'; sc.onInputsUpdated(); }, 5000);
3704
+ }
3705
+ if (isSmarterplanPromotionalVideo) {
3706
+ const sc = new TvPlayer();
3707
+ sc.setComponent(component);
3708
+ sc.onInit(modelNode);
3709
+ setTimeout(() => { sc.inputs.loadingState = 'Loaded'; sc.onInputsUpdated(); }, 5000);
3710
+ }
3711
+ sceneObject.start();
3712
+ try {
3713
+ await this.waitForModelLoaded(component, this.loadTimeoutMs);
3714
+ }
3715
+ catch (e) {
3716
+ await this.deleteObject3D(uuid);
3717
+ throw e;
3718
+ }
3719
+ if (animator && !obj.viewFrustum) {
3720
+ // The camera component starts with the model node, so it is ready now that the model is loaded
3721
+ this.applyViewFrustum(uuid, false);
3722
+ }
3723
+ return modelNode.obj3D;
3724
+ }
3725
+ /**
3726
+ * Removes a 3D object for good: detaches its gizmo, stops its scene object and forgets it.
3727
+ */
3575
3728
  async deleteObject3D(uuid) {
3576
- const obj = this.dictionnaryObjects3D.get(uuid);
3577
- if (obj)
3578
- obj.obj3D.visible = false;
3729
+ this.detachGizmo(uuid);
3730
+ const node = this.dictionnaryObjects3D.get(uuid);
3731
+ const sceneObject = this.dictionnarySceneObjects3D.get(uuid);
3732
+ if (node) {
3733
+ try {
3734
+ node.obj3D.visible = false;
3735
+ }
3736
+ catch { /* already destroyed */ }
3737
+ }
3738
+ if (sceneObject && typeof sceneObject.stop === 'function') {
3739
+ try {
3740
+ sceneObject.stop();
3741
+ }
3742
+ catch { /* already stopped */ }
3743
+ }
3744
+ this.dictionnaryObjects3D.delete(uuid);
3745
+ this.dictionnarySceneObjects3D.delete(uuid);
3746
+ this.loaderComponents.delete(uuid);
3747
+ if (this.lastObject3D && (this.lastObject3D.uuid === uuid || this.lastObject3D.id === uuid)) {
3748
+ this.lastObject3D = null;
3749
+ }
3750
+ if (node && this.azimuthalCrown === node) {
3751
+ this.azimuthalCrown = null;
3752
+ }
3753
+ const animator = this.securityCameraAnimators.get(uuid);
3754
+ if (animator && this.securityCameraAnimator === animator) {
3755
+ this.securityCameraAnimator = null;
3756
+ }
3757
+ this.securityCameraAnimators.delete(uuid);
3758
+ this.viewFrustumVisible.delete(uuid);
3759
+ if (this.lastSecurityCameraUuid === uuid) {
3760
+ this.lastSecurityCameraUuid = null;
3761
+ }
3579
3762
  }
3580
3763
  getObject3DModelNodeFromDictionnary(uuid) {
3581
3764
  let obj = this.dictionnaryObjects3D.get(uuid);
@@ -3595,10 +3778,32 @@ class MatterportObject3DService {
3595
3778
  let obj = this.dictionnaryObjects3D.get(objectId);
3596
3779
  return obj ? obj.obj3D.visible : false;
3597
3780
  }
3781
+ /** Shows / hides a model (used by the placement preview) */
3782
+ setObject3DVisible(uuid, visible) {
3783
+ const node = this.dictionnaryObjects3D.get(uuid);
3784
+ if (node)
3785
+ node.obj3D.visible = visible;
3786
+ }
3787
+ /** Enables / disables the pointer collider of a model (disabled while it follows the cursor) */
3788
+ setObject3DColliderEnabled(uuid, enabled) {
3789
+ const component = this.loaderComponents.get(uuid);
3790
+ if (component?.inputs) {
3791
+ component.inputs.colliderEnabled = enabled;
3792
+ }
3793
+ }
3794
+ /**
3795
+ * Moves the camera next to a 3D object by creating a temporary tag on the object centre.
3796
+ * @returns false when the object is unknown or no tag could be reached
3797
+ */
3598
3798
  async pointCameraTo3DObject(sdk, objectId, createTagCallback, goToTagCallback, deleteLastTagCallback, getTagFromElementId) {
3599
- let obj = this.dictionnaryObjects3D.get(objectId);
3799
+ const obj = this.dictionnaryObjects3D.get(objectId);
3800
+ if (!obj)
3801
+ return false;
3802
+ // Tag on the bounding box centre (the model origin is a corner for most models)
3803
+ const box = this.getObject3DBoundingBox(objectId);
3804
+ const anchor = box ? box.center : this.toPoint(obj.obj3D.position);
3600
3805
  const poiObject = {
3601
- coordinate: JSON.stringify(obj.obj3D.position),
3806
+ coordinate: JSON.stringify(anchor),
3602
3807
  type: PoiType.OBJECT3D,
3603
3808
  elementID: objectId,
3604
3809
  };
@@ -3607,9 +3812,16 @@ class MatterportObject3DService {
3607
3812
  await createTagCallback(PoiType.OBJECT3D, objectDb, poiObject);
3608
3813
  }
3609
3814
  catch (err) { }
3610
- let result = getTagFromElementId(objectId);
3611
- await goToTagCallback(result.tag);
3612
- deleteLastTagCallback();
3815
+ try {
3816
+ const result = getTagFromElementId(objectId);
3817
+ if (!result?.tag)
3818
+ return false;
3819
+ await goToTagCallback(result.tag);
3820
+ return true;
3821
+ }
3822
+ finally {
3823
+ deleteLastTagCallback();
3824
+ }
3613
3825
  }
3614
3826
  getSceneNodeFromObject3DId(uuid) {
3615
3827
  return this.dictionnarySceneObjects3D.get(uuid);
@@ -3619,49 +3831,449 @@ class MatterportObject3DService {
3619
3831
  this.azimuthalCrown.obj3D.position.set(poseCamera.position.x, poseCamera.position.y, poseCamera.position.z);
3620
3832
  }
3621
3833
  }
3834
+ //
3835
+ // ---------- Geometry helpers ----------
3836
+ //
3837
+ toPoint(v) {
3838
+ return { x: v.x, y: v.y, z: v.z };
3839
+ }
3840
+ /**
3841
+ * Geometry of the model itself (the loader output): the node also holds the helpers added by other
3842
+ * components, like the view frustum of a security camera, which must not count in its size.
3843
+ */
3844
+ modelRoot(uuid, node) {
3845
+ return this.loaderComponents.get(uuid)?.outputs?.objectRoot ?? node.obj3D;
3846
+ }
3847
+ /** World-space bounding box of the model, null when unknown or when nothing is loaded yet */
3848
+ getObject3DBoundingBox(uuid) {
3849
+ const node = this.dictionnaryObjects3D.get(uuid);
3850
+ if (!node)
3851
+ return null;
3852
+ node.obj3D.updateWorldMatrix(true, true);
3853
+ const box = this.tmpBox.setFromObject(this.modelRoot(uuid, node));
3854
+ if (box.isEmpty())
3855
+ return null;
3856
+ const size = box.getSize(new Vector3());
3857
+ const center = box.getCenter(new Vector3());
3858
+ return {
3859
+ min: this.toPoint(box.min),
3860
+ max: this.toPoint(box.max),
3861
+ size: this.toPoint(size),
3862
+ center: this.toPoint(center),
3863
+ };
3864
+ }
3865
+ /**
3866
+ * Real dimensions of the model in meters (width x, height y, depth z), scale included but not
3867
+ * its rotation: the world bounding box of a rotated object is larger than the object itself.
3868
+ */
3869
+ getObject3DDimensions(uuid) {
3870
+ const node = this.dictionnaryObjects3D.get(uuid);
3871
+ if (!node)
3872
+ return null;
3873
+ const rotation = node.obj3D.rotation.clone();
3874
+ node.obj3D.rotation.set(0, 0, 0);
3875
+ try {
3876
+ node.obj3D.updateWorldMatrix(true, true);
3877
+ const box = this.tmpBox.setFromObject(this.modelRoot(uuid, node));
3878
+ return box.isEmpty() ? null : this.toPoint(box.getSize(new Vector3()));
3879
+ }
3880
+ finally {
3881
+ node.obj3D.rotation.copy(rotation);
3882
+ node.obj3D.updateWorldMatrix(true, true);
3883
+ }
3884
+ }
3885
+ /**
3886
+ * Uniform scale so that the largest bounding box dimension equals `targetSizeMeters`.
3887
+ * The node position is untouched, so call placeObject3DOnSurface afterwards.
3888
+ * @returns the new scale, null when the object is unknown / not loaded / `targetSizeMeters` invalid
3889
+ */
3890
+ normalizeObject3DSize(uuid, targetSizeMeters) {
3891
+ const node = this.dictionnaryObjects3D.get(uuid);
3892
+ const box = this.getObject3DBoundingBox(uuid);
3893
+ if (!node || !box || !(targetSizeMeters > 0))
3894
+ return null;
3895
+ const largest = Math.max(box.size.x, box.size.y, box.size.z);
3896
+ if (!(largest > 0))
3897
+ return null;
3898
+ const factor = targetSizeMeters / largest;
3899
+ node.obj3D.scale.multiplyScalar(factor);
3900
+ this.emitTransformChanged(uuid);
3901
+ return this.toPoint(node.obj3D.scale);
3902
+ }
3903
+ /**
3904
+ * Places the model on a surface point without touching its pivot: the node position is
3905
+ * adjusted so that the bounding box bottom-center lies on `point`.
3906
+ * - floor (normal pointing up, or no normal): bottom-center on the point
3907
+ * - wall (mostly horizontal normal): model turned around Y so its +Z faces away from the wall,
3908
+ * and pushed along the normal by half its depth so it does not cut the wall
3909
+ * - ceiling (normal pointing down): the model hangs, top-center on the point
3910
+ * @returns the new transform, null when the object is unknown or not loaded
3911
+ */
3912
+ /**
3913
+ * @param frontYawOffset rotation (radians, around Y) that turns the front of the model toward its +Z
3914
+ * axis, for models whose front is another axis (e.g. -PI/2 when it is +X)
3915
+ */
3916
+ placeObject3DOnSurface(uuid, point, normal, frontYawOffset = 0) {
3917
+ const node = this.dictionnaryObjects3D.get(uuid);
3918
+ if (!node || !this.getObject3DBoundingBox(uuid))
3919
+ return null;
3920
+ const obj = node.obj3D;
3921
+ const n = normal ? new Vector3(normal.x, normal.y, normal.z) : new Vector3(0, 1, 0);
3922
+ if (n.lengthSq() === 0)
3923
+ n.set(0, 1, 0);
3924
+ n.normalize();
3925
+ const isWall = Math.abs(n.y) < 0.5;
3926
+ const isCeiling = !isWall && n.y < 0;
3927
+ const target = new Vector3(point.x, point.y, point.z);
3928
+ if (isWall) {
3929
+ // Face away from the wall; the box has to be measured after the rotation
3930
+ obj.rotation.y = Math.atan2(n.x, n.z) + frontYawOffset;
3931
+ }
3932
+ const box = this.getObject3DBoundingBox(uuid);
3933
+ if (isWall) {
3934
+ const horizontal = new Vector3(n.x, 0, n.z).normalize();
3935
+ const halfDepth = 0.5 * (Math.abs(horizontal.x) * box.size.x + Math.abs(horizontal.z) * box.size.z);
3936
+ target.addScaledVector(horizontal, halfDepth);
3937
+ }
3938
+ const anchorY = isCeiling ? box.max.y : box.min.y;
3939
+ obj.position.x += target.x - box.center.x;
3940
+ obj.position.y += target.y - anchorY;
3941
+ obj.position.z += target.z - box.center.z;
3942
+ this.emitTransformChanged(uuid);
3943
+ return this.getObject3DTransform(uuid);
3944
+ }
3945
+ getObject3DTransform(uuid) {
3946
+ const node = this.dictionnaryObjects3D.get(uuid);
3947
+ if (!node)
3948
+ return null;
3949
+ const o = node.obj3D;
3950
+ return {
3951
+ position: this.toPoint(o.position),
3952
+ rotation: { x: o.rotation.x, y: o.rotation.y, z: o.rotation.z },
3953
+ scale: this.toPoint(o.scale),
3954
+ };
3955
+ }
3956
+ /** Applies a (partial) transform. Rotation is in radians. */
3957
+ setObject3DTransform(uuid, transform) {
3958
+ const node = this.dictionnaryObjects3D.get(uuid);
3959
+ if (!node)
3960
+ return false;
3961
+ const o = node.obj3D;
3962
+ if (transform.position)
3963
+ o.position.set(transform.position.x, transform.position.y, transform.position.z);
3964
+ if (transform.rotation)
3965
+ o.rotation.set(transform.rotation.x, transform.rotation.y, transform.rotation.z);
3966
+ if (transform.scale)
3967
+ o.scale.set(transform.scale.x, transform.scale.y, transform.scale.z);
3968
+ this.emitTransformChanged(uuid);
3969
+ return true;
3970
+ }
3971
+ emitTransformChanged(uuid) {
3972
+ const transform = this.getObject3DTransform(uuid);
3973
+ if (transform) {
3974
+ this.object3DTransformChanged$.next({ uuid, ...transform });
3975
+ }
3976
+ }
3977
+ //
3978
+ // ---------- Gizmo ----------
3979
+ //
3980
+ /** Creates the mp.transformControls node of a model and wires it (events, snap) */
3981
+ createGizmo(uuid, modelNode, sceneObject, mode, visible, node) {
3982
+ const gizmoNode = node ?? sceneObject.addNode();
3983
+ const component = gizmoNode.addComponent('mp.transformControls');
3984
+ gizmoNode.start();
3985
+ const tc = component.transformControls;
3986
+ tc.visible = visible;
3987
+ component.inputs.selection = modelNode;
3988
+ component.inputs.mode = mode;
3989
+ modelNode.obj3D.controls = component;
3990
+ const entry = {
3991
+ uuid,
3992
+ node: gizmoNode,
3993
+ component,
3994
+ mode,
3995
+ dragStart: null,
3996
+ lastPosition: modelNode.obj3D.position.clone(),
3997
+ onDraggingChanged: (event) => {
3998
+ entry.dragStart = event?.value ? modelNode.obj3D.position.clone() : null;
3999
+ entry.lastPosition = modelNode.obj3D.position.clone();
4000
+ },
4001
+ onObjectChange: () => this.onGizmoObjectChange(entry, modelNode),
4002
+ };
4003
+ if (typeof tc.addEventListener === 'function') {
4004
+ tc.addEventListener('dragging-changed', entry.onDraggingChanged);
4005
+ tc.addEventListener('objectChange', entry.onObjectChange);
4006
+ }
4007
+ this.applySnap(tc);
4008
+ this.gizmos.set(uuid, entry);
4009
+ this.activeGizmoUuid = uuid;
4010
+ return entry;
4011
+ }
4012
+ /** Keeps a translate drag within maxDragDistance / maxDragHeight of where it started, then notifies */
4013
+ onGizmoObjectChange(entry, modelNode) {
4014
+ const pos = modelNode.obj3D.position;
4015
+ if (entry.mode === 'translate') {
4016
+ const start = entry.dragStart ?? entry.lastPosition;
4017
+ const delta = pos.clone().sub(start);
4018
+ delta.y = MathUtils.clamp(delta.y, -this.maxDragHeight, this.maxDragHeight);
4019
+ const length = delta.length();
4020
+ if (length > this.maxDragDistance) {
4021
+ delta.multiplyScalar(this.maxDragDistance / length);
4022
+ }
4023
+ pos.copy(start).add(delta);
4024
+ }
4025
+ entry.lastPosition = pos.clone();
4026
+ this.emitTransformChanged(entry.uuid);
4027
+ }
4028
+ applySnap(tc) {
4029
+ if (!tc)
4030
+ return;
4031
+ const snap = this.gizmoSnap;
4032
+ const translation = snap?.translation ?? null;
4033
+ const rotation = snap?.rotationDeg != null ? MathUtils.degToRad(snap.rotationDeg) : null;
4034
+ const scale = snap?.scale ?? null;
4035
+ if (typeof tc.setTranslationSnap === 'function')
4036
+ tc.setTranslationSnap(translation);
4037
+ else
4038
+ tc.translationSnap = translation;
4039
+ if (typeof tc.setRotationSnap === 'function')
4040
+ tc.setRotationSnap(rotation);
4041
+ else
4042
+ tc.rotationSnap = rotation;
4043
+ if (typeof tc.setScaleSnap === 'function')
4044
+ tc.setScaleSnap(scale);
4045
+ else
4046
+ tc.scaleSnap = scale;
4047
+ }
4048
+ /**
4049
+ * Shows the transform gizmo on an object (any previous gizmo is detached first).
4050
+ * Re-attaching to the object that already has the gizmo only changes its mode.
4051
+ */
4052
+ async attachGizmo(uuid, mode, sdk) {
4053
+ const modelNode = this.dictionnaryObjects3D.get(uuid);
4054
+ const sceneObject = this.dictionnarySceneObjects3D.get(uuid);
4055
+ if (!modelNode || !sceneObject)
4056
+ return false;
4057
+ const existing = this.gizmos.get(uuid);
4058
+ if (existing && this.activeGizmoUuid === uuid) {
4059
+ this.setGizmoMode(mode);
4060
+ return true;
4061
+ }
4062
+ // A started scene object refuses new nodes (addNode returns null): use a dedicated one
4063
+ let node = sceneObject.addNode();
4064
+ let gizmoSceneObject = null;
4065
+ if (!node) {
4066
+ if (!sdk?.Scene)
4067
+ return false;
4068
+ [gizmoSceneObject] = await sdk.Scene.createObjects(1);
4069
+ node = gizmoSceneObject.addNode();
4070
+ }
4071
+ this.detachGizmo();
4072
+ const entry = this.createGizmo(uuid, modelNode, sceneObject, mode, true, node);
4073
+ entry.sceneObject = gizmoSceneObject;
4074
+ this.lastObject3D = modelNode.obj3D;
4075
+ return true;
4076
+ }
4077
+ /** Switches the active gizmo between translate / rotate / scale without re-attaching it */
4078
+ setGizmoMode(mode) {
4079
+ const entry = this.activeGizmoUuid ? this.gizmos.get(this.activeGizmoUuid) : null;
4080
+ if (!entry)
4081
+ return;
4082
+ entry.mode = mode;
4083
+ entry.dragStart = null;
4084
+ entry.component.inputs.mode = mode;
4085
+ const tc = entry.component.transformControls;
4086
+ if (tc && typeof tc.setMode === 'function')
4087
+ tc.setMode(mode);
4088
+ }
4089
+ /** Really removes the gizmo from the scene (default: the active one) */
4090
+ detachGizmo(uuid) {
4091
+ const id = uuid ?? this.activeGizmoUuid;
4092
+ if (!id)
4093
+ return;
4094
+ const entry = this.gizmos.get(id);
4095
+ if (!entry)
4096
+ return;
4097
+ const tc = entry.component?.transformControls;
4098
+ if (tc) {
4099
+ if (typeof tc.removeEventListener === 'function') {
4100
+ tc.removeEventListener('dragging-changed', entry.onDraggingChanged);
4101
+ tc.removeEventListener('objectChange', entry.onObjectChange);
4102
+ }
4103
+ tc.visible = false;
4104
+ if (typeof tc.detach === 'function')
4105
+ tc.detach();
4106
+ if (typeof tc.dispose === 'function')
4107
+ tc.dispose();
4108
+ if (tc.parent && typeof tc.parent.remove === 'function')
4109
+ tc.parent.remove(tc);
4110
+ }
4111
+ try {
4112
+ entry.node.stop();
4113
+ }
4114
+ catch { /* already stopped */ }
4115
+ try {
4116
+ entry.sceneObject?.stop();
4117
+ }
4118
+ catch { /* already stopped */ }
4119
+ const modelNode = this.dictionnaryObjects3D.get(id);
4120
+ if (modelNode?.obj3D)
4121
+ modelNode.obj3D.controls = null;
4122
+ this.gizmos.delete(id);
4123
+ if (this.activeGizmoUuid === id)
4124
+ this.activeGizmoUuid = null;
4125
+ }
4126
+ isGizmoAttached(uuid) {
4127
+ return uuid ? this.gizmos.has(uuid) : this.activeGizmoUuid !== null;
4128
+ }
4129
+ /** Snap steps for all gizmos (null / missing value = no snap) */
4130
+ setGizmoSnap(snap) {
4131
+ this.gizmoSnap = snap ? { ...snap } : null;
4132
+ this.gizmos.forEach((entry) => this.applySnap(entry.component?.transformControls));
4133
+ }
4134
+ setGizmoMaxDragDistance(maxDistance, maxHeight) {
4135
+ if (maxDistance > 0)
4136
+ this.maxDragDistance = maxDistance;
4137
+ if (maxHeight !== undefined && maxHeight > 0)
4138
+ this.maxDragHeight = maxHeight;
4139
+ }
4140
+ /**
4141
+ * Prevents camera navigation to other sweeps while a gizmo is edited.
4142
+ * The SDK has no "freeze camera" switch, so every enabled sweep except the current one is
4143
+ * disabled (Sweep.disable); exactly those sweeps are re-enabled on unlock. Sweeps already
4144
+ * disabled (forbidden sweeps, edit mode) are never touched, and `keepDisabled` lists sweeps
4145
+ * that must stay disabled on unlock.
4146
+ * @returns false when the lock could not be applied (SDK without Sweep.disable, unknown current sweep)
4147
+ */
4148
+ async setNavigationLocked(sdk, locked, sweepCollection, currentSweepId, keepDisabled = []) {
4149
+ if (!sdk?.Sweep || typeof sdk.Sweep.disable !== 'function' || typeof sdk.Sweep.enable !== 'function') {
4150
+ return false;
4151
+ }
4152
+ if (locked) {
4153
+ if (this.sweepsDisabledByLock.length > 0)
4154
+ return true;
4155
+ if (!sweepCollection || !currentSweepId)
4156
+ return false;
4157
+ // a sweep this lock re-enabled just before may still be reported disabled by the SDK
4158
+ const ids = Object.keys(sweepCollection).filter((sid) => sid !== currentSweepId &&
4159
+ (sweepCollection[sid]?.enabled !== false || this.sweepsEnabledByUnlock.includes(sid)));
4160
+ if (ids.length === 0)
4161
+ return true;
4162
+ try {
4163
+ await sdk.Sweep.disable(...ids);
4164
+ this.sweepsDisabledByLock = ids;
4165
+ }
4166
+ catch {
4167
+ // fall back to one call per sweep to know exactly which ones were disabled
4168
+ const results = await Promise.allSettled(ids.map((sid) => sdk.Sweep.disable(sid)));
4169
+ this.sweepsDisabledByLock = ids.filter((_, i) => results[i].status === 'fulfilled');
4170
+ }
4171
+ return true;
4172
+ }
4173
+ const toEnable = this.sweepsDisabledByLock.filter((sid) => !keepDisabled.includes(sid));
4174
+ this.sweepsDisabledByLock = [];
4175
+ this.sweepsEnabledByUnlock = toEnable;
4176
+ if (toEnable.length > 0) {
4177
+ try {
4178
+ await sdk.Sweep.enable(...toEnable);
4179
+ }
4180
+ catch {
4181
+ await Promise.allSettled(toEnable.map((sid) => sdk.Sweep.enable(sid)));
4182
+ }
4183
+ }
4184
+ return true;
4185
+ }
4186
+ isNavigationLocked() {
4187
+ return this.sweepsDisabledByLock.length > 0;
4188
+ }
4189
+ /** Sweeps currently disabled by the navigation lock */
4190
+ getSweepsDisabledByLock() {
4191
+ return [...this.sweepsDisabledByLock];
4192
+ }
4193
+ /**
4194
+ * Legacy API: attaches a gizmo to a model node. Prefer attachGizmo(uuid, mode).
4195
+ */
3622
4196
  async attachGizmoControlTo3DObject(sdk, modelNode, sceneObject, mode, visible, isNewObject) {
3623
4197
  let node = sceneObject.addNode();
3624
4198
  if (!node) {
3625
4199
  const [newSceneObject] = await sdk.Scene.createObjects(1);
3626
4200
  node = newSceneObject.addNode();
3627
4201
  }
3628
- const myControl = node.addComponent('mp.transformControls');
3629
- node.start();
3630
- myControl.transformControls.visible = visible;
3631
- myControl.inputs.selection = modelNode;
3632
- myControl.inputs.mode = mode;
3633
- modelNode.obj3D.controls = myControl;
3634
- if (isNewObject) {
3635
- if (!this.lastObject3D || !this.lastObject3D.controls) {
3636
- try {
3637
- modelNode.obj3D.uuid = this.lastObject3D.uuid || this.lastObject3D.id;
3638
- }
3639
- catch (e) {
3640
- console.log(`id obj in Scene was not assigned to id from DB`);
3641
- }
3642
- this.lastObject3D = modelNode.obj3D;
4202
+ if (isNewObject && (!this.lastObject3D || !this.lastObject3D.controls)) {
4203
+ try {
4204
+ modelNode.obj3D.uuid = this.lastObject3D.uuid || this.lastObject3D.id;
4205
+ }
4206
+ catch (e) {
4207
+ // lastObject3D is not set: the model keeps its own uuid
3643
4208
  }
3644
4209
  }
3645
- else {
3646
- this.lastObject3D = modelNode.obj3D;
3647
- }
4210
+ this.detachGizmo();
4211
+ this.createGizmo(modelNode.obj3D.uuid, modelNode, sceneObject, mode, visible, node);
4212
+ this.lastObject3D = modelNode.obj3D;
3648
4213
  return modelNode;
3649
4214
  }
4215
+ /** Removes the gizmo of the last added / edited object */
3650
4216
  removeGizmoFromLastObject() {
3651
- if (this.lastObject3D && this.lastObject3D.controls) {
3652
- this.lastObject3D.controls.transformControls.visible = false;
3653
- this.lastObject3D.controls.transformControls.dispose();
3654
- this.lastObject3D.controls = null;
4217
+ const last = this.lastObject3D;
4218
+ if (!last)
4219
+ return;
4220
+ const uuid = last.uuid ?? last.id;
4221
+ if (uuid && this.gizmos.has(uuid)) {
4222
+ this.detachGizmo(uuid);
4223
+ }
4224
+ else if (last.controls) {
4225
+ // gizmo not created by this service: best effort
4226
+ last.controls.transformControls.visible = false;
4227
+ last.controls.transformControls.dispose();
4228
+ last.controls = null;
3655
4229
  }
3656
4230
  }
3657
- toggleViewFrustum() {
3658
- if (this.securityCameraAnimator) {
4231
+ //
4232
+ // ---------- Security camera ----------
4233
+ //
4234
+ applyViewFrustum(uuid, visible) {
4235
+ const animator = this.securityCameraAnimators.get(uuid);
4236
+ if (!animator)
4237
+ return false;
4238
+ const current = this.viewFrustumVisible.get(uuid) ?? true;
4239
+ if (current === visible)
4240
+ return true;
4241
+ try {
4242
+ animator.toggleViewFrustum();
4243
+ }
4244
+ catch {
4245
+ return false;
4246
+ }
4247
+ this.viewFrustumVisible.set(uuid, visible);
4248
+ return true;
4249
+ }
4250
+ /** Shows / hides the view frustum of the security camera `uuid` */
4251
+ setViewFrustum(uuid, visible) {
4252
+ return this.applyViewFrustum(uuid, visible);
4253
+ }
4254
+ isViewFrustumVisible(uuid) {
4255
+ return this.viewFrustumVisible.get(uuid) ?? false;
4256
+ }
4257
+ /** Toggles the frustum of `uuid`, or of the last loaded security camera when omitted */
4258
+ toggleViewFrustum(uuid) {
4259
+ const id = uuid ?? this.lastSecurityCameraUuid;
4260
+ if (id && this.securityCameraAnimators.has(id)) {
4261
+ this.applyViewFrustum(id, !(this.viewFrustumVisible.get(id) ?? true));
4262
+ }
4263
+ else if (!uuid && this.securityCameraAnimator) {
4264
+ // animator set by hand (legacy)
3659
4265
  this.securityCameraAnimator.toggleViewFrustum();
3660
4266
  }
3661
4267
  }
3662
4268
  clear() {
4269
+ Array.from(this.gizmos.keys()).forEach((id) => this.detachGizmo(id));
3663
4270
  this.dictionnaryObjects3D.clear();
3664
4271
  this.dictionnarySceneObjects3D.clear();
4272
+ this.loaderComponents.clear();
4273
+ this.securityCameraAnimators.clear();
4274
+ this.viewFrustumVisible.clear();
4275
+ this.lastSecurityCameraUuid = null;
4276
+ this.sweepsDisabledByLock = [];
3665
4277
  this.lastObject3D = null;
3666
4278
  this.azimuthalCrown = null;
3667
4279
  this.securityCameraAnimator = null;
@@ -4358,6 +4970,8 @@ class Config {
4358
4970
  constructor() { }
4359
4971
  }
4360
4972
 
4973
+ /** Pointer intersections a 3D object can be placed on (Pointer.Colliders MODEL and SWEEP) */
4974
+ const PLACEMENT_SURFACES = ['intersectedobject.model', 'intersectedobject.sweep'];
4361
4975
  class MatterportService {
4362
4976
  sdkKey;
4363
4977
  router;
@@ -4378,6 +4992,18 @@ class MatterportService {
4378
4992
  isMeasureModeOn = false;
4379
4993
  interactionMode = ViewerInteractions.DEFAULT;
4380
4994
  cameraMode = 'INSIDE';
4995
+ /** Emitted when a placement (tag following the cursor, or 3D object preview) is cancelled by right click or Escape */
4996
+ placementCancelled$ = new Subject();
4997
+ /** Pending interactive placement of a 3D object (see startObject3DPlacement) */
4998
+ object3DPlacement = null;
4999
+ /** Sweeps snapshot taken when navigation is locked (disabled sweeps vanish from Sweep.data) */
5000
+ lockedSweepsSnapshot = {};
5001
+ /**
5002
+ * Every sweep seen in Sweep.data. After an unlock the SDK re-emits the re-enabled sweeps little by
5003
+ * little: a lock asked meanwhile must still know all of them (it found nothing to disable).
5004
+ */
5005
+ knownSweeps = {};
5006
+ keydownTargets = [];
4381
5007
  get mattertagIDs() {
4382
5008
  return this.matterportTagService.mattertagIDs;
4383
5009
  }
@@ -4519,6 +5145,10 @@ class MatterportService {
4519
5145
  * Actions on left click when positioning mattertag in visit
4520
5146
  */
4521
5147
  pointerLeftClickHandler = (evt) => {
5148
+ if (this.object3DPlacement) {
5149
+ this.validateObject3DPlacement();
5150
+ return;
5151
+ }
4522
5152
  if (this.mattertagToFollow) {
4523
5153
  const mattertagData = this.dictionnaryTags.get(this.mattertagToFollow);
4524
5154
  if (mattertagData) {
@@ -4532,8 +5162,7 @@ class MatterportService {
4532
5162
  };
4533
5163
  pointerRightClickHandler = (e) => {
4534
5164
  e.preventDefault();
4535
- this.cancelFollowingCursor();
4536
- alert('action cancelled');
5165
+ this.cancelCurrentPlacement();
4537
5166
  };
4538
5167
  pointerMiddleClickHandler = (e) => {
4539
5168
  this.pointerService.updateCursorDisplay(this.poseMatterport);
@@ -4580,6 +5209,7 @@ class MatterportService {
4580
5209
  onRightClick: this.pointerRightClickHandler.bind(this),
4581
5210
  onMiddleClick: this.pointerMiddleClickHandler.bind(this),
4582
5211
  });
5212
+ this.registerEscapeListener(iframe);
4583
5213
  // Load SDK
4584
5214
  const showcaseWindow = iframe.contentWindow;
4585
5215
  iframe.addEventListener('load', async () => {
@@ -4656,6 +5286,9 @@ class MatterportService {
4656
5286
  // (we are about to validate, but it exists in sdk already)
4657
5287
  this.enable_following_tag(this.mattertagToFollow);
4658
5288
  }
5289
+ if (this.object3DPlacement) {
5290
+ this.updateObject3DPlacementPreview(intersection);
5291
+ }
4659
5292
  if (this.pointerService) {
4660
5293
  this.pointerService.setCursorPositionButtonDisplayed(false);
4661
5294
  }
@@ -4681,6 +5314,11 @@ class MatterportService {
4681
5314
  // subscribe to sweeps
4682
5315
  this.sdk.Sweep.data.subscribe({
4683
5316
  onCollectionUpdated: function subscr(collection) {
5317
+ this.knownSweeps = { ...this.knownSweeps, ...collection };
5318
+ if (this.object3DService.isNavigationLocked()) {
5319
+ // sweeps disabled by the lock are no longer in the collection: keep them known
5320
+ collection = { ...this.lockedSweepsSnapshot, ...collection };
5321
+ }
4684
5322
  this.sweeps = Object.keys(collection);
4685
5323
  this.sweepCollection = collection;
4686
5324
  // Rebuild sid ↔ uuid maps whenever the collection updates
@@ -4792,7 +5430,7 @@ class MatterportService {
4792
5430
  // Pointer trick
4793
5431
  // Create a div that will appear when the cursor is still
4794
5432
  // It will intercept the click of the mouse before it trigerring the Matterport's tour navigation
4795
- this.pointerService.startPointerTrick(this.sdk, this.container, () => this.poseMatterport, () => this.poseCamera, () => this.interactionMode, () => this.mattertagToFollow);
5433
+ this.pointerService.startPointerTrick(this.sdk, this.container, () => this.poseMatterport, () => this.poseCamera, () => this.interactionMode, () => this.getFollowTarget());
4796
5434
  /**
4797
5435
  * Transitions
4798
5436
  */
@@ -4864,7 +5502,7 @@ class MatterportService {
4864
5502
  * Styling of pointer
4865
5503
  */
4866
5504
  updatePointerTrick() {
4867
- this.pointerService.updatePointerTrick(this.sdk, this.container, this.poseMatterport, this.poseCamera, this.interactionMode, this.mattertagToFollow);
5505
+ this.pointerService.updatePointerTrick(this.sdk, this.container, this.poseMatterport, this.poseCamera, this.interactionMode, this.getFollowTarget());
4868
5506
  }
4869
5507
  /**
4870
5508
  * Triggers a tag visibility refresh based on current camera mode, floor and sweep.
@@ -5293,6 +5931,9 @@ class MatterportService {
5293
5931
  * @param uuid
5294
5932
  */
5295
5933
  async deleteObject3D(uuid) {
5934
+ if (this.object3DPlacement?.uuid === uuid) {
5935
+ this.cancelObject3DPlacement();
5936
+ }
5296
5937
  return this.object3DService.deleteObject3D(uuid);
5297
5938
  }
5298
5939
  getObject3DModelNodeFromDictionnary(uuid) {
@@ -5337,6 +5978,11 @@ class MatterportService {
5337
5978
  * Cancels following of cursor (meaning deleting last Mattertag)
5338
5979
  */
5339
5980
  cancelFollowingCursor() {
5981
+ if (this.object3DPlacement) {
5982
+ // generic cancel (toolbox buttons...) while a 3D object follows the cursor
5983
+ this.cancelObject3DPlacement();
5984
+ return;
5985
+ }
5340
5986
  this.deleteLastMattertag();
5341
5987
  this.setInteractionMode(ViewerInteractions.DEFAULT);
5342
5988
  }
@@ -5705,7 +6351,10 @@ class MatterportService {
5705
6351
  * Clear all variables, deletes all tags (when viewer is remover or model changed=reload needed)
5706
6352
  */
5707
6353
  async clearAll() {
5708
- console.log('removing viewer');
6354
+ this.cancelObject3DPlacement();
6355
+ this.removeEscapeListener();
6356
+ this.lockedSweepsSnapshot = {};
6357
+ this.knownSweeps = {};
5709
6358
  this.clearViewSubscriptions();
5710
6359
  this.viewsByType.clear();
5711
6360
  this.currentView = null;
@@ -5729,6 +6378,9 @@ class MatterportService {
5729
6378
  async init3DObjectViewer() {
5730
6379
  return this.object3DService.init3DObjectViewer(this.sdk);
5731
6380
  }
6381
+ /**
6382
+ * Adds a 3D model; resolves once it is loaded, rejects on unknown format or load timeout (~15 s).
6383
+ */
5732
6384
  async add3DObject(obj, mode) {
5733
6385
  return this.object3DService.add3DObject(this.sdk, obj, mode);
5734
6386
  }
@@ -5738,7 +6390,43 @@ class MatterportService {
5738
6390
  isObjectVisible(objectId) {
5739
6391
  return this.object3DService.isObjectVisible(objectId);
5740
6392
  }
5741
- async pointCameraTo3DObject(objectId) {
6393
+ /** Moves the camera to the object (centered on its bounding box). Resolves false when the object is unknown. */
6394
+ /**
6395
+ * Moves the camera to the sweep closest to a 3D object and turns it toward the object.
6396
+ * @param visibleArea client X bounds of the part of the viewer not covered by other panels
6397
+ * (the lateral menu covers the right of the viewer): the object is centered there.
6398
+ * Falls back to the temporary tag navigation when no sweep can be used.
6399
+ */
6400
+ async pointCameraTo3DObject(objectId, visibleArea) {
6401
+ const box = this.object3DService.getObject3DBoundingBox(objectId);
6402
+ const sweep = box ? this.findNearestSweep(box.center) : null;
6403
+ const sweepData = sweep ? { ...this.lockedSweepsSnapshot, ...(this.sweepCollection ?? {}) }[sweep.sid] : null;
6404
+ if (box && sweepData?.position && this.sdk?.Sweep) {
6405
+ const projection = this.poseCamera?.projection;
6406
+ const tanHalfFov = projection?.[0] ? 1 / projection[0] : 1;
6407
+ let screenX = 0;
6408
+ const viewer = document.querySelector('#viewer-module');
6409
+ if (visibleArea && viewer) {
6410
+ const rect = viewer.getBoundingClientRect();
6411
+ screenX = visibleCenterScreenX(rect, visibleArea.left, visibleArea.right);
6412
+ }
6413
+ const rotation = computeFramingRotation(sweepData.position, box.center, tanHalfFov, screenX);
6414
+ try {
6415
+ if (this.poseCamera?.sweep === sweep.sid) {
6416
+ await this.sdk.Camera.setRotation(rotation, { speed: 80 });
6417
+ }
6418
+ else {
6419
+ await this.sdk.Sweep.moveTo(sweep.sid, {
6420
+ rotation,
6421
+ transition: this.sdk.Sweep.Transition?.FLY,
6422
+ });
6423
+ }
6424
+ return true;
6425
+ }
6426
+ catch (error) {
6427
+ // disabled or unreachable sweep: use the tag navigation below
6428
+ }
6429
+ }
5742
6430
  return this.object3DService.pointCameraTo3DObject(this.sdk, objectId, this.createMattertagFromPOI.bind(this), this.goToTag.bind(this), this.deleteLastMattertag.bind(this), this.getTagFromElementId.bind(this));
5743
6431
  }
5744
6432
  getSceneNodeFromObject3DId(uuid) {
@@ -5753,61 +6441,314 @@ class MatterportService {
5753
6441
  removeGizmoFromLastObject() {
5754
6442
  this.object3DService.removeGizmoFromLastObject();
5755
6443
  }
5756
- toggleViewFrustum() {
5757
- this.object3DService.toggleViewFrustum();
6444
+ /** Toggles the frustum of the security camera `uuid` (default: the last loaded one) */
6445
+ toggleViewFrustum(uuid) {
6446
+ this.object3DService.toggleViewFrustum(uuid);
5758
6447
  }
5759
- 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 });
5760
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportService, providedIn: 'root' });
5761
- }
5762
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportService, decorators: [{
5763
- type: Injectable,
5764
- args: [{
5765
- providedIn: 'root',
5766
- }]
5767
- }], ctorParameters: () => [{ type: Config, decorators: [{
5768
- type: Inject,
5769
- args: ['config']
5770
- }] }, { type: undefined, decorators: [{
5771
- type: Inject,
5772
- args: [MATTERPORT_SDK_KEY]
5773
- }] }, { type: i1$1.Router }, { type: i1$1.ActivatedRoute }, { type: BaseVisibilityService }, { type: i0.NgZone }, { type: MatterportMeasurementService }, { type: MatterportNavigationService }, { type: MatterportPointerService }, { type: MatterportObject3DService }, { type: MatterportTagService }] });
5774
-
5775
- /**
5776
- * Builds an orthonormal frame from three points, or null when they are (nearly) collinear.
5777
- *
5778
- * Three points define a plane, and a plane plus an origin defines an orientation. Collinear points
5779
- * do not: they leave the rotation about their common line undetermined, which is why the caller has
5780
- * to reject them rather than produce a confident wrong answer.
5781
- */
5782
- function frameFromPoints(a, b, c) {
5783
- const e1 = b.clone().sub(a);
5784
- if (e1.lengthSq() < 1e-12) {
5785
- return null;
6448
+ setViewFrustum(uuid, visible) {
6449
+ return this.object3DService.setViewFrustum(uuid, visible);
5786
6450
  }
5787
- e1.normalize();
5788
- const toC = c.clone().sub(a);
5789
- const e2 = toC.clone().sub(e1.clone().multiplyScalar(toC.dot(e1)));
5790
- if (e2.lengthSq() < 1e-12) {
5791
- return null;
6451
+ isViewFrustumVisible(uuid) {
6452
+ return this.object3DService.isViewFrustumVisible(uuid);
5792
6453
  }
5793
- e2.normalize();
5794
- return new THREE.Matrix4().makeBasis(e1, e2, e1.clone().cross(e2));
6454
+ //
6455
+ // ---------- 3D objects: geometry, transform, gizmo ----------
6456
+ //
6457
+ /** Emits on gizmo changes and on setObject3DTransform / placeObject3DOnSurface / normalizeObject3DSize */
6458
+ get object3DTransformChanged$() {
6459
+ return this.object3DService.object3DTransformChanged$;
6460
+ }
6461
+ /** World-space bounding box, null when the object is unknown or not loaded */
6462
+ getObject3DBoundingBox(uuid) {
6463
+ return this.object3DService.getObject3DBoundingBox(uuid);
6464
+ }
6465
+ /** Dimensions of the model in meters, without its rotation */
6466
+ getObject3DDimensions(uuid) {
6467
+ return this.object3DService.getObject3DDimensions(uuid);
6468
+ }
6469
+ /** Uniform scale so the largest dimension equals `targetSizeMeters`; returns the new scale or null */
6470
+ normalizeObject3DSize(uuid, targetSizeMeters) {
6471
+ return this.object3DService.normalizeObject3DSize(uuid, targetSizeMeters);
6472
+ }
6473
+ /** Moves the object so its bounding box bottom-center (top-center on a ceiling) lies on `point` */
6474
+ placeObject3DOnSurface(uuid, point, normal, frontYawOffset = 0) {
6475
+ return this.object3DService.placeObject3DOnSurface(uuid, point, normal, frontYawOffset);
6476
+ }
6477
+ getObject3DTransform(uuid) {
6478
+ return this.object3DService.getObject3DTransform(uuid);
6479
+ }
6480
+ /** Rotation in radians */
6481
+ setObject3DTransform(uuid, transform) {
6482
+ return this.object3DService.setObject3DTransform(uuid, transform);
6483
+ }
6484
+ attachGizmo(uuid, mode) {
6485
+ return this.object3DService.attachGizmo(uuid, mode, this.sdk);
6486
+ }
6487
+ setGizmoMode(mode) {
6488
+ this.object3DService.setGizmoMode(mode);
6489
+ }
6490
+ detachGizmo(uuid) {
6491
+ this.object3DService.detachGizmo(uuid);
6492
+ }
6493
+ isGizmoAttached(uuid) {
6494
+ return this.object3DService.isGizmoAttached(uuid);
6495
+ }
6496
+ /** null disables snapping (default) */
6497
+ setGizmoSnap(snap) {
6498
+ this.object3DService.setGizmoSnap(snap);
6499
+ }
6500
+ /** Limits of a translate drag from its start (default 2 m, +/- 1.5 m vertically) */
6501
+ setGizmoMaxDragDistance(maxDistance, maxHeight) {
6502
+ this.object3DService.setGizmoMaxDragDistance(maxDistance, maxHeight);
6503
+ }
6504
+ /**
6505
+ * Locks / unlocks camera navigation to other sweeps while a gizmo is edited: every enabled sweep
6506
+ * except the current one is disabled, and exactly those are re-enabled on unlock.
6507
+ * @returns false when the lock could not be applied (SDK not ready, current sweep unknown)
6508
+ */
6509
+ async setNavigationLocked(locked) {
6510
+ if (!this.sdk)
6511
+ return false;
6512
+ const currentSweep = this.poseCamera?.sweep ?? null;
6513
+ const sweeps = { ...this.knownSweeps, ...(this.sweepCollection ?? {}) };
6514
+ if (locked && !this.object3DService.isNavigationLocked()) {
6515
+ this.lockedSweepsSnapshot = sweeps;
6516
+ }
6517
+ const keepDisabled = (this.forbiddenSweeps ?? []).map((s) => this.navigationService.resolveSweepSid(s));
6518
+ const done = await this.object3DService.setNavigationLocked(this.sdk, locked, sweeps, currentSweep, keepDisabled);
6519
+ if (!locked || !done) {
6520
+ this.lockedSweepsSnapshot = {};
6521
+ }
6522
+ return done;
6523
+ }
6524
+ isNavigationLocked() {
6525
+ return this.object3DService.isNavigationLocked();
6526
+ }
6527
+ //
6528
+ // ---------- 3D objects: interactive placement ----------
6529
+ //
6530
+ /**
6531
+ * Makes an already loaded model follow the cursor on the surfaces of the visit.
6532
+ * Left click resolves with the surface point / normal under the cursor (read the object origin
6533
+ * with getObject3DTransform); right click or Escape hides the model and resolves null
6534
+ * (deleting it is up to the caller). Resolves null right away if another interaction is running.
6535
+ */
6536
+ startObject3DPlacement(uuid, options = {}) {
6537
+ if (!this.object3DService.getObject3DModelNodeFromDictionnary(uuid)) {
6538
+ return Promise.resolve(null);
6539
+ }
6540
+ if (this.object3DPlacement) {
6541
+ this.cancelObject3DPlacement();
6542
+ }
6543
+ if (this.interactionMode !== ViewerInteractions.DEFAULT) {
6544
+ return Promise.resolve(null);
6545
+ }
6546
+ return new Promise((resolve) => {
6547
+ this.object3DPlacement = { uuid, resolve, lastPoint: null, lastNormal: null, frontYawOffset: options.frontYawOffset ?? 0 };
6548
+ this.object3DService.setObject3DColliderEnabled(uuid, false);
6549
+ this.object3DService.setObject3DVisible(uuid, false);
6550
+ this.setInteractionMode(ViewerInteractions.PLACING_OBJECT3D);
6551
+ if (this.poseMatterport?.position) {
6552
+ this.updateObject3DPlacementPreview(this.poseMatterport);
6553
+ }
6554
+ });
6555
+ }
6556
+ isPlacingObject3D() {
6557
+ return !!this.object3DPlacement;
6558
+ }
6559
+ /** Cancels the running object placement (model hidden, promise resolves null) */
6560
+ cancelObject3DPlacement() {
6561
+ const placement = this.object3DPlacement;
6562
+ if (!placement)
6563
+ return;
6564
+ this.endObject3DPlacement();
6565
+ this.object3DService.setObject3DVisible(placement.uuid, false);
6566
+ placement.resolve(null);
6567
+ }
6568
+ /**
6569
+ * Drops the object where it is previewed (same as a left click). Touch screens have no hover: each
6570
+ * tap moves the preview, and the app confirms with a button.
6571
+ * @returns false when no surface was pointed yet
6572
+ */
6573
+ confirmObject3DPlacement() {
6574
+ if (!this.object3DPlacement?.lastPoint)
6575
+ return false;
6576
+ this.validateObject3DPlacement();
6577
+ return true;
6578
+ }
6579
+ /** True once the preview stands on a surface (the placement can be confirmed) */
6580
+ hasObject3DPlacementPoint() {
6581
+ return !!this.object3DPlacement?.lastPoint;
6582
+ }
6583
+ validateObject3DPlacement() {
6584
+ const placement = this.object3DPlacement;
6585
+ if (!placement || !placement.lastPoint)
6586
+ return; // no surface under the cursor yet
6587
+ this.endObject3DPlacement();
6588
+ placement.resolve({
6589
+ position: placement.lastPoint,
6590
+ normal: placement.lastNormal ?? { x: 0, y: 1, z: 0 },
6591
+ });
6592
+ }
6593
+ endObject3DPlacement() {
6594
+ const placement = this.object3DPlacement;
6595
+ if (!placement)
6596
+ return;
6597
+ this.object3DPlacement = null;
6598
+ this.object3DService.setObject3DColliderEnabled(placement.uuid, true);
6599
+ this.setInteractionMode(ViewerInteractions.DEFAULT);
6600
+ }
6601
+ updateObject3DPlacementPreview(intersection) {
6602
+ const placement = this.object3DPlacement;
6603
+ if (!placement || !intersection?.position)
6604
+ return;
6605
+ // Only the scanned surfaces: hitting the preview itself (reported as "unknown") would push it away
6606
+ // along the ray at each move, until it lands kilometers from the camera
6607
+ if (typeof intersection.object === 'string' && !PLACEMENT_SURFACES.includes(intersection.object))
6608
+ return;
6609
+ const point = { x: intersection.position.x, y: intersection.position.y, z: intersection.position.z };
6610
+ const normal = intersection.normal
6611
+ ? { x: intersection.normal.x, y: intersection.normal.y, z: intersection.normal.z }
6612
+ : { x: 0, y: 1, z: 0 };
6613
+ if (!placement.lastPoint) {
6614
+ // hidden until it is on a surface (it was loaded at the camera position)
6615
+ this.object3DService.setObject3DVisible(placement.uuid, true);
6616
+ }
6617
+ placement.lastPoint = point;
6618
+ placement.lastNormal = normal;
6619
+ this.object3DService.placeObject3DOnSurface(placement.uuid, point, normal, placement.frontYawOffset);
6620
+ }
6621
+ /** Token for the pointer trick: truthy while something follows the cursor (tag or 3D object) */
6622
+ getFollowTarget() {
6623
+ return this.mattertagToFollow ?? this.object3DPlacement?.uuid ?? null;
6624
+ }
6625
+ /**
6626
+ * Cancels whatever follows the cursor (right click / Escape): the 3D object preview, or the tag.
6627
+ * Emits placementCancelled$ when something was actually running.
6628
+ */
6629
+ cancelCurrentPlacement() {
6630
+ const wasRunning = this.interactionMode !== ViewerInteractions.DEFAULT || !!this.object3DPlacement;
6631
+ // nothing follows the cursor: a plain right click must not remove the last (existing) tag
6632
+ if (!wasRunning)
6633
+ return;
6634
+ if (this.object3DPlacement) {
6635
+ this.cancelObject3DPlacement();
6636
+ }
6637
+ else {
6638
+ this.cancelFollowingCursor();
6639
+ }
6640
+ this.placementCancelled$.next();
6641
+ }
6642
+ /** Escape cancels any placement. Listens on the page and on the (same-origin) Showcase iframe, which takes the focus on click. */
6643
+ registerEscapeListener(iframe) {
6644
+ this.removeEscapeListener();
6645
+ const handler = (e) => {
6646
+ if (e.key !== 'Escape')
6647
+ return;
6648
+ if (this.interactionMode !== ViewerInteractions.DEFAULT || this.object3DPlacement) {
6649
+ this.cancelCurrentPlacement();
6650
+ }
6651
+ };
6652
+ const targets = [document];
6653
+ try {
6654
+ if (iframe?.contentWindow)
6655
+ targets.push(iframe.contentWindow);
6656
+ }
6657
+ catch { /* cross-origin iframe: page listener only */ }
6658
+ targets.forEach((target) => {
6659
+ target.addEventListener('keydown', handler);
6660
+ this.keydownTargets.push({ target, handler });
6661
+ });
6662
+ }
6663
+ removeEscapeListener() {
6664
+ this.keydownTargets.forEach(({ target, handler }) => target.removeEventListener('keydown', handler));
6665
+ this.keydownTargets = [];
6666
+ }
6667
+ ngOnDestroy() {
6668
+ this.cancelObject3DPlacement();
6669
+ this.removeEscapeListener();
6670
+ }
6671
+ //
6672
+ // ---------- Sweeps ----------
6673
+ //
6674
+ /**
6675
+ * Closest enabled sweep to a world position (for instance to re-assign the zone of a moved object).
6676
+ * `sweepId` is the DB-format id (like currentSweep), `sid` the SDK id. null when no sweep is known.
6677
+ */
6678
+ findNearestSweep(position) {
6679
+ const collection = { ...this.lockedSweepsSnapshot, ...(this.sweepCollection ?? {}) };
6680
+ const sids = Object.keys(collection).filter((sid) => collection[sid]?.position && collection[sid].enabled !== false);
6681
+ const best = pickNearestSweepOnSameStorey(sids.map((sid) => ({ sid, position: collection[sid].position, floorY: sweepFloorY(collection[sid]) })), position);
6682
+ const data = best ? collection[best] : null;
6683
+ if (!best)
6684
+ return null;
6685
+ return {
6686
+ sweepId: this.navigationService.resolveSweepUuid(best),
6687
+ sid: best,
6688
+ floorSequence: data.floorInfo?.sequence ?? (typeof data.floor === 'number' ? data.floor : null),
6689
+ };
6690
+ }
6691
+ 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 });
6692
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportService, providedIn: 'root' });
6693
+ }
6694
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportService, decorators: [{
6695
+ type: Injectable,
6696
+ args: [{
6697
+ providedIn: 'root',
6698
+ }]
6699
+ }], ctorParameters: () => [{ type: Config, decorators: [{
6700
+ type: Inject,
6701
+ args: ['config']
6702
+ }] }, { type: undefined, decorators: [{
6703
+ type: Inject,
6704
+ args: [MATTERPORT_SDK_KEY]
6705
+ }] }, { type: i1$1.Router }, { type: i1$1.ActivatedRoute }, { type: BaseVisibilityService }, { type: i0.NgZone }, { type: MatterportMeasurementService }, { type: MatterportNavigationService }, { type: MatterportPointerService }, { type: MatterportObject3DService }, { type: MatterportTagService }] });
6706
+
6707
+ /**
6708
+ * Builds an orthonormal frame from three points, or null when they are (nearly) collinear.
6709
+ *
6710
+ * Three points define a plane, and a plane plus an origin defines an orientation. Collinear points
6711
+ * do not: they leave the rotation about their common line undetermined, which is why the caller has
6712
+ * to reject them rather than produce a confident wrong answer.
6713
+ */
6714
+ function frameFromPoints(a, b, c) {
6715
+ const e1 = b.clone().sub(a);
6716
+ if (e1.lengthSq() < 1e-12) {
6717
+ return null;
6718
+ }
6719
+ e1.normalize();
6720
+ const toC = c.clone().sub(a);
6721
+ const e2 = toC.clone().sub(e1.clone().multiplyScalar(toC.dot(e1)));
6722
+ if (e2.lengthSq() < 1e-12) {
6723
+ return null;
6724
+ }
6725
+ e2.normalize();
6726
+ return new THREE.Matrix4().makeBasis(e1, e2, e1.clone().cross(e2));
5795
6727
  }
5796
6728
  /**
5797
- * Solves the transform that maps stored tag coordinates onto the loaded model.
6729
+ * Solves the transform that maps stored tag coordinates onto the loaded model, from as many matched
6730
+ * tags as there are - up to three.
6731
+ *
6732
+ * Three pairs pin down everything: position, orientation and scale. A model that shares only a
6733
+ * corner with the tags' space may not have three tags to offer, so fewer are accepted, each
6734
+ * settling what it can and assuming the rest:
5798
6735
  *
5799
- * Takes exactly the correspondences a person can produce by hand: three tags, each pointed at twice
5800
- * - once where it currently sits, once where it belongs. That is the minimum that pins down all
5801
- * three parts of the answer. One pair fixes only translation; two leave the rotation about the line
5802
- * between them free; three resolve it.
6736
+ * - Two pairs: position, heading and scale. The one thing two points cannot say is how the model
6737
+ * is tilted, so both spaces are taken to agree on which way is up.
6738
+ * - One pair: position only. Heading and scale are taken to match already.
5803
6739
  *
5804
- * Returns null when the points are collinear or degenerate, because a wrong alignment applied
5805
- * silently is worse than none.
6740
+ * `targetUp` is the model's vertical axis, which the tags' own vertical (+Y) is matched to in those
6741
+ * two cases. Returns null when the points cannot answer the question asked of them - collinear for
6742
+ * three, one above the other for two - because a wrong calibration applied silently is worse than
6743
+ * none.
5806
6744
  */
5807
- function solveThreeJsAlignment(pairs) {
5808
- if (!pairs || pairs.length < 3) {
6745
+ function solveThreeJsAlignment(pairs, targetUp = { x: 0, y: 1, z: 0 }) {
6746
+ if (!pairs || pairs.length === 0) {
5809
6747
  return null;
5810
6748
  }
6749
+ if (pairs.length < 3) {
6750
+ return solveReducedAlignment(pairs, new THREE.Vector3(targetUp.x, targetUp.y, targetUp.z).normalize());
6751
+ }
5811
6752
  const sources = pairs.slice(0, 3).map((pair) => new THREE.Vector3(pair.source.x, pair.source.y, pair.source.z));
5812
6753
  const targets = pairs.slice(0, 3).map((pair) => new THREE.Vector3(pair.target.x, pair.target.y, pair.target.z));
5813
6754
  const sourceFrame = frameFromPoints(sources[0], sources[1], sources[2]);
@@ -5848,6 +6789,41 @@ function solveThreeJsAlignment(pairs) {
5848
6789
  scale,
5849
6790
  };
5850
6791
  }
6792
+ /** The one- and two-pair cases of solveThreeJsAlignment, which share the "same vertical" assumption. */
6793
+ function solveReducedAlignment(pairs, up) {
6794
+ const sources = pairs.map((pair) => new THREE.Vector3(pair.source.x, pair.source.y, pair.source.z));
6795
+ const targets = pairs.map((pair) => new THREE.Vector3(pair.target.x, pair.target.y, pair.target.z));
6796
+ // Starts from the rotation that only stands the tags' vertical on the model's.
6797
+ const rotation = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), up);
6798
+ let scale = 1;
6799
+ if (pairs.length === 2) {
6800
+ const sourceSpan = sources[1].clone().sub(sources[0]);
6801
+ const targetSpan = targets[1].clone().sub(targets[0]);
6802
+ if (sourceSpan.length() < 1e-9 || targetSpan.length() < 1e-9) {
6803
+ return null;
6804
+ }
6805
+ scale = targetSpan.length() / sourceSpan.length();
6806
+ // Heading is the turn about the vertical that lines the two spans up, so only their horizontal
6807
+ // parts count. Two points one above the other have none, and say nothing about heading.
6808
+ const flatten = (v) => v.clone().sub(up.clone().multiplyScalar(v.dot(up)));
6809
+ const from = flatten(sourceSpan.clone().applyQuaternion(rotation));
6810
+ const to = flatten(targetSpan);
6811
+ if (from.length() < 1e-6 * sourceSpan.length() || to.length() < 1e-6 * targetSpan.length()) {
6812
+ return null;
6813
+ }
6814
+ const heading = Math.atan2(new THREE.Vector3().crossVectors(from, to).dot(up), from.dot(to));
6815
+ rotation.premultiply(new THREE.Quaternion().setFromAxisAngle(up, heading));
6816
+ }
6817
+ // A point lands at t + s*R*p; solved for t at the centroids, as in the three-pair case.
6818
+ const centroid = (points) => points.reduce((sum, v) => sum.add(v), new THREE.Vector3()).multiplyScalar(1 / points.length);
6819
+ const translation = centroid(targets).sub(centroid(sources).applyQuaternion(rotation).multiplyScalar(scale));
6820
+ const euler = new THREE.Euler().setFromQuaternion(rotation);
6821
+ return {
6822
+ translation: { x: translation.x, y: translation.y, z: translation.z },
6823
+ rotation: { x: euler.x, y: euler.y, z: euler.z },
6824
+ scale,
6825
+ };
6826
+ }
5851
6827
  class ThreeJsService {
5852
6828
  ngZone;
5853
6829
  scene;
@@ -5885,6 +6861,61 @@ class ThreeJsService {
5885
6861
  pendingTagPlacement = null;
5886
6862
  /** Pointer position waiting for a preview pick, coalesced to one per frame. */
5887
6863
  pendingPreviewEvent = null;
6864
+ /**
6865
+ * Tags whose position is in this model's own coordinates. A sibling of the tag group that does not
6866
+ * carry the calibration: calibrating a model for the tags it imports must not move the ones that
6867
+ * were placed straight onto it.
6868
+ */
6869
+ modelTagGroup = new THREE.Group();
6870
+ /**
6871
+ * The capture on screen. A tag placed in a 3D model is recorded with it, which is how the tags
6872
+ * placed in this one are told from those that came from elsewhere.
6873
+ */
6874
+ currentCaptureID = null;
6875
+ /**
6876
+ * The model's surface as a grid: every cell some part of the surface passes through.
6877
+ *
6878
+ * It answers "is this point near the model?" without visiting the triangles, of which a captured
6879
+ * model has millions - the question is asked for every tag, every time the calibration changes.
6880
+ */
6881
+ surfaceCells = null;
6882
+ surfaceOrigin = new THREE.Vector3();
6883
+ surfaceCellSize = 1;
6884
+ /**
6885
+ * How far from the model's surface a tag may be and still count as on it, in metres.
6886
+ *
6887
+ * Every tag of the space is offered to every model; this is what decides which ones a model
6888
+ * shows. A tag marks something on a surface, so one that belongs here sits on the surface or
6889
+ * close to it. One that lands deep inside the model, or well away from it, marks something that
6890
+ * is not part of this model. The allowance covers a calibration done by hand and the model not
6891
+ * matching the scan exactly, which together run to a few tens of centimetres.
6892
+ */
6893
+ static ON_MODEL_METRES = 0.75;
6894
+ /** Cells per axis the grid's keys can address, with room either side for points off the model. */
6895
+ static SURFACE_GRID_SPAN = 1024;
6896
+ static SURFACE_GRID_PAD = 128;
6897
+ /** Own tags moved to keep their place under the last calibration, waiting to be stored. */
6898
+ rebasedTags = [];
6899
+ /** The type filter in force. Null shows every type. */
6900
+ visibleTagTypes = null;
6901
+ measuring = false;
6902
+ /** Points clicked so far, in tag space. */
6903
+ measurePoints = [];
6904
+ /**
6905
+ * Everything drawn for the measurement. A child of the tag group, so its points share the frame
6906
+ * tag coordinates are stored in - which is metres, and what the saved POI has to be in.
6907
+ */
6908
+ measureGroup = new THREE.Group();
6909
+ /** The segment that follows the pointer from the last point clicked. */
6910
+ measureRubberBand;
6911
+ lastMeasurement = null;
6912
+ /** Half the model's diagonal as placed in the scene, where it is centred on the origin. */
6913
+ modelWorldRadius = 0;
6914
+ /** The outermost group the model and its tags hang from: where "which way is up" is applied. */
6915
+ modelGroup;
6916
+ modelUp = 'y';
6917
+ /** While measuring: how many points and each segment's length. Null when it is abandoned. */
6918
+ measurementProgress$ = new Subject();
5888
6919
  /**
5889
6920
  * Layer carrying the model alone, so the depth pick can render it without the tags.
5890
6921
  *
@@ -5892,6 +6923,8 @@ class ThreeJsService {
5892
6923
  * their parent passed, so marking the model's own meshes is enough - no re-parenting.
5893
6924
  */
5894
6925
  static PICK_LAYER = 1;
6926
+ /** The app's highlight colour, so a measurement reads as the active thing on screen. */
6927
+ static MEASURE_COLOR = '#FFB103';
5895
6928
  /** Cache Storage bucket holding downloaded models, and how many to keep. */
5896
6929
  static MODEL_CACHE = 'smarterplan-3d-models';
5897
6930
  static MODEL_CACHE_SIZE = 3;
@@ -5908,6 +6941,8 @@ class ThreeJsService {
5908
6941
  aligning = false;
5909
6942
  alignmentPairs = [];
5910
6943
  alignmentSource = null;
6944
+ /** The tag `alignmentSource` was read from, kept so the same tag is not matched twice. */
6945
+ alignmentSourceTagId;
5911
6946
  /** Drives the alignment walkthrough's UI: how many pairs are done and what it wants next. */
5912
6947
  alignmentProgress$ = new Subject();
5913
6948
  /** In-flight camera move started by goToTag, advanced by the render loop. */
@@ -6023,6 +7058,8 @@ class ThreeJsService {
6023
7058
  // new model and show the previous visit's tags on it.
6024
7059
  this.discardCurrentLoad();
6025
7060
  this.clearTags();
7061
+ this.measuring = false;
7062
+ this.clearMeasurement();
6026
7063
  this.cameraTween = null;
6027
7064
  this.modelRoot = undefined;
6028
7065
  // 1. Setup Scene
@@ -6130,7 +7167,7 @@ class ThreeJsService {
6130
7167
  * and stays indexed, so far less data reaches the GPU. KTX2 textures are supported too: they
6131
7168
  * stay compressed in VRAM instead of being decoded to raw pixels like JPEG/PNG.
6132
7169
  */
6133
- loadGLTFModel(url, cameraPosition, rotation, cacheKey) {
7170
+ loadGLTFModel(url, cameraPosition, rotation, cacheKey, up) {
6134
7171
  const loader = new GLTFLoader();
6135
7172
  // KTX2 needs the renderer to report which compressed formats this GPU accepts, so this has to
6136
7173
  // happen after init(). Guarded so a model without KTX2 textures still loads if it's missing.
@@ -6156,7 +7193,7 @@ class ThreeJsService {
6156
7193
  this.forceDoubleSidedMaterials(gltf.scene);
6157
7194
  this.ensureVertexNormals(gltf.scene);
6158
7195
  this.tuneTextureSampling(gltf.scene);
6159
- this.placeLoadedObjectInScene(gltf.scene, cameraPosition, rotation);
7196
+ this.placeLoadedObjectInScene(gltf.scene, cameraPosition, rotation, up);
6160
7197
  this.modelLoading$.next(false);
6161
7198
  };
6162
7199
  const onError = (error) => {
@@ -6629,7 +7666,7 @@ class ThreeJsService {
6629
7666
  });
6630
7667
  }
6631
7668
  /** Centers, scales and adds a loaded object to the scene, then frames the camera on it. */
6632
- placeLoadedObjectInScene(object, cameraPosition, rotation) {
7669
+ placeLoadedObjectInScene(object, cameraPosition, rotation, up) {
6633
7670
  const box = new THREE.Box3().setFromObject(object);
6634
7671
  const center = box.getCenter(new THREE.Vector3());
6635
7672
  const size = box.getSize(new THREE.Vector3());
@@ -6644,12 +7681,25 @@ class ThreeJsService {
6644
7681
  if (rotation) {
6645
7682
  object.rotation.set(rotation.x, rotation.y, rotation.z);
6646
7683
  }
7684
+ // Which way is up. A choice someone made by hand always wins; otherwise, unless the model came
7685
+ // with an orientation of its own, it is worked out from the geometry. Applied to the outer
7686
+ // group, above both the model and the tags: they turn together, and every coordinate already
7687
+ // stored - tag positions, the calibration - keeps meaning what it meant.
7688
+ this.modelGroup = group;
7689
+ this.modelUp = up ?? (rotation ? 'y' : this.detectModelUp(object, size));
7690
+ group.quaternion.copy(this.uprightRotation(this.modelUp));
6647
7691
  this.modelRoot = object;
7692
+ this.modelWorldRadius = (size.length() / 2) * scale;
7693
+ // The box above was measured before any rotation from the metadata was applied; with one, it
7694
+ // has to be measured again to be the model as it actually sits.
7695
+ this.buildSurfaceGrid(object, rotation ? new THREE.Box3().setFromObject(object) : box);
7696
+ this.refreshTagVisibility();
6648
7697
  // Marks the model for the depth pick, which renders this layer alone - so tags and the ghost
6649
7698
  // itself cannot end up being what the cursor reports as the surface under it.
6650
7699
  object.traverse((child) => child.layers.enable(ThreeJsService.PICK_LAYER));
6651
7700
  centered.add(object);
6652
7701
  centered.add(this.tagGroup);
7702
+ centered.add(this.modelTagGroup);
6653
7703
  group.add(centered);
6654
7704
  group.scale.setScalar(scale);
6655
7705
  this.scene.add(group);
@@ -6665,6 +7715,98 @@ class ThreeJsService {
6665
7715
  this.controls.target.set(0, 0, 0);
6666
7716
  this.controls.update();
6667
7717
  }
7718
+ /**
7719
+ * Which of the model's axes is its vertical: 'y' when it already stands upright, or when it is
7720
+ * not clear enough to act on.
7721
+ *
7722
+ * Models arrive in two conventions: Y up (glTF, and what this viewer assumes) and Z up (most CAD
7723
+ * and scanning exports). A Z-up model loads lying on its side. Two independent cues say which
7724
+ * axis is the vertical one, and both have to agree before anything is turned:
7725
+ *
7726
+ * - Surface area. Floors and ceilings all face along the vertical, while walls are shared out
7727
+ * between the two horizontal axes, so the vertical usually collects the most area.
7728
+ * - Extent. A building is normally wider than it is tall, so the vertical is its shortest side.
7729
+ *
7730
+ * Either alone is wrong often enough to matter - a long corridor has more wall than floor, a
7731
+ * tower is taller than it is wide - and turning a model that was fine is worse than leaving one
7732
+ * that was not. A model that already has Y as its vertical is never touched.
7733
+ */
7734
+ detectModelUp(object, size) {
7735
+ const axes = ['x', 'y', 'z'];
7736
+ // Projected area facing each way along each axis: [positive, negative].
7737
+ const area = { x: [0, 0], y: [0, 0], z: [0, 0] };
7738
+ let triangleCount = 0;
7739
+ object.updateMatrixWorld(true);
7740
+ object.traverse((child) => {
7741
+ const position = child.isMesh && child.geometry?.attributes?.position;
7742
+ if (position) {
7743
+ triangleCount += (child.geometry.index ? child.geometry.index.count : position.count) / 3;
7744
+ }
7745
+ });
7746
+ if (triangleCount === 0) {
7747
+ return 'y';
7748
+ }
7749
+ // A sample is plenty to see where the area lies, and keeps this cheap on models with millions
7750
+ // of triangles.
7751
+ const stride = Math.max(1, Math.ceil(triangleCount / 200000));
7752
+ const a = new THREE.Vector3();
7753
+ const b = new THREE.Vector3();
7754
+ const c = new THREE.Vector3();
7755
+ const normal = new THREE.Vector3();
7756
+ object.traverse((child) => {
7757
+ const position = child.isMesh && child.geometry?.attributes?.position;
7758
+ if (!position) {
7759
+ return;
7760
+ }
7761
+ const index = child.geometry.index;
7762
+ const count = (index ? index.count : position.count) / 3;
7763
+ for (let triangle = 0; triangle < count; triangle += stride) {
7764
+ const offset = triangle * 3;
7765
+ a.fromBufferAttribute(position, index ? index.getX(offset) : offset).applyMatrix4(child.matrixWorld);
7766
+ b.fromBufferAttribute(position, index ? index.getX(offset + 1) : offset + 1).applyMatrix4(child.matrixWorld);
7767
+ c.fromBufferAttribute(position, index ? index.getX(offset + 2) : offset + 2).applyMatrix4(child.matrixWorld);
7768
+ // The cross product's length is twice the triangle's area, so its components are the
7769
+ // area the triangle shows along each axis.
7770
+ normal.crossVectors(b.sub(a), c.sub(a));
7771
+ axes.forEach((axis) => {
7772
+ area[axis][normal[axis] >= 0 ? 0 : 1] += Math.abs(normal[axis]);
7773
+ });
7774
+ }
7775
+ });
7776
+ const total = { x: area.x[0] + area.x[1], y: area.y[0] + area.y[1], z: area.z[0] + area.z[1] };
7777
+ const byArea = axes.reduce((best, axis) => (total[axis] > total[best] ? axis : best), 'y');
7778
+ const byExtent = axes.reduce((best, axis) => (size[axis] < size[best] ? axis : best), 'y');
7779
+ if (byArea === 'y' || byArea !== byExtent || total[byArea] < 1.25 * total.y) {
7780
+ return 'y';
7781
+ }
7782
+ // Which way along that axis is up. Roofs and ground make an outdoor model face mostly upward,
7783
+ // which settles it; indoors floors and ceilings balance out, and the convention - positive is
7784
+ // up - is the better guess.
7785
+ const [positive, negative] = area[byArea];
7786
+ const up = `${negative > 0.65 * (positive + negative) ? '-' : ''}${byArea}`;
7787
+ console.info(`ThreeJsService: this model's vertical axis looks like ${up}, not y - standing it upright.`);
7788
+ return up;
7789
+ }
7790
+ /** The rotation that brings the given model axis to point up. */
7791
+ uprightRotation(up) {
7792
+ const axis = new THREE.Vector3();
7793
+ axis[up.replace('-', '')] = up.startsWith('-') ? -1 : 1;
7794
+ return new THREE.Quaternion().setFromUnitVectors(axis, new THREE.Vector3(0, 1, 0));
7795
+ }
7796
+ /** Which of the model's axes is currently treated as up, whether chosen or worked out. */
7797
+ getModelUp() {
7798
+ return this.modelUp;
7799
+ }
7800
+ /**
7801
+ * Turns the model so the given axis points up, at once.
7802
+ *
7803
+ * The model and its tags turn as one, so nothing stored has to be recomputed. The caller is
7804
+ * expected to store the choice, or the next load goes back to working it out.
7805
+ */
7806
+ setModelUp(up) {
7807
+ this.modelUp = up;
7808
+ this.modelGroup?.quaternion.copy(this.uprightRotation(up));
7809
+ }
6668
7810
  /**
6669
7811
  * Keeps the drawing buffer and camera matched to the canvas as it is resized.
6670
7812
  *
@@ -6690,6 +7832,7 @@ class ThreeJsService {
6690
7832
  this.camera.aspect = width / height;
6691
7833
  this.camera.updateProjectionMatrix();
6692
7834
  this.renderer.setSize(width, height);
7835
+ this.updateMeasureLineResolution();
6693
7836
  });
6694
7837
  this.canvasResizeObserver.observe(container);
6695
7838
  }
@@ -6701,9 +7844,16 @@ class ThreeJsService {
6701
7844
  * applied until solveAlignmentFromPairs() succeeds.
6702
7845
  */
6703
7846
  startTagAlignment() {
7847
+ // One pointer mode at a time, the same rule startMeasuring() applies the other way round.
7848
+ if (this.measuring) {
7849
+ this.cancelMeasuring();
7850
+ }
6704
7851
  this.aligning = true;
6705
7852
  this.alignmentPairs = [];
6706
7853
  this.alignmentSource = null;
7854
+ // While calibrating, the tags from elsewhere are all shown - including the ones off the model,
7855
+ // which are exactly the ones a calibration is for.
7856
+ this.refreshTagVisibility();
6707
7857
  if (this.renderer) {
6708
7858
  this.renderer.domElement.style.cursor = 'crosshair';
6709
7859
  }
@@ -6716,6 +7866,7 @@ class ThreeJsService {
6716
7866
  this.alignmentSource = null;
6717
7867
  this.pendingPreviewEvent = null;
6718
7868
  this.removePlacementPreview();
7869
+ this.refreshTagVisibility();
6719
7870
  if (this.renderer) {
6720
7871
  this.renderer.domElement.style.cursor = 'default';
6721
7872
  }
@@ -6735,12 +7886,22 @@ class ThreeJsService {
6735
7886
  * first half of a pair, which is a click on a tag, not on the model.
6736
7887
  */
6737
7888
  isPreviewingPlacement() {
6738
- return this.placingTag || (this.aligning && !!this.alignmentSource);
7889
+ return this.placingTag || this.measuring || (this.aligning && !!this.alignmentSource);
6739
7890
  }
6740
7891
  /** Whether a tag is currently registered for this element. */
6741
7892
  hasTag(elementId) {
6742
7893
  return this.tagSprites.has(elementId);
6743
7894
  }
7895
+ /** How many tags are on show - the ones a person could actually click. */
7896
+ getTagCount() {
7897
+ let visible = 0;
7898
+ this.tagSprites.forEach((sprite) => {
7899
+ if (sprite.visible) {
7900
+ visible += 1;
7901
+ }
7902
+ });
7903
+ return visible;
7904
+ }
6744
7905
  /**
6745
7906
  * Solves and applies the alignment collected so far, returning it so the caller can persist it.
6746
7907
  *
@@ -6748,35 +7909,105 @@ class ThreeJsService {
6748
7909
  * the walkthrough stays open for the person to pick a better third point.
6749
7910
  */
6750
7911
  solveAlignmentFromPairs() {
6751
- const alignment = solveThreeJsAlignment(this.alignmentPairs);
7912
+ const up = new THREE.Vector3();
7913
+ up[this.modelUp.replace('-', '')] = this.modelUp.startsWith('-') ? -1 : 1;
7914
+ const alignment = solveThreeJsAlignment(this.alignmentPairs, up);
6752
7915
  if (!alignment) {
6753
7916
  return null;
6754
7917
  }
6755
- this.setTagAlignment(alignment);
7918
+ // Out of calibrating mode first: applying the result re-decides which tags show, and from here
7919
+ // on that is the ones on the model again, not all of them.
6756
7920
  this.aligning = false;
7921
+ this.applyAlignmentKeepingOwnTags(alignment);
6757
7922
  this.pendingPreviewEvent = null;
6758
7923
  this.removePlacementPreview();
6759
7924
  this.renderer.domElement.style.cursor = 'default';
6760
7925
  this.alignmentProgress$.next({ pairs: this.alignmentPairs.length, awaiting: null });
6761
7926
  return alignment;
6762
7927
  }
7928
+ /**
7929
+ * Applies a new calibration while keeping every tag placed in this model exactly where it is.
7930
+ *
7931
+ * A calibration is worked out for the tags that came from elsewhere. The model's own tags were put
7932
+ * where they are by someone clicking the model, so they must not follow it. Their stored
7933
+ * coordinates are rewritten instead: into the shared space as the new calibration defines it.
7934
+ * That is also what makes them comparable with other visits from here on - a tag placed before
7935
+ * the model was calibrated was in the model's own coordinates, visible nowhere else.
7936
+ *
7937
+ * The rewritten tags are kept for the caller to store; see takeRebasedTags().
7938
+ */
7939
+ applyAlignmentKeepingOwnTags(alignment) {
7940
+ const own = [...this.tagSprites.values()].filter((sprite) => this.isOwnedByVisit(sprite));
7941
+ // Where each one is now, in the space the model sits in, and how its parent was turned.
7942
+ const placed = own.map((sprite) => {
7943
+ sprite.parent?.updateMatrix();
7944
+ return {
7945
+ sprite,
7946
+ position: sprite.position.clone().applyMatrix4(sprite.parent?.matrix ?? new THREE.Matrix4()),
7947
+ turn: sprite.parent?.quaternion.clone() ?? new THREE.Quaternion(),
7948
+ };
7949
+ });
7950
+ this.setTagAlignment(alignment);
7951
+ const inverse = this.tagGroup.matrix.clone().invert();
7952
+ const unturn = this.tagGroup.quaternion.clone().invert();
7953
+ this.rebasedTags = placed.map(({ sprite, position, turn }) => {
7954
+ sprite.position.copy(position.applyMatrix4(inverse));
7955
+ sprite.userData.frame = null;
7956
+ this.parentTag(sprite);
7957
+ this.scaleTagForModel(sprite);
7958
+ const rotation = unturn.clone().multiply(turn);
7959
+ return {
7960
+ id: sprite.name,
7961
+ position: { x: sprite.position.x, y: sprite.position.y, z: sprite.position.z },
7962
+ rotateDirection: ({ x, y, z }) => {
7963
+ const turned = new THREE.Vector3(x, y, z).applyQuaternion(rotation);
7964
+ return { x: turned.x, y: turned.y, z: turned.z };
7965
+ },
7966
+ };
7967
+ });
7968
+ this.refreshTagVisibility();
7969
+ }
7970
+ /** Hands over the tags the last calibration rewrote, once, for them to be stored. */
7971
+ takeRebasedTags() {
7972
+ const rebased = this.rebasedTags;
7973
+ this.rebasedTags = [];
7974
+ return rebased;
7975
+ }
7976
+ /**
7977
+ * Chooses the tag for the pair being matched by id, instead of by clicking it.
7978
+ *
7979
+ * Clicking a tag supposes it can be found. On a model that has never been calibrated the tags may
7980
+ * sit far from it, or all on top of one another; a list has neither problem.
7981
+ */
7982
+ selectAlignmentTag(elementId) {
7983
+ const sprite = this.tagSprites.get(elementId);
7984
+ if (!this.aligning || this.alignmentSource || !sprite || !this.calibrationCandidates().includes(sprite)) {
7985
+ return false;
7986
+ }
7987
+ this.setAlignmentSource(sprite);
7988
+ return true;
7989
+ }
7990
+ setAlignmentSource(sprite) {
7991
+ // The sprite's own position is the stored coordinate, untouched by any alignment already
7992
+ // applied - that transform lives on the group, not on the sprite.
7993
+ const stored = sprite.position;
7994
+ this.alignmentSource = { x: stored.x, y: stored.y, z: stored.z };
7995
+ this.alignmentSourceTagId = sprite.name;
7996
+ this.alignmentProgress$.next({
7997
+ pairs: this.alignmentPairs.length,
7998
+ awaiting: 'point',
7999
+ tagId: sprite.name,
8000
+ });
8001
+ }
6763
8002
  /** Handles a click while the alignment walkthrough is running. */
6764
8003
  handleAlignmentClick(event) {
6765
8004
  if (!this.alignmentSource) {
6766
8005
  // First half of a pair: which tag are we talking about?
6767
- const hit = this.raycaster.intersectObjects(this.tagGroup.children, false)[0];
8006
+ const hit = this.raycaster.intersectObjects(this.calibrationCandidates(), false)[0];
6768
8007
  if (!hit) {
6769
8008
  return;
6770
8009
  }
6771
- // The sprite's own position is the stored coordinate, untouched by any alignment already
6772
- // applied - that transform lives on the group, not on the sprite.
6773
- const stored = hit.object.position;
6774
- this.alignmentSource = { x: stored.x, y: stored.y, z: stored.z };
6775
- this.alignmentProgress$.next({
6776
- pairs: this.alignmentPairs.length,
6777
- awaiting: 'point',
6778
- tagId: hit.object.name,
6779
- });
8010
+ this.setAlignmentSource(hit.object);
6780
8011
  return;
6781
8012
  }
6782
8013
  // Second half: where that tag actually belongs on the model.
@@ -6784,83 +8015,319 @@ class ThreeJsService {
6784
8015
  if (!target) {
6785
8016
  return;
6786
8017
  }
6787
- this.alignmentPairs.push({ source: this.alignmentSource, target });
8018
+ this.alignmentPairs.push({ tagId: this.alignmentSourceTagId, source: this.alignmentSource, target });
6788
8019
  this.alignmentSource = null;
6789
8020
  // The ghost belongs to the click that just happened; the next half is a click on a tag.
6790
8021
  this.pendingPreviewEvent = null;
6791
8022
  this.removePlacementPreview();
6792
8023
  this.alignmentProgress$.next({
6793
8024
  pairs: this.alignmentPairs.length,
6794
- awaiting: this.alignmentPairs.length >= 3 ? null : 'tag',
8025
+ // Done at three, or sooner when there are no more tags to match: fewer pairs still calibrate,
8026
+ // only less completely.
8027
+ awaiting: this.alignmentPairs.length >= 3 || this.calibrationCandidates().length === 0 ? null : 'tag',
6795
8028
  });
6796
8029
  }
6797
- /** Arms tag placement: the next click on the model drops a tag instead of being ignored. */
6798
- startTagPlacement(poiType = null) {
6799
- this.placingTag = true;
6800
- this.placementPoiType = poiType;
6801
- this.pendingTagPlacement = null;
8030
+ /**
8031
+ * Starts a measurement: every click on the model adds a point, and each pair of consecutive
8032
+ * points is a measured segment.
8033
+ *
8034
+ * It ends through finishMeasuring() or cancelMeasuring() rather than on its own after two points,
8035
+ * because a measurement may have several segments - the same as Matterport's, whose results are
8036
+ * stored as a list of distances.
8037
+ */
8038
+ startMeasuring() {
8039
+ // One pointer mode at a time: a click cannot be both a tag position and a measured point.
8040
+ this.cancelTagPlacement();
8041
+ if (this.aligning) {
8042
+ this.cancelTagAlignment();
8043
+ }
8044
+ this.clearMeasurement();
8045
+ if (this.measureGroup.parent !== this.tagGroup) {
8046
+ this.tagGroup.add(this.measureGroup);
8047
+ }
8048
+ this.measuring = true;
6802
8049
  if (this.renderer) {
6803
8050
  this.renderer.domElement.style.cursor = 'crosshair';
6804
8051
  }
8052
+ this.measurementProgress$.next({ points: 0, distances: [] });
6805
8053
  }
6806
- /** Leaves placement mode without placing anything. */
6807
- cancelTagPlacement() {
6808
- this.placingTag = false;
6809
- this.pendingTagPlacement = null;
8054
+ /**
8055
+ * Abandons the measurement and removes what was drawn.
8056
+ *
8057
+ * Also what clears a finished measurement off the model once its form is saved or closed, which
8058
+ * is why it only reports an abandonment when one was actually in progress.
8059
+ */
8060
+ cancelMeasuring() {
8061
+ const wasMeasuring = this.measuring;
8062
+ this.measuring = false;
6810
8063
  this.pendingPreviewEvent = null;
6811
8064
  this.removePlacementPreview();
8065
+ this.clearMeasurement();
8066
+ // Forgotten here so that a form opened later cannot pick up a measurement already dealt with.
8067
+ this.lastMeasurement = null;
6812
8068
  if (this.renderer) {
6813
8069
  this.renderer.domElement.style.cursor = 'default';
6814
8070
  }
8071
+ if (wasMeasuring) {
8072
+ this.measurementProgress$.next(null);
8073
+ }
6815
8074
  }
6816
8075
  /**
6817
- * Shows a translucent tag under the cursor while placing, so the spot is visible before commiting.
8076
+ * Ends the measurement and returns it, or null when there are not yet two points to measure
8077
+ * between - in which case measuring simply carries on.
6818
8078
  *
6819
- * Matterport does this with a tag that follows the pointer; the equivalent here is a preview
6820
- * sprite that gets removed the moment the real tag takes its place.
8079
+ * The drawing stays on the model so it is still visible while the person names the measurement;
8080
+ * cancelMeasuring() takes it away afterwards.
6821
8081
  */
6822
- updatePlacementPreview(event) {
6823
- const position = this.pickPointOnGpu(event);
6824
- if (!position) {
6825
- this.removePlacementPreview();
6826
- this.renderer.domElement.style.cursor = 'not-allowed';
6827
- return;
8082
+ finishMeasuring() {
8083
+ if (!this.measuring || this.measurePoints.length < 2) {
8084
+ return null;
6828
8085
  }
6829
- this.renderer.domElement.style.cursor = 'crosshair';
6830
- if (!this.placementPreview) {
6831
- this.placementPreview = new THREE.Sprite(new THREE.SpriteMaterial({
6832
- map: this.createTagTexture('#9E9E9E'),
8086
+ this.measuring = false;
8087
+ this.pendingPreviewEvent = null;
8088
+ this.removePlacementPreview();
8089
+ this.updateMeasureRubberBand(null);
8090
+ this.renderer.domElement.style.cursor = 'default';
8091
+ this.lastMeasurement = {
8092
+ points: this.measurePoints.map((point) => ({ x: point.x, y: point.y, z: point.z })),
8093
+ distances: this.measureDistances(),
8094
+ screenshot: this.captureMeasurementScreenshot(),
8095
+ };
8096
+ return this.lastMeasurement;
8097
+ }
8098
+ isMeasuring() {
8099
+ return this.measuring;
8100
+ }
8101
+ /** The measurement finishMeasuring() last produced, for the form that saves it. */
8102
+ getLastMeasurement() {
8103
+ return this.lastMeasurement;
8104
+ }
8105
+ /** Metres between consecutive points. Tag space is in metres, so no conversion is involved. */
8106
+ measureDistances() {
8107
+ const distances = [];
8108
+ for (let index = 1; index < this.measurePoints.length; index += 1) {
8109
+ const distance = this.measurePoints[index - 1].distanceTo(this.measurePoints[index]);
8110
+ // Two decimals, the precision Matterport's own measurements are stored with.
8111
+ distances.push(Math.round(distance * 100) / 100);
8112
+ }
8113
+ return distances;
8114
+ }
8115
+ addMeasurePoint(point) {
8116
+ const previous = this.measurePoints[this.measurePoints.length - 1];
8117
+ this.measurePoints.push(point);
8118
+ const marker = new THREE.Sprite(new THREE.SpriteMaterial({
8119
+ map: this.createTagTexture(ThreeJsService.MEASURE_COLOR),
8120
+ depthTest: false,
8121
+ transparent: true,
8122
+ // Sized against the viewport, not the model: measuring is done zoomed right in, where
8123
+ // anything sized in metres would cover the very thing being measured.
8124
+ sizeAttenuation: false,
8125
+ }));
8126
+ marker.scale.set(0.022, 0.022, 1);
8127
+ marker.position.copy(point);
8128
+ marker.renderOrder = 1001;
8129
+ this.measureGroup.add(marker);
8130
+ if (previous) {
8131
+ this.measureGroup.add(this.createMeasureLine(previous, point));
8132
+ const distance = Math.round(previous.distanceTo(point) * 100) / 100;
8133
+ const label = new THREE.Sprite(new THREE.SpriteMaterial({
8134
+ map: this.createMeasureLabelTexture(`${distance.toFixed(2)} m`),
6833
8135
  depthTest: false,
6834
8136
  transparent: true,
6835
- opacity: 0.6,
8137
+ sizeAttenuation: false,
6836
8138
  }));
6837
- this.placementPreview.renderOrder = 999;
6838
- this.tagGroup.add(this.placementPreview);
6839
- this.scaleTagForModel(this.placementPreview);
6840
- }
6841
- this.placementPreview.position.copy(position);
8139
+ label.scale.set(0.15, 0.05, 1);
8140
+ label.position.copy(previous).add(point).multiplyScalar(0.5);
8141
+ label.renderOrder = 1002;
8142
+ this.measureGroup.add(label);
8143
+ }
8144
+ this.measurementProgress$.next({
8145
+ points: this.measurePoints.length,
8146
+ distances: this.measureDistances(),
8147
+ });
6842
8148
  }
6843
8149
  /**
6844
- * The point under the cursor, read from the depth buffer instead of raycast, in tag space.
6845
- *
6846
- * A raycast walks every triangle of the mesh it hits: on a captured model that is millions of
6847
- * them, tens of milliseconds, every time the pointer moves. Coalescing to one per frame did not
6848
- * help, because the cost is the pick itself - it simply became one slow frame instead of several.
6849
- *
6850
- * Rendering the model's depth into a single pixel under the cursor answers the same question on
6851
- * the GPU in constant time, whatever the model's size. The camera's view offset is what narrows
6852
- * the whole image down to that one pixel, so the value read back is the depth the cursor is over.
8150
+ * A segment with a width in pixels.
6853
8151
  *
6854
- * Used for the ghost only. The click that actually places a tag still raycasts: it is one pick,
6855
- * its cost does not matter, and it yields the surface normal, which depth alone cannot give.
8152
+ * WebGL's own lines are one pixel wide whatever is asked for, which all but disappears over a
8153
+ * photographic texture. This kind is drawn as screen-facing geometry, so the width holds.
6856
8154
  */
6857
- pickPointOnGpu(event) {
6858
- if (!this.modelRoot || !this.renderer || !this.camera) {
6859
- return null;
6860
- }
6861
- if (!this.pickTarget) {
6862
- this.pickTarget = new THREE.WebGLRenderTarget(1, 1);
6863
- this.pickCamera = new THREE.PerspectiveCamera();
8155
+ createMeasureLine(from, to) {
8156
+ const geometry = new LineGeometry();
8157
+ geometry.setPositions([from.x, from.y, from.z, to.x, to.y, to.z]);
8158
+ const material = new LineMaterial({
8159
+ color: new THREE.Color(ThreeJsService.MEASURE_COLOR).getHex(),
8160
+ linewidth: 3,
8161
+ depthTest: false,
8162
+ transparent: true,
8163
+ });
8164
+ const size = this.renderer.getSize(new THREE.Vector2());
8165
+ material.resolution.set(size.x, size.y);
8166
+ const line = new Line2(geometry, material);
8167
+ line.renderOrder = 1000;
8168
+ return line;
8169
+ }
8170
+ /** Line widths are in pixels, which the material can only honour if it knows the canvas size. */
8171
+ updateMeasureLineResolution() {
8172
+ if (!this.renderer) {
8173
+ return;
8174
+ }
8175
+ const size = this.renderer.getSize(new THREE.Vector2());
8176
+ this.measureGroup.children.forEach((child) => {
8177
+ if (child.material?.resolution) {
8178
+ child.material.resolution.set(size.x, size.y);
8179
+ }
8180
+ });
8181
+ }
8182
+ /** Stretches a segment from the last point to the pointer, or hides it when given null. */
8183
+ updateMeasureRubberBand(target) {
8184
+ const last = this.measurePoints[this.measurePoints.length - 1];
8185
+ if (!this.measuring || !last || !target) {
8186
+ if (this.measureRubberBand) {
8187
+ this.measureRubberBand.visible = false;
8188
+ }
8189
+ return;
8190
+ }
8191
+ if (!this.measureRubberBand) {
8192
+ this.measureRubberBand = this.createMeasureLine(last, target);
8193
+ this.measureRubberBand.material.opacity = 0.6;
8194
+ this.measureGroup.add(this.measureRubberBand);
8195
+ }
8196
+ this.measureRubberBand.geometry.setPositions([
8197
+ last.x, last.y, last.z,
8198
+ target.x, target.y, target.z,
8199
+ ]);
8200
+ this.measureRubberBand.visible = true;
8201
+ }
8202
+ createMeasureLabelTexture(text) {
8203
+ const canvas = document.createElement('canvas');
8204
+ canvas.width = 384;
8205
+ canvas.height = 128;
8206
+ const context = canvas.getContext('2d');
8207
+ const radius = 28;
8208
+ context.fillStyle = 'rgba(0, 0, 0, 0.78)';
8209
+ context.beginPath();
8210
+ context.moveTo(radius, 8);
8211
+ context.arcTo(canvas.width - 8, 8, canvas.width - 8, canvas.height - 8, radius);
8212
+ context.arcTo(canvas.width - 8, canvas.height - 8, 8, canvas.height - 8, radius);
8213
+ context.arcTo(8, canvas.height - 8, 8, 8, radius);
8214
+ context.arcTo(8, 8, canvas.width - 8, 8, radius);
8215
+ context.closePath();
8216
+ context.fill();
8217
+ context.fillStyle = '#FFFFFF';
8218
+ context.font = 'bold 60px sans-serif';
8219
+ context.textAlign = 'center';
8220
+ context.textBaseline = 'middle';
8221
+ context.fillText(text, canvas.width / 2, canvas.height / 2 + 4);
8222
+ return new THREE.CanvasTexture(canvas);
8223
+ }
8224
+ /** Removes everything drawn for a measurement and forgets its points. */
8225
+ clearMeasurement() {
8226
+ [...this.measureGroup.children].forEach((child) => {
8227
+ this.measureGroup.remove(child);
8228
+ child.geometry?.dispose?.();
8229
+ child.material?.map?.dispose?.();
8230
+ child.material?.dispose?.();
8231
+ });
8232
+ this.measureRubberBand = undefined;
8233
+ this.measurePoints = [];
8234
+ }
8235
+ /**
8236
+ * A picture of the scene with the measurement on it, as Matterport attaches to its own.
8237
+ *
8238
+ * Tags are left out, as they are there: the picture is about the measurement. It is taken straight
8239
+ * after a render because the drawing buffer is only guaranteed to hold the frame until the
8240
+ * browser presents it, and scaled down since it is uploaded and shown as a thumbnail.
8241
+ */
8242
+ captureMeasurementScreenshot() {
8243
+ const hidden = [];
8244
+ this.tagSprites.forEach((sprite) => {
8245
+ if (sprite.visible) {
8246
+ sprite.visible = false;
8247
+ hidden.push(sprite);
8248
+ }
8249
+ });
8250
+ this.renderer.render(this.scene, this.camera);
8251
+ const source = this.renderer.domElement;
8252
+ const scale = Math.min(1, 800 / source.width, 600 / source.height);
8253
+ const canvas = document.createElement('canvas');
8254
+ canvas.width = Math.max(1, Math.round(source.width * scale));
8255
+ canvas.height = Math.max(1, Math.round(source.height * scale));
8256
+ canvas.getContext('2d').drawImage(source, 0, 0, canvas.width, canvas.height);
8257
+ hidden.forEach((sprite) => {
8258
+ sprite.visible = true;
8259
+ });
8260
+ return canvas.toDataURL('image/jpeg', 0.85);
8261
+ }
8262
+ /** Arms tag placement: the next click on the model drops a tag instead of being ignored. */
8263
+ startTagPlacement(poiType = null) {
8264
+ this.placingTag = true;
8265
+ this.placementPoiType = poiType;
8266
+ this.pendingTagPlacement = null;
8267
+ if (this.renderer) {
8268
+ this.renderer.domElement.style.cursor = 'crosshair';
8269
+ }
8270
+ }
8271
+ /** Leaves placement mode without placing anything. */
8272
+ cancelTagPlacement() {
8273
+ this.placingTag = false;
8274
+ this.pendingTagPlacement = null;
8275
+ this.pendingPreviewEvent = null;
8276
+ this.removePlacementPreview();
8277
+ if (this.renderer) {
8278
+ this.renderer.domElement.style.cursor = 'default';
8279
+ }
8280
+ }
8281
+ /**
8282
+ * Shows a translucent tag under the cursor while placing, so the spot is visible before commiting.
8283
+ *
8284
+ * Matterport does this with a tag that follows the pointer; the equivalent here is a preview
8285
+ * sprite that gets removed the moment the real tag takes its place.
8286
+ */
8287
+ updatePlacementPreview(event) {
8288
+ const position = this.pickPointOnGpu(event);
8289
+ if (!position) {
8290
+ this.removePlacementPreview();
8291
+ this.updateMeasureRubberBand(null);
8292
+ this.renderer.domElement.style.cursor = 'not-allowed';
8293
+ return;
8294
+ }
8295
+ this.renderer.domElement.style.cursor = 'crosshair';
8296
+ if (!this.placementPreview) {
8297
+ this.placementPreview = new THREE.Sprite(new THREE.SpriteMaterial({
8298
+ map: this.createTagTexture('#9E9E9E'),
8299
+ depthTest: false,
8300
+ transparent: true,
8301
+ opacity: 0.6,
8302
+ }));
8303
+ this.placementPreview.renderOrder = 999;
8304
+ this.tagGroup.add(this.placementPreview);
8305
+ this.scaleTagForModel(this.placementPreview);
8306
+ }
8307
+ this.placementPreview.position.copy(position);
8308
+ this.updateMeasureRubberBand(position);
8309
+ }
8310
+ /**
8311
+ * The point under the cursor, read from the depth buffer instead of raycast, in tag space.
8312
+ *
8313
+ * A raycast walks every triangle of the mesh it hits: on a captured model that is millions of
8314
+ * them, tens of milliseconds, every time the pointer moves. Coalescing to one per frame did not
8315
+ * help, because the cost is the pick itself - it simply became one slow frame instead of several.
8316
+ *
8317
+ * Rendering the model's depth into a single pixel under the cursor answers the same question on
8318
+ * the GPU in constant time, whatever the model's size. The camera's view offset is what narrows
8319
+ * the whole image down to that one pixel, so the value read back is the depth the cursor is over.
8320
+ *
8321
+ * Used for the ghost only. The click that actually places a tag still raycasts: it is one pick,
8322
+ * its cost does not matter, and it yields the surface normal, which depth alone cannot give.
8323
+ */
8324
+ pickPointOnGpu(event) {
8325
+ if (!this.modelRoot || !this.renderer || !this.camera) {
8326
+ return null;
8327
+ }
8328
+ if (!this.pickTarget) {
8329
+ this.pickTarget = new THREE.WebGLRenderTarget(1, 1);
8330
+ this.pickCamera = new THREE.PerspectiveCamera();
6864
8331
  this.pickCamera.layers.set(ThreeJsService.PICK_LAYER);
6865
8332
  this.pickDepthMaterial = new THREE.MeshDepthMaterial({
6866
8333
  depthPacking: THREE.RGBADepthPacking,
@@ -6940,9 +8407,250 @@ class ThreeJsService {
6940
8407
  * filter that happens to match nothing.
6941
8408
  */
6942
8409
  setVisibleTagTypes(types) {
8410
+ this.visibleTagTypes = types;
8411
+ this.tagSprites.forEach((sprite) => this.applyTagVisibility(sprite));
8412
+ }
8413
+ /** Says which capture is on screen, so the tags placed in it can be told from the rest. */
8414
+ setCurrentCapture(captureID) {
8415
+ this.currentCaptureID = captureID;
6943
8416
  this.tagSprites.forEach((sprite) => {
6944
- sprite.visible = types === null || types.includes(sprite.userData.poiType);
8417
+ this.parentTag(sprite);
8418
+ this.applyTagVisibility(sprite);
8419
+ });
8420
+ }
8421
+ /** Every tag registered in the scene, shown or not. */
8422
+ getTags() {
8423
+ const candidates = new Set(this.calibrationCandidates());
8424
+ return [...this.tagSprites.values()].map((sprite) => ({
8425
+ id: sprite.name,
8426
+ label: sprite.userData.label ?? null,
8427
+ poiType: sprite.userData.poiType ?? null,
8428
+ visible: sprite.visible,
8429
+ calibratable: candidates.has(sprite),
8430
+ }));
8431
+ }
8432
+ /**
8433
+ * How far the tags from elsewhere sit from the model, measured in model radii. Null without both.
8434
+ *
8435
+ * Before a model is calibrated those tags are wherever their own coordinates put them, which for
8436
+ * a model with a different origin can be a long way off - too far to find, let alone click.
8437
+ */
8438
+ getTagDistanceFromModel() {
8439
+ if (!this.modelRoot || !this.modelWorldRadius) {
8440
+ return null;
8441
+ }
8442
+ const centroid = new THREE.Vector3();
8443
+ const candidates = this.calibrationCandidates();
8444
+ candidates.forEach((sprite) => centroid.add(sprite.getWorldPosition(new THREE.Vector3())));
8445
+ const count = candidates.length;
8446
+ if (count === 0) {
8447
+ return null;
8448
+ }
8449
+ // The model is centred on the world origin when it is placed.
8450
+ return centroid.divideScalar(count).length() / this.modelWorldRadius;
8451
+ }
8452
+ /**
8453
+ * Hangs a tag from the group its coordinates belong to: outside the calibration when they are in
8454
+ * this model's own coordinates, under it otherwise.
8455
+ */
8456
+ parentTag(sprite) {
8457
+ const parent = sprite.userData.frame === 'model' && this.isOwnedByVisit(sprite)
8458
+ ? this.modelTagGroup
8459
+ : this.tagGroup;
8460
+ if (sprite.parent !== parent) {
8461
+ parent.add(sprite);
8462
+ }
8463
+ }
8464
+ /**
8465
+ * The tags a calibration can be worked out from: those that came from somewhere else, whether or
8466
+ * not they are on show - before calibrating, most of them will not be.
8467
+ *
8468
+ * Tags placed in this model are where they are because someone clicked there; matching them to
8469
+ * "where they really are" would be circular. A tag in another model's own coordinates cannot be
8470
+ * placed here at all. And a tag already used in this calibration cannot be used again - two
8471
+ * pairs from one tag carry no more than one.
8472
+ */
8473
+ calibrationCandidates() {
8474
+ const used = new Set(this.alignmentPairs.map((pair) => pair.tagId));
8475
+ return [...this.tagSprites.values()].filter((sprite) => sprite.parent === this.tagGroup
8476
+ && sprite.userData.frame !== 'model'
8477
+ && !this.isOwnedByVisit(sprite)
8478
+ && !used.has(sprite.name));
8479
+ }
8480
+ /** How many tags there are to calibrate with, not counting those already used. */
8481
+ getCalibrationCandidateCount() {
8482
+ return this.calibrationCandidates().length;
8483
+ }
8484
+ /**
8485
+ * How many tags from elsewhere are not on this model, and so not shown on it.
8486
+ *
8487
+ * On a model that has not been calibrated, this is what suggests it should be: the tags are out
8488
+ * there, just not where the model is.
8489
+ */
8490
+ getOffModelTagCount() {
8491
+ return this.calibrationCandidates().filter((sprite) => !this.isOnModel(sprite)).length;
8492
+ }
8493
+ /** Whether the tag was placed in the visit on screen. */
8494
+ isOwnedByVisit(sprite) {
8495
+ return !!sprite.userData.originCaptureID
8496
+ && sprite.userData.originCaptureID === this.currentCaptureID;
8497
+ }
8498
+ /**
8499
+ * A tag shows when its type is not filtered out and it belongs on this model.
8500
+ *
8501
+ * One place decides, because the two used to set `visible` separately and whichever ran last
8502
+ * undid the other.
8503
+ */
8504
+ applyTagVisibility(sprite) {
8505
+ const typeAllowed = this.visibleTagTypes === null
8506
+ || this.visibleTagTypes.includes(sprite.userData.poiType);
8507
+ sprite.visible = typeAllowed && this.belongsOnThisModel(sprite);
8508
+ }
8509
+ /**
8510
+ * Whether a tag has a place on this model.
8511
+ *
8512
+ * Every tag of the space is offered to every model - there is one set of tags, not one per model.
8513
+ * What a model shows of it is the part that sits on the model.
8514
+ */
8515
+ belongsOnThisModel(sprite) {
8516
+ // Placed here by a click on this very surface.
8517
+ if (this.isOwnedByVisit(sprite)) {
8518
+ return true;
8519
+ }
8520
+ // Placed in another model before that one was calibrated: its coordinates are that model's own
8521
+ // and say nothing about where it would be in this one.
8522
+ if (sprite.userData.frame === 'model') {
8523
+ return false;
8524
+ }
8525
+ // Calibrating is matching tags to where they belong, so they all have to be on offer.
8526
+ if (this.aligning) {
8527
+ return true;
8528
+ }
8529
+ return this.isOnModel(sprite);
8530
+ }
8531
+ /** Whether a tag, drawn where its coordinates and the calibration put it, sits on the model. */
8532
+ isOnModel(sprite) {
8533
+ if (!this.surfaceCells) {
8534
+ // No model to be on yet.
8535
+ return false;
8536
+ }
8537
+ const parent = sprite.parent ?? this.tagGroup;
8538
+ parent.updateMatrix();
8539
+ // The grid is in the model's own units; the calibration's scale is how many of those a metre is.
8540
+ const reach = ThreeJsService.ON_MODEL_METRES * (parent.scale.x || 1);
8541
+ return this.isNearSurface(sprite.position.clone().applyMatrix4(parent.matrix), reach);
8542
+ }
8543
+ /** Re-decides every tag's visibility: the model, the calibration or the mode changed. */
8544
+ refreshTagVisibility() {
8545
+ this.tagSprites.forEach((sprite) => this.applyTagVisibility(sprite));
8546
+ }
8547
+ surfaceKey(x, y, z) {
8548
+ const span = ThreeJsService.SURFACE_GRID_SPAN;
8549
+ const pad = ThreeJsService.SURFACE_GRID_PAD;
8550
+ const [i, j, k] = [x + pad, y + pad, z + pad];
8551
+ if (i < 0 || j < 0 || k < 0 || i >= span || j >= span || k >= span) {
8552
+ return -1;
8553
+ }
8554
+ return i + span * (j + span * k);
8555
+ }
8556
+ /**
8557
+ * Lays the grid over the model's surface.
8558
+ *
8559
+ * Runs once per model, in one pass over its triangles. A triangle smaller than a cell is marked
8560
+ * by its centre, which is every triangle of a scanned model; a larger one - the two triangles of
8561
+ * a drawn wall - is sampled across its face, or the middle of the wall would read as empty.
8562
+ */
8563
+ buildSurfaceGrid(object, box) {
8564
+ const size = box.getSize(new THREE.Vector3());
8565
+ const longest = Math.max(size.x, size.y, size.z);
8566
+ if (!(longest > 0)) {
8567
+ this.surfaceCells = null;
8568
+ return;
8569
+ }
8570
+ // A quarter of a metre where the model is in metres; coarser on a very large one, so the grid
8571
+ // stays addressable and its memory bounded.
8572
+ const cell = Math.max(0.25, longest / 384);
8573
+ const origin = box.min.clone();
8574
+ const cells = new Set();
8575
+ const mark = (point) => {
8576
+ const key = this.surfaceKey(Math.floor((point.x - origin.x) / cell), Math.floor((point.y - origin.y) / cell), Math.floor((point.z - origin.z) / cell));
8577
+ if (key >= 0) {
8578
+ cells.add(key);
8579
+ }
8580
+ };
8581
+ let triangleCount = 0;
8582
+ object.updateMatrixWorld(true);
8583
+ object.traverse((child) => {
8584
+ const position = child.isMesh && child.geometry?.attributes?.position;
8585
+ if (position) {
8586
+ triangleCount += (child.geometry.index ? child.geometry.index.count : position.count) / 3;
8587
+ }
8588
+ });
8589
+ // Past a few million triangles they are far smaller than a cell, and every cell is hit many
8590
+ // times over; a sample marks the same cells.
8591
+ const stride = Math.max(1, Math.ceil(triangleCount / 3000000));
8592
+ const a = new THREE.Vector3();
8593
+ const b = new THREE.Vector3();
8594
+ const c = new THREE.Vector3();
8595
+ const point = new THREE.Vector3();
8596
+ object.traverse((child) => {
8597
+ const position = child.isMesh && child.geometry?.attributes?.position;
8598
+ if (!position) {
8599
+ return;
8600
+ }
8601
+ const index = child.geometry.index;
8602
+ const count = (index ? index.count : position.count) / 3;
8603
+ for (let triangle = 0; triangle < count; triangle += stride) {
8604
+ const offset = triangle * 3;
8605
+ a.fromBufferAttribute(position, index ? index.getX(offset) : offset).applyMatrix4(child.matrixWorld);
8606
+ b.fromBufferAttribute(position, index ? index.getX(offset + 1) : offset + 1).applyMatrix4(child.matrixWorld);
8607
+ c.fromBufferAttribute(position, index ? index.getX(offset + 2) : offset + 2).applyMatrix4(child.matrixWorld);
8608
+ const edge = Math.max(a.distanceTo(b), b.distanceTo(c), c.distanceTo(a));
8609
+ if (edge <= cell) {
8610
+ mark(point.copy(a).add(b).add(c).multiplyScalar(1 / 3));
8611
+ continue;
8612
+ }
8613
+ // Half-cell steps across the face. Capped, so one enormous triangle cannot stall the load;
8614
+ // the reach a tag is tested with spans several cells and bridges what the cap leaves.
8615
+ const steps = Math.min(96, Math.ceil((2 * edge) / cell));
8616
+ for (let i = 0; i <= steps; i += 1) {
8617
+ for (let j = 0; j <= steps - i; j += 1) {
8618
+ point.copy(a)
8619
+ .addScaledVector(b.clone().sub(a), i / steps)
8620
+ .addScaledVector(c.clone().sub(a), j / steps);
8621
+ mark(point);
8622
+ }
8623
+ }
8624
+ }
6945
8625
  });
8626
+ this.surfaceCells = cells;
8627
+ this.surfaceOrigin.copy(origin);
8628
+ this.surfaceCellSize = cell;
8629
+ }
8630
+ /** Whether some part of the model's surface lies within `reach` of a point, in model units. */
8631
+ isNearSurface(point, reach) {
8632
+ const cell = this.surfaceCellSize;
8633
+ const origin = this.surfaceOrigin;
8634
+ const x = (point.x - origin.x) / cell;
8635
+ const y = (point.y - origin.y) / cell;
8636
+ const z = (point.z - origin.z) / cell;
8637
+ // In cells. Capped: beyond this the calibration's scale makes a metre so many cells that the
8638
+ // search would dwarf the model, and a tag that far off is not on it by any reading.
8639
+ const span = Math.min(16, Math.ceil(reach / cell));
8640
+ // A cell is "within reach" when its centre is, give or take the half-diagonal of the cell.
8641
+ const limit = (reach / cell + 0.87) ** 2;
8642
+ const [cx, cy, cz] = [Math.floor(x), Math.floor(y), Math.floor(z)];
8643
+ for (let k = cz - span; k <= cz + span; k += 1) {
8644
+ for (let j = cy - span; j <= cy + span; j += 1) {
8645
+ for (let i = cx - span; i <= cx + span; i += 1) {
8646
+ const distance = (i + 0.5 - x) ** 2 + (j + 0.5 - y) ** 2 + (k + 0.5 - z) ** 2;
8647
+ if (distance <= limit && this.surfaceCells.has(this.surfaceKey(i, j, k))) {
8648
+ return true;
8649
+ }
8650
+ }
8651
+ }
8652
+ }
8653
+ return false;
6946
8654
  }
6947
8655
  /**
6948
8656
  * Moves the camera to look at a tag, mirroring Matterport's goToTag.
@@ -7073,8 +8781,11 @@ class ThreeJsService {
7073
8781
  this.tagGroup.position.set(translation?.x ?? 0, translation?.y ?? 0, translation?.z ?? 0);
7074
8782
  this.tagGroup.rotation.set(rotation?.x ?? 0, rotation?.y ?? 0, rotation?.z ?? 0);
7075
8783
  this.tagGroup.scale.setScalar(scale ?? 1);
7076
- // Tag size is derived from the world scale it inherits, which this call just changed.
8784
+ this.tagGroup.updateMatrix();
8785
+ // Tag size is derived from the world scale it inherits, which this call just changed - and so
8786
+ // is which tags land on the model.
7077
8787
  this.tagSprites.forEach((sprite) => this.scaleTagForModel(sprite));
8788
+ this.refreshTagVisibility();
7078
8789
  }
7079
8790
  /**
7080
8791
  * Adds a tag, or moves/restyles the one already registered for this element.
@@ -7089,6 +8800,10 @@ class ThreeJsService {
7089
8800
  existing.position.set(tag.position.x, tag.position.y, tag.position.z);
7090
8801
  existing.userData.poiType = tag.poiType ?? existing.userData.poiType;
7091
8802
  existing.userData.label = tag.label ?? existing.userData.label;
8803
+ existing.userData.originCaptureID = tag.originCaptureID ?? existing.userData.originCaptureID;
8804
+ existing.userData.frame = tag.frame ?? existing.userData.frame;
8805
+ this.parentTag(existing);
8806
+ this.applyTagVisibility(existing);
7092
8807
  return;
7093
8808
  }
7094
8809
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
@@ -7101,10 +8816,13 @@ class ThreeJsService {
7101
8816
  sprite.name = tag.id;
7102
8817
  sprite.userData.poiType = tag.poiType ?? null;
7103
8818
  sprite.userData.label = tag.label ?? null;
8819
+ sprite.userData.originCaptureID = tag.originCaptureID ?? null;
8820
+ sprite.userData.frame = tag.frame ?? null;
7104
8821
  sprite.position.set(tag.position.x, tag.position.y, tag.position.z);
7105
8822
  sprite.renderOrder = 999;
8823
+ this.parentTag(sprite);
7106
8824
  this.scaleTagForModel(sprite);
7107
- this.tagGroup.add(sprite);
8825
+ this.applyTagVisibility(sprite);
7108
8826
  this.tagSprites.set(tag.id, sprite);
7109
8827
  if (tag.iconUrl) {
7110
8828
  this.applyTagIcon(sprite, tag.iconUrl);
@@ -7116,7 +8834,7 @@ class ThreeJsService {
7116
8834
  if (!sprite) {
7117
8835
  return;
7118
8836
  }
7119
- this.tagGroup.remove(sprite);
8837
+ sprite.parent?.remove(sprite);
7120
8838
  sprite.material.map?.dispose();
7121
8839
  sprite.material.dispose();
7122
8840
  this.tagSprites.delete(elementId);
@@ -7157,7 +8875,8 @@ class ThreeJsService {
7157
8875
  * size on screen whatever the model's units are.
7158
8876
  */
7159
8877
  scaleTagForModel(sprite) {
7160
- const worldScale = this.tagGroup.getWorldScale(new THREE.Vector3()).x || 1;
8878
+ // Its own parent's scale: tags in the model's coordinates do not inherit the calibration's.
8879
+ const worldScale = (sprite.parent ?? this.tagGroup).getWorldScale(new THREE.Vector3()).x || 1;
7161
8880
  const size = 0.4 / worldScale;
7162
8881
  sprite.scale.set(size, size, 1);
7163
8882
  }
@@ -7216,7 +8935,7 @@ class ThreeJsService {
7216
8935
  this.removePlacementPreview();
7217
8936
  // Tags first: they draw on top of the model, so they have to answer the pointer first too.
7218
8937
  const hoveredTag = this.raycaster
7219
- .intersectObjects(this.tagGroup.children, false)
8938
+ .intersectObjects([...this.tagSprites.values()], false)
7220
8939
  .find((hit) => hit.object.visible)?.object;
7221
8940
  if (hoveredTag) {
7222
8941
  this.renderer.domElement.style.cursor = 'pointer';
@@ -7244,110 +8963,589 @@ class ThreeJsService {
7244
8963
  this.objectHovered$.next(null);
7245
8964
  }
7246
8965
  }
7247
- onMouseClick(event) {
7248
- if (!this.isDraggingCamera) {
7249
- const rect = this.renderer.domElement.getBoundingClientRect();
7250
- this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
7251
- this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
7252
- // Alignment and placement both take the click before anything can be opened by it.
7253
- if (this.aligning) {
7254
- this.raycaster.setFromCamera(this.mouse, this.camera);
7255
- this.handleAlignmentClick(event);
7256
- return;
7257
- }
7258
- // Placement wins over everything: while armed, a click means "put it here", not "open that".
7259
- if (this.placingTag) {
7260
- const placement = this.pickPlacementOnModel(event);
7261
- if (placement) {
7262
- placement.poiType = this.placementPoiType;
7263
- this.pendingTagPlacement = placement;
7264
- this.placingTag = false;
7265
- this.removePlacementPreview();
7266
- this.renderer.domElement.style.cursor = 'default';
7267
- this.tagPlaced$.next(placement);
7268
- }
7269
- return;
7270
- }
7271
- this.raycaster.setFromCamera(this.mouse, this.camera);
7272
- // Same precedence as hover: a tag drawn over the model claims the click.
7273
- const tagHits = this.raycaster.intersectObjects(this.tagGroup.children, false)
7274
- .filter((hit) => hit.object.visible);
7275
- if (tagHits.length > 0) {
7276
- const tag = tagHits[0].object;
7277
- this.tagClicked$.next({ elementId: tag.name, poiType: tag.userData.poiType ?? null });
7278
- return;
7279
- }
7280
- const intersects = this.raycaster.intersectObjects(this.clickableObjects, true);
7281
- if (intersects.length > 0) {
7282
- const clickedObject = intersects[0].object;
7283
- const id = clickedObject?.parent?.name;
7284
- if (id) {
7285
- this.objectClicked$.next(id);
7286
- }
7287
- }
7288
- else {
7289
- this.objectClicked$.next(null);
8966
+ onMouseClick(event) {
8967
+ if (!this.isDraggingCamera) {
8968
+ const rect = this.renderer.domElement.getBoundingClientRect();
8969
+ this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
8970
+ this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
8971
+ // Alignment and placement both take the click before anything can be opened by it.
8972
+ if (this.aligning) {
8973
+ this.raycaster.setFromCamera(this.mouse, this.camera);
8974
+ this.handleAlignmentClick(event);
8975
+ return;
8976
+ }
8977
+ // Measuring takes every click as a point, the same way placement takes it as a position.
8978
+ if (this.measuring) {
8979
+ const placement = this.pickPlacementOnModel(event);
8980
+ if (placement) {
8981
+ const { x, y, z } = placement.position;
8982
+ this.addMeasurePoint(new THREE.Vector3(x, y, z));
8983
+ }
8984
+ return;
8985
+ }
8986
+ // Placement wins over everything: while armed, a click means "put it here", not "open that".
8987
+ if (this.placingTag) {
8988
+ const placement = this.pickPlacementOnModel(event);
8989
+ if (placement) {
8990
+ placement.poiType = this.placementPoiType;
8991
+ this.pendingTagPlacement = placement;
8992
+ this.placingTag = false;
8993
+ this.removePlacementPreview();
8994
+ this.renderer.domElement.style.cursor = 'default';
8995
+ this.tagPlaced$.next(placement);
8996
+ }
8997
+ return;
8998
+ }
8999
+ this.raycaster.setFromCamera(this.mouse, this.camera);
9000
+ // Same precedence as hover: a tag drawn over the model claims the click.
9001
+ const tagHits = this.raycaster.intersectObjects([...this.tagSprites.values()], false)
9002
+ .filter((hit) => hit.object.visible);
9003
+ if (tagHits.length > 0) {
9004
+ const tag = tagHits[0].object;
9005
+ this.tagClicked$.next({ elementId: tag.name, poiType: tag.userData.poiType ?? null });
9006
+ return;
9007
+ }
9008
+ const intersects = this.raycaster.intersectObjects(this.clickableObjects, true);
9009
+ if (intersects.length > 0) {
9010
+ const clickedObject = intersects[0].object;
9011
+ const id = clickedObject?.parent?.name;
9012
+ if (id) {
9013
+ this.objectClicked$.next(id);
9014
+ }
9015
+ }
9016
+ else {
9017
+ this.objectClicked$.next(null);
9018
+ }
9019
+ }
9020
+ }
9021
+ /**
9022
+ * Escape cancels whatever the pointer is currently committed to.
9023
+ *
9024
+ * Emits through the same subjects the explicit Cancel buttons use, so the UI driving them follows
9025
+ * along instead of being left showing a mode that is no longer armed.
9026
+ */
9027
+ onKeyDown = (event) => {
9028
+ if (event.key !== 'Escape') {
9029
+ return;
9030
+ }
9031
+ if (this.placingTag) {
9032
+ // Nothing outside the scene tracks placement, so clearing it here is the whole of it.
9033
+ this.cancelTagPlacement();
9034
+ return;
9035
+ }
9036
+ if (this.measuring) {
9037
+ this.cancelMeasuring();
9038
+ return;
9039
+ }
9040
+ if (this.aligning) {
9041
+ this.cancelTagAlignment();
9042
+ }
9043
+ };
9044
+ animate = () => {
9045
+ this.animationFrameId = requestAnimationFrame(this.animate);
9046
+ this.stepCameraTween();
9047
+ this.controls.update();
9048
+ // One pick per frame, against the pointer's latest position - see onMouseMove.
9049
+ if (this.pendingPreviewEvent) {
9050
+ const event = this.pendingPreviewEvent;
9051
+ this.pendingPreviewEvent = null;
9052
+ this.updatePlacementPreview(event);
9053
+ }
9054
+ this.renderer.render(this.scene, this.camera);
9055
+ };
9056
+ destroy() {
9057
+ if (this.animationFrameId) {
9058
+ cancelAnimationFrame(this.animationFrameId);
9059
+ }
9060
+ window.removeEventListener('keydown', this.onKeyDown);
9061
+ if (this.canvasResizeObserver) {
9062
+ this.canvasResizeObserver.disconnect();
9063
+ this.canvasResizeObserver = undefined;
9064
+ }
9065
+ this.discardCurrentLoad();
9066
+ this.clearTags();
9067
+ this.measuring = false;
9068
+ this.clearMeasurement();
9069
+ if (this.renderer) {
9070
+ this.renderer.dispose();
9071
+ }
9072
+ if (this.controls) {
9073
+ this.controls.dispose();
9074
+ }
9075
+ }
9076
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ThreeJsService, deps: [{ token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Injectable });
9077
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ThreeJsService, providedIn: 'root' });
9078
+ }
9079
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ThreeJsService, decorators: [{
9080
+ type: Injectable,
9081
+ args: [{
9082
+ providedIn: 'root'
9083
+ }]
9084
+ }], ctorParameters: () => [{ type: i0.NgZone }] });
9085
+
9086
+ class CaptureService {
9087
+ API; //AWS
9088
+ constructor(apiInjected) {
9089
+ this.API = apiInjected;
9090
+ }
9091
+ async getCapturesBySpace(spaceID) {
9092
+ return this.API.CapturesBySpace(spaceID).then((cap) => cap.items);
9093
+ }
9094
+ async getCapture(captureID) {
9095
+ return this.API.__proto__.GetCapture(captureID);
9096
+ }
9097
+ async deleteCapture(captureID) {
9098
+ return this.API.__proto__.DeleteCapture({ id: captureID });
9099
+ }
9100
+ async updateCapture(capture) {
9101
+ return this.API.__proto__.UpdateCapture(capture);
9102
+ }
9103
+ async createCapture(capture) {
9104
+ return this.API.__proto__.CreateCapture(capture);
9105
+ }
9106
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CaptureService, deps: [{ token: 'currentAPIService' }], target: i0.ɵɵFactoryTarget.Injectable });
9107
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CaptureService, providedIn: "root" });
9108
+ }
9109
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CaptureService, decorators: [{
9110
+ type: Injectable,
9111
+ args: [{
9112
+ providedIn: "root",
9113
+ }]
9114
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
9115
+ type: Inject,
9116
+ args: ['currentAPIService']
9117
+ }] }] });
9118
+
9119
+ /* eslint-disable class-methods-use-this */
9120
+ class ZoneService {
9121
+ userService;
9122
+ API; //AWS APIService
9123
+ constructor(apiInjected, userService) {
9124
+ this.userService = userService;
9125
+ this.API = apiInjected;
9126
+ }
9127
+ async getZonesBySpace(spaceID) {
9128
+ const list = [];
9129
+ if (this.API.__proto__.hasOwnProperty('ZonesBySpace')) {
9130
+ // Paginated: without a nextToken loop, only the first page (100 zones) was returned and
9131
+ // the sweeps of the other zones silently disappeared.
9132
+ let nextToken = null;
9133
+ do {
9134
+ const zones = await this.API.__proto__.ZonesBySpace(spaceID, null, null, 1000, nextToken);
9135
+ for (const zone of zones?.items || []) {
9136
+ const zoneObject = { ...zone };
9137
+ zoneObject.virtual = this.zoneIsVirtual(zone);
9138
+ list.push(zoneObject);
9139
+ }
9140
+ nextToken = zones?.nextToken ?? null;
9141
+ } while (nextToken);
9142
+ return list;
9143
+ }
9144
+ else {
9145
+ throw new Error('ZonesBySpace not implemented');
9146
+ }
9147
+ }
9148
+ async getZonesBySpaceForUser(spaceID) {
9149
+ const missionForLocation = this.userService.currentMission(spaceID);
9150
+ const list = [];
9151
+ if (missionForLocation && missionForLocation.zoneID) {
9152
+ const allowedZone = await this.getZone(missionForLocation.zoneID);
9153
+ allowedZone.virtual = this.zoneIsVirtual(allowedZone);
9154
+ if (!allowedZone.parent) {
9155
+ // the whole building is authorized
9156
+ return this.getZonesBySpace(spaceID);
9157
+ }
9158
+ list.push(allowedZone);
9159
+ // allow all children zones of an allowed parent
9160
+ const children = (await this.API.__proto__.ZonesByZone(allowedZone.id))
9161
+ .items;
9162
+ if (children.length > 0) {
9163
+ for (const child of children) {
9164
+ const childZone = { ...child };
9165
+ childZone.virtual = this.zoneIsVirtual(child);
9166
+ list.push(childZone);
9167
+ }
9168
+ }
9169
+ // allow the parent Floor of the allowed zone
9170
+ if (allowedZone.parent.layer &&
9171
+ allowedZone.parent.layer.name === 'FLOOR') {
9172
+ list.push(allowedZone.parent);
9173
+ }
9174
+ }
9175
+ else {
9176
+ return this.getZonesBySpace(spaceID);
9177
+ }
9178
+ return list;
9179
+ }
9180
+ async filterZonesIDsForUser(zones) {
9181
+ const { spaceID } = zones[0];
9182
+ const zoneIDs = new Set(zones.map((zone) => zone.id));
9183
+ const allowedZones = await this.getZonesBySpaceForUser(spaceID);
9184
+ const filteredZones = allowedZones.filter((zone) => zoneIDs.has(zone.id));
9185
+ return filteredZones;
9186
+ }
9187
+ async deleteZone(zoneID) {
9188
+ return this.API.__proto__.DeleteZone({ id: zoneID });
9189
+ }
9190
+ async getZonesForSweepForSpace(sweepID, spaceID) {
9191
+ const zonesSpace = (await this.API.__proto__.ZonesBySpace(spaceID)).items;
9192
+ return zonesSpace.filter((zone) => zone.sweepIDs && zone.sweepIDs.includes(sweepID));
9193
+ }
9194
+ // // eslint-disable-next-line class-methods-use-this
9195
+ sortZonesFromChildToParent(zonesToSort) {
9196
+ // get default
9197
+ const defaultZone = zonesToSort.find((z) => !z.parentID);
9198
+ if (defaultZone) {
9199
+ const indexOf = zonesToSort.indexOf(defaultZone);
9200
+ zonesToSort.splice(indexOf, 1);
9201
+ }
9202
+ const zones = zonesToSort.sort((a, b) => {
9203
+ if (a.parentID === b.id) {
9204
+ return -1;
9205
+ }
9206
+ return 0;
9207
+ });
9208
+ // so far by number of navigation points in them
9209
+ // const zones = zonesToSort.sort(
9210
+ // (a, b) => a.navigations.items.length - b.navigations.items.length,
9211
+ // );
9212
+ if (defaultZone) {
9213
+ zones.push(defaultZone);
9214
+ }
9215
+ return zones;
9216
+ }
9217
+ async getZonesForObject(object) {
9218
+ if (object.pois.items && object.pois.items.length > 0) {
9219
+ const [poi] = object.pois.items;
9220
+ if (poi.matterportSweepID) {
9221
+ const zones = await this.getZonesForSweepForSpace(poi.matterportSweepID, object.spaceID);
9222
+ return zones;
9223
+ }
9224
+ }
9225
+ return null;
9226
+ }
9227
+ async createVirtualZone(zoneInput) {
9228
+ zoneInput.sweepIDs = [];
9229
+ const zone = await this.API.__proto__.CreateZone(zoneInput);
9230
+ console.log('virtual zone created', zone);
9231
+ return zone;
9232
+ }
9233
+ zoneIsVirtual(zone) {
9234
+ if (!zone.sweepIDs) {
9235
+ return true;
9236
+ }
9237
+ if ((zone.tags && zone.tags.includes('virtual')) ||
9238
+ zone.sweepIDs.length === 0) {
9239
+ return true;
9240
+ }
9241
+ return false;
9242
+ }
9243
+ async getModel3DForZone(zoneID) {
9244
+ const zone = await this.API.__proto__.GetZone(zoneID);
9245
+ if (zone.nodeIDs) {
9246
+ const node = await this.API.__proto__.GetNode(zone.nodeIDs[0]);
9247
+ return node.model3D;
9248
+ }
9249
+ return null;
9250
+ }
9251
+ async getZone(zoneID) {
9252
+ return this.API.__proto__.GetZone(zoneID);
9253
+ }
9254
+ async getDefaultZoneForSpace(spaceID) {
9255
+ return this.getZonesBySpace(spaceID).then(async (zones) => {
9256
+ const zonesBuilding = zones.filter((zone) => zone.layer && zone.layer.name === 'BUILDING');
9257
+ if (zonesBuilding.length === 1) {
9258
+ return zonesBuilding[0];
9259
+ }
9260
+ const parentZone = zones.find((zone) => !zone.parentID);
9261
+ return parentZone;
9262
+ });
9263
+ }
9264
+ async create(input) {
9265
+ return this.API.__proto__.CreateZone(input);
9266
+ }
9267
+ // async getSortedZonesForSweepForUser(
9268
+ // sweep: string,
9269
+ // spaceID: string,
9270
+ // ): Promise<Zone[]> {
9271
+ // if (!spaceID) {
9272
+ // return [];
9273
+ // }
9274
+ // const zones = await this.getZonesForSweepForSpace(sweep, spaceID);
9275
+ // if (zones.length === 0) {
9276
+ // return [];
9277
+ // }
9278
+ // const filteredZones = await this.filterZonesIDsForUser(zones);
9279
+ // if (filteredZones.length > 0) {
9280
+ // let zonesSorted = this.sortZonesFromChildToParent(filteredZones);
9281
+ // zonesSorted = zonesSorted.filter((zone) => zone !== undefined);
9282
+ // return zonesSorted;
9283
+ // }
9284
+ // return [];
9285
+ // }
9286
+ async updateZone(input) {
9287
+ return this.API.__proto__.UpdateZone(input);
9288
+ }
9289
+ async getZonesForNodeForSpace(nodeID, spaceID) {
9290
+ return this.API.__proto__.ZonesBySpace(spaceID).then(async (zones) => {
9291
+ const filteredZones = zones.items.filter((zone) => zone.nodeIDs && zone.nodeIDs.includes(nodeID));
9292
+ return filteredZones;
9293
+ });
9294
+ }
9295
+ /**
9296
+ * Gets the Zone (layer FLOOR) for a matterport sweep ID in a given Space
9297
+ * @param sweepID
9298
+ * @param spaceID
9299
+ * @returns Zone
9300
+ */
9301
+ async getFloorZoneForSweepInSpace(sweepID, spaceID) {
9302
+ const zones = await this.getZonesForSweepForSpace(sweepID, spaceID);
9303
+ const floors = zones.filter((zone) => zone.layer && zone.layer.name.includes('FLOOR'));
9304
+ return floors.length > 0 ? floors[0] : null;
9305
+ }
9306
+ /**
9307
+ * Get zones with audio track for entire space
9308
+ * @param spaceID
9309
+ * @returns Promise<Zone[]>
9310
+ */
9311
+ async getAudioZonesForSpace(spaceID) {
9312
+ const allZones = await this.getZonesBySpace(spaceID);
9313
+ return allZones.filter((zone) => zone.audioID);
9314
+ }
9315
+ /**
9316
+ * Get audio zones for user (within allowed zones)
9317
+ * @param spaceID
9318
+ * @returns Promise<Zone[]>
9319
+ */
9320
+ async getAudioZonesForUserForSpace(spaceID) {
9321
+ const userZones = await this.getZonesBySpaceForUser(spaceID);
9322
+ return userZones.filter((zone) => zone.audioID);
9323
+ }
9324
+ /**
9325
+ * Get Zones by Parent Zone ID
9326
+ * @param parentZoneID string
9327
+ * @returns Promise<Zone[]>
9328
+ */
9329
+ async getZonesByParentZone(parentZoneID) {
9330
+ return this.API.__proto__
9331
+ .ZonesByZone(parentZoneID)
9332
+ .then((res) => res.items);
9333
+ }
9334
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ZoneService, deps: [{ token: 'currentAPIService' }, { token: BaseUserService }], target: i0.ɵɵFactoryTarget.Injectable });
9335
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ZoneService, providedIn: 'root' });
9336
+ }
9337
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ZoneService, decorators: [{
9338
+ type: Injectable,
9339
+ args: [{
9340
+ providedIn: 'root',
9341
+ }]
9342
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
9343
+ type: Inject,
9344
+ args: ['currentAPIService']
9345
+ }] }, { type: BaseUserService }] });
9346
+
9347
+ class NavigationService {
9348
+ userService;
9349
+ zoneService;
9350
+ API;
9351
+ constructor(apiInjected, userService, zoneService) {
9352
+ this.userService = userService;
9353
+ this.zoneService = zoneService;
9354
+ this.API = apiInjected;
9355
+ }
9356
+ async createNavigation(navInput) {
9357
+ return this.API.__proto__.CreateNavigation(navInput);
9358
+ }
9359
+ async deleteNavigation(id) {
9360
+ return this.API.__proto__.DeleteNavigation({ id });
9361
+ }
9362
+ async getNavigationsForNode(nodeID) {
9363
+ const limit = 200;
9364
+ let navigationsTokenDict = { navigations: [], nextToken: null };
9365
+ do {
9366
+ navigationsTokenDict =
9367
+ await this.mergeAPIResponseWithNextTokenWithLimit(nodeID, limit, navigationsTokenDict);
9368
+ } while (navigationsTokenDict.nextToken);
9369
+ return navigationsTokenDict.navigations;
9370
+ }
9371
+ async mergeAPIResponseWithNextTokenWithLimit(nodeID, limit, navigationsTokenDict) {
9372
+ return this.API.__proto__.NavigationsByNode(nodeID, null, null, limit, navigationsTokenDict.nextToken).then((response) => {
9373
+ const navigations = response.items;
9374
+ const navObject = {
9375
+ navigations: [...navigationsTokenDict.navigations, ...navigations],
9376
+ nextToken: response.nextToken,
9377
+ };
9378
+ return navObject;
9379
+ });
9380
+ }
9381
+ async getNavigation(id) {
9382
+ return this.API.__proto__.GetNavigation(id);
9383
+ }
9384
+ async getSweepsIDsForSpace(spaceID) {
9385
+ const zones = await this.zoneService.getZonesBySpace(spaceID);
9386
+ const zonesWithSweeps = zones.filter((zone) => zone.sweepIDs && zone.sweepIDs.length > 0);
9387
+ const allSweeps = zonesWithSweeps.flatMap((zone) => zone.sweepIDs);
9388
+ return [...new Set(allSweeps)];
9389
+ }
9390
+ async getAllowedSweepsIDsForUserInSpace(spaceID) {
9391
+ const missionsForSpace = this.userService.currentMissions.filter((mis) => mis.spaceID === spaceID);
9392
+ console.log(missionsForSpace);
9393
+ let navIDs = [];
9394
+ if (this.userService.userHasAccessToAllZonesInSpace(spaceID)) {
9395
+ console.log(1);
9396
+ return this.getSweepsIDsForSpace(spaceID);
9397
+ }
9398
+ await Promise.all(missionsForSpace.map(async (mission) => {
9399
+ navIDs = [...navIDs, ...mission.zone.sweepIDs];
9400
+ }));
9401
+ console.log('final navids = ', navIDs);
9402
+ return navIDs;
9403
+ }
9404
+ async getForbiddenNavigationIDsForUserInSpace(spaceID) {
9405
+ const allowed = await this.getAllowedSweepsIDsForUserInSpace(spaceID);
9406
+ const allNavs = await this.getSweepsIDsForSpace(spaceID);
9407
+ const difference = allNavs.filter((x) => !allowed.includes(x));
9408
+ return difference.length > 0 ? difference : null;
9409
+ }
9410
+ async getNavigationsForMatterportIDForSpace(matterportID, spaceID) {
9411
+ return this.API.__proto__.NavigationsByMatterportIDBySpace(matterportID, {
9412
+ eq: spaceID,
9413
+ }).then((navs) => navs.items);
9414
+ }
9415
+ async getNavigationsForZone(zone) {
9416
+ const responseNavs = [];
9417
+ await Promise.all(zone.sweepIDs.map(async (sweepID) => {
9418
+ const navs = await this.getNavigationsForMatterportIDForSpace(sweepID, zone.spaceID);
9419
+ if (navs.length > 0) {
9420
+ responseNavs.push(navs[0]);
7290
9421
  }
7291
- }
9422
+ }));
9423
+ return responseNavs;
9424
+ }
9425
+ async updateNavigation(navInput) {
9426
+ return this.API.__proto__.UpdateNavigation(navInput);
9427
+ }
9428
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NavigationService, deps: [{ token: 'currentAPIService' }, { token: BaseUserService }, { token: ZoneService }], target: i0.ɵɵFactoryTarget.Injectable });
9429
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NavigationService, providedIn: "root" });
9430
+ }
9431
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NavigationService, decorators: [{
9432
+ type: Injectable,
9433
+ args: [{
9434
+ providedIn: "root",
9435
+ }]
9436
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
9437
+ type: Inject,
9438
+ args: ['currentAPIService']
9439
+ }] }, { type: BaseUserService }, { type: ZoneService }] });
9440
+
9441
+ /**
9442
+ * Finds the Matterport sweep a point in a space sits closest to.
9443
+ *
9444
+ * A tag placed in Matterport is recorded with the sweep it was placed from, and much of the
9445
+ * Matterport side keys on that: which floor plan draws the tag, which zone it belongs to, which
9446
+ * floor it shows on. A tag placed in the Three.js viewer has no sweep, so it was missing from all
9447
+ * of them. Resolving the nearest one at creation - what the tag copy tool already does for copied
9448
+ * tags - lets every one of those readers work unchanged instead of each growing its own exception.
9449
+ */
9450
+ class SweepLocatorService {
9451
+ captureService;
9452
+ navigationService;
9453
+ /**
9454
+ * Sweeps sit a few metres apart, so a point on a scanned surface always has one nearby. Anything
9455
+ * further means the coordinates are not in the Matterport capture's space - a model that was
9456
+ * never calibrated - and a sweep picked from that far would file the tag under an arbitrary
9457
+ * floor. Leaving it without one is the honest answer there.
9458
+ */
9459
+ static MAX_DISTANCE_METRES = 10;
9460
+ /** Sweep positions per space. They only change on import, so one load per session is enough. */
9461
+ sweepsBySpace = new Map();
9462
+ constructor(captureService, navigationService) {
9463
+ this.captureService = captureService;
9464
+ this.navigationService = navigationService;
7292
9465
  }
7293
9466
  /**
7294
- * Escape cancels whatever the pointer is currently committed to.
9467
+ * The sweep nearest to a point, or null when the space has none within reach.
7295
9468
  *
7296
- * Emits through the same subjects the explicit Cancel buttons use, so the UI driving them follows
7297
- * along instead of being left showing a mode that is no longer armed.
9469
+ * Never throws: this only refines where a tag is filed, and a tag that cannot be filed is still
9470
+ * better saved without a sweep than not saved at all.
7298
9471
  */
7299
- onKeyDown = (event) => {
7300
- if (event.key !== 'Escape') {
7301
- return;
7302
- }
7303
- if (this.placingTag) {
7304
- // Nothing outside the scene tracks placement, so clearing it here is the whole of it.
7305
- this.cancelTagPlacement();
7306
- return;
7307
- }
7308
- if (this.aligning) {
7309
- this.cancelTagAlignment();
9472
+ async findNearestSweep(point, spaceID) {
9473
+ if (!point || !spaceID) {
9474
+ return null;
7310
9475
  }
7311
- };
7312
- animate = () => {
7313
- this.animationFrameId = requestAnimationFrame(this.animate);
7314
- this.stepCameraTween();
7315
- this.controls.update();
7316
- // One pick per frame, against the pointer's latest position - see onMouseMove.
7317
- if (this.pendingPreviewEvent) {
7318
- const event = this.pendingPreviewEvent;
7319
- this.pendingPreviewEvent = null;
7320
- this.updatePlacementPreview(event);
9476
+ try {
9477
+ return this.nearestSweep(point, await this.loadSweeps(spaceID));
7321
9478
  }
7322
- this.renderer.render(this.scene, this.camera);
7323
- };
7324
- destroy() {
7325
- if (this.animationFrameId) {
7326
- cancelAnimationFrame(this.animationFrameId);
9479
+ catch (error) {
9480
+ console.warn(`SweepLocatorService: could not resolve a sweep in space ${spaceID}`, error);
9481
+ return null;
7327
9482
  }
7328
- window.removeEventListener('keydown', this.onKeyDown);
7329
- if (this.canvasResizeObserver) {
7330
- this.canvasResizeObserver.disconnect();
7331
- this.canvasResizeObserver = undefined;
9483
+ }
9484
+ /** The sweep closest to a point, or null when none is within reach. */
9485
+ nearestSweep(point, sweeps) {
9486
+ let nearest = null;
9487
+ let nearestDistance = SweepLocatorService.MAX_DISTANCE_METRES;
9488
+ for (const sweep of sweeps) {
9489
+ const distance = Math.hypot(sweep.x - point.x, sweep.y - point.y, sweep.z - point.z);
9490
+ if (distance <= nearestDistance) {
9491
+ nearestDistance = distance;
9492
+ nearest = sweep.sweepID;
9493
+ }
7332
9494
  }
7333
- this.discardCurrentLoad();
7334
- this.clearTags();
7335
- if (this.renderer) {
7336
- this.renderer.dispose();
9495
+ return nearest;
9496
+ }
9497
+ loadSweeps(spaceID) {
9498
+ let sweeps = this.sweepsBySpace.get(spaceID);
9499
+ if (!sweeps) {
9500
+ sweeps = this.fetchSweeps(spaceID);
9501
+ this.sweepsBySpace.set(spaceID, sweeps);
9502
+ // A failed load must not be remembered as "this space has no sweeps".
9503
+ sweeps.catch(() => this.sweepsBySpace.delete(spaceID));
7337
9504
  }
7338
- if (this.controls) {
7339
- this.controls.dispose();
9505
+ return sweeps;
9506
+ }
9507
+ async fetchSweeps(spaceID) {
9508
+ const captures = await this.captureService.getCapturesBySpace(spaceID);
9509
+ // A 3D model is imported with one navigation entry of its own, a placeholder sitting at the
9510
+ // origin. It is not a sweep, and counting it would make it the "nearest" for any tag close to
9511
+ // the origin.
9512
+ const nodeIDs = (captures ?? [])
9513
+ .filter((capture) => capture.viewer !== CaptureViewer.THREEJS)
9514
+ .reduce((all, capture) => all.concat(capture.nodes?.items ?? []), [])
9515
+ .map((node) => node?.id)
9516
+ .filter((id) => !!id);
9517
+ const navigations = (await Promise.all(nodeIDs.map((id) => this.navigationService.getNavigationsForNode(id)))).reduce((all, list) => all.concat(list), []);
9518
+ const sweeps = [];
9519
+ for (const navigation of navigations) {
9520
+ if (!navigation?.matterportSweepID || !navigation.position) {
9521
+ continue;
9522
+ }
9523
+ try {
9524
+ const position = JSON.parse(navigation.position);
9525
+ if ([position?.x, position?.y, position?.z].every((value) => typeof value === 'number')) {
9526
+ sweeps.push({
9527
+ sweepID: navigation.matterportSweepID,
9528
+ x: position.x,
9529
+ y: position.y,
9530
+ z: position.z,
9531
+ });
9532
+ }
9533
+ }
9534
+ catch {
9535
+ // One unreadable position should not cost the space all of its sweeps.
9536
+ }
7340
9537
  }
9538
+ return sweeps;
7341
9539
  }
7342
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ThreeJsService, deps: [{ token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Injectable });
7343
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ThreeJsService, providedIn: 'root' });
9540
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SweepLocatorService, deps: [{ token: CaptureService }, { token: NavigationService }], target: i0.ɵɵFactoryTarget.Injectable });
9541
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SweepLocatorService, providedIn: 'root' });
7344
9542
  }
7345
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ThreeJsService, decorators: [{
9543
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SweepLocatorService, decorators: [{
7346
9544
  type: Injectable,
7347
9545
  args: [{
7348
- providedIn: 'root'
9546
+ providedIn: 'root',
7349
9547
  }]
7350
- }], ctorParameters: () => [{ type: i0.NgZone }] });
9548
+ }], ctorParameters: () => [{ type: CaptureService }, { type: NavigationService }] });
7351
9549
 
7352
9550
  /* eslint-disable no-await-in-loop */
7353
9551
  /* eslint-disable camelcase */
@@ -7359,6 +9557,7 @@ class ViewerService {
7359
9557
  poiService;
7360
9558
  matterportService;
7361
9559
  threeJsService;
9560
+ sweepLocator;
7362
9561
  viewerMode = 'Matterport';
7363
9562
  currentModel3D;
7364
9563
  tourUrl;
@@ -7374,6 +9573,20 @@ class ViewerService {
7374
9573
  * lists that feed tags are exactly that.
7375
9574
  */
7376
9575
  currentSpaceID = null;
9576
+ /**
9577
+ * The Three.js capture on screen, or null in Matterport.
9578
+ *
9579
+ * A space can hold several 3D models. A tag placed in one is recorded with it, so that model
9580
+ * keeps showing the tag whatever selection of tags it is otherwise restricted to.
9581
+ */
9582
+ currentCaptureID = null;
9583
+ /**
9584
+ * Whether the 3D model on screen has been calibrated against the tags' shared space.
9585
+ *
9586
+ * Calibrating is what says the model and that space overlap. Until then a tag placed in the model
9587
+ * is in the model's own coordinates: it belongs to no Matterport sweep and to no other visit.
9588
+ */
9589
+ threeJsCalibrated = false;
7377
9590
  sweepToMove;
7378
9591
  rotationToMove;
7379
9592
  goToTriggered = new Subject();
@@ -7402,12 +9615,13 @@ class ViewerService {
7402
9615
  },
7403
9616
  };
7404
9617
  isObjectLoaded = false;
7405
- constructor(sdkKey, config, poiService, matterportService, threeJsService) {
9618
+ constructor(sdkKey, config, poiService, matterportService, threeJsService, sweepLocator) {
7406
9619
  this.sdkKey = sdkKey;
7407
9620
  this.config = config;
7408
9621
  this.poiService = poiService;
7409
9622
  this.matterportService = matterportService;
7410
9623
  this.threeJsService = threeJsService;
9624
+ this.sweepLocator = sweepLocator;
7411
9625
  this.setMode('Matterport');
7412
9626
  }
7413
9627
  /*-----------------------------------------------------------------------------------------------------------------
@@ -7613,14 +9827,77 @@ class ViewerService {
7613
9827
  if (![position?.x, position?.y, position?.z].every((value) => typeof value === 'number')) {
7614
9828
  return;
7615
9829
  }
9830
+ const origin = getThreeJsPoiOrigin(poi);
7616
9831
  this.threeJsService.upsertTag({
7617
9832
  id: object.id,
7618
9833
  position,
7619
9834
  poiType: poi.type,
7620
9835
  label: object.name ?? object.title ?? null,
7621
9836
  iconUrl: await this.resolveTagIconUrl(poi),
9837
+ originCaptureID: origin?.captureID ?? null,
9838
+ frame: origin?.frame ?? null,
7622
9839
  });
7623
9840
  }
9841
+ /**
9842
+ * Stores the tags the last calibration had to rewrite, and returns how many it could not.
9843
+ *
9844
+ * Calibrating keeps the tags placed in this model where they are by changing their coordinates
9845
+ * rather than their place (see ThreeJsService.applyAlignmentKeepingOwnTags). The scene already
9846
+ * shows the result; this writes it down. Call it once the calibration itself is stored - with the
9847
+ * old calibration still on record, the old coordinates are the ones that match it.
9848
+ */
9849
+ async persistRebasedThreeJsTags() {
9850
+ const rebased = this.threeJsService.takeRebasedTags();
9851
+ let failed = 0;
9852
+ await Promise.all(rebased.map(async (tag) => {
9853
+ try {
9854
+ const poi = await this.poiService.getPoiByElementId(tag.id);
9855
+ if (!poi?.id) {
9856
+ return;
9857
+ }
9858
+ let metadata = {};
9859
+ try {
9860
+ metadata = (poi.metadata ? JSON.parse(poi.metadata) : {}) ?? {};
9861
+ }
9862
+ catch {
9863
+ metadata = {};
9864
+ }
9865
+ // In the shared space from here on, so no longer marked as being in the model's own.
9866
+ delete metadata.frame;
9867
+ if (metadata.normal) {
9868
+ metadata.normal = tag.rotateDirection(metadata.normal);
9869
+ }
9870
+ if (this.currentCaptureID) {
9871
+ metadata.captureID = this.currentCaptureID;
9872
+ }
9873
+ await this.poiService.updatePoi({
9874
+ id: poi.id,
9875
+ coordinate: JSON.stringify(tag.position),
9876
+ metadata: JSON.stringify(metadata),
9877
+ // Its place in the tour may have changed with its coordinates - or it may have one for
9878
+ // the first time, now that the model is calibrated.
9879
+ matterportSweepID: await this.findNearestSweepForThreeJs(tag.position),
9880
+ });
9881
+ }
9882
+ catch (error) {
9883
+ failed += 1;
9884
+ console.warn(`ViewerService: could not store the new coordinates of tag ${tag.id}`, error);
9885
+ }
9886
+ }));
9887
+ return { stored: rebased.length - failed, failed };
9888
+ }
9889
+ /** How many tags the calibration walkthrough could still use: the ones from elsewhere. */
9890
+ getThreeJsCalibrationCandidateCount() {
9891
+ return this.checkMode('threejs') ? this.threeJsService.getCalibrationCandidateCount() : 0;
9892
+ }
9893
+ /** How many tags from elsewhere are not on the 3D model, and so are not shown on it. */
9894
+ getThreeJsOffModelTagCount() {
9895
+ return this.checkMode('threejs') ? this.threeJsService.getOffModelTagCount() : 0;
9896
+ }
9897
+ /** The tags registered in the Three.js scene, shown or not. */
9898
+ getThreeJsTags() {
9899
+ return this.checkMode('threejs') ? this.threeJsService.getTags() : [];
9900
+ }
7624
9901
  /**
7625
9902
  * The image to draw a tag with, mirroring what Matterport shows for the same POI.
7626
9903
  *
@@ -7713,6 +9990,7 @@ class ViewerService {
7713
9990
  if (!alignment) {
7714
9991
  return null;
7715
9992
  }
9993
+ this.threeJsCalibrated = true;
7716
9994
  // Merged into whatever the capture already stores, so an existing camera placement survives.
7717
9995
  let stored = {};
7718
9996
  try {
@@ -7767,7 +10045,11 @@ class ViewerService {
7767
10045
  if (!this.checkMode('threejs')) {
7768
10046
  return;
7769
10047
  }
7770
- this.threeJsService.setTagAlignment(parseThreeJsPlacement(metadata)?.alignment ?? null);
10048
+ const alignment = parseThreeJsPlacement(metadata)?.alignment ?? null;
10049
+ this.threeJsCalibrated = !!alignment;
10050
+ // Which tags were placed in this model is decided against the capture on screen.
10051
+ this.threeJsService.setCurrentCapture(this.currentCaptureID);
10052
+ this.threeJsService.setTagAlignment(alignment);
7771
10053
  }
7772
10054
  /**
7773
10055
  * Enable tag placement menu to add a new element depending on its type (TICKET, DATA, EQUIPMENT etc)
@@ -7836,22 +10118,116 @@ class ViewerService {
7836
10118
  if (this.checkMode('Matterport')) {
7837
10119
  await this.matterportService.action_toolbox_floorplan();
7838
10120
  }
7839
- }
7840
- action_toolbox_mesure() {
7841
- if (this.matterportService.getInteractionMode() !== ViewerInteractions.DEFAULT) {
7842
- this.action_canceled();
10121
+ }
10122
+ action_toolbox_mesure() {
10123
+ if (this.matterportService.getInteractionMode() !== ViewerInteractions.DEFAULT) {
10124
+ this.action_canceled();
10125
+ }
10126
+ this.action_toolbox_inside_view();
10127
+ // Matterport
10128
+ if (this.checkMode('Matterport')) {
10129
+ this.matterportService.action_toolbox_mesure();
10130
+ }
10131
+ // Matterport's SDK brings its own measuring tool; the Three.js viewer has to provide one.
10132
+ if (this.checkMode('threejs')) {
10133
+ this.threeJsService.startMeasuring();
10134
+ }
10135
+ }
10136
+ action_toolbox_cancel_mesure() {
10137
+ // Matterport
10138
+ if (this.checkMode('Matterport')) {
10139
+ this.matterportService.action_toolbox_cancel_mesure();
10140
+ }
10141
+ if (this.checkMode('threejs')) {
10142
+ this.threeJsService.cancelMeasuring();
10143
+ // The toolbar toggles on this flag; left on, the next click would switch measuring "off"
10144
+ // instead of starting a new one.
10145
+ this.toolboxStates.mesure.stat = false;
10146
+ }
10147
+ }
10148
+ /** While measuring in the Three.js viewer: points and segment lengths so far, null if abandoned. */
10149
+ get threeJsMeasurementProgress$() {
10150
+ return this.threeJsService.measurementProgress$;
10151
+ }
10152
+ /** Ends the Three.js measurement in progress, or returns null when it has fewer than two points. */
10153
+ finishThreeJsMeasurement() {
10154
+ if (!this.checkMode('threejs')) {
10155
+ return null;
10156
+ }
10157
+ const measurement = this.threeJsService.finishMeasuring();
10158
+ if (measurement) {
10159
+ this.toolboxStates.mesure.stat = false;
10160
+ }
10161
+ return measurement;
10162
+ }
10163
+ /** The measurement last finished in the Three.js viewer, for the form that saves it. */
10164
+ getThreeJsLastMeasurement() {
10165
+ return this.checkMode('threejs') ? this.threeJsService.getLastMeasurement() : null;
10166
+ }
10167
+ /**
10168
+ * The Matterport sweep nearest to a point picked in the Three.js viewer, or null.
10169
+ *
10170
+ * For callers that write their own POI, as the measurement form does - anything created through
10171
+ * createPoiForThreeJsPlacement already gets this.
10172
+ */
10173
+ findNearestSweepForThreeJs(point) {
10174
+ // An uncalibrated model's coordinates are its own. Measured against the Matterport sweeps they
10175
+ // could land near one by coincidence, and the tag would turn up in the tour somewhere arbitrary.
10176
+ if (!this.threeJsCalibrated) {
10177
+ return Promise.resolve(null);
10178
+ }
10179
+ return this.sweepLocator.findNearestSweep(point, this.currentSpaceID);
10180
+ }
10181
+ /**
10182
+ * The metadata a POI placed in the Three.js viewer is stored with: which 3D model it was placed
10183
+ * in, and - when that model is not calibrated - that its coordinates are the model's own.
10184
+ */
10185
+ buildThreeJsPoiMetadata(extra = {}) {
10186
+ return JSON.stringify({
10187
+ ...extra,
10188
+ ...(this.currentCaptureID ? { captureID: this.currentCaptureID } : {}),
10189
+ ...(this.currentCaptureID && !this.threeJsCalibrated ? { frame: 'model' } : {}),
10190
+ });
10191
+ }
10192
+ /** Which of the 3D model's axes is being treated as up. */
10193
+ getThreeJsModelUp() {
10194
+ return this.threeJsService.getModelUp();
10195
+ }
10196
+ /**
10197
+ * Turns the 3D model to the next candidate for "which axis is up", and returns the capture
10198
+ * metadata with that choice written in, for the caller to store.
10199
+ *
10200
+ * The vertical axis is worked out from the geometry when a model loads, and that can be wrong:
10201
+ * a narrow corridor and a low slab are the same shape turned over. This is the way to correct it.
10202
+ */
10203
+ cycleThreeJsModelUp(existingMetadata) {
10204
+ if (!this.checkMode('threejs')) {
10205
+ return null;
10206
+ }
10207
+ const current = THREEJS_MODEL_UP_OPTIONS.indexOf(this.threeJsService.getModelUp());
10208
+ const up = THREEJS_MODEL_UP_OPTIONS[(current + 1) % THREEJS_MODEL_UP_OPTIONS.length];
10209
+ this.threeJsService.setModelUp(up);
10210
+ let stored = {};
10211
+ try {
10212
+ stored = existingMetadata ? JSON.parse(existingMetadata) : {};
7843
10213
  }
7844
- this.action_toolbox_inside_view();
7845
- // Matterport
7846
- if (this.checkMode('Matterport')) {
7847
- this.matterportService.action_toolbox_mesure();
10214
+ catch {
10215
+ stored = {};
7848
10216
  }
10217
+ stored.up = up;
10218
+ return { up, metadata: JSON.stringify(stored) };
7849
10219
  }
7850
- action_toolbox_cancel_mesure() {
7851
- // Matterport
7852
- if (this.checkMode('Matterport')) {
7853
- this.matterportService.action_toolbox_cancel_mesure();
7854
- }
10220
+ /** Chooses the tag for the calibration pair being matched by id, instead of by clicking it. */
10221
+ selectThreeJsAlignmentTag(elementId) {
10222
+ return this.checkMode('threejs') && this.threeJsService.selectAlignmentTag(elementId);
10223
+ }
10224
+ /** How far the visible tags sit from the 3D model, in model radii. Null without both. */
10225
+ getThreeJsTagDistanceFromModel() {
10226
+ return this.checkMode('threejs') ? this.threeJsService.getTagDistanceFromModel() : null;
10227
+ }
10228
+ /** How many tags the Three.js scene is showing. */
10229
+ getThreeJsTagCount() {
10230
+ return this.checkMode('threejs') ? this.threeJsService.getTagCount() : 0;
7855
10231
  }
7856
10232
  async action_update_poi(poi, isValid, element) {
7857
10233
  if (this.checkMode('Matterport')) {
@@ -7941,11 +10317,15 @@ class ViewerService {
7941
10317
  if (!placement || !element?.id) {
7942
10318
  return null;
7943
10319
  }
10320
+ // Without a sweep the tag is left off everything on the Matterport side that is organised by
10321
+ // sweep - the floor plan most visibly, which only draws tags whose sweep belongs to it.
10322
+ const matterportSweepID = await this.findNearestSweepForThreeJs(placement.position);
7944
10323
  const poi = await this.poiService.createPOIForSpaceID({
7945
10324
  coordinate: JSON.stringify(placement.position),
7946
10325
  type: poiType,
7947
10326
  elementID: element.id,
7948
- metadata: JSON.stringify({ normal: placement.normal }),
10327
+ metadata: this.buildThreeJsPoiMetadata({ normal: placement.normal }),
10328
+ ...(matterportSweepID ? { matterportSweepID } : {}),
7949
10329
  });
7950
10330
  // Draw it straight away rather than waiting for the next refresh, so the user sees where the
7951
10331
  // thing they just created ended up. Through the same path the refresh uses, so it arrives with
@@ -8075,7 +10455,7 @@ class ViewerService {
8075
10455
  const placement = parseThreeJsPlacement(placementMetadata);
8076
10456
  // The key, not the signed URL, is what identifies the model across loads - the signature makes
8077
10457
  // every URL unique, which is exactly why the browser's own cache never helps here.
8078
- this.threeJsService.loadGLTFModel(url, placement?.cameraPosition, placement?.rotation, key);
10458
+ this.threeJsService.loadGLTFModel(url, placement?.cameraPosition, placement?.rotation, key, placement?.up);
8079
10459
  }
8080
10460
  /**
8081
10461
  * Loads a 3D model into the Three.js scene (OBJ).
@@ -8170,7 +10550,7 @@ class ViewerService {
8170
10550
  isObject3DLoadedInPosition() {
8171
10551
  return this.isObjectLoaded;
8172
10552
  }
8173
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ViewerService, deps: [{ token: MATTERPORT_SDK_KEY }, { token: 'config' }, { token: PoiService }, { token: MatterportService }, { token: ThreeJsService }], target: i0.ɵɵFactoryTarget.Injectable });
10553
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ViewerService, deps: [{ token: MATTERPORT_SDK_KEY }, { token: 'config' }, { token: PoiService }, { token: MatterportService }, { token: ThreeJsService }, { token: SweepLocatorService }], target: i0.ɵɵFactoryTarget.Injectable });
8174
10554
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ViewerService, providedIn: 'root' });
8175
10555
  }
8176
10556
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ViewerService, decorators: [{
@@ -8184,7 +10564,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
8184
10564
  }] }, { type: Config, decorators: [{
8185
10565
  type: Inject,
8186
10566
  args: ['config']
8187
- }] }, { type: PoiService }, { type: MatterportService }, { type: ThreeJsService }] });
10567
+ }] }, { type: PoiService }, { type: MatterportService }, { type: ThreeJsService }, { type: SweepLocatorService }] });
8188
10568
 
8189
10569
  class ModalSwitchVisitComponent {
8190
10570
  activeModal;
@@ -8227,6 +10607,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
8227
10607
  type: Input
8228
10608
  }] } });
8229
10609
 
10610
+ /**
10611
+ * Where a POI placed in a Three.js model says it was placed, or null for any other POI.
10612
+ *
10613
+ * `frame` is 'model' when its coordinates are in that model's own space - it was placed before the
10614
+ * model was calibrated - rather than in the space tags share with the other visits.
10615
+ */
10616
+ function getThreeJsPoiOrigin(poi) {
10617
+ if (!poi?.metadata) {
10618
+ return null;
10619
+ }
10620
+ try {
10621
+ const parsed = JSON.parse(poi.metadata);
10622
+ return typeof parsed?.captureID === "string"
10623
+ ? { captureID: parsed.captureID, frame: parsed.frame === "model" ? "model" : null }
10624
+ : null;
10625
+ }
10626
+ catch {
10627
+ return null;
10628
+ }
10629
+ }
10630
+ const THREEJS_MODEL_UP_OPTIONS = ["y", "z", "x", "-y", "-z", "-x"];
8230
10631
  /**
8231
10632
  * Parses the placement stored in a Three.js capture's metadata, or null when there is none.
8232
10633
  *
@@ -8265,7 +10666,12 @@ function parseThreeJsPlacement(metadata) {
8265
10666
  placement.alignment = alignment;
8266
10667
  }
8267
10668
  }
8268
- return placement.cameraPosition || placement.rotation || placement.alignment ? placement : null;
10669
+ if (THREEJS_MODEL_UP_OPTIONS.includes(parsed.up)) {
10670
+ placement.up = parsed.up;
10671
+ }
10672
+ return placement.cameraPosition || placement.rotation || placement.alignment || placement.up
10673
+ ? placement
10674
+ : null;
8269
10675
  }
8270
10676
  catch {
8271
10677
  console.warn("Ignoring unparseable Three.js placement metadata:", metadata);
@@ -8690,285 +11096,57 @@ function shuffleArray(array) {
8690
11096
  const newArray = [...array];
8691
11097
  for (let index = array.length - 1; index > 0; index--) {
8692
11098
  const indexNew = Math.floor(Math.random() * (index + 1));
8693
- // eslint-disable-next-line no-param-reassign
8694
- [newArray[index], newArray[indexNew]] = [
8695
- newArray[indexNew],
8696
- newArray[index],
8697
- ];
8698
- }
8699
- return newArray;
8700
- }
8701
- function numberToDateString(timestamp) {
8702
- const jsDate = new Date(timestamp);
8703
- return jsDate.toLocaleString("fr-FR");
8704
- }
8705
- function durationToString(duration, translate) {
8706
- const days = Math.floor(duration / 3600 / 24);
8707
- const hours = Math.floor((duration - days * 24 * 3600) / 3600);
8708
- const minutes = Math.floor((duration % 3600) / 60);
8709
- const seconds = Math.floor(duration % 60);
8710
- const daysString = translate.instant("day(s)");
8711
- const hoursString = translate.instant("hour(s)");
8712
- const oneHourString = translate.instant("hour");
8713
- const minutesString = translate.instant("minute(s)");
8714
- const secondsString = translate.instant("second(s)");
8715
- if (duration > 3600 * 24 * 7) {
8716
- return `${days} ${daysString}`;
8717
- }
8718
- if (duration > 3600 * 24) {
8719
- return `${days} ${daysString} ${hours} ${hoursString}`;
8720
- }
8721
- if (duration === 3600) {
8722
- return `${hours} ${oneHourString}`;
8723
- }
8724
- if (duration > 3600) {
8725
- if (minutes === 0) {
8726
- return `${hours} ${hoursString}`;
8727
- }
8728
- return `${hours} ${hoursString} ${minutes} ${minutesString}`;
8729
- }
8730
- if (duration >= 60) {
8731
- if (seconds === 0) {
8732
- return `${minutes} ${minutesString}`;
8733
- }
8734
- return `${minutes} ${minutesString} ${seconds} ${secondsString}`;
8735
- }
8736
- return `${seconds} ${secondsString}`;
8737
- }
8738
- function pointToString(point) {
8739
- const x = point.x.toFixed(3);
8740
- const y = point.y.toFixed(3);
8741
- const z = point.z.toFixed(3);
8742
- return `{ x: ${x}, y: ${y}, z: ${z} }`;
8743
- }
8744
-
8745
- /* eslint-disable class-methods-use-this */
8746
- class ZoneService {
8747
- userService;
8748
- API; //AWS APIService
8749
- constructor(apiInjected, userService) {
8750
- this.userService = userService;
8751
- this.API = apiInjected;
8752
- }
8753
- async getZonesBySpace(spaceID) {
8754
- const list = [];
8755
- if (this.API.__proto__.hasOwnProperty('ZonesBySpace')) {
8756
- // Paginated: without a nextToken loop, only the first page (100 zones) was returned and
8757
- // the sweeps of the other zones silently disappeared.
8758
- let nextToken = null;
8759
- do {
8760
- const zones = await this.API.__proto__.ZonesBySpace(spaceID, null, null, 1000, nextToken);
8761
- for (const zone of zones?.items || []) {
8762
- const zoneObject = { ...zone };
8763
- zoneObject.virtual = this.zoneIsVirtual(zone);
8764
- list.push(zoneObject);
8765
- }
8766
- nextToken = zones?.nextToken ?? null;
8767
- } while (nextToken);
8768
- return list;
8769
- }
8770
- else {
8771
- throw new Error('ZonesBySpace not implemented');
8772
- }
8773
- }
8774
- async getZonesBySpaceForUser(spaceID) {
8775
- const missionForLocation = this.userService.currentMission(spaceID);
8776
- const list = [];
8777
- if (missionForLocation && missionForLocation.zoneID) {
8778
- const allowedZone = await this.getZone(missionForLocation.zoneID);
8779
- allowedZone.virtual = this.zoneIsVirtual(allowedZone);
8780
- if (!allowedZone.parent) {
8781
- // the whole building is authorized
8782
- return this.getZonesBySpace(spaceID);
8783
- }
8784
- list.push(allowedZone);
8785
- // allow all children zones of an allowed parent
8786
- const children = (await this.API.__proto__.ZonesByZone(allowedZone.id))
8787
- .items;
8788
- if (children.length > 0) {
8789
- for (const child of children) {
8790
- const childZone = { ...child };
8791
- childZone.virtual = this.zoneIsVirtual(child);
8792
- list.push(childZone);
8793
- }
8794
- }
8795
- // allow the parent Floor of the allowed zone
8796
- if (allowedZone.parent.layer &&
8797
- allowedZone.parent.layer.name === 'FLOOR') {
8798
- list.push(allowedZone.parent);
8799
- }
8800
- }
8801
- else {
8802
- return this.getZonesBySpace(spaceID);
8803
- }
8804
- return list;
8805
- }
8806
- async filterZonesIDsForUser(zones) {
8807
- const { spaceID } = zones[0];
8808
- const zoneIDs = new Set(zones.map((zone) => zone.id));
8809
- const allowedZones = await this.getZonesBySpaceForUser(spaceID);
8810
- const filteredZones = allowedZones.filter((zone) => zoneIDs.has(zone.id));
8811
- return filteredZones;
8812
- }
8813
- async deleteZone(zoneID) {
8814
- return this.API.__proto__.DeleteZone({ id: zoneID });
8815
- }
8816
- async getZonesForSweepForSpace(sweepID, spaceID) {
8817
- const zonesSpace = (await this.API.__proto__.ZonesBySpace(spaceID)).items;
8818
- return zonesSpace.filter((zone) => zone.sweepIDs && zone.sweepIDs.includes(sweepID));
8819
- }
8820
- // // eslint-disable-next-line class-methods-use-this
8821
- sortZonesFromChildToParent(zonesToSort) {
8822
- // get default
8823
- const defaultZone = zonesToSort.find((z) => !z.parentID);
8824
- if (defaultZone) {
8825
- const indexOf = zonesToSort.indexOf(defaultZone);
8826
- zonesToSort.splice(indexOf, 1);
8827
- }
8828
- const zones = zonesToSort.sort((a, b) => {
8829
- if (a.parentID === b.id) {
8830
- return -1;
8831
- }
8832
- return 0;
8833
- });
8834
- // so far by number of navigation points in them
8835
- // const zones = zonesToSort.sort(
8836
- // (a, b) => a.navigations.items.length - b.navigations.items.length,
8837
- // );
8838
- if (defaultZone) {
8839
- zones.push(defaultZone);
8840
- }
8841
- return zones;
8842
- }
8843
- async getZonesForObject(object) {
8844
- if (object.pois.items && object.pois.items.length > 0) {
8845
- const [poi] = object.pois.items;
8846
- if (poi.matterportSweepID) {
8847
- const zones = await this.getZonesForSweepForSpace(poi.matterportSweepID, object.spaceID);
8848
- return zones;
8849
- }
8850
- }
8851
- return null;
8852
- }
8853
- async createVirtualZone(zoneInput) {
8854
- zoneInput.sweepIDs = [];
8855
- const zone = await this.API.__proto__.CreateZone(zoneInput);
8856
- console.log('virtual zone created', zone);
8857
- return zone;
8858
- }
8859
- zoneIsVirtual(zone) {
8860
- if (!zone.sweepIDs) {
8861
- return true;
8862
- }
8863
- if ((zone.tags && zone.tags.includes('virtual')) ||
8864
- zone.sweepIDs.length === 0) {
8865
- return true;
8866
- }
8867
- return false;
8868
- }
8869
- async getModel3DForZone(zoneID) {
8870
- const zone = await this.API.__proto__.GetZone(zoneID);
8871
- if (zone.nodeIDs) {
8872
- const node = await this.API.__proto__.GetNode(zone.nodeIDs[0]);
8873
- return node.model3D;
8874
- }
8875
- return null;
8876
- }
8877
- async getZone(zoneID) {
8878
- return this.API.__proto__.GetZone(zoneID);
8879
- }
8880
- async getDefaultZoneForSpace(spaceID) {
8881
- return this.getZonesBySpace(spaceID).then(async (zones) => {
8882
- const zonesBuilding = zones.filter((zone) => zone.layer && zone.layer.name === 'BUILDING');
8883
- if (zonesBuilding.length === 1) {
8884
- return zonesBuilding[0];
8885
- }
8886
- const parentZone = zones.find((zone) => !zone.parentID);
8887
- return parentZone;
8888
- });
8889
- }
8890
- async create(input) {
8891
- return this.API.__proto__.CreateZone(input);
8892
- }
8893
- // async getSortedZonesForSweepForUser(
8894
- // sweep: string,
8895
- // spaceID: string,
8896
- // ): Promise<Zone[]> {
8897
- // if (!spaceID) {
8898
- // return [];
8899
- // }
8900
- // const zones = await this.getZonesForSweepForSpace(sweep, spaceID);
8901
- // if (zones.length === 0) {
8902
- // return [];
8903
- // }
8904
- // const filteredZones = await this.filterZonesIDsForUser(zones);
8905
- // if (filteredZones.length > 0) {
8906
- // let zonesSorted = this.sortZonesFromChildToParent(filteredZones);
8907
- // zonesSorted = zonesSorted.filter((zone) => zone !== undefined);
8908
- // return zonesSorted;
8909
- // }
8910
- // return [];
8911
- // }
8912
- async updateZone(input) {
8913
- return this.API.__proto__.UpdateZone(input);
11099
+ // eslint-disable-next-line no-param-reassign
11100
+ [newArray[index], newArray[indexNew]] = [
11101
+ newArray[indexNew],
11102
+ newArray[index],
11103
+ ];
8914
11104
  }
8915
- async getZonesForNodeForSpace(nodeID, spaceID) {
8916
- return this.API.__proto__.ZonesBySpace(spaceID).then(async (zones) => {
8917
- const filteredZones = zones.items.filter((zone) => zone.nodeIDs && zone.nodeIDs.includes(nodeID));
8918
- return filteredZones;
8919
- });
11105
+ return newArray;
11106
+ }
11107
+ function numberToDateString(timestamp) {
11108
+ const jsDate = new Date(timestamp);
11109
+ return jsDate.toLocaleString("fr-FR");
11110
+ }
11111
+ function durationToString(duration, translate) {
11112
+ const days = Math.floor(duration / 3600 / 24);
11113
+ const hours = Math.floor((duration - days * 24 * 3600) / 3600);
11114
+ const minutes = Math.floor((duration % 3600) / 60);
11115
+ const seconds = Math.floor(duration % 60);
11116
+ const daysString = translate.instant("day(s)");
11117
+ const hoursString = translate.instant("hour(s)");
11118
+ const oneHourString = translate.instant("hour");
11119
+ const minutesString = translate.instant("minute(s)");
11120
+ const secondsString = translate.instant("second(s)");
11121
+ if (duration > 3600 * 24 * 7) {
11122
+ return `${days} ${daysString}`;
8920
11123
  }
8921
- /**
8922
- * Gets the Zone (layer FLOOR) for a matterport sweep ID in a given Space
8923
- * @param sweepID
8924
- * @param spaceID
8925
- * @returns Zone
8926
- */
8927
- async getFloorZoneForSweepInSpace(sweepID, spaceID) {
8928
- const zones = await this.getZonesForSweepForSpace(sweepID, spaceID);
8929
- const floors = zones.filter((zone) => zone.layer && zone.layer.name.includes('FLOOR'));
8930
- return floors.length > 0 ? floors[0] : null;
11124
+ if (duration > 3600 * 24) {
11125
+ return `${days} ${daysString} ${hours} ${hoursString}`;
8931
11126
  }
8932
- /**
8933
- * Get zones with audio track for entire space
8934
- * @param spaceID
8935
- * @returns Promise<Zone[]>
8936
- */
8937
- async getAudioZonesForSpace(spaceID) {
8938
- const allZones = await this.getZonesBySpace(spaceID);
8939
- return allZones.filter((zone) => zone.audioID);
11127
+ if (duration === 3600) {
11128
+ return `${hours} ${oneHourString}`;
8940
11129
  }
8941
- /**
8942
- * Get audio zones for user (within allowed zones)
8943
- * @param spaceID
8944
- * @returns Promise<Zone[]>
8945
- */
8946
- async getAudioZonesForUserForSpace(spaceID) {
8947
- const userZones = await this.getZonesBySpaceForUser(spaceID);
8948
- return userZones.filter((zone) => zone.audioID);
11130
+ if (duration > 3600) {
11131
+ if (minutes === 0) {
11132
+ return `${hours} ${hoursString}`;
11133
+ }
11134
+ return `${hours} ${hoursString} ${minutes} ${minutesString}`;
8949
11135
  }
8950
- /**
8951
- * Get Zones by Parent Zone ID
8952
- * @param parentZoneID string
8953
- * @returns Promise<Zone[]>
8954
- */
8955
- async getZonesByParentZone(parentZoneID) {
8956
- return this.API.__proto__
8957
- .ZonesByZone(parentZoneID)
8958
- .then((res) => res.items);
11136
+ if (duration >= 60) {
11137
+ if (seconds === 0) {
11138
+ return `${minutes} ${minutesString}`;
11139
+ }
11140
+ return `${minutes} ${minutesString} ${seconds} ${secondsString}`;
8959
11141
  }
8960
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ZoneService, deps: [{ token: 'currentAPIService' }, { token: BaseUserService }], target: i0.ɵɵFactoryTarget.Injectable });
8961
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ZoneService, providedIn: 'root' });
11142
+ return `${seconds} ${secondsString}`;
11143
+ }
11144
+ function pointToString(point) {
11145
+ const x = point.x.toFixed(3);
11146
+ const y = point.y.toFixed(3);
11147
+ const z = point.z.toFixed(3);
11148
+ return `{ x: ${x}, y: ${y}, z: ${z} }`;
8962
11149
  }
8963
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ZoneService, decorators: [{
8964
- type: Injectable,
8965
- args: [{
8966
- providedIn: 'root',
8967
- }]
8968
- }], ctorParameters: () => [{ type: undefined, decorators: [{
8969
- type: Inject,
8970
- args: ['currentAPIService']
8971
- }] }, { type: BaseUserService }] });
8972
11150
 
8973
11151
  /* eslint-disable no-case-declarations */
8974
11152
  /* eslint-disable no-await-in-loop */
@@ -9186,7 +11364,12 @@ class FilterService {
9186
11364
  // visibility filter already reads "no sweep" as "not floor-scoped, show it";
9187
11365
  // this is the same rule applied to zones.
9188
11366
  if (poi && poi.coordinate && !poi.matterportSweepID) {
9189
- filteredObjects.push(object);
11367
+ // ...unless it was placed in a 3D model. With no sweep, that model was never
11368
+ // tied to this tour: the coordinates are the model's own, and here they would
11369
+ // put the tag somewhere arbitrary.
11370
+ if (!getThreeJsPoiOrigin(poi)) {
11371
+ filteredObjects.push(object);
11372
+ }
9190
11373
  return;
9191
11374
  }
9192
11375
  if (poi && zone) {
@@ -9581,133 +11764,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
9581
11764
  }]
9582
11765
  }], ctorParameters: () => [{ type: BaseUserService }, { type: ZoneService }, { type: PoiService }] });
9583
11766
 
9584
- class NavigationService {
9585
- userService;
9586
- zoneService;
9587
- API;
9588
- constructor(apiInjected, userService, zoneService) {
9589
- this.userService = userService;
9590
- this.zoneService = zoneService;
9591
- this.API = apiInjected;
9592
- }
9593
- async createNavigation(navInput) {
9594
- return this.API.__proto__.CreateNavigation(navInput);
9595
- }
9596
- async deleteNavigation(id) {
9597
- return this.API.__proto__.DeleteNavigation({ id });
9598
- }
9599
- async getNavigationsForNode(nodeID) {
9600
- const limit = 200;
9601
- let navigationsTokenDict = { navigations: [], nextToken: null };
9602
- do {
9603
- navigationsTokenDict =
9604
- await this.mergeAPIResponseWithNextTokenWithLimit(nodeID, limit, navigationsTokenDict);
9605
- } while (navigationsTokenDict.nextToken);
9606
- return navigationsTokenDict.navigations;
9607
- }
9608
- async mergeAPIResponseWithNextTokenWithLimit(nodeID, limit, navigationsTokenDict) {
9609
- return this.API.__proto__.NavigationsByNode(nodeID, null, null, limit, navigationsTokenDict.nextToken).then((response) => {
9610
- const navigations = response.items;
9611
- const navObject = {
9612
- navigations: [...navigationsTokenDict.navigations, ...navigations],
9613
- nextToken: response.nextToken,
9614
- };
9615
- return navObject;
9616
- });
9617
- }
9618
- async getNavigation(id) {
9619
- return this.API.__proto__.GetNavigation(id);
9620
- }
9621
- async getSweepsIDsForSpace(spaceID) {
9622
- const zones = await this.zoneService.getZonesBySpace(spaceID);
9623
- const zonesWithSweeps = zones.filter((zone) => zone.sweepIDs && zone.sweepIDs.length > 0);
9624
- const allSweeps = zonesWithSweeps.flatMap((zone) => zone.sweepIDs);
9625
- return [...new Set(allSweeps)];
9626
- }
9627
- async getAllowedSweepsIDsForUserInSpace(spaceID) {
9628
- const missionsForSpace = this.userService.currentMissions.filter((mis) => mis.spaceID === spaceID);
9629
- console.log(missionsForSpace);
9630
- let navIDs = [];
9631
- if (this.userService.userHasAccessToAllZonesInSpace(spaceID)) {
9632
- console.log(1);
9633
- return this.getSweepsIDsForSpace(spaceID);
9634
- }
9635
- await Promise.all(missionsForSpace.map(async (mission) => {
9636
- navIDs = [...navIDs, ...mission.zone.sweepIDs];
9637
- }));
9638
- console.log('final navids = ', navIDs);
9639
- return navIDs;
9640
- }
9641
- async getForbiddenNavigationIDsForUserInSpace(spaceID) {
9642
- const allowed = await this.getAllowedSweepsIDsForUserInSpace(spaceID);
9643
- const allNavs = await this.getSweepsIDsForSpace(spaceID);
9644
- const difference = allNavs.filter((x) => !allowed.includes(x));
9645
- return difference.length > 0 ? difference : null;
9646
- }
9647
- async getNavigationsForMatterportIDForSpace(matterportID, spaceID) {
9648
- return this.API.__proto__.NavigationsByMatterportIDBySpace(matterportID, {
9649
- eq: spaceID,
9650
- }).then((navs) => navs.items);
9651
- }
9652
- async getNavigationsForZone(zone) {
9653
- const responseNavs = [];
9654
- await Promise.all(zone.sweepIDs.map(async (sweepID) => {
9655
- const navs = await this.getNavigationsForMatterportIDForSpace(sweepID, zone.spaceID);
9656
- if (navs.length > 0) {
9657
- responseNavs.push(navs[0]);
9658
- }
9659
- }));
9660
- return responseNavs;
9661
- }
9662
- async updateNavigation(navInput) {
9663
- return this.API.__proto__.UpdateNavigation(navInput);
9664
- }
9665
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NavigationService, deps: [{ token: 'currentAPIService' }, { token: BaseUserService }, { token: ZoneService }], target: i0.ɵɵFactoryTarget.Injectable });
9666
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NavigationService, providedIn: "root" });
9667
- }
9668
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NavigationService, decorators: [{
9669
- type: Injectable,
9670
- args: [{
9671
- providedIn: "root",
9672
- }]
9673
- }], ctorParameters: () => [{ type: undefined, decorators: [{
9674
- type: Inject,
9675
- args: ['currentAPIService']
9676
- }] }, { type: BaseUserService }, { type: ZoneService }] });
9677
-
9678
- class CaptureService {
9679
- API; //AWS
9680
- constructor(apiInjected) {
9681
- this.API = apiInjected;
9682
- }
9683
- async getCapturesBySpace(spaceID) {
9684
- return this.API.CapturesBySpace(spaceID).then((cap) => cap.items);
9685
- }
9686
- async getCapture(captureID) {
9687
- return this.API.__proto__.GetCapture(captureID);
9688
- }
9689
- async deleteCapture(captureID) {
9690
- return this.API.__proto__.DeleteCapture({ id: captureID });
9691
- }
9692
- async updateCapture(capture) {
9693
- return this.API.__proto__.UpdateCapture(capture);
9694
- }
9695
- async createCapture(capture) {
9696
- return this.API.__proto__.CreateCapture(capture);
9697
- }
9698
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CaptureService, deps: [{ token: 'currentAPIService' }], target: i0.ɵɵFactoryTarget.Injectable });
9699
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CaptureService, providedIn: "root" });
9700
- }
9701
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CaptureService, decorators: [{
9702
- type: Injectable,
9703
- args: [{
9704
- providedIn: "root",
9705
- }]
9706
- }], ctorParameters: () => [{ type: undefined, decorators: [{
9707
- type: Inject,
9708
- args: ['currentAPIService']
9709
- }] }] });
9710
-
9711
11767
  class NodeService {
9712
11768
  API;
9713
11769
  constructor(apiInjected) {
@@ -11043,7 +13099,7 @@ class BaseObjectService {
11043
13099
  // is no sweep to resolve a visit from, which is why this used to report the model
11044
13100
  // as unreachable. The space's 3D visit is the one that can show it.
11045
13101
  if (poi && poi.coordinate && !poi.matterportSweepID) {
11046
- const visit = await this.getThreeJsVisitForSpace(object.spaceID);
13102
+ const visit = await this.getThreeJsVisitForSpace(object.spaceID, getThreeJsPoiOrigin(poi)?.captureID);
11047
13103
  return visit
11048
13104
  ? { model3D: visit.model3d, sweep: null, rotation: null }
11049
13105
  : null;
@@ -11079,15 +13135,15 @@ class BaseObjectService {
11079
13135
  });
11080
13136
  }
11081
13137
  /**
11082
- * The space's 3D visit, or null when it has none.
13138
+ * The 3D visit to show a POI in, or null when the space has none.
11083
13139
  *
11084
- * Takes the first one the database returns. That is not the space's chosen default - the
11085
- * default ordering lives in SpaceService, which cannot be injected here. The POI does not record
11086
- * which capture it was placed in, so with several 3D models this is a guess either way.
13140
+ * The one the POI was placed in, when it says. Older POIs do not, and for those it is the first
13141
+ * 3D visit the database returns - a guess, when the space has several.
11087
13142
  */
11088
- async getThreeJsVisitForSpace(spaceID) {
11089
- const visits = await this.visitService.getVisitsForSpaceForUser(spaceID, false);
11090
- return (visits ?? []).find((visit) => visit.viewer === CaptureViewer.THREEJS) ?? null;
13143
+ async getThreeJsVisitForSpace(spaceID, captureID) {
13144
+ const visits = (await this.visitService.getVisitsForSpaceForUser(spaceID, false) ?? [])
13145
+ .filter((visit) => visit.viewer === CaptureViewer.THREEJS);
13146
+ return visits.find((visit) => visit.captureID === captureID) ?? visits[0] ?? null;
11091
13147
  }
11092
13148
  async updateIconForAllObjects(objects, tagIcon) {
11093
13149
  const IDs = [];
@@ -12483,7 +14539,7 @@ class EquipmentService extends BaseObjectService {
12483
14539
  user: this.userService.cu,
12484
14540
  locationId: this.currentSpaceID,
12485
14541
  timestamp: new Date(),
12486
- zoneId: this.currentZone.id,
14542
+ zoneId: this.currentZone?.id,
12487
14543
  floorId: this.selectedFloor?.id,
12488
14544
  },
12489
14545
  streamName: 'smarterplanKinesis-analytics',
@@ -12947,6 +15003,11 @@ class EquipmentService extends BaseObjectService {
12947
15003
  }
12948
15004
  updateDueToCreate(createdEquip) {
12949
15005
  this.currentEquipments.space.unshift(createdEquip);
15006
+ // initEquips only records a zone's list when it has a zone or found equipments, so the first
15007
+ // equipment of a space viewed without zones (Three.js) arrives with no list to join.
15008
+ if (!this.currentEquipments.zonesMap.has(this.zoneIDFilter)) {
15009
+ this.currentEquipments.zonesMap.set(this.zoneIDFilter, []);
15010
+ }
12950
15011
  this.currentEquipments.zonesMap
12951
15012
  .get(this.zoneIDFilter)
12952
15013
  .unshift(createdEquip);
@@ -13020,6 +15081,26 @@ class EquipmentService extends BaseObjectService {
13020
15081
  }
13021
15082
  }
13022
15083
  });
15084
+ // The Three.js viewer shows a whole space at once, with no zone to navigate into, so it
15085
+ // announces itself here instead of through `zoneChange`. Leaving the zone filter empty is what
15086
+ // makes every equipment of the space show up.
15087
+ this.zoneChangeService.spaceWithoutZones
15088
+ .pipe(takeUntil(this.destroy$))
15089
+ .subscribe((spaceID) => {
15090
+ this.currentSpaceID = spaceID;
15091
+ this.zoneIDFilter = null;
15092
+ this.currentZone = null;
15093
+ this.currentEquipments = {
15094
+ space: [],
15095
+ zonesMap: new Map(),
15096
+ };
15097
+ if (!spaceID) {
15098
+ this.equipmentsTags.next(null);
15099
+ this.equipmentsUpdated.next({ space: [], zone: null });
15100
+ return;
15101
+ }
15102
+ this.initEquips();
15103
+ });
13023
15104
  this.deleteObservable
13024
15105
  .pipe(takeUntil(this.destroy$))
13025
15106
  .subscribe((equipment) => {
@@ -13994,6 +16075,19 @@ class FeatureService extends BaseObjectService {
13994
16075
  this.initFeatures();
13995
16076
  }
13996
16077
  });
16078
+ // The Three.js viewer shows a whole space at once, with no zone to navigate into, so it
16079
+ // announces itself here instead of through `zoneChange`.
16080
+ this.zoneChangeService.spaceWithoutZones.subscribe((spaceID) => {
16081
+ this.currentSpaceID = spaceID;
16082
+ this.zoneIDFilter = null;
16083
+ if (!spaceID) {
16084
+ this.featureTags.next(null);
16085
+ this.featureUpdated.next({ space: [], zone: null });
16086
+ }
16087
+ else if (!this.userService.isMuseumUser(spaceID)) {
16088
+ this.initFeatures();
16089
+ }
16090
+ });
13997
16091
  this.updateDone.subscribe(() => {
13998
16092
  if (this.currentSpaceID) {
13999
16093
  this.initFeatures();
@@ -14384,6 +16478,19 @@ class MeasurementService {
14384
16478
  this.initMeasurements();
14385
16479
  }
14386
16480
  });
16481
+ // The Three.js viewer shows a whole space at once, with no zone to navigate into, so it
16482
+ // announces itself here instead of through `zoneChange`.
16483
+ this.zoneChangeService.spaceWithoutZones.subscribe((spaceID) => {
16484
+ this.currentSpaceID = spaceID;
16485
+ this.zoneIDFilter = null;
16486
+ if (!spaceID) {
16487
+ this.measurementsTags.next(null);
16488
+ this.measurementsUpdated.next({ space: [], zone: null });
16489
+ this.measurementFilter = null;
16490
+ return;
16491
+ }
16492
+ this.initMeasurements();
16493
+ });
14387
16494
  this.filterService.subscribeToDataFilterUpdate((dateRange) => {
14388
16495
  this.dateFilter = dateRange;
14389
16496
  this.initMeasurements();
@@ -14460,6 +16567,9 @@ class MeasurementService {
14460
16567
  type: PoiType.MEASURE,
14461
16568
  elementID: comment.id,
14462
16569
  matterportSweepID: sweepID,
16570
+ // Only the Three.js viewer sends this: it records which 3D model the measurement was taken
16571
+ // in, the same as createPoiForThreeJsPlacement does for the other tags.
16572
+ ...(dataMeasurements.metadata ? { metadata: dataMeasurements.metadata } : {}),
14463
16573
  };
14464
16574
  const poi = await this.poiService.createPOIForSpaceID(poiInput);
14465
16575
  const screen = await fetch(screenshot);
@@ -19306,5 +21416,5 @@ function floatValidator() {
19306
21416
  * Generated bundle index. Do not edit.
19307
21417
  */
19308
21418
 
19309
- 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, 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, 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 };
21419
+ 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 };
19310
21420
  //# sourceMappingURL=smarterplan-ngx-smarterplan-core.mjs.map