@ifc-lite/viewer 1.29.0 → 1.30.0

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 (80) hide show
  1. package/.turbo/turbo-build.log +32 -31
  2. package/CHANGELOG.md +58 -0
  3. package/dist/assets/{basketViewActivator-BSRgF1Hw.js → basketViewActivator-BHNb23Vw.js} +6 -6
  4. package/dist/assets/{bcf-3uE1MvcT.js → bcf-C0XZ2DSl.js} +1 -1
  5. package/dist/assets/{deflate-BLlUfw9-.js → deflate-DvvFcUtV.js} +1 -1
  6. package/dist/assets/{exporters-BL6UmxRa.js → exporters-CvORJOLn.js} +1345 -1434
  7. package/dist/assets/geometry.worker-B7X9DQQY.js +1 -0
  8. package/dist/assets/{geotiff-BydcIud8.js → geotiff-fSD_sVw_.js} +10 -10
  9. package/dist/assets/{ids-DFl74rTt.js → ids-BUOe5QQl.js} +951 -713
  10. package/dist/assets/idsValidation.worker-DEodXb0f.js +190468 -0
  11. package/dist/assets/ifc-lite_bg-CmMuB1zf.wasm +0 -0
  12. package/dist/assets/{index-BNTlm2lP.js → index-B6T42T86.js} +35235 -32937
  13. package/dist/assets/index-D0tqJL0X.css +1 -0
  14. package/dist/assets/{index.es-Bk4nLsyS.js → index.es-YGMensDM.js} +7 -7
  15. package/dist/assets/{jpeg-BvMO8-Tc.js → jpeg-0Sla88_N.js} +1 -1
  16. package/dist/assets/{jspdf.es.min-BZ_ed66E.js → jspdf.es.min-mnbLNj-p.js} +4 -4
  17. package/dist/assets/{lerc-CNnDpLpV.js → lerc-C7xUDHpL.js} +1 -1
  18. package/dist/assets/{lzw-DBaPrGGZ.js → lzw-CK480t0_.js} +1 -1
  19. package/dist/assets/{native-bridge-DFOoBvTg.js → native-bridge-sLWRanza.js} +1 -1
  20. package/dist/assets/{packbits-C7uyD2Bi.js → packbits-DcL4imYS.js} +1 -1
  21. package/dist/assets/parser.worker-BsGV6ml7.js +182 -0
  22. package/dist/assets/{pdf-DlqdjX9e.js → pdf-BARGfLmx.js} +8 -8
  23. package/dist/assets/raw-BMWh6mDy.js +1 -0
  24. package/dist/assets/{sandbox-0Z2NzeOJ.js → sandbox-BSiO04m8.js} +2801 -2609
  25. package/dist/assets/server-client-AlpWMVq9.js +741 -0
  26. package/dist/assets/{webimage-zN-oCabb.js → webimage-uy5DjZLk.js} +1 -1
  27. package/dist/assets/{xlsx-N2LbIR1G.js → xlsx-D02ho69_.js} +6 -6
  28. package/dist/assets/{zstd-Jk3QKIeb.js → zstd-DcR1TBwT.js} +1 -1
  29. package/dist/index.html +7 -7
  30. package/package.json +27 -32
  31. package/src/components/viewer/CesiumOverlay.tsx +195 -8
  32. package/src/components/viewer/IDSPanel.tsx +23 -7
  33. package/src/components/viewer/MainToolbar.tsx +31 -0
  34. package/src/components/viewer/SunSkyPanel.tsx +363 -0
  35. package/src/components/viewer/Viewport.tsx +49 -1
  36. package/src/components/viewer/ViewportContainer.tsx +20 -1
  37. package/src/components/viewer/useAnimationLoop.ts +19 -1
  38. package/src/disable-react-dev-perf-track.ts +38 -0
  39. package/src/hooks/has-entity-type.ts +33 -0
  40. package/src/hooks/ids/idsWorkerClient.ts +136 -0
  41. package/src/hooks/ingest/federationAlign.ts +1 -1
  42. package/src/hooks/ingest/pointCloudIngest.ts +22 -98
  43. package/src/hooks/ingest/viewerModelIngest.ts +8 -13
  44. package/src/hooks/useAlignmentLines3D.ts +5 -0
  45. package/src/hooks/useGridLines3D.ts +4 -0
  46. package/src/hooks/useIDS.ts +77 -13
  47. package/src/hooks/useIfcCache.ts +1 -1
  48. package/src/hooks/useIfcFederation.ts +1 -1
  49. package/src/hooks/useIfcServer.ts +1 -1
  50. package/src/hooks/useModelSelection.ts +1 -1
  51. package/src/hooks/useSolarEnvironment.ts +114 -0
  52. package/src/hooks/useSolarSweep.ts +66 -0
  53. package/src/hooks/useSymbolicAnnotations.ts +10 -0
  54. package/src/hooks/useViewerSelectors.ts +1 -1
  55. package/src/lib/geo/cesium-sun.ts +277 -0
  56. package/src/lib/geo/solar-direction.test.ts +70 -0
  57. package/src/lib/geo/solar-direction.ts +94 -0
  58. package/src/lib/lighting-presets.ts +128 -0
  59. package/src/lib/recent-files.ts +4 -24
  60. package/src/lib/solar-time.ts +55 -0
  61. package/src/main.tsx +5 -0
  62. package/src/store/index.ts +8 -0
  63. package/src/store/slices/annotationsSlice.test.ts +0 -16
  64. package/src/store/slices/cesiumSlice.ts +3 -3
  65. package/src/store/slices/dataSlice.test.ts +0 -40
  66. package/src/store/slices/environmentSlice.ts +101 -0
  67. package/src/store/slices/idsSlice.ts +6 -1
  68. package/src/store/slices/selectionSlice.test.ts +0 -43
  69. package/src/store/slices/solarSlice.ts +121 -0
  70. package/src/store/slices/visibilitySlice.test.ts +15 -45
  71. package/src/utils/loadingUtils.ts +1 -1
  72. package/src/workers/idsValidation.worker.ts +98 -0
  73. package/dist/assets/geometry.worker-DVwFYHTq.js +0 -1
  74. package/dist/assets/ifc-lite_bg-FPffpFK_.wasm +0 -0
  75. package/dist/assets/index-DpoJvkdg.css +0 -1
  76. package/dist/assets/parser.worker-U_PVhLNi.js +0 -182
  77. package/dist/assets/raw-p_2cfl6T.js +0 -1
  78. package/dist/assets/server-client-DUMy2mXg.js +0 -719
  79. package/src/components/ui/context-menu.tsx +0 -174
  80. package/src/store.ts +0 -80
