@ifc-lite/viewer 1.17.6 → 1.19.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 (156) hide show
  1. package/.turbo/turbo-build.log +20 -15
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +949 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-RZy5c3Td.js} +1 -1
  5. package/dist/assets/decode-worker-Collf_X_.js +1320 -0
  6. package/dist/assets/{exporters-CcPS9MK5.js → exporters-BraHBeoi.js} +4194 -3025
  7. package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-DQEZB2rB.js} +1 -1
  8. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  9. package/dist/assets/index-0XpVr_S5.css +1 -0
  10. package/dist/assets/{index-Bfms9I4A.js → index-BOi3BuUI.js} +46423 -31181
  11. package/dist/assets/index-XwKzDuw6.js +22 -0
  12. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-CpBeOPQa.js} +1 -1
  13. package/dist/assets/sandbox-Baez7n-t.js +9682 -0
  14. package/dist/assets/{server-client-BuZK7OST.js → server-client-BB6cMAXE.js} +1 -1
  15. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-CAYCUHbE.js} +1 -1
  16. package/dist/index.html +6 -6
  17. package/package.json +11 -10
  18. package/src/apache-arrow.d.ts +30 -0
  19. package/src/components/viewer/AddElementPanel.tsx +758 -0
  20. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  21. package/src/components/viewer/ChatPanel.tsx +64 -2
  22. package/src/components/viewer/CommandPalette.tsx +56 -7
  23. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  24. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  25. package/src/components/viewer/ExportDialog.tsx +19 -1
  26. package/src/components/viewer/MainToolbar.tsx +73 -12
  27. package/src/components/viewer/PointCloudPanel.tsx +174 -0
  28. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  29. package/src/components/viewer/SearchInline.tsx +669 -0
  30. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  31. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  32. package/src/components/viewer/SearchModal.text.tsx +388 -0
  33. package/src/components/viewer/SearchModal.tsx +235 -0
  34. package/src/components/viewer/ToolOverlays.tsx +5 -0
  35. package/src/components/viewer/ViewerLayout.tsx +24 -4
  36. package/src/components/viewer/Viewport.tsx +29 -2
  37. package/src/components/viewer/ViewportContainer.tsx +45 -5
  38. package/src/components/viewer/ViewportOverlays.tsx +13 -2
  39. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  40. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  41. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  42. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  43. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  44. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  45. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  46. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  47. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  48. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  49. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  50. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  51. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  52. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  53. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  54. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  55. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  56. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  57. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  58. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  59. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  60. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  61. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  62. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  63. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  64. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  65. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  66. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  67. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  68. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  69. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  70. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  71. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  72. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  73. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  74. package/src/components/viewer/selectionHandlers.ts +446 -0
  75. package/src/components/viewer/tools/AddElementOverlay.tsx +581 -0
  76. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  77. package/src/components/viewer/useMouseControls.ts +9 -1
  78. package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
  79. package/src/components/viewer/usePointCloudSync.ts +98 -0
  80. package/src/hooks/ingest/pointCloudIngest.ts +391 -0
  81. package/src/hooks/ingest/viewerModelIngest.ts +32 -3
  82. package/src/hooks/useIfcFederation.ts +72 -3
  83. package/src/hooks/useIfcLoader.ts +89 -13
  84. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  85. package/src/hooks/useSandbox.ts +1 -1
  86. package/src/hooks/useSearchIndex.ts +125 -0
  87. package/src/index.css +66 -0
  88. package/src/lib/llm/system-prompt.test.ts +14 -0
  89. package/src/lib/llm/system-prompt.ts +102 -1
  90. package/src/lib/llm/types.ts +6 -0
  91. package/src/lib/recent-files.ts +38 -4
  92. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  93. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  94. package/src/lib/scripts/templates.ts +7 -0
  95. package/src/lib/search/common-ifc-types.ts +36 -0
  96. package/src/lib/search/filter-evaluate.test.ts +537 -0
  97. package/src/lib/search/filter-evaluate.ts +610 -0
  98. package/src/lib/search/filter-rules.test.ts +119 -0
  99. package/src/lib/search/filter-rules.ts +198 -0
  100. package/src/lib/search/filter-schema.test.ts +233 -0
  101. package/src/lib/search/filter-schema.ts +146 -0
  102. package/src/lib/search/recent-searches.test.ts +116 -0
  103. package/src/lib/search/recent-searches.ts +93 -0
  104. package/src/lib/search/result-export.test.ts +101 -0
  105. package/src/lib/search/result-export.ts +104 -0
  106. package/src/lib/search/saved-filters.test.ts +118 -0
  107. package/src/lib/search/saved-filters.ts +154 -0
  108. package/src/lib/search/tier0-scan.test.ts +196 -0
  109. package/src/lib/search/tier0-scan.ts +237 -0
  110. package/src/lib/search/tier1-index.test.ts +242 -0
  111. package/src/lib/search/tier1-index.ts +448 -0
  112. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  113. package/src/sdk/adapters/export-adapter.ts +404 -1
  114. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  115. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  116. package/src/sdk/adapters/model-compat.ts +8 -2
  117. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  118. package/src/sdk/adapters/store-adapter.ts +201 -0
  119. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  120. package/src/sdk/local-backend.ts +16 -8
  121. package/src/services/desktop-export.ts +3 -1
  122. package/src/services/desktop-native-metadata.ts +41 -18
  123. package/src/services/file-dialog.ts +8 -3
  124. package/src/services/tauri-modules.d.ts +25 -0
  125. package/src/store/basketVisibleSet.ts +3 -0
  126. package/src/store/globalId.ts +4 -1
  127. package/src/store/index.ts +79 -1
  128. package/src/store/slices/addElementMeshes.ts +365 -0
  129. package/src/store/slices/addElementSlice.ts +275 -0
  130. package/src/store/slices/annotationsSlice.test.ts +133 -0
  131. package/src/store/slices/annotationsSlice.ts +251 -0
  132. package/src/store/slices/dataSlice.test.ts +23 -4
  133. package/src/store/slices/dataSlice.ts +1 -1
  134. package/src/store/slices/modelSlice.test.ts +67 -9
  135. package/src/store/slices/modelSlice.ts +39 -7
  136. package/src/store/slices/mutationSlice.ts +964 -3
  137. package/src/store/slices/overlayCompositor.test.ts +164 -0
  138. package/src/store/slices/overlaySlice.test.ts +93 -0
  139. package/src/store/slices/overlaySlice.ts +151 -0
  140. package/src/store/slices/pinboardSlice.test.ts +6 -1
  141. package/src/store/slices/playbackSlice.ts +128 -0
  142. package/src/store/slices/pointCloudSlice.ts +102 -0
  143. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  144. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  145. package/src/store/slices/scheduleSlice.test.ts +694 -0
  146. package/src/store/slices/scheduleSlice.ts +1330 -0
  147. package/src/store/slices/searchSlice.test.ts +342 -0
  148. package/src/store/slices/searchSlice.ts +341 -0
  149. package/src/store/slices/selectionSlice.test.ts +46 -0
  150. package/src/store/slices/selectionSlice.ts +20 -0
  151. package/src/store/types.ts +7 -0
  152. package/src/store.ts +14 -0
  153. package/vite.config.ts +1 -0
  154. package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
  155. package/dist/assets/index-_bfZsDCC.css +0 -1
  156. package/dist/assets/sandbox-C8575tul.js +0 -5951
