@smarterplan/ngx-smarterplan-core 1.4.11 → 1.4.12

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.
@@ -9,8 +9,8 @@ import * as i1$1 from '@angular/router';
9
9
  import { RouterModule } from '@angular/router';
10
10
  import { Matrix4, Vector3, Color, Object3D, Euler, PerspectiveCamera, CylinderBufferGeometry, BoxGeometry, MeshBasicMaterial, NormalBlending, BackSide, Mesh, EdgesGeometry, AdditiveBlending, ShaderMaterial, AnimationMixer, VectorKeyframeTrack, InterpolateSmooth, AnimationClip, LoopOnce, Group, PlaneBufferGeometry, CanvasTexture, Texture, LinearFilter } from 'three';
11
11
  import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js';
12
+ import { Auth, API, graphqlOperation, Storage, Cache } from 'aws-amplify';
12
13
  import { DateTime } from 'luxon';
13
- import { Auth, Storage, API, Cache } from 'aws-amplify';
14
14
  import * as i11 from '@angular/common';
15
15
  import { CommonModule } from '@angular/common';
16
16
  import * as FileSaver from 'file-saver';
@@ -1901,14 +1901,14 @@ class PoiService {
1901
1901
  });
1902
1902
  }
1903
1903
  async getPoiByElementId(elementId) {
1904
- const pois = await this.API.__proto__.PoisByElement(elementId);
1904
+ const pois = await this.API.PoisByElement(elementId);
1905
1905
  if (pois && pois.items.length > 0) {
1906
1906
  return pois.items[0];
1907
1907
  }
1908
1908
  return null;
1909
1909
  }
1910
1910
  async getPoisByType(type) {
1911
- return this.API.__proto__.PoisByType(type).then((pois) => pois.items);
1911
+ return this.API.PoisByType(type).then((pois) => pois.items);
1912
1912
  }
1913
1913
  async createPOIForSpaceID(poi) {
1914
1914
  removeNullKeysFromObject(poi);
@@ -1930,23 +1930,100 @@ class PoiService {
1930
1930
  // partitionKey: "",
1931
1931
  }, 'AWSKinesis');
1932
1932
  }
1933
- return this.API.__proto__.CreatePOI(poi);
1933
+ return this.API.CreatePOI(poi);
1934
1934
  }
1935
1935
  poiIsVirtual(poi) {
1936
1936
  const { coordinate } = poi;
1937
1937
  return !coordinate;
1938
1938
  }
1939
1939
  async updatePoi(poi) {
1940
- return this.API.__proto__.UpdatePOI(poi);
1940
+ return this.API.UpdatePOI(poi);
1941
1941
  }
1942
1942
  async deletePoi(poiID) {
1943
- return this.API.__proto__.DeletePOI({ id: poiID });
1943
+ return this.API.DeletePOI({ id: poiID });
1944
1944
  }
1945
1945
  async getPoisByMatterportSweepByType(sweep, type) {
1946
- return this.API.__proto__
1947
- .ListPOIs({ matterportSweepID: { eq: sweep } }, 300)
1946
+ return this.API.ListPOIS({ matterportSweepID: { eq: sweep } }, 300)
1948
1947
  .then((response) => response.items.filter((poi) => poi.type === type));
1949
1948
  }
