@smarterplan/ngx-smarterplan-core 1.4.21 → 1.4.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +24 -24
  2. package/esm2022/lib/components/csv-export/csv-export.component.mjs +3 -3
  3. package/esm2022/lib/components/loader/loader.component.mjs +3 -3
  4. package/esm2022/lib/components/menu-bar/avatar/avatar.component.mjs +1 -1
  5. package/esm2022/lib/components/menu-bar/menu-bar.component.mjs +3 -3
  6. package/esm2022/lib/components/menu-bar/navigation-bar/navigation-bar.component.mjs +3 -3
  7. package/esm2022/lib/components/modal-switch-visit/modal-switch-visit.component.mjs +3 -3
  8. package/esm2022/lib/components/search-bar/search-bar.component.mjs +3 -3
  9. package/esm2022/lib/components/splat-viewer/splat-viewer.component.mjs +361 -0
  10. package/esm2022/lib/components/support-modal/support-modal.component.mjs +3 -3
  11. package/esm2022/lib/config.mjs +1 -1
  12. package/esm2022/lib/helpers.service.mjs +1 -1
  13. package/esm2022/lib/matterport-extensions/nest-thermostat/CanvasImage.mjs +1 -1
  14. package/esm2022/lib/matterport-extensions/nest-thermostat/CanvasRenderer.mjs +1 -1
  15. package/esm2022/lib/matterport-extensions/nest-thermostat/NestThermostat.mjs +1 -1
  16. package/esm2022/lib/matterport-extensions/nest-thermostat/PlaneRenderer.mjs +1 -1
  17. package/esm2022/lib/matterport-extensions/scene-component/SceneComponent.mjs +1 -1
  18. package/esm2022/lib/matterport-extensions/security-camera/SecurityCamera.mjs +22 -22
  19. package/esm2022/lib/matterport-extensions/tv-player/TvPlayer.mjs +1 -1
  20. package/esm2022/lib/matterport-extensions/view-frustum-mesh/ViewFrustumMesh.mjs +22 -22
  21. package/esm2022/lib/mattertagData.mjs +1 -1
  22. package/esm2022/lib/ngx-smarterplan-core.module.mjs +9 -4
  23. package/esm2022/lib/ngx-smarterplan-core.service.mjs +1 -1
  24. package/esm2022/lib/pipes/duration-to-string.pipe.mjs +1 -1
  25. package/esm2022/lib/pipes/format-date-number-to-digits.pipe.mjs +1 -1
  26. package/esm2022/lib/pipes/hashtag-from-id.pipe.mjs +1 -1
  27. package/esm2022/lib/pipes/safe-url.pipe.mjs +1 -1
  28. package/esm2022/lib/pipes/time-date-to-local-string.pipe.mjs +1 -1
  29. package/esm2022/lib/pipes/username-from-id.pipe.mjs +1 -1
  30. package/esm2022/lib/services/amplify-cache.service.mjs +1 -1
  31. package/esm2022/lib/services/base-tab.service.mjs +1 -1
  32. package/esm2022/lib/services/baseVisibility.service.mjs +1 -1
  33. package/esm2022/lib/services/content.service.mjs +1 -1
  34. package/esm2022/lib/services/filter.service.mjs +1 -1
  35. package/esm2022/lib/services/intervention.service.mjs +1 -1
  36. package/esm2022/lib/services/locale.service.mjs +1 -1
  37. package/esm2022/lib/services/matterport-import.service.mjs +1 -1
  38. package/esm2022/lib/services/matterport-measurement.service.mjs +1 -1
  39. package/esm2022/lib/services/matterport-navigation.service.mjs +1 -1
  40. package/esm2022/lib/services/matterport-pointer.service.mjs +6 -6
  41. package/esm2022/lib/services/matterport-tag-copy.service.mjs +1 -1
  42. package/esm2022/lib/services/matterport-tag.service.mjs +12 -7
  43. package/esm2022/lib/services/matterport.service.mjs +17 -17
  44. package/esm2022/lib/services/models/affectation.service.mjs +1 -1
  45. package/esm2022/lib/services/models/base-object.service.mjs +1 -1
  46. package/esm2022/lib/services/models/capture.service.mjs +1 -1
  47. package/esm2022/lib/services/models/comment.service.mjs +1 -1
  48. package/esm2022/lib/services/models/domain.service.mjs +1 -1
  49. package/esm2022/lib/services/models/equipment.service.mjs +1 -1
  50. package/esm2022/lib/services/models/event.service.mjs +1 -1
  51. package/esm2022/lib/services/models/feature.service.mjs +1 -1
  52. package/esm2022/lib/services/models/hashtag.service.mjs +1 -1
  53. package/esm2022/lib/services/models/layer.service.mjs +1 -1
  54. package/esm2022/lib/services/models/measurement.service.mjs +1 -1
  55. package/esm2022/lib/services/models/mission.service.mjs +1 -1
  56. package/esm2022/lib/services/models/navigation.service.mjs +1 -1
  57. package/esm2022/lib/services/models/node.service.mjs +1 -1
  58. package/esm2022/lib/services/models/object3D.service.mjs +1 -1
  59. package/esm2022/lib/services/models/operation.service.mjs +1 -1
  60. package/esm2022/lib/services/models/organisation.service.mjs +1 -1
  61. package/esm2022/lib/services/models/plan.service.mjs +1 -1
  62. package/esm2022/lib/services/models/poi.service.mjs +27 -27
  63. package/esm2022/lib/services/models/profile.service.mjs +1 -1
  64. package/esm2022/lib/services/models/property.service.mjs +1 -1
  65. package/esm2022/lib/services/models/space.service.mjs +1 -1
  66. package/esm2022/lib/services/models/template.service.mjs +1 -1
  67. package/esm2022/lib/services/models/ticket.service.mjs +1 -1
  68. package/esm2022/lib/services/models/visit.service.mjs +1 -1
  69. package/esm2022/lib/services/models/zone.service.mjs +1 -1
  70. package/esm2022/lib/services/navigator.service.mjs +1 -1
  71. package/esm2022/lib/services/obj-to-glb.mjs +649 -0
  72. package/esm2022/lib/services/s3.service.mjs +43 -3
  73. package/esm2022/lib/services/search.service.mjs +1 -1
  74. package/esm2022/lib/services/support.service.mjs +1 -1
  75. package/esm2022/lib/services/sweep-locator.service.mjs +1 -1
  76. package/esm2022/lib/services/tag.service.mjs +16 -16
  77. package/esm2022/lib/services/threejs-import.service.mjs +6 -4
  78. package/esm2022/lib/services/threejs.service.mjs +867 -95
  79. package/esm2022/lib/services/user.service.mjs +1 -1
  80. package/esm2022/lib/services/utils/matterport-transform.util.mjs +1 -1
  81. package/esm2022/lib/services/validators.service.mjs +1 -1
  82. package/esm2022/lib/services/viewer.service.mjs +189 -7
  83. package/esm2022/lib/services/zone-drawer.service.mjs +1 -1
  84. package/esm2022/lib/services/zoneChange.service.mjs +1 -1
  85. package/esm2022/lib/tokens/matterport-sdk-key.token.mjs +1 -1
  86. package/esm2022/lib/validators/email.directive.mjs +1 -1
  87. package/esm2022/lib/validators/no-empty.directive.mjs +1 -1
  88. package/esm2022/lib/validators/number.directive.mjs +1 -1
  89. package/esm2022/lib/validators/text.directive.mjs +1 -1
  90. package/esm2022/public-api.mjs +3 -1
  91. package/fesm2022/smarterplan-ngx-smarterplan-core.mjs +2239 -233
  92. package/fesm2022/smarterplan-ngx-smarterplan-core.mjs.map +1 -1
  93. package/lib/components/splat-viewer/splat-viewer.component.d.ts +98 -0
  94. package/lib/ngx-smarterplan-core.module.d.ts +7 -6
  95. package/lib/services/matterport-tag.service.d.ts +5 -5
  96. package/lib/services/obj-to-glb.d.ts +66 -0
  97. package/lib/services/s3.service.d.ts +7 -0
  98. package/lib/services/threejs.service.d.ts +336 -16
  99. package/lib/services/viewer.service.d.ts +110 -8
  100. package/package.json +1 -1
  101. package/public-api.d.ts +2 -0
  102. package/src/assets/viewers/splat/chunk-BYLI4YBI.js +17037 -0
  103. package/src/assets/viewers/splat/chunk-LDV6YPMG.js +1 -0
  104. package/src/assets/viewers/splat/chunk-WZ5Z4TU3.js +105719 -0
  105. package/src/assets/viewers/splat/convert-4DRLH77X.js +1 -0
  106. package/src/assets/viewers/splat/convert.html +11 -0
  107. package/src/assets/viewers/splat/converter-SLBB5MHG.js +1 -0
  108. package/src/assets/viewers/splat/index.html +90 -0
  109. package/src/assets/viewers/splat/spz-DEYRea_v-QE6YSGIB.js +5292 -0
  110. package/src/assets/viewers/splat/viewer-W4X3FWQJ.js +2704 -0
  111. package/src/assets/viewers/splat/viewer.css +1096 -0
  112. package/src/assets/viewers/splat/webp.wasm +0 -0
  113. package/src/assets/viewers/splat/worker.mjs +1383 -0
  114. package/src/assets/vs-app.json +67 -67
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Injectable, Input, Component, InjectionToken, Inject, EventEmitter, isDevMode, Output, Pipe, ViewChild, NgModule } from '@angular/core';
2
+ import { Injectable, Input, Component, InjectionToken, Inject, EventEmitter, isDevMode, Output, Pipe, ViewChild, HostListener, NgModule } from '@angular/core';
3
3
  import * as i1 from '@ngx-translate/core';
4
4
  import { TranslateModule } from '@ngx-translate/core';
5
5
  import { Subject, BehaviorSubject, takeUntil, Observable } from 'rxjs';
@@ -90,11 +90,11 @@ class CsvExportComponent {
90
90
  link.remove();
91
91
  }