@@ -0,0 +1,242 @@
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
+ import { describe, it } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { StringTable, EntityTableBuilder } from '@ifc-lite/data';
8
+ import type { IfcDataStore } from '@ifc-lite/parser';
9
+ import {
10
+ buildTier1Index,
11
+ queryTier1Indexes,
12
+ __internal,
13
+ type Tier1Index,
14
+ } from './tier1-index.js';
15
+
16
+ function buildStore(rows: Array<{
17
+ expressId: number;
18
+ type: string;
19
+ globalId: string;
20
+ name: string;
21
+ description?: string;
22
+ objectType?: string;
23
+ }>): IfcDataStore {
24
+ const strings = new StringTable();
25
+ const builder = new EntityTableBuilder(rows.length, strings);
26
+ for (const r of rows) {
27
+ builder.add(
28
+ r.expressId,
29
+ r.type,
30
+ r.globalId,
31
+ r.name,
32
+ r.description ?? '',
33
+ r.objectType ?? '',
34
+ false,
35
+ false,
36
+ );
37
+ }
38
+ const entities = builder.build();
39
+ return {
40
+ fileSize: 0,
41
+ schemaVersion: 'IFC4',
42
+ entityCount: rows.length,
43
+ parseTime: 0,
44
+ source: new Uint8Array(0),
45
+ entityIndex: { byId: { ranges: new Uint32Array(0), index: new Map() }, byType: new Map() },
46
+ strings,
47
+ entities,
48
+ properties: { count: 0 },
49
+ quantities: { count: 0 },
50
+ relationships: { count: 0 },
51
+ } as unknown as IfcDataStore;
52
+ }
53
+
54
+ const baseRows = [
55
+ { expressId: 10, type: 'IFCWALL', globalId: '1abcdefghijklmnopqrstu', name: 'Wall-EXT-001' },
56
+ { expressId: 20, type: 'IFCWALL', globalId: '2abcdefghijklmnopqrstu', name: 'Wall-INT-002' },
57
+ { expressId: 30, type: 'IFCDOOR', globalId: '3abcdefghijklmnopqrstu', name: 'Door-A-201' },
58
+ { expressId: 40, type: 'IFCWINDOW', globalId: '4abcdefghijklmnopqrstu', name: 'WIN-North-1', description: 'fire-rated' },
59
+ { expressId: 50, type: 'IFCSLAB', globalId: '5abcdefghijklmnopqrstu', name: '', objectType: 'SLAB-FLOOR' },
60
+ { expressId: 60, type: 'IFCWALL', globalId: '', name: '' }, // all-empty, should be skipped
61
+ ];
62
+
63
+ describe('tokenize', () => {
64
+ it('splits on whitespace and IFC punctuation', () => {
65
+ assert.deepStrictEqual(__internal.tokenize('Wall-EXT-001'), ['wall', 'ext', '001']);
66
+ assert.deepStrictEqual(__internal.tokenize('Pset_WallCommon'), ['pset', 'wallcommon']);
67
+ assert.deepStrictEqual(__internal.tokenize('Level.01/Zone:A'), ['level', '01', 'zone']);
68
+ });
69
+
70
+ it('strips one-char non-digit tokens but keeps digits', () => {
71
+ assert.deepStrictEqual(__internal.tokenize('a b c 2'), ['2']);
72
+ assert.deepStrictEqual(__internal.tokenize('X-2-Y'), ['2']);
73
+ });
74
+
75
+ it('returns empty on empty input', () => {
76
+ assert.deepStrictEqual(__internal.tokenize(''), []);
77
+ });
78
+ });
79
+
80
+ describe('prefixRange', () => {
81
+ const tokens = ['apple', 'apricot', 'banana', 'blueberry', 'cherry'];
82
+ it('returns the [lo, hi) range of tokens matching the prefix', () => {
83
+ assert.deepStrictEqual(__internal.prefixRange(tokens, 'ap'), [0, 2]);
84
+ assert.deepStrictEqual(__internal.prefixRange(tokens, 'b'), [2, 4]);
85
+ assert.deepStrictEqual(__internal.prefixRange(tokens, 'cherry'), [4, 5]);
86
+ });
87
+ it('returns an empty range when the prefix does not match', () => {
88
+ const [lo, hi] = __internal.prefixRange(tokens, 'z');
89
+ assert.strictEqual(lo, hi);
90
+ });
91
+ });
92
+
93
+ describe('buildTier1Index', () => {
94
+ it('populates entries for non-empty rows and skips the all-empty row', async () => {
95
+ const store = buildStore(baseRows);
96
+ const idx = await buildTier1Index('m', store);
97
+ assert.strictEqual(idx.modelId, 'm');
98
+ assert.strictEqual(idx.sourceEntityCount, baseRows.length);
99
+ // Six rows in but row 60 is all-empty → 5 entries.
100
+ assert.strictEqual(idx.entries.length, 5);
101
+ assert.ok(idx.entries.every((e) => e.expressId !== 60));
102
+ });
103
+
104
+ it('tokenizes names, types, descriptions, and objectTypes into the token index', async () => {
105
+ const store = buildStore(baseRows);
106
+ const idx = await buildTier1Index('m', store);
107
+ assert.ok(idx.tokenIndex.has('wall'), 'type token "wall" is present');
108
+ assert.ok(idx.tokenIndex.has('ext'), 'name token "ext" is present');
109
+ assert.ok(idx.tokenIndex.has('fire'), 'description token "fire" is present');
110
+ assert.ok(idx.tokenIndex.has('slab'), 'objectType token "slab" is present');
111
+ // GUIDs are intentionally NOT tokenized.
112
+ assert.ok(!idx.tokenIndex.has('1abcdefghijklmnopqrstu'));
113
+ });
114
+
115
+ it('builds a case-sensitive globalIdMap for O(1) exact GUID lookup', async () => {
116
+ const store = buildStore(baseRows);
117
+ const idx = await buildTier1Index('m', store);
118
+ assert.strictEqual(idx.globalIdMap.size, 5);
119
+ assert.ok(idx.globalIdMap.has('3abcdefghijklmnopqrstu'));
120
+ // Empty GUIDs don't land in the map.
121
+ assert.ok(!idx.globalIdMap.has(''));
122
+ });
123
+
124
+ it('supports AbortSignal cancellation between chunks', async () => {
125
+ const manyRows = Array.from({ length: 500 }, (_, i) => ({
126
+ expressId: 1000 + i,
127
+ type: 'IFCWALL',
128
+ globalId: `g${String(i).padStart(21, 'x')}`,
129
+ name: `Wall-${i}`,
130
+ }));
131
+ const store = buildStore(manyRows);
132
+ const controller = new AbortController();
133
+ controller.abort();
134
+ await assert.rejects(
135
+ () => buildTier1Index('m', store, { signal: controller.signal, chunkSize: 50 }),
136
+ (err: unknown) => err instanceof DOMException && err.name === 'AbortError',
137
+ );
138
+ });
139
+
140
+ it('invokes the onProgress callback after each chunk', async () => {
141
+ const manyRows = Array.from({ length: 250 }, (_, i) => ({
142
+ expressId: 1000 + i,
143
+ type: 'IFCWALL',
144
+ globalId: `g${String(i).padStart(21, 'x')}`,
145
+ name: `Wall-${i}`,
146
+ }));
147
+ const store = buildStore(manyRows);
148
+ const progressValues: Array<{ done: number; total: number }> = [];
149
+ await buildTier1Index('m', store, {
150
+ chunkSize: 50,
151
+ onProgress: (done, total) => progressValues.push({ done, total }),
152
+ });
153
+ assert.ok(progressValues.length >= 5, 'at least one progress call per chunk');
154
+ assert.strictEqual(progressValues[progressValues.length - 1].done, 250);
155
+ assert.strictEqual(progressValues[progressValues.length - 1].total, 250);
156
+ });
157
+ });
158
+
159
+ describe('queryTier1Indexes', () => {
160
+ const makeIndex = async (id: string, rows: typeof baseRows): Promise<Tier1Index> => {
161
+ return buildTier1Index(id, buildStore(rows));
162
+ };
163
+
164
+ it('returns nothing for empty queries', async () => {
165
+ const idx = await makeIndex('m', baseRows);
166
+ assert.deepStrictEqual(queryTier1Indexes([idx], ''), []);
167
+ assert.deepStrictEqual(queryTier1Indexes([idx], ' '), []);
168
+ });
169
+
170
+ it('exact GUID match takes the fast path with highest score', async () => {
171
+ const idx = await makeIndex('m', baseRows);
172
+ const results = queryTier1Indexes([idx], '3abcdefghijklmnopqrstu');
173
+ assert.ok(results.length >= 1);
174
+ assert.strictEqual(results[0].expressId, 30);
175
+ assert.strictEqual(results[0].matchField, 'globalId');
176
+ assert.ok(results[0].score >= 1000);
177
+ });
178
+
179
+ it('matches by exact token with correct scoring precedence', async () => {
180
+ const idx = await makeIndex('m', baseRows);
181
+ const results = queryTier1Indexes([idx], 'wall');
182
+ // Three walls: two named Wall-EXT/Wall-INT (IFCWALL) AND the all-empty
183
+ // IFCWALL row is skipped → two results.
184
+ const ids = results.map((r) => r.expressId).sort((a, b) => a - b);
185
+ assert.deepStrictEqual(ids, [10, 20]);
186
+ });
187
+
188
+ it('prefix-expands single tokens ("wal" surfaces "wall")', async () => {
189
+ const idx = await makeIndex('m', baseRows);
190
+ const results = queryTier1Indexes([idx], 'wal');
191
+ const ids = results.map((r) => r.expressId).sort((a, b) => a - b);
192
+ // Should include both walls via prefix expansion.
193
+ assert.ok(ids.includes(10));
194
+ assert.ok(ids.includes(20));
195
+ });
196
+
197
+ it('matches by IFC type', async () => {
198
+ const idx = await makeIndex('m', baseRows);
199
+ const results = queryTier1Indexes([idx], 'ifcdoor');
200
+ assert.strictEqual(results.length, 1);
201
+ assert.strictEqual(results[0].expressId, 30);
202
+ assert.strictEqual(results[0].matchField, 'type');
203
+ });
204
+
205
+ it('matches description / objectType substrings even with no token hit', async () => {
206
+ const idx = await makeIndex('m', baseRows);
207
+ const desc = queryTier1Indexes([idx], 'fire-rated');
208
+ assert.ok(desc.some((r) => r.expressId === 40 && r.matchField === 'description'));
209
+
210
+ const obj = queryTier1Indexes([idx], 'slab-floor');
211
+ assert.ok(obj.some((r) => r.expressId === 50 && r.matchField === 'objectType'));
212
+ });
213
+
214
+ it('respects the result limit and orders by descending score', async () => {
215
+ const many = Array.from({ length: 20 }, (_, i) => ({
216
+ expressId: 100 + i,
217
+ type: 'IFCWALL',
218
+ globalId: `g${String(i).padStart(21, 'x')}`,
219
+ name: i === 0 ? 'wall' : `wall-${i}`,
220
+ }));
221
+ const idx = await buildTier1Index('m', buildStore(many));
222
+ const results = queryTier1Indexes([idx], 'wall', { limit: 5 });
223
+ assert.strictEqual(results.length, 5);
224
+ // Exact-match "wall" row outranks the prefix/token-only siblings.
225
+ assert.strictEqual(results[0].name, 'wall');
226
+ for (let i = 1; i < results.length; i++) {
227
+ assert.ok(results[i - 1].score >= results[i].score);
228
+ }
229
+ });
230
+
231
+ it('merges results across multiple federated indexes and dedupes', async () => {
232
+ const a = await buildTier1Index('A', buildStore([
233
+ { expressId: 1, type: 'IFCWALL', globalId: 'aaaaaaaaaaaaaaaaaaaaaa', name: 'Lobby-Wall' },
234
+ ]));
235
+ const b = await buildTier1Index('B', buildStore([
236
+ { expressId: 2, type: 'IFCWALL', globalId: 'bbbbbbbbbbbbbbbbbbbbbb', name: 'Tower-Wall' },
237
+ ]));
238
+ const results = queryTier1Indexes([a, b], 'wall');
239
+ const tagged = results.map((r) => `${r.modelId}:${r.expressId}`).sort();
240
+ assert.deepStrictEqual(tagged, ['A:1', 'B:2']);
241
+ });
242
+ });
@@ -0,0 +1,448 @@
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
+ * Tier-1 search index — a per-model inverted token index built AFTER
7
+ * the IFC load completes (triggered by useSearchIndex once the store
8
+ * is present). Zero work on the load hot path; the build itself yields
9
+ * to the event loop every `chunkSize` rows via MessageChannel so a
10
+ * 4M-entity index never blocks input.
11
+ *
12
+ * Tier-1 replaces the Tier-0 linear scan transparently for any model
13
+ * that has finished indexing. Models still being indexed (or models
14
+ * that lost their index) fall back to Tier-0 without any caller
15
+ * awareness — see `SearchInline`.
16
+ *
17
+ * Indexed fields (all already materialised in EntityTable by the
18
+ * parser — no on-demand extraction is ever triggered):
19
+ * - name, globalId, description, objectType, IFC type name
20
+ *
21
+ * Tokenisation splits on whitespace AND the punctuation common in IFC
22
+ * names (`-`, `_`, `.`, `/`, `:`) so "Wall-EXT-001" and "Pset_WallCommon"
23
+ * break into useful tokens.
24
+ */
25
+
26
+ import type { IfcDataStore } from '@ifc-lite/parser';
27
+ import type { SearchResult, MatchField } from './tier0-scan.js';
28
+
29
+ export interface Tier1IndexEntry {
30
+ /** Row index in the original EntityTable. */
31
+ rowIndex: number;
32
+ expressId: number;
33
+ typeEnum: number;
34
+ /** IFC type name resolved once at build time (e.g. "IfcWall"). */
35
+ typeName: string;
36
+ typeNameLower: string;
37
+ name: string;
38
+ nameLower: string;
39
+ globalId: string;
40
+ description: string;
41
+ descriptionLower: string;
42
+ objectType: string;
43
+ objectTypeLower: string;
44
+ }
45
+
46
+ export interface Tier1Index {
47
+ modelId: string;
48
+ /** Sparse — populated rows only (same empty-row filter as Tier-0). */
49
+ entries: Tier1IndexEntry[];
50
+ /** token → indexes into `entries`; values are Uint32Arrays to avoid GC churn. */
51
+ tokenIndex: Map<string, Uint32Array>;
52
+ /** Sorted unique token list for prefix range queries (binary search). */
53
+ sortedTokens: string[];
54
+ /** GlobalId exact lookup → index into `entries`. Case-sensitive (IFC GUIDs are). */
55
+ globalIdMap: Map<string, number>;
56
+ /** Total entity count in the source store (for diagnostics, not query). */
57
+ sourceEntityCount: number;
58
+ /** Wall-clock build duration in ms. */
59
+ buildTimeMs: number;
60
+ }
61
+
62
+ export interface BuildTier1IndexOptions {
63
+ /** Rows per yield point. Default 20_000 — ~3 yields for a 147K model, ~220 for 4.4M. */
64
+ chunkSize?: number;
65
+ /** Abort the build early (e.g. model was removed or replaced). */
66
+ signal?: AbortSignal;
67
+ /** Optional progress callback — called after each chunk. Cheap observer only. */
68
+ onProgress?: (done: number, total: number) => void;
69
+ }
70
+
71
+ const DEFAULT_CHUNK_SIZE = 20_000;
72
+
73
+ // Same scoring ladder as Tier-0 so result ordering between the two tiers
74
+ // stays comparable while a multi-model search straddles both paths.
75
+ const SCORE = {
76
+ GUID_EXACT: 1000,
77
+ NAME_EXACT: 500,
78
+ NAME_PREFIX: 100,
79
+ TYPE_EXACT: 80,
80
+ TYPE_PREFIX: 60,
81
+ NAME_SUBSTR: 40,
82
+ OBJECTTYPE_SUBSTR: 20,
83
+ DESCRIPTION_SUBSTR: 10,
84
+ TOKEN_MATCH: 70,
85
+ } as const;
86
+
87
+ const TOKEN_SPLIT = /[\s\-_./:]+/;
88
+
89
+ function tokenize(value: string): string[] {
90
+ if (!value) return [];
91
+ const out: string[] = [];
92
+ const parts = value.toLowerCase().split(TOKEN_SPLIT);
93
+ for (const p of parts) {
94
+ // Skip empty fragments and one-char tokens that aren't digits. One-char
95
+ // letters produce ~O(entities) postings with essentially no selectivity,
96
+ // inflating the index for no query benefit.
97
+ if (p.length === 0) continue;
98
+ if (p.length === 1 && !/\d/.test(p)) continue;
99
+ out.push(p);
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** Yield control back to the event loop between chunks.
105
+ *
106
+ * Mirrors the pattern used by `packages/parser/src/columnar-parser.ts`
107
+ * but closes the MessageChannel ports in the fallback path — an unclosed
108
+ * port keeps Node's event loop alive and hangs the test runner on
109
+ * shutdown. `scheduler.yield` (when present) and `setImmediate` (Node)
110
+ * don't have this problem.
111
+ */
112
+ function yieldToEventLoop(): Promise<void> {
113
+ const maybeScheduler = (globalThis as typeof globalThis & {
114
+ scheduler?: { yield?: () => Promise<void> };
115
+ }).scheduler;
116
+ if (typeof maybeScheduler?.yield === 'function') {
117
+ return maybeScheduler.yield();
118
+ }
119
+ if (typeof setImmediate === 'function') {
120
+ return new Promise<void>((resolve) => {
121
+ setImmediate(() => resolve());
122
+ });
123
+ }
124
+ return new Promise<void>((resolve) => {
125
+ const channel = new MessageChannel();
126
+ channel.port1.onmessage = () => {
127
+ channel.port1.close();
128
+ channel.port2.close();
129
+ resolve();
130
+ };
131
+ channel.port2.postMessage(null);
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Build a Tier-1 index over one model. Runs on the main thread but yields
137
+ * between chunks so input latency stays below ~16 ms per yield point.
138
+ *
139
+ * Must only be called AFTER `store` is fully populated (i.e. the model's
140
+ * `ifcDataStore` is non-null in the viewer store). Calling during the load
141
+ * phase is a bug — the caller should gate on the model-ready event.
142
+ */
143
+ export async function buildTier1Index(
144
+ modelId: string,
145
+ store: IfcDataStore,
146
+ options: BuildTier1IndexOptions = {},
147
+ ): Promise<Tier1Index> {
148
+ const start = performance.now();
149
+ const chunkSize = options.chunkSize ?? DEFAULT_CHUNK_SIZE;
150
+ const signal = options.signal;
151
+ const onProgress = options.onProgress;
152
+
153
+ const table = store.entities;
154
+ const strings = store.strings;
155
+ const count = table.count;
156
+
157
+ const expressIdCol = table.expressId;
158
+ const typeEnumCol = table.typeEnum;
159
+ const nameCol = table.name;
160
+ const globalIdCol = table.globalId;
161
+ const descriptionCol = table.description;
162
+ const objectTypeCol = table.objectType;
163
+
164
+ const entries: Tier1IndexEntry[] = [];
165
+ // Build token postings as number[] arrays then freeze each to Uint32Array
166
+ // at the end — avoids a growing Uint32Array copy per insertion.
167
+ const pending = new Map<string, number[]>();
168
+ const globalIdMap = new Map<string, number>();
169
+
170
+ for (let start = 0; start < count; start += chunkSize) {
171
+ if (signal?.aborted) {
172
+ throw new DOMException('buildTier1Index aborted', 'AbortError');
173
+ }
174
+ const end = Math.min(start + chunkSize, count);
175
+ for (let i = start; i < end; i++) {
176
+ const nIdx = nameCol[i];
177
+ const gIdx = globalIdCol[i];
178
+ const dIdx = descriptionCol[i];
179
+ const oIdx = objectTypeCol[i];
180
+ // Same empty-row shortcut as Tier-0: rows with no searchable string
181
+ // slot at all are skipped entirely.
182
+ if (nIdx === 0 && gIdx === 0 && dIdx === 0 && oIdx === 0) continue;
183
+
184
+ const expressId = expressIdCol[i];
185
+ const name = nIdx !== 0 ? strings.get(nIdx) : '';
186
+ const globalId = gIdx !== 0 ? strings.get(gIdx) : '';
187
+ const description = dIdx !== 0 ? strings.get(dIdx) : '';
188
+ const objectType = oIdx !== 0 ? strings.get(oIdx) : '';
189
+ const typeName = table.getTypeName(expressId);
190
+
191
+ const entryIndex = entries.length;
192
+ entries.push({
193
+ rowIndex: i,
194
+ expressId,
195
+ typeEnum: typeEnumCol[i],
196
+ typeName,
197
+ typeNameLower: typeName.toLowerCase(),
198
+ name,
199
+ nameLower: name.toLowerCase(),
200
+ globalId,
201
+ description,
202
+ descriptionLower: description.toLowerCase(),
203
+ objectType,
204
+ objectTypeLower: objectType.toLowerCase(),
205
+ });
206
+
207
+ if (globalId) globalIdMap.set(globalId, entryIndex);
208
+
209
+ // Insert all tokens from name, type, description, objectType — NOT
210
+ // globalId (GUIDs are opaque 22-char strings; token-matching them
211
+ // just bloats the index).
212
+ const seen = new Set<string>();
213
+ const addTokens = (s: string) => {
214
+ for (const t of tokenize(s)) {
215
+ if (seen.has(t)) continue;
216
+ seen.add(t);
217
+ let list = pending.get(t);
218
+ if (!list) {
219
+ list = [];
220
+ pending.set(t, list);
221
+ }
222
+ list.push(entryIndex);
223
+ }
224
+ };
225
+ addTokens(name);
226
+ addTokens(typeName);
227
+ addTokens(description);
228
+ addTokens(objectType);
229
+ }
230
+
231
+ onProgress?.(end, count);
232
+ if (end < count) await yieldToEventLoop();
233
+ }
234
+
235
+ const tokenIndex = new Map<string, Uint32Array>();
236
+ for (const [token, list] of pending) {
237
+ tokenIndex.set(token, Uint32Array.from(list));
238
+ }
239
+ const sortedTokens = Array.from(tokenIndex.keys()).sort();
240
+
241
+ return {
242
+ modelId,
243
+ entries,
244
+ tokenIndex,
245
+ sortedTokens,
246
+ globalIdMap,
247
+ sourceEntityCount: count,
248
+ buildTimeMs: performance.now() - start,
249
+ };
250
+ }
251
+
252
+ /** Binary-search the sorted-tokens array for all tokens starting with `prefix`.
253
+ * Returns the half-open range [lo, hi) in `sortedTokens`. */
254
+ function prefixRange(sortedTokens: readonly string[], prefix: string): [number, number] {
255
+ if (!prefix) return [0, 0];
256
+ let lo = 0;
257
+ let hi = sortedTokens.length;
258
+ // Lower bound — first index with sortedTokens[i] >= prefix.
259
+ while (lo < hi) {
260
+ const mid = (lo + hi) >>> 1;
261
+ if (sortedTokens[mid] < prefix) lo = mid + 1;
262
+ else hi = mid;
263
+ }
264
+ const start = lo;
265
+ // Upper bound — first index with sortedTokens[i] NOT starting with prefix.
266
+ hi = sortedTokens.length;
267
+ let end = start;
268
+ // Bump the last char of `prefix` by one to find the first token past the
269
+ // prefix range. Works for any code point short of U+FFFF.
270
+ const bump = prefix.slice(0, -1) + String.fromCharCode(prefix.charCodeAt(prefix.length - 1) + 1);
271
+ let l = start;
272
+ while (l < hi) {
273
+ const mid = (l + hi) >>> 1;
274
+ if (sortedTokens[mid] < bump) l = mid + 1;
275
+ else hi = mid;
276
+ }
277
+ end = l;
278
+ return [start, end];
279
+ }
280
+
281
+ interface ScoredCandidate {
282
+ entryIndex: number;
283
+ score: number;
284
+ matchField: MatchField;
285
+ }
286
+
287
+ /**
288
+ * Query one or more Tier-1 indexes. Results from all indexes are merged,
289
+ * deduped, sorted by descending score, and capped at `limit`. The shape
290
+ * matches `runTier0Scan` so `SearchInline` can concatenate Tier-0 and
291
+ * Tier-1 result streams without a converter.
292
+ */
293
+ export function queryTier1Indexes(
294
+ indexes: Iterable<Tier1Index>,
295
+ query: string,
296
+ options: { limit?: number } = {},
297
+ ): SearchResult[] {
298
+ const trimmed = query.trim();
299
+ if (trimmed.length < 1) return [];
300
+ const limit = options.limit ?? 50;
301
+ const needle = trimmed.toLowerCase();
302
+ const queryTokens = tokenize(trimmed);
303
+ const looksLikeGuid = trimmed.length === 22 && /^[A-Za-z0-9_$]{22}$/.test(trimmed);
304
+
305
+ const results: SearchResult[] = [];
306
+
307
+ for (const index of indexes) {
308
+ const scored = scoreOneIndex(index, trimmed, needle, queryTokens, looksLikeGuid);
309
+ for (const c of scored) {
310
+ const e = index.entries[c.entryIndex];
311
+ results.push({
312
+ modelId: index.modelId,
313
+ expressId: e.expressId,
314
+ typeName: e.typeName,
315
+ name: e.name,
316
+ globalId: e.globalId,
317
+ description: e.description,
318
+ objectType: e.objectType,
319
+ matchField: c.matchField,
320
+ score: c.score,
321
+ });
322
+ }
323
+ }
324
+
325
+ results.sort((a, b) => {
326
+ if (b.score !== a.score) return b.score - a.score;
327
+ if (a.modelId !== b.modelId) return a.modelId < b.modelId ? -1 : 1;
328
+ return a.expressId - b.expressId;
329
+ });
330
+
331
+ const seen = new Set<string>();
332
+ const out: SearchResult[] = [];
333
+ for (const r of results) {
334
+ const key = `${r.modelId}:${r.expressId}`;
335
+ if (seen.has(key)) continue;
336
+ seen.add(key);
337
+ out.push(r);
338
+ if (out.length >= limit) break;
339
+ }
340
+ return out;
341
+ }
342
+
343
+ function scoreOneIndex(
344
+ index: Tier1Index,
345
+ rawQuery: string,
346
+ needle: string,
347
+ queryTokens: string[],
348
+ looksLikeGuid: boolean,
349
+ ): ScoredCandidate[] {
350
+ const scored = new Map<number, ScoredCandidate>();
351
+
352
+ const bump = (entryIndex: number, score: number, matchField: MatchField) => {
353
+ const prev = scored.get(entryIndex);
354
+ if (!prev || prev.score < score) {
355
+ scored.set(entryIndex, { entryIndex, score, matchField });
356
+ }
357
+ };
358
+
359
+ // GUID exact — O(1) and highest-ranked.
360
+ if (looksLikeGuid) {
361
+ const hit = index.globalIdMap.get(rawQuery);
362
+ if (hit !== undefined) bump(hit, SCORE.GUID_EXACT, 'globalId');
363
+ }
364
+
365
+ // Candidate pool from the token index. For each query token:
366
+ // 1. Exact token postings → TOKEN_MATCH per hit
367
+ // 2. Prefix-matching tokens via sortedTokens range → TOKEN_MATCH
368
+ // When the user typed a single token we still do a prefix expansion so
369
+ // "wal" surfaces results with a "wall" token before they finish typing.
370
+ const candidateSet = new Set<number>();
371
+ const queryTokenHits = new Map<number, number>(); // entryIndex → count of query tokens matched
372
+
373
+ for (const qt of queryTokens) {
374
+ const matchedEntries = new Set<number>();
375
+ const exact = index.tokenIndex.get(qt);
376
+ if (exact) {
377
+ for (let i = 0; i < exact.length; i++) matchedEntries.add(exact[i]);
378
+ }
379
+ // Prefix expansion for all query tokens (cheap when token count is small).
380
+ const [plo, phi] = prefixRange(index.sortedTokens, qt);
381
+ for (let ti = plo; ti < phi; ti++) {
382
+ const tok = index.sortedTokens[ti];
383
+ if (tok === qt) continue;
384
+ const list = index.tokenIndex.get(tok);
385
+ if (!list) continue;
386
+ for (let i = 0; i < list.length; i++) matchedEntries.add(list[i]);
387
+ }
388
+ for (const entryIndex of matchedEntries) {
389
+ candidateSet.add(entryIndex);
390
+ queryTokenHits.set(entryIndex, (queryTokenHits.get(entryIndex) ?? 0) + 1);
391
+ }
392
+ }
393
+
394
+ // Verify candidates against the full lowered strings first — this
395
+ // resolves the matchField accurately (name / type / objectType /
396
+ // description) when any substring-level match lands. If nothing
397
+ // substring-level matches, the entry still qualifies via full token
398
+ // coverage and falls back to a generic TOKEN_MATCH.
399
+ for (const entryIndex of candidateSet) {
400
+ const e = index.entries[entryIndex];
401
+ const preSize = scored.size;
402
+ scoreEntry(e, needle, entryIndex, bump);
403
+ if (scored.size === preSize) {
404
+ const hits = queryTokenHits.get(entryIndex) ?? 0;
405
+ if (queryTokens.length > 0 && hits === queryTokens.length) {
406
+ bump(entryIndex, SCORE.TOKEN_MATCH, 'name');
407
+ }
408
+ }
409
+ }
410
+
411
+ // Substring-fallback pass: for very short needles (<3 chars) OR needles
412
+ // that contain punctuation the tokenizer would strip, the token index
413
+ // has no coverage. Do a last-ditch linear sweep over the entries array.
414
+ // This is still bounded by `entries.length` (populated rows only) not
415
+ // the raw entity count, so it stays ~10× cheaper than Tier-0.
416
+ if (candidateSet.size === 0 && (needle.length < 3 || /[\s\-_./:]/.test(needle))) {
417
+ const entries = index.entries;
418
+ for (let i = 0; i < entries.length; i++) {
419
+ scoreEntry(entries[i], needle, i, bump);
420
+ }
421
+ }
422
+
423
+ return Array.from(scored.values());
424
+ }
425
+
426
+ function scoreEntry(
427
+ e: Tier1IndexEntry,
428
+ needle: string,
429
+ entryIndex: number,
430
+ bump: (entryIndex: number, score: number, matchField: MatchField) => void,
431
+ ): void {
432
+ if (e.nameLower) {
433
+ if (e.nameLower === needle) bump(entryIndex, SCORE.NAME_EXACT, 'name');
434
+ else if (e.nameLower.startsWith(needle)) bump(entryIndex, SCORE.NAME_PREFIX, 'name');
435
+ else if (e.nameLower.includes(needle)) bump(entryIndex, SCORE.NAME_SUBSTR, 'name');
436
+ }
437
+ if (e.typeNameLower === needle) bump(entryIndex, SCORE.TYPE_EXACT, 'type');
438
+ else if (e.typeNameLower.startsWith(needle)) bump(entryIndex, SCORE.TYPE_PREFIX, 'type');
439
+ if (e.objectTypeLower && e.objectTypeLower.includes(needle)) {
440
+ bump(entryIndex, SCORE.OBJECTTYPE_SUBSTR, 'objectType');
441
+ }
442
+ if (e.descriptionLower && e.descriptionLower.includes(needle)) {
443
+ bump(entryIndex, SCORE.DESCRIPTION_SUBSTR, 'description');
444
+ }
445
+ }
446
+
447
+ /** Exposed for tests only. */
448
+ export const __internal = { tokenize, prefixRange };