1949
+ /**
1950
+ * Optimized version: fetches POIs for MANY sweeps in as few GraphQL calls as possible,
1951
+ * using an `or: [{ matterportSweepID: { eq } }, ...]` filter instead of looping one
1952
+ * request per sweep. This is a DynamoDB Scan with a server-side filter (not a GSI
1953
+ * hash-key query), so `or` composition works and collapses N sweep-by-sweep calls
1954
+ * into a handful of paginated calls total.
1955
+ *
1956
+ * NOTE: `in` is NOT supported on this schema's ModelStringInput (older Amplify codegen),
1957
+ * hence the `or` composition instead — functionally equivalent for this use case.
1958
+ *
1959
+ * Chunked because DynamoDB/AppSync filter expressions have a practical size ceiling —
1960
+ * splitting keeps each request well within safe limits regardless of space size.
1961
+ */
1962
+ async getPoisByMatterportSweeps(sweeps) {
1963
+ if (!sweeps || sweeps.length === 0) {
1964
+ return [];
1965
+ }
1966
+ const queryStatement = `
1967
+ query ListPOISLight($filter: ModelPOIFilterInput, $limit: Int, $nextToken: String) {
1968
+ listPOIS(filter: $filter, limit: $limit, nextToken: $nextToken) {
1969
+ items {
1970
+ id
1971
+ coordinate
1972
+ specific
1973
+ private
1974
+ global
1975
+ type
1976
+ elementID
1977
+ level
1978
+ role
1979
+ category
1980
+ domainID
1981
+ metadata
1982
+ tagIcon
1983
+ tagDescription
1984
+ tagImageID
1985
+ matterportSweepID
1986
+ createdAt
1987
+ updatedAt
1988
+ }
1989
+ nextToken
1990
+ }
1991
+ }
1992
+ `;
1993
+ const CHUNK_SIZE = 50; // safe margin for an "or" filter expression's practical size
1994
+ const chunks = [];
1995
+ for (let i = 0; i < sweeps.length; i += CHUNK_SIZE) {
1996
+ chunks.push(sweeps.slice(i, i + CHUNK_SIZE));
1997
+ }
1998
+ let allPois = [];
1999
+ for (const chunk of chunks) {
2000
+ let nextToken = null;
2001
+ do {
2002
+ const variables = {
2003
+ filter: {
2004
+ or: chunk.map((sweep) => ({ matterportSweepID: { eq: sweep } }))
2005
+ },
2006
+ limit: 1000,
2007
+ nextToken
2008
+ };
2009
+ try {
2010
+ const response = (await API.graphql(graphqlOperation(queryStatement, variables)));
2011
+ const resData = response.data.listPOIS;
2012
+ if (resData && resData.items.length > 0) {
2013
+ allPois = [...allPois, ...resData.items];
2014
+ }
2015
+ nextToken = resData?.nextToken ?? null;
2016
+ }
2017
+ catch (error) {
2018
+ // A failed page shouldn't abort the whole batch — log and stop paging this chunk only,
2019
+ // the rest of the copy process continues with whatever was already collected.
2020
+ console.error(`Error while fetching POIs for a sweep chunk:`, error);
2021
+ nextToken = null;
2022
+ }
2023
+ } while (nextToken);
2024
+ }
2025
+ return allPois;
2026
+ }
1950
2027
  unsubscribe() {
1951
2028
  this.destroy$.next(true);
1952
2029
  this.destroy$.complete();
@@ -2367,6 +2444,9 @@ class MatterportNavigationService {
2367
2444
  else {
2368
2445
  console.warn('No matterport floor found to move to');
2369
2446
  }
2447
+ console.log("action_go_to_floor.floorMatterport", floorMatterport);
2448
+ console.log("action_go_to_floor.floorName", floorName);
2449
+ console.log("action_go_to_floor.matterportFloorSequence", matterportFloorSequence);
2370
2450
  }
2371
2451
  /**
2372
2452
  * Navigate to a sweep. The caller can pass either a sid or a uuid;
@@ -2397,6 +2477,9 @@ class MatterportNavigationService {
2397
2477
  await sdk.Camera.setRotation(rotation, { speed: 100 });
2398
2478
  }
2399
2479
  }, 1000);
2480
+ console.log("action_go_to_sweep.sweep", sweep);
2481
+ console.log("action_go_to_sweep.rotation", rotation);
2482
+ console.log("action_go_to_sweep.resolvedSid", resolvedSid);
2400
2483
  }
2401
2484
  /**
2402
2485
  * Disable forbidden sweeps. Resolves each sweep ID to sid before calling SDK.
@@ -2416,6 +2499,130 @@ class MatterportNavigationService {
2416
2499
  }));
2417
2500
  console.log('removed sweeps:', removed);
2418
2501
  }
2502
+ /**
2503
+ * Move to a position
2504
+ * @param sdk
2505
+ * @param position
2506
+ * @param rotation
2507
+ * @param options
2508
+ */
2509
+ async move_to_position(sdk, position, rotation, options) {
2510
+ // Validate SDK
2511
+ if (!sdk) {
2512
+ throw new Error('Matterport SDK instance is required');
2513
+ }
2514
+ // Merge options with defaults
2515
+ const { transition = 'FLY', durationMs = 800, anglesInDegrees = true, offset = { x: 0, y: 0, z: 0 } } = options || {};
2516
+ const degToRad = (d) => d * Math.PI / 180;
2517
+ // Prepare rotation values (default to 0 when not provided)
2518
+ const yaw = rotation?.yaw ?? 0;
2519
+ const pitch = rotation?.pitch ?? 0;
2520
+ const roll = rotation?.roll ?? 0;
2521
+ // Convert to radians if angles are provided in degrees
2522
+ const rotYaw = anglesInDegrees ? degToRad(yaw) : yaw;
2523
+ const rotPitch = anglesInDegrees ? degToRad(pitch) : pitch;
2524
+ const rotRoll = anglesInDegrees ? degToRad(roll) : roll;
2525
+ // Preserve previous transition flag and mark as in-transition
2526
+ const prevInTransition = this.inTransitionMode;
2527
+ this.inTransitionMode = true;
2528
+ try {
2529
+ // Attempt to use Camera.lookAt when available
2530
+ if (sdk?.Camera?.lookAt) {
2531
+ const lookAtOpts = {};
2532
+ // Prefer SDK constants when available, otherwise use fallback string
2533
+ if (sdk.Camera.TransitionType && sdk.Camera.TransitionType[transition]) {
2534
+ lookAtOpts.transition = sdk.Camera.TransitionType[transition];
2535
+ }
2536
+ else {
2537
+ lookAtOpts.transition = transition;
2538
+ }
2539
+ lookAtOpts.duration = durationMs;
2540
+ if (offset)
2541
+ lookAtOpts.offset = offset;
2542
+ try {
2543
+ await sdk.Camera.lookAt(position, lookAtOpts);
2544
+ }
2545
+ catch (err) {
2546
+ // If lookAt fails, continue to fallbacks below
2547
+ if (isDevMode())
2548
+ console.debug('Camera.lookAt failed, falling back to setState', err);
2549
+ }
2550
+ }
2551
+ // If lookAt is not present or failed, try setState for position
2552
+ if (!sdk?.Camera?.lookAt && sdk?.Camera?.setState) {
2553
+ const statePos = {
2554
+ position: { x: position.x, y: position.y, z: position.z }
2555
+ };
2556
+ try {
2557
+ await sdk.Camera.setState(statePos);
2558
+ }
2559
+ catch (err) {
2560
+ if (isDevMode())
2561
+ console.debug('Camera.setState(position) failed', err);
2562
+ }
2563
+ }
2564
+ // Apply rotation if provided
2565
+ if (rotation) {
2566
+ // Prefer setRotation when available
2567
+ if (sdk?.Camera?.setRotation) {
2568
+ // Try Vector2 signature { x: yaw, y: pitch } first
2569
+ try {
2570
+ await sdk.Camera.setRotation({ x: rotYaw, y: rotPitch }, { speed: Math.max(10, Math.floor(durationMs / 10)) });
2571
+ }
2572
+ catch (err1) {
2573
+ // If that fails, try Vector3 signature { x: pitch, y: yaw, z: roll }
2574
+ try {
2575
+ await sdk.Camera.setRotation({ x: rotPitch, y: rotYaw, z: rotRoll }, { speed: Math.max(10, Math.floor(durationMs / 10)) });
2576
+ }
2577
+ catch (err2) {
2578
+ if (isDevMode())
2579
+ console.debug('Camera.setRotation failed with multiple signatures', err1, err2);
2580
+ // Fallback to setState with rotation if available
2581
+ if (sdk?.Camera?.setState) {
2582
+ try {
2583
+ await sdk.Camera.setState({
2584
+ rotation: { x: rotPitch, y: rotYaw, z: rotRoll },
2585
+ position: { x: position.x, y: position.y, z: position.z }
2586
+ });
2587
+ }
2588
+ catch (err3) {
2589
+ if (isDevMode())
2590
+ console.debug('Fallback Camera.setState(rotation) failed', err3);
2591
+ }
2592
+ }
2593
+ }
2594
+ }
2595
+ }
2596
+ else if (sdk?.Camera?.setState) {
2597
+ // If setRotation is not available, use setState to apply rotation
2598
+ try {
2599
+ await sdk.Camera.setState({
2600
+ rotation: { x: rotPitch, y: rotYaw, z: rotRoll },
2601
+ position: { x: position.x, y: position.y, z: position.z }
2602
+ });
2603
+ }
2604
+ catch (err) {
2605
+ if (isDevMode())
2606
+ console.debug('Camera.setState(rotation) failed', err);
2607
+ }
2608
+ }
2609
+ else {
2610
+ if (isDevMode())
2611
+ console.warn('No method available to apply rotation (setRotation/setState not found)');
2612
+ }
2613
+ }
2614
+ // Wait a bit to allow transition animations to complete
2615
+ await wait(durationMs + 120);
2616
+ }
2617
+ catch (err) {
2618
+ // Re-throw after ensuring flags are restored in finally
2619
+ throw err;
2620
+ }
2621
+ finally {
2622
+ // Restore previous transition flag
2623
+ this.inTransitionMode = prevInTransition;
2624
+ }
2625
+ }
2419
2626
  /**
2420
2627
  * Returns the current sweep as a **uuid** (for database matching).
2421
2628
  * The SDK's poseCamera.sweep is a sid; we translate it.
@@ -3243,6 +3450,12 @@ class MatterportTagService {
3243
3450
  async addMattertagToViewer(sdk, mattertagData) {
3244
3451
  if (!sdk)
3245
3452
  return null;
3453
+ console.log('---------------');
3454
+ console.log('element id ' + mattertagData.elementID);
3455
+ console.log('position: ', mattertagData.getPosition());
3456
+ console.log('rotation: ', mattertagData.getRotation());
3457
+ console.log('sweep id: ', mattertagData.getSweepID());
3458
+ console.log('---------------');
3246
3459
  this.tagAddQueue = this.tagAddQueue.then(async () => {
3247
3460
  const data = mattertagData.getData();
3248
3461
  const existingId = data?.id || null;
@@ -3496,6 +3709,12 @@ class MatterportTagService {
3496
3709
  }
3497
3710
  mattertagData.setPoi(poi);
3498
3711
  const createdTagID = await this.addMattertagToViewer(sdk, mattertagData);
3712
+ console.log('---------------');
3713
+ console.log('element id ' + mattertagData.elementID);
3714
+ console.log('position: ', mattertagData.getPosition());
3715
+ console.log('rotation: ', mattertagData.getRotation());
3716
+ console.log('sweep id: ', mattertagData.getSweepID());
3717
+ console.log('---------------');
3499
3718
  if (createdTagID && sdk) {
3500
3719
  await wait(100);
3501
3720
  await setTagIconAndOpacity(createdTagID, mattertagData);
@@ -4338,6 +4557,51 @@ class MatterportService {
4338
4557
  console.warn('Cannot navigate to tag', error);
4339
4558
  }
4340
4559
  }
4560
+ /**
4561
+ * Move the viewer camera to a specific 3D position and apply a rotation.
4562
+ * Delegates the heavy lifting to MatterportNavigationService.move_to_position.
4563
+ * - position: { x, y, z }
4564
+ * - rotation: { yaw, pitch?, roll? } in degrees by default
4565
+ * - options: optional transition and conversion settings (see navigation service)
4566
+ */
4567
+ async goToPosition(position, rotation = null, options) {
4568
+ // Ensure SDK is initialized
4569
+ if (!this.sdk) {
4570
+ throw new Error('Matterport SDK is not initialized');
4571
+ }
4572
+ // Prevent concurrent navigation if already in a sweep/mode transition
4573
+ if (this.inTransitionMode || this.inTransitionSweep) {
4574
+ if (isDevMode())
4575
+ console.debug('goToPosition aborted: viewer is in transition');
4576
+ return;
4577
+ }
4578
+ try {
4579
+ // Mark transition state on navigation service (move_to_position will also manage flags)
4580
+ this.inTransitionMode = true;
4581
+ // Delegate to navigation service which contains the robust move_to_position implementation
4582
+ await this.navigationService.move_to_position(this.sdk, position, rotation, options);
4583
+ // Update local cached camera info
4584
+ this.lastCameraPosition = { ...position };
4585
+ // Emit a simplified current camera pose for subscribers
4586
+ this.currentCameraPose.next({
4587
+ position: { ...position },
4588
+ rotation: rotation ? { ...rotation } : null,
4589
+ sweep: this.poseCamera?.sweep ?? null
4590
+ });
4591
+ if (isDevMode()) {
4592
+ console.debug('goToPosition completed', { position, rotation, options });
4593
+ }
4594
+ }
4595
+ catch (err) {
4596
+ if (isDevMode())
4597
+ console.error('goToPosition error', err);
4598
+ throw err;
4599
+ }
4600
+ finally {
4601
+ // Ensure transition flag is cleared (navigation service may also clear it)
4602
+ this.inTransitionMode = false;
4603
+ }
4604
+ }
4341
4605
  /**
4342
4606
  * Computes the context-aware opacity for a single tag at the moment it is rendered.
4343
4607
  * - Cursor tag: always use provided defaultOpacity.
@@ -5418,6 +5682,17 @@ const uploadJsonToS3 = async (path, json, filename) => {
5418
5682
  return null;
5419
5683
  });
5420
5684
  };
5685
+ /**
5686
+ * Physically duplicates an S3 object to a new key. Used when copying attachments
5687
+ * between features/tickets/etc. so the copy is fully independent: deleting the
5688
+ * original file later must not affect the copy.
5689
+ * IMPORTANT: does NOT swallow errors — callers need the real error (e.g. NoSuchKey)
5690
+ * to distinguish an orphaned DB reference from a genuine copy failure.
5691
+ */
5692
+ const copyFileInS3 = async (sourceKey, destKey) => {
5693
+ const result = await Storage.copy({ key: sourceKey, level: "public" }, { key: destKey, level: "public" });
5694
+ return result.key;
5695
+ };
5421
5696
 
5422
5697
  function getDistanceBetweenTwoPoints(startPoint, endPoint) {
5423
5698
  const dx = endPoint.x - startPoint.x;
@@ -6759,13 +7034,16 @@ class NavigationService {
6759
7034
  }
6760
7035
  async getAllowedSweepsIDsForUserInSpace(spaceID) {
6761
7036
  const missionsForSpace = this.userService.currentMissions.filter((mis) => mis.spaceID === spaceID);
7037
+ console.log(missionsForSpace);
6762
7038
  let navIDs = [];
6763
7039
  if (this.userService.userHasAccessToAllZonesInSpace(spaceID)) {
7040
+ console.log(1);
6764
7041
  return this.getSweepsIDsForSpace(spaceID);
6765
7042
  }
6766
7043
  await Promise.all(missionsForSpace.map(async (mission) => {
6767
7044
  navIDs = [...navIDs, ...mission.zone.sweepIDs];
6768
7045
  }));
7046
+ console.log('final navids = ', navIDs);
6769
7047
  return navIDs;
6770
7048
  }
6771
7049
  async getForbiddenNavigationIDsForUserInSpace(spaceID) {
@@ -7933,7 +8211,8 @@ class TicketsService extends BaseObjectService {
7933
8211
  // partitionKey: "",
7934
8212
  }, 'AWSKinesis');
7935
8213
  }
7936
- return this.API.__proto__.CreateTicket(input);
8214
+ const { id, createdAt, updatedAt, __typename, children, domain, ownerMission, equipments, prestataire, space, pois, ...graphqlInput } = input;
8215
+ return this.API.CreateTicket(input);
7937
8216
  }
7938
8217
  async updateTicket(input) {
7939
8218
  return this.API.__proto__.UpdateTicket(input);
@@ -8315,6 +8594,21 @@ class EquipmentService extends BaseObjectService {
8315
8594
  return [];
8316
8595
  });
8317
8596
  }
