@ifc-lite/viewer 1.7.0 → 1.9.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.
- package/CHANGELOG.md +88 -0
- package/dist/assets/{Arrow.dom-BGPQieQQ.js → Arrow.dom-CusgkT03.js} +1 -1
- package/dist/assets/browser-BXNIkE8a.js +694 -0
- package/dist/assets/emscripten-module-BTRCZGcB.wasm +0 -0
- package/dist/assets/emscripten-module-CGIn_cMh.wasm +0 -0
- package/dist/assets/emscripten-module-DYvzWiHh.wasm +0 -0
- package/dist/assets/emscripten-module-NWak2PoB.wasm +0 -0
- package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +1 -0
- package/dist/assets/esbuild-COv63sf-.js +1 -0
- package/dist/assets/esbuild-Cpd5nU_H.wasm +0 -0
- package/dist/assets/ffi-DlhRHxHv.js +1 -0
- package/dist/assets/index-6Mr3byM-.js +216 -0
- package/dist/assets/index-CGbokkQ9.css +1 -0
- package/dist/assets/index-huvR-kGC.js +98305 -0
- package/dist/assets/module-6F3E5H7Y-tx0BadV3.js +6 -0
- package/dist/assets/{native-bridge-DD0SNyQ5.js → native-bridge-DsHOKdgD.js} +1 -1
- package/dist/assets/{wasm-bridge-D54YMO7X.js → wasm-bridge-Bd73HXn-.js} +1 -1
- package/dist/index.html +12 -3
- package/index.html +10 -1
- package/package.json +30 -21
- package/src/App.tsx +6 -1
- package/src/components/ui/dialog.tsx +8 -6
- package/src/components/viewer/CodeEditor.tsx +309 -0
- package/src/components/viewer/CommandPalette.tsx +597 -0
- package/src/components/viewer/Drawing2DCanvas.tsx +364 -1
- package/src/components/viewer/EntityContextMenu.tsx +47 -20
- package/src/components/viewer/ExportDialog.tsx +166 -17
- package/src/components/viewer/HierarchyPanel.tsx +3 -1
- package/src/components/viewer/LensPanel.tsx +848 -85
- package/src/components/viewer/MainToolbar.tsx +145 -84
- package/src/components/viewer/ScriptPanel.tsx +416 -0
- package/src/components/viewer/Section2DPanel.tsx +269 -29
- package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
- package/src/components/viewer/ViewerLayout.tsx +63 -11
- package/src/components/viewer/Viewport.tsx +58 -23
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -1
- package/src/components/viewer/hierarchy/types.ts +1 -1
- package/src/components/viewer/lists/ListResultsTable.tsx +53 -19
- package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
- package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
- package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
- package/src/components/viewer/tools/computePolygonArea.ts +72 -0
- package/src/components/viewer/useGeometryStreaming.ts +25 -5
- package/src/hooks/ids/idsExportService.ts +1 -1
- package/src/hooks/useAnnotation2D.ts +551 -0
- package/src/hooks/useDrawingExport.ts +83 -1
- package/src/hooks/useKeyboardShortcuts.ts +114 -14
- package/src/hooks/useLens.ts +40 -55
- package/src/hooks/useLensDiscovery.ts +46 -0
- package/src/hooks/useModelSelection.ts +5 -22
- package/src/hooks/useSandbox.ts +113 -0
- package/src/index.css +7 -1
- package/src/lib/lens/adapter.ts +127 -1
- package/src/lib/lists/columnToAutoColor.ts +33 -0
- package/src/lib/recent-files.ts +122 -0
- package/src/lib/scripts/persistence.ts +132 -0
- package/src/lib/scripts/templates/bim-globals.d.ts +111 -0
- package/src/lib/scripts/templates/data-quality-audit.ts +149 -0
- package/src/lib/scripts/templates/envelope-check.ts +164 -0
- package/src/lib/scripts/templates/federation-compare.ts +189 -0
- package/src/lib/scripts/templates/fire-safety-check.ts +161 -0
- package/src/lib/scripts/templates/mep-equipment-schedule.ts +175 -0
- package/src/lib/scripts/templates/quantity-takeoff.ts +145 -0
- package/src/lib/scripts/templates/reset-view.ts +6 -0
- package/src/lib/scripts/templates/space-validation.ts +189 -0
- package/src/lib/scripts/templates/tsconfig.json +13 -0
- package/src/lib/scripts/templates.ts +86 -0
- package/src/sdk/BimProvider.tsx +50 -0
- package/src/sdk/adapters/export-adapter.ts +283 -0
- package/src/sdk/adapters/lens-adapter.ts +44 -0
- package/src/sdk/adapters/model-adapter.ts +32 -0
- package/src/sdk/adapters/model-compat.ts +80 -0
- package/src/sdk/adapters/mutate-adapter.ts +45 -0
- package/src/sdk/adapters/query-adapter.ts +241 -0
- package/src/sdk/adapters/selection-adapter.ts +29 -0
- package/src/sdk/adapters/spatial-adapter.ts +37 -0
- package/src/sdk/adapters/types.ts +11 -0
- package/src/sdk/adapters/viewer-adapter.ts +103 -0
- package/src/sdk/adapters/visibility-adapter.ts +61 -0
- package/src/sdk/local-backend.ts +144 -0
- package/src/sdk/useBimHost.ts +69 -0
- package/src/store/constants.ts +10 -2
- package/src/store/index.ts +28 -2
- package/src/store/resolveEntityRef.ts +44 -0
- package/src/store/slices/drawing2DSlice.ts +321 -0
- package/src/store/slices/lensSlice.ts +46 -4
- package/src/store/slices/pinboardSlice.ts +171 -42
- package/src/store/slices/scriptSlice.ts +218 -0
- package/src/store/slices/uiSlice.ts +2 -0
- package/src/store.ts +3 -0
- package/tsconfig.json +5 -2
- package/vite.config.ts +8 -0
- package/dist/assets/index-dgdgiQ9p.js +0 -75456
- package/dist/assets/index-yTqs8kgX.css +0 -1
package/src/lib/lens/adapter.ts
CHANGED
|
@@ -10,8 +10,14 @@
|
|
|
10
10
|
* - Legacy single-model: uses offset = 0
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type { LensDataProvider, PropertySetInfo } from '@ifc-lite/lens';
|
|
13
|
+
import type { LensDataProvider, PropertySetInfo, ClassificationInfo } from '@ifc-lite/lens';
|
|
14
14
|
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
15
|
+
import {
|
|
16
|
+
extractEntityAttributesOnDemand,
|
|
17
|
+
extractQuantitiesOnDemand,
|
|
18
|
+
extractClassificationsOnDemand,
|
|
19
|
+
extractMaterialsOnDemand,
|
|
20
|
+
} from '@ifc-lite/parser';
|
|
15
21
|
import type { FederatedModel } from '@/store/types';
|
|
16
22
|
|
|
17
23
|
interface ModelEntry {
|
|
@@ -111,6 +117,126 @@ export function createLensDataProvider(
|
|
|
111
117
|
if (!psets) return [];
|
|
112
118
|
return psets as PropertySetInfo[];
|
|
113
119
|
},
|
|
120
|
+
|
|
121
|
+
getEntityAttribute(globalId: number, attrName: string): string | undefined {
|
|
122
|
+
const resolved = resolveGlobalId(globalId, entries);
|
|
123
|
+
if (!resolved) return undefined;
|
|
124
|
+
const store = resolved.entry.ifcDataStore;
|
|
125
|
+
const id = resolved.expressId;
|
|
126
|
+
|
|
127
|
+
// Fast path: columnar attributes stored during initial parse
|
|
128
|
+
switch (attrName) {
|
|
129
|
+
case 'Name':
|
|
130
|
+
return store.entities.getName(id) || undefined;
|
|
131
|
+
case 'Description': {
|
|
132
|
+
const desc = store.entities.getDescription?.(id);
|
|
133
|
+
if (desc) return desc;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case 'ObjectType': {
|
|
137
|
+
const ot = store.entities.getObjectType?.(id);
|
|
138
|
+
if (ot) return ot;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'Tag':
|
|
142
|
+
// Tag is not stored in columnar — always on-demand
|
|
143
|
+
break;
|
|
144
|
+
case 'GlobalId':
|
|
145
|
+
return store.entities.getGlobalId(id) || undefined;
|
|
146
|
+
case 'Type':
|
|
147
|
+
return store.entities.getTypeName?.(id) || undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Slow path: on-demand extraction from source buffer
|
|
151
|
+
if (store.source?.length > 0 && store.entityIndex) {
|
|
152
|
+
const attrs = extractEntityAttributesOnDemand(store, id);
|
|
153
|
+
switch (attrName) {
|
|
154
|
+
case 'Name': return attrs.name || undefined;
|
|
155
|
+
case 'Description': return attrs.description || undefined;
|
|
156
|
+
case 'ObjectType': return attrs.objectType || undefined;
|
|
157
|
+
case 'Tag': return attrs.tag || undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
getQuantityValue(
|
|
164
|
+
globalId: number,
|
|
165
|
+
qsetName: string,
|
|
166
|
+
quantName: string,
|
|
167
|
+
): number | string | undefined {
|
|
168
|
+
const resolved = resolveGlobalId(globalId, entries);
|
|
169
|
+
if (!resolved) return undefined;
|
|
170
|
+
const store = resolved.entry.ifcDataStore;
|
|
171
|
+
const id = resolved.expressId;
|
|
172
|
+
|
|
173
|
+
// On-demand quantity extraction
|
|
174
|
+
if (store.onDemandQuantityMap && store.source?.length > 0) {
|
|
175
|
+
const qsets = extractQuantitiesOnDemand(store, id);
|
|
176
|
+
for (const qset of qsets) {
|
|
177
|
+
if (qset.name === qsetName) {
|
|
178
|
+
for (const q of qset.quantities) {
|
|
179
|
+
if (q.name === quantName) return q.value;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Fallback: pre-built quantity tables
|
|
187
|
+
const qsets = store.quantities?.getForEntity?.(id);
|
|
188
|
+
if (!qsets) return undefined;
|
|
189
|
+
for (const qset of qsets) {
|
|
190
|
+
if (qset.name === qsetName) {
|
|
191
|
+
for (const q of qset.quantities) {
|
|
192
|
+
if (q.name === quantName) return q.value;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
getClassifications(globalId: number): ClassificationInfo[] {
|
|
200
|
+
const resolved = resolveGlobalId(globalId, entries);
|
|
201
|
+
if (!resolved) return [];
|
|
202
|
+
const store = resolved.entry.ifcDataStore;
|
|
203
|
+
return extractClassificationsOnDemand(store, resolved.expressId);
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
getQuantitySets(globalId: number): ReadonlyArray<{
|
|
207
|
+
name: string;
|
|
208
|
+
quantities: ReadonlyArray<{ name: string }>;
|
|
209
|
+
}> {
|
|
210
|
+
const resolved = resolveGlobalId(globalId, entries);
|
|
211
|
+
if (!resolved) return [];
|
|
212
|
+
const store = resolved.entry.ifcDataStore;
|
|
213
|
+
const id = resolved.expressId;
|
|
214
|
+
|
|
215
|
+
// On-demand quantity extraction
|
|
216
|
+
if (store.onDemandQuantityMap && store.source?.length > 0) {
|
|
217
|
+
return extractQuantitiesOnDemand(store, id);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Fallback: pre-built quantity tables
|
|
221
|
+
const qsets = store.quantities?.getForEntity?.(id);
|
|
222
|
+
if (!qsets) return [];
|
|
223
|
+
return qsets as ReadonlyArray<{ name: string; quantities: ReadonlyArray<{ name: string }> }>;
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
getMaterialName(globalId: number): string | undefined {
|
|
227
|
+
const resolved = resolveGlobalId(globalId, entries);
|
|
228
|
+
if (!resolved) return undefined;
|
|
229
|
+
const store = resolved.entry.ifcDataStore;
|
|
230
|
+
const info = extractMaterialsOnDemand(store, resolved.expressId);
|
|
231
|
+
if (!info) return undefined;
|
|
232
|
+
// Return the top-level material name, or first layer/constituent name
|
|
233
|
+
if (info.name) return info.name;
|
|
234
|
+
if (info.layers?.length) return info.layers[0].materialName;
|
|
235
|
+
if (info.constituents?.length) return info.constituents[0].materialName;
|
|
236
|
+
if (info.profiles?.length) return info.profiles[0].materialName;
|
|
237
|
+
if (info.materials?.length) return info.materials[0];
|
|
238
|
+
return undefined;
|
|
239
|
+
},
|
|
114
240
|
};
|
|
115
241
|
}
|
|
116
242
|
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
* Maps a list ColumnDefinition to a lens AutoColorSpec.
|
|
7
|
+
* This bridges the lists feature (column-based data tables) with
|
|
8
|
+
* the lens feature (3D coloring) by converting column metadata
|
|
9
|
+
* into the auto-color specification used by the lens engine.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ColumnDefinition } from '@ifc-lite/lists';
|
|
13
|
+
import type { AutoColorSpec } from '@ifc-lite/lens';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Convert a list column definition to an auto-color spec.
|
|
17
|
+
*
|
|
18
|
+
* @param col - Column definition from a list configuration
|
|
19
|
+
* @returns AutoColorSpec for the lens engine
|
|
20
|
+
*/
|
|
21
|
+
export function columnToAutoColor(col: ColumnDefinition): AutoColorSpec {
|
|
22
|
+
switch (col.source) {
|
|
23
|
+
case 'attribute':
|
|
24
|
+
if (col.propertyName === 'Class') return { source: 'ifcType' };
|
|
25
|
+
return { source: 'attribute', propertyName: col.propertyName };
|
|
26
|
+
case 'property':
|
|
27
|
+
return { source: 'property', psetName: col.psetName, propertyName: col.propertyName };
|
|
28
|
+
case 'quantity':
|
|
29
|
+
return { source: 'quantity', psetName: col.psetName, propertyName: col.propertyName };
|
|
30
|
+
default:
|
|
31
|
+
return { source: 'ifcType' };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
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
|
+
* Track recently opened model files via localStorage + IndexedDB.
|
|
7
|
+
*
|
|
8
|
+
* localStorage: metadata (name, size, timestamp) — for palette display.
|
|
9
|
+
* IndexedDB: actual file blobs — so recent files can be loaded instantly
|
|
10
|
+
* without the user re-selecting them from the file picker.
|
|
11
|
+
*
|
|
12
|
+
* Shared between MainToolbar (writes) and CommandPalette (reads).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const KEY = 'ifc-lite:recent-files';
|
|
16
|
+
const DB_NAME = 'ifc-lite-file-cache';
|
|
17
|
+
const DB_VERSION = 1;
|
|
18
|
+
const STORE_NAME = 'files';
|
|
19
|
+
const MAX_CACHED_FILES = 5;
|
|
20
|
+
/** Max file size to cache (150 MB) — avoids filling IndexedDB quota */
|
|
21
|
+
const MAX_CACHE_SIZE = 150 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
export interface RecentFileEntry {
|
|
24
|
+
name: string;
|
|
25
|
+
size: number;
|
|
26
|
+
timestamp: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── localStorage (metadata) ─────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export function getRecentFiles(): RecentFileEntry[] {
|
|
32
|
+
try { return JSON.parse(localStorage.getItem(KEY) ?? '[]'); }
|
|
33
|
+
catch { return []; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function recordRecentFiles(files: { name: string; size: number }[]) {
|
|
37
|
+
try {
|
|
38
|
+
const names = new Set(files.map(f => f.name));
|
|
39
|
+
const existing = getRecentFiles().filter(f => !names.has(f.name));
|
|
40
|
+
const entries: RecentFileEntry[] = files.map(f => ({
|
|
41
|
+
name: f.name,
|
|
42
|
+
size: f.size,
|
|
43
|
+
timestamp: Date.now(),
|
|
44
|
+
}));
|
|
45
|
+
localStorage.setItem(KEY, JSON.stringify([...entries, ...existing].slice(0, 10)));
|
|
46
|
+
} catch { /* noop */ }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Format bytes into human-readable size */
|
|
50
|
+
export function formatFileSize(bytes: number): string {
|
|
51
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
52
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
53
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── IndexedDB (file blob cache) ─────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function openDB(): Promise<IDBDatabase> {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
61
|
+
req.onupgradeneeded = () => {
|
|
62
|
+
const db = req.result;
|
|
63
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
64
|
+
db.createObjectStore(STORE_NAME, { keyPath: 'name' });
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
req.onsuccess = () => resolve(req.result);
|
|
68
|
+
req.onerror = () => reject(req.error);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Cache file blobs in IndexedDB for instant reload from palette. */
|
|
73
|
+
export async function cacheFileBlobs(files: File[]): Promise<void> {
|
|
74
|
+
try {
|
|
75
|
+
const db = await openDB();
|
|
76
|
+
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
77
|
+
const store = tx.objectStore(STORE_NAME);
|
|
78
|
+
|
|
79
|
+
for (const file of files) {
|
|
80
|
+
if (file.size > MAX_CACHE_SIZE) continue; // skip oversized files
|
|
81
|
+
const blob = await file.arrayBuffer();
|
|
82
|
+
store.put({ name: file.name, blob, size: file.size, type: file.type, timestamp: Date.now() });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Evict old entries beyond MAX_CACHED_FILES
|
|
86
|
+
const allReq = store.getAll();
|
|
87
|
+
allReq.onsuccess = () => {
|
|
88
|
+
const all = allReq.result as { name: string; timestamp: number }[];
|
|
89
|
+
if (all.length > MAX_CACHED_FILES) {
|
|
90
|
+
all.sort((a, b) => b.timestamp - a.timestamp);
|
|
91
|
+
for (let i = MAX_CACHED_FILES; i < all.length; i++) {
|
|
92
|
+
store.delete(all[i].name);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
await new Promise<void>((resolve, reject) => {
|
|
98
|
+
tx.oncomplete = () => resolve();
|
|
99
|
+
tx.onerror = () => reject(tx.error);
|
|
100
|
+
});
|
|
101
|
+
db.close();
|
|
102
|
+
} catch { /* IndexedDB unavailable — degrade gracefully */ }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Retrieve a cached file blob and reconstruct a File object. */
|
|
106
|
+
export async function getCachedFile(name: string): Promise<File | null> {
|
|
107
|
+
try {
|
|
108
|
+
const db = await openDB();
|
|
109
|
+
const tx = db.transaction(STORE_NAME, 'readonly');
|
|
110
|
+
const store = tx.objectStore(STORE_NAME);
|
|
111
|
+
const req = store.get(name);
|
|
112
|
+
const result = await new Promise<{ name: string; blob: ArrayBuffer; size: number; type: string } | undefined>((resolve, reject) => {
|
|
113
|
+
req.onsuccess = () => resolve(req.result);
|
|
114
|
+
req.onerror = () => reject(req.error);
|
|
115
|
+
});
|
|
116
|
+
db.close();
|
|
117
|
+
if (!result) return null;
|
|
118
|
+
return new File([result.blob], result.name, { type: result.type || 'application/octet-stream' });
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
* Persistence for user scripts via localStorage.
|
|
7
|
+
*
|
|
8
|
+
* Uses a versioned schema so future additions (tags, description, etc.)
|
|
9
|
+
* can be migrated without data loss.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Current schema version */
|
|
13
|
+
const SCHEMA_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
export interface SavedScript {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
code: string;
|
|
19
|
+
createdAt: number;
|
|
20
|
+
updatedAt: number;
|
|
21
|
+
version: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Stored wrapper with schema version for migration */
|
|
25
|
+
interface StoredScripts {
|
|
26
|
+
schemaVersion: number;
|
|
27
|
+
scripts: SavedScript[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const STORAGE_KEY = 'ifc-lite-scripts';
|
|
31
|
+
|
|
32
|
+
/** Maximum scripts allowed (prevents storage exhaustion) */
|
|
33
|
+
const MAX_SCRIPTS = 500;
|
|
34
|
+
|
|
35
|
+
/** Maximum code size per script in characters (~100KB) */
|
|
36
|
+
const MAX_SCRIPT_SIZE = 100_000;
|
|
37
|
+
|
|
38
|
+
export function loadSavedScripts(): SavedScript[] {
|
|
39
|
+
try {
|
|
40
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
41
|
+
if (!raw) return [];
|
|
42
|
+
|
|
43
|
+
const parsed: unknown = JSON.parse(raw);
|
|
44
|
+
|
|
45
|
+
// Handle legacy format (bare array without schema version)
|
|
46
|
+
if (Array.isArray(parsed)) {
|
|
47
|
+
return migrateFromLegacy(parsed);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Versioned format — validate structure
|
|
51
|
+
if (
|
|
52
|
+
typeof parsed === 'object' &&
|
|
53
|
+
parsed !== null &&
|
|
54
|
+
'schemaVersion' in parsed &&
|
|
55
|
+
'scripts' in parsed &&
|
|
56
|
+
Array.isArray((parsed as StoredScripts).scripts)
|
|
57
|
+
) {
|
|
58
|
+
return (parsed as StoredScripts).scripts;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return [];
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Migrate from the original unversioned format — discards corrupted entries */
|
|
68
|
+
function migrateFromLegacy(scripts: unknown[]): SavedScript[] {
|
|
69
|
+
const migrated: SavedScript[] = [];
|
|
70
|
+
for (const s of scripts) {
|
|
71
|
+
if (s === null || typeof s !== 'object') continue;
|
|
72
|
+
const script = s as Record<string, unknown>;
|
|
73
|
+
|
|
74
|
+
// Validate essential fields — discard garbage values from String()/Number() coercion
|
|
75
|
+
const id = typeof script.id === 'string' && script.id.length > 0 ? script.id : crypto.randomUUID();
|
|
76
|
+
const name = typeof script.name === 'string' && script.name.length > 0 ? script.name : 'Untitled';
|
|
77
|
+
const code = typeof script.code === 'string' ? script.code : '';
|
|
78
|
+
const createdAt = typeof script.createdAt === 'number' && isFinite(script.createdAt) ? script.createdAt : Date.now();
|
|
79
|
+
const updatedAt = typeof script.updatedAt === 'number' && isFinite(script.updatedAt) ? script.updatedAt : Date.now();
|
|
80
|
+
|
|
81
|
+
migrated.push({ id, name, code, createdAt, updatedAt, version: SCHEMA_VERSION });
|
|
82
|
+
}
|
|
83
|
+
// Save in new format
|
|
84
|
+
saveScripts(migrated);
|
|
85
|
+
return migrated;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type SaveResult =
|
|
89
|
+
| { ok: true }
|
|
90
|
+
| { ok: false; reason: 'quota_exceeded' | 'serialization_error' | 'unknown'; message: string };
|
|
91
|
+
|
|
92
|
+
export function saveScripts(scripts: SavedScript[]): SaveResult {
|
|
93
|
+
const stored: StoredScripts = {
|
|
94
|
+
schemaVersion: SCHEMA_VERSION,
|
|
95
|
+
scripts,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const json = JSON.stringify(stored);
|
|
100
|
+
localStorage.setItem(STORAGE_KEY, json);
|
|
101
|
+
return { ok: true };
|
|
102
|
+
} catch (err: unknown) {
|
|
103
|
+
if (err instanceof DOMException && err.name === 'QuotaExceededError') {
|
|
104
|
+
console.warn('[Scripts] localStorage quota exceeded. Consider deleting unused scripts.');
|
|
105
|
+
return { ok: false, reason: 'quota_exceeded', message: 'Storage quota exceeded. Delete unused scripts to free space.' };
|
|
106
|
+
}
|
|
107
|
+
if (err instanceof TypeError) {
|
|
108
|
+
console.warn('[Scripts] Failed to serialize scripts:', err.message);
|
|
109
|
+
return { ok: false, reason: 'serialization_error', message: err.message };
|
|
110
|
+
}
|
|
111
|
+
console.warn('[Scripts] Failed to save scripts to localStorage');
|
|
112
|
+
return { ok: false, reason: 'unknown', message: String(err) };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Validate a script name — returns sanitized name or null if invalid */
|
|
117
|
+
export function validateScriptName(name: string): string | null {
|
|
118
|
+
const trimmed = name.trim();
|
|
119
|
+
if (trimmed.length === 0) return null;
|
|
120
|
+
if (trimmed.length > 100) return trimmed.slice(0, 100);
|
|
121
|
+
return trimmed;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Check if adding another script is within limits */
|
|
125
|
+
export function canCreateScript(currentCount: number): boolean {
|
|
126
|
+
return currentCount < MAX_SCRIPTS;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Check if script code is within size limits */
|
|
130
|
+
export function isScriptWithinSizeLimit(code: string): boolean {
|
|
131
|
+
return code.length <= MAX_SCRIPT_SIZE;
|
|
132
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
+
* AUTO-GENERATED — do not edit by hand.
|
|
7
|
+
* Run: npx tsx scripts/generate-bim-globals.ts
|
|
8
|
+
*
|
|
9
|
+
* Type declarations for the sandbox `bim` global.
|
|
10
|
+
* Generated from NAMESPACE_SCHEMAS in bridge-schema.ts.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ── Entity types ────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
interface BimEntity {
|
|
16
|
+
ref: { modelId: string; expressId: number };
|
|
17
|
+
name: string; Name: string;
|
|
18
|
+
type: string; Type: string;
|
|
19
|
+
globalId: string; GlobalId: string;
|
|
20
|
+
description: string; Description: string;
|
|
21
|
+
objectType: string; ObjectType: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface BimPropertySet {
|
|
25
|
+
name: string;
|
|
26
|
+
properties: Array<{ name: string; value: string | number | boolean | null }>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface BimQuantitySet {
|
|
30
|
+
name: string;
|
|
31
|
+
quantities: Array<{ name: string; value: number | null }>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface BimModelInfo {
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
schemaVersion: string;
|
|
38
|
+
entityCount: number;
|
|
39
|
+
fileSize: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Namespace declarations ──────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
declare const bim: {
|
|
45
|
+
/** Model operations */
|
|
46
|
+
model: {
|
|
47
|
+
/** List loaded models */
|
|
48
|
+
list(): BimModelInfo[];
|
|
49
|
+
/** Get active model */
|
|
50
|
+
active(): BimModelInfo | null;
|
|
51
|
+
/** Get active model ID */
|
|
52
|
+
activeId(): string | null;
|
|
53
|
+
};
|
|
54
|
+
/** Query entities */
|
|
55
|
+
query: {
|
|
56
|
+
/** Get all entities */
|
|
57
|
+
all(): BimEntity[];
|
|
58
|
+
/** Filter by IFC type e.g. 'IfcWall' */
|
|
59
|
+
byType(...types: string[]): BimEntity[];
|
|
60
|
+
/** Get entity by model ID and express ID */
|
|
61
|
+
entity(modelId: string, expressId: number): BimEntity | null;
|
|
62
|
+
/** Get all IfcPropertySet data for an entity */
|
|
63
|
+
properties(entity: BimEntity): BimPropertySet[];
|
|
64
|
+
/** Get all IfcElementQuantity data for an entity */
|
|
65
|
+
quantities(entity: BimEntity): BimQuantitySet[];
|
|
66
|
+
};
|
|
67
|
+
/** Viewer control */
|
|
68
|
+
viewer: {
|
|
69
|
+
/** Colorize entities e.g. '#ff0000' */
|
|
70
|
+
colorize(entities: BimEntity[], color: string): void;
|
|
71
|
+
/** Batch colorize with [{entities, color}] */
|
|
72
|
+
colorizeAll(batches: Array<{ entities: BimEntity[]; color: string }>): void;
|
|
73
|
+
/** Hide entities */
|
|
74
|
+
hide(entities: BimEntity[]): void;
|
|
75
|
+
/** Show entities */
|
|
76
|
+
show(entities: BimEntity[]): void;
|
|
77
|
+
/** Isolate entities */
|
|
78
|
+
isolate(entities: BimEntity[]): void;
|
|
79
|
+
/** Select entities */
|
|
80
|
+
select(entities: BimEntity[]): void;
|
|
81
|
+
/** Fly camera to entities */
|
|
82
|
+
flyTo(entities: BimEntity[]): void;
|
|
83
|
+
/** Reset all colors */
|
|
84
|
+
resetColors(): void;
|
|
85
|
+
/** Reset all visibility */
|
|
86
|
+
resetVisibility(): void;
|
|
87
|
+
};
|
|
88
|
+
/** Property editing */
|
|
89
|
+
mutate: {
|
|
90
|
+
/** Set a property value */
|
|
91
|
+
setProperty(entity: unknown, psetName: string, propName: string, value: unknown): void;
|
|
92
|
+
/** Delete a property */
|
|
93
|
+
deleteProperty(entity: unknown, psetName: string, propName: string): void;
|
|
94
|
+
/** Undo last mutation */
|
|
95
|
+
undo(modelId: string): void;
|
|
96
|
+
/** Redo undone mutation */
|
|
97
|
+
redo(modelId: string): void;
|
|
98
|
+
};
|
|
99
|
+
/** Lens visualization */
|
|
100
|
+
lens: {
|
|
101
|
+
/** Get built-in lens presets */
|
|
102
|
+
presets(): unknown[];
|
|
103
|
+
};
|
|
104
|
+
/** Data export */
|
|
105
|
+
export: {
|
|
106
|
+
/** Export entities to CSV string */
|
|
107
|
+
csv(entities: BimEntity[], options: { columns: string[]; filename?: string; separator?: string }): string;
|
|
108
|
+
/** Export entities to JSON array */
|
|
109
|
+
json(entities: BimEntity[], columns: string[]): Record<string, unknown>[];
|
|
110
|
+
};
|
|
111
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
export {} // module boundary (stripped by transpiler)
|
|
2
|
+
|
|
3
|
+
// ── Data Quality Audit ──────────────────────────────────────────────────
|
|
4
|
+
// Stakeholder: BIM Manager / QA
|
|
5
|
+
//
|
|
6
|
+
// Scans every entity in the model for data completeness issues that would
|
|
7
|
+
// take hours to find by clicking through the UI. Produces a scorecard,
|
|
8
|
+
// color-codes the 3D view by quality level, and exports an issues CSV.
|
|
9
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
bim.viewer.resetColors()
|
|
12
|
+
const all = bim.query.all()
|
|
13
|
+
if (all.length === 0) { console.error('No entities loaded'); throw new Error('empty model') }
|
|
14
|
+
|
|
15
|
+
// ── 1. Check every entity for missing attributes ────────────────────────
|
|
16
|
+
interface Issue { entity: BimEntity; field: string }
|
|
17
|
+
const issues: Issue[] = []
|
|
18
|
+
const scores: Record<string, { entity: BimEntity; score: number }> = {}
|
|
19
|
+
|
|
20
|
+
// Track property coverage per type
|
|
21
|
+
const typePsetCoverage: Record<string, { total: number; withPsets: number }> = {}
|
|
22
|
+
|
|
23
|
+
for (const e of all) {
|
|
24
|
+
let score = 0
|
|
25
|
+
const maxScore = 5 // Name, Description, ObjectType, has properties, has quantities
|
|
26
|
+
|
|
27
|
+
if (e.Name && e.Name !== '') score++
|
|
28
|
+
else issues.push({ entity: e, field: 'Name' })
|
|
29
|
+
|
|
30
|
+
if (e.Description && e.Description !== '') score++
|
|
31
|
+
else issues.push({ entity: e, field: 'Description' })
|
|
32
|
+
|
|
33
|
+
if (e.ObjectType && e.ObjectType !== '') score++
|
|
34
|
+
else issues.push({ entity: e, field: 'ObjectType' })
|
|
35
|
+
|
|
36
|
+
const psets = bim.query.properties(e)
|
|
37
|
+
if (psets.length > 0) score++
|
|
38
|
+
else issues.push({ entity: e, field: 'PropertySets' })
|
|
39
|
+
|
|
40
|
+
const qsets = bim.query.quantities(e)
|
|
41
|
+
if (qsets.length > 0) score++
|
|
42
|
+
else issues.push({ entity: e, field: 'Quantities' })
|
|
43
|
+
|
|
44
|
+
scores[e.GlobalId] = { entity: e, score }
|
|
45
|
+
|
|
46
|
+
// Track pset coverage per type
|
|
47
|
+
if (!typePsetCoverage[e.Type]) typePsetCoverage[e.Type] = { total: 0, withPsets: 0 }
|
|
48
|
+
typePsetCoverage[e.Type].total++
|
|
49
|
+
if (psets.length > 0) typePsetCoverage[e.Type].withPsets++
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── 2. Classify entities by quality tier ────────────────────────────────
|
|
53
|
+
const tiers = { complete: [] as BimEntity[], good: [] as BimEntity[], partial: [] as BimEntity[], poor: [] as BimEntity[] }
|
|
54
|
+
for (const { entity, score } of Object.values(scores)) {
|
|
55
|
+
if (score === 5) tiers.complete.push(entity)
|
|
56
|
+
else if (score >= 4) tiers.good.push(entity)
|
|
57
|
+
else if (score >= 2) tiers.partial.push(entity)
|
|
58
|
+
else tiers.poor.push(entity)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── 3. Color-code by quality ────────────────────────────────────────────
|
|
62
|
+
const batches: Array<{ entities: BimEntity[]; color: string }> = []
|
|
63
|
+
if (tiers.complete.length > 0) batches.push({ entities: tiers.complete, color: '#27ae60' }) // green
|
|
64
|
+
if (tiers.good.length > 0) batches.push({ entities: tiers.good, color: '#f1c40f' }) // yellow
|
|
65
|
+
if (tiers.partial.length > 0) batches.push({ entities: tiers.partial, color: '#e67e22' }) // orange
|
|
66
|
+
if (tiers.poor.length > 0) batches.push({ entities: tiers.poor, color: '#e74c3c' }) // red
|
|
67
|
+
if (batches.length > 0) bim.viewer.colorizeAll(batches)
|
|
68
|
+
|
|
69
|
+
// ── 4. Report ───────────────────────────────────────────────────────────
|
|
70
|
+
const overallScore = ((tiers.complete.length + tiers.good.length * 0.8 + tiers.partial.length * 0.4) / all.length * 100)
|
|
71
|
+
console.log('═══════════════════════════════════════')
|
|
72
|
+
console.log(' DATA QUALITY AUDIT')
|
|
73
|
+
console.log('═══════════════════════════════════════')
|
|
74
|
+
console.log('')
|
|
75
|
+
console.log('Overall score: ' + overallScore.toFixed(1) + '% (' + all.length + ' entities)')
|
|
76
|
+
console.log('')
|
|
77
|
+
console.log(' Complete (5/5): ' + tiers.complete.length + ' ● green')
|
|
78
|
+
console.log(' Good (4/5): ' + tiers.good.length + ' ● yellow')
|
|
79
|
+
console.log(' Partial (2-3): ' + tiers.partial.length + ' ● orange')
|
|
80
|
+
console.log(' Poor (0-1): ' + tiers.poor.length + ' ● red')
|
|
81
|
+
|
|
82
|
+
// ── 5. Issue breakdown by field ─────────────────────────────────────────
|
|
83
|
+
const issuesByField: Record<string, number> = {}
|
|
84
|
+
for (const issue of issues) {
|
|
85
|
+
issuesByField[issue.field] = (issuesByField[issue.field] || 0) + 1
|
|
86
|
+
}
|
|
87
|
+
console.log('')
|
|
88
|
+
console.log('── Missing Data Breakdown ──')
|
|
89
|
+
for (const [field, count] of Object.entries(issuesByField).sort((a, b) => b[1] - a[1])) {
|
|
90
|
+
const pct = (count / all.length * 100).toFixed(1)
|
|
91
|
+
console.log(' ' + field + ': ' + count + ' entities (' + pct + '% missing)')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── 6. Property coverage per type ───────────────────────────────────────
|
|
95
|
+
console.log('')
|
|
96
|
+
console.log('── Property Coverage by Type ──')
|
|
97
|
+
const coverageSorted = Object.entries(typePsetCoverage)
|
|
98
|
+
.sort((a, b) => b[1].total - a[1].total)
|
|
99
|
+
.slice(0, 15)
|
|
100
|
+
for (const [type, cov] of coverageSorted) {
|
|
101
|
+
const pct = (cov.withPsets / cov.total * 100).toFixed(0)
|
|
102
|
+
const bar = '█'.repeat(Math.round(cov.withPsets / cov.total * 20))
|
|
103
|
+
console.log(' ' + type + ': ' + bar + ' ' + pct + '% (' + cov.withPsets + '/' + cov.total + ')')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── 7. Worst offenders (first 10 entities with score 0-1) ──────────────
|
|
107
|
+
if (tiers.poor.length > 0) {
|
|
108
|
+
console.log('')
|
|
109
|
+
console.log('── Worst Offenders (score 0-1) ──')
|
|
110
|
+
for (const e of tiers.poor.slice(0, 10)) {
|
|
111
|
+
console.log(' ' + (e.Name || '<no name>') + ' [' + e.Type + '] GlobalId=' + e.GlobalId)
|
|
112
|
+
}
|
|
113
|
+
if (tiers.poor.length > 10) {
|
|
114
|
+
console.log(' ... and ' + (tiers.poor.length - 10) + ' more')
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── 8. Export all entities with discovered property/quantity data ────────
|
|
119
|
+
// Discover common property columns from a sample
|
|
120
|
+
const sampleForDiscovery = all.slice(0, 200)
|
|
121
|
+
const discoveredPropPaths = new Set<string>()
|
|
122
|
+
const discoveredQtyPaths = new Set<string>()
|
|
123
|
+
for (const e of sampleForDiscovery) {
|
|
124
|
+
const psets = bim.query.properties(e)
|
|
125
|
+
for (const pset of psets) {
|
|
126
|
+
for (const p of pset.properties) {
|
|
127
|
+
if (p.value !== null && p.value !== '') {
|
|
128
|
+
discoveredPropPaths.add(pset.name + '.' + p.name)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const qsets = bim.query.quantities(e)
|
|
133
|
+
for (const qset of qsets) {
|
|
134
|
+
for (const q of qset.quantities) {
|
|
135
|
+
if (q.value !== null && q.value !== 0) {
|
|
136
|
+
discoveredQtyPaths.add(qset.name + '.' + q.name)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Cap columns to keep CSV manageable
|
|
142
|
+
const auditPropCols = Array.from(discoveredPropPaths).sort().slice(0, 20)
|
|
143
|
+
const auditQtyCols = Array.from(discoveredQtyPaths).sort().slice(0, 10)
|
|
144
|
+
bim.export.csv(all, {
|
|
145
|
+
columns: ['Name', 'Type', 'GlobalId', 'Description', 'ObjectType', ...auditPropCols, ...auditQtyCols],
|
|
146
|
+
filename: 'data-quality-audit.csv'
|
|
147
|
+
})
|
|
148
|
+
console.log('')
|
|
149
|
+
console.log('Exported ' + all.length + ' entities (' + (5 + auditPropCols.length + auditQtyCols.length) + ' columns) to data-quality-audit.csv')
|