92
92
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CsvExportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
93
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: CsvExportComponent, selector: "lib-csv-export", inputs: { headers: "headers", rowsInput: "rowsInput", getRows: "getRows", title: "title", buttonLabel: "buttonLabel" }, ngImport: i0, template: "<span [style.cursor]=\"'pointer'\" (click)=\"onClickDownload()\" class=\"download-csv d-flex\">\r\n <span class=\"iconify\" data-icon=\"mdi:file-download-outline\"></span>\r\n {{ buttonLabel | translate}}\r\n</span>\r\n", styles: [".download-csv{color:var(--smarterplan-primary)}\n"], dependencies: [{ kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
93
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: CsvExportComponent, selector: "lib-csv-export", inputs: { headers: "headers", rowsInput: "rowsInput", getRows: "getRows", title: "title", buttonLabel: "buttonLabel" }, ngImport: i0, template: "<span [style.cursor]=\"'pointer'\" (click)=\"onClickDownload()\" class=\"download-csv d-flex\">\n <span class=\"iconify\" data-icon=\"mdi:file-download-outline\"></span>\n {{ buttonLabel | translate}}\n</span>\n", styles: [".download-csv{color:var(--smarterplan-primary)}\n"], dependencies: [{ kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
94
94
  }
95
95
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CsvExportComponent, decorators: [{
96
96
  type: Component,
97
- args: [{ selector: 'lib-csv-export', template: "<span [style.cursor]=\"'pointer'\" (click)=\"onClickDownload()\" class=\"download-csv d-flex\">\r\n <span class=\"iconify\" data-icon=\"mdi:file-download-outline\"></span>\r\n {{ buttonLabel | translate}}\r\n</span>\r\n", styles: [".download-csv{color:var(--smarterplan-primary)}\n"] }]
97
+ args: [{ selector: 'lib-csv-export', template: "<span [style.cursor]=\"'pointer'\" (click)=\"onClickDownload()\" class=\"download-csv d-flex\">\n <span class=\"iconify\" data-icon=\"mdi:file-download-outline\"></span>\n {{ buttonLabel | translate}}\n</span>\n", styles: [".download-csv{color:var(--smarterplan-primary)}\n"] }]
98
98
  }], propDecorators: { headers: [{
99
99
  type: Input
100
100
  }], rowsInput: [{
@@ -821,30 +821,30 @@ class SecurityCamera extends SceneComponent {
821
821
  }
822
822
  }
823
823
  function vertexShader$1() {
824
- return `
825
- uniform mat4 lightMatrix;
826
-
827
- varying vec4 vLightCoords;
828
-
829
- void main() {
830
- // create a UV region [0,1] x [0,1] to represent what the lightMatrix "sees"
831
- vLightCoords = lightMatrix * modelMatrix * vec4(position, 1.0);
832
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
833
- }
824
+ return `
825
+ uniform mat4 lightMatrix;
826
+
827
+ varying vec4 vLightCoords;
828
+
829
+ void main() {
830
+ // create a UV region [0,1] x [0,1] to represent what the lightMatrix "sees"
831
+ vLightCoords = lightMatrix * modelMatrix * vec4(position, 1.0);
832
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
833
+ }
834
834
  `;
835
835
  }
836
836
  function fragmentShader$1() {
837
- return `
838
- uniform vec3 color;
839
-
840
- varying vec4 vLightCoords;
841
-
842
- void main() {
843
- vec2 lightUV = vLightCoords.xy / vLightCoords.w;
844
- float inView = float(max(lightUV.x, lightUV.y) <= 1.0 && min(lightUV.x, lightUV.y) >= 0.0 && vLightCoords.z > 0.0);
845
-
846
- gl_FragColor = vec4(color, min(inView, 0.2));
847
- }
837
+ return `
838
+ uniform vec3 color;
839
+
840
+ varying vec4 vLightCoords;
841
+
842
+ void main() {
843
+ vec2 lightUV = vLightCoords.xy / vLightCoords.w;
844
+ float inView = float(max(lightUV.x, lightUV.y) <= 1.0 && min(lightUV.x, lightUV.y) >= 0.0 && vLightCoords.z > 0.0);
845
+
846
+ gl_FragColor = vec4(color, min(inView, 0.2));
847
+ }
848
848
  `;
849
849
  }
850
850
  function updateHighlightUniforms$1(projector, uniforms) {
@@ -1037,30 +1037,30 @@ class ViewFrustumMesh extends SceneComponent {
1037
1037
  }
1038
1038
  }
1039
1039
  function vertexShader() {
1040
- return `
1041
- uniform mat4 lightMatrix;
1042
-
1043
- varying vec4 vLightCoords;
1044
-
1045
- void main() {
1046
- // create a UV region [0,1] x [0,1] to represent what the lightMatrix "sees"
1047
- vLightCoords = lightMatrix * modelMatrix * vec4(position, 1.0);
1048
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
1049
- }
1040
+ return `
1041
+ uniform mat4 lightMatrix;
1042
+
1043
+ varying vec4 vLightCoords;
1044
+
1045
+ void main() {
1046
+ // create a UV region [0,1] x [0,1] to represent what the lightMatrix "sees"
1047
+ vLightCoords = lightMatrix * modelMatrix * vec4(position, 1.0);
1048
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
1049
+ }
1050
1050
  `;
1051
1051
  }
1052
1052
  function fragmentShader() {
1053
- return `
1054
- uniform vec3 color;
1055
-
1056
- varying vec4 vLightCoords;
1057
-
1058
- void main() {
1059
- vec2 lightUV = vLightCoords.xy / vLightCoords.w;
1060
- float inView = float(max(lightUV.x, lightUV.y) <= 1.0 && min(lightUV.x, lightUV.y) >= 0.0 && vLightCoords.z > 0.0);
1061
-
1062
- gl_FragColor = vec4(color, min(inView, 0.2));
1063
- }
1053
+ return `
1054
+ uniform vec3 color;
1055
+
1056
+ varying vec4 vLightCoords;
1057
+
1058
+ void main() {
1059
+ vec2 lightUV = vLightCoords.xy / vLightCoords.w;
1060
+ float inView = float(max(lightUV.x, lightUV.y) <= 1.0 && min(lightUV.x, lightUV.y) >= 0.0 && vLightCoords.z > 0.0);
1061
+
1062
+ gl_FragColor = vec4(color, min(inView, 0.2));
1063
+ }
1064
1064
  `;
1065
1065
  }
1066
1066
  function updateHighlightUniforms(projector, uniforms) {
@@ -1095,22 +1095,62 @@ const uploadFileToS3 = async (path, file, objectId) => {
1095
1095
  return result.key;
1096
1096
  });
1097
1097
  };
1098
+ /**
1099
+ * Fetches fresh credentials, and says in the console how long the ones held had left: when an
1100
+ * upload is refused, this is what tells why.
1101
+ */
1102
+ const renewCredentials = async () => {
1103
+ try {
1104
+ const held = await Auth.currentCredentials();
1105
+ const left = held?.expiration ? new Date(held.expiration).getTime() - Date.now() : 0;
1106
+ console.info(`[upload] credentials held expire in ${Math.round(left / 60000)} min, fetching new ones`);
1107
+ // What Amplify itself does on signing in. Nobody signs in here through a federated provider,
1108
+ // whose stored sign-in this would also drop.
1109
+ await Auth.Credentials.clear();
1110
+ await Auth.currentCredentials();
1111
+ }
1112
+ catch (error) {
1113
+ // The upload will say what is wrong, if anything is.
1114
+ console.warn("[upload] could not renew the credentials:", error);
1115
+ }
1116
+ };
1117
+ const isExpiredTokenError = (error) => /ExpiredToken|token has expired/i.test(`${error?.name ?? ""} ${error?.Code ?? ""} ${error?.message ?? error ?? ""}`);
1098
1118
  /**
1099
1119
  * Uploads a file, reporting progress as a 0-1 fraction.
1100
1120
  *
1101
1121
  * uploadFileToS3 above swallows progress, which is fine for a photo and unusable for a 3D model:
1102
1122
  * these run to hundreds of megabytes, and an upload with no feedback is indistinguishable from a
1103
1123
  * frozen page. Returns the stored key, which is what gets saved on the record.
1124
+ *
1125
+ * A large file is sent in parts, each signed when it is sent. Left to itself, Storage.put signs
1126
+ * them all with whatever credentials Amplify last handed the S3 provider, for any call - credentials
1127
+ * that last an hour at most, and an upload of a few gigabytes takes longer: every part after they
1128
+ * expire is refused with "The provided token has expired", and fetching new ones does not help, the
1129
+ * upload never asks for them. Passing no credentials at all makes it ask Amplify for them part by
1130
+ * part, and Amplify renews them as they age. Should they run out anyway, it starts once more.
1104
1131
  */
1105
1132
  const uploadFileToS3WithProgress = async (path, file, onProgress) => {
1106
- return Storage.put(path + file.name, file, {
1133
+ const put = () => Storage.put(path + file.name, file, {
1107
1134
  contentType: file.type || "application/octet-stream",
1135
+ // Present and empty, so that it replaces the provider's own; see above.
1136
+ credentials: undefined,
1108
1137
  progressCallback(progress) {
1109
1138
  if (onProgress && progress.total > 0) {
1110
1139
  onProgress(progress.loaded / progress.total);
1111
1140
  }
1112
1141
  },
1113
1142
  }).then((result) => result.key);
1143
+ try {
1144
+ return await put();
1145
+ }
1146
+ catch (error) {
1147
+ if (!isExpiredTokenError(error)) {
1148
+ throw error;
1149
+ }
1150
+ console.warn("[upload] refused, the token had expired; starting again with new credentials", error);
1151
+ await renewCredentials();
1152
+ return put();
1153
+ }
1114
1154
  };
1115
1155
  const getSignedImageUrlForEquipment = async (equip) => {
1116
1156
  if (!equip.photo) {
@@ -2223,32 +2263,32 @@ class PoiService {
2223
2263
  if (!sweeps || sweeps.length === 0) {
2224
2264
  return [];
2225
2265
  }
2226
- const queryStatement = `
2227
- query ListPOISLight($filter: ModelPOIFilterInput, $limit: Int, $nextToken: String) {
2228
- listPOIS(filter: $filter, limit: $limit, nextToken: $nextToken) {
2229
- items {
2230
- id
2231
- coordinate
2232
- specific
2233
- private
2234
- global
2235
- type
2236
- elementID
2237
- level
2238
- role
2239
- category
2240
- domainID
2241
- metadata
2242
- tagIcon
2243
- tagDescription
2244
- tagImageID
2245
- matterportSweepID
2246
- createdAt
2247
- updatedAt
2248
- }
2249
- nextToken
2250
- }
2251
- }
2266
+ const queryStatement = `
2267
+ query ListPOISLight($filter: ModelPOIFilterInput, $limit: Int, $nextToken: String) {
2268
+ listPOIS(filter: $filter, limit: $limit, nextToken: $nextToken) {
2269
+ items {
2270
+ id
2271
+ coordinate
2272
+ specific
2273
+ private
2274
+ global
2275
+ type
2276
+ elementID
2277
+ level
2278
+ role
2279
+ category
2280
+ domainID
2281
+ metadata
2282
+ tagIcon
2283
+ tagDescription
2284
+ tagImageID
2285
+ matterportSweepID
2286
+ createdAt
2287
+ updatedAt
2288
+ }
2289
+ nextToken
2290
+ }
2291
+ }
2252
2292
  `;
2253
2293
  const CHUNK_SIZE = 50; // safe margin for an "or" filter expression's practical size
2254
2294
  const chunks = [];
@@ -3116,11 +3156,11 @@ class MatterportPointerService {
3116
3156
  }
3117
3157
  updateCursorDisplay(poseMatterport) {
3118
3158
  if (this.textDisplayCursorPositionPanel && poseMatterport) {
3119
- this.textDisplayCursorPositionPanel.innerHTML = `position:
3120
- ${pointToString(poseMatterport.position)}\n
3121
- normal:
3122
- ${pointToString(poseMatterport.normal)}\n
3123
- floorId:
3159
+ this.textDisplayCursorPositionPanel.innerHTML = `position:
3160
+ ${pointToString(poseMatterport.position)}\n
3161
+ normal:
3162
+ ${pointToString(poseMatterport.normal)}\n
3163
+ floorId:
3124
3164
  ${poseMatterport.floorId}`;
3125
3165
  if (this.getCursorPositionButton) {
3126
3166
  this.getCursorPositionButton.style.display = 'none';
@@ -4648,11 +4688,11 @@ class MatterportTagService {
4648
4688
  * floor and room context. Must be called after each sweep, floor or mode change.
4649
4689
  *
4650
4690
  * Rules:
4651
- * - DOLLHOUSE / FLOORPLAN → all tags hidden (opacity 0).
4652
- * - INSIDE → only tags whose sweep belongs to the current floor are shown;
4653
- * proximity-based opacity is applied via computeTagOpacity().
4654
- * Tags on other floors are hidden (opacity 0).
4655
- * - TRANSITIONING → no change (skip update to avoid flickering).
4691
+ * - A tag shows when its sweep is on the floor being displayed, in every mode. Tags on
4692
+ * other floors are hidden (opacity 0) and cannot be opened.
4693
+ * - While all floors are displayed - the usual state of DOLLHOUSE and FLOORPLAN - every
4694
+ * tag shows.
4695
+ * - A tag with no sweep belongs to no floor and always shows.
4656
4696
  *
4657
4697
  * @param sdk Matterport SDK instance.
4658
4698
  * @param poseCamera Current camera pose.
@@ -4664,7 +4704,12 @@ class MatterportTagService {
4664
4704
  if (!sdk || this.dictionnaryTags.size === 0)
4665
4705
  return;
4666
4706
  const isInteriorMode = currentCameraMode === CameraMode.INSIDE;
4667
- const currentFloorId = currentFloorSequence !== null ? currentFloorSequence.toString() : null;
4707
+ // The SDK reports sequence -1 while all floors are displayed, which is the dollhouse's and the
4708
+ // floor plan's normal state. That is "no floor in particular", not a floor that no tag is on:
4709
+ // read as a floor id, it matched nothing and hid every tag that has a sweep.
4710
+ const currentFloorId = currentFloorSequence !== null && currentFloorSequence >= 0
4711
+ ? currentFloorSequence.toString()
4712
+ : null;
4668
4713
  const promises = [];
4669
4714
  for (const [tagId, mattertagData] of this.dictionnaryTags) {
4670
4715
  if (tagId === mattertagToFollow)
@@ -5819,22 +5864,22 @@ class MatterportService {
5819
5864
  );
5820
5865
  }*/
5821
5866
  addCustomCss() {
5822
- const css = `
5823
- .footer-ui,
5824
- .showcase-footer,
5825
- .mp-nova-btn-group,
5826
- .footer-divider,
5827
- .logo-link,
5828
- .footer-logo,
5829
- .bottom-ui,
5830
- img.logo-image,
5831
- img[class^="logo-image"],
5832
- img[class*=" logo-image"],
5833
- [data-testid="showcase-footer"] {
5834
- opacity: 0 !important;
5835
- visibility: hidden !important;
5836
- pointer-events: none !important;
5837
- }
5867
+ const css = `
5868
+ .footer-ui,
5869
+ .showcase-footer,
5870
+ .mp-nova-btn-group,
5871
+ .footer-divider,
5872
+ .logo-link,
5873
+ .footer-logo,
5874
+ .bottom-ui,
5875
+ img.logo-image,
5876
+ img[class^="logo-image"],
5877
+ img[class*=" logo-image"],
5878
+ [data-testid="showcase-footer"] {
5879
+ opacity: 0 !important;
5880
+ visibility: hidden !important;
5881
+ pointer-events: none !important;
5882
+ }
5838
5883
  `;
5839
5884
  // 1st method: Via Scene SDK
5840
5885
  if (this.sdk?.Scene?.addStyle) {
@@ -6824,6 +6869,15 @@ function solveReducedAlignment(pairs, up) {
6824
6869
  scale,
6825
6870
  };
6826
6871
  }
6872
+ /**
6873
+ * The 3D viewers' background where there is nothing to draw, unless the app sets its own as
6874
+ * --smarterplan-viewer-background: the SmarterPlan app's light blue, from the POC.
6875
+ */
6876
+ const VIEWER_BACKGROUND = '#e0f7ff';
6877
+ /** The 3D viewers' background as the app sets it, where the element is. */
6878
+ function viewerBackground(element) {
6879
+ return getComputedStyle(element).getPropertyValue('--smarterplan-viewer-background').trim() || VIEWER_BACKGROUND;
6880
+ }
6827
6881
  class ThreeJsService {
6828
6882
  ngZone;
6829
6883
  scene;
@@ -6842,6 +6896,17 @@ class ThreeJsService {
6842
6896
  objectClicked$ = new Subject();
6843
6897
  /** Emits when the user clicks a tag, mirroring Matterport's tag click. */
6844
6898
  tagClicked$ = new Subject();
6899
+ /**
6900
+ * Emits the tag the camera is sent to (goToTag): for a viewer drawing the model elsewhere, the
6901
+ * splat viewer, to point it out.
6902
+ */
6903
+ tagFocused$ = new Subject();
6904
+ /**
6905
+ * Emits when the tags change - added, moved, removed, filtered, calibrated differently - at most
6906
+ * once per turn of the event loop, however many changed in it.
6907
+ */
6908
+ tagsChanged$ = new Subject();
6909
+ tagsChangeQueued = false;
6845
6910
  /** Emits a tag's label while the pointer is over it, and null when it leaves. */
6846
6911
  tagHovered$ = new Subject();
6847
6912
  // Tags live under the model rather than the scene, so the centering and scaling applied when the
@@ -6851,6 +6916,22 @@ class ThreeJsService {
6851
6916
  canvasResizeObserver;
6852
6917
  /** Aborts the in-flight model download. Parsing cannot be interrupted, only not started. */
6853
6918
  loadAbortController;
6919
+ /**
6920
+ * Which load is the wanted one. Every load takes the next number as it starts, and anything that
6921
+ * makes it unwanted - another load, the scene being rebuilt, the viewer closing - moves the
6922
+ * number on.
6923
+ *
6924
+ * Aborting the download is not enough to stop a load. A model read from the cache has no
6925
+ * download to abort, and one that has finished downloading still has seconds of work ahead of it
6926
+ * before it reaches the screen. A load that finds the number has moved on drops what it has.
6927
+ */
6928
+ loadGeneration = 0;
6929
+ /** Whether the wanted load is still on its way, so abandoning it can say that it stopped. */
6930
+ loadInProgress = false;
6931
+ /** Whether a scene is up: started by init() and not yet taken down by destroy(). */
6932
+ live = false;
6933
+ /** Takes off each listener init() put on the canvas. */
6934
+ canvasListeners = [];
6854
6935
  // The loaded base model, kept so tag placement can raycast against its geometry.
6855
6936
  modelRoot;
6856
6937
  /** True while the next click on the model places a tag instead of orbiting. */
@@ -6872,6 +6953,12 @@ class ThreeJsService {
6872
6953
  * placed in this one are told from those that came from elsewhere.
6873
6954
  */
6874
6955
  currentCaptureID = null;
6956
+ /**
6957
+ * Whether the visit on screen is the only one the site has. A tag records the visit it was placed
6958
+ * in, but some do not - placed before that was recorded, or in the development model. With
6959
+ * another visit around, such a tag is taken to come from it. With none, it can only be this one's.
6960
+ */
6961
+ soleVisit = false;
6875
6962
  /**
6876
6963
  * The model's surface as a grid: every cell some part of the surface passes through.
6877
6964
  *
@@ -6916,6 +7003,17 @@ class ThreeJsService {
6916
7003
  modelUp = 'y';
6917
7004
  /** While measuring: how many points and each segment's length. Null when it is abandoned. */
6918
7005
  measurementProgress$ = new Subject();
7006
+ /**
7007
+ * True when a measurement starts taking clicks, false when it stops - finished or abandoned. For a
7008
+ * viewer drawing the model elsewhere, which takes the clicks itself; see addMeasurePointAtModelPoint.
7009
+ */
7010
+ measuringChanged$ = new Subject();
7011
+ /**
7012
+ * A measurement - under way or finished - was taken off the model. For a viewer drawing it
7013
+ * elsewhere, the splat viewer: a finished one stays drawn while its form is open, which
7014
+ * measurementProgress$ does not say the end of.
7015
+ */
7016
+ measurementCleared$ = new Subject();
6919
7017
  /**
6920
7018
  * Layer carrying the model alone, so the depth pick can render it without the tags.
6921
7019
  *
@@ -6923,8 +7021,18 @@ class ThreeJsService {
6923
7021
  * their parent passed, so marking the model's own meshes is enough - no re-parenting.
6924
7022
  */
6925
7023
  static PICK_LAYER = 1;
7024
+ /** How large a tag is drawn, in CSS pixels. About what Matterport draws its own at. */
7025
+ static TAG_SCREEN_PX = 28;
6926
7026
  /** The app's highlight colour, so a measurement reads as the active thing on screen. */
6927
7027
  static MEASURE_COLOR = '#FFB103';
7028
+ /** Green for the points of a calibration that are settled, as opposed to the one in hand. */
7029
+ static CALIBRATION_DONE_COLOR = '#1E9E5A';
7030
+ /** Size on screen of the numbered pin left where a tag was matched: a little under a tag's. */
7031
+ static CALIBRATION_PIN_PX = 24;
7032
+ /** How long a mark just dropped takes to settle to its size, in milliseconds. */
7033
+ static CONFIRM_PULSE_MS = 350;
7034
+ /** How many matched tags a calibration takes. Three settle everything there is to settle. */
7035
+ static CALIBRATION_PAIRS = 3;
6928
7036
  /** Cache Storage bucket holding downloaded models, and how many to keep. */
6929
7037
  static MODEL_CACHE = 'smarterplan-3d-models';
6930
7038
  static MODEL_CACHE_SIZE = 3;
@@ -6939,12 +7047,36 @@ class ThreeJsService {
6939
7047
  /** Translucent marker shown under the cursor while placing. */
6940
7048
  placementPreview;
6941
7049
  aligning = false;
7050
+ /**
7051
+ * True from the moment the calibration panel opens, before the walkthrough is armed: the tags
7052
+ * from elsewhere are all on show, as they are while calibrating, so that what there is to
7053
+ * calibrate with can be seen while reading what calibrating involves.
7054
+ */
7055
+ revealingCalibrationTags = false;
6942
7056
  alignmentPairs = [];
6943
7057
  alignmentSource = null;
6944
7058
  /** The tag `alignmentSource` was read from, kept so the same tag is not matched twice. */
6945
7059
  alignmentSourceTagId;
7060
+ /**
7061
+ * Everything drawn for the calibration under way: a numbered pin where each tag was matched, a
7062
+ * line from the pin to its tag, and a ring round the tag being matched.
7063
+ *
7064
+ * A sibling of the tag group rather than a child of it. The points clicked are where the tags are
7065
+ * to end up, in the frame the calibration maps onto (see pickAlignmentTarget) - not in the tags'
7066
+ * own frame, which is the one about to change.
7067
+ */
7068
+ calibrationGroup = new THREE.Group();
7069
+ /** Marks still settling after being dropped, advanced by the render loop. */
7070
+ pulses = [];
6946
7071
  /** Drives the alignment walkthrough's UI: how many pairs are done and what it wants next. */
6947
7072
  alignmentProgress$ = new Subject();
7073
+ /**
7074
+ * Emits when a click during calibration did nothing, with what it was expected to land on.
7075
+ *
7076
+ * A click that changes nothing looks the same as a click that was not received. This lets the
7077
+ * panel point at what it is waiting for instead.
7078
+ */
7079
+ alignmentClickIgnored$ = new Subject();
6948
7080
  /** In-flight camera move started by goToTag, advanced by the render loop. */
6949
7081
  cameraTween = null;
6950
7082
  /** Emits true while a heavy OBJ file is being parsed (in a worker, when available), false once done. */
@@ -7056,15 +7188,30 @@ class ThreeJsService {
7056
7188
  // by then, so the model being abandoned would be handed to the scene built below.
7057
7189
  // - The tag group, which is a field and keeps its sprites. They would be re-parented under the
7058
7190
  // new model and show the previous visit's tags on it.
7191
+ // Usually destroy() ran before this and there is nothing left to stop. When it did not, the
7192
+ // scene before would carry on beside the one started below: its render loop drawing every
7193
+ // frame as well, its model still on the graphics card, its listeners answering every click a
7194
+ // second time.
7195
+ if (this.live) {
7196
+ this.destroy();
7197
+ }
7059
7198
  this.discardCurrentLoad();
7060
7199
  this.clearTags();
7061
7200
  this.measuring = false;
7062
7201
  this.clearMeasurement();
7202
+ // A calibration left half-done belongs to the scene it was started in: its points mean nothing
7203
+ // in the next one, and left armed it would take that scene's first clicks for itself.
7204
+ this.aligning = false;
7205
+ this.revealingCalibrationTags = false;
7206
+ this.soleVisit = false;
7207
+ this.alignmentPairs = [];
7208
+ this.clearAlignmentSource();
7209
+ this.clearCalibrationMarks();
7210
+ this.pulses = [];
7063
7211
  this.cameraTween = null;
7064
- this.modelRoot = undefined;
7065
7212
  // 1. Setup Scene
7066
7213
  this.scene = new THREE.Scene();
7067
- this.scene.background = new THREE.Color(0xe0f7ff); // from POC
7214
+ this.scene.background = new THREE.Color(viewerBackground(canvas));
7068
7215
  // 2. Setup Camera
7069
7216
  this.camera = new THREE.PerspectiveCamera(75, canvas.clientWidth / canvas.clientHeight, 0.1, 10000);
7070
7217
  this.camera.position.set(0, 5, 10); // will be overridden later if passed
@@ -7109,7 +7256,14 @@ class ThreeJsService {
7109
7256
  // A light only contributes while it is part of the scene graph, and this one hangs off the camera.
7110
7257
  this.scene.add(this.camera);
7111
7258
  // 5. Setup Controls (from POC)
7112
- this.controls = new OrbitControls(this.camera, this.renderer.domElement);
7259
+ //
7260
+ // Created outside Angular's zone, as is the pointer-move listener further down. The controls
7261
+ // listen to every pointer move and wheel tick, and inside the zone each of those events had
7262
+ // Angular re-check the whole page - dozens of times a second while the camera is dragged,
7263
+ // competing with the frames being drawn - for a change that only ever concerns the canvas.
7264
+ this.ngZone.runOutsideAngular(() => {
7265
+ this.controls = new OrbitControls(this.camera, this.renderer.domElement);
7266
+ });
7113
7267
  this.controls.rotateSpeed = 1.0;
7114
7268
  this.controls.dampingFactor = 0.1;
7115
7269
  this.controls.enableDamping = true;
@@ -7133,9 +7287,18 @@ class ThreeJsService {
7133
7287
  }, 250);
7134
7288
  }
7135
7289
  });
7136
- // Interaction Event Listeners on canvas
7137
- canvas.addEventListener('click', this.onMouseClick.bind(this), false);
7138
- canvas.addEventListener('mousemove', this.onMouseMove.bind(this), false);
7290
+ // Interaction Event Listeners on canvas. Kept so that destroy() can take them off again: the
7291
+ // same canvas is used again when one 3D model is swapped for another, and a second set of
7292
+ // listeners would answer every click twice.
7293
+ const listen = (type, handler) => {
7294
+ canvas.addEventListener(type, handler, false);
7295
+ this.canvasListeners.push(() => canvas.removeEventListener(type, handler, false));
7296
+ };
7297
+ // A click changes what the page shows - a panel opens, a point is recorded - so Angular has to
7298
+ // hear of it, wherever init() itself was called from.
7299
+ listen('click', (event) => this.ngZone.run(() => this.onMouseClick(event)));
7300
+ // Moving the pointer does not: it moves the ghost and the tooltip, both drawn here.
7301
+ this.ngZone.runOutsideAngular(() => listen('mousemove', (event) => this.onMouseMove(event)));
7139
7302
  // Escape backs out of placement and calibration. On the window rather than the canvas, which
7140
7303
  // would need focus the user has no reason to have given it - they are aiming with the mouse.
7141
7304
  // Removed first because this service is a singleton: init() runs again for every canvas, and
@@ -7148,6 +7311,7 @@ class ThreeJsService {
7148
7311
  this.pickCamera = undefined;
7149
7312
  window.addEventListener('keydown', this.onKeyDown);
7150
7313
  // 6. Start the render loop
7314
+ this.live = true;
7151
7315
  this.ngZone.runOutsideAngular(() => {
7152
7316
  this.animate();
7153
7317
  });
@@ -7184,33 +7348,25 @@ class ThreeJsService {
7184
7348
  }
7185
7349
  // Lets gltfpack/gltf-transform output using EXT_meshopt_compression load without extra setup.
7186
7350
  loader.setMeshoptDecoder(MeshoptDecoder);
7187
- this.modelLoading$.next(true);
7351
+ const wanted = this.beginLoad();
7188
7352
  this.modelLoadProgress$.next(0);
7189
7353
  // Order matters: normals must exist before anisotropy, and the Phong conversion has to run
7190
7354
  // before side/flatShading are touched so those settings land on the materials we keep.
7191
7355
  const onLoaded = (gltf) => {
7356
+ if (!wanted()) {
7357
+ // Built for a request that has since been replaced or abandoned. It was never on screen;
7358
+ // what it holds is freed here because nothing else ever will.
7359
+ this.disposeModel(gltf.scene);
7360
+ return;
7361
+ }
7192
7362
  this.convertToPhongMaterials(gltf.scene);
7193
7363
  this.forceDoubleSidedMaterials(gltf.scene);
7194
7364
  this.ensureVertexNormals(gltf.scene);
7195
7365
  this.tuneTextureSampling(gltf.scene);
7196
7366
  this.placeLoadedObjectInScene(gltf.scene, cameraPosition, rotation, up);
7197
- this.modelLoading$.next(false);
7198
- };
7199
- const onError = (error) => {
7200
- // Cancelling is a decision, not a failure: clear the overlay without an error in the console.
7201
- if (error?.name === 'AbortError') {
7202
- // Only a cancel of the load that is still current came from the user; anything else is this
7203
- // load having been superseded or discarded.
7204
- if (this.loadAbortController?.signal.aborted) {
7205
- this.modelLoadCancelled$.next();
7206
- }
7207
- }
7208
- else {
7209
- console.error('Error loading GLTF model:', error);
7210
- }
7211
- this.modelLoading$.next(false);
7212
- this.modelLoadProgress$.next(null);
7367
+ this.endLoad();
7213
7368
  };
7369
+ const onError = (error) => this.loadFailed(wanted, error, 'GLTF model');
7214
7370
  // GLTFLoader decodes embedded images with createImageBitmap when the browser offers it, and on
7215
7371
  // large texture atlases that path returns visibly corrupt data - scattered white blocks across
7216
7372
  // the model. The exact same bytes decoded through an HTMLImageElement, which is what the .obj
@@ -7228,7 +7384,8 @@ class ThreeJsService {
7228
7384
  .then((buffer) => {
7229
7385
  // parse() cannot be interrupted, so a cancel that lands during the paint yield has to be
7230
7386
  // caught here - the last moment before the main thread is committed for several seconds.
7231
- if (this.loadAbortController?.signal.aborted) {
7387
+ // The same goes for a load nobody is waiting for any more.
7388
+ if (!wanted() || this.loadAbortController?.signal.aborted) {
7232
7389
  throw new DOMException('Model load cancelled', 'AbortError');
7233
7390
  }
7234
7391
  const globalScope = globalThis;
@@ -7243,6 +7400,28 @@ class ThreeJsService {
7243
7400
  })
7244
7401
  .catch(onError);
7245
7402
  }
7403
+ /** Ends a load that failed, unless another load has replaced it in the meantime. */
7404
+ loadFailed(wanted, error, what) {
7405
+ if (!wanted()) {
7406
+ // Whatever replaced this load owns the loading indicator now; ending it from here would
7407
+ // switch the indicator off while that one is still on its way.
7408
+ return;
7409
+ }
7410
+ this.loadInProgress = false;
7411
+ // Cancelling is a decision, not a failure: clear the overlay without an error in the console.
7412
+ if (error?.name === 'AbortError') {
7413
+ // Only a cancel of the load that is still current came from the user; anything else is this
7414
+ // load having been superseded or discarded.
7415
+ if (this.loadAbortController?.signal.aborted) {
7416
+ this.modelLoadCancelled$.next();
7417
+ }
7418
+ }
7419
+ else {
7420
+ console.error(`Error loading ${what}:`, error);
7421
+ }
7422
+ this.modelLoading$.next(false);
7423
+ this.modelLoadProgress$.next(null);
7424
+ }
7246
7425
  /**
7247
7426
  * Stops the model download in progress, if any.
7248
7427
  *
@@ -7261,8 +7440,30 @@ class ThreeJsService {
7261
7440
  * scene is not something the person asked for, so it must not surface as "loading cancelled".
7262
7441
  */
7263
7442
  discardCurrentLoad() {
7443
+ this.loadGeneration += 1;
7264
7444
  this.loadAbortController?.abort();
7265
7445
  this.loadAbortController = undefined;
7446
+ if (this.loadInProgress) {
7447
+ this.loadInProgress = false;
7448
+ this.modelLoading$.next(false);
7449
+ this.modelLoadProgress$.next(null);
7450
+ }
7451
+ }
7452
+ /**
7453
+ * Starts a load, replacing whichever was under way, and returns the question it has to keep
7454
+ * asking: is it still the one that is wanted?
7455
+ */
7456
+ beginLoad() {
7457
+ this.loadGeneration += 1;
7458
+ const generation = this.loadGeneration;
7459
+ this.loadInProgress = true;
7460
+ this.modelLoading$.next(true);
7461
+ return () => generation === this.loadGeneration;
7462
+ }
7463
+ /** Marks the wanted load as over, whichever way it ended. */
7464
+ endLoad() {
7465
+ this.loadInProgress = false;
7466
+ this.modelLoading$.next(false);
7266
7467
  }
7267
7468
  /**
7268
7469
  * Resolves once the browser has painted at least one frame.
@@ -7293,6 +7494,11 @@ class ThreeJsService {
7293
7494
  this.loadAbortController = new AbortController();
7294
7495
  const { signal } = this.loadAbortController;
7295
7496
  const cached = await this.readModelFromCache(cacheKey);
7497
+ if (signal.aborted) {
7498
+ // Replaced or abandoned while the cache was being read. With nothing being downloaded, this
7499
+ // is the only place a load served from the cache can find that out.
7500
+ throw new DOMException('Model load cancelled', 'AbortError');
7501
+ }
7296
7502
  if (cached) {
7297
7503
  this.modelLoadProgress$.next(1);
7298
7504
  return cached;
@@ -7633,12 +7839,25 @@ class ThreeJsService {
7633
7839
  const basePath = mtlUrl.substring(0, mtlUrl.lastIndexOf('/') + 1);
7634
7840
  mtlLoader.setPath(basePath);
7635
7841
  const mtlFileName = mtlUrl.substring(mtlUrl.lastIndexOf('/') + 1);
7636
- this.modelLoading$.next(true);
7842
+ // These loaders cannot be interrupted, so a load that is no longer wanted runs to its end and
7843
+ // is dropped there - see loadGeneration.
7844
+ const wanted = this.beginLoad();
7845
+ const finish = () => {
7846
+ if (wanted()) {
7847
+ this.endLoad();
7848
+ }
7849
+ };
7637
7850
  mtlLoader.load(mtlFileName, (materials) => {
7851
+ if (!wanted()) {
7852
+ return;
7853
+ }
7638
7854
  materials.preload();
7639
7855
  if (this.objParserWorker) {
7640
7856
  this.requestObjParseInWorker(objUrl)
7641
7857
  .then((meshes) => {
7858
+ if (!wanted()) {
7859
+ return;
7860
+ }
7642
7861
  const group = new THREE.Group();
7643
7862
  meshes.forEach((meshData) => {
7644
7863
  group.add(this.meshFromParsedData(meshData, materials));
@@ -7648,25 +7867,42 @@ class ThreeJsService {
7648
7867
  .catch((error) => {
7649
7868
  console.error('Error parsing OBJ model in worker:', error);
7650
7869
  })
7651
- .finally(() => this.modelLoading$.next(false));
7870
+ .finally(finish);
7652
7871
  return;
7653
7872
  }
7654
7873
  const objLoader = new OBJLoader();
7655
7874
  objLoader.setMaterials(materials);
7656
7875
  objLoader.load(objUrl, (object) => {
7876
+ if (!wanted()) {
7877
+ this.disposeModel(object);
7878
+ return;
7879
+ }
7657
7880
  this.placeLoadedObjectInScene(object, cameraPosition, rotation);
7658
- this.modelLoading$.next(false);
7881
+ finish();
7659
7882
  }, undefined, (error) => {
7660
7883
  console.error('Error loading OBJ model:', error);
7661
- this.modelLoading$.next(false);
7884
+ finish();
7662
7885
  });
7663
7886
  }, undefined, (error) => {
7664
7887
  console.error('Error loading MTL file:', error);
7665
- this.modelLoading$.next(false);
7888
+ finish();
7666
7889
  });
7667
7890
  }
7668
7891
  /** Centers, scales and adds a loaded object to the scene, then frames the camera on it. */
7669
7892
  placeLoadedObjectInScene(object, cameraPosition, rotation, up) {
7893
+ // A scene shows one model. Whatever was there is taken out and freed first, or the two would be
7894
+ // drawn through each other, every frame, for as long as the viewer stays open.
7895
+ if (this.modelRoot) {
7896
+ // Points being clicked for a calibration or a measurement were clicked on the model that is
7897
+ // about to go; they say nothing about the one arriving.
7898
+ if (this.aligning) {
7899
+ this.cancelTagAlignment();
7900
+ }
7901
+ if (this.measuring) {
7902
+ this.cancelMeasuring();
7903
+ }
7904
+ }
7905
+ this.removeModel();
7670
7906
  const box = new THREE.Box3().setFromObject(object);
7671
7907
  const center = box.getCenter(new THREE.Vector3());
7672
7908
  const size = box.getSize(new THREE.Vector3());
@@ -7703,8 +7939,9 @@ class ThreeJsService {
7703
7939
  group.add(centered);
7704
7940
  group.scale.setScalar(scale);
7705
7941
  this.scene.add(group);
7706
- // Tags registered before the model finished loading were sized against the old scale.
7707
- this.tagSprites.forEach((sprite) => this.scaleTagForModel(sprite));
7942
+ this.scene.updateMatrixWorld(true);
7943
+ // Markers registered before the model finished loading were sized against the old scale.
7944
+ this.refreshScreenSizes();
7708
7945
  this.adjustClippingPlanes((size.length() / 2) * scale);
7709
7946
  if (cameraPosition) {
7710
7947
  this.camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z);
@@ -7720,16 +7957,25 @@ class ThreeJsService {
7720
7957
  * not clear enough to act on.
7721
7958
  *
7722
7959
  * Models arrive in two conventions: Y up (glTF, and what this viewer assumes) and Z up (most CAD
7723
- * and scanning exports). A Z-up model loads lying on its side. Two independent cues say which
7724
- * axis is the vertical one, and both have to agree before anything is turned:
7960
+ * and scanning exports). A Z-up model loads lying on its side. Three independent cues say which
7961
+ * axis is the vertical one:
7725
7962
  *
7726
7963
  * - Surface area. Floors and ceilings all face along the vertical, while walls are shared out
7727
7964
  * between the two horizontal axes, so the vertical usually collects the most area.
7728
7965
  * - Extent. A building is normally wider than it is tall, so the vertical is its shortest side.
7966
+ * - Openness. A capture made from outside has ground and roofs but nothing underneath them, so
7967
+ * along the vertical nearly all of its surface faces one way; walls face both ways evenly.
7968
+ *
7969
+ * Each is wrong often enough on its own - a long corridor has more wall than floor, a tower is
7970
+ * taller than it is wide - so a cue only counts when its answer is clear-cut, and the model is
7971
+ * turned when the clear answers agree. It used to take area and extent agreeing with each other,
7972
+ * which failed on exactly the models that need this most: in a whole building or a city block
7973
+ * there is as much wall as floor, the area cue comes out a three-way tie, and a model 130 m wide
7974
+ * and 34 m tall with 86% of its surface facing one way was left lying on its side.
7729
7975
  *
7730
- * Either alone is wrong often enough to matter - a long corridor has more wall than floor, a
7731
- * tower is taller than it is wide - and turning a model that was fine is worse than leaving one
7732
- * that was not. A model that already has Y as its vertical is never touched.
7976
+ * Turning a model that was fine is worse than leaving one that was not: when the clear answers
7977
+ * contradict each other, or there is none, nothing is turned. A model that already has Y as its
7978
+ * vertical is never touched.
7733
7979
  */
7734
7980
  detectModelUp(object, size) {
7735
7981
  const axes = ['x', 'y', 'z'];
@@ -7744,6 +7990,7 @@ class ThreeJsService {
7744
7990
  }
7745
7991
  });
7746
7992
  if (triangleCount === 0) {
7993
+ // Nothing with faces to weigh: the convention.
7747
7994
  return 'y';
7748
7995
  }
7749
7996
  // A sample is plenty to see where the area lies, and keeps this cheap on models with millions
@@ -7774,16 +8021,45 @@ class ThreeJsService {
7774
8021
  }
7775
8022
  });
7776
8023
  const total = { x: area.x[0] + area.x[1], y: area.y[0] + area.y[1], z: area.z[0] + area.z[1] };
7777
- const byArea = axes.reduce((best, axis) => (total[axis] > total[best] ? axis : best), 'y');
7778
- const byExtent = axes.reduce((best, axis) => (size[axis] < size[best] ? axis : best), 'y');
7779
- if (byArea === 'y' || byArea !== byExtent || total[byArea] < 1.25 * total.y) {
8024
+ const extent = { x: size.x, y: size.y, z: size.z };
8025
+ // How one-sided the surface is along each axis: 0 when it faces both ways evenly, 1 when it
8026
+ // all faces the same way.
8027
+ const lopsided = { x: 0, y: 0, z: 0 };
8028
+ axes.forEach((axis) => {
8029
+ lopsided[axis] = total[axis] > 0 ? Math.abs(area[axis][0] - area[axis][1]) / total[axis] : 0;
8030
+ });
8031
+ /** The axes ordered by a measure, largest first. */
8032
+ const ranked = (measure) => [...axes].sort((first, second) => measure[second] - measure[first]);
8033
+ const [mostArea, nextArea] = ranked(total);
8034
+ const [, middleExtent, shortest] = ranked(extent);
8035
+ const [mostOpen, nextOpen] = ranked(lopsided);
8036
+ // What each cue says, when it says it clearly; null when it has no clear answer.
8037
+ const areaSays = total[mostArea] >= 1.25 * total[nextArea] ? mostArea : null;
8038
+ const extentSays = extent[middleExtent] >= 1.5 * extent[shortest] ? shortest : null;
8039
+ const opennessSays = lopsided[mostOpen] >= 0.5
8040
+ && lopsided[mostOpen] >= 2 * lopsided[nextOpen]
8041
+ // Not on the strength of a few stray triangles: the surface along that axis has to matter.
8042
+ && total[mostOpen] >= 0.25 * total[mostArea]
8043
+ ? mostOpen
8044
+ : null;
8045
+ const clear = [areaSays, extentSays, opennessSays].filter((axis) => axis !== null);
8046
+ let vertical = 'y';
8047
+ if (clear.length > 0) {
8048
+ // Clear answers that contradict each other are no answer.
8049
+ vertical = clear.every((axis) => axis === clear[0]) ? clear[0] : 'y';
8050
+ }
8051
+ else if (mostArea === shortest && total[mostArea] >= 1.25 * total.y) {
8052
+ // Nothing clear-cut, but area and extent lean the same way and away from Y.
8053
+ vertical = mostArea;
8054
+ }
8055
+ if (vertical === 'y') {
7780
8056
  return 'y';
7781
8057
  }
7782
8058
  // Which way along that axis is up. Roofs and ground make an outdoor model face mostly upward,
7783
8059
  // which settles it; indoors floors and ceilings balance out, and the convention - positive is
7784
8060
  // up - is the better guess.
7785
- const [positive, negative] = area[byArea];
7786
- const up = `${negative > 0.65 * (positive + negative) ? '-' : ''}${byArea}`;
8061
+ const [positive, negative] = area[vertical];
8062
+ const up = `${negative > 0.65 * (positive + negative) ? '-' : ''}${vertical}`;
7787
8063
  console.info(`ThreeJsService: this model's vertical axis looks like ${up}, not y - standing it upright.`);
7788
8064
  return up;
7789
8065
  }
@@ -7833,6 +8109,7 @@ class ThreeJsService {
7833
8109
  this.camera.updateProjectionMatrix();
7834
8110
  this.renderer.setSize(width, height);
7835
8111
  this.updateMeasureLineResolution();
8112
+ this.refreshScreenSizes();
7836
8113
  });
7837
8114
  this.canvasResizeObserver.observe(container);
7838
8115
  }
@@ -7843,6 +8120,34 @@ class ThreeJsService {
7843
8120
  * only has to supply the matching spot on the model. No new records are written; nothing is
7844
8121
  * applied until solveAlignmentFromPairs() succeeds.
7845
8122
  */
8123
+ /**
8124
+ * Shows every tag that came from elsewhere, whether or not it lands on the model, without arming
8125
+ * anything: clicks still do what they always do.
8126
+ *
8127
+ * Calibrating shows them too, and ending a calibration - saved or abandoned - puts them back
8128
+ * under the usual rule, so this only ever has to be switched on.
8129
+ */
8130
+ revealCalibrationTags(revealed = true) {
8131
+ this.revealingCalibrationTags = revealed;
8132
+ this.refreshTagVisibility();
8133
+ // Another viewer showing the tags shows these as well now; see showsCalibrationTags.
8134
+ this.announceTagsChanged();
8135
+ }
8136
+ /**
8137
+ * Whether the tags a calibration works with are on show - while one is under way, or its panel
8138
+ * open - including those that do not land on the model. For another viewer showing the tags,
8139
+ * which otherwise leaves out the ones from elsewhere until the model is calibrated.
8140
+ */
8141
+ showsCalibrationTags() {
8142
+ return this.aligning || this.revealingCalibrationTags;
8143
+ }
8144
+ /** Says a tag was clicked in a viewer drawing the model elsewhere, as a click on it here would. */
8145
+ clickTag(elementId) {
8146
+ const sprite = this.tagSprites.get(elementId);
8147
+ if (sprite) {
8148
+ this.tagClicked$.next({ elementId, poiType: sprite.userData.poiType ?? null });
8149
+ }
8150
+ }
7846
8151
  startTagAlignment() {
7847
8152
  // One pointer mode at a time, the same rule startMeasuring() applies the other way round.
7848
8153
  if (this.measuring) {
@@ -7850,27 +8155,28 @@ class ThreeJsService {
7850
8155
  }
7851
8156
  this.aligning = true;
7852
8157
  this.alignmentPairs = [];
7853
- this.alignmentSource = null;
8158
+ this.clearAlignmentSource();
7854
8159
  // While calibrating, the tags from elsewhere are all shown - including the ones off the model,
7855
8160
  // which are exactly the ones a calibration is for.
7856
8161
  this.refreshTagVisibility();
7857
8162
  if (this.renderer) {
7858
8163
  this.renderer.domElement.style.cursor = 'crosshair';
7859
8164
  }
7860
- this.alignmentProgress$.next({ pairs: 0, awaiting: 'tag' });
8165
+ this.alignmentChanged();
8166
+ this.announceTagsChanged();
7861
8167
  }
7862
8168
  cancelTagAlignment() {
7863
8169
  const wasAligning = this.aligning;
7864
8170
  this.aligning = false;
8171
+ this.revealingCalibrationTags = false;
7865
8172
  this.alignmentPairs = [];
7866
- this.alignmentSource = null;
7867
- this.pendingPreviewEvent = null;
7868
- this.removePlacementPreview();
8173
+ this.clearAlignmentSource();
7869
8174
  this.refreshTagVisibility();
7870
8175
  if (this.renderer) {
7871
8176
  this.renderer.domElement.style.cursor = 'default';
7872
8177
  }
7873
- this.alignmentProgress$.next({ pairs: 0, awaiting: null });
8178
+ this.alignmentChanged();
8179
+ this.announceTagsChanged();
7874
8180
  if (wasAligning) {
7875
8181
  this.alignmentCancelled$.next();
7876
8182
  }
@@ -7918,11 +8224,10 @@ class ThreeJsService {
7918
8224
  // Out of calibrating mode first: applying the result re-decides which tags show, and from here
7919
8225
  // on that is the ones on the model again, not all of them.
7920
8226
  this.aligning = false;
8227
+ this.revealingCalibrationTags = false;
7921
8228
  this.applyAlignmentKeepingOwnTags(alignment);
7922
- this.pendingPreviewEvent = null;
7923
- this.removePlacementPreview();
7924
8229
  this.renderer.domElement.style.cursor = 'default';
7925
- this.alignmentProgress$.next({ pairs: this.alignmentPairs.length, awaiting: null });
8230
+ this.alignmentChanged();
7926
8231
  return alignment;
7927
8232
  }
7928
8233
  /**
@@ -7981,7 +8286,8 @@ class ThreeJsService {
7981
8286
  */
7982
8287
  selectAlignmentTag(elementId) {
7983
8288
  const sprite = this.tagSprites.get(elementId);
7984
- if (!this.aligning || this.alignmentSource || !sprite || !this.calibrationCandidates().includes(sprite)) {
8289
+ if (!this.aligning || this.alignmentSource || !this.canMatchAnotherTag()
8290
+ || !sprite || !this.calibrationCandidates().includes(sprite)) {
7985
8291
  return false;
7986
8292
  }
7987
8293
  this.setAlignmentSource(sprite);
@@ -7993,18 +8299,28 @@ class ThreeJsService {
7993
8299
  const stored = sprite.position;
7994
8300
  this.alignmentSource = { x: stored.x, y: stored.y, z: stored.z };
7995
8301
  this.alignmentSourceTagId = sprite.name;
7996
- this.alignmentProgress$.next({
7997
- pairs: this.alignmentPairs.length,
7998
- awaiting: 'point',
7999
- tagId: sprite.name,
8000
- });
8302
+ this.alignmentChanged('tag');
8303
+ }
8304
+ clearAlignmentSource() {
8305
+ this.alignmentSource = null;
8306
+ this.alignmentSourceTagId = undefined;
8307
+ }
8308
+ /** Whether the calibration still has room for a pair and a tag to make it from. */
8309
+ canMatchAnotherTag() {
8310
+ return this.alignmentPairs.length < ThreeJsService.CALIBRATION_PAIRS
8311
+ && this.calibrationCandidates().length > 0;
8001
8312
  }
8002
8313
  /** Handles a click while the alignment walkthrough is running. */
8003
8314
  handleAlignmentClick(event) {
8004
8315
  if (!this.alignmentSource) {
8005
8316
  // First half of a pair: which tag are we talking about?
8317
+ if (!this.canMatchAnotherTag()) {
8318
+ // Nothing left to match: what remains is to save, or to take a pair back.
8319
+ return;
8320
+ }
8006
8321
  const hit = this.raycaster.intersectObjects(this.calibrationCandidates(), false)[0];
8007
8322
  if (!hit) {
8323
+ this.alignmentClickIgnored$.next('tag');
8008
8324
  return;
8009
8325
  }
8010
8326
  this.setAlignmentSource(hit.object);
@@ -8013,18 +8329,220 @@ class ThreeJsService {
8013
8329
  // Second half: where that tag actually belongs on the model.
8014
8330
  const target = this.pickAlignmentTarget(event);
8015
8331
  if (!target) {
8332
+ this.alignmentClickIgnored$.next('point');
8016
8333
  return;
8017
8334
  }
8018
8335
  this.alignmentPairs.push({ tagId: this.alignmentSourceTagId, source: this.alignmentSource, target });
8019
- this.alignmentSource = null;
8020
- // The ghost belongs to the click that just happened; the next half is a click on a tag.
8336
+ this.clearAlignmentSource();
8337
+ this.alignmentChanged('point');
8338
+ }
8339
+ /**
8340
+ * Records where the tag being matched really is, from a spot another viewer picked on the model -
8341
+ * the splat viewer - in the model's own coordinates: the frame pickAlignmentTarget measures a
8342
+ * click in, as placeTagAtModelPoint takes it. False when no tag is waiting for its spot.
8343
+ */
8344
+ matchAlignmentTagAtModelPoint(point) {
8345
+ if (!this.aligning || !this.alignmentSource) {
8346
+ return false;
8347
+ }
8348
+ this.alignmentPairs.push({
8349
+ tagId: this.alignmentSourceTagId,
8350
+ source: this.alignmentSource,
8351
+ target: { x: point.x, y: point.y, z: point.z },
8352
+ });
8353
+ this.clearAlignmentSource();
8354
+ this.alignmentChanged('point');
8355
+ return true;
8356
+ }
8357
+ /**
8358
+ * Takes back the last thing done in the calibration under way: the choice of tag while its place
8359
+ * on the model is being asked for, the last matched pair otherwise.
8360
+ *
8361
+ * One step at a time, so a click made by mistake costs that click and not the whole calibration.
8362
+ * Returns false when there is nothing to take back.
8363
+ */
8364
+ undoAlignmentStep() {
8365
+ if (!this.aligning) {
8366
+ return false;
8367
+ }
8368
+ if (this.alignmentSource) {
8369
+ this.clearAlignmentSource();
8370
+ }
8371
+ else if (this.alignmentPairs.length > 0) {
8372
+ this.alignmentPairs.pop();
8373
+ }
8374
+ else {
8375
+ return false;
8376
+ }
8377
+ this.alignmentChanged();
8378
+ return true;
8379
+ }
8380
+ /**
8381
+ * Removes one matched pair, whichever it is, putting its tag back on offer.
8382
+ *
8383
+ * For the point noticed to be wrong only after others were matched: taking steps back one by one
8384
+ * to reach it would throw away the good ones made since.
8385
+ */
8386
+ removeAlignmentPair(index) {
8387
+ if (!this.aligning || !Number.isInteger(index) || index < 0 || index >= this.alignmentPairs.length) {
8388
+ return false;
8389
+ }
8390
+ this.alignmentPairs.splice(index, 1);
8391
+ this.alignmentChanged();
8392
+ return true;
8393
+ }
8394
+ /** The tags matched so far in the calibration under way, in the order they are numbered in. */
8395
+ getAlignmentPairs() {
8396
+ return this.alignmentPairs.map((pair) => {
8397
+ const sprite = this.tagSprites.get(pair.tagId);
8398
+ return {
8399
+ tagId: pair.tagId,
8400
+ label: sprite?.userData.label ?? null,
8401
+ poiType: sprite?.userData.poiType ?? null,
8402
+ target: { ...pair.target },
8403
+ };
8404
+ });
8405
+ }
8406
+ /**
8407
+ * Brings the scene and the panel in line with the calibration's state, after anything changed it.
8408
+ *
8409
+ * `dropped` names the mark the change put down, if it put one down, so that it can be shown
8410
+ * arriving: that movement is what tells someone their click was taken.
8411
+ */
8412
+ alignmentChanged(dropped) {
8413
+ // The ghost previews the pin about to be dropped, number included, so it is never carried over
8414
+ // from one state to the next; the next pointer move draws the right one.
8021
8415
  this.pendingPreviewEvent = null;
8022
8416
  this.removePlacementPreview();
8417
+ if (this.aligning && this.renderer) {
8418
+ this.renderer.domElement.style.cursor = 'crosshair';
8419
+ }
8420
+ this.redrawCalibrationMarks(dropped);
8421
+ let awaiting = null;
8422
+ if (this.aligning) {
8423
+ if (this.alignmentSource) {
8424
+ awaiting = 'point';
8425
+ }
8426
+ else if (this.canMatchAnotherTag()) {
8427
+ // Otherwise done at three, or sooner when there are no more tags to match: fewer pairs
8428
+ // still calibrate, only less completely.
8429
+ awaiting = 'tag';
8430
+ }
8431
+ }
8023
8432
  this.alignmentProgress$.next({
8024
8433
  pairs: this.alignmentPairs.length,
8025
- // Done at three, or sooner when there are no more tags to match: fewer pairs still calibrate,
8026
- // only less completely.
8027
- awaiting: this.alignmentPairs.length >= 3 || this.calibrationCandidates().length === 0 ? null : 'tag',
8434
+ awaiting,
8435
+ tagId: awaiting === 'point' ? this.alignmentSourceTagId : undefined,
8436
+ matched: this.getAlignmentPairs(),
8437
+ });
8438
+ }
8439
+ /**
8440
+ * Draws the calibration as it stands: what has been matched, and what is being matched.
8441
+ *
8442
+ * Redrawn whole on every change rather than patched. There are at most three pairs, and undoing
8443
+ * or removing one renumbers the others.
8444
+ */
8445
+ redrawCalibrationMarks(dropped) {
8446
+ this.clearCalibrationMarks();
8447
+ if (!this.aligning) {
8448
+ return;
8449
+ }
8450
+ const frame = this.tagGroup.parent ?? this.scene;
8451
+ if (this.calibrationGroup.parent !== frame) {
8452
+ frame.add(this.calibrationGroup);
8453
+ }
8454
+ this.tagGroup.updateMatrix();
8455
+ // Where a tag is drawn at the moment, in the frame the marks are drawn in.
8456
+ const drawnAt = (stored) => new THREE.Vector3(stored.x, stored.y, stored.z).applyMatrix4(this.tagGroup.matrix);
8457
+ this.alignmentPairs.forEach((pair, index) => {
8458
+ const target = new THREE.Vector3(pair.target.x, pair.target.y, pair.target.z);
8459
+ // From where the tag is to where it was said to belong: the correction, made visible.
8460
+ const link = this.createMeasureLine(drawnAt(pair.source), target, ThreeJsService.CALIBRATION_DONE_COLOR, 2);
8461
+ link.material.opacity = 0.75;
8462
+ // The same green as the pin it leads to; see createCalibrationMark.
8463
+ link.material.color.convertSRGBToLinear();
8464
+ link.material.toneMapped = false;
8465
+ // Behind the tags: it sets off from the middle of one, and should come out from under its
8466
+ // icon rather than run across it.
8467
+ link.renderOrder = 997;
8468
+ this.calibrationGroup.add(link);
8469
+ const pin = this.createCalibrationMark(this.createNumberedTexture(index + 1, ThreeJsService.CALIBRATION_DONE_COLOR), 1001);
8470
+ pin.position.copy(target);
8471
+ this.calibrationGroup.add(pin);
8472
+ this.setScreenSize(pin, ThreeJsService.CALIBRATION_PIN_PX);
8473
+ if (dropped === 'point' && index === this.alignmentPairs.length - 1) {
8474
+ this.pulse(pin);
8475
+ }
8476
+ // Its tag is spoken for; it stays in view to show what the pin refers to, but stands back.
8477
+ const matched = this.tagSprites.get(pair.tagId);
8478
+ if (matched) {
8479
+ matched.material.opacity = 0.4;
8480
+ }
8481
+ });
8482
+ if (this.alignmentSource) {
8483
+ const ring = this.createCalibrationMark(this.createRingTexture(ThreeJsService.MEASURE_COLOR), 998);
8484
+ ring.position.copy(drawnAt(this.alignmentSource));
8485
+ this.calibrationGroup.add(ring);
8486
+ this.setScreenSize(ring, ThreeJsService.TAG_SCREEN_PX * 1.6);
8487
+ if (dropped === 'tag') {
8488
+ this.pulse(ring);
8489
+ }
8490
+ }
8491
+ }
8492
+ /** Removes everything drawn for a calibration, and gives the tags it dimmed their look back. */
8493
+ clearCalibrationMarks() {
8494
+ [...this.calibrationGroup.children].forEach((child) => {
8495
+ this.calibrationGroup.remove(child);
8496
+ child.geometry?.dispose?.();
8497
+ child.material?.map?.dispose?.();
8498
+ child.material?.dispose?.();
8499
+ });
8500
+ this.tagSprites.forEach((sprite) => {
8501
+ sprite.material.opacity = 1;
8502
+ });
8503
+ }
8504
+ /**
8505
+ * A calibration mark: a fixed size on screen and showing through the model, as the tags do, and
8506
+ * drawn in the colours it was painted in.
8507
+ *
8508
+ * The renderer's tone mapping and output conversion are there for the model's lighting. Put
8509
+ * through them a mark comes out pale - the amber nearer to cream - and a mark that has to be
8510
+ * picked out against a photograph of a building is the last thing that should be.
8511
+ */
8512
+ createCalibrationMark(map, renderOrder) {
8513
+ // Says the canvas holds sRGB, which it does, so the output conversion gives it back unchanged.
8514
+ if ('colorSpace' in map) {
8515
+ map.colorSpace = 'srgb'; // THREE.SRGBColorSpace, r152+
8516
+ }
8517
+ else {
8518
+ map.encoding = 3001; // THREE.sRGBEncoding
8519
+ }
8520
+ const mark = new THREE.Sprite(new THREE.SpriteMaterial({
8521
+ map, depthTest: false, transparent: true, sizeAttenuation: false, toneMapped: false,
8522
+ }));
8523
+ mark.renderOrder = renderOrder;
8524
+ return mark;
8525
+ }
8526
+ /** Shows a mark arriving: it starts oversized and settles. */
8527
+ pulse(sprite) {
8528
+ this.pulses.push({ sprite, startedAt: performance.now() });
8529
+ }
8530
+ stepPulses() {
8531
+ if (this.pulses.length === 0) {
8532
+ return;
8533
+ }
8534
+ const now = performance.now();
8535
+ this.pulses = this.pulses.filter(({ sprite, startedAt }) => {
8536
+ const size = sprite.userData.screenSize;
8537
+ if (!sprite.parent || !size) {
8538
+ // Redrawn or cleared in the meantime.
8539
+ return false;
8540
+ }
8541
+ const progress = Math.min(1, (now - startedAt) / ThreeJsService.CONFIRM_PULSE_MS);
8542
+ this.setScreenSize(sprite, size[0], size[1]);
8543
+ // Eased out, so most of the settling happens while the eye is still on the click.
8544
+ sprite.scale.multiplyScalar(1 + 0.9 * (1 - progress) ** 2);
8545
+ return progress < 1;
8028
8546
  });
8029
8547
  }
8030
8548
  /**
@@ -8049,6 +8567,7 @@ class ThreeJsService {
8049
8567
  if (this.renderer) {
8050
8568
  this.renderer.domElement.style.cursor = 'crosshair';
8051
8569
  }
8570
+ this.measuringChanged$.next(true);
8052
8571
  this.measurementProgress$.next({ points: 0, distances: [] });
8053
8572
  }
8054
8573
  /**
@@ -8069,8 +8588,10 @@ class ThreeJsService {
8069
8588
  this.renderer.domElement.style.cursor = 'default';
8070
8589
  }
8071
8590
  if (wasMeasuring) {
8591
+ this.measuringChanged$.next(false);
8072
8592
  this.measurementProgress$.next(null);
8073
8593
  }
8594
+ this.measurementCleared$.next();
8074
8595
  }
8075
8596
  /**
8076
8597
  * Ends the measurement and returns it, or null when there are not yet two points to measure
@@ -8079,11 +8600,12 @@ class ThreeJsService {
8079
8600
  * The drawing stays on the model so it is still visible while the person names the measurement;
8080
8601
  * cancelMeasuring() takes it away afterwards.
8081
8602
  */
8082
- finishMeasuring() {
8603
+ finishMeasuring(screenshot) {
8083
8604
  if (!this.measuring || this.measurePoints.length < 2) {
8084
8605
  return null;
8085
8606
  }
8086
8607
  this.measuring = false;
8608
+ this.measuringChanged$.next(false);
8087
8609
  this.pendingPreviewEvent = null;
8088
8610
  this.removePlacementPreview();
8089
8611
  this.updateMeasureRubberBand(null);
@@ -8091,10 +8613,32 @@ class ThreeJsService {
8091
8613
  this.lastMeasurement = {
8092
8614
  points: this.measurePoints.map((point) => ({ x: point.x, y: point.y, z: point.z })),
8093
8615
  distances: this.measureDistances(),
8094
- screenshot: this.captureMeasurementScreenshot(),
8616
+ // From the viewer the model is drawn in, when it is not this scene: the splat viewer.
8617
+ screenshot: screenshot ?? this.captureMeasurementScreenshot(),
8095
8618
  };
8096
8619
  return this.lastMeasurement;
8097
8620
  }
8621
+ /**
8622
+ * Adds a point to the measurement under way from a spot another viewer picked on the model - the
8623
+ * splat viewer - in the model's own coordinates. Into the frame the measurement is kept in, the
8624
+ * tags', through the calibration, as placeTagAtModelPoint does. False when not measuring.
8625
+ */
8626
+ addMeasurePointAtModelPoint(point) {
8627
+ if (!this.measuring) {
8628
+ return false;
8629
+ }
8630
+ this.tagGroup.updateMatrix();
8631
+ this.addMeasurePoint(new THREE.Vector3(point.x, point.y, point.z).applyMatrix4(this.tagGroup.matrix.clone().invert()));
8632
+ return true;
8633
+ }
8634
+ /** The measurement's points, under way or finished, in the model's own coordinates: for a viewer drawing it elsewhere. */
8635
+ getMeasurePointsInModelFrame() {
8636
+ this.tagGroup.updateMatrix();
8637
+ return this.measurePoints.map((point) => {
8638
+ const { x, y, z } = point.clone().applyMatrix4(this.tagGroup.matrix);
8639
+ return { x, y, z };
8640
+ });
8641
+ }
8098
8642
  isMeasuring() {
8099
8643
  return this.measuring;
8100
8644
  }
@@ -8123,10 +8667,10 @@ class ThreeJsService {
8123
8667
  // anything sized in metres would cover the very thing being measured.
8124
8668
  sizeAttenuation: false,
8125
8669
  }));
8126
- marker.scale.set(0.022, 0.022, 1);
8127
8670
  marker.position.copy(point);
8128
8671
  marker.renderOrder = 1001;
8129
8672
  this.measureGroup.add(marker);
8673
+ this.setScreenSize(marker, 12);
8130
8674
  if (previous) {
8131
8675
  this.measureGroup.add(this.createMeasureLine(previous, point));
8132
8676
  const distance = Math.round(previous.distanceTo(point) * 100) / 100;
@@ -8136,10 +8680,10 @@ class ThreeJsService {
8136
8680
  transparent: true,
8137
8681
  sizeAttenuation: false,
8138
8682
  }));
8139
- label.scale.set(0.15, 0.05, 1);
8140
8683
  label.position.copy(previous).add(point).multiplyScalar(0.5);
8141
8684
  label.renderOrder = 1002;
8142
8685
  this.measureGroup.add(label);
8686
+ this.setScreenSize(label, 84, 28);
8143
8687
  }
8144
8688
  this.measurementProgress$.next({
8145
8689
  points: this.measurePoints.length,
@@ -8152,12 +8696,12 @@ class ThreeJsService {
8152
8696
  * WebGL's own lines are one pixel wide whatever is asked for, which all but disappears over a
8153
8697
  * photographic texture. This kind is drawn as screen-facing geometry, so the width holds.
8154
8698
  */
8155
- createMeasureLine(from, to) {
8699
+ createMeasureLine(from, to, color = ThreeJsService.MEASURE_COLOR, width = 3) {
8156
8700
  const geometry = new LineGeometry();
8157
8701
  geometry.setPositions([from.x, from.y, from.z, to.x, to.y, to.z]);
8158
8702
  const material = new LineMaterial({
8159
- color: new THREE.Color(ThreeJsService.MEASURE_COLOR).getHex(),
8160
- linewidth: 3,
8703
+ color: new THREE.Color(color).getHex(),
8704
+ linewidth: width,
8161
8705
  depthTest: false,
8162
8706
  transparent: true,
8163
8707
  });
@@ -8173,7 +8717,7 @@ class ThreeJsService {
8173
8717
  return;
8174
8718
  }
8175
8719
  const size = this.renderer.getSize(new THREE.Vector2());
8176
- this.measureGroup.children.forEach((child) => {
8720
+ [...this.measureGroup.children, ...this.calibrationGroup.children].forEach((child) => {
8177
8721
  if (child.material?.resolution) {
8178
8722
  child.material.resolution.set(size.x, size.y);
8179
8723
  }
@@ -8259,6 +8803,12 @@ class ThreeJsService {
8259
8803
  });
8260
8804
  return canvas.toDataURL('image/jpeg', 0.85);
8261
8805
  }
8806
+ /**
8807
+ * True when tag placement is armed, false when it ends - placed or abandoned. For a viewer drawing
8808
+ * the model instead of this scene, the splat viewer, which picks the spot itself; see
8809
+ * placeTagAtModelPoint.
8810
+ */
8811
+ placementChanged$ = new Subject();
8262
8812
  /** Arms tag placement: the next click on the model drops a tag instead of being ignored. */
8263
8813
  startTagPlacement(poiType = null) {
8264
8814
  this.placingTag = true;
@@ -8267,9 +8817,40 @@ class ThreeJsService {
8267
8817
  if (this.renderer) {
8268
8818
  this.renderer.domElement.style.cursor = 'crosshair';
8269
8819
  }
8820
+ this.placementChanged$.next(true);
8821
+ }
8822
+ /**
8823
+ * Places the armed tag at a spot another viewer picked on the model - the splat viewer - given
8824
+ * in the model's own coordinates, as a click on the model in this scene would. Into the frame tag
8825
+ * coordinates are stored in, through the calibration, as pickPlacementOnModel does.
8826
+ */
8827
+ placeTagAtModelPoint(point, normal = null) {
8828
+ if (!this.placingTag) {
8829
+ return null;
8830
+ }
8831
+ this.tagGroup.updateMatrix();
8832
+ const position = new THREE.Vector3(point.x, point.y, point.z).applyMatrix4(this.tagGroup.matrix.clone().invert());
8833
+ // Turned only: the calibration's scale changes no direction.
8834
+ const facing = normal
8835
+ ? new THREE.Vector3(normal.x, normal.y, normal.z).applyQuaternion(this.tagGroup.quaternion.clone().invert()).normalize()
8836
+ : new THREE.Vector3(0, 1, 0);
8837
+ const placement = {
8838
+ position: { x: position.x, y: position.y, z: position.z },
8839
+ normal: { x: facing.x, y: facing.y, z: facing.z },
8840
+ poiType: this.placementPoiType,
8841
+ };
8842
+ this.pendingTagPlacement = placement;
8843
+ this.placingTag = false;
8844
+ this.removePlacementPreview();
8845
+ this.placementChanged$.next(false);
8846
+ this.tagPlaced$.next(placement);
8847
+ return placement;
8270
8848
  }
8271
8849
  /** Leaves placement mode without placing anything. */
8272
8850
  cancelTagPlacement() {
8851
+ if (this.placingTag) {
8852
+ this.placementChanged$.next(false);
8853
+ }
8273
8854
  this.placingTag = false;
8274
8855
  this.pendingTagPlacement = null;
8275
8856
  this.pendingPreviewEvent = null;
@@ -8294,15 +8875,20 @@ class ThreeJsService {
8294
8875
  }
8295
8876
  this.renderer.domElement.style.cursor = 'crosshair';
8296
8877
  if (!this.placementPreview) {
8297
- this.placementPreview = new THREE.Sprite(new THREE.SpriteMaterial({
8298
- map: this.createTagTexture('#9E9E9E'),
8299
- depthTest: false,
8300
- transparent: true,
8301
- opacity: 0.6,
8302
- }));
8878
+ // While calibrating, the ghost is the pin the click will leave, number and all: what is
8879
+ // under the pointer is what stays there.
8880
+ this.placementPreview = this.aligning
8881
+ ? this.createCalibrationMark(this.createNumberedTexture(this.alignmentPairs.length + 1, ThreeJsService.CALIBRATION_DONE_COLOR), 999)
8882
+ : new THREE.Sprite(new THREE.SpriteMaterial({
8883
+ map: this.createTagTexture('#9E9E9E'),
8884
+ depthTest: false,
8885
+ transparent: true,
8886
+ sizeAttenuation: false,
8887
+ }));
8303
8888
  this.placementPreview.renderOrder = 999;
8889
+ this.placementPreview.material.opacity = 0.6;
8304
8890
  this.tagGroup.add(this.placementPreview);
8305
- this.scaleTagForModel(this.placementPreview);
8891
+ this.setScreenSize(this.placementPreview, this.aligning ? ThreeJsService.CALIBRATION_PIN_PX : ThreeJsService.TAG_SCREEN_PX);
8306
8892
  }
8307
8893
  this.placementPreview.position.copy(position);
8308
8894
  this.updateMeasureRubberBand(position);
@@ -8409,6 +8995,16 @@ class ThreeJsService {
8409
8995
  setVisibleTagTypes(types) {
8410
8996
  this.visibleTagTypes = types;
8411
8997
  this.tagSprites.forEach((sprite) => this.applyTagVisibility(sprite));
8998
+ this.announceTagsChanged();
8999
+ }
9000
+ /** Says whether the visit on screen is the site's only one; see soleVisit. */
9001
+ setSoleVisit(sole) {
9002
+ this.soleVisit = sole;
9003
+ this.tagSprites.forEach((sprite) => {
9004
+ this.parentTag(sprite);
9005
+ this.applyTagVisibility(sprite);
9006
+ });
9007
+ this.announceTagsChanged();
8412
9008
  }
8413
9009
  /** Says which capture is on screen, so the tags placed in it can be told from the rest. */
8414
9010
  setCurrentCapture(captureID) {
@@ -8417,6 +9013,7 @@ class ThreeJsService {
8417
9013
  this.parentTag(sprite);
8418
9014
  this.applyTagVisibility(sprite);
8419
9015
  });
9016
+ this.announceTagsChanged();
8420
9017
  }
8421
9018
  /** Every tag registered in the scene, shown or not. */
8422
9019
  getTags() {
@@ -8492,8 +9089,8 @@ class ThreeJsService {
8492
9089
  }
8493
9090
  /** Whether the tag was placed in the visit on screen. */
8494
9091
  isOwnedByVisit(sprite) {
8495
- return !!sprite.userData.originCaptureID
8496
- && sprite.userData.originCaptureID === this.currentCaptureID;
9092
+ const origin = sprite.userData.originCaptureID;
9093
+ return origin ? origin === this.currentCaptureID : this.soleVisit;
8497
9094
  }
8498
9095
  /**
8499
9096
  * A tag shows when its type is not filtered out and it belongs on this model.
@@ -8522,8 +9119,9 @@ class ThreeJsService {
8522
9119
  if (sprite.userData.frame === 'model') {
8523
9120
  return false;
8524
9121
  }
8525
- // Calibrating is matching tags to where they belong, so they all have to be on offer.
8526
- if (this.aligning) {
9122
+ // Calibrating is matching tags to where they belong, so they all have to be on offer - from
9123
+ // the moment the panel opens, not only once the first click is being waited for.
9124
+ if (this.aligning || this.revealingCalibrationTags) {
8527
9125
  return true;
8528
9126
  }
8529
9127
  return this.isOnModel(sprite);
@@ -8661,6 +9259,9 @@ class ThreeJsService {
8661
9259
  */
8662
9260
  goToTag(elementId) {
8663
9261
  const sprite = this.tagSprites.get(elementId);
9262
+ if (sprite) {
9263
+ this.tagFocused$.next(elementId);
9264
+ }
8664
9265
  if (!sprite || !this.camera) {
8665
9266
  return;
8666
9267
  }
@@ -8782,10 +9383,11 @@ class ThreeJsService {
8782
9383
  this.tagGroup.rotation.set(rotation?.x ?? 0, rotation?.y ?? 0, rotation?.z ?? 0);
8783
9384
  this.tagGroup.scale.setScalar(scale ?? 1);
8784
9385
  this.tagGroup.updateMatrix();
8785
- // Tag size is derived from the world scale it inherits, which this call just changed - and so
8786
- // is which tags land on the model.
8787
- this.tagSprites.forEach((sprite) => this.scaleTagForModel(sprite));
9386
+ // Marker sizes are worked back from the scale they inherit, which this call just changed - and
9387
+ // so is which tags land on the model.
9388
+ this.refreshScreenSizes();
8788
9389
  this.refreshTagVisibility();
9390
+ this.announceTagsChanged();
8789
9391
  }
8790
9392
  /**
8791
9393
  * Adds a tag, or moves/restyles the one already registered for this element.
@@ -8802,22 +9404,29 @@ class ThreeJsService {
8802
9404
  existing.userData.label = tag.label ?? existing.userData.label;
8803
9405
  existing.userData.originCaptureID = tag.originCaptureID ?? existing.userData.originCaptureID;
8804
9406
  existing.userData.frame = tag.frame ?? existing.userData.frame;
9407
+ existing.userData.iconUrl = tag.iconUrl ?? existing.userData.iconUrl;
9408
+ existing.userData.normal = tag.normal ?? existing.userData.normal;
8805
9409
  this.parentTag(existing);
8806
9410
  this.applyTagVisibility(existing);
9411
+ this.announceTagsChanged();
8807
9412
  return;
8808
9413
  }
8809
9414
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
8810
9415
  map: this.createTagTexture(tag.color),
8811
9416
  // Tags read as UI rather than geometry, so they stay visible through the model instead of
8812
- // being occluded by whatever wall happens to be between them and the camera.
9417
+ // being occluded by whatever wall happens to be between them and the camera - and, for
9418
+ // the same reason, keep one size on screen rather than a size in the model.
8813
9419
  depthTest: false,
8814
9420
  transparent: true,
9421
+ sizeAttenuation: false,
8815
9422
  }));
8816
9423
  sprite.name = tag.id;
8817
9424
  sprite.userData.poiType = tag.poiType ?? null;
8818
9425
  sprite.userData.label = tag.label ?? null;
8819
9426
  sprite.userData.originCaptureID = tag.originCaptureID ?? null;
8820
9427
  sprite.userData.frame = tag.frame ?? null;
9428
+ sprite.userData.iconUrl = tag.iconUrl ?? null;
9429
+ sprite.userData.normal = tag.normal ?? null;
8821
9430
  sprite.position.set(tag.position.x, tag.position.y, tag.position.z);
8822
9431
  sprite.renderOrder = 999;
8823
9432
  this.parentTag(sprite);
@@ -8827,6 +9436,52 @@ class ThreeJsService {
8827
9436
  if (tag.iconUrl) {
8828
9437
  this.applyTagIcon(sprite, tag.iconUrl);
8829
9438
  }
9439
+ this.announceTagsChanged();
9440
+ }
9441
+ /**
9442
+ * The tags another viewer should show, and the calibration they are drawn under.
9443
+ *
9444
+ * The same ones this scene would offer, short of the test against the model's surface, which needs
9445
+ * the model here: the type filter applies, and a tag placed in another model before it was
9446
+ * calibrated is left out, its coordinates meaning nothing in this one.
9447
+ */
9448
+ getTagSnapshot() {
9449
+ const tags = [];
9450
+ this.tagSprites.forEach((sprite) => {
9451
+ const owned = this.isOwnedByVisit(sprite);
9452
+ const typeAllowed = this.visibleTagTypes === null || this.visibleTagTypes.includes(sprite.userData.poiType);
9453
+ if (!typeAllowed || (sprite.userData.frame === 'model' && !owned)) {
9454
+ return;
9455
+ }
9456
+ tags.push({
9457
+ id: sprite.name,
9458
+ position: { x: sprite.position.x, y: sprite.position.y, z: sprite.position.z },
9459
+ label: sprite.userData.label ?? null,
9460
+ poiType: sprite.userData.poiType ?? null,
9461
+ iconUrl: sprite.userData.iconUrl ?? null,
9462
+ inModelFrame: sprite.userData.frame === 'model' && owned,
9463
+ normal: sprite.userData.normal ?? null,
9464
+ });
9465
+ });
9466
+ const group = this.tagGroup;
9467
+ return {
9468
+ tags,
9469
+ alignment: {
9470
+ translation: { x: group.position.x, y: group.position.y, z: group.position.z },
9471
+ rotation: { x: group.rotation.x, y: group.rotation.y, z: group.rotation.z },
9472
+ scale: group.scale.x,
9473
+ },
9474
+ };
9475
+ }
9476
+ announceTagsChanged() {
9477
+ if (this.tagsChangeQueued) {
9478
+ return;
9479
+ }
9480
+ this.tagsChangeQueued = true;
9481
+ queueMicrotask(() => {
9482
+ this.tagsChangeQueued = false;
9483
+ this.tagsChanged$.next();
9484
+ });
8830
9485
  }
8831
9486
  /** Removes the tag registered for an element, if there is one. */
8832
9487
  removeTag(elementId) {
@@ -8838,6 +9493,7 @@ class ThreeJsService {
8838
9493
  sprite.material.map?.dispose();
8839
9494
  sprite.material.dispose();
8840
9495
  this.tagSprites.delete(elementId);
9496
+ this.announceTagsChanged();
8841
9497
  }
8842
9498
  /** Removes every tag from the scene. */
8843
9499
  clearTags() {
@@ -8867,18 +9523,78 @@ class ThreeJsService {
8867
9523
  context.stroke();
8868
9524
  return new THREE.CanvasTexture(canvas);
8869
9525
  }
9526
+ /** The default marker with a number on it: the nth point of a calibration. */
9527
+ createNumberedTexture(number, color) {
9528
+ const texture = this.createTagTexture(color);
9529
+ const canvas = texture.image;
9530
+ const context = canvas.getContext('2d');
9531
+ context.fillStyle = '#FFFFFF';
9532
+ context.font = 'bold 76px sans-serif';
9533
+ context.textAlign = 'center';
9534
+ context.textBaseline = 'middle';
9535
+ context.fillText(String(number), canvas.width / 2, canvas.height / 2 + 5);
9536
+ return texture;
9537
+ }
9538
+ /** A ring with nothing in the middle, to go round a tag and leave it showing. */
9539
+ createRingTexture(color) {
9540
+ const size = 128;
9541
+ const canvas = document.createElement('canvas');
9542
+ canvas.width = size;
9543
+ canvas.height = size;
9544
+ const context = canvas.getContext('2d');
9545
+ // White under the colour, a little wider, for the same reason the tags have a white edge.
9546
+ context.beginPath();
9547
+ context.arc(size / 2, size / 2, size / 2 - 10, 0, Math.PI * 2);
9548
+ context.lineWidth = 16;
9549
+ context.strokeStyle = '#FFFFFF';
9550
+ context.stroke();
9551
+ context.lineWidth = 10;
9552
+ context.strokeStyle = color;
9553
+ context.stroke();
9554
+ return new THREE.CanvasTexture(canvas);
9555
+ }
8870
9556
  /**
8871
- * Sizes a tag relative to the model.
9557
+ * Gives a tag its size: the same number of pixels on screen whatever the model.
8872
9558
  *
8873
- * A sprite inherits the model's scale and the alignment's, so a fixed size would come out tiny on
8874
- * a large model and huge on a small one. Dividing by the group's world scale keeps tags the same
8875
- * size on screen whatever the model's units are.
9559
+ * It used to be a fixed fraction of the model instead. That reads fine on a room, but on a model
9560
+ * the size of a city block it is a tag several metres across, which fills the view as soon as
9561
+ * the camera moves in to look at anything.
8876
9562
  */
8877
9563
  scaleTagForModel(sprite) {
8878
- // Its own parent's scale: tags in the model's coordinates do not inherit the calibration's.
8879
- const worldScale = (sprite.parent ?? this.tagGroup).getWorldScale(new THREE.Vector3()).x || 1;
8880
- const size = 0.4 / worldScale;
8881
- sprite.scale.set(size, size, 1);
9564
+ this.setScreenSize(sprite, ThreeJsService.TAG_SCREEN_PX);
9565
+ }
9566
+ /**
9567
+ * Fixes a sprite's size on screen, in CSS pixels.
9568
+ *
9569
+ * The sprite must be drawn without size attenuation, which makes its size a proportion of the
9570
+ * viewport instead of something that shrinks with distance. Its scale is then worked back from
9571
+ * the pixels wanted, the viewport's height and the scale it inherits from its parents - the
9572
+ * model's normalisation and, for tags, the calibration - so none of those show in the result.
9573
+ * The size asked for is kept on the sprite, so it can be worked out again when any of them changes.
9574
+ */
9575
+ setScreenSize(sprite, widthPx, heightPx = widthPx) {
9576
+ sprite.userData.screenSize = [widthPx, heightPx];
9577
+ const inherited = (sprite.parent ?? this.tagGroup).getWorldScale(new THREE.Vector3()).x || 1;
9578
+ const perPixel = this.worldUnitsPerPixel() / inherited;
9579
+ sprite.scale.set(widthPx * perPixel, heightPx * perPixel, 1);
9580
+ }
9581
+ /** What one screen pixel is worth in the scale of a sprite drawn without size attenuation. */
9582
+ worldUnitsPerPixel() {
9583
+ const height = this.renderer?.getSize?.(new THREE.Vector2()).y || 800;
9584
+ const fieldOfView = THREE.MathUtils.degToRad(this.camera?.fov ?? 75);
9585
+ return (2 * Math.tan(fieldOfView / 2)) / height;
9586
+ }
9587
+ /** Re-applies every marker's on-screen size: the viewport, the model or the calibration changed. */
9588
+ refreshScreenSizes() {
9589
+ const resize = (object) => {
9590
+ if (object?.userData?.screenSize) {
9591
+ this.setScreenSize(object, object.userData.screenSize[0], object.userData.screenSize[1]);
9592
+ }
9593
+ };
9594
+ this.tagSprites.forEach(resize);
9595
+ this.measureGroup.children.forEach(resize);
9596
+ this.calibrationGroup.children.forEach(resize);
9597
+ resize(this.placementPreview);
8882
9598
  }
8883
9599
  /**
8884
9600
  * Adds secondary interactive objects (e.g., POIs, sensors) to the scene.
@@ -8992,6 +9708,7 @@ class ThreeJsService {
8992
9708
  this.placingTag = false;
8993
9709
  this.removePlacementPreview();
8994
9710
  this.renderer.domElement.style.cursor = 'default';
9711
+ this.placementChanged$.next(false);
8995
9712
  this.tagPlaced$.next(placement);
8996
9713
  }
8997
9714
  return;
@@ -9019,12 +9736,19 @@ class ThreeJsService {
9019
9736
  }
9020
9737
  }
9021
9738
  /**
9022
- * Escape cancels whatever the pointer is currently committed to.
9739
+ * Escape cancels whatever the pointer is currently committed to. While calibrating, the undo keys
9740
+ * take back one step of it instead of all of it.
9023
9741
  *
9024
9742
  * Emits through the same subjects the explicit Cancel buttons use, so the UI driving them follows
9025
9743
  * along instead of being left showing a mode that is no longer armed.
9026
9744
  */
9027
9745
  onKeyDown = (event) => {
9746
+ if (this.aligning && this.isUndoKey(event)) {
9747
+ if (this.undoAlignmentStep()) {
9748
+ event.preventDefault();
9749
+ }
9750
+ return;
9751
+ }
9028
9752
  if (event.key !== 'Escape') {
9029
9753
  return;
9030
9754
  }
@@ -9041,9 +9765,22 @@ class ThreeJsService {
9041
9765
  this.cancelTagAlignment();
9042
9766
  }
9043
9767
  };
9768
+ /**
9769
+ * Ctrl+Z (Cmd+Z on a Mac) or Backspace, unless typed into a field - where they mean something
9770
+ * else, to whatever the field is.
9771
+ */
9772
+ isUndoKey(event) {
9773
+ const target = event.target;
9774
+ if (target?.isContentEditable || ['INPUT', 'TEXTAREA'].includes(target?.tagName ?? '')) {
9775
+ return false;
9776
+ }
9777
+ return event.key === 'Backspace'
9778
+ || ((event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === 'z');
9779
+ }
9044
9780
  animate = () => {
9045
9781
  this.animationFrameId = requestAnimationFrame(this.animate);
9046
9782
  this.stepCameraTween();
9783
+ this.stepPulses();
9047
9784
  this.controls.update();
9048
9785
  // One pick per frame, against the pointer's latest position - see onMouseMove.
9049
9786
  if (this.pendingPreviewEvent) {
@@ -9053,11 +9790,84 @@ class ThreeJsService {
9053
9790
  }
9054
9791
  this.renderer.render(this.scene, this.camera);
9055
9792
  };
9056
- destroy() {
9793
+ /**
9794
+ * Stops drawing the scene, or starts again. For while another viewer covers it - the splat viewer
9795
+ * - where it would otherwise go on drawing, unseen, every frame, on the graphics card the other
9796
+ * viewer needs. The scene keeps its tags and calibration all the same.
9797
+ */
9798
+ setRenderingPaused(paused) {
9799
+ if (paused) {
9800
+ this.stopRendering();
9801
+ }
9802
+ else if (this.live && !this.animationFrameId) {
9803
+ this.ngZone.runOutsideAngular(() => this.animate());
9804
+ }
9805
+ }
9806
+ /** Stops the render loop, if one is running. */
9807
+ stopRendering() {
9057
9808
  if (this.animationFrameId) {
9058
9809
  cancelAnimationFrame(this.animationFrameId);
9810
+ this.animationFrameId = 0;
9811
+ }
9812
+ }
9813
+ /**
9814
+ * Takes the model out of the scene and frees what it holds on the graphics card.
9815
+ *
9816
+ * The tags are kept. They hang from the model's group so as to share its frame, and are moved out
9817
+ * of it first: they belong to the visit, not to the file that was drawn under them.
9818
+ */
9819
+ removeModel() {
9820
+ const group = this.modelGroup;
9821
+ const model = this.modelRoot;
9822
+ this.modelGroup = undefined;
9823
+ this.modelRoot = undefined;
9824
+ this.surfaceCells = null;
9825
+ this.modelWorldRadius = 0;
9826
+ [this.tagGroup, this.modelTagGroup, this.calibrationGroup].forEach((kept) => kept.parent?.remove(kept));
9827
+ group?.parent?.remove(group);
9828
+ if (model) {
9829
+ this.disposeModel(model);
9059
9830
  }
9831
+ }
9832
+ /**
9833
+ * Frees a model's geometry, materials and textures.
9834
+ *
9835
+ * Dropping the last reference to a model does not do this. What it uploaded stays on the graphics
9836
+ * card until the page is closed - hundreds of megabytes for a captured building - and on a
9837
+ * machine whose graphics memory is shared with the rest of the system, everything slows down
9838
+ * long before anything fails.
9839
+ */
9840
+ disposeModel(model) {
9841
+ const freed = new Set();
9842
+ const free = (resource) => {
9843
+ if (resource && typeof resource.dispose === 'function' && !freed.has(resource)) {
9844
+ freed.add(resource);
9845
+ resource.dispose();
9846
+ }
9847
+ };
9848
+ model.traverse((child) => {
9849
+ free(child.geometry);
9850
+ const materials = Array.isArray(child.material) ? child.material : [child.material];
9851
+ materials.forEach((material) => {
9852
+ if (!material) {
9853
+ return;
9854
+ }
9855
+ // Whichever slots this kind of material has: map, normalMap, emissiveMap...
9856
+ Object.values(material).forEach((value) => {
9857
+ if (value?.isTexture) {
9858
+ free(value);
9859
+ }
9860
+ });
9861
+ free(material);
9862
+ });
9863
+ });
9864
+ }
9865
+ destroy() {
9866
+ this.live = false;
9867
+ this.stopRendering();
9060
9868
  window.removeEventListener('keydown', this.onKeyDown);
9869
+ this.canvasListeners.forEach((remove) => remove());
9870
+ this.canvasListeners = [];
9061
9871
  if (this.canvasResizeObserver) {
9062
9872
  this.canvasResizeObserver.disconnect();
9063
9873
  this.canvasResizeObserver = undefined;
@@ -9066,6 +9876,13 @@ class ThreeJsService {
9066
9876
  this.clearTags();
9067
9877
  this.measuring = false;
9068
9878
  this.clearMeasurement();
9879
+ this.clearCalibrationMarks();
9880
+ this.pulses = [];
9881
+ // While the renderer is still there to hand the memory back through.
9882
+ this.removeModel();
9883
+ // They were in the scene that is going. Kept, the next scene would go on testing every pointer
9884
+ // move against objects nobody can see, and would skip loading them again as "already there".
9885
+ this.clickableObjects = [];
9069
9886
  if (this.renderer) {
9070
9887
  this.renderer.dispose();
9071
9888
  }
@@ -9551,6 +10368,39 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
9551
10368
  /* eslint-disable camelcase */
9552
10369
  /* eslint-disable no-underscore-dangle */
9553
10370
  /* eslint-disable class-methods-use-this */
10371
+ /**
10372
+ * What a Gaussian splat file is named with, when it is a .ply: the content is what tells it from
10373
+ * another .ply, and the key is all a visit stores (see isGaussianSplatPly).
10374
+ */
10375
+ const GAUSSIAN_SPLAT_PLY_SUFFIX = '.splat.ply';
10376
+ /**
10377
+ * Whether a 3D model file holds Gaussian splats, which the Three.js viewer cannot draw: .spz,
10378
+ * .splat, .ksplat, SuperSplat's .sog, a .ply named with GAUSSIAN_SPLAT_PLY_SUFFIX, or the
10379
+ * lod-meta.json of a Streamed SOG - SuperSplat's levels of detail, in pieces beside it.
10380
+ */
10381
+ function isGaussianSplatFile(name) {
10382
+ return /\.(spz|splat|ksplat|sog|splat\.ply)($|\?)/i.test(name ?? '') || isStreamedSplatFile(name);
10383
+ }
10384
+ /**
10385
+ * Whether a .ply holds Gaussian splats: what splat tools export carries, for each splat, its colour
10386
+ * as spherical harmonics (f_dc_0...) and its size (scale_0...). Only the header is read.
10387
+ */
10388
+ async function isGaussianSplatPly(file) {
10389
+ const start = await file.slice(0, 64 * 1024).text();
10390
+ const end = start.indexOf('end_header');
10391
+ if (!start.startsWith('ply') || end < 0) {
10392
+ return false;
10393
+ }
10394
+ const properties = start.slice(0, end).split(/\r?\n/)
10395
+ .map((line) => line.trim().split(/\s+/))
10396
+ .filter((words) => words[0] === 'property')
10397
+ .map((words) => words[words.length - 1]);
10398
+ return properties.includes('f_dc_0') && properties.includes('scale_0');
10399
+ }
10400
+ /** Whether a 3D model file is the lod-meta.json of a Streamed SOG; see isGaussianSplatFile. */
10401
+ function isStreamedSplatFile(name) {
10402
+ return /(^|\/)lod-meta\.json($|\?)/i.test(name ?? '');
10403
+ }
9554
10404
  class ViewerService {
9555
10405
  sdkKey;
9556
10406
  config;
@@ -9827,15 +10677,35 @@ class ViewerService {
9827
10677
  if (![position?.x, position?.y, position?.z].every((value) => typeof value === 'number')) {
9828
10678
  return;
9829
10679
  }
10680
+ // A tag is its position; the icon is how it is dressed. One that cannot be fetched - a
10681
+ // signature that expired, a file gone from the bucket - must not cost the tag itself.
10682
+ let iconUrl;
10683
+ try {
10684
+ iconUrl = await this.resolveTagIconUrl(poi);
10685
+ }
10686
+ catch (error) {
10687
+ console.warn(`ViewerService: no icon for the tag of element ${object.id}, using the default one.`, error);
10688
+ iconUrl = this.defaultTagIconUrl(poi.type);
10689
+ }
9830
10690
  const origin = getThreeJsPoiOrigin(poi);
10691
+ // Matterport's stem, kept with the POI by both viewers that place tags.
10692
+ let normal = null;
10693
+ try {
10694
+ const stored = poi.metadata ? JSON.parse(poi.metadata)?.normal : null;
10695
+ normal = [stored?.x, stored?.y, stored?.z].every((value) => typeof value === 'number') ? stored : null;
10696
+ }
10697
+ catch {
10698
+ normal = null;
10699
+ }
9831
10700
  this.threeJsService.upsertTag({
9832
10701
  id: object.id,
9833
10702
  position,
9834
10703
  poiType: poi.type,
9835
10704
  label: object.name ?? object.title ?? null,
9836
- iconUrl: await this.resolveTagIconUrl(poi),
10705
+ iconUrl,
9837
10706
  originCaptureID: origin?.captureID ?? null,
9838
10707
  frame: origin?.frame ?? null,
10708
+ normal,
9839
10709
  });
9840
10710
  }
9841
10711
  /**
@@ -9953,6 +10823,40 @@ class ViewerService {
9953
10823
  this.threeJsService.goToTag(elementId);
9954
10824
  }
9955
10825
  }
10826
+ /** The tag the Three.js camera is sent to, for the splat viewer to point out; see goToThreeJsTag. */
10827
+ get threeJsTagFocused$() {
10828
+ return this.threeJsService.tagFocused$;
10829
+ }
10830
+ /** A tag clicked in the splat viewer, said as threeJsTagClicked$ says one clicked in the scene. */
10831
+ clickThreeJsTag(elementId) {
10832
+ if (this.checkMode('threejs')) {
10833
+ this.threeJsService.clickTag(elementId);
10834
+ }
10835
+ }
10836
+ /** Whether the visit's 3D model has a calibration, from its capture's metadata. */
10837
+ isThreeJsCalibrated() {
10838
+ return this.threeJsCalibrated;
10839
+ }
10840
+ /** Whether the tags a calibration works with are on show; see ThreeJsService.showsCalibrationTags. */
10841
+ isThreeJsShowingCalibrationTags() {
10842
+ return this.checkMode('threejs') && this.threeJsService.showsCalibrationTags();
10843
+ }
10844
+ /** Whether a calibration is under way in the Three.js scene. */
10845
+ isThreeJsAligningTags() {
10846
+ return this.checkMode('threejs') && this.threeJsService.isAligningTags();
10847
+ }
10848
+ /** Whether a measurement is taking clicks in the Three.js scene. */
10849
+ isThreeJsMeasuring() {
10850
+ return this.checkMode('threejs') && this.threeJsService.isMeasuring();
10851
+ }
10852
+ /** True when a measurement starts taking clicks, false when it stops; see ThreeJsService.measuringChanged$. */
10853
+ get threeJsMeasuringChanged$() {
10854
+ return this.threeJsService.measuringChanged$;
10855
+ }
10856
+ /** Says a click in the splat viewer was of no use to the calibration, as one in the scene would. */
10857
+ ignoreThreeJsAlignmentClick(expected) {
10858
+ this.threeJsService.alignmentClickIgnored$.next(expected);
10859
+ }
9956
10860
  /** Shows only tags of the given POI types; null clears the filter. */
9957
10861
  setVisibleThreeJsTagTypes(types) {
9958
10862
  if (this.checkMode('threejs')) {
@@ -9975,6 +10879,24 @@ class ViewerService {
9975
10879
  this.threeJsService.cancelTagAlignment();
9976
10880
  }
9977
10881
  }
10882
+ /**
10883
+ * Says whether the 3D model on screen is the site's only visit. Then there is nowhere else for a
10884
+ * tag to have come from, and the tags that do not record where they were placed are its own.
10885
+ */
10886
+ setThreeJsSoleVisit(sole) {
10887
+ if (this.checkMode('threejs')) {
10888
+ this.threeJsService.setSoleVisit(sole);
10889
+ }
10890
+ }
10891
+ /**
10892
+ * Shows the tags a calibration would work with, including those hidden for not landing on the
10893
+ * model, before anything is armed. Cancelling or saving the calibration hides them again.
10894
+ */
10895
+ revealThreeJsCalibrationTags() {
10896
+ if (this.checkMode('threejs')) {
10897
+ this.threeJsService.revealCalibrationTags(true);
10898
+ }
10899
+ }
9978
10900
  get threeJsAlignmentProgress$() {
9979
10901
  return this.threeJsService.alignmentProgress$;
9980
10902
  }
@@ -10149,17 +11071,35 @@ class ViewerService {
10149
11071
  get threeJsMeasurementProgress$() {
10150
11072
  return this.threeJsService.measurementProgress$;
10151
11073
  }
10152
- /** Ends the Three.js measurement in progress, or returns null when it has fewer than two points. */
10153
- finishThreeJsMeasurement() {
11074
+ /**
11075
+ * Ends the Three.js measurement in progress, or returns null when it has fewer than two points.
11076
+ * `screenshot` is the picture of it, when the model is drawn by another viewer than the scene.
11077
+ */
11078
+ finishThreeJsMeasurement(screenshot) {
10154
11079
  if (!this.checkMode('threejs')) {
10155
11080
  return null;
10156
11081
  }
10157
- const measurement = this.threeJsService.finishMeasuring();
11082
+ const measurement = this.threeJsService.finishMeasuring(screenshot);
10158
11083
  if (measurement) {
10159
11084
  this.toolboxStates.mesure.stat = false;
10160
11085
  }
10161
11086
  return measurement;
10162
11087
  }
11088
+ /** A measurement was taken off the model, finished or not; see ThreeJsService.measurementCleared$. */
11089
+ get threeJsMeasurementCleared$() {
11090
+ return this.threeJsService.measurementCleared$;
11091
+ }
11092
+ /**
11093
+ * Adds a point to the measurement under way, picked on the model in another viewer - the splat
11094
+ * viewer - in the model's own coordinates. False when no measurement is under way.
11095
+ */
11096
+ addThreeJsMeasurePointAtModelPoint(point) {
11097
+ return this.checkMode('threejs') && this.threeJsService.addMeasurePointAtModelPoint(point);
11098
+ }
11099
+ /** The points of the measurement on the model, in the model's own coordinates, for another viewer to draw. */
11100
+ getThreeJsMeasurePointsInModelFrame() {
11101
+ return this.checkMode('threejs') ? this.threeJsService.getMeasurePointsInModelFrame() : [];
11102
+ }
10163
11103
  /** The measurement last finished in the Three.js viewer, for the form that saves it. */
10164
11104
  getThreeJsLastMeasurement() {
10165
11105
  return this.checkMode('threejs') ? this.threeJsService.getLastMeasurement() : null;
@@ -10221,6 +11161,24 @@ class ViewerService {
10221
11161
  selectThreeJsAlignmentTag(elementId) {
10222
11162
  return this.checkMode('threejs') && this.threeJsService.selectAlignmentTag(elementId);
10223
11163
  }
11164
+ /**
11165
+ * Gives the tag being matched in the calibration under way its real place, picked on the model in
11166
+ * another viewer - the splat viewer - in the model's own coordinates. False when no tag was waiting.
11167
+ */
11168
+ matchThreeJsAlignmentTagAtModelPoint(point) {
11169
+ return this.checkMode('threejs') && this.threeJsService.matchAlignmentTagAtModelPoint(point);
11170
+ }
11171
+ /**
11172
+ * Takes back the last step of the calibration under way: the tag just chosen, or else the last
11173
+ * pair matched. False when there was nothing to take back.
11174
+ */
11175
+ undoThreeJsAlignmentStep() {
11176
+ return this.checkMode('threejs') && this.threeJsService.undoAlignmentStep();
11177
+ }
11178
+ /** Removes one matched pair of the calibration under way, by its place in the list. */
11179
+ removeThreeJsAlignmentPair(index) {
11180
+ return this.checkMode('threejs') && this.threeJsService.removeAlignmentPair(index);
11181
+ }
10224
11182
  /** How far the visible tags sit from the 3D model, in model radii. Null without both. */
10225
11183
  getThreeJsTagDistanceFromModel() {
10226
11184
  return this.checkMode('threejs') ? this.threeJsService.getTagDistanceFromModel() : null;
@@ -10295,6 +11253,11 @@ class ViewerService {
10295
11253
  position: placement.position,
10296
11254
  poiType: updatedPoi?.type ?? poi.type,
10297
11255
  iconUrl: updatedPoi?.tagIcon ? await getSignedFile(updatedPoi.tagIcon) : null,
11256
+ normal: placement.normal,
11257
+ // As it is stored (see buildThreeJsPoiMetadata): placed in this model, and in its own
11258
+ // coordinates while it is not calibrated - so that it shows as the model's own straight away.
11259
+ ...(this.currentCaptureID ? { originCaptureID: this.currentCaptureID } : {}),
11260
+ ...(this.currentCaptureID && !this.threeJsCalibrated ? { frame: 'model' } : {}),
10298
11261
  });
10299
11262
  this.threeJsService.cancelTagPlacement();
10300
11263
  return updatedPoi;
@@ -10309,7 +11272,7 @@ class ViewerService {
10309
11272
  *
10310
11273
  * Returns null when there is no pending placement, which is the normal case in Matterport mode.
10311
11274
  */
10312
- async createPoiForThreeJsPlacement(element, poiType) {
11275
+ async createPoiForThreeJsPlacement(element, poiType, extra = {}) {
10313
11276
  if (!this.checkMode('threejs')) {
10314
11277
  return null;
10315
11278
  }
@@ -10321,6 +11284,8 @@ class ViewerService {
10321
11284
  // sweep - the floor plan most visibly, which only draws tags whose sweep belongs to it.
10322
11285
  const matterportSweepID = await this.findNearestSweepForThreeJs(placement.position);
10323
11286
  const poi = await this.poiService.createPOIForSpaceID({
11287
+ // What a form asked for besides the place - the museum's, say - under what the placement says.
11288
+ ...extra,
10324
11289
  coordinate: JSON.stringify(placement.position),
10325
11290
  type: poiType,
10326
11291
  elementID: element.id,
@@ -10342,6 +11307,35 @@ class ViewerService {
10342
11307
  get threeJsAlignmentCancelled$() {
10343
11308
  return this.threeJsService.alignmentCancelled$;
10344
11309
  }
11310
+ /** Emits when the Three.js tags change; see getThreeJsTagSnapshot. */
11311
+ get threeJsTagsChanged$() {
11312
+ return this.threeJsService.tagsChanged$;
11313
+ }
11314
+ /** The tags, and their calibration, as a viewer drawing the scene elsewhere needs them. */
11315
+ getThreeJsTagSnapshot() {
11316
+ return this.threeJsService.getTagSnapshot();
11317
+ }
11318
+ /** Stops drawing the Three.js scene while another viewer covers it, or starts again. */
11319
+ setThreeJsRenderingPaused(paused) {
11320
+ this.threeJsService.setRenderingPaused(paused);
11321
+ }
11322
+ /** Emits when a click during calibration landed on nothing it could use: a tag, or the model. */
11323
+ get threeJsAlignmentClickIgnored$() {
11324
+ return this.threeJsService.alignmentClickIgnored$;
11325
+ }
11326
+ /** Emits when Three.js tag placement is armed (true) and when it ends (false). */
11327
+ get threeJsPlacementChanged$() {
11328
+ return this.threeJsService.placementChanged$;
11329
+ }
11330
+ /**
11331
+ * Completes an armed Three.js tag placement at a spot the splat viewer picked, in the model's own
11332
+ * coordinates: the creation form then opens as for a click in the Three.js scene.
11333
+ */
11334
+ placeThreeJsTagAtModelPoint(point, normal = null) {
11335
+ if (this.checkMode('threejs')) {
11336
+ this.threeJsService.placeTagAtModelPoint(point, normal);
11337
+ }
11338
+ }
10345
11339
  /** Leaves Three.js tag placement mode without placing anything. */
10346
11340
  cancelThreeJsTagPlacement() {
10347
11341
  if (this.checkMode('threejs')) {
@@ -10492,7 +11486,12 @@ class ViewerService {
10492
11486
  }
10493
11487
  }
10494
11488
  async initMatterportSdk(spaceID, model3D, tagService, module = SpModule.IMMO) {
10495
- this.isLoaded = false;
11489
+ // A Three.js visit is announced as loaded by initThreeJs, and when one visit is swapped for
11490
+ // another that has already happened by the time this runs. Clearing the flag here left the
11491
+ // rest of the page believing no viewer was up - "show in 3D" greyed out on every panel.
11492
+ if (!this.checkMode('threejs')) {
11493
+ this.isLoaded = false;
11494
+ }
10496
11495
  this.matterportService.currentSpaceID = spaceID;
10497
11496
  if (this.matterportService.sdk) {
10498
11497
  await this.clearAll();
@@ -10592,11 +11591,11 @@ class ModalSwitchVisitComponent {
10592
11591
  });
10593
11592
  }
10594
11593
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ModalSwitchVisitComponent, deps: [{ token: i1$2.NgbActiveModal }, { token: i1$1.Router }, { token: ViewerService }], target: i0.ɵɵFactoryTarget.Component });
10595
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: ModalSwitchVisitComponent, selector: "lib-modal-switch-visit", inputs: { content: "content", model3D: "model3D", spaceID: "spaceID", dataToMove: "dataToMove" }, ngImport: i0, template: "<div class=\"modal-container\">\r\n <p>{{content}}</p>\r\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"switchVisit()\">{{'Yes, switch visit' | translate }}</button>\r\n <button class=\"btn btn-outline-primary rounded-pill\"(click)=\"activeModal.close()\">{{'No, stay here' | translate }}</button>\r\n</div>\r\n", styles: [".modal-container{margin:10px}button{margin-right:8px}\n"], dependencies: [{ kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
11594
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: ModalSwitchVisitComponent, selector: "lib-modal-switch-visit", inputs: { content: "content", model3D: "model3D", spaceID: "spaceID", dataToMove: "dataToMove" }, ngImport: i0, template: "<div class=\"modal-container\">\n <p>{{content}}</p>\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"switchVisit()\">{{'Yes, switch visit' | translate }}</button>\n <button class=\"btn btn-outline-primary rounded-pill\"(click)=\"activeModal.close()\">{{'No, stay here' | translate }}</button>\n</div>\n", styles: [".modal-container{margin:10px}button{margin-right:8px}\n"], dependencies: [{ kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
10596
11595
  }
10597
11596
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ModalSwitchVisitComponent, decorators: [{
10598
11597
  type: Component,
10599
- args: [{ selector: 'lib-modal-switch-visit', template: "<div class=\"modal-container\">\r\n <p>{{content}}</p>\r\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"switchVisit()\">{{'Yes, switch visit' | translate }}</button>\r\n <button class=\"btn btn-outline-primary rounded-pill\"(click)=\"activeModal.close()\">{{'No, stay here' | translate }}</button>\r\n</div>\r\n", styles: [".modal-container{margin:10px}button{margin-right:8px}\n"] }]
11598
+ args: [{ selector: 'lib-modal-switch-visit', template: "<div class=\"modal-container\">\n <p>{{content}}</p>\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"switchVisit()\">{{'Yes, switch visit' | translate }}</button>\n <button class=\"btn btn-outline-primary rounded-pill\"(click)=\"activeModal.close()\">{{'No, stay here' | translate }}</button>\n</div>\n", styles: [".modal-container{margin:10px}button{margin-right:8px}\n"] }]
10600
11599
  }], ctorParameters: () => [{ type: i1$2.NgbActiveModal }, { type: i1$1.Router }, { type: ViewerService }], propDecorators: { content: [{
10601
11600
  type: Input
10602
11601
  }], model3D: [{
@@ -15315,11 +16314,11 @@ class SearchBarComponent {
15315
16314
  this.searchEvent.emit(results);
15316
16315
  }
15317
16316
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SearchBarComponent, deps: [{ token: SearchService }], target: i0.ɵɵFactoryTarget.Component });
15318
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SearchBarComponent, selector: "lib-search-bar", inputs: { searchable: "searchable", objectType: "objectType" }, outputs: { searchEvent: "searchEvent" }, ngImport: i0, template: "<div class=\"col-md-8 searchbar\">\r\n <div class=\"row depend\">\r\n <div class=\"col-md-5\">\r\n <input id=\"searchbar\" type=\"text\" class=\"form-control\" placeholder=\"keyword\" />\r\n </div>\r\n <div class=\"col-md-5\">\r\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"onSubmit()\">{{ 'Search' |\r\n translate}}</button>\r\n </div>\r\n </div>\r\n</div>", styles: [".searchbar{margin-bottom:10px}\n"], dependencies: [{ kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
16317
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SearchBarComponent, selector: "lib-search-bar", inputs: { searchable: "searchable", objectType: "objectType" }, outputs: { searchEvent: "searchEvent" }, ngImport: i0, template: "<div class=\"col-md-8 searchbar\">\n <div class=\"row depend\">\n <div class=\"col-md-5\">\n <input id=\"searchbar\" type=\"text\" class=\"form-control\" placeholder=\"keyword\" />\n </div>\n <div class=\"col-md-5\">\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"onSubmit()\">{{ 'Search' |\n translate}}</button>\n </div>\n </div>\n</div>", styles: [".searchbar{margin-bottom:10px}\n"], dependencies: [{ kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
15319
16318
  }
15320
16319
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SearchBarComponent, decorators: [{
15321
16320
  type: Component,
15322
- args: [{ selector: 'lib-search-bar', template: "<div class=\"col-md-8 searchbar\">\r\n <div class=\"row depend\">\r\n <div class=\"col-md-5\">\r\n <input id=\"searchbar\" type=\"text\" class=\"form-control\" placeholder=\"keyword\" />\r\n </div>\r\n <div class=\"col-md-5\">\r\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"onSubmit()\">{{ 'Search' |\r\n translate}}</button>\r\n </div>\r\n </div>\r\n</div>", styles: [".searchbar{margin-bottom:10px}\n"] }]
16321
+ args: [{ selector: 'lib-search-bar', template: "<div class=\"col-md-8 searchbar\">\n <div class=\"row depend\">\n <div class=\"col-md-5\">\n <input id=\"searchbar\" type=\"text\" class=\"form-control\" placeholder=\"keyword\" />\n </div>\n <div class=\"col-md-5\">\n <button class=\"btn btn-outline-primary rounded-pill\" (click)=\"onSubmit()\">{{ 'Search' |\n translate}}</button>\n </div>\n </div>\n</div>", styles: [".searchbar{margin-bottom:10px}\n"] }]
15323
16322
  }], ctorParameters: () => [{ type: SearchService }], propDecorators: { searchable: [{
15324
16323
  type: Input
15325
16324
  }], objectType: [{
@@ -15767,11 +16766,11 @@ class LoaderComponent {
15767
16766
  document.documentElement.style.setProperty('--color', this.color);
15768
16767
  }
15769
16768
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: LoaderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
15770
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: LoaderComponent, selector: "lib-loader", inputs: { useLogo: "useLogo", color: "color" }, ngImport: i0, template: "<div class=\"sk-fading-circle\">\r\n <img *ngIf=\"useLogo\" src=\"../../../../assets/icons/smarterplan.svg\">\r\n <div class=\"sk-circle1 sk-circle\"></div>\r\n <div class=\"sk-circle2 sk-circle\"></div>\r\n <div class=\"sk-circle3 sk-circle\"></div>\r\n <div class=\"sk-circle4 sk-circle\"></div>\r\n <div class=\"sk-circle5 sk-circle\"></div>\r\n <div class=\"sk-circle6 sk-circle\"></div>\r\n <div class=\"sk-circle7 sk-circle\"></div>\r\n <div class=\"sk-circle8 sk-circle\"></div>\r\n <div class=\"sk-circle9 sk-circle\"></div>\r\n <div class=\"sk-circle10 sk-circle\"></div>\r\n <div class=\"sk-circle11 sk-circle\"></div>\r\n <div class=\"sk-circle12 sk-circle\"></div>\r\n</div>\r\n", styles: [".sk-fading-circle{width:88px;height:88px;position:relative}.sk-fading-circle img{width:2rem;position:absolute;left:calc(50% - 16px);top:calc(50% - 16px)}.sk-fading-circle .sk-circle{width:100%;height:100%;position:absolute;left:0;top:0}.sk-fading-circle .sk-circle:before{content:\"\";display:block;margin:0 auto;width:15%;height:15%;background-color:var(--color);border-radius:100%;-webkit-animation:sk-circleFadeDelay 1.2s infinite ease-in-out both;animation:sk-circleFadeDelay 1.2s infinite ease-in-out both}.sk-fading-circle .sk-circle2{-webkit-transform:rotate(30deg);-ms-transform:rotate(30deg);transform:rotate(30deg)}.sk-fading-circle .sk-circle3{-webkit-transform:rotate(60deg);-ms-transform:rotate(60deg);transform:rotate(60deg)}.sk-fading-circle .sk-circle4{-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.sk-fading-circle .sk-circle5{-webkit-transform:rotate(120deg);-ms-transform:rotate(120deg);transform:rotate(120deg)}.sk-fading-circle .sk-circle6{-webkit-transform:rotate(150deg);-ms-transform:rotate(150deg);transform:rotate(150deg)}.sk-fading-circle .sk-circle7{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.sk-fading-circle .sk-circle8{-webkit-transform:rotate(210deg);-ms-transform:rotate(210deg);transform:rotate(210deg)}.sk-fading-circle .sk-circle9{-webkit-transform:rotate(240deg);-ms-transform:rotate(240deg);transform:rotate(240deg)}.sk-fading-circle .sk-circle10{-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.sk-fading-circle .sk-circle11{-webkit-transform:rotate(300deg);-ms-transform:rotate(300deg);transform:rotate(300deg)}.sk-fading-circle .sk-circle12{-webkit-transform:rotate(330deg);-ms-transform:rotate(330deg);transform:rotate(330deg)}.sk-fading-circle .sk-circle2:before{-webkit-animation-delay:-1.1s;animation-delay:-1.1s}.sk-fading-circle .sk-circle3:before{-webkit-animation-delay:-1s;animation-delay:-1s}.sk-fading-circle .sk-circle4:before{-webkit-animation-delay:-.9s;animation-delay:-.9s}.sk-fading-circle .sk-circle5:before{-webkit-animation-delay:-.8s;animation-delay:-.8s}.sk-fading-circle .sk-circle6:before{-webkit-animation-delay:-.7s;animation-delay:-.7s}.sk-fading-circle .sk-circle7:before{-webkit-animation-delay:-.6s;animation-delay:-.6s}.sk-fading-circle .sk-circle8:before{-webkit-animation-delay:-.5s;animation-delay:-.5s}.sk-fading-circle .sk-circle9:before{-webkit-animation-delay:-.4s;animation-delay:-.4s}.sk-fading-circle .sk-circle10:before{-webkit-animation-delay:-.3s;animation-delay:-.3s}.sk-fading-circle .sk-circle11:before{-webkit-animation-delay:-.2s;animation-delay:-.2s}.sk-fading-circle .sk-circle12:before{-webkit-animation-delay:-.1s;animation-delay:-.1s}@-webkit-keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}@keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}\n"], dependencies: [{ kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] });
16769
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: LoaderComponent, selector: "lib-loader", inputs: { useLogo: "useLogo", color: "color" }, ngImport: i0, template: "<div class=\"sk-fading-circle\">\n <img *ngIf=\"useLogo\" src=\"../../../../assets/icons/smarterplan.svg\">\n <div class=\"sk-circle1 sk-circle\"></div>\n <div class=\"sk-circle2 sk-circle\"></div>\n <div class=\"sk-circle3 sk-circle\"></div>\n <div class=\"sk-circle4 sk-circle\"></div>\n <div class=\"sk-circle5 sk-circle\"></div>\n <div class=\"sk-circle6 sk-circle\"></div>\n <div class=\"sk-circle7 sk-circle\"></div>\n <div class=\"sk-circle8 sk-circle\"></div>\n <div class=\"sk-circle9 sk-circle\"></div>\n <div class=\"sk-circle10 sk-circle\"></div>\n <div class=\"sk-circle11 sk-circle\"></div>\n <div class=\"sk-circle12 sk-circle\"></div>\n</div>\n", styles: [".sk-fading-circle{width:88px;height:88px;position:relative}.sk-fading-circle img{width:2rem;position:absolute;left:calc(50% - 16px);top:calc(50% - 16px)}.sk-fading-circle .sk-circle{width:100%;height:100%;position:absolute;left:0;top:0}.sk-fading-circle .sk-circle:before{content:\"\";display:block;margin:0 auto;width:15%;height:15%;background-color:var(--color);border-radius:100%;-webkit-animation:sk-circleFadeDelay 1.2s infinite ease-in-out both;animation:sk-circleFadeDelay 1.2s infinite ease-in-out both}.sk-fading-circle .sk-circle2{-webkit-transform:rotate(30deg);-ms-transform:rotate(30deg);transform:rotate(30deg)}.sk-fading-circle .sk-circle3{-webkit-transform:rotate(60deg);-ms-transform:rotate(60deg);transform:rotate(60deg)}.sk-fading-circle .sk-circle4{-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.sk-fading-circle .sk-circle5{-webkit-transform:rotate(120deg);-ms-transform:rotate(120deg);transform:rotate(120deg)}.sk-fading-circle .sk-circle6{-webkit-transform:rotate(150deg);-ms-transform:rotate(150deg);transform:rotate(150deg)}.sk-fading-circle .sk-circle7{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.sk-fading-circle .sk-circle8{-webkit-transform:rotate(210deg);-ms-transform:rotate(210deg);transform:rotate(210deg)}.sk-fading-circle .sk-circle9{-webkit-transform:rotate(240deg);-ms-transform:rotate(240deg);transform:rotate(240deg)}.sk-fading-circle .sk-circle10{-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.sk-fading-circle .sk-circle11{-webkit-transform:rotate(300deg);-ms-transform:rotate(300deg);transform:rotate(300deg)}.sk-fading-circle .sk-circle12{-webkit-transform:rotate(330deg);-ms-transform:rotate(330deg);transform:rotate(330deg)}.sk-fading-circle .sk-circle2:before{-webkit-animation-delay:-1.1s;animation-delay:-1.1s}.sk-fading-circle .sk-circle3:before{-webkit-animation-delay:-1s;animation-delay:-1s}.sk-fading-circle .sk-circle4:before{-webkit-animation-delay:-.9s;animation-delay:-.9s}.sk-fading-circle .sk-circle5:before{-webkit-animation-delay:-.8s;animation-delay:-.8s}.sk-fading-circle .sk-circle6:before{-webkit-animation-delay:-.7s;animation-delay:-.7s}.sk-fading-circle .sk-circle7:before{-webkit-animation-delay:-.6s;animation-delay:-.6s}.sk-fading-circle .sk-circle8:before{-webkit-animation-delay:-.5s;animation-delay:-.5s}.sk-fading-circle .sk-circle9:before{-webkit-animation-delay:-.4s;animation-delay:-.4s}.sk-fading-circle .sk-circle10:before{-webkit-animation-delay:-.3s;animation-delay:-.3s}.sk-fading-circle .sk-circle11:before{-webkit-animation-delay:-.2s;animation-delay:-.2s}.sk-fading-circle .sk-circle12:before{-webkit-animation-delay:-.1s;animation-delay:-.1s}@-webkit-keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}@keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}\n"], dependencies: [{ kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] });
15771
16770
  }
15772
16771
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: LoaderComponent, decorators: [{
15773
16772
  type: Component,
15774
- args: [{ selector: 'lib-loader', template: "<div class=\"sk-fading-circle\">\r\n <img *ngIf=\"useLogo\" src=\"../../../../assets/icons/smarterplan.svg\">\r\n <div class=\"sk-circle1 sk-circle\"></div>\r\n <div class=\"sk-circle2 sk-circle\"></div>\r\n <div class=\"sk-circle3 sk-circle\"></div>\r\n <div class=\"sk-circle4 sk-circle\"></div>\r\n <div class=\"sk-circle5 sk-circle\"></div>\r\n <div class=\"sk-circle6 sk-circle\"></div>\r\n <div class=\"sk-circle7 sk-circle\"></div>\r\n <div class=\"sk-circle8 sk-circle\"></div>\r\n <div class=\"sk-circle9 sk-circle\"></div>\r\n <div class=\"sk-circle10 sk-circle\"></div>\r\n <div class=\"sk-circle11 sk-circle\"></div>\r\n <div class=\"sk-circle12 sk-circle\"></div>\r\n</div>\r\n", styles: [".sk-fading-circle{width:88px;height:88px;position:relative}.sk-fading-circle img{width:2rem;position:absolute;left:calc(50% - 16px);top:calc(50% - 16px)}.sk-fading-circle .sk-circle{width:100%;height:100%;position:absolute;left:0;top:0}.sk-fading-circle .sk-circle:before{content:\"\";display:block;margin:0 auto;width:15%;height:15%;background-color:var(--color);border-radius:100%;-webkit-animation:sk-circleFadeDelay 1.2s infinite ease-in-out both;animation:sk-circleFadeDelay 1.2s infinite ease-in-out both}.sk-fading-circle .sk-circle2{-webkit-transform:rotate(30deg);-ms-transform:rotate(30deg);transform:rotate(30deg)}.sk-fading-circle .sk-circle3{-webkit-transform:rotate(60deg);-ms-transform:rotate(60deg);transform:rotate(60deg)}.sk-fading-circle .sk-circle4{-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.sk-fading-circle .sk-circle5{-webkit-transform:rotate(120deg);-ms-transform:rotate(120deg);transform:rotate(120deg)}.sk-fading-circle .sk-circle6{-webkit-transform:rotate(150deg);-ms-transform:rotate(150deg);transform:rotate(150deg)}.sk-fading-circle .sk-circle7{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.sk-fading-circle .sk-circle8{-webkit-transform:rotate(210deg);-ms-transform:rotate(210deg);transform:rotate(210deg)}.sk-fading-circle .sk-circle9{-webkit-transform:rotate(240deg);-ms-transform:rotate(240deg);transform:rotate(240deg)}.sk-fading-circle .sk-circle10{-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.sk-fading-circle .sk-circle11{-webkit-transform:rotate(300deg);-ms-transform:rotate(300deg);transform:rotate(300deg)}.sk-fading-circle .sk-circle12{-webkit-transform:rotate(330deg);-ms-transform:rotate(330deg);transform:rotate(330deg)}.sk-fading-circle .sk-circle2:before{-webkit-animation-delay:-1.1s;animation-delay:-1.1s}.sk-fading-circle .sk-circle3:before{-webkit-animation-delay:-1s;animation-delay:-1s}.sk-fading-circle .sk-circle4:before{-webkit-animation-delay:-.9s;animation-delay:-.9s}.sk-fading-circle .sk-circle5:before{-webkit-animation-delay:-.8s;animation-delay:-.8s}.sk-fading-circle .sk-circle6:before{-webkit-animation-delay:-.7s;animation-delay:-.7s}.sk-fading-circle .sk-circle7:before{-webkit-animation-delay:-.6s;animation-delay:-.6s}.sk-fading-circle .sk-circle8:before{-webkit-animation-delay:-.5s;animation-delay:-.5s}.sk-fading-circle .sk-circle9:before{-webkit-animation-delay:-.4s;animation-delay:-.4s}.sk-fading-circle .sk-circle10:before{-webkit-animation-delay:-.3s;animation-delay:-.3s}.sk-fading-circle .sk-circle11:before{-webkit-animation-delay:-.2s;animation-delay:-.2s}.sk-fading-circle .sk-circle12:before{-webkit-animation-delay:-.1s;animation-delay:-.1s}@-webkit-keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}@keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}\n"] }]
16773
+ args: [{ selector: 'lib-loader', template: "<div class=\"sk-fading-circle\">\n <img *ngIf=\"useLogo\" src=\"../../../../assets/icons/smarterplan.svg\">\n <div class=\"sk-circle1 sk-circle\"></div>\n <div class=\"sk-circle2 sk-circle\"></div>\n <div class=\"sk-circle3 sk-circle\"></div>\n <div class=\"sk-circle4 sk-circle\"></div>\n <div class=\"sk-circle5 sk-circle\"></div>\n <div class=\"sk-circle6 sk-circle\"></div>\n <div class=\"sk-circle7 sk-circle\"></div>\n <div class=\"sk-circle8 sk-circle\"></div>\n <div class=\"sk-circle9 sk-circle\"></div>\n <div class=\"sk-circle10 sk-circle\"></div>\n <div class=\"sk-circle11 sk-circle\"></div>\n <div class=\"sk-circle12 sk-circle\"></div>\n</div>\n", styles: [".sk-fading-circle{width:88px;height:88px;position:relative}.sk-fading-circle img{width:2rem;position:absolute;left:calc(50% - 16px);top:calc(50% - 16px)}.sk-fading-circle .sk-circle{width:100%;height:100%;position:absolute;left:0;top:0}.sk-fading-circle .sk-circle:before{content:\"\";display:block;margin:0 auto;width:15%;height:15%;background-color:var(--color);border-radius:100%;-webkit-animation:sk-circleFadeDelay 1.2s infinite ease-in-out both;animation:sk-circleFadeDelay 1.2s infinite ease-in-out both}.sk-fading-circle .sk-circle2{-webkit-transform:rotate(30deg);-ms-transform:rotate(30deg);transform:rotate(30deg)}.sk-fading-circle .sk-circle3{-webkit-transform:rotate(60deg);-ms-transform:rotate(60deg);transform:rotate(60deg)}.sk-fading-circle .sk-circle4{-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.sk-fading-circle .sk-circle5{-webkit-transform:rotate(120deg);-ms-transform:rotate(120deg);transform:rotate(120deg)}.sk-fading-circle .sk-circle6{-webkit-transform:rotate(150deg);-ms-transform:rotate(150deg);transform:rotate(150deg)}.sk-fading-circle .sk-circle7{-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.sk-fading-circle .sk-circle8{-webkit-transform:rotate(210deg);-ms-transform:rotate(210deg);transform:rotate(210deg)}.sk-fading-circle .sk-circle9{-webkit-transform:rotate(240deg);-ms-transform:rotate(240deg);transform:rotate(240deg)}.sk-fading-circle .sk-circle10{-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.sk-fading-circle .sk-circle11{-webkit-transform:rotate(300deg);-ms-transform:rotate(300deg);transform:rotate(300deg)}.sk-fading-circle .sk-circle12{-webkit-transform:rotate(330deg);-ms-transform:rotate(330deg);transform:rotate(330deg)}.sk-fading-circle .sk-circle2:before{-webkit-animation-delay:-1.1s;animation-delay:-1.1s}.sk-fading-circle .sk-circle3:before{-webkit-animation-delay:-1s;animation-delay:-1s}.sk-fading-circle .sk-circle4:before{-webkit-animation-delay:-.9s;animation-delay:-.9s}.sk-fading-circle .sk-circle5:before{-webkit-animation-delay:-.8s;animation-delay:-.8s}.sk-fading-circle .sk-circle6:before{-webkit-animation-delay:-.7s;animation-delay:-.7s}.sk-fading-circle .sk-circle7:before{-webkit-animation-delay:-.6s;animation-delay:-.6s}.sk-fading-circle .sk-circle8:before{-webkit-animation-delay:-.5s;animation-delay:-.5s}.sk-fading-circle .sk-circle9:before{-webkit-animation-delay:-.4s;animation-delay:-.4s}.sk-fading-circle .sk-circle10:before{-webkit-animation-delay:-.3s;animation-delay:-.3s}.sk-fading-circle .sk-circle11:before{-webkit-animation-delay:-.2s;animation-delay:-.2s}.sk-fading-circle .sk-circle12:before{-webkit-animation-delay:-.1s;animation-delay:-.1s}@-webkit-keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}@keyframes sk-circleFadeDelay{0%,39%,to{opacity:0}40%{opacity:1}}\n"] }]
15775
16774
  }], ctorParameters: () => [], propDecorators: { useLogo: [{
15776
16775
  type: Input
15777
16776
  }], color: [{
@@ -15829,11 +16828,11 @@ class SupportModalComponent {
15829
16828
  }
15830
16829
  }
15831
16830
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SupportModalComponent, deps: [{ token: SupportService }, { token: i1$2.NgbActiveModal }, { token: i3.UntypedFormBuilder }, { token: BaseUserService }, { token: ValidatorsService }], target: i0.ɵɵFactoryTarget.Component });
15832
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SupportModalComponent, selector: "app-support-modal", ngImport: i0, template: "<div class=\"modal-header\">\r\n <h5 class=\"modal-title\" translate=\"Help\"></h5>\r\n</div>\r\n<div class=\"modal-body\">\r\n <div class=\"support-loader-container\">\r\n <lib-loader *ngIf=\"loading\"></lib-loader>\r\n </div>\r\n <form (ngSubmit)=\"onSubmit()\" [formGroup]=\"supportForm\" style=\"margin-top:1rem;\" *ngIf=\"!loading && status==='new'\">\r\n <div>\r\n <label>{{'Title' | translate}} *</label>\r\n <div>\r\n <input type=\"text\" class=\"form-control\" formControlName=\"title\" data-testid=\"new-support-title\"\r\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['title'])}\"\r\n maxlength=\"255\">\r\n </div>\r\n <div class=\"invalid-feedback\">\r\n <span data-testid=\"new-support-title-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['title'])\"\r\n *ngIf=\" validatorsService.isControlError(supportForm.controls['title'])\"></span>\r\n </div>\r\n </div>\r\n <div *ngIf=\"!currentUser\">\r\n <label>{{'Email' | translate}} *</label>\r\n <div>\r\n <input type=\"email\" class=\"form-control\" formControlName=\"email\" data-testid=\"new-support-email\"\r\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['email'])}\"\r\n maxlength=\"255\">\r\n </div>\r\n <div class=\"invalid-feedback\">\r\n <span data-testid=\"new-support-email-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['email'])\"\r\n *ngIf=\" validatorsService.isControlError(supportForm.controls['email'])\"></span>\r\n </div>\r\n </div>\r\n <div>\r\n <label>{{'Description' | translate}} *</label>\r\n <div>\r\n <textarea formControlName=\"description\" class=\"form-control\"\r\n data-testid=\"new-support-description\"\r\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['description'])}\"></textarea>\r\n </div>\r\n <div class=\"invalid-feedback\" >\r\n <span data-testid=\"new-support-description-invalid\" [translate]=\" validatorsService.getError(supportForm.controls['description'])\"\r\n *ngIf=\"validatorsService.isControlError(supportForm.controls['description'])\"></span>\r\n </div>\r\n </div>\r\n </form>\r\n\r\n <div *ngIf=\"status ==='send'\" translate=\"Your request has been registered\" data-testid=\"new-support-send-info\">\r\n </div>\r\n <div *ngIf=\"status ==='error'\" translate=\"An error has occurred, please renew your request\"\r\n data-testid=\"new-support-error-info\">\r\n\r\n </div>\r\n</div>\r\n<div class=\"modal-footer\">\r\n <button type=\"button\" class=\"btn rounded-pill btn-museum\" data-dismiss=\"modal\" translate=\"Close\" (click)=\"onClose()\"\r\n data-testid=\"new-support-close-btn\"></button>\r\n <button *ngIf=\"!loading && status==='new'\" type=\"button\" class=\"btn rounded-pill btn-museum\" translate=\"Submit\"\r\n (click)=\"onSubmit()\" [disabled]=\"!supportForm.valid\" data-testid=\"new-support-submit-btn\"></button>\r\n</div>", styles: [".support-loader-container{width:88px;margin:auto}.invalid-feedback{height:16px}\n"], dependencies: [{ kind: "directive", type: i3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i3.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i3.MaxLengthValidator, selector: "[maxlength][formControlName],[maxlength][formControl],[maxlength][ngModel]", inputs: ["maxlength"] }, { kind: "directive", type: i3.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i3.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "directive", type: i11.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1.TranslateDirective, selector: "[translate],[ngx-translate]", inputs: ["translate", "translateParams"] }, { kind: "component", type: LoaderComponent, selector: "lib-loader", inputs: ["useLogo", "color"] }, { kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
16831
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SupportModalComponent, selector: "app-support-modal", ngImport: i0, template: "<div class=\"modal-header\">\n <h5 class=\"modal-title\" translate=\"Help\"></h5>\n</div>\n<div class=\"modal-body\">\n <div class=\"support-loader-container\">\n <lib-loader *ngIf=\"loading\"></lib-loader>\n </div>\n <form (ngSubmit)=\"onSubmit()\" [formGroup]=\"supportForm\" style=\"margin-top:1rem;\" *ngIf=\"!loading && status==='new'\">\n <div>\n <label>{{'Title' | translate}} *</label>\n <div>\n <input type=\"text\" class=\"form-control\" formControlName=\"title\" data-testid=\"new-support-title\"\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['title'])}\"\n maxlength=\"255\">\n </div>\n <div class=\"invalid-feedback\">\n <span data-testid=\"new-support-title-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['title'])\"\n *ngIf=\" validatorsService.isControlError(supportForm.controls['title'])\"></span>\n </div>\n </div>\n <div *ngIf=\"!currentUser\">\n <label>{{'Email' | translate}} *</label>\n <div>\n <input type=\"email\" class=\"form-control\" formControlName=\"email\" data-testid=\"new-support-email\"\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['email'])}\"\n maxlength=\"255\">\n </div>\n <div class=\"invalid-feedback\">\n <span data-testid=\"new-support-email-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['email'])\"\n *ngIf=\" validatorsService.isControlError(supportForm.controls['email'])\"></span>\n </div>\n </div>\n <div>\n <label>{{'Description' | translate}} *</label>\n <div>\n <textarea formControlName=\"description\" class=\"form-control\"\n data-testid=\"new-support-description\"\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['description'])}\"></textarea>\n </div>\n <div class=\"invalid-feedback\" >\n <span data-testid=\"new-support-description-invalid\" [translate]=\" validatorsService.getError(supportForm.controls['description'])\"\n *ngIf=\"validatorsService.isControlError(supportForm.controls['description'])\"></span>\n </div>\n </div>\n </form>\n\n <div *ngIf=\"status ==='send'\" translate=\"Your request has been registered\" data-testid=\"new-support-send-info\">\n </div>\n <div *ngIf=\"status ==='error'\" translate=\"An error has occurred, please renew your request\"\n data-testid=\"new-support-error-info\">\n\n </div>\n</div>\n<div class=\"modal-footer\">\n <button type=\"button\" class=\"btn rounded-pill btn-museum\" data-dismiss=\"modal\" translate=\"Close\" (click)=\"onClose()\"\n data-testid=\"new-support-close-btn\"></button>\n <button *ngIf=\"!loading && status==='new'\" type=\"button\" class=\"btn rounded-pill btn-museum\" translate=\"Submit\"\n (click)=\"onSubmit()\" [disabled]=\"!supportForm.valid\" data-testid=\"new-support-submit-btn\"></button>\n</div>", styles: [".support-loader-container{width:88px;margin:auto}.invalid-feedback{height:16px}\n"], dependencies: [{ kind: "directive", type: i3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i3.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i3.MaxLengthValidator, selector: "[maxlength][formControlName],[maxlength][formControl],[maxlength][ngModel]", inputs: ["maxlength"] }, { kind: "directive", type: i3.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i3.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "directive", type: i11.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1.TranslateDirective, selector: "[translate],[ngx-translate]", inputs: ["translate", "translateParams"] }, { kind: "component", type: LoaderComponent, selector: "lib-loader", inputs: ["useLogo", "color"] }, { kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
15833
16832
  }
15834
16833
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SupportModalComponent, decorators: [{
15835
16834
  type: Component,
15836
- args: [{ selector: 'app-support-modal', template: "<div class=\"modal-header\">\r\n <h5 class=\"modal-title\" translate=\"Help\"></h5>\r\n</div>\r\n<div class=\"modal-body\">\r\n <div class=\"support-loader-container\">\r\n <lib-loader *ngIf=\"loading\"></lib-loader>\r\n </div>\r\n <form (ngSubmit)=\"onSubmit()\" [formGroup]=\"supportForm\" style=\"margin-top:1rem;\" *ngIf=\"!loading && status==='new'\">\r\n <div>\r\n <label>{{'Title' | translate}} *</label>\r\n <div>\r\n <input type=\"text\" class=\"form-control\" formControlName=\"title\" data-testid=\"new-support-title\"\r\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['title'])}\"\r\n maxlength=\"255\">\r\n </div>\r\n <div class=\"invalid-feedback\">\r\n <span data-testid=\"new-support-title-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['title'])\"\r\n *ngIf=\" validatorsService.isControlError(supportForm.controls['title'])\"></span>\r\n </div>\r\n </div>\r\n <div *ngIf=\"!currentUser\">\r\n <label>{{'Email' | translate}} *</label>\r\n <div>\r\n <input type=\"email\" class=\"form-control\" formControlName=\"email\" data-testid=\"new-support-email\"\r\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['email'])}\"\r\n maxlength=\"255\">\r\n </div>\r\n <div class=\"invalid-feedback\">\r\n <span data-testid=\"new-support-email-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['email'])\"\r\n *ngIf=\" validatorsService.isControlError(supportForm.controls['email'])\"></span>\r\n </div>\r\n </div>\r\n <div>\r\n <label>{{'Description' | translate}} *</label>\r\n <div>\r\n <textarea formControlName=\"description\" class=\"form-control\"\r\n data-testid=\"new-support-description\"\r\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['description'])}\"></textarea>\r\n </div>\r\n <div class=\"invalid-feedback\" >\r\n <span data-testid=\"new-support-description-invalid\" [translate]=\" validatorsService.getError(supportForm.controls['description'])\"\r\n *ngIf=\"validatorsService.isControlError(supportForm.controls['description'])\"></span>\r\n </div>\r\n </div>\r\n </form>\r\n\r\n <div *ngIf=\"status ==='send'\" translate=\"Your request has been registered\" data-testid=\"new-support-send-info\">\r\n </div>\r\n <div *ngIf=\"status ==='error'\" translate=\"An error has occurred, please renew your request\"\r\n data-testid=\"new-support-error-info\">\r\n\r\n </div>\r\n</div>\r\n<div class=\"modal-footer\">\r\n <button type=\"button\" class=\"btn rounded-pill btn-museum\" data-dismiss=\"modal\" translate=\"Close\" (click)=\"onClose()\"\r\n data-testid=\"new-support-close-btn\"></button>\r\n <button *ngIf=\"!loading && status==='new'\" type=\"button\" class=\"btn rounded-pill btn-museum\" translate=\"Submit\"\r\n (click)=\"onSubmit()\" [disabled]=\"!supportForm.valid\" data-testid=\"new-support-submit-btn\"></button>\r\n</div>", styles: [".support-loader-container{width:88px;margin:auto}.invalid-feedback{height:16px}\n"] }]
16835
+ args: [{ selector: 'app-support-modal', template: "<div class=\"modal-header\">\n <h5 class=\"modal-title\" translate=\"Help\"></h5>\n</div>\n<div class=\"modal-body\">\n <div class=\"support-loader-container\">\n <lib-loader *ngIf=\"loading\"></lib-loader>\n </div>\n <form (ngSubmit)=\"onSubmit()\" [formGroup]=\"supportForm\" style=\"margin-top:1rem;\" *ngIf=\"!loading && status==='new'\">\n <div>\n <label>{{'Title' | translate}} *</label>\n <div>\n <input type=\"text\" class=\"form-control\" formControlName=\"title\" data-testid=\"new-support-title\"\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['title'])}\"\n maxlength=\"255\">\n </div>\n <div class=\"invalid-feedback\">\n <span data-testid=\"new-support-title-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['title'])\"\n *ngIf=\" validatorsService.isControlError(supportForm.controls['title'])\"></span>\n </div>\n </div>\n <div *ngIf=\"!currentUser\">\n <label>{{'Email' | translate}} *</label>\n <div>\n <input type=\"email\" class=\"form-control\" formControlName=\"email\" data-testid=\"new-support-email\"\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['email'])}\"\n maxlength=\"255\">\n </div>\n <div class=\"invalid-feedback\">\n <span data-testid=\"new-support-email-invalid\" [translate]=\"validatorsService.getError(supportForm.controls['email'])\"\n *ngIf=\" validatorsService.isControlError(supportForm.controls['email'])\"></span>\n </div>\n </div>\n <div>\n <label>{{'Description' | translate}} *</label>\n <div>\n <textarea formControlName=\"description\" class=\"form-control\"\n data-testid=\"new-support-description\"\n [ngClass]=\"{'is-invalid' : validatorsService.isControlError(supportForm.controls['description'])}\"></textarea>\n </div>\n <div class=\"invalid-feedback\" >\n <span data-testid=\"new-support-description-invalid\" [translate]=\" validatorsService.getError(supportForm.controls['description'])\"\n *ngIf=\"validatorsService.isControlError(supportForm.controls['description'])\"></span>\n </div>\n </div>\n </form>\n\n <div *ngIf=\"status ==='send'\" translate=\"Your request has been registered\" data-testid=\"new-support-send-info\">\n </div>\n <div *ngIf=\"status ==='error'\" translate=\"An error has occurred, please renew your request\"\n data-testid=\"new-support-error-info\">\n\n </div>\n</div>\n<div class=\"modal-footer\">\n <button type=\"button\" class=\"btn rounded-pill btn-museum\" data-dismiss=\"modal\" translate=\"Close\" (click)=\"onClose()\"\n data-testid=\"new-support-close-btn\"></button>\n <button *ngIf=\"!loading && status==='new'\" type=\"button\" class=\"btn rounded-pill btn-museum\" translate=\"Submit\"\n (click)=\"onSubmit()\" [disabled]=\"!supportForm.valid\" data-testid=\"new-support-submit-btn\"></button>\n</div>", styles: [".support-loader-container{width:88px;margin:auto}.invalid-feedback{height:16px}\n"] }]
15837
16836
  }], ctorParameters: () => [{ type: SupportService }, { type: i1$2.NgbActiveModal }, { type: i3.UntypedFormBuilder }, { type: BaseUserService }, { type: ValidatorsService }] });
15838
16837
 
15839
16838
  class LocaleService {
@@ -17273,11 +18272,11 @@ class NavigationBarComponent {
17273
18272
  this.baseVisibilityService.isChangePositionVisible.next(false);
17274
18273
  }
17275
18274
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NavigationBarComponent, deps: [{ token: 'currentAwsKinesisAnalyticsService' }, { token: i1$1.Router }, { token: SpaceService }, { token: BaseUserService }, { token: ZoneService }, { token: VisitService }, { token: ViewerService }, { token: NavigatorService }, { token: ZoneChangeService }, { token: BaseVisibilityService }], target: i0.ɵɵFactoryTarget.Component });
17276
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: NavigationBarComponent, selector: "lib-navigation-bar", viewQueries: [{ propertyName: "navbarSelectMenu", first: true, predicate: ["navbarSelectMenu"], descendants: true }], ngImport: i0, template: "<nav class=\"navbar navbar-expand-md navbar-dark navbar-inverse second-nav\">\r\n <div class=\"d-flex w-100\">\r\n <div #navbarSelectMenu class=\"w-100\" id=\"nav-item-container-blue\">\r\n <div class=\"d-flex flex-column flex-md-row p-1 mt-2\">\r\n <select class=\"form-control flex-fill flex-lg-fill-unset location select-menu\"\r\n [(ngModel)]=\"selectedLocation\" (ngModelChange)=\"onLocationClick()\">\r\n <option [ngValue]=\"null\" selected>{{ 'Location' | translate }}</option>\r\n <option *ngFor=\"let loc of locations; index as index\" [ngValue]=\"loc\"> {{ loc.name }}</option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" (change)=\"onVisitClick($event)\"\r\n *ngIf=\"visitsPerSpace && visitsPerSpace.length > 1\">\r\n <option value=\"default\" selected disabled hidden>{{'Visit' | translate}}</option>\r\n <option *ngFor=\"let visit of visitsPerSpace; index as index\" value=\"{{index}}\">{{ visit.name}}\r\n </option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset floor select-menu\" [(ngModel)]=\"selectedFloor\"\r\n (ngModelChange)=\"onFloorClick()\">\r\n <option [ngValue]=\"null\" selected>{{'Floor' | translate}}</option>\r\n <option *ngFor=\"let floor of floorsPerSpace\" [ngValue]=\"floor\">{{ floor.name}}</option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu zone\" [(ngModel)]=\"selectedZone\"\r\n (ngModelChange)=\"onZoneClick()\">\r\n <option [ngValue]=\"null\" selected>{{'Zone' | translate}}</option>\r\n <option *ngFor=\"let zone of zonesPerFloor\" [ngValue]=\"zone\"><span\r\n *ngIf=\"zone.code_ref\">{{zone.code_ref}}_</span>{{zone.name}}</option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" [(ngModel)]=\"selectedChildZone\"\r\n *ngIf=\"childrenZones && childrenZones.length>0\" (ngModelChange)=\"onChildZoneClick()\">\r\n <option [ngValue]=\"null\" selected disabled>{{'Zone' | translate}}</option>\r\n <option *ngFor=\"let zone of childrenZones\" [ngValue]=\"zone\">{{ zone.name}}</option>\r\n </select>\r\n <button (click)=\"goTo()\"\r\n class=\"btn btn-outline-primary rounded-pill mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d d-none d-lg-block\"\r\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\r\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\r\n </button>\r\n </div>\r\n <div class=\"row align-items-center justify-content-center ml-0 p-1\" *ngIf=\"showLocation()\">\r\n <p class=\"fw-bold lh-base align-middle text-center\">\r\n {{ showLocation() }}\r\n </p>\r\n </div>\r\n <div class=\"row align-items-center justify-content-center ml-0 p-1 d-block d-lg-none\">\r\n <button (click)=\"goTo()\"\r\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d\"\r\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\r\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n</nav>", styles: [":host{position:sticky;top:0;height:123px}.navbar{z-index:200;padding:.4rem 1rem}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0}@media (min-width: 992px){.flex-lg-fill-unset{flex:unset!important}}.form-control{width:100%;min-width:80px;max-width:18%}.location{width:auto;max-width:35%}.floor,.zone{width:auto;max-width:30%}button{color:#fff;padding:5px 10px;font-size:1.1em}.navbar-toggler{margin-right:1em}.select-menu{max-width:inherit}.btn-3d .icon-go-to-3d{width:30px;height:30px;background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0D%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0D%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0D%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0D%0A<style type=\"text/css\">%0D%0A%09.st0{fill:%23565656;}%0D%0A<\\/style>%0D%0A<g>%0D%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0D%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0D%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0D%0A%09%09/>%0D%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0D%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0D%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0D%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0D%0A</g>%0D%0A</svg>%0D%0A');border:none;vertical-align:middle;fill:var(--smarterplan-primary)}.btn-3d:hover:enabled .icon-go-to-3d{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0D%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0D%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0D%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0D%0A<style type=\"text/css\">%0D%0A%09.st0{fill:%23FFFFFF;}%0D%0A<\\/style>%0D%0A<g>%0D%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0D%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0D%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0D%0A%09%09/>%0D%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0D%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0D%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0D%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0D%0A</g>%0D%0A</svg>%0D%0A')}.btn-3d[disabled]:hover .icon-go-to-3d{fill:var(--smarterplan-primary)}.outer_circle{background-color:#ebebeb;border-radius:50%;width:32px;height:32px;padding:1px}@media (max-width: 767px){.navbar{padding:.2rem .5rem}.form-control,.location,.floor,.zone{max-width:100%;margin-bottom:15px}input,select{margin-right:0;margin-bottom:15px}}\n"], dependencies: [{ kind: "directive", type: i3.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i3.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i3.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i11.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
18275
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: NavigationBarComponent, selector: "lib-navigation-bar", viewQueries: [{ propertyName: "navbarSelectMenu", first: true, predicate: ["navbarSelectMenu"], descendants: true }], ngImport: i0, template: "<nav class=\"navbar navbar-expand-md navbar-dark navbar-inverse second-nav\">\n <div class=\"d-flex w-100\">\n <div #navbarSelectMenu class=\"w-100\" id=\"nav-item-container-blue\">\n <div class=\"d-flex flex-column flex-md-row p-1 mt-2\">\n <select class=\"form-control flex-fill flex-lg-fill-unset location select-menu\"\n [(ngModel)]=\"selectedLocation\" (ngModelChange)=\"onLocationClick()\">\n <option [ngValue]=\"null\" selected>{{ 'Location' | translate }}</option>\n <option *ngFor=\"let loc of locations; index as index\" [ngValue]=\"loc\"> {{ loc.name }}</option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" (change)=\"onVisitClick($event)\"\n *ngIf=\"visitsPerSpace && visitsPerSpace.length > 1\">\n <option value=\"default\" selected disabled hidden>{{'Visit' | translate}}</option>\n <option *ngFor=\"let visit of visitsPerSpace; index as index\" value=\"{{index}}\">{{ visit.name}}\n </option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset floor select-menu\" [(ngModel)]=\"selectedFloor\"\n (ngModelChange)=\"onFloorClick()\">\n <option [ngValue]=\"null\" selected>{{'Floor' | translate}}</option>\n <option *ngFor=\"let floor of floorsPerSpace\" [ngValue]=\"floor\">{{ floor.name}}</option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu zone\" [(ngModel)]=\"selectedZone\"\n (ngModelChange)=\"onZoneClick()\">\n <option [ngValue]=\"null\" selected>{{'Zone' | translate}}</option>\n <option *ngFor=\"let zone of zonesPerFloor\" [ngValue]=\"zone\"><span\n *ngIf=\"zone.code_ref\">{{zone.code_ref}}_</span>{{zone.name}}</option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" [(ngModel)]=\"selectedChildZone\"\n *ngIf=\"childrenZones && childrenZones.length>0\" (ngModelChange)=\"onChildZoneClick()\">\n <option [ngValue]=\"null\" selected disabled>{{'Zone' | translate}}</option>\n <option *ngFor=\"let zone of childrenZones\" [ngValue]=\"zone\">{{ zone.name}}</option>\n </select>\n <button (click)=\"goTo()\"\n class=\"btn btn-outline-primary rounded-pill mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d d-none d-lg-block\"\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\n </button>\n </div>\n <div class=\"row align-items-center justify-content-center ml-0 p-1\" *ngIf=\"showLocation()\">\n <p class=\"fw-bold lh-base align-middle text-center\">\n {{ showLocation() }}\n </p>\n </div>\n <div class=\"row align-items-center justify-content-center ml-0 p-1 d-block d-lg-none\">\n <button (click)=\"goTo()\"\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d\"\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\n </button>\n </div>\n </div>\n </div>\n</nav>", styles: [":host{position:sticky;top:0;height:123px}.navbar{z-index:200;padding:.4rem 1rem}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0}@media (min-width: 992px){.flex-lg-fill-unset{flex:unset!important}}.form-control{width:100%;min-width:80px;max-width:18%}.location{width:auto;max-width:35%}.floor,.zone{width:auto;max-width:30%}button{color:#fff;padding:5px 10px;font-size:1.1em}.navbar-toggler{margin-right:1em}.select-menu{max-width:inherit}.btn-3d .icon-go-to-3d{width:30px;height:30px;background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0A<style type=\"text/css\">%0A%09.st0{fill:%23565656;}%0A<\\/style>%0A<g>%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0A%09%09/>%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0A</g>%0A</svg>%0A');border:none;vertical-align:middle;fill:var(--smarterplan-primary)}.btn-3d:hover:enabled .icon-go-to-3d{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0A<style type=\"text/css\">%0A%09.st0{fill:%23FFFFFF;}%0A<\\/style>%0A<g>%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0A%09%09/>%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0A</g>%0A</svg>%0A')}.btn-3d[disabled]:hover .icon-go-to-3d{fill:var(--smarterplan-primary)}.outer_circle{background-color:#ebebeb;border-radius:50%;width:32px;height:32px;padding:1px}@media (max-width: 767px){.navbar{padding:.2rem .5rem}.form-control,.location,.floor,.zone{max-width:100%;margin-bottom:15px}input,select{margin-right:0;margin-bottom:15px}}\n"], dependencies: [{ kind: "directive", type: i3.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i3.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i3.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i11.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "pipe", type: i1.TranslatePipe, name: "translate" }] });
17277
18276
  }
17278
18277
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NavigationBarComponent, decorators: [{
17279
18278
  type: Component,
17280
- args: [{ selector: 'lib-navigation-bar', template: "<nav class=\"navbar navbar-expand-md navbar-dark navbar-inverse second-nav\">\r\n <div class=\"d-flex w-100\">\r\n <div #navbarSelectMenu class=\"w-100\" id=\"nav-item-container-blue\">\r\n <div class=\"d-flex flex-column flex-md-row p-1 mt-2\">\r\n <select class=\"form-control flex-fill flex-lg-fill-unset location select-menu\"\r\n [(ngModel)]=\"selectedLocation\" (ngModelChange)=\"onLocationClick()\">\r\n <option [ngValue]=\"null\" selected>{{ 'Location' | translate }}</option>\r\n <option *ngFor=\"let loc of locations; index as index\" [ngValue]=\"loc\"> {{ loc.name }}</option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" (change)=\"onVisitClick($event)\"\r\n *ngIf=\"visitsPerSpace && visitsPerSpace.length > 1\">\r\n <option value=\"default\" selected disabled hidden>{{'Visit' | translate}}</option>\r\n <option *ngFor=\"let visit of visitsPerSpace; index as index\" value=\"{{index}}\">{{ visit.name}}\r\n </option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset floor select-menu\" [(ngModel)]=\"selectedFloor\"\r\n (ngModelChange)=\"onFloorClick()\">\r\n <option [ngValue]=\"null\" selected>{{'Floor' | translate}}</option>\r\n <option *ngFor=\"let floor of floorsPerSpace\" [ngValue]=\"floor\">{{ floor.name}}</option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu zone\" [(ngModel)]=\"selectedZone\"\r\n (ngModelChange)=\"onZoneClick()\">\r\n <option [ngValue]=\"null\" selected>{{'Zone' | translate}}</option>\r\n <option *ngFor=\"let zone of zonesPerFloor\" [ngValue]=\"zone\"><span\r\n *ngIf=\"zone.code_ref\">{{zone.code_ref}}_</span>{{zone.name}}</option>\r\n </select>\r\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" [(ngModel)]=\"selectedChildZone\"\r\n *ngIf=\"childrenZones && childrenZones.length>0\" (ngModelChange)=\"onChildZoneClick()\">\r\n <option [ngValue]=\"null\" selected disabled>{{'Zone' | translate}}</option>\r\n <option *ngFor=\"let zone of childrenZones\" [ngValue]=\"zone\">{{ zone.name}}</option>\r\n </select>\r\n <button (click)=\"goTo()\"\r\n class=\"btn btn-outline-primary rounded-pill mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d d-none d-lg-block\"\r\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\r\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\r\n </button>\r\n </div>\r\n <div class=\"row align-items-center justify-content-center ml-0 p-1\" *ngIf=\"showLocation()\">\r\n <p class=\"fw-bold lh-base align-middle text-center\">\r\n {{ showLocation() }}\r\n </p>\r\n </div>\r\n <div class=\"row align-items-center justify-content-center ml-0 p-1 d-block d-lg-none\">\r\n <button (click)=\"goTo()\"\r\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d\"\r\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\r\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\r\n </button>\r\n </div>\r\n </div>\r\n </div>\r\n</nav>", styles: [":host{position:sticky;top:0;height:123px}.navbar{z-index:200;padding:.4rem 1rem}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0}@media (min-width: 992px){.flex-lg-fill-unset{flex:unset!important}}.form-control{width:100%;min-width:80px;max-width:18%}.location{width:auto;max-width:35%}.floor,.zone{width:auto;max-width:30%}button{color:#fff;padding:5px 10px;font-size:1.1em}.navbar-toggler{margin-right:1em}.select-menu{max-width:inherit}.btn-3d .icon-go-to-3d{width:30px;height:30px;background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0D%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0D%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0D%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0D%0A<style type=\"text/css\">%0D%0A%09.st0{fill:%23565656;}%0D%0A<\\/style>%0D%0A<g>%0D%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0D%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0D%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0D%0A%09%09/>%0D%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0D%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0D%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0D%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0D%0A</g>%0D%0A</svg>%0D%0A');border:none;vertical-align:middle;fill:var(--smarterplan-primary)}.btn-3d:hover:enabled .icon-go-to-3d{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0D%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0D%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0D%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0D%0A<style type=\"text/css\">%0D%0A%09.st0{fill:%23FFFFFF;}%0D%0A<\\/style>%0D%0A<g>%0D%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0D%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0D%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0D%0A%09%09/>%0D%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0D%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0D%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0D%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0D%0A</g>%0D%0A</svg>%0D%0A')}.btn-3d[disabled]:hover .icon-go-to-3d{fill:var(--smarterplan-primary)}.outer_circle{background-color:#ebebeb;border-radius:50%;width:32px;height:32px;padding:1px}@media (max-width: 767px){.navbar{padding:.2rem .5rem}.form-control,.location,.floor,.zone{max-width:100%;margin-bottom:15px}input,select{margin-right:0;margin-bottom:15px}}\n"] }]
18279
+ args: [{ selector: 'lib-navigation-bar', template: "<nav class=\"navbar navbar-expand-md navbar-dark navbar-inverse second-nav\">\n <div class=\"d-flex w-100\">\n <div #navbarSelectMenu class=\"w-100\" id=\"nav-item-container-blue\">\n <div class=\"d-flex flex-column flex-md-row p-1 mt-2\">\n <select class=\"form-control flex-fill flex-lg-fill-unset location select-menu\"\n [(ngModel)]=\"selectedLocation\" (ngModelChange)=\"onLocationClick()\">\n <option [ngValue]=\"null\" selected>{{ 'Location' | translate }}</option>\n <option *ngFor=\"let loc of locations; index as index\" [ngValue]=\"loc\"> {{ loc.name }}</option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" (change)=\"onVisitClick($event)\"\n *ngIf=\"visitsPerSpace && visitsPerSpace.length > 1\">\n <option value=\"default\" selected disabled hidden>{{'Visit' | translate}}</option>\n <option *ngFor=\"let visit of visitsPerSpace; index as index\" value=\"{{index}}\">{{ visit.name}}\n </option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset floor select-menu\" [(ngModel)]=\"selectedFloor\"\n (ngModelChange)=\"onFloorClick()\">\n <option [ngValue]=\"null\" selected>{{'Floor' | translate}}</option>\n <option *ngFor=\"let floor of floorsPerSpace\" [ngValue]=\"floor\">{{ floor.name}}</option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu zone\" [(ngModel)]=\"selectedZone\"\n (ngModelChange)=\"onZoneClick()\">\n <option [ngValue]=\"null\" selected>{{'Zone' | translate}}</option>\n <option *ngFor=\"let zone of zonesPerFloor\" [ngValue]=\"zone\"><span\n *ngIf=\"zone.code_ref\">{{zone.code_ref}}_</span>{{zone.name}}</option>\n </select>\n <select class=\"form-control flex-fill flex-lg-fill-unset select-menu\" [(ngModel)]=\"selectedChildZone\"\n *ngIf=\"childrenZones && childrenZones.length>0\" (ngModelChange)=\"onChildZoneClick()\">\n <option [ngValue]=\"null\" selected disabled>{{'Zone' | translate}}</option>\n <option *ngFor=\"let zone of childrenZones\" [ngValue]=\"zone\">{{ zone.name}}</option>\n </select>\n <button (click)=\"goTo()\"\n class=\"btn btn-outline-primary rounded-pill mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d d-none d-lg-block\"\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\n </button>\n </div>\n <div class=\"row align-items-center justify-content-center ml-0 p-1\" *ngIf=\"showLocation()\">\n <p class=\"fw-bold lh-base align-middle text-center\">\n {{ showLocation() }}\n </p>\n </div>\n <div class=\"row align-items-center justify-content-center ml-0 p-1 d-block d-lg-none\">\n <button (click)=\"goTo()\"\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center btn-3d\"\n [disabled]=\"!selectedLocation || !selectedModel3d || !canGo()\">\n <i class=\"icon-go-to-3d d-inline-block\"></i> {{'3D visit' | translate}}\n </button>\n </div>\n </div>\n </div>\n</nav>", styles: [":host{position:sticky;top:0;height:123px}.navbar{z-index:200;padding:.4rem 1rem}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0}@media (min-width: 992px){.flex-lg-fill-unset{flex:unset!important}}.form-control{width:100%;min-width:80px;max-width:18%}.location{width:auto;max-width:35%}.floor,.zone{width:auto;max-width:30%}button{color:#fff;padding:5px 10px;font-size:1.1em}.navbar-toggler{margin-right:1em}.select-menu{max-width:inherit}.btn-3d .icon-go-to-3d{width:30px;height:30px;background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0A<style type=\"text/css\">%0A%09.st0{fill:%23565656;}%0A<\\/style>%0A<g>%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0A%09%09/>%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0A</g>%0A</svg>%0A');border:none;vertical-align:middle;fill:var(--smarterplan-primary)}.btn-3d:hover:enabled .icon-go-to-3d{background:url('data:image/svg+xml,<?xml version=\"1.0\" encoding=\"utf-8\"?>%0A<!-- Generator: Adobe Illustrator 25.2.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->%0A<svg version=\"1.1\" id=\"Calque_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" x=\"0px\" y=\"0px\"%0A%09 viewBox=\"0 0 60 60\" style=\"enable-background:new 0 0 60 60;\" xml:space=\"preserve\">%0A<style type=\"text/css\">%0A%09.st0{fill:%23FFFFFF;}%0A<\\/style>%0A<g>%0A%09<path class=\"st0\" d=\"M43.9,32.1V17.7c0-0.4-0.2-0.7-0.5-0.9l-12.8-8c-0.2-0.1-0.4-0.2-0.6-0.2c-0.2,0-0.4,0.1-0.6,0.2l-12.8,8%0A%09%09c-0.3,0.2-0.5,0.5-0.5,0.9v14.4c0,0.4,0.2,0.7,0.5,0.9l12.8,8c0.3,0.2,0.8,0.2,1.1,0l12.8-8C43.7,32.8,43.9,32.5,43.9,32.1z%0A%09%09 M28.9,38.2l-10.7-6.7v-12l10.7,6.7V38.2z M30,24.4l-10.8-6.8L30,10.9l10.8,6.8L30,24.4z M41.8,31.5l-10.7,6.7v-12l10.7-6.7V31.5z\"%0A%09%09/>%0A%09<path class=\"st0\" d=\"M56.1,29.3l-10.3-6.8v2l1.4,1v0l0.8,0.5l0,0l7,4.8c0.3,0.2,0.4,0.5,0.4,0.8c0,0.3-0.2,0.6-0.4,0.8l-6.8,4.7%0A%09%09l0,0l-0.9,0.7v0l-2.9,2v0l-13.8,9.6c-0.3,0.2-0.8,0.2-1.1,0l-14.2-9.8l-2.6-1.8v0L5,32.4c-0.3-0.2-0.4-0.5-0.4-0.8%0A%09%09c0-0.3,0.2-0.6,0.4-0.8l0,0l7.3-5l0,0l0.5-0.3v0l1.4-1v-2L3.9,29.3c-0.8,0.5-1.2,1.4-1.2,2.4c0,0.9,0.5,1.8,1.2,2.4l8.8,6.1v0%0A%09%09l4.5,3.1l11.2,7.7c1,0.7,2.3,0.7,3.3,0l13.7-9.5l1.9-1.3v0l8.8-6.1c0.8-0.5,1.2-1.4,1.2-2.4S56.9,29.8,56.1,29.3z\"/>%0A</g>%0A</svg>%0A')}.btn-3d[disabled]:hover .icon-go-to-3d{fill:var(--smarterplan-primary)}.outer_circle{background-color:#ebebeb;border-radius:50%;width:32px;height:32px;padding:1px}@media (max-width: 767px){.navbar{padding:.2rem .5rem}.form-control,.location,.floor,.zone{max-width:100%;margin-bottom:15px}input,select{margin-right:0;margin-bottom:15px}}\n"] }]
17281
18280
  }], ctorParameters: () => [{ type: undefined, decorators: [{
17282
18281
  type: Inject,
17283
18282
  args: ['currentAwsKinesisAnalyticsService']
@@ -17546,15 +18545,367 @@ class MenuBarComponent {
17546
18545
  }
17547
18546
  }
17548
18547
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MenuBarComponent, deps: [{ token: i1$1.Router }, { token: LocaleService }, { token: BaseUserService }, { token: NavigatorService }, { token: SupportService }, { token: BaseVisibilityService }, { token: i1$2.NgbCalendar }, { token: i1$2.NgbDateParserFormatter }, { token: FilterService }, { token: AmplifyCacheService }], target: i0.ɵɵFactoryTarget.Component });
17549
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: MenuBarComponent, selector: "lib-menu-bar", inputs: { cacheName: "cacheName" }, ngImport: i0, template: "<div id=\"menu-bar\">\r\n <div *ngIf=\"!isVisitor\">\r\n <nav class=\"navbar navbar-expand-md navbar-light p-0\">\r\n <div class=\"container-fluid\" *ngIf=\"isVisible\">\r\n <div class=\"navbar-brand order-md-0\" [routerLink]=\"['/dashboard']\">\r\n <img alt=\"SmarterPlan\" src=\"../assets/images/logos/logo-immo.png\">\r\n </div>\r\n <div class=\"ms-auto nav-item dropdown profile_pic-container order-md-2\">\r\n <div class=\"profile_pic dropdown-toggle\" data-testid=\"dropdown-menu-button\" data-bs-toggle=\"dropdown\">\r\n <lib-avatar></lib-avatar>\r\n </div>\r\n <div class=\"dropdown-menu dropdown-menu-right\">\r\n <p *ngIf=\"user && mission\" class=\"p-2\">\r\n <span class=\"username\">{{ user.displayName }}</span><br />\r\n <span class=\"user-role\">{{ mission.role }}</span><br />\r\n <span class=\"org\">{{ mission.organisation ? mission.organisation.name : \"No organisation\" |\r\n translate}}</span>\r\n </p>\r\n <div class=\"dropdown-divider\"></div>\r\n <!-- <a class=\"dropdown-item\" routerLink=\"/debug\" *ngIf=\"isManager\">Debug</a> -->\r\n <a class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</a>\r\n <a class=\"dropdown-item\" routerLink=\"/dashboard\">{{'Data management' | translate}}</a>\r\n <a class=\"dropdown-item\" data-testid=\"user-management-btn\" routerLink=\"/admin\" *ngIf=\"isManager\">\r\n {{'Users management' | translate}}</a>\r\n <a class=\"dropdown-item\" routerLink=\"/profile\">\r\n {{'Profile' | translate}}</a>\r\n <div class=\"dropdown-divider\"></div>\r\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\r\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\r\n translate}}</button>\r\n </div>\r\n </div>\r\n <button class=\"navbar-toggler order-md-1\" (click)=\"navbarCollapse.toggle()\" type=\"button\"\r\n [attr.aria-expanded]=\"!isCollapsed\" aria-controls=\"navbar-collapse\">\r\n <span class=\"navbar-toggler-icon\"></span>\r\n </button>\r\n <div class=\"navbar-collapse order-md-1\" #navbarCollapse=\"ngbCollapse\" [(ngbCollapse)]=\"isCollapsed\"\r\n [animation]=\"false\">\r\n <form\r\n class=\"form-inline m-0 mt-2 form-container d-flex justify-content-start align-items-start align-items-md-center flex-column flex-md-row flex-nowrap filters-menu-block\">\r\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\r\n <input class=\"form-control search-button m-1\" placeholder=\"keyword\" id=\"mainSearchBar\" type=\"text\">\r\n <button class=\"btn btn-outline-primary rounded-pill m-1 w-fit-content h-fit-content align-self-md-center\"\r\n (click)=\"onSubmit()\">{{'Search' |\r\n translate}}</button>\r\n </div>\r\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\r\n <div class=\"input-group date-selector m-1\">\r\n <input #dpFromDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpFromDate\"\r\n [value]=\"fromDate | formatDateNumberToDigits\"\r\n (input)=\"fromDate = validateInput(fromDate, dpFromDate.value)\">\r\n <div class=\"input-group-append\">\r\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\r\n type=\"button\"></button>\r\n </div>\r\n </div>\r\n <input name=\"datepicker\" class=\"form-control visually-hidden\" ngbDatepicker #datepicker=\"ngbDatepicker\"\r\n [autoClose]=\"'outside'\" (dateSelect)=\"onDateSelection($event)\" [displayMonths]=\"2\" [dayTemplate]=\"t\"\r\n outsideDays=\"hidden\" [startDate]=\"fromDate || calendar.getToday()\" [container]=\"'body'\" tabindex=\"-1\">\r\n <ng-template #t let-date let-focused=\"focused\">\r\n <span class=\"custom-day\" [class.focused]=\"focused\" [class.range]=\"isRange(date)\"\r\n [class.faded]=\"isHovered(date) || isInside(date)\" (mouseenter)=\"hoveredDate = date\"\r\n (mouseleave)=\"hoveredDate = null\">\r\n {{ date.day }}\r\n </span>\r\n </ng-template>\r\n <span class=\"ms-1 p-2\">{{'to' | translate}}</span>\r\n <div class=\"input-group date-selector m-1\">\r\n <input #dpToDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpToDate\"\r\n [value]=\"toDate | formatDateNumberToDigits\" (input)=\"toDate = validateInput(toDate, dpToDate.value)\">\r\n <div class=\"input-group-append\">\r\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\r\n type=\"button\"></button>\r\n </div>\r\n </div>\r\n </div>\r\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-1 justify-content-evenly\">\r\n <button\r\n class=\"btn btn-primary rounded-pill ms-1 w-25 w-md-fit-content h-md-fit-content align-self-md-center\"\r\n type=\"button\" (click)=\"onApply()\" *ngIf=\"!isDateFilterApplied || dateChanged\">\r\n {{'Apply' | translate}}\r\n </button>\r\n <span\r\n class=\"appliedText ms-1 py-2 align-self-md-center pb-md-0 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\r\n *ngIf=\"isDateFilterApplied && !dateChanged\">\r\n <span class=\"iconify\" data-icon=\"ion:checkmark\" data-width=\"15\" data-height=\"15\"></span>\r\n {{\"Applied\" | translate}}\r\n </span>\r\n <button\r\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\r\n type=\"button\" (click)=\"onClearDates()\">{{'Clear'\r\n |\r\n translate}}</button>\r\n <div\r\n class=\"change-position-button align-self-end pb-2 align-self-md-center mx-2 pb-md-0 flex-nowrap w-25 w-md-fit-content h-md-fit-content\">\r\n <input type=\"checkbox\" class=\"btn-check\" id=\"btn-check-outlined\" autocomplete=\"off\">\r\n <label class=\"link-primary cursor-pointer text-decoration-underline\" (click)=\"onChangePositionClick()\"\r\n for=\"btn-check-outlined\">{{\r\n (isChangePositionVisible ? \"Close\" : \"Move to\") | translate }}</label>\r\n </div>\r\n </div>\r\n </form>\r\n </div>\r\n </div>\r\n </nav>\r\n <lib-navigation-bar *ngIf=\"isVisible && isChangePositionVisible\"></lib-navigation-bar>\r\n </div>\r\n <div *ngIf=\"isVisitor && isVisible\">\r\n <div class=\"visitor-div\">\r\n <div class=\"logo-container\" data-testid=\"dropdown-menu-button\">\r\n <img class=\"img-logo\" alt=\"SmarterPlanLogo\" src=\"../assets/images/logos/logo-reserve.png\">\r\n </div>\r\n <div class=\"avatar-container-menu\">\r\n <div class=\"profile_pic dropdown-toggle cursor-pointer\" data-bs-toggle=\"dropdown\">\r\n <lib-avatar></lib-avatar>\r\n </div>\r\n <div class=\"dropdown-menu dropdown-menu-right\">\r\n <button class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</button>\r\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\r\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\r\n translate}}</button>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n</div>", styles: ["#menu-bar{position:sticky;top:0;z-index:200}.navbar{z-index:200}.navbar-brand{padding-top:5px;padding-bottom:5px;cursor:pointer}.navbar-brand img{height:70px}.profile_pic{border-radius:50%;height:45px;width:45px;margin:8px;border:.2px solid rgb(150,150,150)}.navbar-nav .profile-pic-container .dropdown-menu{position:absolute}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}.profile_pic.dropdown-toggle:after{display:none}.navbar-light{border-bottom:1px solid var(--smarterplan-primary);background-color:#fff}.navbar form{width:100%;margin:0;padding:0}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0;width:auto}button{padding:5px 10px}button:disabled,button[disabled]{color:#c04f4f}.navbar-toggler{margin-right:1em}.icon{height:40px;width:40px;color:var(--smarterplan-primary)}.search-button-container{width:auto;align-items:stretch;justify-items:start}.search-button-container .search-button{width:auto;border-radius:1rem!important}.visitor-div{background-color:var(--smarterplan-primary);height:60px;display:flex;justify-content:space-between}.profile_pic-container{margin-right:1em}.logo-container,.img-logo{height:100%}.change-position-button{margin-left:2rem}.change-position-button label,.change-position-button .btn-check:checked,.change-position-button .btn-check:focus,.change-position-button .btn-outline-primary,.change-position-button .btn{box-shadow:none!important;outline:0}.change-position-button label{border-radius:16px}.change-position-button .btn-check:checked+.btn{background-color:var(--smarterplan-primary);color:#fff!important}.filters-menu-block{width:auto}@media (min-width: 1200px){.navbar-brand img{height:100px}.profile_pic{height:60px;width:60px;margin:15px}}@media (min-width: 992px) and (max-width: 1199.98px){.navbar-brand img{height:80px}.profile_pic{height:50px;width:50px;margin:12px}}@media (min-width: 768px) and (max-width: 991.98px){.navbar-brand img{height:60px}.profile_pic{height:40px;width:40px;margin:10px}}@media (max-width: 767.98px){.navbar-brand img{height:50px}.profile_pic{height:30px;width:30px;margin:8px}.search-button-container,.filters-menu-block{width:100%}}.form-container{margin:.5rem 0}.navbar-collapse.show{display:flex;padding-bottom:.5rem}.hidden{width:0;margin:0;border:none;padding:0}.custom-day{text-align:center;padding:.185rem .25rem;display:inline-block;height:2rem;width:2rem}.custom-day.focused{background-color:#e6e6e6}.custom-day.range,.custom-day:hover{background-color:var(--smarterplan-primary-lighter);color:#fff}.custom-day.faded{background-color:var(--smarterplan-primary-transparent)}.calendar{height:100%}.appliedText,.appliedText span{color:var(--smarterplan-primary)}@media (max-width: 767px){.form-inline{flex-direction:column}}.hidden{opacity:0}.date-selector{flex-wrap:nowrap}.date-selector input{min-width:80px;margin:0}@media (min-width: 768px){.w-md-fit-content{width:fit-content!important}.h-md-content{height:fit-content!important}}\n"], dependencies: [{ kind: "directive", type: i3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i3.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$1.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "directive", type: i1$2.NgbCollapse, selector: "[ngbCollapse]", inputs: ["animation", "ngbCollapse", "horizontal"], outputs: ["ngbCollapseChange", "shown", "hidden"], exportAs: ["ngbCollapse"] }, { kind: "directive", type: i1$2.NgbInputDatepicker, selector: "input[ngbDatepicker]", inputs: ["autoClose", "contentTemplate", "datepickerClass", "dayTemplate", "dayTemplateData", "displayMonths", "firstDayOfWeek", "footerTemplate", "markDisabled", "minDate", "maxDate", "navigation", "outsideDays", "placement", "popperOptions", "restoreFocus", "showWeekNumbers", "startDate", "container", "positionTarget", "weekdays", "disabled"], outputs: ["dateSelect", "navigate", "closed"], exportAs: ["ngbDatepicker"] }, { kind: "component", type: AvatarComponent, selector: "lib-avatar", inputs: ["size", "userID"] }, { kind: "component", type: NavigationBarComponent, selector: "lib-navigation-bar" }, { kind: "pipe", type: i1.TranslatePipe, name: "translate" }, { kind: "pipe", type: FormatDateNumberToDigitsPipe, name: "formatDateNumberToDigits" }] });
18548
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: MenuBarComponent, selector: "lib-menu-bar", inputs: { cacheName: "cacheName" }, ngImport: i0, template: "<div id=\"menu-bar\">\n <div *ngIf=\"!isVisitor\">\n <nav class=\"navbar navbar-expand-md navbar-light p-0\">\n <div class=\"container-fluid\" *ngIf=\"isVisible\">\n <div class=\"navbar-brand order-md-0\" [routerLink]=\"['/dashboard']\">\n <img alt=\"SmarterPlan\" src=\"../assets/images/logos/logo-immo.png\">\n </div>\n <div class=\"ms-auto nav-item dropdown profile_pic-container order-md-2\">\n <div class=\"profile_pic dropdown-toggle\" data-testid=\"dropdown-menu-button\" data-bs-toggle=\"dropdown\">\n <lib-avatar></lib-avatar>\n </div>\n <div class=\"dropdown-menu dropdown-menu-right\">\n <p *ngIf=\"user && mission\" class=\"p-2\">\n <span class=\"username\">{{ user.displayName }}</span><br />\n <span class=\"user-role\">{{ mission.role }}</span><br />\n <span class=\"org\">{{ mission.organisation ? mission.organisation.name : \"No organisation\" |\n translate}}</span>\n </p>\n <div class=\"dropdown-divider\"></div>\n <!-- <a class=\"dropdown-item\" routerLink=\"/debug\" *ngIf=\"isManager\">Debug</a> -->\n <a class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</a>\n <a class=\"dropdown-item\" routerLink=\"/dashboard\">{{'Data management' | translate}}</a>\n <a class=\"dropdown-item\" data-testid=\"user-management-btn\" routerLink=\"/admin\" *ngIf=\"isManager\">\n {{'Users management' | translate}}</a>\n <a class=\"dropdown-item\" routerLink=\"/profile\">\n {{'Profile' | translate}}</a>\n <div class=\"dropdown-divider\"></div>\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\n translate}}</button>\n </div>\n </div>\n <button class=\"navbar-toggler order-md-1\" (click)=\"navbarCollapse.toggle()\" type=\"button\"\n [attr.aria-expanded]=\"!isCollapsed\" aria-controls=\"navbar-collapse\">\n <span class=\"navbar-toggler-icon\"></span>\n </button>\n <div class=\"navbar-collapse order-md-1\" #navbarCollapse=\"ngbCollapse\" [(ngbCollapse)]=\"isCollapsed\"\n [animation]=\"false\">\n <form\n class=\"form-inline m-0 mt-2 form-container d-flex justify-content-start align-items-start align-items-md-center flex-column flex-md-row flex-nowrap filters-menu-block\">\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\n <input class=\"form-control search-button m-1\" placeholder=\"keyword\" id=\"mainSearchBar\" type=\"text\">\n <button class=\"btn btn-outline-primary rounded-pill m-1 w-fit-content h-fit-content align-self-md-center\"\n (click)=\"onSubmit()\">{{'Search' |\n translate}}</button>\n </div>\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\n <div class=\"input-group date-selector m-1\">\n <input #dpFromDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpFromDate\"\n [value]=\"fromDate | formatDateNumberToDigits\"\n (input)=\"fromDate = validateInput(fromDate, dpFromDate.value)\">\n <div class=\"input-group-append\">\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\n type=\"button\"></button>\n </div>\n </div>\n <input name=\"datepicker\" class=\"form-control visually-hidden\" ngbDatepicker #datepicker=\"ngbDatepicker\"\n [autoClose]=\"'outside'\" (dateSelect)=\"onDateSelection($event)\" [displayMonths]=\"2\" [dayTemplate]=\"t\"\n outsideDays=\"hidden\" [startDate]=\"fromDate || calendar.getToday()\" [container]=\"'body'\" tabindex=\"-1\">\n <ng-template #t let-date let-focused=\"focused\">\n <span class=\"custom-day\" [class.focused]=\"focused\" [class.range]=\"isRange(date)\"\n [class.faded]=\"isHovered(date) || isInside(date)\" (mouseenter)=\"hoveredDate = date\"\n (mouseleave)=\"hoveredDate = null\">\n {{ date.day }}\n </span>\n </ng-template>\n <span class=\"ms-1 p-2\">{{'to' | translate}}</span>\n <div class=\"input-group date-selector m-1\">\n <input #dpToDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpToDate\"\n [value]=\"toDate | formatDateNumberToDigits\" (input)=\"toDate = validateInput(toDate, dpToDate.value)\">\n <div class=\"input-group-append\">\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\n type=\"button\"></button>\n </div>\n </div>\n </div>\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-1 justify-content-evenly\">\n <button\n class=\"btn btn-primary rounded-pill ms-1 w-25 w-md-fit-content h-md-fit-content align-self-md-center\"\n type=\"button\" (click)=\"onApply()\" *ngIf=\"!isDateFilterApplied || dateChanged\">\n {{'Apply' | translate}}\n </button>\n <span\n class=\"appliedText ms-1 py-2 align-self-md-center pb-md-0 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\n *ngIf=\"isDateFilterApplied && !dateChanged\">\n <span class=\"iconify\" data-icon=\"ion:checkmark\" data-width=\"15\" data-height=\"15\"></span>\n {{\"Applied\" | translate}}\n </span>\n <button\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\n type=\"button\" (click)=\"onClearDates()\">{{'Clear'\n |\n translate}}</button>\n <div\n class=\"change-position-button align-self-end pb-2 align-self-md-center mx-2 pb-md-0 flex-nowrap w-25 w-md-fit-content h-md-fit-content\">\n <input type=\"checkbox\" class=\"btn-check\" id=\"btn-check-outlined\" autocomplete=\"off\">\n <label class=\"link-primary cursor-pointer text-decoration-underline\" (click)=\"onChangePositionClick()\"\n for=\"btn-check-outlined\">{{\n (isChangePositionVisible ? \"Close\" : \"Move to\") | translate }}</label>\n </div>\n </div>\n </form>\n </div>\n </div>\n </nav>\n <lib-navigation-bar *ngIf=\"isVisible && isChangePositionVisible\"></lib-navigation-bar>\n </div>\n <div *ngIf=\"isVisitor && isVisible\">\n <div class=\"visitor-div\">\n <div class=\"logo-container\" data-testid=\"dropdown-menu-button\">\n <img class=\"img-logo\" alt=\"SmarterPlanLogo\" src=\"../assets/images/logos/logo-reserve.png\">\n </div>\n <div class=\"avatar-container-menu\">\n <div class=\"profile_pic dropdown-toggle cursor-pointer\" data-bs-toggle=\"dropdown\">\n <lib-avatar></lib-avatar>\n </div>\n <div class=\"dropdown-menu dropdown-menu-right\">\n <button class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</button>\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\n translate}}</button>\n </div>\n </div>\n </div>\n </div>\n</div>", styles: ["#menu-bar{position:sticky;top:0;z-index:200}.navbar{z-index:200}.navbar-brand{padding-top:5px;padding-bottom:5px;cursor:pointer}.navbar-brand img{height:70px}.profile_pic{border-radius:50%;height:45px;width:45px;margin:8px;border:.2px solid rgb(150,150,150)}.navbar-nav .profile-pic-container .dropdown-menu{position:absolute}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}.profile_pic.dropdown-toggle:after{display:none}.navbar-light{border-bottom:1px solid var(--smarterplan-primary);background-color:#fff}.navbar form{width:100%;margin:0;padding:0}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0;width:auto}button{padding:5px 10px}button:disabled,button[disabled]{color:#c04f4f}.navbar-toggler{margin-right:1em}.icon{height:40px;width:40px;color:var(--smarterplan-primary)}.search-button-container{width:auto;align-items:stretch;justify-items:start}.search-button-container .search-button{width:auto;border-radius:1rem!important}.visitor-div{background-color:var(--smarterplan-primary);height:60px;display:flex;justify-content:space-between}.profile_pic-container{margin-right:1em}.logo-container,.img-logo{height:100%}.change-position-button{margin-left:2rem}.change-position-button label,.change-position-button .btn-check:checked,.change-position-button .btn-check:focus,.change-position-button .btn-outline-primary,.change-position-button .btn{box-shadow:none!important;outline:0}.change-position-button label{border-radius:16px}.change-position-button .btn-check:checked+.btn{background-color:var(--smarterplan-primary);color:#fff!important}.filters-menu-block{width:auto}@media (min-width: 1200px){.navbar-brand img{height:100px}.profile_pic{height:60px;width:60px;margin:15px}}@media (min-width: 992px) and (max-width: 1199.98px){.navbar-brand img{height:80px}.profile_pic{height:50px;width:50px;margin:12px}}@media (min-width: 768px) and (max-width: 991.98px){.navbar-brand img{height:60px}.profile_pic{height:40px;width:40px;margin:10px}}@media (max-width: 767.98px){.navbar-brand img{height:50px}.profile_pic{height:30px;width:30px;margin:8px}.search-button-container,.filters-menu-block{width:100%}}.form-container{margin:.5rem 0}.navbar-collapse.show{display:flex;padding-bottom:.5rem}.hidden{width:0;margin:0;border:none;padding:0}.custom-day{text-align:center;padding:.185rem .25rem;display:inline-block;height:2rem;width:2rem}.custom-day.focused{background-color:#e6e6e6}.custom-day.range,.custom-day:hover{background-color:var(--smarterplan-primary-lighter);color:#fff}.custom-day.faded{background-color:var(--smarterplan-primary-transparent)}.calendar{height:100%}.appliedText,.appliedText span{color:var(--smarterplan-primary)}@media (max-width: 767px){.form-inline{flex-direction:column}}.hidden{opacity:0}.date-selector{flex-wrap:nowrap}.date-selector input{min-width:80px;margin:0}@media (min-width: 768px){.w-md-fit-content{width:fit-content!important}.h-md-content{height:fit-content!important}}\n"], dependencies: [{ kind: "directive", type: i3.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i3.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i3.NgForm, selector: "form:not([ngNoForm]):not([formGroup]),ng-form,[ngForm]", inputs: ["ngFormOptions"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i11.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1$1.RouterLink, selector: "[routerLink]", inputs: ["target", "queryParams", "fragment", "queryParamsHandling", "state", "info", "relativeTo", "preserveFragment", "skipLocationChange", "replaceUrl", "routerLink"] }, { kind: "directive", type: i1$2.NgbCollapse, selector: "[ngbCollapse]", inputs: ["animation", "ngbCollapse", "horizontal"], outputs: ["ngbCollapseChange", "shown", "hidden"], exportAs: ["ngbCollapse"] }, { kind: "directive", type: i1$2.NgbInputDatepicker, selector: "input[ngbDatepicker]", inputs: ["autoClose", "contentTemplate", "datepickerClass", "dayTemplate", "dayTemplateData", "displayMonths", "firstDayOfWeek", "footerTemplate", "markDisabled", "minDate", "maxDate", "navigation", "outsideDays", "placement", "popperOptions", "restoreFocus", "showWeekNumbers", "startDate", "container", "positionTarget", "weekdays", "disabled"], outputs: ["dateSelect", "navigate", "closed"], exportAs: ["ngbDatepicker"] }, { kind: "component", type: AvatarComponent, selector: "lib-avatar", inputs: ["size", "userID"] }, { kind: "component", type: NavigationBarComponent, selector: "lib-navigation-bar" }, { kind: "pipe", type: i1.TranslatePipe, name: "translate" }, { kind: "pipe", type: FormatDateNumberToDigitsPipe, name: "formatDateNumberToDigits" }] });
17550
18549
  }
17551
18550
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MenuBarComponent, decorators: [{
17552
18551
  type: Component,
17553
- args: [{ selector: 'lib-menu-bar', template: "<div id=\"menu-bar\">\r\n <div *ngIf=\"!isVisitor\">\r\n <nav class=\"navbar navbar-expand-md navbar-light p-0\">\r\n <div class=\"container-fluid\" *ngIf=\"isVisible\">\r\n <div class=\"navbar-brand order-md-0\" [routerLink]=\"['/dashboard']\">\r\n <img alt=\"SmarterPlan\" src=\"../assets/images/logos/logo-immo.png\">\r\n </div>\r\n <div class=\"ms-auto nav-item dropdown profile_pic-container order-md-2\">\r\n <div class=\"profile_pic dropdown-toggle\" data-testid=\"dropdown-menu-button\" data-bs-toggle=\"dropdown\">\r\n <lib-avatar></lib-avatar>\r\n </div>\r\n <div class=\"dropdown-menu dropdown-menu-right\">\r\n <p *ngIf=\"user && mission\" class=\"p-2\">\r\n <span class=\"username\">{{ user.displayName }}</span><br />\r\n <span class=\"user-role\">{{ mission.role }}</span><br />\r\n <span class=\"org\">{{ mission.organisation ? mission.organisation.name : \"No organisation\" |\r\n translate}}</span>\r\n </p>\r\n <div class=\"dropdown-divider\"></div>\r\n <!-- <a class=\"dropdown-item\" routerLink=\"/debug\" *ngIf=\"isManager\">Debug</a> -->\r\n <a class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</a>\r\n <a class=\"dropdown-item\" routerLink=\"/dashboard\">{{'Data management' | translate}}</a>\r\n <a class=\"dropdown-item\" data-testid=\"user-management-btn\" routerLink=\"/admin\" *ngIf=\"isManager\">\r\n {{'Users management' | translate}}</a>\r\n <a class=\"dropdown-item\" routerLink=\"/profile\">\r\n {{'Profile' | translate}}</a>\r\n <div class=\"dropdown-divider\"></div>\r\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\r\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\r\n translate}}</button>\r\n </div>\r\n </div>\r\n <button class=\"navbar-toggler order-md-1\" (click)=\"navbarCollapse.toggle()\" type=\"button\"\r\n [attr.aria-expanded]=\"!isCollapsed\" aria-controls=\"navbar-collapse\">\r\n <span class=\"navbar-toggler-icon\"></span>\r\n </button>\r\n <div class=\"navbar-collapse order-md-1\" #navbarCollapse=\"ngbCollapse\" [(ngbCollapse)]=\"isCollapsed\"\r\n [animation]=\"false\">\r\n <form\r\n class=\"form-inline m-0 mt-2 form-container d-flex justify-content-start align-items-start align-items-md-center flex-column flex-md-row flex-nowrap filters-menu-block\">\r\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\r\n <input class=\"form-control search-button m-1\" placeholder=\"keyword\" id=\"mainSearchBar\" type=\"text\">\r\n <button class=\"btn btn-outline-primary rounded-pill m-1 w-fit-content h-fit-content align-self-md-center\"\r\n (click)=\"onSubmit()\">{{'Search' |\r\n translate}}</button>\r\n </div>\r\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\r\n <div class=\"input-group date-selector m-1\">\r\n <input #dpFromDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpFromDate\"\r\n [value]=\"fromDate | formatDateNumberToDigits\"\r\n (input)=\"fromDate = validateInput(fromDate, dpFromDate.value)\">\r\n <div class=\"input-group-append\">\r\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\r\n type=\"button\"></button>\r\n </div>\r\n </div>\r\n <input name=\"datepicker\" class=\"form-control visually-hidden\" ngbDatepicker #datepicker=\"ngbDatepicker\"\r\n [autoClose]=\"'outside'\" (dateSelect)=\"onDateSelection($event)\" [displayMonths]=\"2\" [dayTemplate]=\"t\"\r\n outsideDays=\"hidden\" [startDate]=\"fromDate || calendar.getToday()\" [container]=\"'body'\" tabindex=\"-1\">\r\n <ng-template #t let-date let-focused=\"focused\">\r\n <span class=\"custom-day\" [class.focused]=\"focused\" [class.range]=\"isRange(date)\"\r\n [class.faded]=\"isHovered(date) || isInside(date)\" (mouseenter)=\"hoveredDate = date\"\r\n (mouseleave)=\"hoveredDate = null\">\r\n {{ date.day }}\r\n </span>\r\n </ng-template>\r\n <span class=\"ms-1 p-2\">{{'to' | translate}}</span>\r\n <div class=\"input-group date-selector m-1\">\r\n <input #dpToDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpToDate\"\r\n [value]=\"toDate | formatDateNumberToDigits\" (input)=\"toDate = validateInput(toDate, dpToDate.value)\">\r\n <div class=\"input-group-append\">\r\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\r\n type=\"button\"></button>\r\n </div>\r\n </div>\r\n </div>\r\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-1 justify-content-evenly\">\r\n <button\r\n class=\"btn btn-primary rounded-pill ms-1 w-25 w-md-fit-content h-md-fit-content align-self-md-center\"\r\n type=\"button\" (click)=\"onApply()\" *ngIf=\"!isDateFilterApplied || dateChanged\">\r\n {{'Apply' | translate}}\r\n </button>\r\n <span\r\n class=\"appliedText ms-1 py-2 align-self-md-center pb-md-0 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\r\n *ngIf=\"isDateFilterApplied && !dateChanged\">\r\n <span class=\"iconify\" data-icon=\"ion:checkmark\" data-width=\"15\" data-height=\"15\"></span>\r\n {{\"Applied\" | translate}}\r\n </span>\r\n <button\r\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\r\n type=\"button\" (click)=\"onClearDates()\">{{'Clear'\r\n |\r\n translate}}</button>\r\n <div\r\n class=\"change-position-button align-self-end pb-2 align-self-md-center mx-2 pb-md-0 flex-nowrap w-25 w-md-fit-content h-md-fit-content\">\r\n <input type=\"checkbox\" class=\"btn-check\" id=\"btn-check-outlined\" autocomplete=\"off\">\r\n <label class=\"link-primary cursor-pointer text-decoration-underline\" (click)=\"onChangePositionClick()\"\r\n for=\"btn-check-outlined\">{{\r\n (isChangePositionVisible ? \"Close\" : \"Move to\") | translate }}</label>\r\n </div>\r\n </div>\r\n </form>\r\n </div>\r\n </div>\r\n </nav>\r\n <lib-navigation-bar *ngIf=\"isVisible && isChangePositionVisible\"></lib-navigation-bar>\r\n </div>\r\n <div *ngIf=\"isVisitor && isVisible\">\r\n <div class=\"visitor-div\">\r\n <div class=\"logo-container\" data-testid=\"dropdown-menu-button\">\r\n <img class=\"img-logo\" alt=\"SmarterPlanLogo\" src=\"../assets/images/logos/logo-reserve.png\">\r\n </div>\r\n <div class=\"avatar-container-menu\">\r\n <div class=\"profile_pic dropdown-toggle cursor-pointer\" data-bs-toggle=\"dropdown\">\r\n <lib-avatar></lib-avatar>\r\n </div>\r\n <div class=\"dropdown-menu dropdown-menu-right\">\r\n <button class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</button>\r\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\r\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\r\n translate}}</button>\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n</div>", styles: ["#menu-bar{position:sticky;top:0;z-index:200}.navbar{z-index:200}.navbar-brand{padding-top:5px;padding-bottom:5px;cursor:pointer}.navbar-brand img{height:70px}.profile_pic{border-radius:50%;height:45px;width:45px;margin:8px;border:.2px solid rgb(150,150,150)}.navbar-nav .profile-pic-container .dropdown-menu{position:absolute}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}.profile_pic.dropdown-toggle:after{display:none}.navbar-light{border-bottom:1px solid var(--smarterplan-primary);background-color:#fff}.navbar form{width:100%;margin:0;padding:0}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0;width:auto}button{padding:5px 10px}button:disabled,button[disabled]{color:#c04f4f}.navbar-toggler{margin-right:1em}.icon{height:40px;width:40px;color:var(--smarterplan-primary)}.search-button-container{width:auto;align-items:stretch;justify-items:start}.search-button-container .search-button{width:auto;border-radius:1rem!important}.visitor-div{background-color:var(--smarterplan-primary);height:60px;display:flex;justify-content:space-between}.profile_pic-container{margin-right:1em}.logo-container,.img-logo{height:100%}.change-position-button{margin-left:2rem}.change-position-button label,.change-position-button .btn-check:checked,.change-position-button .btn-check:focus,.change-position-button .btn-outline-primary,.change-position-button .btn{box-shadow:none!important;outline:0}.change-position-button label{border-radius:16px}.change-position-button .btn-check:checked+.btn{background-color:var(--smarterplan-primary);color:#fff!important}.filters-menu-block{width:auto}@media (min-width: 1200px){.navbar-brand img{height:100px}.profile_pic{height:60px;width:60px;margin:15px}}@media (min-width: 992px) and (max-width: 1199.98px){.navbar-brand img{height:80px}.profile_pic{height:50px;width:50px;margin:12px}}@media (min-width: 768px) and (max-width: 991.98px){.navbar-brand img{height:60px}.profile_pic{height:40px;width:40px;margin:10px}}@media (max-width: 767.98px){.navbar-brand img{height:50px}.profile_pic{height:30px;width:30px;margin:8px}.search-button-container,.filters-menu-block{width:100%}}.form-container{margin:.5rem 0}.navbar-collapse.show{display:flex;padding-bottom:.5rem}.hidden{width:0;margin:0;border:none;padding:0}.custom-day{text-align:center;padding:.185rem .25rem;display:inline-block;height:2rem;width:2rem}.custom-day.focused{background-color:#e6e6e6}.custom-day.range,.custom-day:hover{background-color:var(--smarterplan-primary-lighter);color:#fff}.custom-day.faded{background-color:var(--smarterplan-primary-transparent)}.calendar{height:100%}.appliedText,.appliedText span{color:var(--smarterplan-primary)}@media (max-width: 767px){.form-inline{flex-direction:column}}.hidden{opacity:0}.date-selector{flex-wrap:nowrap}.date-selector input{min-width:80px;margin:0}@media (min-width: 768px){.w-md-fit-content{width:fit-content!important}.h-md-content{height:fit-content!important}}\n"] }]
18552
+ args: [{ selector: 'lib-menu-bar', template: "<div id=\"menu-bar\">\n <div *ngIf=\"!isVisitor\">\n <nav class=\"navbar navbar-expand-md navbar-light p-0\">\n <div class=\"container-fluid\" *ngIf=\"isVisible\">\n <div class=\"navbar-brand order-md-0\" [routerLink]=\"['/dashboard']\">\n <img alt=\"SmarterPlan\" src=\"../assets/images/logos/logo-immo.png\">\n </div>\n <div class=\"ms-auto nav-item dropdown profile_pic-container order-md-2\">\n <div class=\"profile_pic dropdown-toggle\" data-testid=\"dropdown-menu-button\" data-bs-toggle=\"dropdown\">\n <lib-avatar></lib-avatar>\n </div>\n <div class=\"dropdown-menu dropdown-menu-right\">\n <p *ngIf=\"user && mission\" class=\"p-2\">\n <span class=\"username\">{{ user.displayName }}</span><br />\n <span class=\"user-role\">{{ mission.role }}</span><br />\n <span class=\"org\">{{ mission.organisation ? mission.organisation.name : \"No organisation\" |\n translate}}</span>\n </p>\n <div class=\"dropdown-divider\"></div>\n <!-- <a class=\"dropdown-item\" routerLink=\"/debug\" *ngIf=\"isManager\">Debug</a> -->\n <a class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</a>\n <a class=\"dropdown-item\" routerLink=\"/dashboard\">{{'Data management' | translate}}</a>\n <a class=\"dropdown-item\" data-testid=\"user-management-btn\" routerLink=\"/admin\" *ngIf=\"isManager\">\n {{'Users management' | translate}}</a>\n <a class=\"dropdown-item\" routerLink=\"/profile\">\n {{'Profile' | translate}}</a>\n <div class=\"dropdown-divider\"></div>\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\n translate}}</button>\n </div>\n </div>\n <button class=\"navbar-toggler order-md-1\" (click)=\"navbarCollapse.toggle()\" type=\"button\"\n [attr.aria-expanded]=\"!isCollapsed\" aria-controls=\"navbar-collapse\">\n <span class=\"navbar-toggler-icon\"></span>\n </button>\n <div class=\"navbar-collapse order-md-1\" #navbarCollapse=\"ngbCollapse\" [(ngbCollapse)]=\"isCollapsed\"\n [animation]=\"false\">\n <form\n class=\"form-inline m-0 mt-2 form-container d-flex justify-content-start align-items-start align-items-md-center flex-column flex-md-row flex-nowrap filters-menu-block\">\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\n <input class=\"form-control search-button m-1\" placeholder=\"keyword\" id=\"mainSearchBar\" type=\"text\">\n <button class=\"btn btn-outline-primary rounded-pill m-1 w-fit-content h-fit-content align-self-md-center\"\n (click)=\"onSubmit()\">{{'Search' |\n translate}}</button>\n </div>\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-0\">\n <div class=\"input-group date-selector m-1\">\n <input #dpFromDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpFromDate\"\n [value]=\"fromDate | formatDateNumberToDigits\"\n (input)=\"fromDate = validateInput(fromDate, dpFromDate.value)\">\n <div class=\"input-group-append\">\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\n type=\"button\"></button>\n </div>\n </div>\n <input name=\"datepicker\" class=\"form-control visually-hidden\" ngbDatepicker #datepicker=\"ngbDatepicker\"\n [autoClose]=\"'outside'\" (dateSelect)=\"onDateSelection($event)\" [displayMonths]=\"2\" [dayTemplate]=\"t\"\n outsideDays=\"hidden\" [startDate]=\"fromDate || calendar.getToday()\" [container]=\"'body'\" tabindex=\"-1\">\n <ng-template #t let-date let-focused=\"focused\">\n <span class=\"custom-day\" [class.focused]=\"focused\" [class.range]=\"isRange(date)\"\n [class.faded]=\"isHovered(date) || isInside(date)\" (mouseenter)=\"hoveredDate = date\"\n (mouseleave)=\"hoveredDate = null\">\n {{ date.day }}\n </span>\n </ng-template>\n <span class=\"ms-1 p-2\">{{'to' | translate}}</span>\n <div class=\"input-group date-selector m-1\">\n <input #dpToDate class=\"form-control\" placeholder=\"dd/MM/yyyy\" name=\"dpToDate\"\n [value]=\"toDate | formatDateNumberToDigits\" (input)=\"toDate = validateInput(toDate, dpToDate.value)\">\n <div class=\"input-group-append\">\n <button class=\"btn btn-outline-secondary calendar\" (click)=\"datepicker.toggle()\"\n type=\"button\"></button>\n </div>\n </div>\n </div>\n <div class=\"input-group search-button-container d-flex flex-row flex-nowrap p-0 m-1 justify-content-evenly\">\n <button\n class=\"btn btn-primary rounded-pill ms-1 w-25 w-md-fit-content h-md-fit-content align-self-md-center\"\n type=\"button\" (click)=\"onApply()\" *ngIf=\"!isDateFilterApplied || dateChanged\">\n {{'Apply' | translate}}\n </button>\n <span\n class=\"appliedText ms-1 py-2 align-self-md-center pb-md-0 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\n *ngIf=\"isDateFilterApplied && !dateChanged\">\n <span class=\"iconify\" data-icon=\"ion:checkmark\" data-width=\"15\" data-height=\"15\"></span>\n {{\"Applied\" | translate}}\n </span>\n <button\n class=\"btn btn-outline-primary rounded-pill w-25 mx-2 w-md-fit-content h-md-fit-content align-self-md-center\"\n type=\"button\" (click)=\"onClearDates()\">{{'Clear'\n |\n translate}}</button>\n <div\n class=\"change-position-button align-self-end pb-2 align-self-md-center mx-2 pb-md-0 flex-nowrap w-25 w-md-fit-content h-md-fit-content\">\n <input type=\"checkbox\" class=\"btn-check\" id=\"btn-check-outlined\" autocomplete=\"off\">\n <label class=\"link-primary cursor-pointer text-decoration-underline\" (click)=\"onChangePositionClick()\"\n for=\"btn-check-outlined\">{{\n (isChangePositionVisible ? \"Close\" : \"Move to\") | translate }}</label>\n </div>\n </div>\n </form>\n </div>\n </div>\n </nav>\n <lib-navigation-bar *ngIf=\"isVisible && isChangePositionVisible\"></lib-navigation-bar>\n </div>\n <div *ngIf=\"isVisitor && isVisible\">\n <div class=\"visitor-div\">\n <div class=\"logo-container\" data-testid=\"dropdown-menu-button\">\n <img class=\"img-logo\" alt=\"SmarterPlanLogo\" src=\"../assets/images/logos/logo-reserve.png\">\n </div>\n <div class=\"avatar-container-menu\">\n <div class=\"profile_pic dropdown-toggle cursor-pointer\" data-bs-toggle=\"dropdown\">\n <lib-avatar></lib-avatar>\n </div>\n <div class=\"dropdown-menu dropdown-menu-right\">\n <button class=\"dropdown-item\" (click)=\"localeService.modal()\">{{'Language' | translate}}</button>\n <button class=\"dropdown-item\" (click)=\"openSupportForm()\">{{\"Help\" | translate}}</button>\n <button data-testid=\"logout-auth-btn\" class=\"dropdown-item\" (click)=\"signOut()\">{{\"Logout\" |\n translate}}</button>\n </div>\n </div>\n </div>\n </div>\n</div>", styles: ["#menu-bar{position:sticky;top:0;z-index:200}.navbar{z-index:200}.navbar-brand{padding-top:5px;padding-bottom:5px;cursor:pointer}.navbar-brand img{height:70px}.profile_pic{border-radius:50%;height:45px;width:45px;margin:8px;border:.2px solid rgb(150,150,150)}.navbar-nav .profile-pic-container .dropdown-menu{position:absolute}.navbar.second-nav{z-index:199;background-color:var(--smarterplan-primary)}.profile_pic.dropdown-toggle:after{display:none}.navbar-light{border-bottom:1px solid var(--smarterplan-primary);background-color:#fff}.navbar form{width:100%;margin:0;padding:0}input,select{margin-right:15px}.select{background:transparent;border:none;color:#fff;margin:6px 20px 0;width:auto}button{padding:5px 10px}button:disabled,button[disabled]{color:#c04f4f}.navbar-toggler{margin-right:1em}.icon{height:40px;width:40px;color:var(--smarterplan-primary)}.search-button-container{width:auto;align-items:stretch;justify-items:start}.search-button-container .search-button{width:auto;border-radius:1rem!important}.visitor-div{background-color:var(--smarterplan-primary);height:60px;display:flex;justify-content:space-between}.profile_pic-container{margin-right:1em}.logo-container,.img-logo{height:100%}.change-position-button{margin-left:2rem}.change-position-button label,.change-position-button .btn-check:checked,.change-position-button .btn-check:focus,.change-position-button .btn-outline-primary,.change-position-button .btn{box-shadow:none!important;outline:0}.change-position-button label{border-radius:16px}.change-position-button .btn-check:checked+.btn{background-color:var(--smarterplan-primary);color:#fff!important}.filters-menu-block{width:auto}@media (min-width: 1200px){.navbar-brand img{height:100px}.profile_pic{height:60px;width:60px;margin:15px}}@media (min-width: 992px) and (max-width: 1199.98px){.navbar-brand img{height:80px}.profile_pic{height:50px;width:50px;margin:12px}}@media (min-width: 768px) and (max-width: 991.98px){.navbar-brand img{height:60px}.profile_pic{height:40px;width:40px;margin:10px}}@media (max-width: 767.98px){.navbar-brand img{height:50px}.profile_pic{height:30px;width:30px;margin:8px}.search-button-container,.filters-menu-block{width:100%}}.form-container{margin:.5rem 0}.navbar-collapse.show{display:flex;padding-bottom:.5rem}.hidden{width:0;margin:0;border:none;padding:0}.custom-day{text-align:center;padding:.185rem .25rem;display:inline-block;height:2rem;width:2rem}.custom-day.focused{background-color:#e6e6e6}.custom-day.range,.custom-day:hover{background-color:var(--smarterplan-primary-lighter);color:#fff}.custom-day.faded{background-color:var(--smarterplan-primary-transparent)}.calendar{height:100%}.appliedText,.appliedText span{color:var(--smarterplan-primary)}@media (max-width: 767px){.form-inline{flex-direction:column}}.hidden{opacity:0}.date-selector{flex-wrap:nowrap}.date-selector input{min-width:80px;margin:0}@media (min-width: 768px){.w-md-fit-content{width:fit-content!important}.h-md-content{height:fit-content!important}}\n"] }]
17554
18553
  }], ctorParameters: () => [{ type: i1$1.Router }, { type: LocaleService }, { type: BaseUserService }, { type: NavigatorService }, { type: SupportService }, { type: BaseVisibilityService }, { type: i1$2.NgbCalendar }, { type: i1$2.NgbDateParserFormatter }, { type: FilterService }, { type: AmplifyCacheService }], propDecorators: { cacheName: [{
17555
18554
  type: Input
17556
18555
  }] } });
17557
18556
 
18557
+ /**
18558
+ * Gaussian splats, drawn by SuperSplat's viewer (PlayCanvas) in an iframe, as Matterport's showcase
18559
+ * is: the page built from projects/splat-viewer, which each app serves from assets/viewers/splat.
18560
+ *
18561
+ * The visit's Three.js scene stays underneath, unseen, holding the tags, their calibration and the
18562
+ * measurement, as it does for a model it draws itself; this hands the viewer what the scene holds,
18563
+ * and turns what is clicked in it into what a click in the scene does - through ViewerService, so
18564
+ * that whatever drives the scene (the add buttons, the calibration panel, the measuring tool, the
18565
+ * tag lists) drives this as well. Tags clicked come out of ViewerService.threeJsTagClicked$, as
18566
+ * clicks in the scene do.
18567
+ *
18568
+ * What it says of loading is for the app's own loading overlay; see the outputs.
18569
+ */
18570
+ class SplatViewerComponent {
18571
+ viewerService;
18572
+ host;
18573
+ sanitizer;
18574
+ translate;
18575
+ /** The model: the S3 key - or assets/ path - of a splat file, or of a Streamed SOG's lod-meta.json. */
18576
+ modelKey = null;
18577
+ /** Which of the model's axes is up, from its capture's placement; null for the viewer's own idea. */
18578
+ up = null;
18579
+ /** How far loading has got, from 0 to 1, or null when the size is not known. 0 when it starts. */
18580
+ loadProgress = new EventEmitter();
18581
+ loaded = new EventEmitter();
18582
+ loadFailed = new EventEmitter();
18583
+ /** Said whenever the mode changes, the viewer's own buttons included. */
18584
+ modeChange = new EventEmitter();
18585
+ frame;
18586
+ /**
18587
+ * The viewer's page, made once, in ngOnInit: a new object, even for the same address, would make
18588
+ * Angular set the iframe's src again, which loads the viewer afresh with nothing in it.
18589
+ */
18590
+ page;
18591
+ /** Whether the page has said it is ready; it says so again each time it starts over. */
18592
+ ready = false;
18593
+ /** The model handed to the page, so the same one is not downloaded twice. */
18594
+ loadedKey = null;
18595
+ /** Set by cancelLoad: the page, starting over empty, waits to be asked again (reload). */
18596
+ cancelled = false;
18597
+ /** Whether the page was last asked to take a click for a calibration. */
18598
+ pickingForCalibration = false;
18599
+ /** The calibration under way, as ViewerService last said. */
18600
+ alignment = null;
18601
+ /** Each segment's length of the measurement on the model, as ViewerService last said. */
18602
+ measureDistances = [];
18603
+ /** The last lod-meta.json handed to the page, as a link of this page's own. */
18604
+ streamedMeta = null;
18605
+ /** Pictures asked of the page, by the number they were asked with, until it answers. */
18606
+ snapshots = new Map();
18607
+ snapshotCount = 0;
18608
+ destroyed$ = new Subject();
18609
+ constructor(viewerService, host, sanitizer, translate) {
18610
+ this.viewerService = viewerService;
18611
+ this.host = host;
18612
+ this.sanitizer = sanitizer;
18613
+ this.translate = translate;
18614
+ }
18615
+ ngOnInit() {
18616
+ // In the app's language, which the viewer's controls speak, and in its colours, as this element's
18617
+ // styles take them from the app's - by now in the page, where they reach it.
18618
+ const style = getComputedStyle(this.host.nativeElement);
18619
+ const query = new URLSearchParams({
18620
+ lang: this.translate.currentLang || 'en',
18621
+ accent: style.color,
18622
+ background: style.backgroundColor,
18623
+ });
18624
+ this.page = this.sanitizer.bypassSecurityTrustResourceUrl(`assets/viewers/splat/index.html?${query}`);
18625
+ // The Three.js scene underneath has nothing to draw while this covers it, and the splat viewer
18626
+ // needs all the graphics card there is.
18627
+ this.viewerService.setThreeJsRenderingPaused(true);
18628
+ const on = (source, next) => source.pipe(takeUntil(this.destroyed$)).subscribe(next);
18629
+ on(this.viewerService.threeJsTagsChanged$, () => this.postTags());
18630
+ // Placing a tag starts from the add buttons, the same in every viewer; the page picks the spot
18631
+ // and says where ('picked').
18632
+ on(this.viewerService.threeJsPlacementChanged$, (placing) => this.post({ type: 'placing', on: placing }));
18633
+ on(this.viewerService.threeJsAlignmentProgress$, (progress) => {
18634
+ this.alignment = progress;
18635
+ this.postCalibration();
18636
+ });
18637
+ on(this.viewerService.threeJsAlignmentCancelled$, () => {
18638
+ this.alignment = null;
18639
+ this.postCalibration();
18640
+ });
18641
+ on(this.viewerService.threeJsMeasuringChanged$, (measuring) => this.post({ type: 'measuring', on: measuring }));
18642
+ on(this.viewerService.threeJsMeasurementProgress$, (progress) => {
18643
+ this.measureDistances = progress?.distances ?? [];
18644
+ this.postMeasurement(!!progress);
18645
+ });
18646
+ // A finished measurement stays drawn while its form is open; this is when it goes.
18647
+ on(this.viewerService.threeJsMeasurementCleared$, () => {
18648
+ this.measureDistances = [];
18649
+ this.postMeasurement(false);
18650
+ });
18651
+ // The camera sent to a tag, from a list: the page points it out.
18652
+ on(this.viewerService.threeJsTagFocused$, (id) => this.post({ type: 'goToTag', id }));
18653
+ }
18654
+ ngOnChanges(changes) {
18655
+ if (changes['modelKey'] && this.ready && !this.cancelled) {
18656
+ void this.load();
18657
+ }
18658
+ }
18659
+ ngOnDestroy() {
18660
+ this.destroyed$.next();
18661
+ this.destroyed$.complete();
18662
+ this.viewerService.setThreeJsRenderingPaused(false);
18663
+ if (this.streamedMeta) {
18664
+ URL.revokeObjectURL(this.streamedMeta);
18665
+ }
18666
+ this.snapshots.forEach((resolve) => resolve(null));
18667
+ this.snapshots.clear();
18668
+ }
18669
+ /** Stops the model loading; nothing is loaded again until reload(). */
18670
+ cancelLoad() {
18671
+ this.cancelled = true;
18672
+ this.loadedKey = null;
18673
+ this.post({ type: 'cancel-load' });
18674
+ }
18675
+ /** Loads the model again, after cancelLoad. */
18676
+ reload() {
18677
+ this.cancelled = false;
18678
+ this.loadedKey = null;
18679
+ if (this.ready) {
18680
+ void this.load();
18681
+ }
18682
+ }
18683
+ setMode(mode) {
18684
+ this.post({ type: 'mode', mode });
18685
+ }
18686
+ /**
18687
+ * The scene as it is on screen, with the measurement drawn on it and no tags, as a JPEG data URL;
18688
+ * null when there is no scene to take, or the page does not answer within ten seconds.
18689
+ */
18690
+ snapshot() {
18691
+ const id = ++this.snapshotCount;
18692
+ return new Promise((resolve) => {
18693
+ this.snapshots.set(id, resolve);
18694
+ this.post({ type: 'snapshot', id });
18695
+ setTimeout(() => {
18696
+ if (this.snapshots.delete(id)) {
18697
+ resolve(null);
18698
+ }
18699
+ }, 10000);
18700
+ });
18701
+ }
18702
+ /** What the page says: that it is ready, how loading goes, what was clicked in it. */
18703
+ onMessage(event) {
18704
+ // Only this component's page, served from this same origin.
18705
+ if (event.source !== this.frame?.nativeElement.contentWindow || event.origin !== window.location.origin) {
18706
+ return;
18707
+ }
18708
+ const message = event.data;
18709
+ const aligning = this.viewerService.isThreeJsAligningTags();
18710
+ switch (message?.type) {
18711
+ case 'ready':
18712
+ // A new page, with nothing loaded or armed in it: the first, or the viewer starting over
18713
+ // for the next model or after a cancelled load.
18714
+ this.ready = true;
18715
+ this.loadedKey = null;
18716
+ this.pickingForCalibration = false;
18717
+ if (!this.cancelled) {
18718
+ void this.load();
18719
+ }
18720
+ this.postTags();
18721
+ this.postCalibration();
18722
+ this.post({ type: 'measuring', on: this.viewerService.isThreeJsMeasuring() });
18723
+ this.postMeasurement(this.viewerService.getThreeJsMeasurePointsInModelFrame().length > 0);
18724
+ break;
18725
+ case 'progress':
18726
+ this.loadProgress.emit(message.fraction ?? null);
18727
+ break;
18728
+ case 'loaded':
18729
+ this.loaded.emit();
18730
+ break;
18731
+ case 'error':
18732
+ console.error('Splat viewer:', message.message);
18733
+ this.loadFailed.emit(String(message.message ?? ''));
18734
+ break;
18735
+ case 'mode':
18736
+ this.modeChange.emit(message.mode);
18737
+ break;
18738
+ case 'picked':
18739
+ if (aligning && this.alignment?.awaiting === 'point') {
18740
+ // Where the tag being matched really is, as a click on the model in the scene would say.
18741
+ if (!this.viewerService.matchThreeJsAlignmentTagAtModelPoint(message.position)) {
18742
+ this.viewerService.ignoreThreeJsAlignmentClick('point');
18743
+ }
18744
+ break;
18745
+ }
18746
+ // As a click on the model in the scene: the app opens the creation form from threeJsTagPlaced$.
18747
+ this.viewerService.placeThreeJsTagAtModelPoint(message.position, message.normal ?? null);
18748
+ break;
18749
+ case 'placing-cancelled':
18750
+ // Escape, as in the scene: it ends a measurement or a calibration as it ends placing a tag.
18751
+ if (this.viewerService.isThreeJsMeasuring()) {
18752
+ this.viewerService.action_toolbox_cancel_mesure();
18753
+ }
18754
+ else if (aligning) {
18755
+ this.viewerService.cancelThreeJsTagAlignment();
18756
+ }
18757
+ else {
18758
+ this.viewerService.cancelThreeJsTagPlacement();
18759
+ }
18760
+ break;
18761
+ case 'calibration-undo':
18762
+ // Ctrl+Z or Backspace in the page, as in the scene.
18763
+ this.viewerService.undoThreeJsAlignmentStep();
18764
+ break;
18765
+ case 'measure-point':
18766
+ this.viewerService.addThreeJsMeasurePointAtModelPoint(message.position);
18767
+ break;
18768
+ case 'tagClick':
18769
+ if (aligning) {
18770
+ // Calibrating: a tag clicked is the one to match, as in the scene, and opens nothing.
18771
+ if (this.alignment?.awaiting !== 'tag' || !this.viewerService.selectThreeJsAlignmentTag(message.id)) {
18772
+ this.viewerService.ignoreThreeJsAlignmentClick('tag');
18773
+ }
18774
+ break;
18775
+ }
18776
+ this.viewerService.clickThreeJsTag(message.id);
18777
+ break;
18778
+ case 'snapshot':
18779
+ this.snapshots.get(message.id)?.(message.image ?? null);
18780
+ this.snapshots.delete(message.id);
18781
+ break;
18782
+ case 'sign': {
18783
+ // A piece of the Streamed SOG open, by its name beside lod-meta.json.
18784
+ const key = this.loadedKey;
18785
+ const folder = key ? key.slice(0, key.lastIndexOf('/') + 1) : '';
18786
+ const file = String(message.file ?? '');
18787
+ const allowed = key && isStreamedSplatFile(key) && /^[\w.-]+$/.test(file);
18788
+ (allowed ? getSignedFile(folder + file) : Promise.resolve(null)).then((url) => {
18789
+ this.post({ type: 'signed', id: message.id, url });
18790
+ });
18791
+ break;
18792
+ }
18793
+ default:
18794
+ break;
18795
+ }
18796
+ }
18797
+ async load() {
18798
+ const key = this.modelKey;
18799
+ if (!key || key === this.loadedKey) {
18800
+ return;
18801
+ }
18802
+ this.loadedKey = key;
18803
+ this.loadProgress.emit(0);
18804
+ let url = key.startsWith('assets/') ? key : await getSignedFile(key);
18805
+ if (url && isStreamedSplatFile(key)) {
18806
+ url = await this.streamedEntry(url);
18807
+ }
18808
+ if (this.loadedKey !== key) {
18809
+ // Another model was asked for, or the load cancelled, while the link was being signed.
18810
+ return;
18811
+ }
18812
+ this.post({ type: 'load', url, fileName: key.split('/').pop(), up: this.up });
18813
+ }
18814
+ /**
18815
+ * The lod-meta.json of a Streamed SOG, with its pieces named so that the page asks for each of
18816
+ * them when it needs it ('sign').
18817
+ *
18818
+ * The pieces sit beside lod-meta.json in S3 and are named relative to it, which a signed link -
18819
+ * its signature in the query - does not survive. And they are fetched while the visit lasts, as the
18820
+ * camera reaches them, long after any link signed up front has run out.
18821
+ */
18822
+ async streamedEntry(signedMeta) {
18823
+ const meta = await (await fetch(signedMeta)).json();
18824
+ meta.filenames = (meta.filenames ?? []).map((name) => `https://splat.invalid/${name}`);
18825
+ if (this.streamedMeta) {
18826
+ URL.revokeObjectURL(this.streamedMeta);
18827
+ }
18828
+ this.streamedMeta = URL.createObjectURL(new Blob([JSON.stringify(meta)], { type: 'application/json' }));
18829
+ return this.streamedMeta;
18830
+ }
18831
+ postTags() {
18832
+ const { tags, alignment } = this.viewerService.getThreeJsTagSnapshot();
18833
+ // Tags placed elsewhere - in Matterport, in another model - are in that place's coordinates,
18834
+ // and only a calibration says where they are in this one. Without it they float off the model
18835
+ // and seem to slide as the camera moves: they are left out until it is calibrated. A
18836
+ // calibration under way, or its panel open, shows them all: they are what it matches.
18837
+ const all = this.viewerService.isThreeJsCalibrated() || this.viewerService.isThreeJsShowingCalibrationTags();
18838
+ const shown = all ? tags : tags.filter((tag) => tag.inModelFrame);
18839
+ this.post({
18840
+ type: 'tags',
18841
+ tags: shown.map(({ id, position, label, iconUrl, inModelFrame, normal }) => ({ id, position, label, iconUrl, inModelFrame, normal })),
18842
+ alignment,
18843
+ });
18844
+ }
18845
+ /**
18846
+ * The calibration under way, for the page to draw - the pins dropped, the tag being matched - and,
18847
+ * while the place of a tag is asked for, to take the click on the model that says where ('picked').
18848
+ */
18849
+ postCalibration() {
18850
+ const on = this.viewerService.isThreeJsAligningTags() && !!this.alignment;
18851
+ const awaiting = on ? this.alignment.awaiting : null;
18852
+ this.post({
18853
+ type: 'calibration',
18854
+ calibration: on
18855
+ ? {
18856
+ selected: awaiting === 'point' ? this.alignment.tagId ?? null : null,
18857
+ pairs: this.alignment.matched.map(({ tagId, target }) => ({ tagId, target })),
18858
+ }
18859
+ : null,
18860
+ });
18861
+ const picking = awaiting === 'point';
18862
+ // Only when it changes: placing a tag uses the same click, and is not to be switched off here.
18863
+ if (picking !== this.pickingForCalibration) {
18864
+ this.pickingForCalibration = picking;
18865
+ this.post({ type: 'placing', on: picking });
18866
+ }
18867
+ }
18868
+ /** The measurement for the page to draw - its points, each segment's length - or nothing. */
18869
+ postMeasurement(drawn) {
18870
+ this.post({
18871
+ type: 'measurement',
18872
+ measurement: drawn
18873
+ ? { points: this.viewerService.getThreeJsMeasurePointsInModelFrame(), distances: this.measureDistances }
18874
+ : null,
18875
+ });
18876
+ }
18877
+ post(command) {
18878
+ const page = this.frame?.nativeElement.contentWindow;
18879
+ if (page && this.ready) {
18880
+ page.postMessage(command, window.location.origin);
18881
+ }
18882
+ }
18883
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SplatViewerComponent, deps: [{ token: ViewerService }, { token: i0.ElementRef }, { token: i1$3.DomSanitizer }, { token: i1.TranslateService }], target: i0.ɵɵFactoryTarget.Component });
18884
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SplatViewerComponent, selector: "lib-splat-viewer", inputs: { modelKey: "modelKey", up: "up" }, outputs: { loadProgress: "loadProgress", loaded: "loaded", loadFailed: "loadFailed", modeChange: "modeChange" }, host: { listeners: { "window:message": "onMessage($event)" } }, viewQueries: [{ propertyName: "frame", first: true, predicate: ["frame"], descendants: true, static: true }], usesOnChanges: true, ngImport: i0, template: '<iframe #frame [src]="page" title="3D" allow="fullscreen; xr-spatial-tracking" allowfullscreen></iframe>', isInline: true, styles: [":host{display:block;color:var(--smarterplan-primary, rgb(83, 182, 187));background-color:var(--smarterplan-viewer-background, #e0f7ff)}iframe{display:block;width:100%;height:100%;border:0}\n"] });
18885
+ }
18886
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SplatViewerComponent, decorators: [{
18887
+ type: Component,
18888
+ args: [{ selector: 'lib-splat-viewer', template: '<iframe #frame [src]="page" title="3D" allow="fullscreen; xr-spatial-tracking" allowfullscreen></iframe>', styles: [":host{display:block;color:var(--smarterplan-primary, rgb(83, 182, 187));background-color:var(--smarterplan-viewer-background, #e0f7ff)}iframe{display:block;width:100%;height:100%;border:0}\n"] }]
18889
+ }], ctorParameters: () => [{ type: ViewerService }, { type: i0.ElementRef }, { type: i1$3.DomSanitizer }, { type: i1.TranslateService }], propDecorators: { modelKey: [{
18890
+ type: Input
18891
+ }], up: [{
18892
+ type: Input
18893
+ }], loadProgress: [{
18894
+ type: Output
18895
+ }], loaded: [{
18896
+ type: Output
18897
+ }], loadFailed: [{
18898
+ type: Output
18899
+ }], modeChange: [{
18900
+ type: Output
18901
+ }], frame: [{
18902
+ type: ViewChild,
18903
+ args: ['frame', { static: true }]
18904
+ }], onMessage: [{
18905
+ type: HostListener,
18906
+ args: ['window:message', ['$event']]
18907
+ }] } });
18908
+
17558
18909
  class NgxSmarterplanCoreModule {
17559
18910
  static forRoot(config) {
17560
18911
  return {
@@ -17581,7 +18932,8 @@ class NgxSmarterplanCoreModule {
17581
18932
  NavigationBarComponent,
17582
18933
  LoaderComponent,
17583
18934
  SupportModalComponent,
17584
- FormatDateNumberToDigitsPipe], imports: [FormsModule,
18935
+ FormatDateNumberToDigitsPipe,
18936
+ SplatViewerComponent], imports: [FormsModule,
17585
18937
  ReactiveFormsModule,
17586
18938
  CommonModule,
17587
18939
  RouterModule,
@@ -17595,7 +18947,8 @@ class NgxSmarterplanCoreModule {
17595
18947
  MenuBarComponent,
17596
18948
  LoaderComponent,
17597
18949
  SupportModalComponent,
17598
- AvatarComponent] });
18950
+ AvatarComponent,
18951
+ SplatViewerComponent] });
17599
18952
  static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NgxSmarterplanCoreModule, imports: [FormsModule,
17600
18953
  ReactiveFormsModule,
17601
18954
  CommonModule,
@@ -17622,7 +18975,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
17622
18975
  NavigationBarComponent,
17623
18976
  LoaderComponent,
17624
18977
  SupportModalComponent,
17625
- FormatDateNumberToDigitsPipe
18978
+ FormatDateNumberToDigitsPipe,
18979
+ SplatViewerComponent
17626
18980
  ],
17627
18981
  imports: [
17628
18982
  FormsModule,
@@ -17646,6 +19000,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
17646
19000
  LoaderComponent,
17647
19001
  SupportModalComponent,
17648
19002
  AvatarComponent,
19003
+ SplatViewerComponent,
17649
19004
  ]
17650
19005
  }]
17651
19006
  }] });
@@ -17705,27 +19060,27 @@ class BaseTagService {
17705
19060
  throw new Error('updatePoiMetadata is not implemented in the base class.');
17706
19061
  }*/
17707
19062
  async prepareEquipmentHtml(equip) {
17708
- throw new Error(`prepareEquipmentHtml is not implemented in the base class.
19063
+ throw new Error(`prepareEquipmentHtml is not implemented in the base class.
17709
19064
  Please override this method in your extended class.`);
17710
19065
  }
17711
19066
  async prepareTicketHtml(ticket) {
17712
- throw new Error(`prepareTicketHtml is not implemented in the base class.
19067
+ throw new Error(`prepareTicketHtml is not implemented in the base class.
17713
19068
  Please override this method in your extended class.`);
17714
19069
  }
17715
19070
  async prepareFeatureHtml(feature) {
17716
- throw new Error(`prepareFeatureHtml is not implemented in the base class.
19071
+ throw new Error(`prepareFeatureHtml is not implemented in the base class.
17717
19072
  Please override this method in your extended class.`);
17718
19073
  }
17719
19074
  async prepareMeasurementHtml(measure) {
17720
- throw new Error(`prepareMeasurementHtml is not implemented in the base class.
19075
+ throw new Error(`prepareMeasurementHtml is not implemented in the base class.
17721
19076
  Please override this method in your extended class.`);
17722
19077
  }
17723
19078
  async prepareDeskHtml(feature) {
17724
- throw new Error(`prepareDeskHtml is not implemented in the base class.
19079
+ throw new Error(`prepareDeskHtml is not implemented in the base class.
17725
19080
  Please override this method in your extended class.`);
17726
19081
  }
17727
19082
  async prepareIndicatorHtml(feature) {
17728
- throw new Error(`prepareDeskHtml is not implemented in the base class.
19083
+ throw new Error(`prepareDeskHtml is not implemented in the base class.
17729
19084
  Please override this method in your extended class.`);
17730
19085
  }
17731
19086
  async getSignedTagIconSource(tagIconSrc) {
@@ -17734,15 +19089,15 @@ class BaseTagService {
17734
19089
  getIconTagImageForFeature(feature, poi) {
17735
19090
  // const tagIcon = JSON.parse(poi.tagIcon);
17736
19091
  // return tagIcon.src;
17737
- throw new Error(`getIconTagImageForFeature is not implemented in the base class.
19092
+ throw new Error(`getIconTagImageForFeature is not implemented in the base class.
17738
19093
  Please override this method in your extended class.`);
17739
19094
  }
17740
19095
  getScriptForTag(object, tagType) {
17741
- throw new Error(`getScriptForTag is not implemented in the base class.
19096
+ throw new Error(`getScriptForTag is not implemented in the base class.
17742
19097
  Please override this method in your extended class.`);
17743
19098
  }
17744
19099
  getAnnexeForCommentTypeInFeature(feature, commentType) {
17745
- throw new Error(`getAnnexeForCommentTypeInFeature is not implemented in the base class.
19100
+ throw new Error(`getAnnexeForCommentTypeInFeature is not implemented in the base class.
17746
19101
  Please override this method in your extended class.`);
17747
19102
  }
17748
19103
  getBillboardMediaToEmbed(object) {
@@ -17751,27 +19106,27 @@ class BaseTagService {
17751
19106
  return { comment: null, tagDescription: '' };
17752
19107
  }
17753
19108
  onActionDetailClick(url) {
17754
- throw new Error(`onActionDetailClick is not implemented in the base class.
19109
+ throw new Error(`onActionDetailClick is not implemented in the base class.
17755
19110
  Please override this method in your extended class.`);
17756
19111
  }
17757
19112
  onActionAudioClick(audioCommentID) {
17758
- throw new Error(`onActionDetailClick is not implemented in the base class.
19113
+ throw new Error(`onActionDetailClick is not implemented in the base class.
17759
19114
  Please override this method in your extended class.`);
17760
19115
  }
17761
19116
  onActionVideoClick(url) {
17762
- throw new Error(`onActionVideoClick is not implemented in the base class.
19117
+ throw new Error(`onActionVideoClick is not implemented in the base class.
17763
19118
  Please override this method in your extended class.`);
17764
19119
  }
17765
19120
  onActionImageClick(imageCommentID) {
17766
- throw new Error(`onActionImageClick is not implemented in the base class.
19121
+ throw new Error(`onActionImageClick is not implemented in the base class.
17767
19122
  Please override this method in your extended class.`);
17768
19123
  }
17769
19124
  onActionDocClick(url) {
17770
- throw new Error(`onActionDocClick is not implemented in the base class.
19125
+ throw new Error(`onActionDocClick is not implemented in the base class.
17771
19126
  Please override this method in your extended class.`);
17772
19127
  }
17773
19128
  onActionYoutubeClick(url) {
17774
- throw new Error(`onActionYoutubeClick is not implemented in the base class.
19129
+ throw new Error(`onActionYoutubeClick is not implemented in the base class.
17775
19130
  Please override this method in your extended class.`);
17776
19131
  }
17777
19132
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BaseTagService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
@@ -18302,7 +19657,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
18302
19657
  }] }, { type: NavigationService }, { type: ZoneService }, { type: ViewerService }, { type: LayerService }, { type: BaseUserService }, { type: PlanService }] });
18303
19658
 
18304
19659
  /** File extensions the Three.js viewer can load as a base model. */
18305
- const SUPPORTED_MODEL_EXTENSIONS = ['.glb', '.gltf'];
19660
+ // The splat formats open in the splat viewer, which runs in an iframe.
19661
+ const SUPPORTED_MODEL_EXTENSIONS = ['.glb', '.gltf', '.splat.ply', '.spz', '.splat', '.ksplat', '.sog', 'lod-meta.json'];
18306
19662
  class ThreeJsImportService {
18307
19663
  navigationService;
18308
19664
  zoneService;
@@ -18332,11 +19688,12 @@ class ThreeJsImportService {
18332
19688
  */
18333
19689
  validateModelKey(key) {
18334
19690
  if (!key || !key.trim()) {
18335
- return 'A 3D model file (.glb) is required for a Three.js visit.';
19691
+ return 'A 3D model file is required.';
18336
19692
  }
18337
19693
  const trimmed = key.trim();
18338
19694
  if (!SUPPORTED_MODEL_EXTENSIONS.some((extension) => trimmed.toLowerCase().endsWith(extension))) {
18339
- return `The 3D model must be a ${SUPPORTED_MODEL_EXTENSIONS.join(' or ')} file.`;
19695
+ const listed = SUPPORTED_MODEL_EXTENSIONS.slice(0, -1).join(', ');
19696
+ return `The 3D model must be a ${listed} or ${SUPPORTED_MODEL_EXTENSIONS[SUPPORTED_MODEL_EXTENSIONS.length - 1]} file.`;
18340
19697
  }
18341
19698
  // A full https:// URL is a signed link that expires; the stored value has to be the key itself
18342
19699
  // so a fresh URL can be signed on every visit.
@@ -18442,6 +19799,655 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
18442
19799
  }]
18443
19800
  }], ctorParameters: () => [{ type: NavigationService }, { type: ZoneService }, { type: LayerService }, { type: BaseUserService }, { type: PlanService }] });
18444
19801
 
19802
+ /**
19803
+ * Converts an .obj model, with its .mtl and textures, into the .glb the Three.js viewer loads - in
19804
+ * the browser, as the model is added, with nothing to install.
19805
+ *
19806
+ * It does what obj2gltf did in converte-modelo.sh, and the reason is the same. An .obj lists every
19807
+ * corner of every triangle; loaded as it is, a captured model's 2.6 million distinct vertices become
19808
+ * 12.6 million, one per corner. Here each position and texture coordinate pair becomes one vertex,
19809
+ * shared by every triangle that uses it, so what reaches the graphics card is the 2.6 million.
19810
+ *
19811
+ * Textures go in as they are, their bytes untouched: a JPEG re-encoded would lose quality and gain
19812
+ * nothing. Normals are only written when the .obj has them; without, the viewer works them out as
19813
+ * it loads the model. The axes are left as the .obj has them - the viewer finds which way is up.
19814
+ *
19815
+ * The .obj is read as a stream, so a file of hundreds of megabytes is never held whole.
19816
+ */
19817
+ /** Whether a set of files holds an .obj model to convert. */
19818
+ function hasObjModel(files) {
19819
+ return files.some((file) => /\.obj$/i.test(file.name));
19820
+ }
19821
+ /**
19822
+ * Converts the .obj among `files` into a .glb, using the .mtl and the images found with it.
19823
+ */
19824
+ async function convertObjToGlb(files, options = {}) {
19825
+ const objs = files.filter((file) => /\.obj$/i.test(file.name));
19826
+ if (objs.length !== 1) {
19827
+ throw new Error(objs.length === 0 ? 'No .obj file was given.' : 'Give one .obj file at a time.');
19828
+ }
19829
+ const obj = objs[0];
19830
+ const byName = new Map(files.map((file) => [baseName(file.name).toLowerCase(), file]));
19831
+ // The .obj's own reading is most of the time; the rest is shared out over what follows.
19832
+ const parsed = await readObj(obj.stream(), obj.size, {
19833
+ ...options,
19834
+ onProgress: (fraction) => options.onProgress?.(fraction * 0.85),
19835
+ });
19836
+ if (parsed.triangleCount === 0) {
19837
+ throw new Error(`${obj.name} has no faces.`);
19838
+ }
19839
+ // Materials: from the .mtl files the .obj names, or failing that any .mtl given with it.
19840
+ const named = parsed.materialLibraries.map((library) => byName.get(baseName(library).toLowerCase())).filter(Boolean);
19841
+ const libraries = named.length > 0 ? named : files.filter((file) => /\.mtl$/i.test(file.name));
19842
+ const materials = new Map();
19843
+ for (const library of libraries) {
19844
+ parseMtl(await library.text()).forEach((material) => materials.set(material.name, material));
19845
+ }
19846
+ await pause(options.signal);
19847
+ const breaks = new Breaks(options.signal, options.onProgress);
19848
+ const geometry = await indexGeometry(parsed, breaks);
19849
+ options.onProgress?.(0.95);
19850
+ await pause(options.signal);
19851
+ const missing = new Set();
19852
+ const glb = await writeGlb(parsed, geometry, materials, byName, missing, obj.name, breaks);
19853
+ options.onProgress?.(1);
19854
+ return {
19855
+ file: new File([glb], obj.name.replace(/\.obj$/i, '') + '.glb', { type: 'model/gltf-binary' }),
19856
+ vertices: geometry.vertexCount,
19857
+ triangles: parsed.triangleCount,
19858
+ materials: parsed.groups.length,
19859
+ missingTextures: [...missing],
19860
+ };
19861
+ }
19862
+ /** The file name without its folders, which is how an .mtl or a texture is looked up. */
19863
+ function baseName(path) {
19864
+ return path.trim().replace(/^"|"$/g, '').split(/[\\/]/).pop() ?? path;
19865
+ }
19866
+ /** Lets the page draw and answer for a moment, and stops here if the conversion was cancelled. */
19867
+ async function pause(signal) {
19868
+ if (signal?.aborted) {
19869
+ throw new DOMException('Model conversion cancelled', 'AbortError');
19870
+ }
19871
+ await new Promise((resolve) => setTimeout(resolve, 0));
19872
+ }
19873
+ /**
19874
+ * Stops a long loop now and then so the page can draw and answer clicks. Checked only every few
19875
+ * thousand turns of the loop: waiting on a promise every turn would cost more than the loop.
19876
+ */
19877
+ class Breaks {
19878
+ signal;
19879
+ onProgress;
19880
+ last = performance.now();
19881
+ constructor(signal, onProgress) {
19882
+ this.signal = signal;
19883
+ this.onProgress = onProgress;
19884
+ }
19885
+ /** Whether this turn of the loop should stop for a moment. Synchronous: it is asked every turn. */
19886
+ due(turn) {
19887
+ return (turn & 0x3fff) === 0 && performance.now() - this.last > 40;
19888
+ }
19889
+ /** Stops for a moment, reporting how far the conversion has got. */
19890
+ async take(fraction) {
19891
+ this.onProgress?.(fraction);
19892
+ await pause(this.signal);
19893
+ this.last = performance.now();
19894
+ }
19895
+ }
19896
+ /** A growable typed array: an .obj does not say up front how many of anything it holds. */
19897
+ class Growable {
19898
+ data;
19899
+ length = 0;
19900
+ constructor(data) {
19901
+ this.data = data;
19902
+ }
19903
+ push(value) {
19904
+ if (this.length === this.data.length) {
19905
+ const bigger = new this.data.constructor(this.data.length * 2);
19906
+ bigger.set(this.data);
19907
+ this.data = bigger;
19908
+ }
19909
+ this.data[this.length] = value;
19910
+ this.length += 1;
19911
+ }
19912
+ view() {
19913
+ return this.data.subarray(0, this.length);
19914
+ }
19915
+ }
19916
+ /**
19917
+ * Reads an .obj from a stream of its bytes: positions, texture coordinates, normals, and triangles
19918
+ * grouped by material. Polygons are split into triangles; negative indices, counting back from the
19919
+ * end, are resolved; anything else (objects, groups, smoothing) is skipped.
19920
+ */
19921
+ async function readObj(stream, size, options = {}) {
19922
+ const parser = new ObjParser();
19923
+ const reader = stream.getReader();
19924
+ let read = 0;
19925
+ let lastBreak = performance.now();
19926
+ try {
19927
+ for (;;) {
19928
+ if (options.signal?.aborted) {
19929
+ throw new DOMException('Model conversion cancelled', 'AbortError');
19930
+ }
19931
+ const { done, value } = await reader.read();
19932
+ if (done) {
19933
+ break;
19934
+ }
19935
+ parser.push(value);
19936
+ read += value.length;
19937
+ if (performance.now() - lastBreak > 40) {
19938
+ options.onProgress?.(size > 0 ? Math.min(read / size, 1) : 0);
19939
+ await new Promise((resolve) => setTimeout(resolve, 0));
19940
+ lastBreak = performance.now();
19941
+ }
19942
+ }
19943
+ }
19944
+ finally {
19945
+ reader.cancel().catch(() => undefined);
19946
+ }
19947
+ parser.end();
19948
+ options.onProgress?.(1);
19949
+ return parser.result();
19950
+ }
19951
+ /**
19952
+ * Line by line, straight from the bytes. Building a string for each of the millions of lines of a
19953
+ * captured model would be most of the work; this reads the numbers off the bytes instead.
19954
+ */
19955
+ class ObjParser {
19956
+ positions = new Growable(new Float64Array(1 << 16));
19957
+ uvs = new Growable(new Float64Array(1 << 16));
19958
+ normals = new Growable(new Float64Array(1 << 16));
19959
+ groups = [];
19960
+ groupByMaterial = new Map();
19961
+ current = null;
19962
+ materialLibraries = [];
19963
+ triangleCount = 0;
19964
+ /** The bytes of the line being read, kept across chunks. */
19965
+ line = new Uint8Array(256);
19966
+ lineLength = 0;
19967
+ // The corners of the face being read, as position/texture/normal indices.
19968
+ face = new Int32Array(3 * 64);
19969
+ push(bytes) {
19970
+ for (let index = 0; index < bytes.length; index += 1) {
19971
+ const byte = bytes[index];
19972
+ if (byte === 10) {
19973
+ this.readLine();
19974
+ this.lineLength = 0;
19975
+ }
19976
+ else {
19977
+ if (this.lineLength === this.line.length) {
19978
+ const longer = new Uint8Array(this.line.length * 2);
19979
+ longer.set(this.line);
19980
+ this.line = longer;
19981
+ }
19982
+ this.line[this.lineLength] = byte;
19983
+ this.lineLength += 1;
19984
+ }
19985
+ }
19986
+ }
19987
+ end() {
19988
+ this.readLine();
19989
+ this.lineLength = 0;
19990
+ }
19991
+ result() {
19992
+ return {
19993
+ positions: this.positions.view(),
19994
+ uvs: this.uvs.view(),
19995
+ normals: this.normals.view(),
19996
+ groups: this.groups.filter((group) => group.corners.length > 0),
19997
+ materialLibraries: this.materialLibraries,
19998
+ triangleCount: this.triangleCount,
19999
+ };
20000
+ }
20001
+ readLine() {
20002
+ const line = this.line;
20003
+ const length = this.lineLength;
20004
+ let at = 0;
20005
+ while (at < length && (line[at] === 32 || line[at] === 9)) {
20006
+ at += 1;
20007
+ }
20008
+ if (at + 1 >= length) {
20009
+ return;
20010
+ }
20011
+ const first = line[at];
20012
+ const second = line[at + 1];
20013
+ if (first === 118 && (second === 32 || second === 9)) {
20014
+ // v x y z
20015
+ this.readNumbers(at + 2, 3, this.positions);
20016
+ }
20017
+ else if (first === 118 && second === 116) {
20018
+ // vt u v
20019
+ this.readNumbers(at + 3, 2, this.uvs);
20020
+ }
20021
+ else if (first === 118 && second === 110) {
20022
+ // vn x y z
20023
+ this.readNumbers(at + 3, 3, this.normals);
20024
+ }
20025
+ else if (first === 102 && (second === 32 || second === 9)) {
20026
+ this.readFace(at + 2);
20027
+ }
20028
+ else if (this.startsWith(at, 'usemtl')) {
20029
+ this.useMaterial(this.restOfLine(at + 6));
20030
+ }
20031
+ else if (this.startsWith(at, 'mtllib')) {
20032
+ this.materialLibraries.push(...this.restOfLine(at + 6).split(/\s+(?=\S+\.mtl\b)/i).filter(Boolean));
20033
+ }
20034
+ }
20035
+ startsWith(at, word) {
20036
+ for (let index = 0; index < word.length; index += 1) {
20037
+ if (this.line[at + index] !== word.charCodeAt(index)) {
20038
+ return false;
20039
+ }
20040
+ }
20041
+ const after = this.line[at + word.length];
20042
+ return after === 32 || after === 9;
20043
+ }
20044
+ restOfLine(at) {
20045
+ let end = this.lineLength;
20046
+ while (end > at && (this.line[end - 1] === 13 || this.line[end - 1] === 32 || this.line[end - 1] === 9)) {
20047
+ end -= 1;
20048
+ }
20049
+ return new TextDecoder().decode(this.line.subarray(at, end)).trim();
20050
+ }
20051
+ useMaterial(name) {
20052
+ let group = this.groupByMaterial.get(name);
20053
+ if (!group) {
20054
+ group = { material: name, corners: new Growable(new Int32Array(1 << 12)) };
20055
+ this.groupByMaterial.set(name, group);
20056
+ this.groups.push(group);
20057
+ }
20058
+ this.current = group;
20059
+ }
20060
+ /** Reads up to `count` numbers from `at` onwards into `target`, padding a short line with 0. */
20061
+ readNumbers(at, count, target) {
20062
+ let read = 0;
20063
+ let position = at;
20064
+ while (read < count) {
20065
+ const [value, next] = this.readNumber(position);
20066
+ if (next === position) {
20067
+ break;
20068
+ }
20069
+ target.push(value);
20070
+ read += 1;
20071
+ position = next;
20072
+ }
20073
+ for (; read < count; read += 1) {
20074
+ target.push(0);
20075
+ }
20076
+ }
20077
+ /** Reads one number starting at or after `at`; returns it and where it ended. */
20078
+ readNumber(at) {
20079
+ const line = this.line;
20080
+ const length = this.lineLength;
20081
+ let index = at;
20082
+ while (index < length && (line[index] === 32 || line[index] === 9 || line[index] === 13)) {
20083
+ index += 1;
20084
+ }
20085
+ const start = index;
20086
+ let negative = false;
20087
+ if (line[index] === 45 || line[index] === 43) {
20088
+ negative = line[index] === 45;
20089
+ index += 1;
20090
+ }
20091
+ let whole = 0;
20092
+ let fraction = 0;
20093
+ let scale = 1;
20094
+ let digits = 0;
20095
+ while (index < length && line[index] >= 48 && line[index] <= 57) {
20096
+ whole = whole * 10 + (line[index] - 48);
20097
+ index += 1;
20098
+ digits += 1;
20099
+ }
20100
+ if (line[index] === 46) {
20101
+ index += 1;
20102
+ while (index < length && line[index] >= 48 && line[index] <= 57) {
20103
+ fraction = fraction * 10 + (line[index] - 48);
20104
+ scale *= 10;
20105
+ index += 1;
20106
+ digits += 1;
20107
+ }
20108
+ }
20109
+ if (digits === 0) {
20110
+ return [0, at];
20111
+ }
20112
+ let value = whole + fraction / scale;
20113
+ if (line[index] === 101 || line[index] === 69) {
20114
+ index += 1;
20115
+ let negativeExponent = false;
20116
+ if (line[index] === 45 || line[index] === 43) {
20117
+ negativeExponent = line[index] === 45;
20118
+ index += 1;
20119
+ }
20120
+ let exponent = 0;
20121
+ while (index < length && line[index] >= 48 && line[index] <= 57) {
20122
+ exponent = exponent * 10 + (line[index] - 48);
20123
+ index += 1;
20124
+ }
20125
+ value *= 10 ** (negativeExponent ? -exponent : exponent);
20126
+ }
20127
+ return [negative ? -value : value, index === start ? at : index];
20128
+ }
20129
+ /** f v/vt/vn ... - any of the three forms, any number of corners, split into a fan. */
20130
+ readFace(at) {
20131
+ const line = this.line;
20132
+ const length = this.lineLength;
20133
+ let corners = 0;
20134
+ let index = at;
20135
+ while (index < length) {
20136
+ while (index < length && (line[index] === 32 || line[index] === 9 || line[index] === 13)) {
20137
+ index += 1;
20138
+ }
20139
+ if (index >= length) {
20140
+ break;
20141
+ }
20142
+ // One corner: up to three integers separated by '/', any of the last two possibly empty.
20143
+ const values = [0, 0, 0];
20144
+ for (let slot = 0; slot < 3; slot += 1) {
20145
+ let negative = false;
20146
+ if (line[index] === 45) {
20147
+ negative = true;
20148
+ index += 1;
20149
+ }
20150
+ let value = 0;
20151
+ let digits = 0;
20152
+ while (index < length && line[index] >= 48 && line[index] <= 57) {
20153
+ value = value * 10 + (line[index] - 48);
20154
+ index += 1;
20155
+ digits += 1;
20156
+ }
20157
+ values[slot] = digits === 0 ? 0 : negative ? -value : value;
20158
+ if (line[index] !== 47) {
20159
+ break;
20160
+ }
20161
+ index += 1;
20162
+ }
20163
+ if (corners < 64 && values[0] !== 0) {
20164
+ // To zero-based indices, counting a negative one back from what has been read so far; -1
20165
+ // means "none" for the texture and normal.
20166
+ this.face[corners * 3] = this.resolve(values[0], this.positions.length / 3);
20167
+ this.face[corners * 3 + 1] = values[1] === 0 ? -1 : this.resolve(values[1], this.uvs.length / 2);
20168
+ this.face[corners * 3 + 2] = values[2] === 0 ? -1 : this.resolve(values[2], this.normals.length / 3);
20169
+ corners += 1;
20170
+ }
20171
+ // Skip to the next separator, past anything unexpected in this token.
20172
+ while (index < length && line[index] !== 32 && line[index] !== 9) {
20173
+ index += 1;
20174
+ }
20175
+ }
20176
+ if (corners < 3) {
20177
+ return;
20178
+ }
20179
+ if (!this.current) {
20180
+ this.useMaterial(null);
20181
+ }
20182
+ const target = this.current.corners;
20183
+ for (let corner = 1; corner + 1 < corners; corner += 1) {
20184
+ for (const which of [0, corner, corner + 1]) {
20185
+ target.push(this.face[which * 3]);
20186
+ target.push(this.face[which * 3 + 1]);
20187
+ target.push(this.face[which * 3 + 2]);
20188
+ }
20189
+ this.triangleCount += 1;
20190
+ }
20191
+ }
20192
+ resolve(index, count) {
20193
+ return index > 0 ? index - 1 : count + index;
20194
+ }
20195
+ }
20196
+ /**
20197
+ * Turns corners into shared vertices: one vertex per distinct position, texture and normal triple.
20198
+ *
20199
+ * Corners are grouped by position first, and the few corners that share a position are compared
20200
+ * with one another. That needs no table of every triple ever seen - which, over millions of
20201
+ * corners, would cost far more memory than the model itself.
20202
+ */
20203
+ async function indexGeometry(parsed, breaks) {
20204
+ const positionCount = parsed.positions.length / 3;
20205
+ const groups = parsed.groups.map((group) => group.corners.view());
20206
+ const cornerCount = groups.reduce((sum, corners) => sum + corners.length / 3, 0);
20207
+ // Every corner, numbered across all groups, listed under its position.
20208
+ const perPosition = new Int32Array(positionCount + 1);
20209
+ groups.forEach((corners) => {
20210
+ for (let at = 0; at < corners.length; at += 3) {
20211
+ perPosition[corners[at] + 1] += 1;
20212
+ }
20213
+ });
20214
+ for (let position = 0; position < positionCount; position += 1) {
20215
+ perPosition[position + 1] += perPosition[position];
20216
+ }
20217
+ const filled = perPosition.slice(0, positionCount);
20218
+ const byPosition = new Int32Array(cornerCount);
20219
+ const cornerStart = [];
20220
+ let numbered = 0;
20221
+ groups.forEach((corners) => {
20222
+ cornerStart.push(numbered);
20223
+ for (let at = 0; at < corners.length; at += 3) {
20224
+ byPosition[filled[corners[at]]] = numbered;
20225
+ filled[corners[at]] += 1;
20226
+ numbered += 1;
20227
+ }
20228
+ });
20229
+ // Which group each corner is in, so its triple can be read back where it already is rather than
20230
+ // from a copy of every corner.
20231
+ const groupOf = new Uint16Array(cornerCount);
20232
+ groups.forEach((corners, group) => groupOf.fill(group, cornerStart[group], cornerStart[group] + corners.length / 3));
20233
+ const vertexOfCorner = new Uint32Array(cornerCount);
20234
+ const sources = new Growable(new Int32Array(Math.max(16, positionCount * 3 * 1.3) | 0));
20235
+ let vertexCount = 0;
20236
+ for (let position = 0; position < positionCount; position += 1) {
20237
+ if (breaks.due(position)) {
20238
+ await breaks.take(0.85 + 0.07 * (position / positionCount));
20239
+ }
20240
+ const first = vertexCount;
20241
+ for (let at = perPosition[position]; at < perPosition[position + 1]; at += 1) {
20242
+ const corner = byPosition[at];
20243
+ const group = groupOf[corner];
20244
+ const triple = (corner - cornerStart[group]) * 3;
20245
+ const uv = groups[group][triple + 1];
20246
+ const normal = groups[group][triple + 2];
20247
+ let found = -1;
20248
+ for (let vertex = first; vertex < vertexCount; vertex += 1) {
20249
+ if (sources.data[vertex * 3 + 1] === uv && sources.data[vertex * 3 + 2] === normal) {
20250
+ found = vertex;
20251
+ break;
20252
+ }
20253
+ }
20254
+ if (found < 0) {
20255
+ found = vertexCount;
20256
+ sources.push(position);
20257
+ sources.push(uv);
20258
+ sources.push(normal);
20259
+ vertexCount += 1;
20260
+ }
20261
+ vertexOfCorner[corner] = found;
20262
+ }
20263
+ }
20264
+ return {
20265
+ vertexCount,
20266
+ sources: sources.view(),
20267
+ indices: groups.map((corners, group) => vertexOfCorner.slice(cornerStart[group], cornerStart[group] + corners.length / 3)),
20268
+ };
20269
+ }
20270
+ /** The parts of an .mtl that matter here: the colour, the opacity and the colour texture. */
20271
+ function parseMtl(text) {
20272
+ const materials = [];
20273
+ let current = null;
20274
+ text.split(/\r?\n/).forEach((raw) => {
20275
+ const line = raw.trim();
20276
+ const [keyword, ...rest] = line.split(/\s+/);
20277
+ if (keyword === 'newmtl') {
20278
+ current = { name: rest.join(' '), colour: [1, 1, 1], opacity: 1, texture: null };
20279
+ materials.push(current);
20280
+ }
20281
+ else if (!current) {
20282
+ return;
20283
+ }
20284
+ else if (keyword === 'Kd' && rest.length >= 3) {
20285
+ current.colour = [Number(rest[0]), Number(rest[1]), Number(rest[2])];
20286
+ }
20287
+ else if (keyword === 'd' && rest.length >= 1) {
20288
+ current.opacity = Number(rest[0]);
20289
+ }
20290
+ else if (keyword === 'map_Kd' && rest.length >= 1) {
20291
+ // Options (-s 1 1 1, -o ..., -bm 0.5) come before the file name, which is what is left.
20292
+ current.texture = rest[rest.length - 1];
20293
+ }
20294
+ });
20295
+ return materials;
20296
+ }
20297
+ /**
20298
+ * Writes the .glb. The binary part is assembled from the arrays and the texture files as they are,
20299
+ * into a Blob - so the textures are never read into memory, nor the whole file built in one buffer.
20300
+ */
20301
+ async function writeGlb(parsed, geometry, materials, files, missing, name, breaks) {
20302
+ const { vertexCount, sources } = geometry;
20303
+ const hasUv = parsed.uvs.length > 0 && hasAny(sources, 1);
20304
+ const hasNormals = parsed.normals.length > 0 && hasAny(sources, 2);
20305
+ const positions = new Float32Array(vertexCount * 3);
20306
+ const min = [Infinity, Infinity, Infinity];
20307
+ const max = [-Infinity, -Infinity, -Infinity];
20308
+ const uvs = hasUv ? new Float32Array(vertexCount * 2) : null;
20309
+ const normals = hasNormals ? new Float32Array(vertexCount * 3) : null;
20310
+ for (let vertex = 0; vertex < vertexCount; vertex += 1) {
20311
+ if (breaks.due(vertex)) {
20312
+ await breaks.take(0.95 + 0.04 * (vertex / vertexCount));
20313
+ }
20314
+ const position = sources[vertex * 3];
20315
+ for (let axis = 0; axis < 3; axis += 1) {
20316
+ const value = parsed.positions[position * 3 + axis];
20317
+ positions[vertex * 3 + axis] = value;
20318
+ min[axis] = Math.min(min[axis], Math.fround(value));
20319
+ max[axis] = Math.max(max[axis], Math.fround(value));
20320
+ }
20321
+ if (uvs) {
20322
+ const uv = sources[vertex * 3 + 1];
20323
+ // An .obj counts v up from the bottom of the image, glTF down from the top.
20324
+ uvs[vertex * 2] = uv >= 0 ? parsed.uvs[uv * 2] : 0;
20325
+ uvs[vertex * 2 + 1] = uv >= 0 ? 1 - parsed.uvs[uv * 2 + 1] : 0;
20326
+ }
20327
+ if (normals) {
20328
+ const normal = sources[vertex * 3 + 2];
20329
+ for (let axis = 0; axis < 3; axis += 1) {
20330
+ normals[vertex * 3 + axis] = normal >= 0 ? parsed.normals[normal * 3 + axis] : 0;
20331
+ }
20332
+ }
20333
+ }
20334
+ const parts = [];
20335
+ const bufferViews = [];
20336
+ let byteLength = 0;
20337
+ const addView = (part, size, target) => {
20338
+ const padding = (4 - (byteLength % 4)) % 4;
20339
+ if (padding) {
20340
+ parts.push(new Uint8Array(padding));
20341
+ byteLength += padding;
20342
+ }
20343
+ parts.push(part);
20344
+ bufferViews.push({ buffer: 0, byteOffset: byteLength, byteLength: size, ...(target ? { target } : {}) });
20345
+ byteLength += size;
20346
+ return bufferViews.length - 1;
20347
+ };
20348
+ const accessors = [];
20349
+ const addAccessor = (data, type, count, extra = {}, target) => {
20350
+ const view = addView(data, data.byteLength, target);
20351
+ accessors.push({ bufferView: view, componentType: data instanceof Float32Array ? 5126 : 5125, count, type, ...extra });
20352
+ return accessors.length - 1;
20353
+ };
20354
+ const attributes = {
20355
+ POSITION: addAccessor(positions, 'VEC3', vertexCount, { min, max }, 34962),
20356
+ };
20357
+ if (normals) {
20358
+ attributes['NORMAL'] = addAccessor(normals, 'VEC3', vertexCount, {}, 34962);
20359
+ }
20360
+ if (uvs) {
20361
+ attributes['TEXCOORD_0'] = addAccessor(uvs, 'VEC2', vertexCount, {}, 34962);
20362
+ }
20363
+ // Materials, each texture file stored once however many materials use it.
20364
+ const images = [];
20365
+ const textures = [];
20366
+ const textureOf = new Map();
20367
+ const textureIndex = (fileName) => {
20368
+ const key = baseName(fileName).toLowerCase();
20369
+ if (textureOf.has(key)) {
20370
+ return textureOf.get(key);
20371
+ }
20372
+ const file = files.get(key);
20373
+ if (!file) {
20374
+ missing.add(baseName(fileName));
20375
+ return null;
20376
+ }
20377
+ const mimeType = /\.png$/i.test(file.name) ? 'image/png' : 'image/jpeg';
20378
+ images.push({ bufferView: addView(file, file.size), mimeType, name: baseName(file.name) });
20379
+ textures.push({ source: images.length - 1, sampler: 0 });
20380
+ textureOf.set(key, textures.length - 1);
20381
+ return textures.length - 1;
20382
+ };
20383
+ const gltfMaterials = [];
20384
+ const primitives = parsed.groups.map((group, index) => {
20385
+ const material = group.material !== null ? materials.get(group.material) : undefined;
20386
+ const texture = material?.texture ? textureIndex(material.texture) : null;
20387
+ gltfMaterials.push({
20388
+ name: group.material ?? 'default',
20389
+ pbrMetallicRoughness: {
20390
+ ...(texture !== null ? { baseColorTexture: { index: texture } } : {}),
20391
+ baseColorFactor: texture !== null ? [1, 1, 1, material?.opacity ?? 1] : [...(material?.colour ?? [0.8, 0.8, 0.8]), material?.opacity ?? 1],
20392
+ metallicFactor: 0,
20393
+ roughnessFactor: 1,
20394
+ },
20395
+ ...(material && material.opacity < 1 ? { alphaMode: 'BLEND' } : {}),
20396
+ });
20397
+ const triangles = geometry.indices[index];
20398
+ return {
20399
+ attributes,
20400
+ indices: addAccessor(triangles, 'SCALAR', triangles.length, {}, 34963),
20401
+ material: gltfMaterials.length - 1,
20402
+ };
20403
+ });
20404
+ const json = {
20405
+ asset: { version: '2.0', generator: 'SmarterPlan obj-to-glb' },
20406
+ scene: 0,
20407
+ scenes: [{ nodes: [0] }],
20408
+ nodes: [{ mesh: 0, name: name.replace(/\.obj$/i, '') }],
20409
+ meshes: [{ primitives }],
20410
+ materials: gltfMaterials,
20411
+ ...(textures.length ? {
20412
+ textures,
20413
+ images,
20414
+ // Linear filtering with mipmaps, repeating: what a captured model's atlas expects.
20415
+ samplers: [{ magFilter: 9729, minFilter: 9987, wrapS: 10497, wrapT: 10497 }],
20416
+ } : {}),
20417
+ accessors,
20418
+ bufferViews,
20419
+ buffers: [{ byteLength }],
20420
+ };
20421
+ let jsonBytes = new TextEncoder().encode(JSON.stringify(json));
20422
+ const jsonPadding = (4 - (jsonBytes.length % 4)) % 4;
20423
+ if (jsonPadding) {
20424
+ const padded = new Uint8Array(jsonBytes.length + jsonPadding).fill(32);
20425
+ padded.set(jsonBytes);
20426
+ jsonBytes = padded;
20427
+ }
20428
+ const binPadding = (4 - (byteLength % 4)) % 4;
20429
+ const binLength = byteLength + binPadding;
20430
+ const header = new DataView(new ArrayBuffer(12 + 8));
20431
+ header.setUint32(0, 0x46546c67, true); // "glTF"
20432
+ header.setUint32(4, 2, true);
20433
+ header.setUint32(8, 12 + 8 + jsonBytes.length + 8 + binLength, true);
20434
+ header.setUint32(12, jsonBytes.length, true);
20435
+ header.setUint32(16, 0x4e4f534a, true); // "JSON"
20436
+ const binHeader = new DataView(new ArrayBuffer(8));
20437
+ binHeader.setUint32(0, binLength, true);
20438
+ binHeader.setUint32(4, 0x004e4942, true); // "BIN"
20439
+ return new Blob([header, jsonBytes, binHeader, ...parts, new Uint8Array(binPadding)]);
20440
+ }
20441
+ /** Whether any vertex uses the given slot of its source triple (texture or normal). */
20442
+ function hasAny(sources, slot) {
20443
+ for (let at = slot; at < sources.length; at += 3) {
20444
+ if (sources[at] >= 0) {
20445
+ return true;
20446
+ }
20447
+ }
20448
+ return false;
20449
+ }
20450
+
18445
20451
  // matterport-transform.util.ts
18446
20452
  /**
18447
20453
  * Computes the rigid transform (Y-axis rotation + translation) between two
@@ -21416,5 +23422,5 @@ function floatValidator() {
21416
23422
  * Generated bundle index. Do not edit.
21417
23423
  */
21418
23424
 
21419
- export { AffectationService, AvatarComponent, BaseLateralTabService, BaseTagService, BaseUserService, BaseVisibilityService, CameraMode, CaptureService, CaptureViewer, CommentService, CommentType, Config, ContentService, CsvExportComponent, DomainService, DomainType, DurationToStringPipe, EmailStatus, EquipmentService, EventService, EventStatus, EventType, FeatureService, FeatureType, FilterService, HashtagFromIdPipe, HashtagService, InterventionService, InventoryStatus, LayerService, LevelStatus, LoaderComponent, Locale, LocaleService, MATTERPORT_SDK_KEY, MatterportImportService, MatterportService, MatterportTagCopyService, MatterportViewType, MattertagActionMode, MattertagData, MeasurementService, MenuBarComponent, MissionService, NavigationService, NavigatorService, NgxSmarterplanCoreModule, NgxSmarterplanCoreService, NodeService, Object3DService, OperationService, OrganisationService, PaymentStatus, PlanService, PoiService, PoiType, ProfileEntity, ProfileService, ProfileStatus, PropertyService, PropertyType, RoleStatus, SafeUrlPipe, SearchBarComponent, SearchObjectType, SearchService, SpModule, SpaceService, SpaceStatus, StatusEquipment, SupportModalComponent, SupportService, SweepLocatorService, THREEJS_MODEL_UP_OPTIONS, TagAction, TemplateService, ThreeJsImportService, ThreeJsService, TicketPriority, TicketStatus, TicketType, TicketsService, TimeDateToLocalStringPipe, TypeNote, UsernameFromIdPipe, ValidatorsService, ViewerInteractions, ViewerService, VisitService, ZoneChangeService, ZoneService, applyRigidTransformToDirection, applyRigidTransformToPoint, applyRigidTransformToYRotationDeg, arraysContainSameElements, checkElementById, computeRigidTransform, convertTimestampToLocalZone, copyFileInS3, copyFileInS3ViaDownload, dateHasExpired, dateTimeToLocalString, deleteFromS3, distance, downloadBlob, downloadEquipmentDocument, downloadFile, downloadFileAsObject, durationToString, emailValidator, enumToArray, filterUniqueArrayByID, floatValidator, getBufferForFileFromS3, getCoefficientsForImage, getCurrentLang, getDistanceBetweenTwoPoints, getHighestLevelForMissions, getHighestRoleForMissions, getLevelsBelow, getLocaleLong, getLocaleShort, getMetaForImage, getRolesBelowForManager, getSignedFile, getSignedImageUrlForEquipment, getSignedImageUrlForProfile, getSignedImageUrlForSpace, getSpaceIDFromUrl, getThreeJsPoiOrigin, getVisitUrl, isEmptyObject, listFilesInFolder, mean, noEmptyValidator, numberToDateString, openDocument, openModalForVisitSwitch, parseThreeJsPlacement, poiTypeToString, pointToString, removeAllFilesFromFolderS3, removeNullKeysFromObject, showScanPointsOnPlanInDiv, shuffleArray, solveThreeJsAlignment, sortAlphabeticallyOnName, stringLocaleToEnum, stringToLowercaseNoSpaces, styleButton, textValidator, translateDatePeriod, uploadBase64Image, uploadBase64ImageWithRetry, uploadFileToS3, uploadFileToS3WithProgress, uploadJsonToS3, validEmail, wait, waitUntil };
23425
+ export { AffectationService, AvatarComponent, BaseLateralTabService, BaseTagService, BaseUserService, BaseVisibilityService, CameraMode, CaptureService, CaptureViewer, CommentService, CommentType, Config, ContentService, CsvExportComponent, DomainService, DomainType, DurationToStringPipe, EmailStatus, EquipmentService, EventService, EventStatus, EventType, FeatureService, FeatureType, FilterService, GAUSSIAN_SPLAT_PLY_SUFFIX, HashtagFromIdPipe, HashtagService, InterventionService, InventoryStatus, LayerService, LevelStatus, LoaderComponent, Locale, LocaleService, MATTERPORT_SDK_KEY, MatterportImportService, MatterportService, MatterportTagCopyService, MatterportViewType, MattertagActionMode, MattertagData, MeasurementService, MenuBarComponent, MissionService, NavigationService, NavigatorService, NgxSmarterplanCoreModule, NgxSmarterplanCoreService, NodeService, Object3DService, OperationService, OrganisationService, PaymentStatus, PlanService, PoiService, PoiType, ProfileEntity, ProfileService, ProfileStatus, PropertyService, PropertyType, RoleStatus, SafeUrlPipe, SearchBarComponent, SearchObjectType, SearchService, SpModule, SpaceService, SpaceStatus, SplatViewerComponent, StatusEquipment, SupportModalComponent, SupportService, SweepLocatorService, THREEJS_MODEL_UP_OPTIONS, TagAction, TemplateService, ThreeJsImportService, ThreeJsService, TicketPriority, TicketStatus, TicketType, TicketsService, TimeDateToLocalStringPipe, TypeNote, UsernameFromIdPipe, VIEWER_BACKGROUND, ValidatorsService, ViewerInteractions, ViewerService, VisitService, ZoneChangeService, ZoneService, applyRigidTransformToDirection, applyRigidTransformToPoint, applyRigidTransformToYRotationDeg, arraysContainSameElements, checkElementById, computeRigidTransform, convertObjToGlb, convertTimestampToLocalZone, copyFileInS3, copyFileInS3ViaDownload, dateHasExpired, dateTimeToLocalString, deleteFromS3, distance, downloadBlob, downloadEquipmentDocument, downloadFile, downloadFileAsObject, durationToString, emailValidator, enumToArray, filterUniqueArrayByID, floatValidator, getBufferForFileFromS3, getCoefficientsForImage, getCurrentLang, getDistanceBetweenTwoPoints, getHighestLevelForMissions, getHighestRoleForMissions, getLevelsBelow, getLocaleLong, getLocaleShort, getMetaForImage, getRolesBelowForManager, getSignedFile, getSignedImageUrlForEquipment, getSignedImageUrlForProfile, getSignedImageUrlForSpace, getSpaceIDFromUrl, getThreeJsPoiOrigin, getVisitUrl, hasObjModel, isEmptyObject, isGaussianSplatFile, isGaussianSplatPly, isStreamedSplatFile, listFilesInFolder, mean, noEmptyValidator, numberToDateString, openDocument, openModalForVisitSwitch, parseThreeJsPlacement, poiTypeToString, pointToString, readObj, removeAllFilesFromFolderS3, removeNullKeysFromObject, showScanPointsOnPlanInDiv, shuffleArray, solveThreeJsAlignment, sortAlphabeticallyOnName, stringLocaleToEnum, stringToLowercaseNoSpaces, styleButton, textValidator, translateDatePeriod, uploadBase64Image, uploadBase64ImageWithRetry, uploadFileToS3, uploadFileToS3WithProgress, uploadJsonToS3, validEmail, wait, waitUntil };
21420
23426
  //# sourceMappingURL=smarterplan-ngx-smarterplan-core.mjs.map