8597
+ /**
8598
+ * Creates a Note attached to an equipment from an S3 key that ALREADY exists
8599
+ * (as opposed to uploadDoc/uploadImage, which upload a fresh browser File).
8600
+ * Used when duplicating equipment attachments during a cross-visit copy: the
8601
+ * S3 object itself has already been physically copied by the caller beforehand,
8602
+ * so this only needs to record the Note pointing at that new key.
8603
+ */
8604
+ async createNoteWithExistingAttachment(equipmentID, attachmentKey, type, ownerMissionID) {
8605
+ return this.API.__proto__.CreateNote({
8606
+ attachment: attachmentKey,
8607
+ type,
8608
+ equipmentID,
8609
+ ownerMissionID,
8610
+ });
8611
+ }
8318
8612
  async uploadImage(equipment, imageFile) {
8319
8613
  return uploadFileToS3(this.prefix.image, imageFile, equipment.id).then(async (url) => {
8320
8614
  if (url) {
@@ -12692,33 +12986,26 @@ class MatterportImportService {
12692
12986
  iframe.addEventListener('load', async () => {
12693
12987
  try {
12694
12988
  this.sdk = await showcaseWindow.MP_SDK.connect(iframe, this.sdkKey, '');
12695
- // Subscribe to Floor data
12696
12989
  this.sdk.Floor.data.subscribe({
12697
12990
  onCollectionUpdated: function upd(collection) {
12698
- // console.log("Floors", collection);
12699
12991
  this.floors = collection;
12700
12992
  }.bind(this),
12701
12993
  });
12702
- // Subscribe to current Floor
12703
12994
  this.sdk.Floor.current.subscribe(function upd(currentFloor) {
12704
12995
  this.currentFloor = currentFloor;
12705
12996
  }.bind(this));
12706
- // Subscribe to Sweep data
12707
12997
  this.sdk.Sweep.data.subscribe({
12708
12998
  onCollectionUpdated: function upd(collection) {
12709
12999
  console.log('the entire up-to-date collection', collection);
12710
13000
  this.sweeps = collection;
12711
13001
  }.bind(this),
12712
13002
  });
12713
- // remove labels
12714
13003
  await this.sdk.Settings.update('labels', false);
12715
13004
  let timeoutExpired = false;
12716
- // start timeout
12717
13005
  setTimeout(() => (timeoutExpired = true), 7000);
12718
13006
  await waitUntil(() => (this.sweeps !== undefined && this.floors !== undefined) ||
12719
13007
  timeoutExpired === true);
12720
13008
  if (timeoutExpired) {
12721
- // we are likely in matterport error case (that we cannot catch!)
12722
13009
  console.log('timeout intitialising Matterport');
12723
13010
  return rej(new Error('error init Matterport'));
12724
13011
  }
@@ -12738,7 +13025,6 @@ class MatterportImportService {
12738
13025
  }
12739
13026
  let appIsLoaded = false;
12740
13027
  this.sdk.App.state.subscribe(function (appState) {
12741
- // app state has changed
12742
13028
  console.log('The current phase: ', appState.phase);
12743
13029
  if (appState.phase === 'appphase.playing') {
12744
13030
  console.log('The app is playing');
@@ -12747,7 +13033,6 @@ class MatterportImportService {
12747
13033
  });
12748
13034
  await waitUntil(() => appIsLoaded === true);
12749
13035
  if (reloading) {
12750
- // perform only 360 import since we are reloading
12751
13036
  await this.import360images(overrideExisting);
12752
13037
  return Promise.resolve(true);
12753
13038
  }
@@ -12759,10 +13044,8 @@ class MatterportImportService {
12759
13044
  return Promise.reject(new Error('missing data (no main Building Zone)'));
12760
13045
  }
12761
13046
  console.log('importing data from matterport');
12762
- // save sweeps data as json
12763
13047
  const json = JSON.stringify(Object.values(this.sweeps));
12764
13048
  await uploadJsonToS3(`visits/${this.modelID}/inventory/`, json, 'data.json');
12765
- // check layer of building type
12766
13049
  const layerBuilding = await this.getLayerByName('BUILDING');
12767
13050
  const createdDefaultZone = await this.createZone(node.metadata, layerBuilding.id, surface, mainZone.id, Object.keys(this.sweeps), node);
12768
13051
  if (!createdDefaultZone) {
@@ -12771,7 +13054,6 @@ class MatterportImportService {
12771
13054
  if (this.floors) {
12772
13055
  floorZones = await this.importFloorData(createdDefaultZone.id, node);
12773
13056
  }
12774
- // console.log(floorZones);
12775
13057
  await Promise.all(Object.keys(this.sweeps).map(async (key) => {
12776
13058
  const sweepInfo = this.sweeps[key];
12777
13059
  const navigationInput = {
@@ -12811,7 +13093,6 @@ class MatterportImportService {
12811
13093
  const newLocal = await this.zoneService.create(zoneInput);
12812
13094
  return newLocal;
12813
13095
  }
12814
- // --- Main import function (complete) ---
12815
13096
  async import360images(overrideExisting = true) {
12816
13097
  console.log('Importing 360 images');
12817
13098
  this.importingImages.next(true);
@@ -12829,10 +13110,8 @@ class MatterportImportService {
12829
13110
  }
12830
13111
  const sweep = scans[index];
12831
13112
  console.log('Moving to sweep:', sweep);
12832
- // Prefer legacy .id first, then uuid, then sid as last resort
12833
13113
  const moveId = sweep.id || sweep.uuid || sweep.sid;
12834
13114
  try {
12835
- // Move to sweep (use the best available id)
12836
13115
  await this.sdk.Sweep.moveTo(moveId, {
12837
13116
  rotation: { x: 0, y: 0 },
12838
13117
  transition: this.sdk.Camera && this.sdk.Camera.TransitionType
@@ -12840,7 +13119,6 @@ class MatterportImportService {
12840
13119
  : undefined,
12841
13120
  transitionTime: 0,
12842
13121
  });
12843
- // Capture with automatic retry (ensures renderer readiness each attempt)
12844
13122
  const img = await this.captureEquirectangularWithRetry({ width: 2048, height: 1024 }, { mattertags: false, sweeps: true }, {
12845
13123
  maxAttempts: 3,
12846
13124
  initialBackoffMs: 500,
@@ -12848,10 +13126,7 @@ class MatterportImportService {
12848
13126
  sweepIdForReadyCheck: moveId,
12849
13127
  waitForSweepTimeoutMs: 12000
12850
13128
  });
12851
- // Upload asynchronously (do not await to keep throughput)
12852
- uploadBase64ImageWithRetry(img,
12853
- // prefer using uuid for storage name if available, else fallback to moveId
12854
- sweep.uuid || moveId, `visits/${this.modelID}/sweeps/`, 'sweep', 5).then(() => {
13129
+ uploadBase64ImageWithRetry(img, sweep.uuid || moveId, `visits/${this.modelID}/sweeps/`, 'sweep', 5).then(() => {
12855
13130
  this.sweepProcessedCount.next(index);
12856
13131
  if (index === nmbScans - 1) {
12857
13132
  console.log('Import 360 done');
@@ -12864,38 +13139,29 @@ class MatterportImportService {
12864
13139
  });
12865
13140
  }
12866
13141
  catch (err) {
12867
- // capture failed after retries — log and continue to next sweep
12868
13142
  console.error(`Failed to capture sweep ${moveId} after retries:`, err);
12869
13143
  this.sweepProcessedCount.next(index);
12870
- // continue to next sweep (change to `throw err` if you want to abort on first failure)
12871
13144
  continue;
12872
13145
  }
12873
13146
  }
12874
- // If loop finishes without hitting the final resolve in upload promise:
12875
13147
  resolve(true);
12876
13148
  this.removeFrame();
12877
13149
  });
12878
13150
  }
12879
- // --- capture with retry and backoff ---
12880
- // options: renderer size; renderOptions: renderer flags; cfg: retry config
12881
13151
  async captureEquirectangularWithRetry(options, renderOptions, { maxAttempts = 3, initialBackoffMs = 500, perAttemptTimeoutMs = 8000, sweepIdForReadyCheck = null, waitForSweepTimeoutMs = 12000 } = {}) {
12882
13152
  let attempt = 0;
12883
13153
  let lastError = null;
12884
13154
  while (attempt < maxAttempts) {
12885
13155
  attempt += 1;
12886
13156
  try {
12887
- // Ensure sweep is ready before each attempt if requested
12888
13157
  if (sweepIdForReadyCheck) {
12889
13158
  await this.waitForSweepReady(sweepIdForReadyCheck, waitForSweepTimeoutMs);
12890
- // small safety delay to let renderer finalize
12891
13159
  await this.sleep(120);
12892
13160
  }
12893
- // Race the takeEquirectangular call against a per-attempt timeout
12894
13161
  const img = await Promise.race([
12895
13162
  this.sdk.Renderer.takeEquirectangular(options, renderOptions),
12896
13163
  this.timeoutPromise(perAttemptTimeoutMs, `takeEquirectangular timed out after ${perAttemptTimeoutMs}ms`)
12897
13164
  ]);
12898
- // success
12899
13165
  return img;
12900
13166
  }
12901
13167
  catch (err) {
@@ -12904,37 +13170,27 @@ class MatterportImportService {
12904
13170
  if (attempt >= maxAttempts) {
12905
13171
  throw lastError;
12906
13172
  }
12907
- // exponential backoff before next attempt
12908
13173
  const backoff = initialBackoffMs * Math.pow(2, attempt - 1);
12909
- // add small jitter to reduce thundering herd
12910
13174
  const jitter = Math.floor(Math.random() * 100);
12911
13175
  await this.sleep(backoff + jitter);
12912
- // loop to next attempt
12913
13176
  }
12914
13177
  }
12915
13178
  throw lastError || new Error('captureEquirectangularWithRetry failed without error');
12916
13179
  }
12917
- // --- Wait until the SDK reports the target sweep as current ---
12918
- // Tries: sdk.Sweep.current.waitUntil -> sdk.Sweep.current.subscribe -> polling getCurrent/value
12919
- // This function matches targetId against multiple fields and prioritizes .id matching.
12920
13180
  waitForSweepReady(targetId, timeoutMs = 10000) {
12921
13181
  const sdk = this.sdk;
12922
- // helper to match multiple id fields, prioritizing id first
12923
13182
  const matchesTarget = (data) => {
12924
13183
  if (!data)
12925
13184
  return false;
12926
- // check .id first (legacy), then uuid, sid, uid
12927
13185
  const fields = [data.id, data.uuid, data.sid, data.uid];
12928
13186
  return fields.some(f => !!f && String(f) === String(targetId));
12929
13187
  };
12930
- // 1) prefer waitUntil if available on the observable
12931
13188
  if (sdk.Sweep && sdk.Sweep.current && typeof sdk.Sweep.current.waitUntil === 'function') {
12932
13189
  return Promise.race([
12933
13190
  sdk.Sweep.current.waitUntil((data) => matchesTarget(data)),
12934
13191
  this.timeoutPromise(timeoutMs, `Timeout waiting for sweep ${targetId}`)
12935
13192
  ]);
12936
13193
  }
12937
- // 2) subscribe if available
12938
13194
  if (sdk.Sweep && sdk.Sweep.current && typeof sdk.Sweep.current.subscribe === 'function') {
12939
13195
  return new Promise((resolve, reject) => {
12940
13196
  const timer = setTimeout(() => {
@@ -12944,7 +13200,6 @@ class MatterportImportService {
12944
13200
  }, timeoutMs);
12945
13201
  let off = null;
12946
13202
  try {
12947
- // subscribe may return an unsubscribe function or an object with unsubscribe()
12948
13203
  const subResult = sdk.Sweep.current.subscribe((data) => {
12949
13204
  if (matchesTarget(data)) {
12950
13205
  clearTimeout(timer);
@@ -12958,7 +13213,6 @@ class MatterportImportService {
12958
13213
  resolve();
12959
13214
  }
12960
13215
  });
12961
- // if subscribe returned an unsubscribe function, keep it as off
12962
13216
  if (typeof subResult === 'function')
12963
13217
  off = subResult;
12964
13218
  else if (subResult && typeof subResult.unsubscribe === 'function')
@@ -12970,7 +13224,6 @@ class MatterportImportService {
12970
13224
  }
12971
13225
  });
12972
13226
  }
12973
- // 3) polling fallback: try getCurrent(), sdk.Sweep.current() function, or .value, else poll renderer readiness
12974
13227
  return new Promise(async (resolve, reject) => {
12975
13228
  const pollInterval = 200;
12976
13229
  const timer = setTimeout(() => {
@@ -12984,11 +13237,9 @@ class MatterportImportService {
12984
13237
  current = await sdk.Sweep.getCurrent();
12985
13238
  }
12986
13239
  else if (sdk.Sweep && typeof sdk.Sweep.current === 'function') {
12987
- // some builds expose a function
12988
13240
  current = await sdk.Sweep.current();
12989
13241
  }
12990
13242
  else if (sdk.Sweep && sdk.Sweep.current && sdk.Sweep.current.value) {
12991
- // some observables expose a .value
12992
13243
  current = sdk.Sweep.current.value;
12993
13244
  }
12994
13245
  if (matchesTarget(current)) {
@@ -13001,12 +13252,10 @@ class MatterportImportService {
13001
13252
  // ignore and retry until timeout
13002
13253
  }
13003
13254
  };
13004
- // initial immediate check
13005
13255
  await checkNow();
13006
13256
  const interval = setInterval(checkNow, pollInterval);
13007
13257
  });
13008
13258
  }
13009
- // --- small helpers ---
13010
13259
  sleep(ms) {
13011
13260
  return new Promise(res => setTimeout(res, ms));
13012
13261
  }
@@ -13027,7 +13276,6 @@ class MatterportImportService {
13027
13276
  console.log('Floor are not loaded yet');
13028
13277
  return;
13029
13278
  }
13030
- // look up for sequence number (the safest method)
13031
13279
  let floorMatterport = Object.values(this.floors).find((floor) => floor.sequence === matterportFloorSequence);
13032
13280
  if (!floorMatterport) {
13033
13281
  floorMatterport = Object.values(this.floors).find((floor) => floorName.includes(floor.name) && floor.name != '');
@@ -13035,13 +13283,11 @@ class MatterportImportService {
13035
13283
  if (!floorMatterport) {
13036
13284
  floorMatterport = Object.values(this.floors).find((floor) => floorName.includes(floor.id));
13037
13285
  }
13038
- // console.log(floorMatterport)
13039
13286
  if (floorMatterport) {
13040
13287
  let retry = true;
13041
13288
  while (retry) {
13042
13289
  try {
13043
13290
  const floorIndex = await this.sdk.Floor.moveTo(floorMatterport.sequence);
13044
- // console.log("moved to floorIndex", floorIndex);
13045
13291
  retry = false;
13046
13292
  }
13047
13293
  catch (error) {
@@ -13060,13 +13306,10 @@ class MatterportImportService {
13060
13306
  const nmbFloors = Object.keys(this.floors).length;
13061
13307
  console.log('Total number of floors', nmbFloors);
13062
13308
  for (let index = 0; index < nmbFloors; index += 1) {
13063
- // eslint-disable-next-line no-await-in-loop
13064
13309
  console.log('Importing floor', index);
13065
13310
  await this.sdk.Floor.moveTo(index).then(async () => {
13066
13311
  await wait(700);
13067
- // console.log("about to save the floor", index);
13068
13312
  const zone = await this.saveFloor(defautZoneID, node);
13069
- // console.log("received floor zone", zone);
13070
13313
  zones.push(zone);
13071
13314
  });
13072
13315
  }
@@ -13084,14 +13327,12 @@ class MatterportImportService {
13084
13327
  console.log('sweeps for floor', sweepsForFloor.length);
13085
13328
  const sweepIDs = sweepsForFloor.map((sweep) => sweep.uuid);
13086
13329
  const zone = await this.createZone(`Étage ${name} - ${node.metadata}`, layerFloor.id, null, defautZoneID, sweepIDs, node, JSON.stringify({ matterportFloorSequence: this.currentFloor.sequence }));
13087
- // TODO link to scans
13088
13330
  const base64 = await this.sdk.Renderer.takeScreenShot({
13089
13331
  width: 4096,
13090
13332
  height: 2048,
13091
13333
  });
13092
13334
  const spaceIDshort = this.spaceID.slice(0, 8);
13093
13335
  await uploadBase64Image(base64, null, `visits/${this.modelID}/plans/`, `plan_floor_${this.currentFloor.id}_${spaceIDshort}`);
13094
- // create Plan object
13095
13336
  const plan = {
13096
13337
  name: `plan_floor_${this.currentFloor.id}`,
13097
13338
  annexe: `visits/${this.modelID}/plans/plan_floor_${this.currentFloor.id}_${spaceIDshort}.jpeg`,
@@ -13102,7 +13343,6 @@ class MatterportImportService {
13102
13343
  isImportedMatterport: true,
13103
13344
  };
13104
13345
  await this.planService.createPlan(plan);
13105
- // console.log("floor saved");
13106
13346
  return Promise.resolve(zone);
13107
13347
  }
13108
13348
  abandon() {
@@ -13124,7 +13364,6 @@ class MatterportImportService {
13124
13364
  if (layers.length > 0) {
13125
13365
  return layers[0];
13126
13366
  }
13127
- // create then
13128
13367
  return this.layerService.createLayerForOrganisation(name, currentOrgId);
13129
13368
  }
13130
13369
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportImportService, deps: [{ token: MATTERPORT_SDK_KEY }, { token: NavigationService }, { token: ZoneService }, { token: ViewerService }, { token: LayerService }, { token: BaseUserService }, { token: PlanService }], target: i0.ɵɵFactoryTarget.Injectable });
@@ -13140,6 +13379,45 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
13140
13379
  args: [MATTERPORT_SDK_KEY]
13141
13380
  }] }, { type: NavigationService }, { type: ZoneService }, { type: ViewerService }, { type: LayerService }, { type: BaseUserService }, { type: PlanService }] });
13142
13381
 
13382
+ // matterport-transform.util.ts
13383
+ /**
13384
+ * Computes the rigid transform (Y-axis rotation + translation) between two
13385
+ * coordinate frames, from 2 pairs of points representing the same physical spot.
13386
+ */
13387
+ function computeRigidTransform(sourceA, targetA, sourceB, targetB) {
13388
+ const vSource = { x: sourceB.x - sourceA.x, z: sourceB.z - sourceA.z };
13389
+ const vTarget = { x: targetB.x - targetA.x, z: targetB.z - targetA.z };
13390
+ const angleSource = Math.atan2(vSource.z, vSource.x);
13391
+ const angleTarget = Math.atan2(vTarget.z, vTarget.x);
13392
+ return {
13393
+ rotationYRad: angleTarget - angleSource,
13394
+ refSource: sourceA,
13395
+ refTarget: targetA
13396
+ };
13397
+ }
13398
+ /** Applies the rigid transform to a 3D position (x, y, z). */
13399
+ function applyRigidTransformToPoint(p, t) {
13400
+ const dx = p.x - t.refSource.x;
13401
+ const dz = p.z - t.refSource.z;
13402
+ const cos = Math.cos(t.rotationYRad);
13403
+ const sin = Math.sin(t.rotationYRad);
13404
+ return {
13405
+ x: t.refTarget.x + (dx * cos - dz * sin),
13406
+ y: t.refTarget.y + (p.y - t.refSource.y), // vertical axis is not rotated
13407
+ z: t.refTarget.z + (dx * sin + dz * cos)
13408
+ };
13409
+ }
13410
+ /** Applies the Y rotation (in degrees) to an orientation angle (a pose's yRotation). */
13411
+ function applyRigidTransformToYRotationDeg(yRotationDeg, t) {
13412
+ const deltaDeg = t.rotationYRad * (180 / Math.PI);
13413
+ return yRotationDeg + deltaDeg;
13414
+ }
13415
+ /** Euclidean distance between two points, useful to validate landmark quality. */
13416
+ function distance(a, b) {
13417
+ const dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
13418
+ return Math.sqrt(dx * dx + dy * dy + dz * dz);
13419
+ }
13420
+
13143
13421
  /* eslint-disable class-methods-use-this */
13144
13422
  class Object3DService {
13145
13423
  userService;
@@ -13506,6 +13784,855 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
13506
13784
  args: ['currentAPIService']
13507
13785
  }] }, { type: BaseUserService }, { type: NavigatorService }, { type: ZoneChangeService }, { type: FilterService }] });
13508
13786
 
13787
+ /** Safely extracts a readable message from an unknown catch value, including Amplify's
13788
+ * GraphQL error shape ({ data, errors: [{ errorType, message }] }). */
13789
+ function toErrorMessage(err) {
13790
+ if (err && typeof err === 'object') {
13791
+ const anyErr = err;
13792
+ if (Array.isArray(anyErr.errors) && anyErr.errors.length > 0) {
13793
+ return anyErr.errors
13794
+ .map((e) => {
13795
+ const title = e.errorType ? `${e.errorType}: ` : '';
13796
+ return `${title}${e.message || 'Unknown error'}`;
13797
+ })
13798
+ .join(' | ');
13799
+ }
13800
+ if (err instanceof Error) {
13801
+ return err.message;
13802
+ }
13803
+ }
13804
+ if (typeof err === 'string') {
13805
+ return err;
13806
+ }
13807
+ try {
13808
+ return JSON.stringify(err);
13809
+ }
13810
+ catch {
13811
+ return String(err);
13812
+ }
13813
+ }
13814
+ /** Thrown when the referenced S3 source file doesn't exist (orphaned DB reference —
13815
+ * e.g. the file was deleted or renamed on S3 without updating the database).
13816
+ * Kept distinct from other copy failures so the calling loop can log a clear,
13817
+ * specific message and skip the whole item instead of a generic error. */
13818
+ class MissingS3FileError extends Error {
13819
+ sourceKey;
13820
+ contextLabel;
13821
+ constructor(sourceKey, contextLabel) {
13822
+ super(`Source file missing on S3: "${sourceKey}" (${contextLabel})`);
13823
+ this.sourceKey = sourceKey;
13824
+ this.contextLabel = contextLabel;
13825
+ this.name = 'MissingS3FileError';
13826
+ }
13827
+ }
13828
+ class MatterportTagCopyService {
13829
+ navigationService;
13830
+ userService;
13831
+ poiService;
13832
+ ticketService;
13833
+ equipmentService;
13834
+ object3DService;
13835
+ featureService;
13836
+ measurementService;
13837
+ commentService;
13838
+ translate;
13839
+ // Emits progress updates during copyTagsFromVisit(); null when idle
13840
+ copyProgress$ = new BehaviorSubject(null);
13841
+ // Emits the accumulated log lines during copyTagsFromVisit(); [] when idle
13842
+ copyLog$ = new BehaviorSubject([]);
13843
+ spModule;
13844
+ log = [];
13845
+ errorSeq = 0;
13846
+ constructor(navigationService, userService, poiService, ticketService, equipmentService, object3DService, featureService, measurementService, commentService, translate) {
13847
+ this.navigationService = navigationService;
13848
+ this.userService = userService;
13849
+ this.poiService = poiService;
13850
+ this.ticketService = ticketService;
13851
+ this.equipmentService = equipmentService;
13852
+ this.object3DService = object3DService;
13853
+ this.featureService = featureService;
13854
+ this.measurementService = measurementService;
13855
+ this.commentService = commentService;
13856
+ this.translate = translate;
13857
+ this.spModule = userService.getSpModule();
13858
+ }
13859
+ //
13860
+ // ---------- Public API ----------
13861
+ //
13862
+ async copyTagsFromVisit(sourceSpaceId, targetSpaceId, landmarkSourceA, landmarkTargetA, landmarkSourceB, landmarkTargetB) {
13863
+ let errorCount = 0;
13864
+ this.resetLog();
13865
+ try {
13866
+ console.log(`Copying Tags from source ${sourceSpaceId} to target ${targetSpaceId}`);
13867
+ this.setStepProgress('collecting', 'collecting', 0);
13868
+ this.copyProgress$.next({
13869
+ phase: 'collecting',
13870
+ processedInPhase: 0, totalInPhase: 0,
13871
+ processedOverall: 0, totalOverall: 0,
13872
+ percent: 0, errorCount: 0
13873
+ });
13874
+ const targetMission = this.userService.currentMission(targetSpaceId);
13875
+ const targetMissionId = targetMission ? targetMission.id : null;
13876
+ const transform = computeRigidTransform(landmarkSourceA, landmarkTargetA, landmarkSourceB, landmarkTargetB);
13877
+ console.log('Rigid transform computed:', {
13878
+ rotationYDeg: transform.rotationYRad * (180 / Math.PI),
13879
+ refSource: transform.refSource,
13880
+ refTarget: transform.refTarget
13881
+ });
13882
+ const sourceSweepIds = await this.navigationService.getAllowedSweepsIDsForUserInSpace(sourceSpaceId);
13883
+ const targetSweepIds = await this.navigationService.getAllowedSweepsIDsForUserInSpace(targetSpaceId);
13884
+ this.setStepProgress('collecting', 'collecting', 30);
13885
+ const [sourceNavPositions, targetNavPositions] = await Promise.all([
13886
+ this.buildNavigationPositionMap(sourceSpaceId, sourceSweepIds),
13887
+ this.buildNavigationPositionMap(targetSpaceId, targetSweepIds)
13888
+ ]);
13889
+ const elementIds = {};
13890
+ let sourceTickets = [];
13891
+ let sourceEquipments = [];
13892
+ let targetTickets = [];
13893
+ let targetEquipments = [];
13894
+ let sourceMeasurements = [];
13895
+ let targetMeasurements = [];
13896
+ if (this.spModule == SpModule.IMMO) {
13897
+ sourceTickets = await this.ticketService.getTicketsBySpace(sourceSpaceId);
13898
+ sourceEquipments = await this.equipmentService.getEquipmentsForSpace(sourceSpaceId);
13899
+ targetTickets = await this.ticketService.getTicketsBySpace(targetSpaceId);
13900
+ targetEquipments = await this.equipmentService.getEquipmentsForSpace(targetSpaceId);
13901
+ }
13902
+ this.setStepProgress('collecting', 'collecting', 55);
13903
+ const sourceObjects3D = await this.object3DService.getObjects3DBySpace(sourceSpaceId);
13904
+ const targetObjects3D = await this.object3DService.getObjects3DBySpace(targetSpaceId);
13905
+ const sourceFeatures = await this.featureService.getFeaturesBySpaceWithoutFiltering(sourceSpaceId);
13906
+ const targetFeatures = await this.featureService.getFeaturesBySpaceWithoutFiltering(targetSpaceId);
13907
+ this.setStepProgress('collecting', 'collecting', 75);
13908
+ if (this.spModule == SpModule.IMMO) {
13909
+ sourceMeasurements = await this.measurementService.getMeasurementsForSpaceForUser(sourceSpaceId);
13910
+ targetMeasurements = await this.measurementService.getMeasurementsForSpaceForUser(targetSpaceId);
13911
+ }
13912
+ this.setStepProgress('collecting', 'collecting', 90);
13913
+ const sourcePoisAll = await this.poiService.getPoisByMatterportSweeps(sourceSweepIds);
13914
+ const targetPoisAll = await this.poiService.getPoisByMatterportSweeps(targetSweepIds);
13915
+ const handledPoiTypes = new Set([
13916
+ PoiType.TICKET, PoiType.EQUIPMENT, PoiType.DATA, PoiType.DESK,
13917
+ PoiType.OBJECT3D, PoiType.MEASURE
13918
+ ]);
13919
+ const sourcePois = sourcePoisAll.filter(p => !handledPoiTypes.has(p.type));
13920
+ const targetPois = targetPoisAll.filter(p => !handledPoiTypes.has(p.type));
13921
+ this.setStepProgress('collecting', 'collecting', 100);
13922
+ const totalOverall = sourceTickets.length + sourceEquipments.length + sourcePois.length
13923
+ + sourceObjects3D.length + sourceFeatures.length + sourceMeasurements.length;
13924
+ let processedOverall = 0;
13925
+ const emitProgress = (phase, processedInPhase, totalInPhase) => {
13926
+ this.copyProgress$.next({
13927
+ phase,
13928
+ processedInPhase,
13929
+ totalInPhase,
13930
+ processedOverall,
13931
+ totalOverall,
13932
+ percent: totalOverall > 0 ? Math.round((processedOverall / totalOverall) * 100) : 0,
13933
+ errorCount
13934
+ });
13935
+ };
13936
+ if (this.spModule == SpModule.IMMO) {
13937
+ // TICKETS
13938
+ for (let i = 0; i < sourceTickets.length; i++) {
13939
+ const sourceObj = sourceTickets[i];
13940
+ try {
13941
+ const existsInTarget = this.matchesExisting(sourceObj, targetTickets);
13942
+ if (!existsInTarget) {
13943
+ const rawSource = sourceObj;
13944
+ const ticketInput = {
13945
+ title: rawSource.title,
13946
+ friendlyLabel: rawSource.friendlyLabel,
13947
+ status: rawSource.status,
13948
+ type: rawSource.type,
13949
+ priority: rawSource.priority,
13950
+ description: rawSource.description,
13951
+ level: rawSource.level,
13952
+ metadata: rawSource.metadata || null,
13953
+ read: rawSource.read || null,
13954
+ projectID: rawSource.projectID || null,
13955
+ problemCode: rawSource.problemCode || null,
13956
+ archivedForMissionID: rawSource.archivedForMissionID || null,
13957
+ spaceID: targetSpaceId,
13958
+ parentID: null,
13959
+ domainID: null,
13960
+ ownerMissionID: targetMissionId
13961
+ };
13962
+ const createdObj = await this.ticketService.createTicket(ticketInput);
13963
+ if (createdObj && createdObj.id) {
13964
+ elementIds[sourceObj.id] = createdObj.id;
13965
+ }
13966
+ if (sourceObj.pois.items && sourceObj.pois.items.length > 0) {
13967
+ const sourcePoi = sourceObj.pois.items[0];
13968
+ const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
13969
+ const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
13970
+ const poiInput = {
13971
+ coordinate: JSON.stringify(adjustedCoordinate),
13972
+ type: sourcePoi.type,
13973
+ elementID: createdObj.id,
13974
+ metadata: sourcePoi.metadata,
13975
+ matterportSweepID: targetSweepId
13976
+ };
13977
+ await this.poiService.createPOIForSpaceID(poiInput);
13978
+ }
13979
+ }
13980
+ }
13981
+ catch (itemError) {
13982
+ console.error(`Error copying ticket "${sourceObj?.title}" (id: ${sourceObj?.id}):`, itemError);
13983
+ errorCount++;
13984
+ this.logCopyItemError(itemError, 'errorTicket', sourceObj?.title || sourceObj?.id);
13985
+ }
13986
+ processedOverall++;
13987
+ emitProgress('tickets', i + 1, sourceTickets.length);
13988
+ const pct = sourceTickets.length > 0 ? Math.round(((i + 1) / sourceTickets.length) * 100) : 100;
13989
+ this.setStepProgress('tickets', 'tickets', pct);
13990
+ }
13991
+ // EQUIPMENTS (title, POI, PHOTO and NOTES/documents all copied — files physically
13992
+ // duplicated in S3 first, so the copy stays independent from the source)
13993
+ for (let i = 0; i < sourceEquipments.length; i++) {
13994
+ const sourceObj = sourceEquipments[i];
13995
+ try {
13996
+ const existsInTarget = this.matchesExisting(sourceObj, targetEquipments);
13997
+ if (!existsInTarget) {
13998
+ const rawSource = sourceObj;
13999
+ let copiedPhotoKey = null;
14000
+ if (rawSource.photo) {
14001
+ copiedPhotoKey = await this.copyS3KeyIfPresent(rawSource.photo, `equipment photo "${rawSource.title}"`);
14002
+ }
14003
+ const equipmentInput = {
14004
+ title: rawSource.title,
14005
+ status: rawSource.status,
14006
+ description: rawSource.description,
14007
+ archivedForMissionID: rawSource.archivedForMissionID || null,
14008
+ spaceID: targetSpaceId,
14009
+ domainID: null,
14010
+ photo: copiedPhotoKey,
14011
+ };
14012
+ const createdObj = await this.equipmentService.createEquipment(equipmentInput, null, null);
14013
+ if (createdObj && createdObj.id) {
14014
+ elementIds[sourceObj.id] = createdObj.id;
14015
+ }
14016
+ // Copy notes (documents, technical/financial/other/contractual/image notes)
14017
+ if (createdObj && rawSource.notes?.items && rawSource.notes.items.length > 0) {
14018
+ for (const sourceNote of rawSource.notes.items) {
14019
+ try {
14020
+ if (!sourceNote.attachment) {
14021
+ continue; // nothing to duplicate for this note
14022
+ }
14023
+ const copiedKey = await this.copyS3KeyIfPresent(sourceNote.attachment, `equipment note for "${sourceObj?.title}"`);
14024
+ await this.equipmentService.createNoteWithExistingAttachment(createdObj.id, copiedKey, sourceNote.type, targetMissionId);
14025
+ }
14026
+ catch (noteError) {
14027
+ console.error(`Error copying note for equipment "${sourceObj?.title}":`, noteError);
14028
+ errorCount++;
14029
+ this.logCopyItemError(noteError, 'errorEquipmentNote', sourceObj?.title || sourceObj?.id);
14030
+ }
14031
+ }
14032
+ }
14033
+ if (sourceObj.pois.items && sourceObj.pois.items.length > 0) {
14034
+ const sourcePoi = sourceObj.pois.items[0];
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);
14045
+ }
14046
+ }
14047
+ }
14048
+ catch (itemError) {
14049
+ console.error(`Error copying equipment "${sourceObj?.title}" (id: ${sourceObj?.id}):`, itemError);
14050
+ errorCount++;
14051
+ this.logCopyItemError(itemError, 'errorEquipment', sourceObj?.title || sourceObj?.id);
14052
+ }
14053
+ processedOverall++;
14054
+ emitProgress('equipments', i + 1, sourceEquipments.length);
14055
+ const pct = sourceEquipments.length > 0 ? Math.round(((i + 1) / sourceEquipments.length) * 100) : 100;
14056
+ this.setStepProgress('equipments', 'equipments', pct);
14057
+ }
14058
+ // Second pass: remap subequipmentsIDs on newly created equipments to point to
14059
+ // their copied counterparts. IDs with no copied counterpart (source equipment
14060
+ // wasn't copied — e.g. it already existed in target, or was skipped) are dropped
14061
+ // rather than left dangling or pointing back at the source space's equipment.
14062
+ for (const sourceObj of sourceEquipments) {
14063
+ const rawSource = sourceObj;
14064
+ const newId = elementIds[sourceObj.id];
14065
+ if (newId && rawSource.subequipmentsIDs && rawSource.subequipmentsIDs.length > 0) {
14066
+ const remapped = rawSource.subequipmentsIDs
14067
+ .map((subId) => elementIds[subId])
14068
+ .filter((id) => !!id);
14069
+ if (remapped.length > 0) {
14070
+ try {
14071
+ await this.equipmentService.updateEquipment({ id: newId, subequipmentsIDs: remapped });
14072
+ }
14073
+ catch (subEquipError) {
14074
+ console.error(`Error remapping sub-equipments for "${sourceObj?.title}":`, subEquipError);
14075
+ errorCount++;
14076
+ this.logCopyItemError(subEquipError, 'errorEquipmentSubEquip', sourceObj?.title || sourceObj?.id);
14077
+ }
14078
+ }
14079
+ }
14080
+ }
14081
+ }
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
+ // OBJECTS 3D (the 3D model file itself — `path` — is physically duplicated in S3)
14123
+ for (let i = 0; i < sourceObjects3D.length; i++) {
14124
+ const sourceObj = sourceObjects3D[i];
14125
+ try {
14126
+ const existsInTarget = targetObjects3D.some(targetObj => targetObj.name === sourceObj.name);
14127
+ if (!existsInTarget) {
14128
+ const rawSource = sourceObj;
14129
+ let copiedPath = rawSource.path;
14130
+ if (rawSource.path) {
14131
+ copiedPath = await this.copyS3KeyIfPresent(rawSource.path, `3D object "${rawSource.name}"`);
14132
+ }
14133
+ /**
14134
+ * Strict parser to ensure [float, float, float] format for GraphQL.
14135
+ * Forces numbers, handles potential DynamoDB object structures,
14136
+ * and ensures we never send [0,0,0] for scale.
14137
+ */
14138
+ const toFloatArray = (val, defaultVal = [0, 0, 0]) => {
14139
+ let array = [];
14140
+ if (Array.isArray(val)) {
14141
+ // If DB format [ {N: "1"}, ... ]
14142
+ if (val.length > 0 && typeof val[0] === 'object' && 'N' in val[0]) {
14143
+ array = val.map(item => parseFloat(item.N));
14144
+ }
14145
+ // If already standard array [1, 1, 1]
14146
+ else {
14147
+ array = val.map(item => parseFloat(item));
14148
+ }
14149
+ }
14150
+ // If object {x, y, z}
14151
+ else if (val && typeof val === 'object') {
14152
+ array = [parseFloat(val.x), parseFloat(val.y), parseFloat(val.z)];
14153
+ }
14154
+ else {
14155
+ return defaultVal;
14156
+ }
14157
+ // Ensure we return valid numbers
14158
+ return array.map(n => isNaN(n) ? 0 : n);
14159
+ };
14160
+ const object3DInput = {
14161
+ path: copiedPath,
14162
+ name: rawSource.name,
14163
+ description: rawSource.description || "",
14164
+ viewFrustum: !!rawSource.viewFrustum,
14165
+ spaceID: targetSpaceId,
14166
+ 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])
14170
+ };
14171
+ // Debugging: Inspect the exact object sent to the mutation
14172
+ console.log(`[DEBUG] Sending Object3D "${rawSource.name}" to API:`, JSON.stringify(object3DInput));
14173
+ const createdObj = await this.object3DService.create(object3DInput);
14174
+ if (createdObj && createdObj.id) {
14175
+ elementIds[sourceObj.id] = createdObj.id;
14176
+ }
14177
+ if (sourceObj.pois?.items && sourceObj.pois.items.length > 0) {
14178
+ const sourcePoi = sourceObj.pois.items[0];
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);
14189
+ }
14190
+ }
14191
+ }
14192
+ catch (itemError) {
14193
+ console.error(`Error copying object3D "${sourceObj?.name}":`, itemError);
14194
+ errorCount++;
14195
+ this.logCopyItemError(itemError, 'errorObject3d', sourceObj?.name || sourceObj?.id);
14196
+ }
14197
+ processedOverall++;
14198
+ emitProgress('objects3d', i + 1, sourceObjects3D.length);
14199
+ const pct = sourceObjects3D.length > 0 ? Math.round(((i + 1) / sourceObjects3D.length) * 100) : 100;
14200
+ this.setStepProgress('objects3d', 'objects3d', pct);
14201
+ }
14202
+ // FEATURES (title, POI, and attached comments — images/videos/docs/links — all copied,
14203
+ // each file physically duplicated in S3 first)
14204
+ for (let i = 0; i < sourceFeatures.length; i++) {
14205
+ const sourceObj = sourceFeatures[i];
14206
+ try {
14207
+ const existsInTarget = this.matchesExisting(sourceObj, targetFeatures);
14208
+ if (!existsInTarget) {
14209
+ const rawSource = sourceObj;
14210
+ const featureInput = {
14211
+ title: rawSource.title,
14212
+ shortDescription: rawSource.shortDescription,
14213
+ level: rawSource.level,
14214
+ type: rawSource.type,
14215
+ archivedForMissionID: rawSource.archivedForMissionID || null,
14216
+ stripeProductID: rawSource.stripeProductID || null,
14217
+ stripePriceID: rawSource.stripePriceID || null,
14218
+ bookingPriceMetadata: rawSource.bookingPriceMetadata || null,
14219
+ locale: rawSource.locale || null,
14220
+ hashtags: rawSource.hashtags || null,
14221
+ description: rawSource.description || null,
14222
+ domainID: null,
14223
+ spaceID: targetSpaceId,
14224
+ ownerMissionID: targetMissionId
14225
+ };
14226
+ const createdObj = await this.featureService.createFeature(featureInput);
14227
+ if (createdObj && createdObj.id) {
14228
+ elementIds[sourceObj.id] = createdObj.id;
14229
+ }
14230
+ if (createdObj && sourceObj.comments?.items && sourceObj.comments.items.length > 0) {
14231
+ for (const sourceComment of sourceObj.comments.items) {
14232
+ try {
14233
+ const rawComment = sourceComment;
14234
+ let copiedAnnexe = null;
14235
+ if (rawComment.annexe) {
14236
+ copiedAnnexe = await this.copyS3KeyIfPresent(rawComment.annexe, `feature attachment "${rawComment.title}"`);
14237
+ }
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
+ }
14253
+ catch (attachmentError) {
14254
+ console.error(`Error copying attachment "${sourceComment?.title}" for feature "${sourceObj?.title}":`, attachmentError);
14255
+ errorCount++;
14256
+ this.logCopyItemError(attachmentError, 'errorFeatureAttachment', sourceComment?.title || sourceComment?.id);
14257
+ }
14258
+ }
14259
+ }
14260
+ if (sourceObj.pois?.items && sourceObj.pois.items.length > 0) {
14261
+ const sourcePoi = sourceObj.pois.items[0];
14262
+ const targetSweepId = this.findClosestTargetSweepId(sourcePoi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
14263
+ const adjustedCoordinate = this.getAdjustedCoordinates(sourcePoi.coordinate, transform);
14264
+ const poiInput = {
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);
14272
+ }
14273
+ }
14274
+ }
14275
+ catch (itemError) {
14276
+ console.error(`Error copying feature "${sourceObj?.title}" (id: ${sourceObj?.id}):`, itemError);
14277
+ errorCount++;
14278
+ this.logCopyItemError(itemError, 'errorFeature', sourceObj?.title || sourceObj?.id);
14279
+ }
14280
+ processedOverall++;
14281
+ emitProgress('features', i + 1, sourceFeatures.length);
14282
+ const pct = sourceFeatures.length > 0 ? Math.round(((i + 1) / sourceFeatures.length) * 100) : 100;
14283
+ this.setStepProgress('features', 'features', pct);
14284
+ }
14285
+ // MEASUREMENTS (IMMO only — a Comment + a positional-only POI, no rotation;
14286
+ // the screenshot file is physically duplicated in S3 first)
14287
+ if (this.spModule == SpModule.IMMO) {
14288
+ for (let i = 0; i < sourceMeasurements.length; i++) {
14289
+ const sourceMeasurement = sourceMeasurements[i];
14290
+ try {
14291
+ const existsInTarget = targetMeasurements.some(tm => tm.comment.title === sourceMeasurement.comment.title);
14292
+ if (!existsInTarget) {
14293
+ const rawComment = sourceMeasurement.comment;
14294
+ let copiedAnnexe = null;
14295
+ if (rawComment.annexe) {
14296
+ copiedAnnexe = await this.copyS3KeyIfPresent(rawComment.annexe, `measurement screenshot "${rawComment.title}"`);
14297
+ }
14298
+ const commentInput = {
14299
+ title: rawComment.title,
14300
+ type: CommentType.MEASURE,
14301
+ dimensions: rawComment.dimensions || null,
14302
+ annexe: copiedAnnexe,
14303
+ spaceID: targetSpaceId,
14304
+ };
14305
+ const createdComment = await this.commentService.createComment(commentInput);
14306
+ if (createdComment && sourceMeasurement.poi) {
14307
+ const targetSweepId = this.findClosestTargetSweepId(sourceMeasurement.poi.matterportSweepID, sourceNavPositions, targetNavPositions, transform);
14308
+ const adjustedPosition = this.getAdjustedPositionOnly(sourceMeasurement.poi.coordinate, transform);
14309
+ const poiInput = {
14310
+ coordinate: JSON.stringify(adjustedPosition),
14311
+ type: PoiType.MEASURE,
14312
+ elementID: createdComment.id,
14313
+ metadata: sourceMeasurement.poi.metadata,
14314
+ matterportSweepID: targetSweepId
14315
+ };
14316
+ await this.poiService.createPOIForSpaceID(poiInput);
14317
+ }
14318
+ }
14319
+ }
14320
+ catch (itemError) {
14321
+ console.error(`Error copying measurement "${sourceMeasurement?.comment?.title}":`, itemError);
14322
+ errorCount++;
14323
+ this.logCopyItemError(itemError, 'errorMeasurement', sourceMeasurement?.comment?.title || sourceMeasurement?.id);
14324
+ }
14325
+ processedOverall++;
14326
+ emitProgress('measurements', i + 1, sourceMeasurements.length);
14327
+ const pct = sourceMeasurements.length > 0 ? Math.round(((i + 1) / sourceMeasurements.length) * 100) : 100;
14328
+ this.setStepProgress('measurements', 'measurements', pct);
14329
+ }
14330
+ }
14331
+ this.copyProgress$.next({
14332
+ phase: 'done',
14333
+ processedInPhase: 0, totalInPhase: 0,
14334
+ processedOverall, totalOverall,
14335
+ percent: 100, errorCount
14336
+ });
14337
+ this.setStepLog('done', errorCount > 0
14338
+ ? this.translate.instant('matterportCopy.doneWithErrors', { count: errorCount })
14339
+ : this.translate.instant('matterportCopy.done'));
14340
+ return true;
14341
+ }
14342
+ catch (error) {
14343
+ console.error('Error copying POIs:', error);
14344
+ this.appendErrorLog(this.translate.instant('matterportCopy.errorGeneral', { error: toErrorMessage(error) }));
14345
+ this.copyProgress$.next(null);
14346
+ return false;
14347
+ }
14348
+ }
14349
+ //
14350
+ // ---------- Progress / log helpers ----------
14351
+ //
14352
+ /** Adds or updates a step line (identified by stepId) with new text, then republishes the log. */
14353
+ setStepLog(stepId, text) {
14354
+ const existingIndex = this.log.findIndex(e => e.type === 'step' && e.id === stepId);
14355
+ const entry = { id: stepId, type: 'step', text };
14356
+ if (existingIndex >= 0) {
14357
+ this.log[existingIndex] = entry;
14358
+ }
14359
+ else {
14360
+ this.log.push(entry);
14361
+ }
14362
+ this.copyLog$.next([...this.log]);
14363
+ }
14364
+ /** Appends a new, permanent log line (never overwritten), then republishes the log. */
14365
+ appendErrorLog(text) {
14366
+ this.errorSeq++;
14367
+ this.log.push({ id: `error-${this.errorSeq}`, type: 'error', text });
14368
+ this.copyLog$.next([...this.log]);
14369
+ }
14370
+ /** Resets the log for a new run. */
14371
+ resetLog() {
14372
+ this.log = [];
14373
+ this.errorSeq = 0;
14374
+ this.copyLog$.next([]);
14375
+ }
14376
+ /** Builds and sets a step's log line as "<translated label>: <percent>%", no step numbering. */
14377
+ setStepProgress(stepId, translationKey, percent) {
14378
+ const label = this.translate.instant(`matterportCopy.${translationKey}`);
14379
+ this.setStepLog(stepId, `${label}: ${percent}%`);
14380
+ }
14381
+ /** Logs a translated, parameterized error line for a single failed item. */
14382
+ logItemError(translationKey, params) {
14383
+ const text = this.translate.instant(`matterportCopy.${translationKey}`, params);
14384
+ this.appendErrorLog(text);
14385
+ }
14386
+ /** Logs an item-level error, using a specific "missing file / orphaned reference"
14387
+ * translation key when the cause is a MissingS3FileError, and the generic per-type
14388
+ * 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
+ logCopyItemError(error, genericKey, itemLabel) {
14391
+ if (error instanceof MissingS3FileError) {
14392
+ this.logItemError('errorMissingS3FileSkipped', {
14393
+ name: itemLabel,
14394
+ file: error.sourceKey
14395
+ });
14396
+ }
14397
+ else {
14398
+ this.logItemError(genericKey, { name: itemLabel, error: toErrorMessage(error) });
14399
+ }
14400
+ }
14401
+ //
14402
+ // ---------- Duplicate detection ----------
14403
+ //
14404
+ /**
14405
+ * Heuristic duplicate-detection: compares title AND description together instead of
14406
+ * title alone, to reduce false positives when several source items happen to share
14407
+ * the same title. Not a perfect guarantee, but meaningfully more robust.
14408
+ */
14409
+ matchesExisting(source, targetList) {
14410
+ return targetList.some(t => t.title === source.title && (t.description || null) === (source.description || null));
14411
+ }
14412
+ //
14413
+ // ---------- Sweep positions / rigid transform ----------
14414
+ //
14415
+ /**
14416
+ * Fetches the position of every sweep in a space and returns it as a Map for O(1) lookup.
14417
+ * Uses allSettled so a single failing sweep lookup doesn't abort the whole map —
14418
+ * that sweep is simply absent from the resulting map (and skipped later).
14419
+ */
14420
+ async buildNavigationPositionMap(spaceId, sweepIds) {
14421
+ const map = new Map();
14422
+ const results = await Promise.allSettled(sweepIds.map(async (sid) => {
14423
+ const navs = await this.navigationService.getNavigationsForMatterportIDForSpace(sid, spaceId);
14424
+ if (navs && navs.length > 0 && navs[0].position) {
14425
+ return { sid, position: JSON.parse(navs[0].position) };
14426
+ }
14427
+ return null;
14428
+ }));
14429
+ for (const result of results) {
14430
+ if (result.status === 'fulfilled' && result.value) {
14431
+ map.set(result.value.sid, result.value.position);
14432
+ }
14433
+ else if (result.status === 'rejected') {
14434
+ console.warn('Failed to fetch a sweep position, skipping it:', result.reason);
14435
+ }
14436
+ }
14437
+ return map;
14438
+ }
14439
+ /** Applies the rigid transform to a POI coordinate (position + orientation). */
14440
+ getAdjustedCoordinates(coordinate, transform) {
14441
+ if (coordinate === "null") {
14442
+ return null;
14443
+ }
14444
+ if (!coordinate || typeof coordinate !== 'string') {
14445
+ return coordinate;
14446
+ }
14447
+ const parsed = JSON.parse(coordinate);
14448
+ const rotatedPos = applyRigidTransformToPoint({ x: parsed.x, y: parsed.y, z: parsed.z }, transform);
14449
+ return {
14450
+ x: rotatedPos.x,
14451
+ y: rotatedPos.y,
14452
+ z: rotatedPos.z,
14453
+ xRotation: parsed.xRotation,
14454
+ yRotation: applyRigidTransformToYRotationDeg(parsed.yRotation, transform)
14455
+ };
14456
+ }
14457
+ /**
14458
+ * Applies the rigid transform's translation+rotation to a plain {x,y,z} position,
14459
+ * WITHOUT adding xRotation/yRotation keys. Used for measurements, whose stored
14460
+ * coordinate is a bare position (no orientation), unlike other POI types.
14461
+ */
14462
+ getAdjustedPositionOnly(coordinate, transform) {
14463
+ if (!coordinate || typeof coordinate !== 'string' || coordinate === 'null') {
14464
+ return coordinate;
14465
+ }
14466
+ const parsed = JSON.parse(coordinate);
14467
+ return applyRigidTransformToPoint({ x: parsed.x, y: parsed.y, z: parsed.z }, transform);
14468
+ }
14469
+ /**
14470
+ * Finds the closest target sweep, comparing positions in the SAME coordinate frame.
14471
+ * Pure in-memory lookup — no network call, thanks to the pre-built position maps.
14472
+ */
14473
+ findClosestTargetSweepId(sourceSweepID, sourceNavPositions, targetNavPositions, transform) {
14474
+ if (!sourceSweepID)
14475
+ return null;
14476
+ const sourcePos = sourceNavPositions.get(sourceSweepID);
14477
+ if (!sourcePos)
14478
+ return null;
14479
+ const sourcePosInTargetFrame = applyRigidTransformToPoint(sourcePos, transform);
14480
+ let closestSweepID = null;
14481
+ let minDistance = Infinity;
14482
+ for (const [targetSweepId, targetPos] of targetNavPositions.entries()) {
14483
+ const dx = targetPos.x - sourcePosInTargetFrame.x;
14484
+ const dy = targetPos.y - sourcePosInTargetFrame.y;
14485
+ const dz = targetPos.z - sourcePosInTargetFrame.z;
14486
+ const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
14487
+ if (dist < minDistance) {
14488
+ minDistance = dist;
14489
+ closestSweepID = targetSweepId;
14490
+ }
14491
+ }
14492
+ return closestSweepID;
14493
+ }
14494
+ //
14495
+ // ---------- S3 file duplication ----------
14496
+ //
14497
+ /** Generates a short random id, used to give each copied S3 file a fresh, collision-safe key. */
14498
+ generateShortId() {
14499
+ return Math.random().toString(36).slice(2, 10);
14500
+ }
14501
+ /**
14502
+ * Builds a new S3 key for a copied attachment and sanitizes the filename
14503
+ * to prevent AWS Amplify SignatureDoesNotMatch errors.
14504
+ */
14505
+ buildCopiedAttachmentKey(sourceAnnexe) {
14506
+ const lastSlash = sourceAnnexe.lastIndexOf('/');
14507
+ const folder = sourceAnnexe.substring(0, lastSlash + 1);
14508
+ let filename = sourceAnnexe.substring(lastSlash + 1);
14509
+ // Strip a leading "<8 hex chars>_" id prefix if present
14510
+ const prefixMatch = filename.match(/^[0-9a-fA-F-]{8}_(.+)$/);
14511
+ if (prefixMatch) {
14512
+ filename = prefixMatch[1];
14513
+ }
14514
+ // SANITIZATION: Replace spaces, parentheses, and accents with underscores
14515
+ // Crucial to prevent Amplify's Storage.copy from failing the signature calculation
14516
+ filename = filename.replace(/[^a-zA-Z0-9.\-_]/g, '_');
14517
+ const newPrefix = `${this.generateShortId()}_`;
14518
+ return `${folder}${newPrefix}${filename}`;
14519
+ }
14520
+ /**
14521
+ * Duplicates an S3 object if a key is present.
14522
+ * - If the source file is genuinely missing from S3 (NoSuchKey / orphaned DB reference),
14523
+ * throws a MissingS3FileError so the caller can log a clear, specific message and
14524
+ * skip the whole item entirely, rather than creating a record with a broken file link.
14525
+ * - Any other kind of failure (permissions, network, etc.) still throws a generic Error.
14526
+ */
14527
+ async copyS3KeyIfPresent(key, contextLabel) {
14528
+ if (!key) {
14529
+ return null;
14530
+ }
14531
+ const sourceKey = this.extractS3KeyFromValue(key);
14532
+ const destKey = this.buildCopiedAttachmentKey(sourceKey);
14533
+ // Log the source key to ensure it matches the real S3 object path
14534
+ console.log(`[DEBUG] Attempting S3 copy from source: "${sourceKey}" to dest: "${destKey}"`);
14535
+ try {
14536
+ const copied = await copyFileInS3(sourceKey, destKey);
14537
+ if (!copied) {
14538
+ throw new MissingS3FileError(sourceKey, contextLabel);
14539
+ }
14540
+ return copied;
14541
+ }
14542
+ catch (error) {
14543
+ const isMissingFile = error instanceof MissingS3FileError
14544
+ || error?.code === 'NoSuchKey'
14545
+ || /NoSuchKey|does not exist/i.test(toErrorMessage(error));
14546
+ if (isMissingFile) {
14547
+ console.warn(`[MatterportTagCopyService] Orphaned S3 reference, skipping item: "${sourceKey}" (${contextLabel})`);
14548
+ throw new MissingS3FileError(sourceKey, contextLabel);
14549
+ }
14550
+ throw error;
14551
+ }
14552
+ }
14553
+ /**
14554
+ * Duplicates the file referenced by a POI's tagIcon (a JSON string shaped like
14555
+ * { src, opacity }, per form-feature.component.ts). If the JSON can't be parsed or
14556
+ * has no `src`, the value is returned unchanged rather than risk corrupting it.
14557
+ */
14558
+ async duplicateTagIcon(tagIconRaw) {
14559
+ if (!tagIconRaw) {
14560
+ return tagIconRaw;
14561
+ }
14562
+ try {
14563
+ const parsed = JSON.parse(tagIconRaw);
14564
+ if (parsed && parsed.src) {
14565
+ parsed.src = await this.copyS3KeyIfPresent(parsed.src, 'tag icon');
14566
+ }
14567
+ return JSON.stringify(parsed);
14568
+ }
14569
+ catch {
14570
+ // Not JSON, or unexpected shape — leave untouched rather than guess.
14571
+ return tagIconRaw;
14572
+ }
14573
+ }
14574
+ /**
14575
+ * Duplicates tagImageID if it looks like a reference to a file (contains a path
14576
+ * separator, whether raw key or pre-signed URL). UNCONFIRMED ASSUMPTION: this
14577
+ * field's exact semantics weren't verified — if it's actually an opaque reference
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;
14584
+ }
14585
+ return this.copyS3KeyIfPresent(tagImageID, 'tag image');
14586
+ }
14587
+ /**
14588
+ * Extracts and normalizes the raw S3 key from a stored value (which may be a full URL
14589
+ * or already a raw key).
14590
+ * IMPORTANT: this must return the RAW (decoded, non URL-encoded) key. Amplify's
14591
+ * Storage.copy() already builds and encodes the 'x-amz-copy-source' header internally
14592
+ * from the key we give it. Pre-encoding it here caused a double-encoding bug (e.g. a
14593
+ * space became "%2520" instead of "%20"), so Storage.copy() looked for a key that never
14594
+ * existed on S3, resulting in a NoSuchKey error.
14595
+ */
14596
+ extractS3KeyFromValue(value) {
14597
+ if (!value) {
14598
+ return '';
14599
+ }
14600
+ let extracted = value;
14601
+ // 1. Extract path from URL and drop query params
14602
+ if (value.startsWith('http://') || value.startsWith('https://')) {
14603
+ try {
14604
+ const url = new URL(value);
14605
+ extracted = decodeURIComponent(url.pathname);
14606
+ }
14607
+ catch {
14608
+ extracted = value.split('?')[0];
14609
+ }
14610
+ }
14611
+ else {
14612
+ extracted = value.split('?')[0];
14613
+ }
14614
+ // 2. Remove leading slash
14615
+ if (extracted.startsWith('/')) {
14616
+ extracted = extracted.substring(1);
14617
+ }
14618
+ // 3. Remove 'public/' prefix
14619
+ // Amplify prepends this automatically; we must strip it from the input
14620
+ // to avoid the 'public/public/' double-prefix issue.
14621
+ if (extracted.startsWith('public/')) {
14622
+ extracted = extracted.substring('public/'.length);
14623
+ }
14624
+ // Return the raw key as-is — Storage.copy() takes care of properly encoding it
14625
+ // for the CopySource header. Do NOT re-encode it here.
14626
+ return extracted;
14627
+ }
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 });
14629
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, providedIn: 'root' });
14630
+ }
14631
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MatterportTagCopyService, decorators: [{
14632
+ type: Injectable,
14633
+ 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 }] });
14635
+
13509
14636
  /* eslint-disable class-methods-use-this */
13510
14637
  class OrganisationService {
13511
14638
  smarterPlanID = "6e28201f-4679-4c29-9d2d-8a325f57cee9";
@@ -13713,5 +14840,5 @@ function floatValidator() {
13713
14840
  * Generated bundle index. Do not edit.
13714
14841
  */
13715
14842
 
13716
- 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, 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, arraysContainSameElements, checkElementById, convertTimestampToLocalZone, dateHasExpired, dateTimeToLocalString, deleteFromS3, 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 };
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 };
13717
14844
  //# sourceMappingURL=smarterplan-ngx-smarterplan-core.mjs.map