@smarterplan/ngx-smarterplan-core 1.4.12 → 1.4.14
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/pipes/hashtag-from-id.pipe.mjs +26 -7
- package/esm2022/lib/services/matterport-tag-copy.service.mjs +838 -195
- package/esm2022/lib/services/matterport-tag.service.mjs +169 -60
- package/esm2022/lib/services/matterport.service.mjs +222 -39
- package/esm2022/lib/services/models/comment.service.mjs +35 -1
- package/esm2022/lib/services/models/equipment.service.mjs +4 -2
- package/esm2022/lib/services/models/feature.service.mjs +26 -1
- package/esm2022/lib/services/models/hashtag.service.mjs +29 -2
- package/esm2022/lib/services/s3.service.mjs +29 -1
- package/esm2022/lib/services/utils/matterport-transform.util.mjs +29 -3
- package/esm2022/lib/services/viewer.service.mjs +8 -1
- package/esm2022/lib/types.service.mjs +9 -1
- package/fesm2022/smarterplan-ngx-smarterplan-core.mjs +1407 -298
- package/fesm2022/smarterplan-ngx-smarterplan-core.mjs.map +1 -1
- package/lib/pipes/hashtag-from-id.pipe.d.ts +9 -0
- package/lib/services/matterport-tag-copy.service.d.ts +196 -15
- package/lib/services/matterport-tag.service.d.ts +61 -7
- package/lib/services/matterport.service.d.ts +56 -1
- package/lib/services/models/comment.service.d.ts +16 -0
- package/lib/services/models/equipment.service.d.ts +4 -1
- package/lib/services/models/feature.service.d.ts +11 -0
- package/lib/services/models/hashtag.service.d.ts +6 -0
- package/lib/services/s3.service.d.ts +14 -0
- package/lib/services/utils/matterport-transform.util.d.ts +16 -1
- package/lib/types.service.d.ts +18 -0
- package/package.json +1 -1
|
@@ -426,6 +426,14 @@ var SpModule;
|
|
|
426
426
|
SpModule["HOTEL"] = "HOTEL";
|
|
427
427
|
SpModule["IMMO"] = "IMMO";
|
|
428
428
|
})(SpModule || (SpModule = {}));
|
|
429
|
+
var MatterportViewType;
|
|
430
|
+
(function (MatterportViewType) {
|
|
431
|
+
MatterportViewType["BASE"] = "viewtype.base";
|
|
432
|
+
MatterportViewType["DEFURNISH"] = "viewtype.defurnish";
|
|
433
|
+
MatterportViewType["LAYERED_BASE"] = "viewtype.layeredbase";
|
|
434
|
+
MatterportViewType["OTHER"] = "viewtype.other";
|
|
435
|
+
MatterportViewType["USER"] = "viewtype.user";
|
|
436
|
+
})(MatterportViewType || (MatterportViewType = {}));
|
|
429
437
|
|
|
430
438
|
/**
|
|
431
439
|
* The base of all `SceneComponent` created with the sdk.
|
|
@@ -3407,6 +3415,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
|
|
|
3407
3415
|
|
|
3408
3416
|
class MatterportTagService {
|
|
3409
3417
|
navigationService;
|
|
3418
|
+
/** Name of the common layer every Smarterplan tag is created in. */
|
|
3419
|
+
static SHARED_TAG_LAYER_NAME = 'smarterplan-shared-tags';
|
|
3410
3420
|
dictionnaryTags = new Map();
|
|
3411
3421
|
tagAddQueue = Promise.resolve();
|
|
3412
3422
|
mattertagToFollow = null;
|
|
@@ -3417,6 +3427,22 @@ class MatterportTagService {
|
|
|
3417
3427
|
tagService = null;
|
|
3418
3428
|
tagsCurrentOpacity = new Map();
|
|
3419
3429
|
tagsCurrentEnabled = new Map();
|
|
3430
|
+
/**
|
|
3431
|
+
* Single layer holding every Smarterplan tag, added to every view of the space so a
|
|
3432
|
+
* tag stays in place — same sid, same content — when the user switches between the
|
|
3433
|
+
* furnished and the Defurnished view. Tags added to the global sdk.Tag (or to a
|
|
3434
|
+
* view's own layer) belong to that view only, which is what made them pop in and out
|
|
3435
|
+
* on every view change.
|
|
3436
|
+
*
|
|
3437
|
+
* Deliberately NOT created with `{ common: true }`: in SDK 26.7.5 a common layer is
|
|
3438
|
+
* pushed into `getOrderedModelViews()` at creation time, and that call defaults to
|
|
3439
|
+
* `includeDefurnish: false`, so the Defurnished view never receives it — while
|
|
3440
|
+
* `View.addLayer()` refuses to repair it ("Layer is common. No need to add."). A
|
|
3441
|
+
* plain layer we attach to each view ourselves reaches every view, Defurnished
|
|
3442
|
+
* included. See attachSharedTagLayerToViews().
|
|
3443
|
+
*/
|
|
3444
|
+
sharedTagLayer = null;
|
|
3445
|
+
sharedTagLayerPromise = null;
|
|
3420
3446
|
constructor(navigationService) {
|
|
3421
3447
|
this.navigationService = navigationService;
|
|
3422
3448
|
}
|
|
@@ -3429,6 +3455,7 @@ class MatterportTagService {
|
|
|
3429
3455
|
return;
|
|
3430
3456
|
try {
|
|
3431
3457
|
this.onGoToTag.next(sid);
|
|
3458
|
+
// `open` only exists on the global Tag module, never on a layer-scoped one.
|
|
3432
3459
|
await sdk.Tag.open(sid);
|
|
3433
3460
|
}
|
|
3434
3461
|
catch (e) {
|
|
@@ -3436,6 +3463,102 @@ class MatterportTagService {
|
|
|
3436
3463
|
console.warn('Cannot open tag', sid, e);
|
|
3437
3464
|
}
|
|
3438
3465
|
}
|
|
3466
|
+
/**
|
|
3467
|
+
* Returns the SDK Tag module to use for an operation addressing an existing tag by ID
|
|
3468
|
+
* (icon, opacity, position, attachments, removal...): always the global sdk.Tag.
|
|
3469
|
+
*
|
|
3470
|
+
* A layer-scoped Tag module only exposes the *replayable* mutations, meant to build
|
|
3471
|
+
* the layer's own content; applying them to an already-created tag silently does
|
|
3472
|
+
* nothing — which is what left every pin white instead of carrying its icon. Only
|
|
3473
|
+
* `add` has to go through the layer, see getTagCreationModule().
|
|
3474
|
+
*/
|
|
3475
|
+
getTagModule(sdk, tagId) {
|
|
3476
|
+
return sdk?.Tag;
|
|
3477
|
+
}
|
|
3478
|
+
/**
|
|
3479
|
+
* Returns the Tag module every new tag must be created through: the shared layer
|
|
3480
|
+
* when the View API is available, the global sdk.Tag otherwise.
|
|
3481
|
+
*/
|
|
3482
|
+
async getTagCreationModule(sdk) {
|
|
3483
|
+
const layer = await this.ensureSharedTagLayer(sdk);
|
|
3484
|
+
return layer?.Tag || sdk?.Tag;
|
|
3485
|
+
}
|
|
3486
|
+
/**
|
|
3487
|
+
* Creates (once per SDK session) and returns the shared tag layer.
|
|
3488
|
+
* Resolves to null when the View API is unavailable, so callers transparently fall
|
|
3489
|
+
* back to the global sdk.Tag.
|
|
3490
|
+
*/
|
|
3491
|
+
async ensureSharedTagLayer(sdk) {
|
|
3492
|
+
if (this.sharedTagLayer)
|
|
3493
|
+
return this.sharedTagLayer;
|
|
3494
|
+
if (!sdk?.View?.createLayer)
|
|
3495
|
+
return null;
|
|
3496
|
+
if (!this.sharedTagLayerPromise) {
|
|
3497
|
+
this.sharedTagLayerPromise = Promise.resolve(sdk.View.createLayer(MatterportTagService.SHARED_TAG_LAYER_NAME))
|
|
3498
|
+
.then(async (layer) => {
|
|
3499
|
+
this.sharedTagLayer = layer;
|
|
3500
|
+
// A freshly created layer can come back toggled off, which hides its tags.
|
|
3501
|
+
if (layer && layer.toggled === false) {
|
|
3502
|
+
await layer.toggle(true).catch(() => { });
|
|
3503
|
+
}
|
|
3504
|
+
return layer;
|
|
3505
|
+
})
|
|
3506
|
+
.catch((e) => {
|
|
3507
|
+
// Allow a later retry (e.g. the SDK was not fully ready yet).
|
|
3508
|
+
this.sharedTagLayerPromise = null;
|
|
3509
|
+
if (isDevMode())
|
|
3510
|
+
console.warn('[MatterportTagService] Could not create the shared tag layer', e);
|
|
3511
|
+
return null;
|
|
3512
|
+
});
|
|
3513
|
+
}
|
|
3514
|
+
return this.sharedTagLayerPromise;
|
|
3515
|
+
}
|
|
3516
|
+
/**
|
|
3517
|
+
* Makes sure the shared layer is part of every view of the space, Defurnished
|
|
3518
|
+
* included. This is what actually shares the tags across views, and the reason the
|
|
3519
|
+
* layer must not be a common one: the SDK only performs the attachment for
|
|
3520
|
+
* non-common layers. Idempotent, so it can run on every view collection update.
|
|
3521
|
+
*/
|
|
3522
|
+
async attachSharedTagLayerToViews(sdk, views) {
|
|
3523
|
+
const layer = await this.ensureSharedTagLayer(sdk);
|
|
3524
|
+
if (!layer)
|
|
3525
|
+
return;
|
|
3526
|
+
for (const view of views) {
|
|
3527
|
+
if (!view?.addLayer || view.hasLayer?.(layer))
|
|
3528
|
+
continue;
|
|
3529
|
+
try {
|
|
3530
|
+
await view.addLayer(layer);
|
|
3531
|
+
}
|
|
3532
|
+
catch (e) {
|
|
3533
|
+
if (isDevMode())
|
|
3534
|
+
console.warn('[MatterportTagService] Cannot add the shared tag layer to view', view?.id, e);
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
/**
|
|
3539
|
+
* Adds a tag through the shared layer so it exists in every view. Falls back
|
|
3540
|
+
* to the global sdk.Tag when the View API is unavailable; that call is raced against
|
|
3541
|
+
* a timeout as a safety net, since it never resolves while a Defurnished view is
|
|
3542
|
+
* active.
|
|
3543
|
+
*/
|
|
3544
|
+
async addTagToSdk(sdk, descriptor) {
|
|
3545
|
+
const sharedTagModule = await this.getTagCreationModule(sdk);
|
|
3546
|
+
try {
|
|
3547
|
+
const sidList = (await Promise.race([
|
|
3548
|
+
sharedTagModule.add(descriptor),
|
|
3549
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Tag.add timed out')), 4000)),
|
|
3550
|
+
]));
|
|
3551
|
+
if (sidList?.length)
|
|
3552
|
+
return sidList[0];
|
|
3553
|
+
}
|
|
3554
|
+
catch (error) {
|
|
3555
|
+
if (error?.message?.includes('already in use'))
|
|
3556
|
+
throw error;
|
|
3557
|
+
if (isDevMode())
|
|
3558
|
+
console.warn('[MatterportTagService] Tag.add failed/timed out', error?.message);
|
|
3559
|
+
}
|
|
3560
|
+
return null;
|
|
3561
|
+
}
|
|
3439
3562
|
/** Opens the most recently created tag in the viewer. */
|
|
3440
3563
|
async goToLastTag(sdk) {
|
|
3441
3564
|
if (!sdk || this.mattertagIDs.length === 0)
|
|
@@ -3450,36 +3573,40 @@ class MatterportTagService {
|
|
|
3450
3573
|
async addMattertagToViewer(sdk, mattertagData) {
|
|
3451
3574
|
if (!sdk)
|
|
3452
3575
|
return null;
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
console.log('sweep id: ', mattertagData.getSweepID());
|
|
3458
|
-
console.log('---------------');
|
|
3459
|
-
this.tagAddQueue = this.tagAddQueue.then(async () => {
|
|
3460
|
-
const data = mattertagData.getData();
|
|
3461
|
-
const existingId = data?.id || null;
|
|
3576
|
+
this.tagAddQueue = this.tagAddQueue
|
|
3577
|
+
.then(async () => {
|
|
3578
|
+
const descriptor = mattertagData.getMattertagDescriptor()[0];
|
|
3579
|
+
const existingId = descriptor?.id || null;
|
|
3462
3580
|
try {
|
|
3463
|
-
const
|
|
3464
|
-
if (
|
|
3465
|
-
|
|
3466
|
-
this.mattertagIDs.
|
|
3467
|
-
|
|
3468
|
-
|
|
3581
|
+
const sid = await this.addTagToSdk(sdk, descriptor);
|
|
3582
|
+
if (sid) {
|
|
3583
|
+
mattertagData.setMatterportSid(sid);
|
|
3584
|
+
if (!this.mattertagIDs.includes(sid)) {
|
|
3585
|
+
this.mattertagIDs.push(sid);
|
|
3586
|
+
}
|
|
3587
|
+
this.dictionnaryTags.set(sid, mattertagData);
|
|
3588
|
+
return sid;
|
|
3469
3589
|
}
|
|
3590
|
+
if (isDevMode())
|
|
3591
|
+
console.warn('[MatterportTagService] Tag.add returned no sid', descriptor);
|
|
3470
3592
|
}
|
|
3471
3593
|
catch (error) {
|
|
3472
3594
|
if (error?.message?.includes('already in use') && existingId) {
|
|
3473
3595
|
if (!this.dictionnaryTags.has(existingId)) {
|
|
3474
3596
|
this.mattertagIDs.push(existingId);
|
|
3475
|
-
this.dictionnaryTags.set(existingId, mattertagData);
|
|
3476
3597
|
}
|
|
3598
|
+
this.dictionnaryTags.set(existingId, mattertagData);
|
|
3477
3599
|
return existingId;
|
|
3478
3600
|
}
|
|
3479
3601
|
if (isDevMode())
|
|
3480
|
-
console.warn('
|
|
3602
|
+
console.warn('[MatterportTagService] addMattertagToViewer failed', error);
|
|
3481
3603
|
}
|
|
3482
3604
|
return null;
|
|
3605
|
+
})
|
|
3606
|
+
.catch((e) => {
|
|
3607
|
+
if (isDevMode())
|
|
3608
|
+
console.warn('[MatterportTagService] tagAddQueue rejected', e);
|
|
3609
|
+
return null;
|
|
3483
3610
|
});
|
|
3484
3611
|
return this.tagAddQueue;
|
|
3485
3612
|
}
|
|
@@ -3498,7 +3625,8 @@ class MatterportTagService {
|
|
|
3498
3625
|
}
|
|
3499
3626
|
this.tagAddQueue = this.tagAddQueue.then(async () => {
|
|
3500
3627
|
try {
|
|
3501
|
-
const
|
|
3628
|
+
const tagModule = await this.getTagCreationModule(sdk);
|
|
3629
|
+
const [createdSid] = await tagModule.add(...descriptor);
|
|
3502
3630
|
this.mattertagIDs.push(createdSid);
|
|
3503
3631
|
this.dictionnaryTags.set(createdSid, mattertagData);
|
|
3504
3632
|
await wait(100);
|
|
@@ -3545,36 +3673,6 @@ class MatterportTagService {
|
|
|
3545
3673
|
tagPos.z >= min.z - margin && tagPos.z <= max.z + margin);
|
|
3546
3674
|
});
|
|
3547
3675
|
}
|
|
3548
|
-
/**
|
|
3549
|
-
* Computes the final context-aware opacity for a single tag.
|
|
3550
|
-
* @param currentFloorSequence Authoritative current floor sequence from sdk.Floor.current
|
|
3551
|
-
* (null = single-floor model or unknown).
|
|
3552
|
-
* @param currentRooms Array of active rooms the camera is in.
|
|
3553
|
-
*/
|
|
3554
|
-
/*computeSingleTagContextOpacity(
|
|
3555
|
-
mattertagData: MattertagData,
|
|
3556
|
-
poseCamera: any,
|
|
3557
|
-
sweepCollection: any,
|
|
3558
|
-
currentFloorSequence: number | null,
|
|
3559
|
-
currentRooms: any[]
|
|
3560
|
-
): number {
|
|
3561
|
-
const mode = this.navigationService.currentCameraMode;
|
|
3562
|
-
|
|
3563
|
-
// 1. Check Floor
|
|
3564
|
-
const tagFloorId = this.getFloorIdForTag(mattertagData, sweepCollection);
|
|
3565
|
-
const currentFloorId = currentFloorSequence?.toString();
|
|
3566
|
-
if (tagFloorId && currentFloorId && tagFloorId !== currentFloorId) {
|
|
3567
|
-
return 0;
|
|
3568
|
-
}
|
|
3569
|
-
|
|
3570
|
-
// 2. Si Dollhouse -> Pleine opacité
|
|
3571
|
-
//if (mode !== CameraMode.INSIDE) return 1;
|
|
3572
|
-
|
|
3573
|
-
// 3. Si Inside -> Check pièce + Distance progressive
|
|
3574
|
-
//if (!this.isTagInRooms(mattertagData, currentRooms)) return 0;
|
|
3575
|
-
|
|
3576
|
-
return 1;
|
|
3577
|
-
}*/
|
|
3578
3676
|
/** Returns the ID of the most recently added tag. */
|
|
3579
3677
|
getLastTag() {
|
|
3580
3678
|
if (this.mattertagIDs.length === 0)
|
|
@@ -3624,10 +3722,11 @@ class MatterportTagService {
|
|
|
3624
3722
|
}
|
|
3625
3723
|
// Apply changes
|
|
3626
3724
|
// Tooltip and click management
|
|
3725
|
+
const tagModule = this.getTagModule(sdk, tagId);
|
|
3627
3726
|
const lastEnabled = this.tagsCurrentEnabled.get(tagId);
|
|
3628
3727
|
if (lastEnabled !== shouldBeEnabled) {
|
|
3629
3728
|
this.tagsCurrentEnabled.set(tagId, shouldBeEnabled);
|
|
3630
|
-
promises.push(
|
|
3729
|
+
promises.push(tagModule.allowAction(tagId, {
|
|
3631
3730
|
opening: shouldBeEnabled,
|
|
3632
3731
|
navigating: shouldBeEnabled,
|
|
3633
3732
|
docking: false
|
|
@@ -3637,16 +3736,26 @@ class MatterportTagService {
|
|
|
3637
3736
|
const lastOpacity = this.tagsCurrentOpacity.get(tagId) ?? -1;
|
|
3638
3737
|
if (Math.abs(lastOpacity - targetOpacity) > 0.05) {
|
|
3639
3738
|
this.tagsCurrentOpacity.set(tagId, targetOpacity);
|
|
3640
|
-
promises.push(
|
|
3739
|
+
promises.push(tagModule.editOpacity(tagId, targetOpacity).catch(() => { }));
|
|
3641
3740
|
}
|
|
3642
3741
|
}
|
|
3643
3742
|
await Promise.all(promises);
|
|
3644
3743
|
}
|
|
3744
|
+
/**
|
|
3745
|
+
* Drops the memoized opacity/enabled state so the next visibility pass re-applies it
|
|
3746
|
+
* to every tag. Needed after a view switch (furnished <-> defurnished): the SDK
|
|
3747
|
+
* re-renders the shared layer's tags and the cached values would otherwise make
|
|
3748
|
+
* updateTagsVisibilityForContext() skip them, leaving tags stuck invisible.
|
|
3749
|
+
*/
|
|
3750
|
+
resetVisibilityCache() {
|
|
3751
|
+
this.tagsCurrentOpacity.clear();
|
|
3752
|
+
this.tagsCurrentEnabled.clear();
|
|
3753
|
+
}
|
|
3645
3754
|
/** Removes a specific tag from the viewer and local dictionaries. */
|
|
3646
3755
|
async deleteMattertagFromId(sdk, mattertagID) {
|
|
3647
3756
|
try {
|
|
3648
3757
|
if (sdk)
|
|
3649
|
-
await sdk.
|
|
3758
|
+
await this.getTagModule(sdk, mattertagID).remove(mattertagID);
|
|
3650
3759
|
this.dictionnaryTags.delete(mattertagID);
|
|
3651
3760
|
const index = this.mattertagIDs.indexOf(mattertagID);
|
|
3652
3761
|
if (index > -1)
|
|
@@ -3669,7 +3778,7 @@ class MatterportTagService {
|
|
|
3669
3778
|
const sidsToRemove = [...this.mattertagIDs];
|
|
3670
3779
|
for (const sid of sidsToRemove) {
|
|
3671
3780
|
try {
|
|
3672
|
-
await sdk.
|
|
3781
|
+
await this.getTagModule(sdk, sid).remove(sid);
|
|
3673
3782
|
}
|
|
3674
3783
|
catch (e) { }
|
|
3675
3784
|
}
|
|
@@ -3680,7 +3789,7 @@ class MatterportTagService {
|
|
|
3680
3789
|
* Creates and configures a Mattertag from a POI, handling coordinates, normals, and sweep ID.
|
|
3681
3790
|
* Skips creation if a tag for the object already exists.
|
|
3682
3791
|
*/
|
|
3683
|
-
async createMattertagFromPOI(sdk, tagType, object, poi,
|
|
3792
|
+
async createMattertagFromPOI(sdk, tagType, object, poi, getTagFromElementId, setTagIconAndOpacity, injectHtmlInTag) {
|
|
3684
3793
|
const { tag } = getTagFromElementId(object.id);
|
|
3685
3794
|
if (tag)
|
|
3686
3795
|
return;
|
|
@@ -3742,10 +3851,11 @@ class MatterportTagService {
|
|
|
3742
3851
|
return;
|
|
3743
3852
|
try {
|
|
3744
3853
|
const iconName = `custom-icon-${mattertagID}`;
|
|
3854
|
+
const tagModule = this.getTagModule(sdk, mattertagID);
|
|
3745
3855
|
await sdk.Asset.registerTexture(iconName, iconPath);
|
|
3746
|
-
await
|
|
3856
|
+
await tagModule.editIcon(mattertagID, iconName);
|
|
3747
3857
|
if (applyOpacity) {
|
|
3748
|
-
await
|
|
3858
|
+
await tagModule.editOpacity(mattertagID, opacity);
|
|
3749
3859
|
}
|
|
3750
3860
|
}
|
|
3751
3861
|
catch (error) {
|
|
@@ -3809,16 +3919,17 @@ class MatterportTagService {
|
|
|
3809
3919
|
if (html && html.trim().length > 0 && sdk) {
|
|
3810
3920
|
const scriptTag = this.tagService.getScriptForTag(object, tagType);
|
|
3811
3921
|
html += `${scriptTag}`;
|
|
3922
|
+
const tagModule = this.getTagModule(sdk, tagID);
|
|
3812
3923
|
const [sandboxId, messenger] = await sdk.Tag.registerSandbox(html);
|
|
3813
3924
|
const attachmentID = this.tagsAttachments[tagID];
|
|
3814
3925
|
if (attachmentID) {
|
|
3815
3926
|
try {
|
|
3816
|
-
await
|
|
3927
|
+
await tagModule.detach(tagID, attachmentID);
|
|
3817
3928
|
}
|
|
3818
3929
|
catch (e) { }
|
|
3819
3930
|
}
|
|
3820
3931
|
this.tagsAttachments[tagID] = sandboxId;
|
|
3821
|
-
await
|
|
3932
|
+
await tagModule.attach(tagID, sandboxId);
|
|
3822
3933
|
// Attach the "View informations" handler in tooltip
|
|
3823
3934
|
messenger.on(TagAction.DETAIL_CLICK, this.tagService.onActionDetailClick.bind(this.tagService));
|
|
3824
3935
|
messenger.on(TagAction.TICKET_CLICK, this.tagService.onActionDetailClick.bind(this.tagService));
|
|
@@ -3831,11 +3942,12 @@ class MatterportTagService {
|
|
|
3831
3942
|
else if (sdk) {
|
|
3832
3943
|
const { comment, tagDescription } = this.tagService.getBillboardMediaToEmbed(object);
|
|
3833
3944
|
if (comment) {
|
|
3945
|
+
const tagModule = this.getTagModule(sdk, tagID);
|
|
3834
3946
|
// 1) Register the media attachment by passing the URL string(s).
|
|
3835
3947
|
// registerAttachment returns an array of attachment IDs.
|
|
3836
3948
|
const [attachmentID] = await sdk.Tag.registerAttachment(comment.externalLink);
|
|
3837
3949
|
// 2) Attach the registered media to the tag.
|
|
3838
|
-
await
|
|
3950
|
+
await tagModule.attach(tagID, attachmentID);
|
|
3839
3951
|
// 3) Determine media type for the billboard (PHOTO or VIDEO).
|
|
3840
3952
|
// Use a simple heuristic: YouTube/Vimeo -> VIDEO, otherwise PHOTO.
|
|
3841
3953
|
// The SDK exposes MediaType on the Tag namespace.
|
|
@@ -3843,7 +3955,7 @@ class MatterportTagService {
|
|
|
3843
3955
|
const mediaType = isVideo ? sdk.Mattertag.MediaType.VIDEO : sdk.Mattertag.MediaType.PHOTO;
|
|
3844
3956
|
// 4) Update the billboard text and media using editBillboard.
|
|
3845
3957
|
// editBillboard accepts Partial<Tag.EditableProperties> such as label, description, media.
|
|
3846
|
-
await
|
|
3958
|
+
await tagModule.editBillboard(tagID, {
|
|
3847
3959
|
label: object.title || 'Media',
|
|
3848
3960
|
description: tagDescription || '',
|
|
3849
3961
|
media: {
|
|
@@ -3869,9 +3981,14 @@ class MatterportTagService {
|
|
|
3869
3981
|
/** Clears all local tag data and resets the addition queue. */
|
|
3870
3982
|
clear() {
|
|
3871
3983
|
this.dictionnaryTags.clear();
|
|
3984
|
+
this.tagsCurrentOpacity.clear();
|
|
3985
|
+
this.tagsCurrentEnabled.clear();
|
|
3872
3986
|
this.tagAddQueue = Promise.resolve();
|
|
3873
3987
|
this.mattertagToFollow = null;
|
|
3874
3988
|
this.mattertagIDs = [];
|
|
3989
|
+
// A new space means a new SDK session, hence a new shared layer to create.
|
|
3990
|
+
this.sharedTagLayer = null;
|
|
3991
|
+
this.sharedTagLayerPromise = null;
|
|
3875
3992
|
}
|
|
3876
3993
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagService, deps: [{ token: MatterportNavigationService }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
3877
3994
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagService, providedIn: 'root' });
|
|
@@ -3987,6 +4104,15 @@ class MatterportService {
|
|
|
3987
4104
|
this.navigationService.forbiddenSweeps = value;
|
|
3988
4105
|
}
|
|
3989
4106
|
tagAction = new Subject();
|
|
4107
|
+
/** Views (furnished, defurnished...) exposed by the SDK for the current space. */
|
|
4108
|
+
viewsByType = new Map();
|
|
4109
|
+
currentView = null;
|
|
4110
|
+
viewSubscriptions = [];
|
|
4111
|
+
/** Emits the active view every time the user switches between furnished/defurnished. */
|
|
4112
|
+
onViewChanged = new Subject();
|
|
4113
|
+
/** Delay left to the SDK to rebuild the layer's tags after a view switch. */
|
|
4114
|
+
static VIEW_SWITCH_REFRESH_DELAY_MS = 800;
|
|
4115
|
+
viewSwitchRestoreTimer = null;
|
|
3990
4116
|
get mattertagToFollow() {
|
|
3991
4117
|
return this.matterportTagService.mattertagToFollow;
|
|
3992
4118
|
}
|
|
@@ -4142,7 +4268,9 @@ class MatterportService {
|
|
|
4142
4268
|
default:
|
|
4143
4269
|
break;
|
|
4144
4270
|
}
|
|
4145
|
-
this.sdk.Asset.registerTexture('icon-position', this.config.my_config.icon_position)
|
|
4271
|
+
this.sdk.Asset.registerTexture('icon-position', this.config.my_config.icon_position)
|
|
4272
|
+
.catch((e) => { if (isDevMode())
|
|
4273
|
+
console.warn('icon-position texture registration failed', e); });
|
|
4146
4274
|
// Current Room (used for getting bounding box and eventually lazy load mattertag or object3D inside current Room)
|
|
4147
4275
|
this.sdk.Room.current.subscribe((currentRoom) => {
|
|
4148
4276
|
const rooms = Array.isArray(currentRoom) ? currentRoom : (currentRoom ? [currentRoom] : []);
|
|
@@ -4240,8 +4368,9 @@ class MatterportService {
|
|
|
4240
4368
|
if (!this.sdk || sid === this.mattertagToFollow)
|
|
4241
4369
|
return; // ignore when we are in positioning mode
|
|
4242
4370
|
try {
|
|
4243
|
-
|
|
4244
|
-
|
|
4371
|
+
const tagModule = this.matterportTagService.getTagModule(this.sdk, sid);
|
|
4372
|
+
if (tagModule?.allowAction) {
|
|
4373
|
+
tagModule.allowAction(sid, { opening: true, navigating: true, docking: false });
|
|
4245
4374
|
}
|
|
4246
4375
|
}
|
|
4247
4376
|
catch (e) {
|
|
@@ -4253,7 +4382,11 @@ class MatterportService {
|
|
|
4253
4382
|
};
|
|
4254
4383
|
// Wait for the application to reach PLAYING phase
|
|
4255
4384
|
if (this.sdk.App && this.sdk.App.state) {
|
|
4256
|
-
this.sdk.App.state.waitUntil((s) => s.phase === this.sdk.App.Phase.PLAYING).then(() => {
|
|
4385
|
+
this.sdk.App.state.waitUntil((s) => s.phase === this.sdk.App.Phase.PLAYING).then(async () => {
|
|
4386
|
+
// Create the common layer holding every tag before any tag is added, so that
|
|
4387
|
+
// tags are shared between the furnished and the defurnished views.
|
|
4388
|
+
await this.matterportTagService.ensureSharedTagLayer(this.sdk);
|
|
4389
|
+
this.initViews();
|
|
4257
4390
|
this.sdk.Camera.pose.subscribe((pose) => {
|
|
4258
4391
|
this.poseCamera = pose;
|
|
4259
4392
|
this.matterportTagService.updateTagsVisibilityForContext(this.sdk, this.sweepCollection, this.navigationService.currentCameraMode, this.mattertagToFollow, this.navigationService.currentFloorSequence, this.navigationService.currentRooms);
|
|
@@ -4393,7 +4526,7 @@ class MatterportService {
|
|
|
4393
4526
|
*/
|
|
4394
4527
|
enable_following_tag(mattertag) {
|
|
4395
4528
|
const OFFSET = 0.0001; // quasi-invisible mais suffit à éviter l'interception
|
|
4396
|
-
this.sdk.
|
|
4529
|
+
this.matterportTagService.getTagModule(this.sdk, mattertag).editPosition(mattertag, {
|
|
4397
4530
|
anchorPosition: {
|
|
4398
4531
|
x: this.poseMatterport.position.x + OFFSET,
|
|
4399
4532
|
y: this.poseMatterport.position.y,
|
|
@@ -4420,28 +4553,42 @@ class MatterportService {
|
|
|
4420
4553
|
//
|
|
4421
4554
|
/** Creates a temporary Mattertag that follows the user's cursor for positioning. */
|
|
4422
4555
|
async addCursorMattertag(mattertagData) {
|
|
4556
|
+
if (!this.poseMatterport && this.poseCamera) {
|
|
4557
|
+
this.poseMatterport = {
|
|
4558
|
+
position: { ...this.poseCamera.position },
|
|
4559
|
+
normal: { x: 0, y: -0.15, z: 0 },
|
|
4560
|
+
time: new Date().getTime()
|
|
4561
|
+
};
|
|
4562
|
+
}
|
|
4423
4563
|
if (!this.poseMatterport)
|
|
4424
4564
|
return;
|
|
4425
4565
|
this.mattertagToFollow = await this.addMattertagToViewer(mattertagData);
|
|
4566
|
+
if (!this.mattertagToFollow) {
|
|
4567
|
+
if (isDevMode())
|
|
4568
|
+
console.warn('addCursorMattertag: tag creation failed, mattertagToFollow is null');
|
|
4569
|
+
return;
|
|
4570
|
+
}
|
|
4426
4571
|
await wait(50);
|
|
4427
|
-
this.
|
|
4428
|
-
this.sdk.Tag.editOpacity(this.mattertagToFollow, 1);
|
|
4429
|
-
this.sdk.Tag.editPosition(this.mattertagToFollow, {
|
|
4430
|
-
anchorPosition: { ...this.poseMatterport.position },
|
|
4431
|
-
stemVector: {
|
|
4432
|
-
x: this.poseMatterport.normal.x * 0.3,
|
|
4433
|
-
y: this.poseMatterport.normal.y * 0.3,
|
|
4434
|
-
z: this.poseMatterport.normal.z * 0.3,
|
|
4435
|
-
}
|
|
4436
|
-
});
|
|
4572
|
+
const tagModule = this.matterportTagService.getTagModule(this.sdk, this.mattertagToFollow);
|
|
4437
4573
|
try {
|
|
4438
|
-
await
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
4574
|
+
await tagModule.editIcon(this.mattertagToFollow, 'icon-position');
|
|
4575
|
+
await tagModule.editOpacity(this.mattertagToFollow, 1);
|
|
4576
|
+
await tagModule.editPosition(this.mattertagToFollow, {
|
|
4577
|
+
anchorPosition: { ...this.poseMatterport.position },
|
|
4578
|
+
stemVector: {
|
|
4579
|
+
x: (this.poseMatterport.normal?.x ?? 0) * 0.3,
|
|
4580
|
+
y: (this.poseMatterport.normal?.y ?? -0.15) * 0.3,
|
|
4581
|
+
z: (this.poseMatterport.normal?.z ?? 0) * 0.3,
|
|
4582
|
+
}
|
|
4583
|
+
});
|
|
4584
|
+
await tagModule.allowAction(this.mattertagToFollow, {
|
|
4585
|
+
opening: true, navigating: false, docking: false
|
|
4442
4586
|
});
|
|
4443
4587
|
}
|
|
4444
|
-
catch (e) {
|
|
4588
|
+
catch (e) {
|
|
4589
|
+
if (isDevMode())
|
|
4590
|
+
console.warn('addCursorMattertag: error configuring cursor tag', e);
|
|
4591
|
+
}
|
|
4445
4592
|
}
|
|
4446
4593
|
get tagAddQueue() {
|
|
4447
4594
|
return this.matterportTagService.tagAddQueue;
|
|
@@ -4470,7 +4617,7 @@ class MatterportService {
|
|
|
4470
4617
|
/** Applies a pre-registered icon to a Mattertag. */
|
|
4471
4618
|
async setRegistredIconForTag(mattertagID, iconName) {
|
|
4472
4619
|
try {
|
|
4473
|
-
this.sdk.
|
|
4620
|
+
this.matterportTagService.getTagModule(this.sdk, mattertagID).editIcon(mattertagID, iconName);
|
|
4474
4621
|
}
|
|
4475
4622
|
catch (e) {
|
|
4476
4623
|
if (isDevMode())
|
|
@@ -4488,7 +4635,7 @@ class MatterportService {
|
|
|
4488
4635
|
else {
|
|
4489
4636
|
iconName = `${iconName}_unavailable`;
|
|
4490
4637
|
}
|
|
4491
|
-
this.
|
|
4638
|
+
await this.editTagIcon(mattertagID, iconName);
|
|
4492
4639
|
return;
|
|
4493
4640
|
}
|
|
4494
4641
|
const stringTagType = poiTypeToString(mattertagData.getType());
|
|
@@ -4520,11 +4667,19 @@ class MatterportService {
|
|
|
4520
4667
|
opacity = tagIcon.opacity;
|
|
4521
4668
|
}
|
|
4522
4669
|
}
|
|
4670
|
+
await this.editTagIcon(mattertagID, iconName);
|
|
4671
|
+
}
|
|
4672
|
+
/**
|
|
4673
|
+
* Applies an icon to a tag. The call is awaited and its rejection reported: a
|
|
4674
|
+
* silently dropped promise here leaves the pin with its default white look.
|
|
4675
|
+
*/
|
|
4676
|
+
async editTagIcon(mattertagID, iconName) {
|
|
4523
4677
|
try {
|
|
4524
|
-
this.sdk.
|
|
4678
|
+
await this.matterportTagService.getTagModule(this.sdk, mattertagID).editIcon(mattertagID, iconName);
|
|
4525
4679
|
}
|
|
4526
4680
|
catch (error) {
|
|
4527
|
-
|
|
4681
|
+
if (isDevMode())
|
|
4682
|
+
console.warn('[MatterportService] editIcon failed for', mattertagID, iconName, error);
|
|
4528
4683
|
}
|
|
4529
4684
|
}
|
|
4530
4685
|
/** Navigates the camera to the most recently created tag. */
|
|
@@ -4545,12 +4700,9 @@ class MatterportService {
|
|
|
4545
4700
|
await this.sdk.Camera.lookAt(position, {
|
|
4546
4701
|
transition: this.sdk.Camera.TransitionType.FLY
|
|
4547
4702
|
});
|
|
4548
|
-
await this.sdk.Tag.open(mattertagID);
|
|
4549
|
-
}
|
|
4550
|
-
else {
|
|
4551
|
-
// Fallback for cases where we don't have local data yet
|
|
4552
|
-
await this.sdk.Tag.open(mattertagID);
|
|
4553
4703
|
}
|
|
4704
|
+
// `open` only exists on the global Tag module, never on a layer-scoped one.
|
|
4705
|
+
await this.sdk.Tag.open(mattertagID);
|
|
4554
4706
|
}
|
|
4555
4707
|
catch (error) {
|
|
4556
4708
|
if (isDevMode())
|
|
@@ -4634,6 +4786,9 @@ class MatterportService {
|
|
|
4634
4786
|
.logo-link,
|
|
4635
4787
|
.footer-logo,
|
|
4636
4788
|
.bottom-ui,
|
|
4789
|
+
img.logo-image,
|
|
4790
|
+
img[class^="logo-image"],
|
|
4791
|
+
img[class*=" logo-image"],
|
|
4637
4792
|
[data-testid="showcase-footer"] {
|
|
4638
4793
|
opacity: 0 !important;
|
|
4639
4794
|
visibility: hidden !important;
|
|
@@ -4660,7 +4815,7 @@ class MatterportService {
|
|
|
4660
4815
|
*/
|
|
4661
4816
|
async updateMatterTagContentForTagID(mattertagID, object = null, tagType = null) {
|
|
4662
4817
|
if (this.sdk) {
|
|
4663
|
-
await this.sdk.
|
|
4818
|
+
await this.matterportTagService.getTagModule(this.sdk, mattertagID).editBillboard(mattertagID, this.dictionnaryTags.get(mattertagID).getData());
|
|
4664
4819
|
}
|
|
4665
4820
|
// Mise à jour de l'icône immédiate
|
|
4666
4821
|
await this.matterportTagService.updateMatterTagContentForTagID(this.sdk, mattertagID, object, tagType, this.setTagIconAndOpacity.bind(this),
|
|
@@ -4703,7 +4858,7 @@ class MatterportService {
|
|
|
4703
4858
|
* @returns
|
|
4704
4859
|
*/
|
|
4705
4860
|
async createMattertagFromPOI(tagType, object, poi) {
|
|
4706
|
-
return this.matterportTagService.createMattertagFromPOI(this.sdk, tagType, object, poi, this.
|
|
4861
|
+
return this.matterportTagService.createMattertagFromPOI(this.sdk, tagType, object, poi, this.getTagFromElementId.bind(this), this.setTagIconAndOpacity.bind(this), this.injectHtmlInTag.bind(this));
|
|
4707
4862
|
}
|
|
4708
4863
|
/**
|
|
4709
4864
|
* Inject custom HTML as Mattertag content
|
|
@@ -4768,6 +4923,10 @@ class MatterportService {
|
|
|
4768
4923
|
const mattertagData = new MattertagData(poiType);
|
|
4769
4924
|
mattertagData.setSweepID(this.navigationService.resolveSweepUuid(this.poseCamera.sweep));
|
|
4770
4925
|
mattertagData.setRotation(this.poseCamera.rotation);
|
|
4926
|
+
const initialPosition = this.poseMatterport?.position ?? this.poseCamera.position;
|
|
4927
|
+
const initialNormal = this.poseMatterport?.normal ?? { x: 0, y: -0.15, z: 0 };
|
|
4928
|
+
mattertagData.setPosition({ ...initialPosition });
|
|
4929
|
+
mattertagData.setNormal({ ...initialNormal });
|
|
4771
4930
|
this.setInteractionMode(ViewerInteractions.ADDING);
|
|
4772
4931
|
await this.addCursorMattertag(mattertagData);
|
|
4773
4932
|
}
|
|
@@ -4885,8 +5044,14 @@ class MatterportService {
|
|
|
4885
5044
|
}
|
|
4886
5045
|
}
|
|
4887
5046
|
async updateMatterTagPosInSdkViewer(mattertagID, object, poiType, poi = null) {
|
|
4888
|
-
|
|
4889
|
-
|
|
5047
|
+
if (!poi || !poi.coordinate) {
|
|
5048
|
+
console.warn('updateMatterTagPosInSdkViewer: poi or poi.coordinate is missing', poi);
|
|
5049
|
+
return;
|
|
5050
|
+
}
|
|
5051
|
+
const IndexToUpdate = object.pois?.items ? object.pois.items.findIndex((u) => u?.id === poi.id) : -1;
|
|
5052
|
+
if (IndexToUpdate !== -1 && object.pois?.items) {
|
|
5053
|
+
object.pois.items[IndexToUpdate] = poi;
|
|
5054
|
+
}
|
|
4890
5055
|
const { x, y, z } = JSON.parse(poi.coordinate);
|
|
4891
5056
|
const newPosition = {
|
|
4892
5057
|
anchorPosition: {
|
|
@@ -4901,8 +5066,7 @@ class MatterportService {
|
|
|
4901
5066
|
z: 0,
|
|
4902
5067
|
},
|
|
4903
5068
|
};
|
|
4904
|
-
|
|
4905
|
-
this.sdk.Tag.editPosition(mattertagID, newPosition);
|
|
5069
|
+
this.matterportTagService.getTagModule(this.sdk, mattertagID).editPosition(mattertagID, newPosition);
|
|
4906
5070
|
this.dictionnaryTags
|
|
4907
5071
|
.get(mattertagID)
|
|
4908
5072
|
.setPosition(newPosition.anchorPosition);
|
|
@@ -4912,8 +5076,7 @@ class MatterportService {
|
|
|
4912
5076
|
continue;
|
|
4913
5077
|
}
|
|
4914
5078
|
if (currentTag.elementID === object.id && tagId !== mattertagID) {
|
|
4915
|
-
this.
|
|
4916
|
-
this.sdk.Tag.remove(tagId);
|
|
5079
|
+
await this.matterportTagService.deleteMattertagFromId(this.sdk, tagId);
|
|
4917
5080
|
break;
|
|
4918
5081
|
}
|
|
4919
5082
|
}
|
|
@@ -4955,6 +5118,140 @@ class MatterportService {
|
|
|
4955
5118
|
return null;
|
|
4956
5119
|
}
|
|
4957
5120
|
//
|
|
5121
|
+
// ---------- Views (furnished / defurnished) ----------
|
|
5122
|
+
//
|
|
5123
|
+
/**
|
|
5124
|
+
* Subscribes to the SDK View API to keep track of the views available for the space
|
|
5125
|
+
* and of the active one. Tags live in a shared common layer, so a view change only
|
|
5126
|
+
* requires re-applying their visibility, never re-creating them.
|
|
5127
|
+
*/
|
|
5128
|
+
initViews() {
|
|
5129
|
+
this.clearViewSubscriptions();
|
|
5130
|
+
this.viewsByType.clear();
|
|
5131
|
+
this.currentView = null;
|
|
5132
|
+
if (!this.sdk?.View?.views) {
|
|
5133
|
+
if (isDevMode())
|
|
5134
|
+
console.warn('[MatterportService] View API unavailable on this SDK version');
|
|
5135
|
+
return;
|
|
5136
|
+
}
|
|
5137
|
+
this.viewSubscriptions.push(this.sdk.View.views.subscribe({
|
|
5138
|
+
onCollectionUpdated: (collection) => {
|
|
5139
|
+
// SDK callbacks run outside Angular: re-enter the zone so the views made
|
|
5140
|
+
// available here (defurnish button...) are picked up by change detection.
|
|
5141
|
+
this.ngZone.run(() => {
|
|
5142
|
+
this.viewsByType.clear();
|
|
5143
|
+
for (const view of Object.values(collection)) {
|
|
5144
|
+
this.viewsByType.set(view.type, view);
|
|
5145
|
+
}
|
|
5146
|
+
});
|
|
5147
|
+
// Views are discovered progressively: make sure each one carries the shared
|
|
5148
|
+
// tag layer, otherwise its tags would not exist in that view.
|
|
5149
|
+
this.matterportTagService.attachSharedTagLayerToViews(this.sdk, Object.values(collection));
|
|
5150
|
+
},
|
|
5151
|
+
}));
|
|
5152
|
+
this.viewSubscriptions.push(this.sdk.View.current.subscribe((view) => {
|
|
5153
|
+
const changed = this.currentView?.id !== view?.id;
|
|
5154
|
+
this.currentView = view;
|
|
5155
|
+
if (!changed)
|
|
5156
|
+
return;
|
|
5157
|
+
this.restoreTagsForActiveView(view);
|
|
5158
|
+
this.ngZone.run(() => this.onViewChanged.next(view));
|
|
5159
|
+
}));
|
|
5160
|
+
}
|
|
5161
|
+
/**
|
|
5162
|
+
* Forces the opacity/enabled state to be pushed again to every tag. The SDK
|
|
5163
|
+
* re-renders the shared layer's tags on a view swap, and the memoized values would
|
|
5164
|
+
* otherwise make updateTagsVisibilityForContext() skip them, leaving tags invisible.
|
|
5165
|
+
*/
|
|
5166
|
+
reapplyTagsVisibility() {
|
|
5167
|
+
this.matterportTagService.resetVisibilityCache();
|
|
5168
|
+
this.refreshTagsVisibility();
|
|
5169
|
+
}
|
|
5170
|
+
/**
|
|
5171
|
+
* Puts the tags back in shape in the view that just became active.
|
|
5172
|
+
*
|
|
5173
|
+
* Activating a view re-materializes the shared layer's tags from the descriptors
|
|
5174
|
+
* recorded when they were added, so everything applied to a tag *afterwards* through
|
|
5175
|
+
* the global Tag module is dropped along the way: the custom icon (the pin falls back
|
|
5176
|
+
* to the descriptor's white) and the sandbox carrying the hover content. They have to
|
|
5177
|
+
* be applied again on the freshly rebuilt tags.
|
|
5178
|
+
*
|
|
5179
|
+
* Visibility is refreshed straight away so the tags show up without waiting, then
|
|
5180
|
+
* content is re-applied once the transition is over — re-applying it too early would
|
|
5181
|
+
* target tags the SDK has not rebuilt yet.
|
|
5182
|
+
*/
|
|
5183
|
+
restoreTagsForActiveView(view) {
|
|
5184
|
+
// A view only materializes its layers once activated: (re)attach the shared one.
|
|
5185
|
+
this.matterportTagService.attachSharedTagLayerToViews(this.sdk, [view])
|
|
5186
|
+
.then(() => this.reapplyTagsVisibility());
|
|
5187
|
+
this.reapplyTagsVisibility();
|
|
5188
|
+
clearTimeout(this.viewSwitchRestoreTimer);
|
|
5189
|
+
this.viewSwitchRestoreTimer = setTimeout(() => {
|
|
5190
|
+
this.refreshTagsContent()
|
|
5191
|
+
.catch((e) => {
|
|
5192
|
+
if (isDevMode())
|
|
5193
|
+
console.warn('[MatterportService] Cannot restore tags for view', view?.id, e);
|
|
5194
|
+
})
|
|
5195
|
+
.then(() => this.reapplyTagsVisibility());
|
|
5196
|
+
}, MatterportService.VIEW_SWITCH_REFRESH_DELAY_MS);
|
|
5197
|
+
}
|
|
5198
|
+
/**
|
|
5199
|
+
* Re-applies the icon and the injected HTML of every tag we own. Used after a view
|
|
5200
|
+
* switch, see restoreTagsForActiveView().
|
|
5201
|
+
*/
|
|
5202
|
+
async refreshTagsContent() {
|
|
5203
|
+
if (!this.sdk)
|
|
5204
|
+
return;
|
|
5205
|
+
// Snapshot: the dictionary can be mutated while the awaits below are pending.
|
|
5206
|
+
for (const [sid, mattertagData] of [...this.dictionnaryTags]) {
|
|
5207
|
+
if (sid === this.mattertagToFollow)
|
|
5208
|
+
continue;
|
|
5209
|
+
await this.setTagIconAndOpacity(sid, mattertagData);
|
|
5210
|
+
this.injectHtmlInTag(mattertagData.getType(), mattertagData.getObject(), sid)
|
|
5211
|
+
.catch((err) => {
|
|
5212
|
+
if (isDevMode())
|
|
5213
|
+
console.warn('[MatterportService] HTML re-injection failed for', sid, err);
|
|
5214
|
+
});
|
|
5215
|
+
}
|
|
5216
|
+
}
|
|
5217
|
+
clearViewSubscriptions() {
|
|
5218
|
+
clearTimeout(this.viewSwitchRestoreTimer);
|
|
5219
|
+
this.viewSwitchRestoreTimer = null;
|
|
5220
|
+
for (const subscription of this.viewSubscriptions) {
|
|
5221
|
+
try {
|
|
5222
|
+
subscription.cancel();
|
|
5223
|
+
}
|
|
5224
|
+
catch (e) {
|
|
5225
|
+
if (isDevMode())
|
|
5226
|
+
console.warn('[MatterportService] Cannot cancel view subscription', e);
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
5229
|
+
this.viewSubscriptions = [];
|
|
5230
|
+
}
|
|
5231
|
+
/** True when the space provides a defurnished view. */
|
|
5232
|
+
get hasDefurnishView() {
|
|
5233
|
+
return this.viewsByType.has(MatterportViewType.DEFURNISH);
|
|
5234
|
+
}
|
|
5235
|
+
/** True when the defurnished view is the active one. */
|
|
5236
|
+
get isDefurnishedView() {
|
|
5237
|
+
return this.currentView?.type === MatterportViewType.DEFURNISH;
|
|
5238
|
+
}
|
|
5239
|
+
/**
|
|
5240
|
+
* Switches between the defurnished view and the furnished one.
|
|
5241
|
+
* @param returnToStart Move the camera back to the view's start location.
|
|
5242
|
+
*/
|
|
5243
|
+
async toggleDefurnishView(returnToStart = false) {
|
|
5244
|
+
const target = this.isDefurnishedView
|
|
5245
|
+
? this.viewsByType.get(MatterportViewType.LAYERED_BASE) ?? this.viewsByType.get(MatterportViewType.BASE)
|
|
5246
|
+
: this.viewsByType.get(MatterportViewType.DEFURNISH);
|
|
5247
|
+
if (!target) {
|
|
5248
|
+
if (isDevMode())
|
|
5249
|
+
console.warn('[MatterportService] No target view to switch to');
|
|
5250
|
+
return;
|
|
5251
|
+
}
|
|
5252
|
+
await target.setActive(returnToStart);
|
|
5253
|
+
}
|
|
5254
|
+
//
|
|
4958
5255
|
// ---------- Viewer related (switch views, go to) ----------
|
|
4959
5256
|
//
|
|
4960
5257
|
async action_toolbox_floorplan() {
|
|
@@ -5006,6 +5303,9 @@ class MatterportService {
|
|
|
5006
5303
|
*/
|
|
5007
5304
|
async clearAll() {
|
|
5008
5305
|
console.log('removing viewer');
|
|
5306
|
+
this.clearViewSubscriptions();
|
|
5307
|
+
this.viewsByType.clear();
|
|
5308
|
+
this.currentView = null;
|
|
5009
5309
|
await this.action_delete_all_mattertags();
|
|
5010
5310
|
this.floors = null;
|
|
5011
5311
|
this.sweeps = null;
|
|
@@ -5253,6 +5553,13 @@ class ViewerService {
|
|
|
5253
5553
|
await this.actionAddObjectFromPOI(object, poi);
|
|
5254
5554
|
}
|
|
5255
5555
|
}));
|
|
5556
|
+
// Newly created tags have no opacity/allowAction applied yet (only set by
|
|
5557
|
+
// updateTagsVisibilityForContext, which runs on Mode/Sweep/Floor/Room/Camera
|
|
5558
|
+
// events). If this batch loaded after those fired, the tags stay in whatever
|
|
5559
|
+
// default state the SDK gave them until something else nudges a refresh.
|
|
5560
|
+
if (this.checkMode('Matterport')) {
|
|
5561
|
+
this.matterportService.refreshTagsVisibility();
|
|
5562
|
+
}
|
|
5256
5563
|
}
|
|
5257
5564
|
async actionAddObjectFromPOI(object, poi) {
|
|
5258
5565
|
if (this.checkMode('Matterport')) {
|
|
@@ -5693,6 +6000,34 @@ const copyFileInS3 = async (sourceKey, destKey) => {
|
|
|
5693
6000
|
const result = await Storage.copy({ key: sourceKey, level: "public" }, { key: destKey, level: "public" });
|
|
5694
6001
|
return result.key;
|
|
5695
6002
|
};
|
|
6003
|
+
/**
|
|
6004
|
+
* Fallback duplication: downloads the object and re-uploads it under the new key.
|
|
6005
|
+
*
|
|
6006
|
+
* Storage.copy() builds the S3 `x-amz-copy-source` header by plain string concatenation
|
|
6007
|
+
* (see @aws-amplify/storage AWSS3Provider.copy: `${bucket}/${srcPrefix}${srcKey}`), with no
|
|
6008
|
+
* URL-encoding of the key. A key holding a space, an accent or a parenthesis therefore
|
|
6009
|
+
* yields a header that does not match the signed canonical request, and S3 answers
|
|
6010
|
+
* "The request signature we calculated does not match the signature you provided".
|
|
6011
|
+
*
|
|
6012
|
+
* Round-tripping the bytes avoids that header entirely and works for any key, at the cost
|
|
6013
|
+
* of transferring the file through the browser — hence its use as a fallback only.
|
|
6014
|
+
* IMPORTANT: like copyFileInS3, this must NOT swallow errors.
|
|
6015
|
+
*/
|
|
6016
|
+
const copyFileInS3ViaDownload = async (sourceKey, destKey) => {
|
|
6017
|
+
const downloaded = await Storage.get(sourceKey, {
|
|
6018
|
+
download: true,
|
|
6019
|
+
level: "public",
|
|
6020
|
+
});
|
|
6021
|
+
const body = downloaded?.Body;
|
|
6022
|
+
if (!body) {
|
|
6023
|
+
throw new Error(`Empty download for "${sourceKey}", cannot duplicate it`);
|
|
6024
|
+
}
|
|
6025
|
+
const result = await Storage.put(destKey, body, {
|
|
6026
|
+
level: "public",
|
|
6027
|
+
contentType: downloaded.ContentType || undefined,
|
|
6028
|
+
});
|
|
6029
|
+
return result.key;
|
|
6030
|
+
};
|
|
5696
6031
|
|
|
5697
6032
|
function getDistanceBetweenTwoPoints(startPoint, endPoint) {
|
|
5698
6033
|
const dx = endPoint.x - startPoint.x;
|
|
@@ -8601,12 +8936,14 @@ class EquipmentService extends BaseObjectService {
|
|
|
8601
8936
|
* S3 object itself has already been physically copied by the caller beforehand,
|
|
8602
8937
|
* so this only needs to record the Note pointing at that new key.
|
|
8603
8938
|
*/
|
|
8604
|
-
async createNoteWithExistingAttachment(equipmentID, attachmentKey, type, ownerMissionID) {
|
|
8939
|
+
async createNoteWithExistingAttachment(equipmentID, attachmentKey, type, ownerMissionID, dates = {}) {
|
|
8605
8940
|
return this.API.__proto__.CreateNote({
|
|
8606
8941
|
attachment: attachmentKey,
|
|
8607
8942
|
type,
|
|
8608
8943
|
equipmentID,
|
|
8609
8944
|
ownerMissionID,
|
|
8945
|
+
signDate: dates.signDate ?? null,
|
|
8946
|
+
expirationDate: dates.expirationDate ?? null,
|
|
8610
8947
|
});
|
|
8611
8948
|
}
|
|
8612
8949
|
async uploadImage(equipment, imageFile) {
|
|
@@ -9320,6 +9657,33 @@ class HashtagService {
|
|
|
9320
9657
|
async getHashtag(hashtagID) {
|
|
9321
9658
|
return this.API.__proto__.GetHashtag(hashtagID);
|
|
9322
9659
|
}
|
|
9660
|
+
/*
|
|
9661
|
+
* ---------------------------------------------------------------------------
|
|
9662
|
+
* Pure data access — NO side effect on currentHashtags / currentSpaceID.
|
|
9663
|
+
*
|
|
9664
|
+
* `currentHashtags` is shared UI state read by HashtagFromIdPipe to render a
|
|
9665
|
+
* keyword's name. Batch work (space duplication, export, admin tooling) touches
|
|
9666
|
+
* hashtags of spaces the operator is not editing, and must not reshape what the
|
|
9667
|
+
* screens are displaying — hence this side-effect-free trio.
|
|
9668
|
+
* ---------------------------------------------------------------------------
|
|
9669
|
+
*/
|
|
9670
|
+
/** Reads a space's keywords without touching the shared cache. */
|
|
9671
|
+
async listHashtagsForSpace(spaceID) {
|
|
9672
|
+
return this.API.__proto__.HashtagsBySpace(spaceID).then((response) => response.items);
|
|
9673
|
+
}
|
|
9674
|
+
/** Creates a keyword in a space without adding it to the shared cache. */
|
|
9675
|
+
async createHashtagForSpace(name, spaceID) {
|
|
9676
|
+
return this.API.__proto__.CreateHashtag({ name, spaceID });
|
|
9677
|
+
}
|
|
9678
|
+
/** Deletes a keyword without touching the shared cache. */
|
|
9679
|
+
async deleteHashtagById(hashtagID) {
|
|
9680
|
+
return this.API.__proto__.DeleteHashtag({ id: hashtagID });
|
|
9681
|
+
}
|
|
9682
|
+
/*
|
|
9683
|
+
* ---------------------------------------------------------------------------
|
|
9684
|
+
* UI-facing operations — same actions, plus maintenance of the shared cache.
|
|
9685
|
+
* ---------------------------------------------------------------------------
|
|
9686
|
+
*/
|
|
9323
9687
|
async createHashtag(hashtag) {
|
|
9324
9688
|
const hashtagCreated = await this.API.__proto__.CreateHashtag(hashtag);
|
|
9325
9689
|
this.currentHashtags.push(hashtagCreated);
|
|
@@ -9333,7 +9697,7 @@ class HashtagService {
|
|
|
9333
9697
|
return this.currentHashtags;
|
|
9334
9698
|
}
|
|
9335
9699
|
this.currentSpaceID = spaceID;
|
|
9336
|
-
return this.
|
|
9700
|
+
return this.listHashtagsForSpace(spaceID);
|
|
9337
9701
|
}
|
|
9338
9702
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HashtagService, deps: [{ token: 'currentAPIService' }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
9339
9703
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HashtagService, providedIn: "root" });
|
|
@@ -9353,14 +9717,33 @@ class HashtagFromIdPipe {
|
|
|
9353
9717
|
constructor(hashtagService) {
|
|
9354
9718
|
this.hashtagService = hashtagService;
|
|
9355
9719
|
}
|
|
9720
|
+
/**
|
|
9721
|
+
* Resolves a hashtag id to its display name.
|
|
9722
|
+
*
|
|
9723
|
+
* The cache is consulted for THIS id, and the network answers whenever the id is not
|
|
9724
|
+
* in it.
|
|
9725
|
+
*
|
|
9726
|
+
* Hashtag ids are globally unique, so a cache holding entries from several spaces can
|
|
9727
|
+
* only ever produce a correct hit or a miss, never a wrong name.
|
|
9728
|
+
*/
|
|
9356
9729
|
async transform(hashtagID) {
|
|
9357
|
-
if (
|
|
9358
|
-
return
|
|
9359
|
-
|
|
9360
|
-
|
|
9730
|
+
if (!hashtagID) {
|
|
9731
|
+
return "";
|
|
9732
|
+
}
|
|
9733
|
+
const cached = this.hashtagService.currentHashtags.find((_hashtag) => _hashtag.id === hashtagID);
|
|
9734
|
+
if (cached) {
|
|
9735
|
+
return cached.name ?? "";
|
|
9736
|
+
}
|
|
9737
|
+
try {
|
|
9738
|
+
const hashtag = await this.hashtagService.getHashtag(hashtagID);
|
|
9739
|
+
return hashtag?.name ?? "";
|
|
9740
|
+
}
|
|
9741
|
+
catch (error) {
|
|
9742
|
+
// A keyword that cannot be read (deleted, network hiccup) must not break the
|
|
9743
|
+
// rendering of the POI that carries it.
|
|
9744
|
+
console.warn(`[HashtagFromIdPipe] Could not resolve the keyword "${hashtagID}":`, error);
|
|
9745
|
+
return "";
|
|
9361
9746
|
}
|
|
9362
|
-
const hashtag = this.hashtagService.currentHashtags.find((_hashtag) => _hashtag.id === hashtagID);
|
|
9363
|
-
return hashtag ? hashtag.name : "";
|
|
9364
9747
|
}
|
|
9365
9748
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: HashtagFromIdPipe, deps: [{ token: HashtagService }], target: i0.ɵɵFactoryTarget.Pipe });
|
|
9366
9749
|
static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "17.3.12", ngImport: i0, type: HashtagFromIdPipe, name: "hashtagFromID" });
|
|
@@ -10080,6 +10463,40 @@ class CommentService {
|
|
|
10080
10463
|
async createComment(comment) {
|
|
10081
10464
|
return this.API.__proto__.CreateComment(comment);
|
|
10082
10465
|
}
|
|
10466
|
+
/**
|
|
10467
|
+
* Returns EVERY comment attached to a feature, following `nextToken` until the
|
|
10468
|
+
* connection is exhausted.
|
|
10469
|
+
*
|
|
10470
|
+
* The `comments` connection nested inside FeaturesBySpace/GetFeature is capped by
|
|
10471
|
+
* the AppSync resolver (`$util.defaultIfNull($context.args.limit, 100)`) and exposes
|
|
10472
|
+
* a `nextToken` that nobody follows, so a media-rich POI silently loses every
|
|
10473
|
+
* attachment past the first page. Anything that must see the complete media set of
|
|
10474
|
+
* a feature (duplication, export, audit) has to use this method rather than
|
|
10475
|
+
* `feature.comments.items`.
|
|
10476
|
+
*/
|
|
10477
|
+
async getAllCommentsForFeature(featureID) {
|
|
10478
|
+
return this.getAllCommentsPaginated((limit, nextToken) => this.API.__proto__.CommentsByFeature(featureID, undefined, undefined, limit, nextToken), featureID);
|
|
10479
|
+
}
|
|
10480
|
+
/** Same as getAllCommentsForFeature(), for the attachments of a ticket. */
|
|
10481
|
+
async getAllCommentsForTicket(ticketID) {
|
|
10482
|
+
return this.getAllCommentsPaginated((limit, nextToken) => this.API.__proto__.CommentsByTicket(ticketID, undefined, undefined, limit, nextToken), ticketID);
|
|
10483
|
+
}
|
|
10484
|
+
/** Drains a paginated comment query until its nextToken is exhausted. */
|
|
10485
|
+
async getAllCommentsPaginated(fetchPage, ownerID) {
|
|
10486
|
+
if (!ownerID) {
|
|
10487
|
+
return [];
|
|
10488
|
+
}
|
|
10489
|
+
const all = [];
|
|
10490
|
+
let nextToken = null;
|
|
10491
|
+
do {
|
|
10492
|
+
const page = await fetchPage(1000, nextToken);
|
|
10493
|
+
if (page?.items?.length > 0) {
|
|
10494
|
+
all.push(...page.items.filter((item) => !!item));
|
|
10495
|
+
}
|
|
10496
|
+
nextToken = page?.nextToken ?? null;
|
|
10497
|
+
} while (nextToken);
|
|
10498
|
+
return all;
|
|
10499
|
+
}
|
|
10083
10500
|
async updateComment(input) {
|
|
10084
10501
|
return this.API.__proto__.UpdateComment(input);
|
|
10085
10502
|
}
|
|
@@ -11361,6 +11778,31 @@ class FeatureService extends BaseObjectService {
|
|
|
11361
11778
|
}
|
|
11362
11779
|
return features;
|
|
11363
11780
|
}
|
|
11781
|
+
/**
|
|
11782
|
+
* Returns EVERY feature of a space, following `nextToken` until the index is
|
|
11783
|
+
* exhausted, with no user/archive filtering at all.
|
|
11784
|
+
*
|
|
11785
|
+
* `getFeaturesBySpaceWithoutFiltering()` reads a single page (the AppSync default
|
|
11786
|
+
* caps it at 100 items) and calls `isArchived()`, which dereferences the current
|
|
11787
|
+
* user's mission ON THAT SPACE — so it both truncates large spaces and throws for a
|
|
11788
|
+
* space the operator has no mission on. Duplication has to see the raw, complete
|
|
11789
|
+
* list, hence this separate accessor rather than a change of the existing one.
|
|
11790
|
+
*/
|
|
11791
|
+
async getAllFeaturesBySpaceRaw(spaceID) {
|
|
11792
|
+
if (!spaceID) {
|
|
11793
|
+
return [];
|
|
11794
|
+
}
|
|
11795
|
+
const all = [];
|
|
11796
|
+
let nextToken = null;
|
|
11797
|
+
do {
|
|
11798
|
+
const page = await this.API.__proto__.FeaturesBySpace(spaceID, undefined, undefined, 1000, nextToken);
|
|
11799
|
+
if (page?.items?.length > 0) {
|
|
11800
|
+
all.push(...page.items.filter((item) => !!item));
|
|
11801
|
+
}
|
|
11802
|
+
nextToken = page?.nextToken ?? null;
|
|
11803
|
+
} while (nextToken);
|
|
11804
|
+
return all;
|
|
11805
|
+
}
|
|
11364
11806
|
isBookingPlace(feature) {
|
|
11365
11807
|
feature.isBookingPlace = feature.type === FeatureType.DESK;
|
|
11366
11808
|
}
|
|
@@ -13407,16 +13849,42 @@ function applyRigidTransformToPoint(p, t) {
|
|
|
13407
13849
|
z: t.refTarget.z + (dx * sin + dz * cos)
|
|
13408
13850
|
};
|
|
13409
13851
|
}
|
|
13410
|
-
/**
|
|
13852
|
+
/**
|
|
13853
|
+
* Applies the Y rotation (in degrees) to an orientation angle (a pose's yRotation).
|
|
13854
|
+
* A null/undefined/non-numeric angle is returned untouched: "no orientation stored"
|
|
13855
|
+
* must stay "no orientation stored", not become the raw rotation delta.
|
|
13856
|
+
*/
|
|
13411
13857
|
function applyRigidTransformToYRotationDeg(yRotationDeg, t) {
|
|
13858
|
+
if (yRotationDeg === null || yRotationDeg === undefined || Number.isNaN(Number(yRotationDeg))) {
|
|
13859
|
+
return yRotationDeg;
|
|
13860
|
+
}
|
|
13412
13861
|
const deltaDeg = t.rotationYRad * (180 / Math.PI);
|
|
13413
|
-
return yRotationDeg + deltaDeg;
|
|
13862
|
+
return Number(yRotationDeg) + deltaDeg;
|
|
13414
13863
|
}
|
|
13415
13864
|
/** Euclidean distance between two points, useful to validate landmark quality. */
|
|
13416
13865
|
function distance(a, b) {
|
|
13417
13866
|
const dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
|
|
13418
13867
|
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
13419
13868
|
}
|
|
13869
|
+
/**
|
|
13870
|
+
* Applies ONLY the rotation part of the transform to a direction vector
|
|
13871
|
+
* (a normal, a stem vector, ...).
|
|
13872
|
+
*
|
|
13873
|
+
* A direction has no origin, so the translation must NOT be applied to it —
|
|
13874
|
+
* doing so would turn a unit normal into a point somewhere in the target model.
|
|
13875
|
+
* Used for a POI's `metadata.normal`, which drives the orientation of the tag's
|
|
13876
|
+
* stem: without this rotation, a pastille copied into a space whose model is
|
|
13877
|
+
* rotated relative to the source ends up pointing into the wall it is stuck on.
|
|
13878
|
+
*/
|
|
13879
|
+
function applyRigidTransformToDirection(v, t) {
|
|
13880
|
+
const cos = Math.cos(t.rotationYRad);
|
|
13881
|
+
const sin = Math.sin(t.rotationYRad);
|
|
13882
|
+
return {
|
|
13883
|
+
x: v.x * cos - v.z * sin,
|
|
13884
|
+
y: v.y, // vertical axis is not rotated
|
|
13885
|
+
z: v.x * sin + v.z * cos
|
|
13886
|
+
};
|
|
13887
|
+
}
|
|
13420
13888
|
|
|
13421
13889
|
/* eslint-disable class-methods-use-this */
|
|
13422
13890
|
class Object3DService {
|
|
@@ -13835,6 +14303,8 @@ class MatterportTagCopyService {
|
|
|
13835
14303
|
featureService;
|
|
13836
14304
|
measurementService;
|
|
13837
14305
|
commentService;
|
|
14306
|
+
hashtagService;
|
|
14307
|
+
spaceService;
|
|
13838
14308
|
translate;
|
|
13839
14309
|
// Emits progress updates during copyTagsFromVisit(); null when idle
|
|
13840
14310
|
copyProgress$ = new BehaviorSubject(null);
|
|
@@ -13843,7 +14313,34 @@ class MatterportTagCopyService {
|
|
|
13843
14313
|
spModule;
|
|
13844
14314
|
log = [];
|
|
13845
14315
|
errorSeq = 0;
|
|
13846
|
-
|
|
14316
|
+
// ---- Per-run remapping state (reset by resetRunState() at the start of every copy) ----
|
|
14317
|
+
/** source element id -> id of its copy in the target space. */
|
|
14318
|
+
elementIds = {};
|
|
14319
|
+
/** source Comment id -> id of its copy. Needed to remap POI.tagImageID, which is a
|
|
14320
|
+
* reference to a Comment record (see the schema: "connection to comment.id"). */
|
|
14321
|
+
commentIds = new Map();
|
|
14322
|
+
// Hashtags are space-scoped records, so their ids can never be carried over as-is: they
|
|
14323
|
+
// are matched by NAME against the target space, and the missing ones are created there.
|
|
14324
|
+
// That creation is deliberately lazy — see resolveHashtagIds() — and audited, so an
|
|
14325
|
+
// interrupted copy cannot leave keywords behind that no POI references.
|
|
14326
|
+
/** source Hashtag id -> its name. */
|
|
14327
|
+
sourceHashtagNames = new Map();
|
|
14328
|
+
/** normalised hashtag name -> its id in the TARGET space (pre-existing or just created). */
|
|
14329
|
+
targetHashtagIdsByName = new Map();
|
|
14330
|
+
/** ids of the hashtags this run created in the target space. */
|
|
14331
|
+
createdHashtagIds = new Set();
|
|
14332
|
+
/** ids of the hashtags actually persisted on a successfully created feature. Anything in
|
|
14333
|
+
* createdHashtagIds but absent here is an orphan and is deleted at the end of the run. */
|
|
14334
|
+
usedHashtagIds = new Set();
|
|
14335
|
+
/** source ids of the elements whose POIs were already created by an element phase.
|
|
14336
|
+
* Guards the standalone POI phase against creating a second, impoverished copy. */
|
|
14337
|
+
handledElementIds = new Set();
|
|
14338
|
+
/** true when source and target spaces belong to the same organisation. Domains are
|
|
14339
|
+
* organisation-scoped (no spaceID), so domainID survives the copy only in that case. */
|
|
14340
|
+
sameOrganisation = false;
|
|
14341
|
+
/** Target space id of the run in progress — used to re-home copied tag icons. */
|
|
14342
|
+
targetSpaceId = null;
|
|
14343
|
+
constructor(navigationService, userService, poiService, ticketService, equipmentService, object3DService, featureService, measurementService, commentService, hashtagService, spaceService, translate) {
|
|
13847
14344
|
this.navigationService = navigationService;
|
|
13848
14345
|
this.userService = userService;
|
|
13849
14346
|
this.poiService = poiService;
|
|
@@ -13853,6 +14350,8 @@ class MatterportTagCopyService {
|
|
|
13853
14350
|
this.featureService = featureService;
|
|
13854
14351
|
this.measurementService = measurementService;
|
|
13855
14352
|
this.commentService = commentService;
|
|
14353
|
+
this.hashtagService = hashtagService;
|
|
14354
|
+
this.spaceService = spaceService;
|
|
13856
14355
|
this.translate = translate;
|
|
13857
14356
|
this.spModule = userService.getSpModule();
|
|
13858
14357
|
}
|
|
@@ -13862,6 +14361,7 @@ class MatterportTagCopyService {
|
|
|
13862
14361
|
async copyTagsFromVisit(sourceSpaceId, targetSpaceId, landmarkSourceA, landmarkTargetA, landmarkSourceB, landmarkTargetB) {
|
|
13863
14362
|
let errorCount = 0;
|
|
13864
14363
|
this.resetLog();
|
|
14364
|
+
this.resetRunState(targetSpaceId);
|
|
13865
14365
|
try {
|
|
13866
14366
|
console.log(`Copying Tags from source ${sourceSpaceId} to target ${targetSpaceId}`);
|
|
13867
14367
|
this.setStepProgress('collecting', 'collecting', 0);
|
|
@@ -13879,6 +14379,9 @@ class MatterportTagCopyService {
|
|
|
13879
14379
|
refSource: transform.refSource,
|
|
13880
14380
|
refTarget: transform.refTarget
|
|
13881
14381
|
});
|
|
14382
|
+
// Domains are organisation-scoped, hashtags are space-scoped: both need to know
|
|
14383
|
+
// whether we stay inside the same organisation before anything is remapped.
|
|
14384
|
+
this.sameOrganisation = await this.spacesShareOrganisation(sourceSpaceId, targetSpaceId);
|
|
13882
14385
|
const sourceSweepIds = await this.navigationService.getAllowedSweepsIDsForUserInSpace(sourceSpaceId);
|
|
13883
14386
|
const targetSweepIds = await this.navigationService.getAllowedSweepsIDsForUserInSpace(targetSpaceId);
|
|
13884
14387
|
this.setStepProgress('collecting', 'collecting', 30);
|
|
@@ -13886,7 +14389,7 @@ class MatterportTagCopyService {
|
|
|
13886
14389
|
this.buildNavigationPositionMap(sourceSpaceId, sourceSweepIds),
|
|
13887
14390
|
this.buildNavigationPositionMap(targetSpaceId, targetSweepIds)
|
|
13888
14391
|
]);
|
|
13889
|
-
const elementIds =
|
|
14392
|
+
const elementIds = this.elementIds;
|
|
13890
14393
|
let sourceTickets = [];
|
|
13891
14394
|
let sourceEquipments = [];
|
|
13892
14395
|
let targetTickets = [];
|
|
@@ -13902,9 +14405,23 @@ class MatterportTagCopyService {
|
|
|
13902
14405
|
this.setStepProgress('collecting', 'collecting', 55);
|
|
13903
14406
|
const sourceObjects3D = await this.object3DService.getObjects3DBySpace(sourceSpaceId);
|
|
13904
14407
|
const targetObjects3D = await this.object3DService.getObjects3DBySpace(targetSpaceId);
|
|
13905
|
-
|
|
13906
|
-
|
|
14408
|
+
// Paginated: a space routinely holds more than the 100 features a single AppSync
|
|
14409
|
+
// page returns, and the tail was silently dropped.
|
|
14410
|
+
//
|
|
14411
|
+
// The source list keeps the historical "skip what the operator archived" rule, so
|
|
14412
|
+
// the set of copied features does not change — but the check is now null-safe:
|
|
14413
|
+
// FeatureService.isArchived() dereferences currentMission(spaceID).id and threw a
|
|
14414
|
+
// TypeError when the operator had no mission on the source space (the common case
|
|
14415
|
+
// for an archived v1 space), aborting the whole copy.
|
|
14416
|
+
// The target list is NOT filtered: it only feeds duplicate detection, and an
|
|
14417
|
+
// archived feature is still a feature that exists there.
|
|
14418
|
+
const sourceFeatures = (await this.featureService.getAllFeaturesBySpaceRaw(sourceSpaceId))
|
|
14419
|
+
.filter(feature => !this.isArchivedForOperator(feature, sourceSpaceId));
|
|
14420
|
+
const targetFeatures = await this.featureService.getAllFeaturesBySpaceRaw(targetSpaceId);
|
|
13907
14421
|
this.setStepProgress('collecting', 'collecting', 75);
|
|
14422
|
+
// Keywords: index both vocabularies now (read-only). The keywords the target space
|
|
14423
|
+
// is missing are created later, POI by POI, only for those actually used.
|
|
14424
|
+
await this.loadHashtagIndex(sourceSpaceId, targetSpaceId);
|
|
13908
14425
|
if (this.spModule == SpModule.IMMO) {
|
|
13909
14426
|
sourceMeasurements = await this.measurementService.getMeasurementsForSpaceForUser(sourceSpaceId);
|
|
13910
14427
|
targetMeasurements = await this.measurementService.getMeasurementsForSpaceForUser(targetSpaceId);
|
|
@@ -13912,12 +14429,44 @@ class MatterportTagCopyService {
|
|
|
13912
14429
|
this.setStepProgress('collecting', 'collecting', 90);
|
|
13913
14430
|
const sourcePoisAll = await this.poiService.getPoisByMatterportSweeps(sourceSweepIds);
|
|
13914
14431
|
const targetPoisAll = await this.poiService.getPoisByMatterportSweeps(targetSweepIds);
|
|
14432
|
+
// A POI is "owned" by an element (ticket, equipment, 3D object, feature, measurement)
|
|
14433
|
+
// when its elementID points at one. Those POIs are created by their element's phase,
|
|
14434
|
+
// which is the only place that knows the element's NEW id — so they must be excluded
|
|
14435
|
+
// from the standalone phase, otherwise every feature ends up with two POIs: one
|
|
14436
|
+
// correctly linked but stripped of its tag metadata, and one carrying the metadata
|
|
14437
|
+
// but still pointing at the source space's element.
|
|
14438
|
+
//
|
|
14439
|
+
// Ownership is recognised by TWO independent criteria, and either one is enough to
|
|
14440
|
+
// keep a POI out of the standalone phase:
|
|
14441
|
+
//
|
|
14442
|
+
// - its type is one an element phase is responsible for. This is the historical
|
|
14443
|
+
// rule and it must be kept: a POI of that type whose element was NOT collected
|
|
14444
|
+
// (deleted ticket, equipment outside the current filter, ...) is an orphan, and
|
|
14445
|
+
// copying it standalone would recreate it with an elementID still pointing into
|
|
14446
|
+
// the source space — an orphan the previous version correctly refused to copy.
|
|
14447
|
+
// - its elementID belongs to an element we actually collected. This is the added
|
|
14448
|
+
// rule, needed because the type alone is not a reliable discriminator: historical
|
|
14449
|
+
// data also holds INFORMATION/ROOM POIs bound to a feature, which the type test
|
|
14450
|
+
// lets through and which would end up duplicated.
|
|
13915
14451
|
const handledPoiTypes = new Set([
|
|
13916
14452
|
PoiType.TICKET, PoiType.EQUIPMENT, PoiType.DATA, PoiType.DESK,
|
|
13917
14453
|
PoiType.OBJECT3D, PoiType.MEASURE
|
|
13918
14454
|
]);
|
|
13919
|
-
const
|
|
13920
|
-
|
|
14455
|
+
for (const owner of [...sourceTickets, ...sourceEquipments, ...sourceObjects3D, ...sourceFeatures]) {
|
|
14456
|
+
if (owner?.id) {
|
|
14457
|
+
this.handledElementIds.add(owner.id);
|
|
14458
|
+
}
|
|
14459
|
+
}
|
|
14460
|
+
for (const measurement of sourceMeasurements) {
|
|
14461
|
+
// a measurement POI is bound to the measurement's Comment, not to the measurement
|
|
14462
|
+
if (measurement?.comment?.id) {
|
|
14463
|
+
this.handledElementIds.add(measurement.comment.id);
|
|
14464
|
+
}
|
|
14465
|
+
}
|
|
14466
|
+
const isOwnedByAnElement = (poi) => handledPoiTypes.has(poi.type)
|
|
14467
|
+
|| (!!poi.elementID && this.handledElementIds.has(poi.elementID));
|
|
14468
|
+
const sourcePois = sourcePoisAll.filter(p => !isOwnedByAnElement(p));
|
|
14469
|
+
const targetPois = targetPoisAll.filter(p => !!p);
|
|
13921
14470
|
this.setStepProgress('collecting', 'collecting', 100);
|
|
13922
14471
|
const totalOverall = sourceTickets.length + sourceEquipments.length + sourcePois.length
|
|
13923
14472
|
+ sourceObjects3D.length + sourceFeatures.length + sourceMeasurements.length;
|
|
@@ -13938,7 +14487,7 @@ class MatterportTagCopyService {
|
|
|
13938
14487
|
for (let i = 0; i < sourceTickets.length; i++) {
|
|
13939
14488
|
const sourceObj = sourceTickets[i];
|
|
13940
14489
|
try {
|
|
13941
|
-
const existsInTarget = this.matchesExisting(sourceObj, targetTickets);
|
|
14490
|
+
const existsInTarget = this.matchesExisting(sourceObj, targetTickets, (t) => [t.title, t.description]);
|
|
13942
14491
|
if (!existsInTarget) {
|
|
13943
14492
|
const rawSource = sourceObj;
|
|
13944
14493
|
const ticketInput = {
|
|
@@ -13953,28 +14502,51 @@ class MatterportTagCopyService {
|
|
|
13953
14502
|
read: rawSource.read || null,
|
|
13954
14503
|
projectID: rawSource.projectID || null,
|
|
13955
14504
|
problemCode: rawSource.problemCode || null,
|
|
13956
|
-
|
|
14505
|
+
// Missions are per-space: a source mission id designates nobody in
|
|
14506
|
+
// the target space. Carrying the list over would look like real data
|
|
14507
|
+
// while hiding the item from no one — reset it, as for features.
|
|
14508
|
+
archivedForMissionID: null,
|
|
13957
14509
|
spaceID: targetSpaceId,
|
|
14510
|
+
// Remapped in the second pass below, once every ticket exists.
|
|
13958
14511
|
parentID: null,
|
|
13959
|
-
|
|
14512
|
+
// Domains are organisation-scoped, like for equipments and features.
|
|
14513
|
+
domainID: this.sameOrganisation ? (rawSource.domainID || null) : null,
|
|
13960
14514
|
ownerMissionID: targetMissionId
|
|
13961
14515
|
};
|
|
13962
14516
|
const createdObj = await this.ticketService.createTicket(ticketInput);
|
|
13963
14517
|
if (createdObj && createdObj.id) {
|
|
13964
14518
|
elementIds[sourceObj.id] = createdObj.id;
|
|
13965
14519
|
}
|
|
13966
|
-
|
|
13967
|
-
|
|
13968
|
-
|
|
13969
|
-
|
|
13970
|
-
|
|
13971
|
-
|
|
13972
|
-
|
|
13973
|
-
|
|
13974
|
-
|
|
13975
|
-
|
|
13976
|
-
|
|
13977
|
-
|
|
14520
|
+
// Attachments of the ticket (photos of the incident, documents...).
|
|
14521
|
+
// They were never copied at all: a duplicated ticket arrived stripped
|
|
14522
|
+
// of every piece of evidence attached to it.
|
|
14523
|
+
if (createdObj) {
|
|
14524
|
+
let sourceComments = [];
|
|
14525
|
+
try {
|
|
14526
|
+
sourceComments = await this.commentService.getAllCommentsForTicket(sourceObj.id);
|
|
14527
|
+
}
|
|
14528
|
+
catch (commentsFetchError) {
|
|
14529
|
+
console.warn(`Falling back to the embedded comment list for ticket "${rawSource.title}":`, commentsFetchError);
|
|
14530
|
+
sourceComments = (sourceObj.comments?.items || []).filter(c => !!c);
|
|
14531
|
+
}
|
|
14532
|
+
for (const sourceComment of this.orderCommentsForCopy(sourceComments)) {
|
|
14533
|
+
try {
|
|
14534
|
+
const created = await this.copyCommentForTicket(sourceComment, createdObj.id, targetSpaceId, targetMissionId);
|
|
14535
|
+
if (created?.id && sourceComment.id) {
|
|
14536
|
+
this.commentIds.set(sourceComment.id, created.id);
|
|
14537
|
+
}
|
|
14538
|
+
}
|
|
14539
|
+
catch (attachmentError) {
|
|
14540
|
+
console.error(`Error copying attachment "${sourceComment?.title}" for ticket "${sourceObj?.title}":`, attachmentError);
|
|
14541
|
+
errorCount++;
|
|
14542
|
+
this.logCopyItemError(attachmentError, 'errorFeatureAttachment', sourceComment?.title || sourceComment?.id, {
|
|
14543
|
+
feature: rawSource.title || sourceObj.id
|
|
14544
|
+
});
|
|
14545
|
+
}
|
|
14546
|
+
}
|
|
14547
|
+
}
|
|
14548
|
+
for (const sourcePoi of (sourceObj.pois?.items || []).filter(p => !!p)) {
|
|
14549
|
+
await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
|
|
13978
14550
|
}
|
|
13979
14551
|
}
|
|
13980
14552
|
}
|
|
@@ -13988,26 +14560,69 @@ class MatterportTagCopyService {
|
|
|
13988
14560
|
const pct = sourceTickets.length > 0 ? Math.round(((i + 1) / sourceTickets.length) * 100) : 100;
|
|
13989
14561
|
this.setStepProgress('tickets', 'tickets', pct);
|
|
13990
14562
|
}
|
|
13991
|
-
//
|
|
13992
|
-
//
|
|
14563
|
+
// Second pass: restore the ticket hierarchy. `parentID` had to be nulled during
|
|
14564
|
+
// creation (the parent may not have existed yet) and was then never restored, so
|
|
14565
|
+
// every sub-ticket came out detached. Same rule as for sub-equipments: a parent
|
|
14566
|
+
// with no copied counterpart is dropped rather than left pointing at the source
|
|
14567
|
+
// space's ticket.
|
|
14568
|
+
for (const sourceObj of sourceTickets) {
|
|
14569
|
+
const rawSource = sourceObj;
|
|
14570
|
+
const newId = elementIds[sourceObj.id];
|
|
14571
|
+
const newParentId = rawSource.parentID ? elementIds[rawSource.parentID] : null;
|
|
14572
|
+
if (newId && newParentId) {
|
|
14573
|
+
try {
|
|
14574
|
+
await this.ticketService.updateTicket({ id: newId, parentID: newParentId });
|
|
14575
|
+
}
|
|
14576
|
+
catch (parentError) {
|
|
14577
|
+
console.error(`Error remapping the parent ticket of "${sourceObj?.title}":`, parentError);
|
|
14578
|
+
errorCount++;
|
|
14579
|
+
this.logCopyItemError(parentError, 'errorTicket', sourceObj?.title || sourceObj?.id);
|
|
14580
|
+
}
|
|
14581
|
+
}
|
|
14582
|
+
}
|
|
14583
|
+
// EQUIPMENTS (identity, POI, PHOTO and NOTES/documents all copied — files
|
|
14584
|
+
// physically duplicated in S3 first, so the copy stays independent from the source)
|
|
14585
|
+
//
|
|
14586
|
+
// CAREFUL: the Equipment model has NO `title` and NO `description` column. It
|
|
14587
|
+
// identifies itself with `name` (String! — required) and annotates itself with
|
|
14588
|
+
// `annotation`. Reading `rawSource.title` therefore always yielded undefined and
|
|
14589
|
+
// the required `name` was never sent, which AppSync rejected with
|
|
14590
|
+
// "Variable 'input' has coerced Null value for NonNull type 'String!'".
|
|
13993
14591
|
for (let i = 0; i < sourceEquipments.length; i++) {
|
|
13994
14592
|
const sourceObj = sourceEquipments[i];
|
|
13995
14593
|
try {
|
|
13996
|
-
|
|
14594
|
+
// Equipment has no `title`/`description`: it identifies itself by name, and by
|
|
14595
|
+
// serial number when one is recorded.
|
|
14596
|
+
const existsInTarget = this.matchesExisting(sourceObj, targetEquipments, (e) => [e.name, e.serialNumber]);
|
|
13997
14597
|
if (!existsInTarget) {
|
|
13998
14598
|
const rawSource = sourceObj;
|
|
13999
14599
|
let copiedPhotoKey = null;
|
|
14000
14600
|
if (rawSource.photo) {
|
|
14001
|
-
copiedPhotoKey = await this.copyS3KeyIfPresent(rawSource.photo, `equipment photo "${rawSource.
|
|
14601
|
+
copiedPhotoKey = await this.copyS3KeyIfPresent(rawSource.photo, `equipment photo "${rawSource.name}"`);
|
|
14002
14602
|
}
|
|
14003
14603
|
const equipmentInput = {
|
|
14004
|
-
|
|
14604
|
+
name: rawSource.name,
|
|
14605
|
+
annotation: rawSource.annotation ?? null,
|
|
14005
14606
|
status: rawSource.status,
|
|
14006
|
-
|
|
14007
|
-
|
|
14607
|
+
// Full identity of the equipment — all of these were dropped
|
|
14608
|
+
family: rawSource.family ?? null,
|
|
14609
|
+
sku: rawSource.sku ?? null,
|
|
14610
|
+
serialNumber: rawSource.serialNumber ?? null,
|
|
14611
|
+
manufacturer: rawSource.manufacturer ?? null,
|
|
14612
|
+
installDate: rawSource.installDate ?? null,
|
|
14613
|
+
warrantyEndDate: rawSource.warrantyEndDate ?? null,
|
|
14614
|
+
metadata: rawSource.metadata ?? null,
|
|
14615
|
+
isCreatedByAI: rawSource.isCreatedByAI ?? null,
|
|
14616
|
+
// Missions are per-space: a source mission id designates nobody in
|
|
14617
|
+
// the target space. Carrying the list over would look like real data
|
|
14618
|
+
// while hiding the item from no one — reset it, as for features.
|
|
14619
|
+
archivedForMissionID: null,
|
|
14008
14620
|
spaceID: targetSpaceId,
|
|
14009
|
-
|
|
14621
|
+
// Domains are organisation-scoped, like for features and POIs.
|
|
14622
|
+
domainID: this.sameOrganisation ? (rawSource.domainID || null) : null,
|
|
14010
14623
|
photo: copiedPhotoKey,
|
|
14624
|
+
// `subequipmentsIDs` is deliberately absent: it is remapped to the
|
|
14625
|
+
// copied equipments in the second pass, once they all exist.
|
|
14011
14626
|
};
|
|
14012
14627
|
const createdObj = await this.equipmentService.createEquipment(equipmentInput, null, null);
|
|
14013
14628
|
if (createdObj && createdObj.id) {
|
|
@@ -14017,38 +14632,35 @@ class MatterportTagCopyService {
|
|
|
14017
14632
|
if (createdObj && rawSource.notes?.items && rawSource.notes.items.length > 0) {
|
|
14018
14633
|
for (const sourceNote of rawSource.notes.items) {
|
|
14019
14634
|
try {
|
|
14020
|
-
|
|
14021
|
-
|
|
14022
|
-
|
|
14023
|
-
const copiedKey =
|
|
14024
|
-
|
|
14635
|
+
// A note without a file is still a note: it carries a type
|
|
14636
|
+
// and its signature/expiry dates. Skipping it, as was done
|
|
14637
|
+
// before, silently dropped those records entirely.
|
|
14638
|
+
const copiedKey = sourceNote.attachment
|
|
14639
|
+
? await this.copyS3KeyIfPresent(sourceNote.attachment, `equipment note for "${sourceObj?.name}"`)
|
|
14640
|
+
: null;
|
|
14641
|
+
await this.equipmentService.createNoteWithExistingAttachment(createdObj.id, copiedKey, sourceNote.type, targetMissionId, {
|
|
14642
|
+
signDate: sourceNote.signDate ?? null,
|
|
14643
|
+
expirationDate: sourceNote.expirationDate ?? null
|
|
14644
|
+
});
|
|
14025
14645
|
}
|
|
14026
14646
|
catch (noteError) {
|
|
14027
|
-
console.error(`Error copying note for equipment "${sourceObj?.
|
|
14647
|
+
console.error(`Error copying note for equipment "${sourceObj?.name}":`, noteError);
|
|
14028
14648
|
errorCount++;
|
|
14029
|
-
this.logCopyItemError(noteError, 'errorEquipmentNote', sourceObj?.
|
|
14649
|
+
this.logCopyItemError(noteError, 'errorEquipmentNote', sourceObj?.name || sourceObj?.id, {
|
|
14650
|
+
equipment: sourceObj?.name || sourceObj?.id
|
|
14651
|
+
});
|
|
14030
14652
|
}
|
|
14031
14653
|
}
|
|
14032
14654
|
}
|
|
14033
|
-
|
|
14034
|
-
|
|
14035
|
-
const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
|
|
14036
|
-
const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
|
|
14037
|
-
const poiInput = {
|
|
14038
|
-
coordinate: JSON.stringify(adjustedCoordinate),
|
|
14039
|
-
type: sourcePoi.type,
|
|
14040
|
-
elementID: createdObj.id,
|
|
14041
|
-
metadata: sourcePoi.metadata,
|
|
14042
|
-
matterportSweepID: targetSweepId
|
|
14043
|
-
};
|
|
14044
|
-
await this.poiService.createPOIForSpaceID(poiInput);
|
|
14655
|
+
for (const sourcePoi of (sourceObj.pois?.items || []).filter(p => !!p)) {
|
|
14656
|
+
await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
|
|
14045
14657
|
}
|
|
14046
14658
|
}
|
|
14047
14659
|
}
|
|
14048
14660
|
catch (itemError) {
|
|
14049
|
-
console.error(`Error copying equipment "${sourceObj?.
|
|
14661
|
+
console.error(`Error copying equipment "${sourceObj?.name}" (id: ${sourceObj?.id}):`, itemError);
|
|
14050
14662
|
errorCount++;
|
|
14051
|
-
this.logCopyItemError(itemError, 'errorEquipment', sourceObj?.
|
|
14663
|
+
this.logCopyItemError(itemError, 'errorEquipment', sourceObj?.name || sourceObj?.id);
|
|
14052
14664
|
}
|
|
14053
14665
|
processedOverall++;
|
|
14054
14666
|
emitProgress('equipments', i + 1, sourceEquipments.length);
|
|
@@ -14071,54 +14683,16 @@ class MatterportTagCopyService {
|
|
|
14071
14683
|
await this.equipmentService.updateEquipment({ id: newId, subequipmentsIDs: remapped });
|
|
14072
14684
|
}
|
|
14073
14685
|
catch (subEquipError) {
|
|
14074
|
-
console.error(`Error remapping sub-equipments for "${sourceObj?.
|
|
14686
|
+
console.error(`Error remapping sub-equipments for "${sourceObj?.name}":`, subEquipError);
|
|
14075
14687
|
errorCount++;
|
|
14076
|
-
this.logCopyItemError(subEquipError, 'errorEquipmentSubEquip', sourceObj?.
|
|
14688
|
+
this.logCopyItemError(subEquipError, 'errorEquipmentSubEquip', sourceObj?.name || sourceObj?.id, {
|
|
14689
|
+
equipment: sourceObj?.name || sourceObj?.id
|
|
14690
|
+
});
|
|
14077
14691
|
}
|
|
14078
14692
|
}
|
|
14079
14693
|
}
|
|
14080
14694
|
}
|
|
14081
14695
|
}
|
|
14082
|
-
// POIS (standalone — types already handled above are excluded)
|
|
14083
|
-
for (let i = 0; i < sourcePois.length; i++) {
|
|
14084
|
-
const sourcePoi = sourcePois[i];
|
|
14085
|
-
try {
|
|
14086
|
-
const existsInTarget = targetPois.some(tp => tp.coordinate === sourcePoi.coordinate && tp.tagDescription === sourcePoi.tagDescription);
|
|
14087
|
-
if (!existsInTarget) {
|
|
14088
|
-
const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
|
|
14089
|
-
const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
|
|
14090
|
-
let elementID = sourcePoi.elementID;
|
|
14091
|
-
if (elementID && elementIds[elementID]) {
|
|
14092
|
-
elementID = elementIds[elementID];
|
|
14093
|
-
}
|
|
14094
|
-
const copiedTagIcon = await this.duplicateTagIcon(sourcePoi.tagIcon);
|
|
14095
|
-
const copiedTagImageID = await this.duplicateTagImageID(sourcePoi.tagImageID);
|
|
14096
|
-
const newPoi = {
|
|
14097
|
-
coordinate: JSON.stringify(adjustedCoordinate),
|
|
14098
|
-
type: sourcePoi.type,
|
|
14099
|
-
elementID: elementID,
|
|
14100
|
-
tagDescription: sourcePoi.tagDescription,
|
|
14101
|
-
tagIcon: copiedTagIcon,
|
|
14102
|
-
tagImageID: copiedTagImageID,
|
|
14103
|
-
virtual: sourcePoi.virtual,
|
|
14104
|
-
xRotation: sourcePoi.xRotation,
|
|
14105
|
-
yRotation: sourcePoi.yRotation,
|
|
14106
|
-
metadata: sourcePoi.metadata,
|
|
14107
|
-
matterportSweepID: targetSweepId
|
|
14108
|
-
};
|
|
14109
|
-
await this.poiService.createPOIForSpaceID(newPoi);
|
|
14110
|
-
}
|
|
14111
|
-
}
|
|
14112
|
-
catch (itemError) {
|
|
14113
|
-
console.error(`Error copying POI (sweep: ${sourcePoi?.matterportSweepID}):`, itemError);
|
|
14114
|
-
errorCount++;
|
|
14115
|
-
this.logCopyItemError(itemError, 'errorPoi', sourcePoi?.matterportSweepID);
|
|
14116
|
-
}
|
|
14117
|
-
processedOverall++;
|
|
14118
|
-
emitProgress('pois', i + 1, sourcePois.length);
|
|
14119
|
-
const pct = sourcePois.length > 0 ? Math.round(((i + 1) / sourcePois.length) * 100) : 100;
|
|
14120
|
-
this.setStepProgress('pois', 'pois', pct);
|
|
14121
|
-
}
|
|
14122
14696
|
// OBJECTS 3D (the 3D model file itself — `path` — is physically duplicated in S3)
|
|
14123
14697
|
for (let i = 0; i < sourceObjects3D.length; i++) {
|
|
14124
14698
|
const sourceObj = sourceObjects3D[i];
|
|
@@ -14126,8 +14700,16 @@ class MatterportTagCopyService {
|
|
|
14126
14700
|
const existsInTarget = targetObjects3D.some(targetObj => targetObj.name === sourceObj.name);
|
|
14127
14701
|
if (!existsInTarget) {
|
|
14128
14702
|
const rawSource = sourceObj;
|
|
14703
|
+
// `path` means two different things depending on `s3Bucket`, exactly as the
|
|
14704
|
+
// viewer reads it (see viewer.component.ts and matterport-object3d.service.ts):
|
|
14705
|
+
// - s3Bucket === true -> a key of a model file uploaded to S3, ours to duplicate;
|
|
14706
|
+
// - otherwise -> a reference into the 3D object library shipped with
|
|
14707
|
+
// the application ("3DObjects/objects/cube.dae" -> assets/3Dobjects/...),
|
|
14708
|
+
// which exists in every space and must be reused verbatim.
|
|
14709
|
+
// Copying unconditionally meant every library object failed with NoSuchKey and
|
|
14710
|
+
// the whole 3D object was skipped, model AND POI.
|
|
14129
14711
|
let copiedPath = rawSource.path;
|
|
14130
|
-
if (rawSource.path) {
|
|
14712
|
+
if (rawSource.path && rawSource.s3Bucket) {
|
|
14131
14713
|
copiedPath = await this.copyS3KeyIfPresent(rawSource.path, `3D object "${rawSource.name}"`);
|
|
14132
14714
|
}
|
|
14133
14715
|
/**
|
|
@@ -14157,39 +14739,65 @@ class MatterportTagCopyService {
|
|
|
14157
14739
|
// Ensure we return valid numbers
|
|
14158
14740
|
return array.map(n => isNaN(n) ? 0 : n);
|
|
14159
14741
|
};
|
|
14742
|
+
// This service is shared, but the two applications do NOT deploy the same
|
|
14743
|
+
// Object3D schema: `viewFrustum` exists only in smarterplan, and `scale`
|
|
14744
|
+
// is [Float] there but a plain Float in museum. Sending the smarterplan
|
|
14745
|
+
// shape to museum was rejected with "The variables input contains a field
|
|
14746
|
+
// that is not defined for input object type 'CreateObject3DInput'".
|
|
14747
|
+
//
|
|
14748
|
+
// The input therefore MIRRORS the source record instead of imposing a
|
|
14749
|
+
// shape: source and target live in the same deployment, so whatever the
|
|
14750
|
+
// source carries is exactly what the target accepts.
|
|
14160
14751
|
const object3DInput = {
|
|
14161
14752
|
path: copiedPath,
|
|
14753
|
+
// Tells the viewer how to interpret `path`. Dropping it turned an
|
|
14754
|
+
// S3-hosted model into a library reference on the copy, which the
|
|
14755
|
+
// viewer then tried to resolve against assets/ and failed to load.
|
|
14756
|
+
s3Bucket: !!rawSource.s3Bucket,
|
|
14162
14757
|
name: rawSource.name,
|
|
14163
14758
|
description: rawSource.description || "",
|
|
14164
|
-
viewFrustum: !!rawSource.viewFrustum,
|
|
14165
14759
|
spaceID: targetSpaceId,
|
|
14166
14760
|
position: toFloatArray(rawSource.position, [0, 0, 0]),
|
|
14167
|
-
rotation: toFloatArray(rawSource.rotation, [0, 0, 0])
|
|
14168
|
-
// Enforce scale to be at least 1.0 if it's invalid
|
|
14169
|
-
scale: toFloatArray(rawSource.scale, [1, 1, 1])
|
|
14761
|
+
rotation: toFloatArray(rawSource.rotation, [0, 0, 0])
|
|
14170
14762
|
};
|
|
14171
|
-
|
|
14172
|
-
|
|
14763
|
+
if ('viewFrustum' in rawSource) {
|
|
14764
|
+
object3DInput.viewFrustum = !!rawSource.viewFrustum;
|
|
14765
|
+
}
|
|
14766
|
+
// Scale keeps the source's own shape: a list stays a list, a scalar stays
|
|
14767
|
+
// a scalar. An absent scale is left unset rather than invented, so the copy
|
|
14768
|
+
// renders exactly like the original.
|
|
14769
|
+
if (Array.isArray(rawSource.scale)) {
|
|
14770
|
+
object3DInput.scale = toFloatArray(rawSource.scale, [1, 1, 1]);
|
|
14771
|
+
}
|
|
14772
|
+
else if (rawSource.scale !== null && rawSource.scale !== undefined) {
|
|
14773
|
+
const scalarScale = parseFloat(rawSource.scale);
|
|
14774
|
+
object3DInput.scale = Number.isNaN(scalarScale) ? 1 : scalarScale;
|
|
14775
|
+
}
|
|
14173
14776
|
const createdObj = await this.object3DService.create(object3DInput);
|
|
14174
14777
|
if (createdObj && createdObj.id) {
|
|
14175
14778
|
elementIds[sourceObj.id] = createdObj.id;
|
|
14176
14779
|
}
|
|
14177
|
-
|
|
14178
|
-
|
|
14179
|
-
const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
|
|
14180
|
-
const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
|
|
14181
|
-
const poiInput = {
|
|
14182
|
-
coordinate: JSON.stringify(adjustedCoordinate),
|
|
14183
|
-
type: sourcePoi.type,
|
|
14184
|
-
elementID: createdObj.id,
|
|
14185
|
-
metadata: sourcePoi.metadata,
|
|
14186
|
-
matterportSweepID: targetSweepId
|
|
14187
|
-
};
|
|
14188
|
-
await this.poiService.createPOIForSpaceID(poiInput);
|
|
14780
|
+
for (const sourcePoi of (sourceObj.pois?.items || []).filter(p => !!p)) {
|
|
14781
|
+
await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
|
|
14189
14782
|
}
|
|
14190
14783
|
}
|
|
14191
14784
|
}
|
|
14192
14785
|
catch (itemError) {
|
|
14786
|
+
// The backend can deny creation of this whole model to the signed-in account
|
|
14787
|
+
// (in MUSEUM, Object3D only grants get/list to userPools). The denial is not
|
|
14788
|
+
// per-item, so every remaining object would fail identically: report it once,
|
|
14789
|
+
// as a permission problem rather than a copy error, and stop the phase instead
|
|
14790
|
+
// of firing one doomed mutation per object.
|
|
14791
|
+
if (this.isUnauthorized(itemError)) {
|
|
14792
|
+
const remaining = sourceObjects3D.length - i;
|
|
14793
|
+
console.warn(`[MatterportTagCopyService] Not allowed to create 3D objects in the target space, skipping ${remaining} of them.`, itemError);
|
|
14794
|
+
errorCount++;
|
|
14795
|
+
this.logItemError('errorObjects3dNotAllowed', { count: remaining });
|
|
14796
|
+
processedOverall += remaining;
|
|
14797
|
+
emitProgress('objects3d', sourceObjects3D.length, sourceObjects3D.length);
|
|
14798
|
+
this.setStepProgress('objects3d', 'objects3d', 100);
|
|
14799
|
+
break;
|
|
14800
|
+
}
|
|
14193
14801
|
console.error(`Error copying object3D "${sourceObj?.name}":`, itemError);
|
|
14194
14802
|
errorCount++;
|
|
14195
14803
|
this.logCopyItemError(itemError, 'errorObject3d', sourceObj?.name || sourceObj?.id);
|
|
@@ -14199,76 +14807,86 @@ class MatterportTagCopyService {
|
|
|
14199
14807
|
const pct = sourceObjects3D.length > 0 ? Math.round(((i + 1) / sourceObjects3D.length) * 100) : 100;
|
|
14200
14808
|
this.setStepProgress('objects3d', 'objects3d', pct);
|
|
14201
14809
|
}
|
|
14202
|
-
// FEATURES
|
|
14203
|
-
//
|
|
14810
|
+
// FEATURES — the "POI" of the MUSEUM module: its own record (title, index, locale,
|
|
14811
|
+
// keywords), its media set (one Comment per image/video/audio/document/link/text)
|
|
14812
|
+
// and its POI record (the pastille: icon, tag description, cover image).
|
|
14813
|
+
// Everything is copied here, files physically duplicated in S3 first.
|
|
14204
14814
|
for (let i = 0; i < sourceFeatures.length; i++) {
|
|
14205
14815
|
const sourceObj = sourceFeatures[i];
|
|
14206
14816
|
try {
|
|
14207
|
-
const existsInTarget = this.matchesExisting(sourceObj, targetFeatures);
|
|
14817
|
+
const existsInTarget = this.matchesExisting(sourceObj, targetFeatures, (f) => [f.title, f.description]);
|
|
14208
14818
|
if (!existsInTarget) {
|
|
14209
14819
|
const rawSource = sourceObj;
|
|
14820
|
+
// Resolved just before the write: a keyword is only created in the target
|
|
14821
|
+
// space when the POI carrying it is about to be created.
|
|
14822
|
+
const resolvedHashtags = await this.resolveHashtagIds(rawSource.hashtags, targetSpaceId);
|
|
14210
14823
|
const featureInput = {
|
|
14211
14824
|
title: rawSource.title,
|
|
14212
14825
|
shortDescription: rawSource.shortDescription,
|
|
14213
14826
|
level: rawSource.level,
|
|
14214
14827
|
type: rawSource.type,
|
|
14215
|
-
|
|
14828
|
+
// `index` drives the ordering of the POI in the space's list, in the
|
|
14829
|
+
// lateral menu and in the guided tour. Dropping it forced a full manual
|
|
14830
|
+
// renumbering of every copied POI.
|
|
14831
|
+
index: rawSource.index ?? null,
|
|
14832
|
+
// Missions are per-space: source mission ids mean nothing in the target
|
|
14833
|
+
// space, so an archive list carried over would hide POIs from nobody
|
|
14834
|
+
// while looking like real data. Reset instead.
|
|
14835
|
+
archivedForMissionID: null,
|
|
14216
14836
|
stripeProductID: rawSource.stripeProductID || null,
|
|
14217
14837
|
stripePriceID: rawSource.stripePriceID || null,
|
|
14218
14838
|
bookingPriceMetadata: rawSource.bookingPriceMetadata || null,
|
|
14219
14839
|
locale: rawSource.locale || null,
|
|
14220
|
-
|
|
14840
|
+
// Hashtags are space-scoped records: the ids must be translated to the
|
|
14841
|
+
// equivalent hashtags of the target space (created on demand).
|
|
14842
|
+
hashtags: resolvedHashtags,
|
|
14221
14843
|
description: rawSource.description || null,
|
|
14222
|
-
|
|
14844
|
+
// Domains are organisation-scoped, so the category survives a copy
|
|
14845
|
+
// inside one organisation and only has to be dropped across two.
|
|
14846
|
+
domainID: this.sameOrganisation ? (rawSource.domainID || null) : null,
|
|
14223
14847
|
spaceID: targetSpaceId,
|
|
14224
14848
|
ownerMissionID: targetMissionId
|
|
14225
14849
|
};
|
|
14226
14850
|
const createdObj = await this.featureService.createFeature(featureInput);
|
|
14227
14851
|
if (createdObj && createdObj.id) {
|
|
14228
14852
|
elementIds[sourceObj.id] = createdObj.id;
|
|
14853
|
+
// The feature exists: its keywords are now genuinely referenced and
|
|
14854
|
+
// must survive the end-of-run cleanup.
|
|
14855
|
+
this.markHashtagsUsed(resolvedHashtags);
|
|
14229
14856
|
}
|
|
14230
|
-
if (createdObj
|
|
14231
|
-
|
|
14857
|
+
if (createdObj) {
|
|
14858
|
+
// Read the media set through its own paginated query: the `comments`
|
|
14859
|
+
// connection embedded in FeaturesBySpace is capped at 100 items and its
|
|
14860
|
+
// nextToken was never followed, so rich POIs lost attachments silently.
|
|
14861
|
+
let sourceComments = [];
|
|
14862
|
+
try {
|
|
14863
|
+
sourceComments = await this.commentService.getAllCommentsForFeature(sourceObj.id);
|
|
14864
|
+
}
|
|
14865
|
+
catch (commentsFetchError) {
|
|
14866
|
+
console.warn(`Falling back to the embedded comment list for feature "${rawSource.title}":`, commentsFetchError);
|
|
14867
|
+
sourceComments = (sourceObj.comments?.items || []).filter(c => !!c);
|
|
14868
|
+
}
|
|
14869
|
+
for (const sourceComment of this.orderCommentsForCopy(sourceComments)) {
|
|
14232
14870
|
try {
|
|
14233
|
-
const
|
|
14234
|
-
|
|
14235
|
-
|
|
14236
|
-
copiedAnnexe = await this.copyS3KeyIfPresent(rawComment.annexe, `feature attachment "${rawComment.title}"`);
|
|
14871
|
+
const created = await this.copyCommentForFeature(sourceComment, createdObj.id, targetSpaceId, targetMissionId);
|
|
14872
|
+
if (created?.id && sourceComment.id) {
|
|
14873
|
+
this.commentIds.set(sourceComment.id, created.id);
|
|
14237
14874
|
}
|
|
14238
|
-
const commentInput = {
|
|
14239
|
-
title: rawComment.title,
|
|
14240
|
-
type: rawComment.type,
|
|
14241
|
-
annexe: copiedAnnexe,
|
|
14242
|
-
externalLink: rawComment.externalLink || null,
|
|
14243
|
-
dimensions: rawComment.dimensions || null,
|
|
14244
|
-
index: rawComment.index,
|
|
14245
|
-
shownInTag: rawComment.shownInTag,
|
|
14246
|
-
isCoverImage: rawComment.isCoverImage,
|
|
14247
|
-
featureID: createdObj.id,
|
|
14248
|
-
spaceID: targetSpaceId,
|
|
14249
|
-
ownerID: targetMissionId
|
|
14250
|
-
};
|
|
14251
|
-
await this.commentService.createComment(commentInput);
|
|
14252
14875
|
}
|
|
14253
14876
|
catch (attachmentError) {
|
|
14254
14877
|
console.error(`Error copying attachment "${sourceComment?.title}" for feature "${sourceObj?.title}":`, attachmentError);
|
|
14255
14878
|
errorCount++;
|
|
14256
|
-
this.logCopyItemError(attachmentError, 'errorFeatureAttachment', sourceComment?.title || sourceComment?.id
|
|
14879
|
+
this.logCopyItemError(attachmentError, 'errorFeatureAttachment', sourceComment?.title || sourceComment?.id, {
|
|
14880
|
+
feature: rawSource.title || sourceObj.id
|
|
14881
|
+
});
|
|
14257
14882
|
}
|
|
14258
14883
|
}
|
|
14259
|
-
|
|
14260
|
-
|
|
14261
|
-
const
|
|
14262
|
-
const
|
|
14263
|
-
|
|
14264
|
-
|
|
14265
|
-
coordinate: JSON.stringify(adjustedCoordinate),
|
|
14266
|
-
type: sourcePoi.type,
|
|
14267
|
-
elementID: createdObj.id,
|
|
14268
|
-
metadata: sourcePoi.metadata,
|
|
14269
|
-
matterportSweepID: targetSweepId
|
|
14270
|
-
};
|
|
14271
|
-
await this.poiService.createPOIForSpaceID(poiInput);
|
|
14884
|
+
// Every POI of the feature, not only the first one: a feature may be
|
|
14885
|
+
// pinned at several places, and only this phase knows the new element id.
|
|
14886
|
+
const sourceFeaturePois = (sourceObj.pois?.items || []).filter(p => !!p);
|
|
14887
|
+
for (const sourcePoi of sourceFeaturePois) {
|
|
14888
|
+
await this.createCopiedPoi(sourcePoi, createdObj.id, sourceNavPositions, targetNavPositions, transform);
|
|
14889
|
+
}
|
|
14272
14890
|
}
|
|
14273
14891
|
}
|
|
14274
14892
|
}
|
|
@@ -14297,12 +14915,17 @@ class MatterportTagCopyService {
|
|
|
14297
14915
|
}
|
|
14298
14916
|
const commentInput = {
|
|
14299
14917
|
title: rawComment.title,
|
|
14918
|
+
description: rawComment.description ?? null,
|
|
14919
|
+
metadata: rawComment.metadata ?? null,
|
|
14300
14920
|
type: CommentType.MEASURE,
|
|
14301
14921
|
dimensions: rawComment.dimensions || null,
|
|
14302
14922
|
annexe: copiedAnnexe,
|
|
14303
14923
|
spaceID: targetSpaceId,
|
|
14304
14924
|
};
|
|
14305
14925
|
const createdComment = await this.commentService.createComment(commentInput);
|
|
14926
|
+
if (createdComment && createdComment.id && rawComment.id) {
|
|
14927
|
+
this.commentIds.set(rawComment.id, createdComment.id);
|
|
14928
|
+
}
|
|
14306
14929
|
if (createdComment && sourceMeasurement.poi) {
|
|
14307
14930
|
const targetSweepId = this.findClosestTargetSweepId(sourceMeasurement.poi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
|
|
14308
14931
|
const adjustedPosition = this.getAdjustedPositionOnly(sourceMeasurement.poi.coordinate, transform);
|
|
@@ -14310,7 +14933,9 @@ class MatterportTagCopyService {
|
|
|
14310
14933
|
coordinate: JSON.stringify(adjustedPosition),
|
|
14311
14934
|
type: PoiType.MEASURE,
|
|
14312
14935
|
elementID: createdComment.id,
|
|
14313
|
-
|
|
14936
|
+
// Same treatment as any other POI: the source's Matterport tag
|
|
14937
|
+
// SID must not travel, and the stem normal has to be rotated.
|
|
14938
|
+
metadata: this.getAdjustedPoiMetadata(sourceMeasurement.poi.metadata, transform),
|
|
14314
14939
|
matterportSweepID: targetSweepId
|
|
14315
14940
|
};
|
|
14316
14941
|
await this.poiService.createPOIForSpaceID(poiInput);
|
|
@@ -14328,6 +14953,36 @@ class MatterportTagCopyService {
|
|
|
14328
14953
|
this.setStepProgress('measurements', 'measurements', pct);
|
|
14329
14954
|
}
|
|
14330
14955
|
}
|
|
14956
|
+
// POIS — standalone ones only: every POI owned by an element was already created by
|
|
14957
|
+
// that element's phase, with the element's NEW id.
|
|
14958
|
+
//
|
|
14959
|
+
// This phase runs LAST on purpose. It used to run third, before 3D objects, features
|
|
14960
|
+
// and measurements existed in the target space, so `elementIds` and `commentIds` were
|
|
14961
|
+
// still empty when it needed them: elementID stayed pointing at the source space and
|
|
14962
|
+
// tagImageID (a reference to a Comment record) could never be resolved.
|
|
14963
|
+
for (let i = 0; i < sourcePois.length; i++) {
|
|
14964
|
+
const sourcePoi = sourcePois[i];
|
|
14965
|
+
try {
|
|
14966
|
+
const existsInTarget = targetPois.some(tp => tp.coordinate === sourcePoi.coordinate && tp.tagDescription === sourcePoi.tagDescription);
|
|
14967
|
+
if (!existsInTarget) {
|
|
14968
|
+
const elementID = sourcePoi.elementID
|
|
14969
|
+
? (elementIds[sourcePoi.elementID] || sourcePoi.elementID)
|
|
14970
|
+
: sourcePoi.elementID;
|
|
14971
|
+
await this.createCopiedPoi(sourcePoi, elementID, sourceNavPositions, targetNavPositions, transform);
|
|
14972
|
+
}
|
|
14973
|
+
}
|
|
14974
|
+
catch (itemError) {
|
|
14975
|
+
console.error(`Error copying POI (sweep: ${sourcePoi?.matterportSweepID}):`, itemError);
|
|
14976
|
+
errorCount++;
|
|
14977
|
+
this.logCopyItemError(itemError, 'errorPoi', sourcePoi?.matterportSweepID, {
|
|
14978
|
+
sweep: sourcePoi?.matterportSweepID
|
|
14979
|
+
});
|
|
14980
|
+
}
|
|
14981
|
+
processedOverall++;
|
|
14982
|
+
emitProgress('pois', i + 1, sourcePois.length);
|
|
14983
|
+
const pct = sourcePois.length > 0 ? Math.round(((i + 1) / sourcePois.length) * 100) : 100;
|
|
14984
|
+
this.setStepProgress('pois', 'pois', pct);
|
|
14985
|
+
}
|
|
14331
14986
|
this.copyProgress$.next({
|
|
14332
14987
|
phase: 'done',
|
|
14333
14988
|
processedInPhase: 0, totalInPhase: 0,
|
|
@@ -14345,6 +15000,12 @@ class MatterportTagCopyService {
|
|
|
14345
15000
|
this.copyProgress$.next(null);
|
|
14346
15001
|
return false;
|
|
14347
15002
|
}
|
|
15003
|
+
finally {
|
|
15004
|
+
// Every exit path — success, partial success, or general failure — gives back the
|
|
15005
|
+
// keywords this run created but no copied POI ended up using. Placed in `finally`
|
|
15006
|
+
// so an exception thrown mid-copy cannot leave them behind either.
|
|
15007
|
+
await this.cleanupUnusedCreatedHashtags();
|
|
15008
|
+
}
|
|
14348
15009
|
}
|
|
14349
15010
|
//
|
|
14350
15011
|
// ---------- Progress / log helpers ----------
|
|
@@ -14373,6 +15034,20 @@ class MatterportTagCopyService {
|
|
|
14373
15034
|
this.errorSeq = 0;
|
|
14374
15035
|
this.copyLog$.next([]);
|
|
14375
15036
|
}
|
|
15037
|
+
/** Clears every id map carried over from a previous run. The service is a root-level
|
|
15038
|
+
* singleton, so leftover mappings would silently link a new copy to records created
|
|
15039
|
+
* during the previous one. */
|
|
15040
|
+
resetRunState(targetSpaceId) {
|
|
15041
|
+
this.elementIds = {};
|
|
15042
|
+
this.commentIds = new Map();
|
|
15043
|
+
this.sourceHashtagNames = new Map();
|
|
15044
|
+
this.targetHashtagIdsByName = new Map();
|
|
15045
|
+
this.createdHashtagIds = new Set();
|
|
15046
|
+
this.usedHashtagIds = new Set();
|
|
15047
|
+
this.handledElementIds = new Set();
|
|
15048
|
+
this.sameOrganisation = false;
|
|
15049
|
+
this.targetSpaceId = targetSpaceId;
|
|
15050
|
+
}
|
|
14376
15051
|
/** Builds and sets a step's log line as "<translated label>: <percent>%", no step numbering. */
|
|
14377
15052
|
setStepProgress(stepId, translationKey, percent) {
|
|
14378
15053
|
const label = this.translate.instant(`matterportCopy.${translationKey}`);
|
|
@@ -14386,28 +15061,408 @@ class MatterportTagCopyService {
|
|
|
14386
15061
|
/** Logs an item-level error, using a specific "missing file / orphaned reference"
|
|
14387
15062
|
* translation key when the cause is a MissingS3FileError, and the generic per-type
|
|
14388
15063
|
* key otherwise. Keeps the copy log readable: a missing file is not a bug to
|
|
14389
|
-
* investigate the same way a network/permission error is.
|
|
14390
|
-
|
|
15064
|
+
* investigate the same way a network/permission error is.
|
|
15065
|
+
* `extraParams` carries the placeholders a few keys need on top of {{name}}/{{error}}
|
|
15066
|
+
* (e.g. errorFeatureAttachment's {{feature}}, errorPoi's {{sweep}}), which used to be
|
|
15067
|
+
* rendered as raw "{{feature}}" text in the log. */
|
|
15068
|
+
logCopyItemError(error, genericKey, itemLabel, extraParams = {}) {
|
|
14391
15069
|
if (error instanceof MissingS3FileError) {
|
|
14392
15070
|
this.logItemError('errorMissingS3FileSkipped', {
|
|
15071
|
+
...extraParams,
|
|
14393
15072
|
name: itemLabel,
|
|
14394
15073
|
file: error.sourceKey
|
|
14395
15074
|
});
|
|
14396
15075
|
}
|
|
14397
15076
|
else {
|
|
14398
|
-
this.logItemError(genericKey, { name: itemLabel, error: toErrorMessage(error) });
|
|
15077
|
+
this.logItemError(genericKey, { ...extraParams, name: itemLabel, error: toErrorMessage(error) });
|
|
15078
|
+
}
|
|
15079
|
+
}
|
|
15080
|
+
/**
|
|
15081
|
+
* Null-safe equivalent of FeatureService.isArchived(): true when the operator has archived
|
|
15082
|
+
* this feature on that space. "No mission on the space" answers false (nothing archived)
|
|
15083
|
+
* instead of throwing, which is what made a copy from a space the operator is not a member
|
|
15084
|
+
* of fail outright.
|
|
15085
|
+
*/
|
|
15086
|
+
isArchivedForOperator(feature, spaceId) {
|
|
15087
|
+
const archivedFor = feature?.archivedForMissionID;
|
|
15088
|
+
if (!archivedFor || archivedFor.length === 0) {
|
|
15089
|
+
return false;
|
|
14399
15090
|
}
|
|
15091
|
+
const mission = this.userService.currentMission(spaceId);
|
|
15092
|
+
return mission?.id ? archivedFor.includes(mission.id) : false;
|
|
15093
|
+
}
|
|
15094
|
+
//
|
|
15095
|
+
// ---------- Cross-space reference remapping ----------
|
|
15096
|
+
//
|
|
15097
|
+
/**
|
|
15098
|
+
* True when both spaces belong to the same organisation.
|
|
15099
|
+
* Domains (POI/Feature categories) are organisation-scoped records with no spaceID, so
|
|
15100
|
+
* a domainID stays meaningful inside one organisation and only becomes a dangling
|
|
15101
|
+
* reference across two. A failed lookup answers "no": dropping a category is recoverable,
|
|
15102
|
+
* pointing at another organisation's domain is not.
|
|
15103
|
+
*/
|
|
15104
|
+
async spacesShareOrganisation(sourceSpaceId, targetSpaceId) {
|
|
15105
|
+
try {
|
|
15106
|
+
const [sourceSpace, targetSpace] = await Promise.all([
|
|
15107
|
+
this.spaceService.getSpace(sourceSpaceId),
|
|
15108
|
+
this.spaceService.getSpace(targetSpaceId)
|
|
15109
|
+
]);
|
|
15110
|
+
const sourceOrg = sourceSpace?.organisationID;
|
|
15111
|
+
const targetOrg = targetSpace?.organisationID;
|
|
15112
|
+
return !!sourceOrg && !!targetOrg && sourceOrg === targetOrg;
|
|
15113
|
+
}
|
|
15114
|
+
catch (error) {
|
|
15115
|
+
console.warn('[MatterportTagCopyService] Could not compare the spaces\' organisations, categories will be dropped:', error);
|
|
15116
|
+
return false;
|
|
15117
|
+
}
|
|
15118
|
+
}
|
|
15119
|
+
/** Normalised lookup key for a hashtag name, so "Moyen Âge" and "moyen âge " match. */
|
|
15120
|
+
hashtagKey(name) {
|
|
15121
|
+
return name.trim().toLowerCase();
|
|
15122
|
+
}
|
|
15123
|
+
/**
|
|
15124
|
+
* READ-ONLY indexing of both spaces' hashtags. Creates nothing.
|
|
15125
|
+
*
|
|
15126
|
+
* `Feature.hashtags` stores Hashtag *ids*, and a Hashtag is a per-space record, so the
|
|
15127
|
+
* ids have to be translated by name. The translation table is built here; the missing
|
|
15128
|
+
* keywords are created later, one at a time, only when a POI that actually uses them is
|
|
15129
|
+
* about to be written (see resolveHashtagIds()).
|
|
15130
|
+
*/
|
|
15131
|
+
async loadHashtagIndex(sourceSpaceId, targetSpaceId) {
|
|
15132
|
+
let sourceHashtags = [];
|
|
15133
|
+
let targetHashtags = [];
|
|
15134
|
+
try {
|
|
15135
|
+
[sourceHashtags, targetHashtags] = await Promise.all([
|
|
15136
|
+
this.hashtagService.listHashtagsForSpace(sourceSpaceId),
|
|
15137
|
+
this.hashtagService.listHashtagsForSpace(targetSpaceId)
|
|
15138
|
+
]);
|
|
15139
|
+
}
|
|
15140
|
+
catch (error) {
|
|
15141
|
+
console.warn('[MatterportTagCopyService] Could not read the hashtags, keywords will be dropped:', error);
|
|
15142
|
+
return;
|
|
15143
|
+
}
|
|
15144
|
+
for (const hashtag of sourceHashtags || []) {
|
|
15145
|
+
if (hashtag?.id && hashtag.name) {
|
|
15146
|
+
this.sourceHashtagNames.set(hashtag.id, hashtag.name);
|
|
15147
|
+
}
|
|
15148
|
+
}
|
|
15149
|
+
for (const hashtag of targetHashtags || []) {
|
|
15150
|
+
if (hashtag?.id && hashtag.name) {
|
|
15151
|
+
this.targetHashtagIdsByName.set(this.hashtagKey(hashtag.name), hashtag.id);
|
|
15152
|
+
}
|
|
15153
|
+
}
|
|
15154
|
+
}
|
|
15155
|
+
/**
|
|
15156
|
+
* Translates one feature's hashtag ids into target-space ids, creating on the fly the
|
|
15157
|
+
* keywords the target space does not have yet.
|
|
15158
|
+
*
|
|
15159
|
+
* Creation is LAZY on purpose. Doing it up front, for the whole source vocabulary, meant
|
|
15160
|
+
* that a copy which failed or was closed halfway left behind keywords no POI referenced —
|
|
15161
|
+
* pollution the operator had to clean up by hand. Here a keyword is only ever written
|
|
15162
|
+
* when a POI that carries it is about to be created, so an interrupted copy stops
|
|
15163
|
+
* creating keywords at the same POI it stops creating content.
|
|
15164
|
+
*
|
|
15165
|
+
* The residual window (keyword created, then createFeature fails) is closed by
|
|
15166
|
+
* markHashtagsUsed() + cleanupUnusedCreatedHashtags().
|
|
15167
|
+
*
|
|
15168
|
+
* A keyword that can neither be found nor created is dropped rather than left as a
|
|
15169
|
+
* dangling id.
|
|
15170
|
+
*/
|
|
15171
|
+
async resolveHashtagIds(sourceHashtagIds, targetSpaceId) {
|
|
15172
|
+
if (!sourceHashtagIds || sourceHashtagIds.length === 0) {
|
|
15173
|
+
return null;
|
|
15174
|
+
}
|
|
15175
|
+
const resolved = [];
|
|
15176
|
+
for (const sourceId of sourceHashtagIds) {
|
|
15177
|
+
if (!sourceId) {
|
|
15178
|
+
continue;
|
|
15179
|
+
}
|
|
15180
|
+
const name = this.sourceHashtagNames.get(sourceId);
|
|
15181
|
+
if (!name) {
|
|
15182
|
+
// the source id points at a hashtag that no longer exists — nothing to carry over
|
|
15183
|
+
continue;
|
|
15184
|
+
}
|
|
15185
|
+
const key = this.hashtagKey(name);
|
|
15186
|
+
let targetId = this.targetHashtagIdsByName.get(key);
|
|
15187
|
+
if (!targetId) {
|
|
15188
|
+
try {
|
|
15189
|
+
const created = await this.hashtagService.createHashtagForSpace(name, targetSpaceId);
|
|
15190
|
+
targetId = created?.id;
|
|
15191
|
+
if (targetId) {
|
|
15192
|
+
this.targetHashtagIdsByName.set(key, targetId);
|
|
15193
|
+
this.createdHashtagIds.add(targetId);
|
|
15194
|
+
}
|
|
15195
|
+
}
|
|
15196
|
+
catch (error) {
|
|
15197
|
+
console.warn(`[MatterportTagCopyService] Could not create the hashtag "${name}" in the target space:`, error);
|
|
15198
|
+
continue;
|
|
15199
|
+
}
|
|
15200
|
+
}
|
|
15201
|
+
if (targetId && !resolved.includes(targetId)) {
|
|
15202
|
+
resolved.push(targetId);
|
|
15203
|
+
}
|
|
15204
|
+
}
|
|
15205
|
+
return resolved.length > 0 ? resolved : null;
|
|
15206
|
+
}
|
|
15207
|
+
/** Records that these hashtags are now referenced by a feature that really exists, so
|
|
15208
|
+
* the end-of-run cleanup leaves them alone. */
|
|
15209
|
+
markHashtagsUsed(hashtagIds) {
|
|
15210
|
+
for (const id of hashtagIds || []) {
|
|
15211
|
+
this.usedHashtagIds.add(id);
|
|
15212
|
+
}
|
|
15213
|
+
}
|
|
15214
|
+
/**
|
|
15215
|
+
* Deletes the hashtags this run created that no copied POI ended up referencing.
|
|
15216
|
+
*
|
|
15217
|
+
* Runs on every exit path — success, general failure, or exception — so the target space
|
|
15218
|
+
* never keeps keywords that came from an aborted copy. Only ids created by THIS run are
|
|
15219
|
+
* ever considered: a pre-existing keyword of the target space is never touched, even if
|
|
15220
|
+
* no copied POI uses it.
|
|
15221
|
+
*/
|
|
15222
|
+
async cleanupUnusedCreatedHashtags() {
|
|
15223
|
+
const orphans = [...this.createdHashtagIds].filter(id => !this.usedHashtagIds.has(id));
|
|
15224
|
+
if (orphans.length === 0) {
|
|
15225
|
+
return;
|
|
15226
|
+
}
|
|
15227
|
+
console.log(`[MatterportTagCopyService] Removing ${orphans.length} keyword(s) created but left unused by this copy.`);
|
|
15228
|
+
for (const hashtagId of orphans) {
|
|
15229
|
+
try {
|
|
15230
|
+
await this.hashtagService.deleteHashtagById(hashtagId);
|
|
15231
|
+
this.createdHashtagIds.delete(hashtagId);
|
|
15232
|
+
}
|
|
15233
|
+
catch (error) {
|
|
15234
|
+
// Cleanup is best-effort: a leftover keyword is harmless next to a failed copy,
|
|
15235
|
+
// and must never be the reason the whole operation reports a failure.
|
|
15236
|
+
console.warn(`[MatterportTagCopyService] Could not remove the unused keyword "${hashtagId}":`, error);
|
|
15237
|
+
}
|
|
15238
|
+
}
|
|
15239
|
+
}
|
|
15240
|
+
//
|
|
15241
|
+
// ---------- Media content (Comments) ----------
|
|
15242
|
+
//
|
|
15243
|
+
/**
|
|
15244
|
+
* Orders a feature's media set exactly the way the UI will read it back, and repairs
|
|
15245
|
+
* the ordering key when the source data cannot express the user's intended order.
|
|
15246
|
+
*
|
|
15247
|
+
* The viewer sorts each media family on its own (`setImages`, `setVideos`, `setAudio`,
|
|
15248
|
+
* `setDocuments`, ... all do `.sort((a, b) => a.index - b.index)`), so `index` only has
|
|
15249
|
+
* to be consistent WITHIN a CommentType. The attachment form however defaults `index`
|
|
15250
|
+
* to 1 for every new attachment, so a multi-image POI very often holds several images
|
|
15251
|
+
* all sharing index 1 — or null. Sorting those is left to DynamoDB's arbitrary scan
|
|
15252
|
+
* order, which is why the copy came out in a different order than the original.
|
|
15253
|
+
*
|
|
15254
|
+
* Strategy: sort deterministically (index, then creation date, then id — i.e. the
|
|
15255
|
+
* upload order the user actually saw), and only rewrite the indices of a family when
|
|
15256
|
+
* they are ambiguous (null or duplicated). A family whose indices are already unique
|
|
15257
|
+
* is copied untouched, so a deliberate ordering is never overwritten.
|
|
15258
|
+
*/
|
|
15259
|
+
orderCommentsForCopy(comments) {
|
|
15260
|
+
const valid = (comments || []).filter(c => !!c);
|
|
15261
|
+
const byType = new Map();
|
|
15262
|
+
for (const comment of valid) {
|
|
15263
|
+
const key = comment.type || 'UNTYPED';
|
|
15264
|
+
if (!byType.has(key)) {
|
|
15265
|
+
byType.set(key, []);
|
|
15266
|
+
}
|
|
15267
|
+
byType.get(key).push(comment);
|
|
15268
|
+
}
|
|
15269
|
+
const ordered = [];
|
|
15270
|
+
for (const family of byType.values()) {
|
|
15271
|
+
family.sort((a, b) => {
|
|
15272
|
+
const ai = this.orderingIndexOf(a);
|
|
15273
|
+
const bi = this.orderingIndexOf(b);
|
|
15274
|
+
if (ai !== bi) {
|
|
15275
|
+
return ai - bi;
|
|
15276
|
+
}
|
|
15277
|
+
const ad = this.creationTimeOf(a);
|
|
15278
|
+
const bd = this.creationTimeOf(b);
|
|
15279
|
+
if (ad !== bd) {
|
|
15280
|
+
return ad - bd;
|
|
15281
|
+
}
|
|
15282
|
+
return String(a.id || '').localeCompare(String(b.id || ''));
|
|
15283
|
+
});
|
|
15284
|
+
const indices = family.map(c => c.index);
|
|
15285
|
+
const isAmbiguous = indices.some(i => i === null || i === undefined)
|
|
15286
|
+
|| new Set(indices).size !== indices.length;
|
|
15287
|
+
family.forEach((comment, position) => {
|
|
15288
|
+
comment.__copyIndex = isAmbiguous ? position + 1 : comment.index;
|
|
15289
|
+
});
|
|
15290
|
+
ordered.push(...family);
|
|
15291
|
+
}
|
|
15292
|
+
return ordered;
|
|
15293
|
+
}
|
|
15294
|
+
/** Sort key for a comment's position; a missing index sorts last, not first. */
|
|
15295
|
+
orderingIndexOf(comment) {
|
|
15296
|
+
const index = comment?.index;
|
|
15297
|
+
return index === null || index === undefined || Number.isNaN(Number(index))
|
|
15298
|
+
? Number.MAX_SAFE_INTEGER
|
|
15299
|
+
: Number(index);
|
|
15300
|
+
}
|
|
15301
|
+
/** Creation timestamp of a comment, used as the tie-breaker: it reflects the order in
|
|
15302
|
+
* which the user uploaded the media, which is the order they saw. */
|
|
15303
|
+
creationTimeOf(comment) {
|
|
15304
|
+
const createdAt = comment?.createdAt;
|
|
15305
|
+
if (!createdAt) {
|
|
15306
|
+
return 0;
|
|
15307
|
+
}
|
|
15308
|
+
const time = new Date(createdAt).getTime();
|
|
15309
|
+
return Number.isNaN(time) ? 0 : time;
|
|
15310
|
+
}
|
|
15311
|
+
/**
|
|
15312
|
+
* Copies ONE media item (image, video, audio, document, text, link, embed, iframe...)
|
|
15313
|
+
* of a feature, duplicating its file in S3 first when it has one.
|
|
15314
|
+
*
|
|
15315
|
+
* Every column of the Comment model is carried over. `description` and `metadata` were
|
|
15316
|
+
* previously dropped, which emptied the captions of images/videos, the body of TEXT
|
|
15317
|
+
* items — where the whole content lives, since a TEXT comment has no file — and any
|
|
15318
|
+
* per-media settings stored in metadata.
|
|
15319
|
+
*/
|
|
15320
|
+
async copyCommentForFeature(sourceComment, targetFeatureId, targetSpaceId, targetMissionId) {
|
|
15321
|
+
return this.copyComment(sourceComment, targetSpaceId, targetMissionId, { featureID: targetFeatureId });
|
|
15322
|
+
}
|
|
15323
|
+
/** Same, for an attachment carried by a ticket rather than by a feature. */
|
|
15324
|
+
async copyCommentForTicket(sourceComment, targetTicketId, targetSpaceId, targetMissionId) {
|
|
15325
|
+
return this.copyComment(sourceComment, targetSpaceId, targetMissionId, { ticketID: targetTicketId });
|
|
15326
|
+
}
|
|
15327
|
+
/** Shared body of the two above: `owner` carries whichever foreign key links the copy back
|
|
15328
|
+
* to its new parent, everything else is identical. */
|
|
15329
|
+
async copyComment(sourceComment, targetSpaceId, targetMissionId, owner) {
|
|
15330
|
+
const rawComment = sourceComment;
|
|
15331
|
+
let copiedAnnexe = null;
|
|
15332
|
+
if (rawComment.annexe) {
|
|
15333
|
+
copiedAnnexe = await this.copyS3KeyIfPresent(rawComment.annexe, `attachment "${rawComment.title}"`);
|
|
15334
|
+
}
|
|
15335
|
+
const commentInput = {
|
|
15336
|
+
title: rawComment.title,
|
|
15337
|
+
description: rawComment.description ?? null,
|
|
15338
|
+
metadata: rawComment.metadata ?? null,
|
|
15339
|
+
type: rawComment.type,
|
|
15340
|
+
annexe: copiedAnnexe,
|
|
15341
|
+
externalLink: rawComment.externalLink || null,
|
|
15342
|
+
dimensions: rawComment.dimensions || null,
|
|
15343
|
+
index: rawComment.__copyIndex ?? rawComment.index ?? null,
|
|
15344
|
+
shownInTag: rawComment.shownInTag,
|
|
15345
|
+
isCoverImage: rawComment.isCoverImage,
|
|
15346
|
+
spaceID: targetSpaceId,
|
|
15347
|
+
ownerID: targetMissionId,
|
|
15348
|
+
...owner
|
|
15349
|
+
};
|
|
15350
|
+
return this.commentService.createComment(commentInput);
|
|
15351
|
+
}
|
|
15352
|
+
//
|
|
15353
|
+
// ---------- POI creation ----------
|
|
15354
|
+
//
|
|
15355
|
+
/**
|
|
15356
|
+
* Creates the target-space copy of a source POI, with EVERY column of the POI model.
|
|
15357
|
+
*
|
|
15358
|
+
* Single entry point for all phases: the element phases (ticket, equipment, 3D object,
|
|
15359
|
+
* feature, measurement) used to build their POI inline with only 5 fields — coordinate,
|
|
15360
|
+
* type, elementID, metadata, sweep — which is precisely how museum POIs lost their
|
|
15361
|
+
* pastille: `tagDescription` (the popup text), `tagIcon` (the custom icon) and
|
|
15362
|
+
* `tagImageID` (the pastille's image) never left the source space.
|
|
15363
|
+
*/
|
|
15364
|
+
async createCopiedPoi(sourcePoi, elementID, sourceNavPositions, targetNavPositions, transform) {
|
|
15365
|
+
const raw = sourcePoi;
|
|
15366
|
+
const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
|
|
15367
|
+
const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
|
|
15368
|
+
const poiInput = {
|
|
15369
|
+
coordinate: adjustedCoordinate === null || adjustedCoordinate === undefined
|
|
15370
|
+
? null
|
|
15371
|
+
: JSON.stringify(adjustedCoordinate),
|
|
15372
|
+
type: sourcePoi.type,
|
|
15373
|
+
elementID: elementID ?? null,
|
|
15374
|
+
matterportSweepID: targetSweepId,
|
|
15375
|
+
// --- pastille (tag popup) ---
|
|
15376
|
+
tagDescription: sourcePoi.tagDescription ?? null,
|
|
15377
|
+
tagIcon: await this.duplicateTagIcon(sourcePoi.tagIcon),
|
|
15378
|
+
tagImageID: this.resolveTagImageID(sourcePoi.tagImageID),
|
|
15379
|
+
// --- rendering / visibility flags, carried over verbatim ---
|
|
15380
|
+
specific: raw.specific ?? null,
|
|
15381
|
+
private: raw.private ?? null,
|
|
15382
|
+
global: raw.global ?? null,
|
|
15383
|
+
level: raw.level ?? null,
|
|
15384
|
+
role: raw.role ?? null,
|
|
15385
|
+
category: raw.category ?? null,
|
|
15386
|
+
domainID: this.sameOrganisation ? (raw.domainID || null) : null,
|
|
15387
|
+
// --- Matterport anchoring ---
|
|
15388
|
+
metadata: this.getAdjustedPoiMetadata(sourcePoi.metadata, transform)
|
|
15389
|
+
};
|
|
15390
|
+
return this.poiService.createPOIForSpaceID(poiInput);
|
|
15391
|
+
}
|
|
15392
|
+
/**
|
|
15393
|
+
* Adapts a POI's `metadata` (see MattertagData.getMetadataString(): { icon, normal, sid })
|
|
15394
|
+
* to the target space.
|
|
15395
|
+
*
|
|
15396
|
+
* - `sid` is the id of the Mattertag inside the SOURCE Matterport model. Carried over,
|
|
15397
|
+
* the viewer tries to re-create the target's tag with an id belonging to another
|
|
15398
|
+
* model ("already in use"), so the pastille fails to attach. It is dropped: the
|
|
15399
|
+
* viewer then allocates a fresh sid on first render.
|
|
15400
|
+
* - `normal` is the stem direction. It is a direction, so only the rotation applies
|
|
15401
|
+
* (never the translation); without it, a copied pastille points into the wall
|
|
15402
|
+
* whenever the two models are not aligned on the same axis.
|
|
15403
|
+
* - `icon` and anything else is kept as-is.
|
|
15404
|
+
*/
|
|
15405
|
+
getAdjustedPoiMetadata(metadata, transform) {
|
|
15406
|
+
if (!metadata) {
|
|
15407
|
+
return metadata ?? null;
|
|
15408
|
+
}
|
|
15409
|
+
try {
|
|
15410
|
+
const parsed = JSON.parse(metadata);
|
|
15411
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
15412
|
+
return metadata;
|
|
15413
|
+
}
|
|
15414
|
+
if (parsed.normal
|
|
15415
|
+
&& typeof parsed.normal === 'object'
|
|
15416
|
+
&& typeof parsed.normal.x === 'number'
|
|
15417
|
+
&& typeof parsed.normal.y === 'number'
|
|
15418
|
+
&& typeof parsed.normal.z === 'number') {
|
|
15419
|
+
parsed.normal = applyRigidTransformToDirection(parsed.normal, transform);
|
|
15420
|
+
}
|
|
15421
|
+
delete parsed.sid;
|
|
15422
|
+
return JSON.stringify(parsed);
|
|
15423
|
+
}
|
|
15424
|
+
catch {
|
|
15425
|
+
// Not JSON, or unexpected shape — leave untouched rather than guess.
|
|
15426
|
+
return metadata;
|
|
15427
|
+
}
|
|
15428
|
+
}
|
|
15429
|
+
/**
|
|
15430
|
+
* Resolves a POI's `tagImageID` — which the schema documents as a "connection to
|
|
15431
|
+
* comment.id", i.e. the id of the Comment holding the image shown in the pastille.
|
|
15432
|
+
*
|
|
15433
|
+
* It is NOT an S3 key, so the previous "does it contain a slash?" heuristic never
|
|
15434
|
+
* matched and the value travelled unchanged, leaving every copied pastille pointing at
|
|
15435
|
+
* a Comment of the source space. It is now translated through the comment id map built
|
|
15436
|
+
* while duplicating the media, and dropped when it cannot be resolved (the pastille
|
|
15437
|
+
* then falls back to the feature's cover image instead of a broken reference).
|
|
15438
|
+
*/
|
|
15439
|
+
resolveTagImageID(tagImageID) {
|
|
15440
|
+
if (!tagImageID) {
|
|
15441
|
+
return null;
|
|
15442
|
+
}
|
|
15443
|
+
return this.commentIds.get(tagImageID) ?? null;
|
|
14400
15444
|
}
|
|
14401
15445
|
//
|
|
14402
15446
|
// ---------- Duplicate detection ----------
|
|
14403
15447
|
//
|
|
14404
15448
|
/**
|
|
14405
|
-
* Heuristic duplicate-detection: compares
|
|
14406
|
-
*
|
|
14407
|
-
*
|
|
15449
|
+
* Heuristic duplicate-detection: compares several identifying fields together rather than
|
|
15450
|
+
* a single label, to reduce false positives when items share one of them.
|
|
15451
|
+
*
|
|
15452
|
+
* The fields are supplied by the caller because the models do not agree on them. The
|
|
15453
|
+
* previous version hard-coded `title`/`description`, which do not exist on Equipment at
|
|
15454
|
+
* all: both sides read `undefined`, every equipment therefore "matched" every other one,
|
|
15455
|
+
* and a target space holding a single equipment silently caused the whole equipment phase
|
|
15456
|
+
* to copy nothing.
|
|
14408
15457
|
*/
|
|
14409
|
-
matchesExisting(source, targetList) {
|
|
14410
|
-
|
|
15458
|
+
matchesExisting(source, targetList, identity) {
|
|
15459
|
+
const sourceKey = this.identityKey(identity(source));
|
|
15460
|
+
return targetList.some(target => this.identityKey(identity(target)) === sourceKey);
|
|
15461
|
+
}
|
|
15462
|
+
/** Serialises the identifying fields unambiguously, so that ["a", "b"] and ["ab", ""]
|
|
15463
|
+
* can never produce the same key. */
|
|
15464
|
+
identityKey(parts) {
|
|
15465
|
+
return JSON.stringify(parts.map(part => part ?? ''));
|
|
14411
15466
|
}
|
|
14412
15467
|
//
|
|
14413
15468
|
// ---------- Sweep positions / rigid transform ----------
|
|
@@ -14501,10 +15556,12 @@ class MatterportTagCopyService {
|
|
|
14501
15556
|
/**
|
|
14502
15557
|
* Builds a new S3 key for a copied attachment and sanitizes the filename
|
|
14503
15558
|
* to prevent AWS Amplify SignatureDoesNotMatch errors.
|
|
15559
|
+
* `folderOverride` re-homes the copy into another prefix — used for tag icons, which
|
|
15560
|
+
* are stored per space (`icons-tags/<spaceId>/`) and must follow the target space.
|
|
14504
15561
|
*/
|
|
14505
|
-
buildCopiedAttachmentKey(sourceAnnexe) {
|
|
15562
|
+
buildCopiedAttachmentKey(sourceAnnexe, folderOverride = null) {
|
|
14506
15563
|
const lastSlash = sourceAnnexe.lastIndexOf('/');
|
|
14507
|
-
const folder = sourceAnnexe.substring(0, lastSlash + 1);
|
|
15564
|
+
const folder = folderOverride ?? sourceAnnexe.substring(0, lastSlash + 1);
|
|
14508
15565
|
let filename = sourceAnnexe.substring(lastSlash + 1);
|
|
14509
15566
|
// Strip a leading "<8 hex chars>_" id prefix if present
|
|
14510
15567
|
const prefixMatch = filename.match(/^[0-9a-fA-F-]{8}_(.+)$/);
|
|
@@ -14524,12 +15581,12 @@ class MatterportTagCopyService {
|
|
|
14524
15581
|
* skip the whole item entirely, rather than creating a record with a broken file link.
|
|
14525
15582
|
* - Any other kind of failure (permissions, network, etc.) still throws a generic Error.
|
|
14526
15583
|
*/
|
|
14527
|
-
async copyS3KeyIfPresent(key, contextLabel) {
|
|
15584
|
+
async copyS3KeyIfPresent(key, contextLabel, folderOverride = null) {
|
|
14528
15585
|
if (!key) {
|
|
14529
15586
|
return null;
|
|
14530
15587
|
}
|
|
14531
15588
|
const sourceKey = this.extractS3KeyFromValue(key);
|
|
14532
|
-
const destKey = this.buildCopiedAttachmentKey(sourceKey);
|
|
15589
|
+
const destKey = this.buildCopiedAttachmentKey(sourceKey, folderOverride);
|
|
14533
15590
|
// Log the source key to ensure it matches the real S3 object path
|
|
14534
15591
|
console.log(`[DEBUG] Attempting S3 copy from source: "${sourceKey}" to dest: "${destKey}"`);
|
|
14535
15592
|
try {
|
|
@@ -14540,49 +15597,101 @@ class MatterportTagCopyService {
|
|
|
14540
15597
|
return copied;
|
|
14541
15598
|
}
|
|
14542
15599
|
catch (error) {
|
|
14543
|
-
|
|
14544
|
-
|| error?.code === 'NoSuchKey'
|
|
14545
|
-
|| /NoSuchKey|does not exist/i.test(toErrorMessage(error));
|
|
14546
|
-
if (isMissingFile) {
|
|
15600
|
+
if (this.isMissingS3File(error)) {
|
|
14547
15601
|
console.warn(`[MatterportTagCopyService] Orphaned S3 reference, skipping item: "${sourceKey}" (${contextLabel})`);
|
|
14548
15602
|
throw new MissingS3FileError(sourceKey, contextLabel);
|
|
14549
15603
|
}
|
|
15604
|
+
// Storage.copy() cannot handle a key holding a space, an accent or a parenthesis:
|
|
15605
|
+
// it puts the key unencoded into `x-amz-copy-source`, which then disagrees with the
|
|
15606
|
+
// signed request. Those files are perfectly readable — only that header is broken —
|
|
15607
|
+
// so fall back to downloading and re-uploading them.
|
|
15608
|
+
if (this.isSignatureMismatch(error)) {
|
|
15609
|
+
console.warn(`[MatterportTagCopyService] Storage.copy() signature mismatch on "${sourceKey}", falling back to download + re-upload.`);
|
|
15610
|
+
try {
|
|
15611
|
+
const copied = await copyFileInS3ViaDownload(sourceKey, destKey);
|
|
15612
|
+
if (!copied) {
|
|
15613
|
+
throw new MissingS3FileError(sourceKey, contextLabel);
|
|
15614
|
+
}
|
|
15615
|
+
return copied;
|
|
15616
|
+
}
|
|
15617
|
+
catch (fallbackError) {
|
|
15618
|
+
if (this.isMissingS3File(fallbackError)) {
|
|
15619
|
+
throw new MissingS3FileError(sourceKey, contextLabel);
|
|
15620
|
+
}
|
|
15621
|
+
throw fallbackError;
|
|
15622
|
+
}
|
|
15623
|
+
}
|
|
14550
15624
|
throw error;
|
|
14551
15625
|
}
|
|
14552
15626
|
}
|
|
15627
|
+
/** True when the failure means the source object simply is not on S3 (orphaned reference). */
|
|
15628
|
+
isMissingS3File(error) {
|
|
15629
|
+
return error instanceof MissingS3FileError
|
|
15630
|
+
|| error?.code === 'NoSuchKey'
|
|
15631
|
+
|| error?.name === 'NoSuchKey'
|
|
15632
|
+
|| /NoSuchKey|does not exist|NotFound/i.test(toErrorMessage(error));
|
|
15633
|
+
}
|
|
15634
|
+
/** True when AppSync refused the operation for lack of permission, as opposed to failing it.
|
|
15635
|
+
* Nothing can be retried or worked around client-side: the account simply is not allowed
|
|
15636
|
+
* to write that model, which is a backend authorisation rule (@auth) to change. */
|
|
15637
|
+
isUnauthorized(error) {
|
|
15638
|
+
const message = toErrorMessage(error);
|
|
15639
|
+
return error?.errorType === 'Unauthorized'
|
|
15640
|
+
|| /Unauthorized|Not Authorized to access/i.test(message);
|
|
15641
|
+
}
|
|
15642
|
+
/** True for the SigV4 mismatch Storage.copy() raises on keys with special characters. */
|
|
15643
|
+
isSignatureMismatch(error) {
|
|
15644
|
+
return error?.code === 'SignatureDoesNotMatch'
|
|
15645
|
+
|| error?.name === 'SignatureDoesNotMatch'
|
|
15646
|
+
|| /signature we calculated does not match|SignatureDoesNotMatch/i.test(toErrorMessage(error));
|
|
15647
|
+
}
|
|
14553
15648
|
/**
|
|
14554
15649
|
* Duplicates the file referenced by a POI's tagIcon (a JSON string shaped like
|
|
14555
|
-
* { src, opacity }, per form-feature.component.ts).
|
|
14556
|
-
*
|
|
15650
|
+
* { src, opacity }, per form-feature.component.ts).
|
|
15651
|
+
*
|
|
15652
|
+
* Two behaviours matter here:
|
|
15653
|
+
* - the copy is re-homed under the TARGET space's icon folder
|
|
15654
|
+
* (`icons-tags/<targetSpaceId>/`, the layout form-feature.component.ts uploads to).
|
|
15655
|
+
* Keeping the source folder produced a target space whose icons all lived under
|
|
15656
|
+
* another space's prefix, so cleaning up the source space broke the copy's icons.
|
|
15657
|
+
* - a missing icon file is NOT fatal. It used to bubble up as a MissingS3FileError and
|
|
15658
|
+
* abort the whole POI — losing the pastille, its text and its media because of a
|
|
15659
|
+
* decorative file. The POI is now created with the icon reference dropped, which
|
|
15660
|
+
* renders the module's default icon, and a log line reports it.
|
|
14557
15661
|
*/
|
|
14558
15662
|
async duplicateTagIcon(tagIconRaw) {
|
|
14559
15663
|
if (!tagIconRaw) {
|
|
14560
|
-
return tagIconRaw;
|
|
15664
|
+
return tagIconRaw ?? null;
|
|
14561
15665
|
}
|
|
15666
|
+
let parsed;
|
|
14562
15667
|
try {
|
|
14563
|
-
|
|
14564
|
-
if (parsed && parsed.src) {
|
|
14565
|
-
parsed.src = await this.copyS3KeyIfPresent(parsed.src, 'tag icon');
|
|
14566
|
-
}
|
|
14567
|
-
return JSON.stringify(parsed);
|
|
15668
|
+
parsed = JSON.parse(tagIconRaw);
|
|
14568
15669
|
}
|
|
14569
15670
|
catch {
|
|
14570
|
-
// Not JSON
|
|
15671
|
+
// Not JSON — leave untouched rather than guess.
|
|
14571
15672
|
return tagIconRaw;
|
|
14572
15673
|
}
|
|
14573
|
-
|
|
14574
|
-
|
|
14575
|
-
|
|
14576
|
-
|
|
14577
|
-
|
|
14578
|
-
* to another DB record rather than a file, this heuristic may incorrectly attempt
|
|
14579
|
-
* a copy. Confirm the real structure to tighten this if it causes issues.
|
|
14580
|
-
*/
|
|
14581
|
-
async duplicateTagImageID(tagImageID) {
|
|
14582
|
-
if (!tagImageID || !tagImageID.includes('/')) {
|
|
14583
|
-
return tagImageID;
|
|
15674
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.src) {
|
|
15675
|
+
return tagIconRaw;
|
|
15676
|
+
}
|
|
15677
|
+
try {
|
|
15678
|
+
parsed.src = await this.copyS3KeyIfPresent(parsed.src, 'tag icon', this.buildTargetIconFolder());
|
|
14584
15679
|
}
|
|
14585
|
-
|
|
15680
|
+
catch (iconError) {
|
|
15681
|
+
if (!(iconError instanceof MissingS3FileError)) {
|
|
15682
|
+
// A real failure (permissions, network, ...) still aborts the POI: it means
|
|
15683
|
+
// the copy cannot be trusted, unlike a file that is simply already gone.
|
|
15684
|
+
throw iconError;
|
|
15685
|
+
}
|
|
15686
|
+
console.warn(`[MatterportTagCopyService] Tag icon missing on S3, falling back to the default icon: "${iconError.sourceKey}"`);
|
|
15687
|
+
this.logItemError('warningTagIconMissing', { file: iconError.sourceKey });
|
|
15688
|
+
delete parsed.src;
|
|
15689
|
+
}
|
|
15690
|
+
return JSON.stringify(parsed);
|
|
15691
|
+
}
|
|
15692
|
+
/** Icon folder of the space being copied into, matching what the feature form uploads to. */
|
|
15693
|
+
buildTargetIconFolder() {
|
|
15694
|
+
return this.targetSpaceId ? `icons-tags/${this.targetSpaceId}/` : null;
|
|
14586
15695
|
}
|
|
14587
15696
|
/**
|
|
14588
15697
|
* Extracts and normalizes the raw S3 key from a stored value (which may be a full URL
|
|
@@ -14625,13 +15734,13 @@ class MatterportTagCopyService {
|
|
|
14625
15734
|
// for the CopySource header. Do NOT re-encode it here.
|
|
14626
15735
|
return extracted;
|
|
14627
15736
|
}
|
|
14628
|
-
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, deps: [{ token: NavigationService }, { token: BaseUserService }, { token: PoiService }, { token: TicketsService }, { token: EquipmentService }, { token: Object3DService }, { token: FeatureService }, { token: MeasurementService }, { token: CommentService }, { token: i1.TranslateService }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
15737
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, deps: [{ token: NavigationService }, { token: BaseUserService }, { token: PoiService }, { token: TicketsService }, { token: EquipmentService }, { token: Object3DService }, { token: FeatureService }, { token: MeasurementService }, { token: CommentService }, { token: HashtagService }, { token: SpaceService }, { token: i1.TranslateService }], target: i0.ɵɵFactoryTarget.Injectable });
|
|
14629
15738
|
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, providedIn: 'root' });
|
|
14630
15739
|
}
|
|
14631
15740
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, decorators: [{
|
|
14632
15741
|
type: Injectable,
|
|
14633
15742
|
args: [{ providedIn: 'root' }]
|
|
14634
|
-
}], ctorParameters: () => [{ type: NavigationService }, { type: BaseUserService }, { type: PoiService }, { type: TicketsService }, { type: EquipmentService }, { type: Object3DService }, { type: FeatureService }, { type: MeasurementService }, { type: CommentService }, { type: i1.TranslateService }] });
|
|
15743
|
+
}], ctorParameters: () => [{ type: NavigationService }, { type: BaseUserService }, { type: PoiService }, { type: TicketsService }, { type: EquipmentService }, { type: Object3DService }, { type: FeatureService }, { type: MeasurementService }, { type: CommentService }, { type: HashtagService }, { type: SpaceService }, { type: i1.TranslateService }] });
|
|
14635
15744
|
|
|
14636
15745
|
/* eslint-disable class-methods-use-this */
|
|
14637
15746
|
class OrganisationService {
|
|
@@ -14840,5 +15949,5 @@ function floatValidator() {
|
|
|
14840
15949
|
* Generated bundle index. Do not edit.
|
|
14841
15950
|
*/
|
|
14842
15951
|
|
|
14843
|
-
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, MattertagActionMode, MattertagData, MeasurementService, MenuBarComponent, MissionService, NavigationService, NavigatorService, NgxSmarterplanCoreModule, NgxSmarterplanCoreService, NodeService, Object3DService, OperationService, OrganisationService, PaymentStatus, PlanService, PoiService, PoiType, ProfileEntity, ProfileService, ProfileStatus, PropertyService, PropertyType, RoleStatus, SafeUrlPipe, SearchBarComponent, SearchObjectType, SearchService, SpModule, SpaceService, SpaceStatus, StatusEquipment, SupportModalComponent, SupportService, TagAction, TemplateService, TicketPriority, TicketStatus, TicketType, TicketsService, TimeDateToLocalStringPipe, TypeNote, UsernameFromIdPipe, ValidatorsService, ViewerInteractions, ViewerService, VisitService, ZoneChangeService, ZoneService, applyRigidTransformToPoint, applyRigidTransformToYRotationDeg, arraysContainSameElements, checkElementById, computeRigidTransform, convertTimestampToLocalZone, copyFileInS3, dateHasExpired, dateTimeToLocalString, deleteFromS3, distance, downloadBlob, downloadEquipmentDocument, downloadFile, downloadFileAsObject, durationToString, emailValidator, enumToArray, filterUniqueArrayByID, floatValidator, getBufferForFileFromS3, getCoefficientsForImage, getCurrentLang, getDistanceBetweenTwoPoints, getHighestLevelForMissions, getHighestRoleForMissions, getLevelsBelow, getLocaleLong, getLocaleShort, getMetaForImage, getRolesBelowForManager, getSignedFile, getSignedImageUrlForEquipment, getSignedImageUrlForProfile, getSignedImageUrlForSpace, getSpaceIDFromUrl, getVisitUrl, isEmptyObject, listFilesInFolder, mean, noEmptyValidator, numberToDateString, openDocument, openModalForVisitSwitch, poiTypeToString, pointToString, removeAllFilesFromFolderS3, removeNullKeysFromObject, showScanPointsOnPlanInDiv, shuffleArray, sortAlphabeticallyOnName, stringLocaleToEnum, stringToLowercaseNoSpaces, styleButton, textValidator, translateDatePeriod, uploadBase64Image, uploadBase64ImageWithRetry, uploadFileToS3, uploadJsonToS3, validEmail, wait, waitUntil };
|
|
15952
|
+
export { AffectationService, AvatarComponent, BaseLateralTabService, BaseTagService, BaseUserService, BaseVisibilityService, CameraMode, CaptureService, CaptureViewer, CommentService, CommentType, Config, ContentService, CsvExportComponent, DomainService, DomainType, DurationToStringPipe, EmailStatus, EquipmentService, EventService, EventStatus, EventType, FeatureService, FeatureType, FilterService, HashtagFromIdPipe, HashtagService, InterventionService, InventoryStatus, LayerService, LevelStatus, LoaderComponent, Locale, LocaleService, MATTERPORT_SDK_KEY, MatterportImportService, MatterportService, MatterportTagCopyService, MatterportViewType, MattertagActionMode, MattertagData, MeasurementService, MenuBarComponent, MissionService, NavigationService, NavigatorService, NgxSmarterplanCoreModule, NgxSmarterplanCoreService, NodeService, Object3DService, OperationService, OrganisationService, PaymentStatus, PlanService, PoiService, PoiType, ProfileEntity, ProfileService, ProfileStatus, PropertyService, PropertyType, RoleStatus, SafeUrlPipe, SearchBarComponent, SearchObjectType, SearchService, SpModule, SpaceService, SpaceStatus, StatusEquipment, SupportModalComponent, SupportService, TagAction, TemplateService, TicketPriority, TicketStatus, TicketType, TicketsService, TimeDateToLocalStringPipe, TypeNote, UsernameFromIdPipe, ValidatorsService, ViewerInteractions, ViewerService, VisitService, ZoneChangeService, ZoneService, applyRigidTransformToDirection, applyRigidTransformToPoint, applyRigidTransformToYRotationDeg, arraysContainSameElements, checkElementById, computeRigidTransform, convertTimestampToLocalZone, copyFileInS3, copyFileInS3ViaDownload, dateHasExpired, dateTimeToLocalString, deleteFromS3, distance, downloadBlob, downloadEquipmentDocument, downloadFile, downloadFileAsObject, durationToString, emailValidator, enumToArray, filterUniqueArrayByID, floatValidator, getBufferForFileFromS3, getCoefficientsForImage, getCurrentLang, getDistanceBetweenTwoPoints, getHighestLevelForMissions, getHighestRoleForMissions, getLevelsBelow, getLocaleLong, getLocaleShort, getMetaForImage, getRolesBelowForManager, getSignedFile, getSignedImageUrlForEquipment, getSignedImageUrlForProfile, getSignedImageUrlForSpace, getSpaceIDFromUrl, getVisitUrl, isEmptyObject, listFilesInFolder, mean, noEmptyValidator, numberToDateString, openDocument, openModalForVisitSwitch, poiTypeToString, pointToString, removeAllFilesFromFolderS3, removeNullKeysFromObject, showScanPointsOnPlanInDiv, shuffleArray, sortAlphabeticallyOnName, stringLocaleToEnum, stringToLowercaseNoSpaces, styleButton, textValidator, translateDatePeriod, uploadBase64Image, uploadBase64ImageWithRetry, uploadFileToS3, uploadJsonToS3, validEmail, wait, waitUntil };
|
|
14844
15953
|
//# sourceMappingURL=smarterplan-ngx-smarterplan-core.mjs.map
|