@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.
- package/esm2022/lib/helpers.service.mjs +28 -2
- package/esm2022/lib/matterport-extensions/security-camera/SecurityCamera.mjs +28 -7
- package/esm2022/lib/services/filter.service.mjs +8 -3
- package/esm2022/lib/services/matterport-object3d.service.mjs +657 -133
- package/esm2022/lib/services/matterport.service.mjs +330 -9
- package/esm2022/lib/services/models/base-object.service.mjs +10 -9
- package/esm2022/lib/services/models/equipment.service.mjs +27 -2
- package/esm2022/lib/services/models/feature.service.mjs +14 -1
- package/esm2022/lib/services/models/measurement.service.mjs +17 -1
- package/esm2022/lib/services/sweep-locator.service.mjs +114 -0
- package/esm2022/lib/services/threejs.service.mjs +840 -35
- package/esm2022/lib/services/utils/matterport-framing.util.mjs +63 -0
- package/esm2022/lib/services/viewer.service.mjs +193 -10
- package/esm2022/lib/types.service.mjs +3 -1
- package/esm2022/public-api.mjs +2 -1
- package/fesm2022/smarterplan-ngx-smarterplan-core.mjs +2886 -776
- package/fesm2022/smarterplan-ngx-smarterplan-core.mjs.map +1 -1
- package/lib/helpers.service.d.ts +17 -0
- package/lib/services/matterport-object3d.service.d.ts +125 -5
- package/lib/services/matterport.service.d.ts +106 -7
- package/lib/services/models/base-object.service.d.ts +3 -4
- package/lib/services/models/measurement.service.d.ts +1 -0
- package/lib/services/sweep-locator.service.d.ts +54 -0
- package/lib/services/threejs.service.d.ts +304 -10
- package/lib/services/utils/matterport-framing.util.d.ts +40 -0
- package/lib/services/viewer.service.d.ts +87 -2
- package/lib/types.service.d.ts +42 -1
- package/package.json +1 -1
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
746
|
-
const roomMesh = floorMesh
|
|
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
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
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
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
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
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
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
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
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
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
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
|
-
|
|
3646
|
-
|
|
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
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
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
|
-
|
|
3658
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5757
|
-
|
|
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
|
-
|
|
5760
|
-
|
|
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
|
-
|
|
5788
|
-
|
|
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
|
-
|
|
5794
|
-
|
|
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
|
-
*
|
|
5800
|
-
*
|
|
5801
|
-
*
|
|
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
|
-
*
|
|
5805
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
8006
|
+
const hit = this.raycaster.intersectObjects(this.calibrationCandidates(), false)[0];
|
|
6768
8007
|
if (!hit) {
|
|
6769
8008
|
return;
|
|
6770
8009
|
}
|
|
6771
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
6798
|
-
|
|
6799
|
-
|
|
6800
|
-
|
|
6801
|
-
|
|
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
|
-
/**
|
|
6807
|
-
|
|
6808
|
-
|
|
6809
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
6820
|
-
*
|
|
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
|
-
|
|
6823
|
-
|
|
6824
|
-
|
|
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.
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
|
|
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
|
-
|
|
8137
|
+
sizeAttenuation: false,
|
|
6836
8138
|
}));
|
|
6837
|
-
|
|
6838
|
-
|
|
6839
|
-
|
|
6840
|
-
|
|
6841
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
6855
|
-
*
|
|
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
|
-
|
|
6858
|
-
|
|
6859
|
-
|
|
6860
|
-
|
|
6861
|
-
|
|
6862
|
-
|
|
6863
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
7259
|
-
if (this.
|
|
7260
|
-
const placement = this.pickPlacementOnModel(event);
|
|
7261
|
-
if (placement) {
|
|
7262
|
-
|
|
7263
|
-
this.
|
|
7264
|
-
|
|
7265
|
-
|
|
7266
|
-
|
|
7267
|
-
|
|
7268
|
-
|
|
7269
|
-
|
|
7270
|
-
|
|
7271
|
-
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
7275
|
-
|
|
7276
|
-
|
|
7277
|
-
|
|
7278
|
-
return;
|
|
7279
|
-
}
|
|
7280
|
-
|
|
7281
|
-
|
|
7282
|
-
|
|
7283
|
-
|
|
7284
|
-
|
|
7285
|
-
|
|
7286
|
-
}
|
|
7287
|
-
|
|
7288
|
-
|
|
7289
|
-
|
|
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
|
-
*
|
|
9467
|
+
* The sweep nearest to a point, or null when the space has none within reach.
|
|
7295
9468
|
*
|
|
7296
|
-
*
|
|
7297
|
-
*
|
|
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
|
-
|
|
7300
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
7323
|
-
|
|
7324
|
-
|
|
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
|
-
|
|
7329
|
-
|
|
7330
|
-
|
|
7331
|
-
|
|
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
|
-
|
|
7334
|
-
|
|
7335
|
-
|
|
7336
|
-
|
|
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
|
-
|
|
7339
|
-
|
|
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:
|
|
7343
|
-
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
7845
|
-
|
|
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
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
8916
|
-
|
|
8917
|
-
|
|
8918
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8943
|
-
|
|
8944
|
-
|
|
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
|
-
|
|
8952
|
-
|
|
8953
|
-
|
|
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
|
-
|
|
8961
|
-
|
|
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
|
-
|
|
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
|
|
13138
|
+
* The 3D visit to show a POI in, or null when the space has none.
|
|
11083
13139
|
*
|
|
11084
|
-
*
|
|
11085
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|