@@ -0,0 +1,136 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Main-thread client for the IDS validation worker.
7
+ *
8
+ * Spawns the worker per run (validation is infrequent and the worker
9
+ * re-parses the model, so a long-lived instance would only pin memory),
10
+ * streams progress back to the caller, and resolves with the report.
11
+ * The caller falls back to in-process validation when `isSupported()`
12
+ * is false or the worker rejects.
13
+ */
14
+
15
+ import type {
16
+ IDSDocument,
17
+ IDSValidationReport,
18
+ ValidationProgress,
19
+ } from '@ifc-lite/ids';
20
+ import type {
21
+ IdsWorkerRequest,
22
+ IdsWorkerResponse,
23
+ } from '@/workers/idsValidation.worker';
24
+
25
+ export function idsWorkerSupported(): boolean {
26
+ return typeof Worker !== 'undefined';
27
+ }
28
+
29
+ export interface RunInWorkerArgs {
30
+ /** Raw IFC/STEP bytes from the loaded model's data store. */
31
+ source: Uint8Array;
32
+ document: IDSDocument;
33
+ schemaVersion: string;
34
+ modelId: string;
35
+ locale: 'en' | 'de' | 'fr';
36
+ includePassingEntities: boolean;
37
+ onProgress?: (progress: ValidationProgress) => void;
38
+ }
39
+
40
+ /**
41
+ * Hand the model bytes + parsed IDS document to the worker and resolve
42
+ * with the validation report. A SharedArrayBuffer-backed source is
43
+ * shared zero-copy; a plain ArrayBuffer is copied and transferred so
44
+ * the main-thread store is never detached.
45
+ */
46
+ export function runValidationInWorker(
47
+ args: RunInWorkerArgs
48
+ ): Promise<IDSValidationReport> {
49
+ return new Promise((resolve, reject) => {
50
+ let worker: Worker;
51
+ try {
52
+ worker = new Worker(
53
+ new URL('../../workers/idsValidation.worker.ts', import.meta.url),
54
+ { type: 'module' }
55
+ );
56
+ } catch (err) {
57
+ reject(
58
+ new Error(
59
+ `Failed to spawn IDS worker: ${err instanceof Error ? err.message : String(err)}`
60
+ )
61
+ );
62
+ return;
63
+ }
64
+
65
+ const id = Date.now();
66
+ const { buffer, transfer } = prepareSource(args.source);
67
+
68
+ const settle = (fn: () => void) => {
69
+ worker.onmessage = null;
70
+ worker.onerror = null;
71
+ worker.onmessageerror = null;
72
+ worker.terminate();
73
+ fn();
74
+ };
75
+
76
+ worker.onmessage = (event: MessageEvent<IdsWorkerResponse>) => {
77
+ const msg = event.data;
78
+ if (!msg || msg.id !== id) return;
79
+ switch (msg.type) {
80
+ case 'progress':
81
+ args.onProgress?.(msg.progress);
82
+ return;
83
+ case 'complete':
84
+ settle(() => resolve(msg.report));
85
+ return;
86
+ case 'error':
87
+ settle(() => reject(new Error(msg.message)));
88
+ return;
89
+ }
90
+ };
91
+
92
+ worker.onerror = (event) => {
93
+ settle(() => reject(new Error(event.message || 'IDS worker crashed')));
94
+ };
95
+ worker.onmessageerror = () => {
96
+ settle(() => reject(new Error('IDS worker message deserialization failed')));
97
+ };
98
+
99
+ const request: IdsWorkerRequest = {
100
+ type: 'validate',
101
+ id,
102
+ source: buffer,
103
+ document: args.document,
104
+ schemaVersion: args.schemaVersion,
105
+ modelId: args.modelId,
106
+ locale: args.locale,
107
+ includePassingEntities: args.includePassingEntities,
108
+ };
109
+ worker.postMessage(request, transfer);
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Resolve the exact source bytes into a worker-postable buffer.
115
+ * SharedArrayBuffer is shared by reference (zero copy); a plain
116
+ * ArrayBuffer (or a partial view) is copied into a fresh, transferable
117
+ * ArrayBuffer so the caller's store keeps its bytes.
118
+ */
119
+ function prepareSource(source: Uint8Array): {
120
+ buffer: ArrayBuffer | SharedArrayBuffer;
121
+ transfer: Transferable[];
122
+ } {
123
+ const sharedAvailable = typeof SharedArrayBuffer !== 'undefined';
124
+ if (
125
+ sharedAvailable &&
126
+ source.buffer instanceof SharedArrayBuffer &&
127
+ source.byteOffset === 0 &&
128
+ source.byteLength === source.buffer.byteLength
129
+ ) {
130
+ // Shared by reference — no copy, the worker only reads it.
131
+ return { buffer: source.buffer, transfer: [] };
132
+ }
133
+ // Copy the exact bytes; transfer the copy (never the original).
134
+ const copy = source.slice();
135
+ return { buffer: copy.buffer, transfer: [copy.buffer] };
136
+ }
@@ -17,7 +17,7 @@ import {
17
17
  type ProjectedCRS,
18
18
  } from '@ifc-lite/parser';
19
19
  import type { CoordinateInfo } from '@ifc-lite/geometry';
20
- import { useViewerStore, type FederatedModel } from '../../store.js';
20
+ import { useViewerStore, type FederatedModel } from '../../store/index.js';
21
21
  import { getEffectiveGeoreference, getEffectiveHorizontalScale, hasStandardGeoreferencing, type GeorefMutationDataLike } from '../../lib/geo/effective-georef.js';
22
22
  import { resolveMapUnitToMetreScale } from '../../lib/geo/geo-scale.js';
23
23
  import { resolveProjection } from '../../lib/geo/reproject.js';
@@ -19,118 +19,42 @@ import {
19
19
  type StreamHandle,
20
20
  } from '@ifc-lite/pointcloud';
21
21
  import type { CoordinateInfo, GeometryResult, PointCloudAsset } from '@ifc-lite/geometry';
22
- import type { IfcDataStore } from '@ifc-lite/parser';
22
+ import { createSyntheticDataStore, type IfcDataStore } from '@ifc-lite/parser';
23
23
  import type { SchemaVersion } from '../../store/types.js';
24
24
  import { createCoordinateInfo } from '../../utils/localParsingUtils.js';
25
25
 
26
26
  export type PointCloudFormat = 'las' | 'laz' | 'ply' | 'pcd' | 'e57' | 'pts' | 'xyz';
27
27
 
28
28
  /**
29
- * IfcTypeEnum.IfcGeographicElement the closest IFC4 entity for a scan
30
- * is `IfcGeographicElement`. We hard-code the enum value (58) here so
31
- * we don't pull `@ifc-lite/data` into the viewer ingest path.
32
- */
33
- const IFC_GEOGRAPHIC_ELEMENT_ENUM = 58;
34
-
35
- /**
36
- * Synthetic IfcDataStore for a point-cloud-only model. Picking a point
37
- * sets the synthetic expressId as the selected entity, which then runs
38
- * through the regular property/hover/properties-panel pipeline. That
39
- * pipeline calls `entities.getTypeName / getName / getGlobalId` and
40
- * `properties.getForEntity` — without those methods, picking crashes
41
- * with "getTypeName is not a function". We give it just enough shape
42
- * to round-trip the single synthetic entity.
29
+ * Synthetic IfcDataStore for a point-cloud-only model. Picking a point sets
30
+ * the synthetic expressId as the selected entity, which then runs through the
31
+ * regular property/hover/properties-panel pipeline. That pipeline calls
32
+ * `entities.getTypeName / getName / getGlobalId` and `properties.getForEntity`
33
+ * `createSyntheticDataStore` builds a real single-row entity table (plus the
34
+ * lazy `getEntity` / `getProperties` accessors) so every member of the
35
+ * `IfcDataStore` contract is present and compiler-enforced — no `as unknown as`
36
+ * shims that silently drop an accessor and crash picking at runtime (#1004).
37
+ *
38
+ * `IfcGeographicElement` is the closest IFC4 entity for a real-world scan; the
39
+ * entity table derives its enum (58) from the type name itself.
43
40
  */
44
41
  function emptyDataStore(
45
42
  buffer: ArrayBuffer,
46
43
  expressId: number,
47
44
  fileName: string,
48
45
  ): IfcDataStore {
49
- const expressIds = new Uint32Array([expressId]);
50
- const empty32 = new Uint32Array(0);
51
- const empty8 = new Uint8Array(0);
52
- const emptyI32 = new Int32Array(0);
53
- const indexOf = (id: number) => (id === expressId ? 0 : -1);
54
- const entities = {
55
- count: 1,
56
- expressId: expressIds,
57
- typeEnum: new Uint16Array([IFC_GEOGRAPHIC_ELEMENT_ENUM]),
58
- globalId: empty32,
59
- name: empty32,
60
- description: empty32,
61
- objectType: empty32,
62
- flags: new Uint8Array([0]),
63
- containedInStorey: new Int32Array([-1]),
64
- definedByType: new Int32Array([-1]),
65
- geometryIndex: new Int32Array([-1]),
66
- typeRanges: new Map(),
67
- getGlobalId: (id: number) => (indexOf(id) >= 0 ? `pointcloud-${expressId}` : ''),
68
- getName: (id: number) => (indexOf(id) >= 0 ? fileName : ''),
69
- getDescription: () => '',
70
- getObjectType: () => '',
71
- getTypeName: (id: number) => (indexOf(id) >= 0 ? 'IfcGeographicElement' : 'Unknown'),
72
- hasGeometry: (id: number) => indexOf(id) >= 0,
73
- getByType: () => [expressId],
74
- getTypeEnum: (id: number) =>
75
- indexOf(id) >= 0 ? IFC_GEOGRAPHIC_ELEMENT_ENUM : 9999, // 9999 = Unknown
76
- getExpressIdByGlobalId: (gid: string) =>
77
- gid === `pointcloud-${expressId}` ? expressId : -1,
78
- getGlobalIdMap: () => new Map([[`pointcloud-${expressId}`, expressId]]),
79
- };
80
- const properties = {
81
- count: 0,
82
- entityId: empty32, psetName: empty32, psetGlobalId: empty32,
83
- propName: empty32, propType: empty8,
84
- valueString: empty32, valueReal: new Float64Array(0),
85
- valueInt: emptyI32, valueBool: empty8, unitId: emptyI32,
86
- entityIndex: new Map<number, number[]>(),
87
- psetIndex: new Map<number, number[]>(),
88
- propIndex: new Map<number, number[]>(),
89
- getForEntity: () => [],
90
- getPropertyValue: () => null,
91
- findByProperty: () => [],
92
- };
93
- const quantities = {
94
- count: 0,
95
- entityId: empty32, qsetName: empty32, qsetGlobalId: empty32,
96
- quantityName: empty32, quantityType: empty8,
97
- valueReal: new Float64Array(0), unitId: emptyI32,
98
- entityIndex: new Map<number, number[]>(),
99
- qsetIndex: new Map<number, number[]>(),
100
- getForEntity: () => [],
101
- };
102
- const relationships = {
103
- count: 0,
104
- relType: empty8, relatingId: empty32, relatedId: empty32,
105
- byRelating: new Map<number, number[]>(),
106
- byRelated: new Map<number, number[]>(),
107
- getOutgoing: () => [],
108
- getIncoming: () => [],
109
- getRelated: () => [],
110
- getRelating: () => [],
111
- };
112
- const byId = new Map<number, unknown>([[expressId, { expressId }]]);
113
- return {
46
+ return createSyntheticDataStore({
47
+ schemaVersion: 'IFC4',
114
48
  fileSize: buffer.byteLength,
115
- schemaVersion: 'IFC4' as const,
116
49
  entityCount: 1,
117
- parseTime: 0,
118
- source: new Uint8Array(0),
119
- entityIndex: {
120
- byId: byId as unknown as IfcDataStore['entityIndex']['byId'],
121
- byType: new Map([['IFCGEOGRAPHICELEMENT', [expressId]]]),
122
- },
123
- strings: {
124
- get: () => '',
125
- getId: () => 0,
126
- count: 0,
127
- } as unknown as IfcDataStore['strings'],
128
- entities: entities as unknown as IfcDataStore['entities'],
129
- properties: properties as unknown as IfcDataStore['properties'],
130
- quantities: quantities as unknown as IfcDataStore['quantities'],
131
- relationships: relationships as unknown as IfcDataStore['relationships'],
132
- spatialHierarchy: undefined,
133
- } as unknown as IfcDataStore;
50
+ entities: [{
51
+ expressId,
52
+ type: 'IfcGeographicElement',
53
+ globalId: `pointcloud-${expressId}`,
54
+ name: fileName,
55
+ hasGeometry: true,
56
+ }],
57
+ });
134
58
  }
135
59
 
136
60
  export interface PointCloudIngestResult {
@@ -2,7 +2,7 @@
2
2
  * License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
- import { parseIfcx, type IfcDataStore, type PointCloudExtraction } from '@ifc-lite/parser';
5
+ import { parseIfcx, createSyntheticDataStore, type IfcDataStore, type PointCloudExtraction } from '@ifc-lite/parser';
6
6
  import { type GeometryResult, type MeshData, type PointCloudAsset } from '@ifc-lite/geometry';
7
7
  import { loadGLBToMeshData } from '@ifc-lite/cache';
8
8
  import type { SchemaVersion } from '../../store/types.js';
@@ -44,20 +44,15 @@ export function convertIfcxMeshes(rawMeshes: RawIfcxMesh[]): MeshData[] {
44
44
  }
45
45
 
46
46
  export function createMinimalGlbDataStore(buffer: ArrayBuffer, meshCount: number): IfcDataStore {
47
- return {
47
+ // A GLB carries renderable meshes but no IFC entities. Build a typed,
48
+ // entity-less store via the shared factory so the full `IfcDataStore`
49
+ // contract (including the lazy `getEntity` / `getProperties` accessors the
50
+ // query path calls) is compiler-enforced instead of cast away (#1004).
51
+ return createSyntheticDataStore({
52
+ schemaVersion: 'IFC4',
48
53
  fileSize: buffer.byteLength,
49
- schemaVersion: 'IFC4' as const,
50
54
  entityCount: meshCount,
51
- parseTime: 0,
52
- source: new Uint8Array(0),
53
- entityIndex: { byId: new Map(), byType: new Map() },
54
- strings: { getString: () => undefined, getStringId: () => undefined, count: 0 } as unknown as IfcDataStore['strings'],
55
- entities: { count: 0, getId: () => 0, getType: () => 0, getName: () => undefined, getGlobalId: () => undefined } as unknown as IfcDataStore['entities'],
56
- properties: { count: 0, getPropertiesForEntity: () => [], getPropertySetForEntity: () => [] } as unknown as IfcDataStore['properties'],
57
- quantities: { count: 0, getQuantitiesForEntity: () => [] } as unknown as IfcDataStore['quantities'],
58
- relationships: { count: 0, getRelationships: () => [], getRelated: () => [] } as unknown as IfcDataStore['relationships'],
59
- spatialHierarchy: null as unknown as IfcDataStore['spatialHierarchy'],
60
- } as unknown as IfcDataStore;
55
+ });
61
56
  }
62
57
 
63
58
  export function getMaxExpressId(dataStore: IfcDataStore, meshes: MeshData[]): number {
@@ -24,6 +24,7 @@ import { useViewerStore } from '@/store';
24
24
  import { useShallow } from 'zustand/react/shallow';
25
25
  import type { IfcDataStore } from '@ifc-lite/parser';
26
26
  import { sourceKey } from './source-key.js';
27
+ import { hasEntityType } from './has-entity-type.js';
27
28
 
28
29
  const EMPTY_F32 = new Float32Array(0);
29
30
 
@@ -42,6 +43,10 @@ function notifyCacheChange(): void {
42
43
  async function parseAlignmentLinesFor(store: IfcDataStore): Promise<Float32Array> {
43
44
  const source = store.source;
44
45
  if (!source || source.byteLength === 0) return EMPTY_F32;
46
+ // Most models (all buildings) have no alignments. Skip the full-source WASM
47
+ // scan — it copies the entire IFC source into the WASM heap on the main thread
48
+ // just to find none (~0.5s on a 170MB file).
49
+ if (!hasEntityType(store, 'IfcAlignment', 'IfcAlignmentCurve')) return EMPTY_F32;
45
50
  const processor = new GeometryProcessor();
46
51
  try {
47
52
  await processor.init();
@@ -24,6 +24,7 @@ import { useViewerStore } from '@/store';
24
24
  import { useShallow } from 'zustand/react/shallow';
25
25
  import type { IfcDataStore } from '@ifc-lite/parser';
26
26
  import { sourceKey } from './source-key.js';
27
+ import { hasEntityType } from './has-entity-type.js';
27
28
 
28
29
  const EMPTY_F32 = new Float32Array(0);
29
30
 
@@ -42,6 +43,9 @@ function notifyCacheChange(): void {
42
43
  async function parseGridLinesFor(store: IfcDataStore): Promise<Float32Array> {
43
44
  const source = store.source;
44
45
  if (!source || source.byteLength === 0) return EMPTY_F32;
46
+ // Skip the full-source WASM scan when the model has no grid — it copies the
47
+ // entire IFC source into the WASM heap on the main thread just to find none.
48
+ if (!hasEntityType(store, 'IfcGridAxis', 'IfcGrid')) return EMPTY_F32;
45
49
  const processor = new GeometryProcessor();
46
50
  try {
47
51
  await processor.init();
@@ -38,6 +38,7 @@ import { getEntityBounds } from '@/utils/viewportUtils';
38
38
  import { getGlobalRenderer } from '@/hooks/useBCF';
39
39
 
40
40
  import { createDataAccessor } from './ids/idsDataAccessor';
41
+ import { runValidationInWorker, idsWorkerSupported } from './ids/idsWorkerClient';
41
42
  import {
42
43
  DEFAULT_FAILED_COLOR,
43
44
  DEFAULT_PASSED_COLOR,
@@ -386,23 +387,85 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
386
387
  try {
387
388
  setIdsLoading(true);
388
389
  setIdsError(null);
390
+ // Paint a "starting" state immediately so the button shows work is
391
+ // underway before the first real progress event arrives.
392
+ setIdsProgress({
393
+ phase: 'filtering',
394
+ specificationIndex: 0,
395
+ totalSpecifications: document.specifications.length,
396
+ entitiesProcessed: 0,
397
+ totalEntities: 0,
398
+ percentage: 0,
399
+ });
389
400
 
390
- // Create data accessor
391
- const accessor = createDataAccessor(dataStore, modelId);
401
+ // Force the loading state to actually paint before spawning the
402
+ // worker and doing any heavy synchronous work, so the spinner +
403
+ // initial progress bar are guaranteed on screen immediately. Race
404
+ // the frame wait against a timer so a backgrounded tab (where
405
+ // requestAnimationFrame is paused) can't stall the run.
406
+ await new Promise<void>((resolve) => {
407
+ let settled = false;
408
+ const done = () => {
409
+ if (settled) return;
410
+ settled = true;
411
+ resolve();
412
+ };
413
+ requestAnimationFrame(() => requestAnimationFrame(done));
414
+ setTimeout(done, 200);
415
+ });
392
416
 
393
- // Create model info
394
- const modelInfo: IDSModelInfo = {
395
- modelId,
396
- schemaVersion: dataStore.schemaVersion || 'IFC4',
397
- entityCount: dataStore.entityCount || accessor.getAllEntityIds().length,
417
+ const schemaVersion = dataStore.schemaVersion || 'IFC4';
418
+
419
+ // Progress events arrive far faster than React should re-render
420
+ // (per 100 entities / per spec); throttle store updates to ~8/s
421
+ // and always pass the terminal event.
422
+ let lastProgressUpdate = 0;
423
+ const onProgress = (p: ValidationProgress) => {
424
+ const now = performance.now();
425
+ if (p.phase === 'complete' || now - lastProgressUpdate >= 120) {
426
+ lastProgressUpdate = now;
427
+ setIdsProgress(p);
428
+ }
398
429
  };
399
430
 
400
- // Run validation
401
- const validationReport = await validateIDS(document, accessor, modelInfo, {
402
- translator,
403
- onProgress: setIdsProgress,
404
- includePassingEntities: true,
405
- });
431
+ let validationReport: IDSValidationReport | null = null;
432
+
433
+ // Preferred path: validate in a Web Worker so the whole run is off
434
+ // the main thread — the UI stays at full frame rate and progress
435
+ // actually paints. Every other heavy stage (parse, geometry)
436
+ // already runs in a worker; this brings validation in line. Falls
437
+ // back to in-process validation if the worker is unavailable or
438
+ // fails (e.g. no source bytes for non-STEP models).
439
+ const canUseWorker = idsWorkerSupported() && !!dataStore.source && dataStore.source.byteLength > 0;
440
+ if (canUseWorker) {
441
+ try {
442
+ validationReport = await runValidationInWorker({
443
+ source: dataStore.source!,
444
+ document,
445
+ schemaVersion,
446
+ modelId,
447
+ locale,
448
+ includePassingEntities: true,
449
+ onProgress,
450
+ });
451
+ } catch (workerErr) {
452
+ console.warn('[IDS] Worker validation failed; falling back to main thread.', workerErr);
453
+ }
454
+ }
455
+
456
+ if (!validationReport) {
457
+ const accessor = createDataAccessor(dataStore, modelId);
458
+ const modelInfo: IDSModelInfo = {
459
+ modelId,
460
+ schemaVersion,
461
+ entityCount: dataStore.entityCount || accessor.getAllEntityIds().length,
462
+ };
463
+ validationReport = await validateIDS(document, accessor, modelInfo, {
464
+ translator,
465
+ onProgress,
466
+ includePassingEntities: true,
467
+ });
468
+ }
406
469
 
407
470
  setIdsValidationReport(validationReport);
408
471
 
@@ -426,6 +489,7 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
426
489
  models,
427
490
  activeModelId,
428
491
  translator,
492
+ locale,
429
493
  setIdsLoading,
430
494
  setIdsError,
431
495
  setIdsProgress,
@@ -23,7 +23,7 @@ import { buildSpatialIndexGuarded } from '../utils/loadingUtils.js';
23
23
  import type { MeshData } from '@ifc-lite/geometry';
24
24
 
25
25
  import { useShallow } from 'zustand/react/shallow';
26
- import { useViewerStore } from '../store.js';
26
+ import { useViewerStore } from '../store/index.js';
27
27
  import { getCached, setCached, deleteCached, type CacheResult } from '../services/cacheService.js';
28
28
  import { rebuildSpatialHierarchy, rebuildOnDemandMaps } from '../utils/spatialHierarchy.js';
29
29
  import { calculateStoreyHeights } from '../utils/localParsingUtils.js';
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { useCallback, useRef } from 'react';
14
14
  import { useShallow } from 'zustand/react/shallow';
15
- import { useViewerStore, type FederatedModel, type SchemaVersion } from '../store.js';
15
+ import { useViewerStore, type FederatedModel, type SchemaVersion } from '../store/index.js';
16
16
  import {
17
17
  detectFormat,
18
18
  parseFederatedIfcx,
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { useCallback } from 'react';
14
- import { useViewerStore } from '../store.js';
14
+ import { useViewerStore } from '../store/index.js';
15
15
  import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
16
16
  import {
17
17
  IfcServerClient,
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import { useEffect } from 'react';
22
- import { useViewerStore } from '../store.js';
22
+ import { useViewerStore } from '../store/index.js';
23
23
  import { resolveEntityRef } from '../store/resolveEntityRef.js';
24
24
 
25
25
  export function useModelSelection() {
@@ -0,0 +1,114 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Bridge the solar study to the WebGPU renderer's lighting environment.
7
+ *
8
+ * While the study is enabled and the model is georeferenced, this hook
9
+ * computes the sun's true direction for the studied instant at the site and
10
+ * publishes it (viewer/world space, Y-up) to `solarSunDirection` — Viewport
11
+ * folds it into `RenderOptions.environment`, so the WebGPU sun and sky track
12
+ * the same instant Cesium's clock is pinned to.
13
+ *
14
+ * It also publishes the panel readout (`solarSunInfo`) when the Cesium
15
+ * overlay is OFF — with Cesium on, CesiumOverlay's solar effect owns that
16
+ * write (it uses the terrain-clamped bridge origin).
17
+ */
18
+
19
+ import { useEffect, useState } from 'react';
20
+ import type { MapConversion, ProjectedCRS } from '@ifc-lite/parser';
21
+ import type { CoordinateInfo } from '@ifc-lite/geometry';
22
+ import { sunPosition, sunTimes, azimuthAltitudeToEnu } from '@ifc-lite/solar';
23
+ import { useViewerStore } from '@/store';
24
+ import { computeCesiumModelOrigin } from '@/lib/geo/cesium-bridge';
25
+ import { enuToViewerDirection } from '@/lib/geo/solar-direction';
26
+
27
+ export interface SolarEnvironmentGeoref {
28
+ mapConversion?: MapConversion;
29
+ projectedCRS?: ProjectedCRS;
30
+ coordinateInfo?: CoordinateInfo;
31
+ lengthUnitScale?: number;
32
+ }
33
+
34
+ interface SiteOrigin {
35
+ latitude: number;
36
+ longitude: number;
37
+ }
38
+
39
+ export function useSolarEnvironment(georef: SolarEnvironmentGeoref | null): void {
40
+ const solarEnabled = useViewerStore((s) => s.solarEnabled);
41
+ const solarDateMs = useViewerStore((s) => s.solarDateMs);
42
+ const cesiumEnabled = useViewerStore((s) => s.cesiumEnabled);
43
+ const setSolarSunDirection = useViewerStore((s) => s.setSolarSunDirection);
44
+ const setSolarSunInfo = useViewerStore((s) => s.setSolarSunInfo);
45
+
46
+ const [origin, setOrigin] = useState<SiteOrigin | null>(null);
47
+
48
+ const mapConversion = georef?.mapConversion;
49
+ const projectedCRS = georef?.projectedCRS;
50
+
51
+ // Resolve the site's lat/lon once per georeference (proj4 lookup is async).
52
+ useEffect(() => {
53
+ if (!solarEnabled || !mapConversion || !projectedCRS) {
54
+ setOrigin(null);
55
+ return;
56
+ }
57
+ let cancelled = false;
58
+ (async () => {
59
+ try {
60
+ const resolved = await computeCesiumModelOrigin(
61
+ mapConversion,
62
+ projectedCRS,
63
+ georef?.coordinateInfo,
64
+ georef?.lengthUnitScale ?? 1,
65
+ );
66
+ if (!cancelled) {
67
+ setOrigin(resolved ? { latitude: resolved.latitude, longitude: resolved.longitude } : null);
68
+ }
69
+ } catch {
70
+ if (!cancelled) setOrigin(null);
71
+ }
72
+ })();
73
+ return () => { cancelled = true; };
74
+ }, [solarEnabled, mapConversion, projectedCRS, georef?.coordinateInfo, georef?.lengthUnitScale]);
75
+
76
+ // Publish the viewer-space sun direction for the studied instant.
77
+ useEffect(() => {
78
+ if (!solarEnabled || !origin) {
79
+ setSolarSunDirection(null);
80
+ return;
81
+ }
82
+ const date = new Date(solarDateMs);
83
+ const sp = sunPosition(date, origin.latitude, origin.longitude);
84
+ const enu = azimuthAltitudeToEnu(sp.azimuth, sp.altitude);
85
+ setSolarSunDirection(enuToViewerDirection(
86
+ enu,
87
+ mapConversion?.xAxisAbscissa ?? 1,
88
+ mapConversion?.xAxisOrdinate ?? 0,
89
+ ));
90
+
91
+ // Panel readout — only when CesiumOverlay isn't publishing it.
92
+ if (!cesiumEnabled) {
93
+ const times = sunTimes(date, origin.latitude, origin.longitude);
94
+ setSolarSunInfo({
95
+ latitude: origin.latitude,
96
+ longitude: origin.longitude,
97
+ azimuth: sp.azimuth,
98
+ altitude: sp.altitude,
99
+ sunriseMs: times.sunrise ? times.sunrise.getTime() : null,
100
+ sunsetMs: times.sunset ? times.sunset.getTime() : null,
101
+ solarNoonMs: times.solarNoon.getTime(),
102
+ });
103
+ }
104
+ }, [
105
+ solarEnabled,
106
+ solarDateMs,
107
+ origin,
108
+ cesiumEnabled,
109
+ mapConversion?.xAxisAbscissa,
110
+ mapConversion?.xAxisOrdinate,
111
+ setSolarSunDirection,
112
+ setSolarSunInfo,
113
+ ]);
114
+ }