@ifc-lite/viewer 1.17.4 → 1.18.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 (196) hide show
  1. package/.turbo/turbo-build.log +20 -17
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +630 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
  12. package/dist/assets/index-COnQRuqY.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
  14. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +13 -13
  20. package/src/App.tsx +16 -2
  21. package/src/apache-arrow.d.ts +30 -0
  22. package/src/components/viewer/AddElementPanel.tsx +758 -0
  23. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  24. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  25. package/src/components/viewer/ChatPanel.tsx +259 -93
  26. package/src/components/viewer/CommandPalette.tsx +56 -7
  27. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  28. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  29. package/src/components/viewer/ExportDialog.tsx +19 -1
  30. package/src/components/viewer/MainToolbar.tsx +73 -13
  31. package/src/components/viewer/PropertiesPanel.tsx +237 -23
  32. package/src/components/viewer/SearchInline.tsx +669 -0
  33. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  34. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  35. package/src/components/viewer/SearchModal.text.tsx +388 -0
  36. package/src/components/viewer/SearchModal.tsx +235 -0
  37. package/src/components/viewer/SettingsPage.tsx +252 -101
  38. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  39. package/src/components/viewer/ToolOverlays.tsx +5 -0
  40. package/src/components/viewer/ViewerLayout.tsx +25 -4
  41. package/src/components/viewer/Viewport.tsx +25 -3
  42. package/src/components/viewer/ViewportContainer.tsx +51 -64
  43. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  44. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  45. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  46. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  47. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  48. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  49. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  50. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  52. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  53. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  54. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  55. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  56. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  57. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  58. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  59. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  60. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  61. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  62. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  63. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  64. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  65. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  66. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  67. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  68. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  69. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  70. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  71. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  72. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  73. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  74. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  75. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  76. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  77. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  78. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  79. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  80. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  81. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  82. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  83. package/src/components/viewer/selectionHandlers.ts +446 -0
  84. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  85. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  86. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  87. package/src/components/viewer/useAnimationLoop.ts +9 -1
  88. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  89. package/src/components/viewer/useMouseControls.ts +9 -1
  90. package/src/components/viewer/useRenderUpdates.ts +1 -1
  91. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  92. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  93. package/src/hooks/useIfcFederation.ts +326 -71
  94. package/src/hooks/useIfcLoader.ts +23 -10
  95. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  96. package/src/hooks/useSandbox.ts +1 -1
  97. package/src/hooks/useSearchIndex.ts +125 -0
  98. package/src/hooks/useViewControls.ts +13 -5
  99. package/src/index.css +550 -10
  100. package/src/lib/desktop-entitlement.ts +2 -4
  101. package/src/lib/geo/cesium-bridge.ts +15 -7
  102. package/src/lib/geo/effective-georef.test.ts +73 -0
  103. package/src/lib/geo/effective-georef.ts +111 -0
  104. package/src/lib/geo/reproject.ts +105 -19
  105. package/src/lib/llm/byok-guard.test.ts +77 -0
  106. package/src/lib/llm/byok-guard.ts +39 -0
  107. package/src/lib/llm/free-models.test.ts +0 -6
  108. package/src/lib/llm/models.ts +104 -42
  109. package/src/lib/llm/stream-client.ts +74 -110
  110. package/src/lib/llm/stream-direct.test.ts +130 -0
  111. package/src/lib/llm/stream-direct.ts +316 -0
  112. package/src/lib/llm/system-prompt.test.ts +14 -0
  113. package/src/lib/llm/system-prompt.ts +102 -1
  114. package/src/lib/llm/types.ts +20 -2
  115. package/src/lib/recent-files.ts +38 -4
  116. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  117. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  118. package/src/lib/scripts/templates.ts +7 -0
  119. package/src/lib/search/common-ifc-types.ts +36 -0
  120. package/src/lib/search/filter-evaluate.test.ts +537 -0
  121. package/src/lib/search/filter-evaluate.ts +610 -0
  122. package/src/lib/search/filter-rules.test.ts +119 -0
  123. package/src/lib/search/filter-rules.ts +198 -0
  124. package/src/lib/search/filter-schema.test.ts +233 -0
  125. package/src/lib/search/filter-schema.ts +146 -0
  126. package/src/lib/search/recent-searches.test.ts +116 -0
  127. package/src/lib/search/recent-searches.ts +93 -0
  128. package/src/lib/search/result-export.test.ts +101 -0
  129. package/src/lib/search/result-export.ts +104 -0
  130. package/src/lib/search/saved-filters.test.ts +118 -0
  131. package/src/lib/search/saved-filters.ts +154 -0
  132. package/src/lib/search/tier0-scan.test.ts +196 -0
  133. package/src/lib/search/tier0-scan.ts +237 -0
  134. package/src/lib/search/tier1-index.test.ts +242 -0
  135. package/src/lib/search/tier1-index.ts +448 -0
  136. package/src/main.tsx +1 -10
  137. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  138. package/src/sdk/adapters/export-adapter.ts +404 -1
  139. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  140. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  141. package/src/sdk/adapters/model-compat.ts +8 -2
  142. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  143. package/src/sdk/adapters/store-adapter.ts +201 -0
  144. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  145. package/src/sdk/local-backend.ts +16 -8
  146. package/src/services/api-keys.ts +73 -0
  147. package/src/services/desktop-export.ts +3 -1
  148. package/src/services/desktop-native-metadata.ts +41 -18
  149. package/src/services/file-dialog.ts +4 -1
  150. package/src/services/tauri-modules.d.ts +25 -0
  151. package/src/store/basketVisibleSet.ts +3 -0
  152. package/src/store/constants.ts +20 -2
  153. package/src/store/globalId.ts +4 -1
  154. package/src/store/index.ts +82 -6
  155. package/src/store/slices/addElementMeshes.ts +365 -0
  156. package/src/store/slices/addElementSlice.ts +275 -0
  157. package/src/store/slices/annotationsSlice.test.ts +133 -0
  158. package/src/store/slices/annotationsSlice.ts +251 -0
  159. package/src/store/slices/cesiumSlice.ts +5 -0
  160. package/src/store/slices/chatSlice.test.ts +6 -76
  161. package/src/store/slices/chatSlice.ts +17 -58
  162. package/src/store/slices/dataSlice.test.ts +23 -4
  163. package/src/store/slices/dataSlice.ts +1 -1
  164. package/src/store/slices/modelSlice.test.ts +67 -9
  165. package/src/store/slices/modelSlice.ts +39 -7
  166. package/src/store/slices/mutationSlice.ts +964 -3
  167. package/src/store/slices/overlayCompositor.test.ts +164 -0
  168. package/src/store/slices/overlaySlice.test.ts +93 -0
  169. package/src/store/slices/overlaySlice.ts +151 -0
  170. package/src/store/slices/pinboardSlice.test.ts +6 -1
  171. package/src/store/slices/playbackSlice.ts +128 -0
  172. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  173. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  174. package/src/store/slices/scheduleSlice.test.ts +694 -0
  175. package/src/store/slices/scheduleSlice.ts +1330 -0
  176. package/src/store/slices/searchSlice.test.ts +342 -0
  177. package/src/store/slices/searchSlice.ts +341 -0
  178. package/src/store/slices/sectionSlice.test.ts +87 -7
  179. package/src/store/slices/sectionSlice.ts +151 -5
  180. package/src/store/slices/selectionSlice.test.ts +46 -0
  181. package/src/store/slices/selectionSlice.ts +20 -0
  182. package/src/store/slices/uiSlice.ts +28 -5
  183. package/src/store/types.ts +26 -0
  184. package/src/store.ts +14 -0
  185. package/src/utils/nativeSpatialDataStore.ts +4 -1
  186. package/src/utils/viewportUtils.ts +7 -2
  187. package/src/vite-env.d.ts +0 -4
  188. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  189. package/dist/assets/ids-B4jTqB1O.js +0 -1
  190. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  191. package/dist/assets/index-DckuDqlv.css +0 -1
  192. package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
  193. package/src/components/viewer/UpgradePage.tsx +0 -71
  194. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  195. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  196. package/src/lib/llm/clerk-auth.ts +0 -62
