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