@@ -0,0 +1,154 @@
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
+ * Saved filter presets — localStorage-backed catalog of named
7
+ * `FilterRule[]` snapshots. Mirrors the `filter_presets` table from
8
+ * the Tauri-side `filter.rs` engine: each preset stores a name, the
9
+ * rule list, and the AND/OR combinator. Presets are surfaced in the
10
+ * builder toolbar as a dropdown; clicking one replaces the current
11
+ * filter state.
12
+ *
13
+ * Pure module — safe to import from tests (stubs storage when
14
+ * `window.localStorage` is unavailable). Names are trimmed and
15
+ * deduplicated by case-insensitive match, so re-saving a preset under
16
+ * the same name overwrites it (matching the Rust ON CONFLICT behaviour).
17
+ */
18
+
19
+ import {
20
+ parseFilterRules,
21
+ type Combinator,
22
+ type FilterRule,
23
+ } from './filter-rules.js';
24
+
25
+ const STORAGE_KEY = 'ifc-lite:search:saved-filters';
26
+ const MAX_ENTRIES = 50;
27
+ const MAX_NAME_LEN = 80;
28
+
29
+ export interface SavedFilterPreset {
30
+ name: string;
31
+ combinator: Combinator;
32
+ rules: FilterRule[];
33
+ /** Wall-clock ms when this preset was last written. */
34
+ updatedAt: number;
35
+ }
36
+
37
+ interface StorageLike {
38
+ getItem(key: string): string | null;
39
+ setItem(key: string, value: string): void;
40
+ removeItem(key: string): void;
41
+ }
42
+
43
+ function safeStorage(): StorageLike | null {
44
+ try {
45
+ const ls = (globalThis as typeof globalThis & { localStorage?: StorageLike }).localStorage;
46
+ if (!ls) return null;
47
+ const probe = `${STORAGE_KEY}:__probe__`;
48
+ ls.setItem(probe, '1');
49
+ ls.removeItem(probe);
50
+ return ls;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function readRaw(): SavedFilterPreset[] {
57
+ const ls = safeStorage();
58
+ if (!ls) return [];
59
+ const raw = ls.getItem(STORAGE_KEY);
60
+ if (!raw) return [];
61
+ try {
62
+ const parsed: unknown = JSON.parse(raw);
63
+ if (!Array.isArray(parsed)) return [];
64
+ const out: SavedFilterPreset[] = [];
65
+ for (const item of parsed) {
66
+ if (typeof item !== 'object' || item === null) continue;
67
+ const o = item as Record<string, unknown>;
68
+ const name = typeof o.name === 'string' ? o.name.trim() : '';
69
+ if (!name || name.length > MAX_NAME_LEN) continue;
70
+ const combinator: Combinator = o.combinator === 'OR' ? 'OR' : 'AND';
71
+ const rules = parseFilterRules(o.rules);
72
+ const updatedAt = typeof o.updatedAt === 'number' ? o.updatedAt : Date.now();
73
+ out.push({ name, combinator, rules, updatedAt });
74
+ }
75
+ return out;
76
+ } catch {
77
+ ls.removeItem(STORAGE_KEY);
78
+ return [];
79
+ }
80
+ }
81
+
82
+ function writeRaw(list: SavedFilterPreset[]): void {
83
+ const ls = safeStorage();
84
+ if (!ls) return;
85
+ try {
86
+ ls.setItem(STORAGE_KEY, JSON.stringify(list));
87
+ } catch {
88
+ // Quota exceeded — swallow; the next save attempt may succeed.
89
+ }
90
+ }
91
+
92
+ /** All saved presets, sorted by name (A→Z) for stable UI ordering. */
93
+ export function loadSavedFilters(): SavedFilterPreset[] {
94
+ const list = readRaw();
95
+ list.sort((a, b) => a.name.localeCompare(b.name));
96
+ return list;
97
+ }
98
+
99
+ /**
100
+ * Insert or update a preset by case-insensitive name match. Returns the
101
+ * resulting full catalog (sorted) so callers can refresh UI without a
102
+ * second read.
103
+ */
104
+ export function saveFilter(
105
+ name: string,
106
+ combinator: Combinator,
107
+ rules: readonly FilterRule[],
108
+ ): SavedFilterPreset[] {
109
+ const trimmed = name.trim();
110
+ if (!trimmed || trimmed.length > MAX_NAME_LEN) return loadSavedFilters();
111
+
112
+ const existing = readRaw();
113
+ const idx = existing.findIndex((p) => p.name.toLowerCase() === trimmed.toLowerCase());
114
+ const preset: SavedFilterPreset = {
115
+ name: trimmed,
116
+ combinator,
117
+ // Defensive copy so callers can mutate their own array without
118
+ // corrupting the saved list (they share references via parseFilterRules
119
+ // on read, but write should snapshot).
120
+ rules: rules.map((r) => ({ ...r }) as FilterRule),
121
+ updatedAt: Date.now(),
122
+ };
123
+ if (idx >= 0) existing[idx] = preset;
124
+ else existing.unshift(preset);
125
+
126
+ // Cap on size — newest survive when capacity overflows.
127
+ const sortedByRecency = existing
128
+ .slice()
129
+ .sort((a, b) => b.updatedAt - a.updatedAt)
130
+ .slice(0, MAX_ENTRIES);
131
+ writeRaw(sortedByRecency);
132
+
133
+ return loadSavedFilters();
134
+ }
135
+
136
+ /** Delete a preset by exact (case-insensitive) name. Returns the new list. */
137
+ export function deleteSavedFilter(name: string): SavedFilterPreset[] {
138
+ const trimmed = name.trim().toLowerCase();
139
+ if (!trimmed) return loadSavedFilters();
140
+ const existing = readRaw();
141
+ const next = existing.filter((p) => p.name.toLowerCase() !== trimmed);
142
+ if (next.length === existing.length) return loadSavedFilters();
143
+ writeRaw(next);
144
+ return loadSavedFilters();
145
+ }
146
+
147
+ /** Wipe the entire catalog. */
148
+ export function clearSavedFilters(): void {
149
+ const ls = safeStorage();
150
+ if (!ls) return;
151
+ ls.removeItem(STORAGE_KEY);
152
+ }
153
+
154
+ export const __internal = { STORAGE_KEY, MAX_ENTRIES, MAX_NAME_LEN };
@@ -0,0 +1,196 @@
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 { runTier0Scan, type ScanModel } from './tier0-scan.js';
10
+
11
+ /**
12
+ * Build a minimal IfcDataStore-shaped object whose `entities` table is real
13
+ * (via @ifc-lite/data EntityTableBuilder) and whose remaining slots are the
14
+ * smallest stubs the scanner accepts. Tier-0 only touches `entities` +
15
+ * `strings`; everything else can be empty.
16
+ */
17
+ function buildStore(rows: Array<{
18
+ expressId: number;
19
+ type: string;
20
+ globalId: string;
21
+ name: string;
22
+ description?: string;
23
+ objectType?: string;
24
+ }>): IfcDataStore {
25
+ const strings = new StringTable();
26
+ const builder = new EntityTableBuilder(rows.length, strings);
27
+ for (const r of rows) {
28
+ builder.add(
29
+ r.expressId,
30
+ r.type,
31
+ r.globalId,
32
+ r.name,
33
+ r.description ?? '',
34
+ r.objectType ?? '',
35
+ false,
36
+ false,
37
+ );
38
+ }
39
+ const entities = builder.build();
40
+
41
+ const store = {
42
+ fileSize: 0,
43
+ schemaVersion: 'IFC4',
44
+ entityCount: rows.length,
45
+ parseTime: 0,
46
+ source: new Uint8Array(0),
47
+ entityIndex: { byId: { ranges: new Uint32Array(0), index: new Map() }, byType: new Map() },
48
+ strings,
49
+ entities,
50
+ properties: { count: 0 },
51
+ quantities: { count: 0 },
52
+ relationships: { count: 0 },
53
+ } as unknown as IfcDataStore;
54
+
55
+ return store;
56
+ }
57
+
58
+ function modelOf(id: string, store: IfcDataStore): ScanModel {
59
+ return { id, ifcDataStore: store };
60
+ }
61
+
62
+ describe('runTier0Scan', () => {
63
+ const baseRows = [
64
+ { expressId: 10, type: 'IFCWALL', globalId: '1abcdefghijklmnopqrstu', name: 'Wall-EXT-001' },
65
+ { expressId: 20, type: 'IFCWALL', globalId: '2abcdefghijklmnopqrstu', name: 'Wall-INT-002' },
66
+ { expressId: 30, type: 'IFCDOOR', globalId: '3abcdefghijklmnopqrstu', name: 'Door-A-201' },
67
+ { expressId: 40, type: 'IFCWINDOW', globalId: '4abcdefghijklmnopqrstu', name: 'WIN-North-1', description: 'fire-rated' },
68
+ { expressId: 50, type: 'IFCSLAB', globalId: '5abcdefghijklmnopqrstu', name: '', objectType: 'SLAB-FLOOR' },
69
+ ];
70
+
71
+ it('returns nothing for empty/whitespace queries', () => {
72
+ const store = buildStore(baseRows);
73
+ assert.deepStrictEqual(runTier0Scan([modelOf('m', store)], ''), []);
74
+ assert.deepStrictEqual(runTier0Scan([modelOf('m', store)], ' '), []);
75
+ });
76
+
77
+ it('handles models with no IfcDataStore', () => {
78
+ const results = runTier0Scan([{ id: 'm', ifcDataStore: null }], 'wall');
79
+ assert.deepStrictEqual(results, []);
80
+ });
81
+
82
+ it('matches by name substring (case-insensitive)', () => {
83
+ const store = buildStore(baseRows);
84
+ const results = runTier0Scan([modelOf('m', store)], 'wall');
85
+ const ids = results.map((r) => r.expressId).sort((a, b) => a - b);
86
+ assert.deepStrictEqual(ids, [10, 20]);
87
+ for (const r of results) {
88
+ assert.strictEqual(r.matchField, 'name');
89
+ }
90
+ });
91
+
92
+ it('exact GUID match wins (highest score, GUID fast path)', () => {
93
+ const store = buildStore(baseRows);
94
+ const results = runTier0Scan([modelOf('m', store)], '3abcdefghijklmnopqrstu');
95
+ assert.ok(results.length >= 1);
96
+ assert.strictEqual(results[0].expressId, 30);
97
+ assert.strictEqual(results[0].matchField, 'globalId');
98
+ // GUID exact (1000) should outrank everything else.
99
+ assert.ok(results[0].score >= 1000);
100
+ });
101
+
102
+ it('GUID fast path does not double-emit a row that the linear scan also returns', () => {
103
+ const store = buildStore(baseRows);
104
+ const results = runTier0Scan([modelOf('m', store)], '3abcdefghijklmnopqrstu');
105
+ const seen = new Set<string>();
106
+ for (const r of results) {
107
+ const key = `${r.modelId}:${r.expressId}`;
108
+ assert.ok(!seen.has(key), `duplicate row for ${key}`);
109
+ seen.add(key);
110
+ }
111
+ });
112
+
113
+ it('matches by IFC type prefix and exact', () => {
114
+ const store = buildStore(baseRows);
115
+ const exact = runTier0Scan([modelOf('m', store)], 'ifcwall');
116
+ // Two walls match by type.
117
+ assert.strictEqual(exact.filter((r) => r.matchField === 'type').length, 2);
118
+
119
+ const prefix = runTier0Scan([modelOf('m', store)], 'ifcwin');
120
+ const winner = prefix.find((r) => r.expressId === 40);
121
+ assert.ok(winner);
122
+ assert.strictEqual(winner!.matchField, 'type');
123
+ });
124
+
125
+ it('keeps the MAX score across fields (a substring name hit can be outranked by a type-exact hit)', () => {
126
+ // The entity's name contains "wall" (NAME_SUBSTR=40) AND its type
127
+ // is exactly "IfcWall" (TYPE_EXACT=80). The match should win on
128
+ // type, not be capped at name. Without this, multi-model results
129
+ // where one model used Tier-1 (max-across-fields) and another
130
+ // used Tier-0 (short-circuit) would rank the same logical match
131
+ // differently — breaking the comparable-ordering guarantee.
132
+ const store = buildStore([
133
+ { expressId: 10, type: 'IFCWALL', globalId: '1abcdefghijklmnopqrstu', name: 'metal-wall-cladding' },
134
+ ]);
135
+ const out = runTier0Scan([modelOf('m', store)], 'ifcwall');
136
+ assert.strictEqual(out.length, 1);
137
+ assert.strictEqual(out[0].matchField, 'type', 'type should outrank a substring name hit');
138
+ // 80 (TYPE_EXACT) ≫ 40 (NAME_SUBSTR).
139
+ assert.strictEqual(out[0].score, 80);
140
+ });
141
+
142
+ it('matches by description and objectType when name/type miss', () => {
143
+ const store = buildStore(baseRows);
144
+
145
+ const desc = runTier0Scan([modelOf('m', store)], 'fire-rated');
146
+ assert.strictEqual(desc.length, 1);
147
+ assert.strictEqual(desc[0].expressId, 40);
148
+ assert.strictEqual(desc[0].matchField, 'description');
149
+
150
+ const obj = runTier0Scan([modelOf('m', store)], 'slab-floor');
151
+ assert.strictEqual(obj.length, 1);
152
+ assert.strictEqual(obj[0].expressId, 50);
153
+ assert.strictEqual(obj[0].matchField, 'objectType');
154
+ });
155
+
156
+ it('respects the result limit and orders by descending score', () => {
157
+ const many = Array.from({ length: 20 }, (_, i) => ({
158
+ expressId: 100 + i,
159
+ type: 'IFCWALL',
160
+ globalId: `g${String(i).padStart(21, 'x')}`,
161
+ name: i === 0 ? 'wall' : `wall-${i}`,
162
+ }));
163
+ const store = buildStore(many);
164
+ const results = runTier0Scan([modelOf('m', store)], 'wall', { limit: 5 });
165
+ assert.strictEqual(results.length, 5);
166
+ // Exact-match row 'wall' must come first (NAME_EXACT > NAME_PREFIX).
167
+ assert.strictEqual(results[0].name, 'wall');
168
+ for (let i = 1; i < results.length; i++) {
169
+ assert.ok(results[i - 1].score >= results[i].score, 'score must be non-increasing');
170
+ }
171
+ });
172
+
173
+ it('searches across multiple federated models and tags results with modelId', () => {
174
+ const a = buildStore([
175
+ { expressId: 1, type: 'IFCWALL', globalId: 'aaaaaaaaaaaaaaaaaaaaaa', name: 'Lobby-Wall' },
176
+ ]);
177
+ const b = buildStore([
178
+ { expressId: 2, type: 'IFCWALL', globalId: 'bbbbbbbbbbbbbbbbbbbbbb', name: 'Tower-Wall' },
179
+ ]);
180
+ const results = runTier0Scan([modelOf('A', a), modelOf('B', b)], 'wall');
181
+ const tagged = results.map((r) => `${r.modelId}:${r.expressId}`).sort();
182
+ assert.deepStrictEqual(tagged, ['A:1', 'B:2']);
183
+ });
184
+
185
+ it('skips rows where every searchable string slot is empty (perf shortcut)', () => {
186
+ // Row 60 has no name/globalId/description/objectType — must NOT appear
187
+ // in any text search (and must not crash the scanner).
188
+ const rows = [
189
+ ...baseRows,
190
+ { expressId: 60, type: 'IFCWALL', globalId: '', name: '' },
191
+ ];
192
+ const store = buildStore(rows);
193
+ const results = runTier0Scan([modelOf('m', store)], 'wall');
194
+ assert.ok(!results.some((r) => r.expressId === 60));
195
+ });
196
+ });
@@ -0,0 +1,237 @@
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-0 search scan: linear pass over EntityTable columns that are
7
+ * ALREADY POPULATED during normal IFC parsing. Adds zero work to the
8
+ * load hot path — `name` and `globalId` are batch-extracted by the
9
+ * parser for geometry entities + types via `batchExtractGlobalIdAndName`,
10
+ * and we read them straight out of the columnar TypedArrays.
11
+ *
12
+ * Crucially: this never calls `extractEntityAttributesOnDemand` (see
13
+ * AGENTS.md §2). Empty-string rows are skipped via O(1) checks against
14
+ * the StringTable index 0 (which is the canonical empty string).
15
+ *
16
+ * Tier-1 (worker-built inverted index) and Tier-3 (DuckDB SQL) layer
17
+ * on top of the same `SearchResult` shape in later phases.
18
+ */
19
+
20
+ import type { IfcDataStore } from '@ifc-lite/parser';
21
+
22
+ /** A federated model carries its IFC store + an offset into the global ID space. */
23
+ export interface ScanModel {
24
+ id: string;
25
+ ifcDataStore: IfcDataStore | null;
26
+ }
27
+
28
+ export type MatchField = 'globalId' | 'name' | 'type' | 'description' | 'objectType';
29
+
30
+ export interface SearchResult {
31
+ modelId: string;
32
+ /** Local express ID inside the source model — federation conversion happens in the UI layer. */
33
+ expressId: number;
34
+ typeName: string;
35
+ name: string;
36
+ globalId: string;
37
+ description: string;
38
+ objectType: string;
39
+ /** Field that produced the highest score for this entity. */
40
+ matchField: MatchField;
41
+ score: number;
42
+ }
43
+
44
+ export interface ScanOptions {
45
+ /** Maximum results returned (sorted by descending score). Default 50. */
46
+ limit?: number;
47
+ /** If set, abort after this many entities scanned per model (perf safety). */
48
+ maxScanPerModel?: number;
49
+ }
50
+
51
+ const DEFAULT_LIMIT = 50;
52
+
53
+ /** Scoring weights — higher = better. Stable enough that downstream UI can compare. */
54
+ const SCORE = {
55
+ GUID_EXACT: 1000,
56
+ NAME_EXACT: 500,
57
+ NAME_PREFIX: 100,
58
+ TYPE_EXACT: 80,
59
+ TYPE_PREFIX: 60,
60
+ NAME_SUBSTR: 40,
61
+ OBJECTTYPE_SUBSTR: 20,
62
+ DESCRIPTION_SUBSTR: 10,
63
+ } as const;
64
+
65
+ /**
66
+ * Run a Tier-0 search across one or more models.
67
+ *
68
+ * Returns up to `limit` results sorted by descending score, then by
69
+ * (modelId, expressId) for stable ordering. Empty/whitespace queries
70
+ * return an empty array — the caller should not even open the popover.
71
+ */
72
+ export function runTier0Scan(
73
+ models: readonly ScanModel[],
74
+ query: string,
75
+ options: ScanOptions = {},
76
+ ): SearchResult[] {
77
+ const trimmed = query.trim();
78
+ if (trimmed.length < 1) return [];
79
+
80
+ const limit = options.limit ?? DEFAULT_LIMIT;
81
+ const needle = trimmed.toLowerCase();
82
+
83
+ // GUID exact-match fast path — IFC GlobalIds are 22-char base64-like strings.
84
+ // We test the trimmed (case-sensitive) form because GUIDs are case-sensitive.
85
+ const looksLikeGuid = trimmed.length === 22 && /^[A-Za-z0-9_$]{22}$/.test(trimmed);
86
+
87
+ const collected: SearchResult[] = [];
88
+
89
+ for (const model of models) {
90
+ const store = model.ifcDataStore;
91
+ if (!store) continue;
92
+ const table = store.entities;
93
+ if (!table || table.count === 0) continue;
94
+
95
+ // GUID fast path: O(1) lookup, push and continue (still scan others
96
+ // for additional substring matches, but skip the row-level pass for
97
+ // this particular GUID).
98
+ if (looksLikeGuid) {
99
+ const exactExpressId = table.getExpressIdByGlobalId(trimmed);
100
+ if (exactExpressId > 0) {
101
+ collected.push({
102
+ modelId: model.id,
103
+ expressId: exactExpressId,
104
+ typeName: table.getTypeName(exactExpressId),
105
+ name: table.getName(exactExpressId),
106
+ globalId: trimmed,
107
+ description: table.getDescription(exactExpressId),
108
+ objectType: table.getObjectType(exactExpressId),
109
+ matchField: 'globalId',
110
+ score: SCORE.GUID_EXACT,
111
+ });
112
+ }
113
+ }
114
+
115
+ scanModel(model.id, store, needle, options.maxScanPerModel, collected);
116
+ }
117
+
118
+ // Stable sort: score desc, then modelId asc, then expressId asc.
119
+ collected.sort((a, b) => {
120
+ if (b.score !== a.score) return b.score - a.score;
121
+ if (a.modelId !== b.modelId) return a.modelId < b.modelId ? -1 : 1;
122
+ return a.expressId - b.expressId;
123
+ });
124
+
125
+ // Dedupe — the GUID fast path may have added a row that the linear pass
126
+ // also matched. Keep the highest-scoring instance per (modelId, expressId).
127
+ const seen = new Set<string>();
128
+ const out: SearchResult[] = [];
129
+ for (const r of collected) {
130
+ const key = `${r.modelId}:${r.expressId}`;
131
+ if (seen.has(key)) continue;
132
+ seen.add(key);
133
+ out.push(r);
134
+ if (out.length >= limit) break;
135
+ }
136
+ return out;
137
+ }
138
+
139
+ function scanModel(
140
+ modelId: string,
141
+ store: IfcDataStore,
142
+ needle: string,
143
+ maxScan: number | undefined,
144
+ out: SearchResult[],
145
+ ): void {
146
+ const table = store.entities;
147
+ const strings = store.strings;
148
+ const count = table.count;
149
+ const cap = maxScan != null ? Math.min(count, maxScan) : count;
150
+
151
+ // Direct typed-array references — avoids per-row method-call overhead.
152
+ const expressId = table.expressId;
153
+ const nameIdx = table.name;
154
+ const globalIdIdx = table.globalId;
155
+ const descriptionIdx = table.description;
156
+ const objectTypeIdx = table.objectType;
157
+
158
+ for (let i = 0; i < cap; i++) {
159
+ // Fast skip: rows where every searchable string slot is the empty
160
+ // string (StringTable index 0) — common for non-geometry entities
161
+ // that the parser never batch-extracted. This keeps 4M-entity scans
162
+ // O(populated rows) in practice.
163
+ const nIdx = nameIdx[i];
164
+ const gIdx = globalIdIdx[i];
165
+ const dIdx = descriptionIdx[i];
166
+ const oIdx = objectTypeIdx[i];
167
+ if (nIdx === 0 && gIdx === 0 && dIdx === 0 && oIdx === 0) continue;
168
+
169
+ const name = nIdx !== 0 ? strings.get(nIdx) : '';
170
+ const globalId = gIdx !== 0 ? strings.get(gIdx) : '';
171
+
172
+ // Score every applicable field and keep the max — Tier-1's
173
+ // `scoreEntry` does the same, and the result-merge code in the UI
174
+ // depends on Tier-0 / Tier-1 producing comparable orderings. The
175
+ // previous short-circuit (skip type once name produced any score)
176
+ // ranked an entity that hits NAME_SUBSTR (40) below an entity that
177
+ // would have hit TYPE_EXACT (80) on its name field, so the same
178
+ // logical match scored differently across paths.
179
+ let score = 0;
180
+ let matchField: MatchField = 'name';
181
+ const bump = (s: number, mf: MatchField): void => {
182
+ if (s > score) {
183
+ score = s;
184
+ matchField = mf;
185
+ }
186
+ };
187
+
188
+ if (name) {
189
+ const nameLower = name.toLowerCase();
190
+ if (nameLower === needle) bump(SCORE.NAME_EXACT, 'name');
191
+ else if (nameLower.startsWith(needle)) bump(SCORE.NAME_PREFIX, 'name');
192
+ else if (nameLower.includes(needle)) bump(SCORE.NAME_SUBSTR, 'name');
193
+ }
194
+
195
+ {
196
+ // Type lookup uses the resolved type-name accessor (handles enum
197
+ // → PascalCase conversion). Cheap but a method call, so it stays
198
+ // inside the per-row loop only because it can outrank NAME_SUBSTR.
199
+ const typeName = table.getTypeName(expressId[i]);
200
+ const typeLower = typeName.toLowerCase();
201
+ if (typeLower === needle) bump(SCORE.TYPE_EXACT, 'type');
202
+ else if (typeLower.startsWith(needle)) bump(SCORE.TYPE_PREFIX, 'type');
203
+ }
204
+
205
+ let objectType = '';
206
+ if (oIdx !== 0) {
207
+ objectType = strings.get(oIdx);
208
+ if (objectType.toLowerCase().includes(needle)) {
209
+ bump(SCORE.OBJECTTYPE_SUBSTR, 'objectType');
210
+ }
211
+ }
212
+
213
+ let description = '';
214
+ if (dIdx !== 0) {
215
+ description = strings.get(dIdx);
216
+ if (description.toLowerCase().includes(needle)) {
217
+ bump(SCORE.DESCRIPTION_SUBSTR, 'description');
218
+ }
219
+ }
220
+
221
+ if (score === 0) continue;
222
+
223
+ // Resolve remaining display fields lazily so non-matches stay cheap.
224
+ const id = expressId[i];
225
+ out.push({
226
+ modelId,
227
+ expressId: id,
228
+ typeName: table.getTypeName(id),
229
+ name,
230
+ globalId,
231
+ description: description || (dIdx !== 0 ? strings.get(dIdx) : ''),
232
+ objectType: objectType || (oIdx !== 0 ? strings.get(oIdx) : ''),
233
+ matchField,
234
+ score,
235
+ });
236
+ }
237
+ }