@ifc-lite/viewer 1.23.0 → 1.25.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 (109) hide show
  1. package/.turbo/turbo-build.log +34 -31
  2. package/CHANGELOG.md +96 -0
  3. package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
  4. package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
  5. package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
  6. package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
  7. package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
  8. package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
  9. package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
  10. package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
  11. package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
  12. package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
  13. package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
  14. package/dist/assets/index-Bws3UAkj.css +1 -0
  15. package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
  16. package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
  17. package/dist/assets/lens-PYsLu_MA.js +1 -0
  18. package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
  19. package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
  20. package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
  21. package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
  22. package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
  23. package/dist/assets/raw-CoIXstQ-.js +1 -0
  24. package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
  25. package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
  26. package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
  27. package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
  28. package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
  29. package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
  30. package/dist/index.html +8 -8
  31. package/package.json +11 -9
  32. package/src/App.tsx +5 -2
  33. package/src/components/extensions/AuditLogPanel.tsx +259 -0
  34. package/src/components/extensions/BundlePreview.tsx +102 -0
  35. package/src/components/extensions/CapabilityReview.tsx +333 -0
  36. package/src/components/extensions/ExtensionDockHost.tsx +192 -0
  37. package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
  38. package/src/components/extensions/ExtensionsPanel.tsx +481 -0
  39. package/src/components/extensions/FlavorDialog.tsx +398 -0
  40. package/src/components/extensions/FlavorImportPreview.tsx +79 -0
  41. package/src/components/extensions/FlavorIndicator.tsx +81 -0
  42. package/src/components/extensions/FlavorListView.tsx +318 -0
  43. package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
  44. package/src/components/extensions/HelpHint.tsx +182 -0
  45. package/src/components/extensions/IdeasPanel.tsx +344 -0
  46. package/src/components/extensions/PlanCard.tsx +227 -0
  47. package/src/components/extensions/PrivacyPanel.tsx +312 -0
  48. package/src/components/extensions/PromoteToolDialog.tsx +313 -0
  49. package/src/components/extensions/RepairQueuePanel.tsx +222 -0
  50. package/src/components/extensions/icon-registry.ts +92 -0
  51. package/src/components/extensions/toast-helpers.ts +49 -0
  52. package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
  53. package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
  54. package/src/components/viewer/ChatPanel.tsx +251 -3
  55. package/src/components/viewer/CommandPalette.tsx +74 -4
  56. package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
  57. package/src/components/viewer/EntityContextMenu.tsx +70 -0
  58. package/src/components/viewer/ExportDialog.tsx +9 -1
  59. package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
  60. package/src/components/viewer/LensPanel.tsx +50 -0
  61. package/src/components/viewer/MainToolbar.tsx +170 -87
  62. package/src/components/viewer/ScriptPanel.tsx +105 -1
  63. package/src/components/viewer/Section2DPanel.tsx +58 -2
  64. package/src/components/viewer/StatusBar.tsx +18 -0
  65. package/src/components/viewer/ViewerLayout.tsx +53 -4
  66. package/src/components/viewer/Viewport.tsx +72 -0
  67. package/src/hooks/useActionLogger.test.ts +161 -0
  68. package/src/hooks/useActionLogger.ts +141 -0
  69. package/src/hooks/useForkExtension.ts +51 -0
  70. package/src/hooks/useIfcFederation.ts +7 -1
  71. package/src/hooks/useInstalledExtensions.ts +43 -0
  72. package/src/hooks/usePrivacyDisclosure.ts +48 -0
  73. package/src/hooks/useRunExtensionTests.ts +67 -0
  74. package/src/hooks/useSlotContributions.ts +38 -0
  75. package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
  76. package/src/hooks/useSymbolicAnnotations.ts +776 -0
  77. package/src/lib/desktop-product.ts +7 -1
  78. package/src/lib/lens/adapter.ts +14 -0
  79. package/src/lib/llm/prompt-cache.ts +77 -0
  80. package/src/lib/llm/stream-client.ts +20 -2
  81. package/src/lib/llm/stream-direct.ts +11 -1
  82. package/src/lib/llm/system-prompt.ts +42 -0
  83. package/src/lib/safe-mode.ts +30 -0
  84. package/src/sdk/ExtensionHostProvider.tsx +103 -0
  85. package/src/services/extensions/flavor-service.ts +183 -0
  86. package/src/services/extensions/host-commands.ts +112 -0
  87. package/src/services/extensions/host-installer.ts +289 -0
  88. package/src/services/extensions/host.ts +514 -0
  89. package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
  90. package/src/services/extensions/idb-flavor-storage.ts +241 -0
  91. package/src/services/extensions/idb-log-storage.test.ts +110 -0
  92. package/src/services/extensions/idb-log-storage.ts +171 -0
  93. package/src/services/extensions/idb-storage.ts +228 -0
  94. package/src/services/extensions/runtime-errors.ts +26 -0
  95. package/src/services/extensions/sandbox-factory.ts +217 -0
  96. package/src/store/constants.ts +48 -6
  97. package/src/store/index.ts +6 -1
  98. package/src/store/slices/drawing2DSlice.ts +8 -0
  99. package/src/store/slices/extensionsSlice.ts +90 -0
  100. package/src/store/slices/lensSlice.ts +28 -0
  101. package/src/store/slices/visibilitySlice.test.ts +6 -0
  102. package/src/store/slices/visibilitySlice.ts +17 -8
  103. package/src/store/types.ts +2 -0
  104. package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
  105. package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
  106. package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
  107. package/dist/assets/index-DS_xJQfP.css +0 -1
  108. package/dist/assets/lens-CpjUdqpw.js +0 -1
  109. package/dist/assets/raw-DzTtEZIY.js +0 -1
@@ -0,0 +1,228 @@
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
+ * IndexedDB-backed implementation of @ifc-lite/extensions' `ExtensionStorage`
7
+ * interface.
8
+ *
9
+ * Two object stores:
10
+ * - `extensions` keyed by extension id → InstalledExtensionRecord
11
+ * - `extension-bundles` keyed by `<id>@<version>` tuple → Uint8Array
12
+ *
13
+ * On startup, we open the database, verify both stores exist, and recreate
14
+ * the database from scratch if anything is missing (mirrors the recovery
15
+ * pattern used by `services/ifc-cache.ts`).
16
+ */
17
+
18
+ import type {
19
+ ExtensionStorage,
20
+ InstalledExtensionRecord,
21
+ } from '@ifc-lite/extensions';
22
+
23
+ const DB_NAME = 'ifc-lite-extensions';
24
+ /**
25
+ * Bump this when adding/removing/renaming object stores or indexes.
26
+ * Every new version MUST extend the `onupgradeneeded` switch below
27
+ * with an idempotent migration step from `event.oldVersion`.
28
+ */
29
+ const DB_VERSION = 1;
30
+ const STORE_EXT = 'extensions';
31
+ const STORE_BUNDLES = 'extension-bundles';
32
+
33
+ let dbPromise: Promise<IDBDatabase> | null = null;
34
+
35
+ /**
36
+ * Thrown from any IDB write path when the browser refuses the
37
+ * operation because the origin's storage quota is exhausted.
38
+ * Callers (host-installer, host) should catch this specifically and
39
+ * surface a "free up space / uninstall something" toast — bubbling
40
+ * the raw `QuotaExceededError` produces a generic console error and
41
+ * a silent UI.
42
+ */
43
+ export class ExtensionStorageQuotaError extends Error {
44
+ readonly cause?: unknown;
45
+ constructor(operation: string, cause?: unknown) {
46
+ super(`Browser storage quota exceeded while ${operation}.`);
47
+ this.name = 'ExtensionStorageQuotaError';
48
+ this.cause = cause;
49
+ }
50
+ }
51
+
52
+ function isQuotaError(err: unknown): boolean {
53
+ if (!err || typeof err !== 'object') return false;
54
+ const name = (err as { name?: unknown }).name;
55
+ return name === 'QuotaExceededError' || name === 'NS_ERROR_DOM_QUOTA_REACHED';
56
+ }
57
+
58
+ export class IdbExtensionStorage implements ExtensionStorage {
59
+ async putExtension(record: InstalledExtensionRecord): Promise<void> {
60
+ const db = await openDatabase();
61
+ try {
62
+ await runStore(db, STORE_EXT, 'readwrite', (store) => store.put(record));
63
+ } catch (err) {
64
+ if (isQuotaError(err)) {
65
+ throw new ExtensionStorageQuotaError(
66
+ `saving extension record "${record.id}"`,
67
+ err,
68
+ );
69
+ }
70
+ throw err;
71
+ }
72
+ }
73
+
74
+ async getExtension(id: string): Promise<InstalledExtensionRecord | undefined> {
75
+ const db = await openDatabase();
76
+ return runStore(db, STORE_EXT, 'readonly', (store) => store.get(id))
77
+ .then((v) => (v ? (v as InstalledExtensionRecord) : undefined));
78
+ }
79
+
80
+ async listExtensions(): Promise<InstalledExtensionRecord[]> {
81
+ const db = await openDatabase();
82
+ return runStore<InstalledExtensionRecord[]>(db, STORE_EXT, 'readonly', (store) => store.getAll());
83
+ }
84
+
85
+ async deleteExtension(id: string): Promise<void> {
86
+ const db = await openDatabase();
87
+ await runStore(db, STORE_EXT, 'readwrite', (store) => store.delete(id));
88
+ // Cascade: drop bundles for this extension. We can't use runStore
89
+ // here — it overwrites req.onsuccess to capture the result, which
90
+ // clobbers the cursor-iteration handler. Roll our own transaction.
91
+ await new Promise<void>((resolve, reject) => {
92
+ const tx = db.transaction(STORE_BUNDLES, 'readwrite');
93
+ const store = tx.objectStore(STORE_BUNDLES);
94
+ const req = store.openCursor();
95
+ req.onsuccess = () => {
96
+ const cursor = req.result;
97
+ if (!cursor) return;
98
+ const key = String(cursor.key);
99
+ if (key.startsWith(`${id}@`)) cursor.delete();
100
+ cursor.continue();
101
+ };
102
+ req.onerror = () => reject(req.error);
103
+ tx.oncomplete = () => resolve();
104
+ tx.onerror = () => reject(tx.error);
105
+ tx.onabort = () => reject(tx.error);
106
+ });
107
+ }
108
+
109
+ async putBundle(id: string, version: string, bytes: Uint8Array): Promise<void> {
110
+ const db = await openDatabase();
111
+ try {
112
+ await runStore(db, STORE_BUNDLES, 'readwrite', (store) =>
113
+ store.put(new Uint8Array(bytes), bundleKey(id, version)),
114
+ );
115
+ } catch (err) {
116
+ if (isQuotaError(err)) {
117
+ throw new ExtensionStorageQuotaError(
118
+ `saving bundle bytes for "${id}@${version}" (${bytes.byteLength} bytes)`,
119
+ err,
120
+ );
121
+ }
122
+ throw err;
123
+ }
124
+ }
125
+
126
+ async getBundle(id: string, version: string): Promise<Uint8Array | undefined> {
127
+ const db = await openDatabase();
128
+ const value = await runStore<Uint8Array | undefined>(
129
+ db,
130
+ STORE_BUNDLES,
131
+ 'readonly',
132
+ (store) => store.get(bundleKey(id, version)),
133
+ );
134
+ return value ? new Uint8Array(value) : undefined;
135
+ }
136
+
137
+ async deleteBundle(id: string, version: string): Promise<void> {
138
+ const db = await openDatabase();
139
+ await runStore(db, STORE_BUNDLES, 'readwrite', (store) =>
140
+ store.delete(bundleKey(id, version)),
141
+ );
142
+ }
143
+
144
+ async clear(): Promise<void> {
145
+ const db = await openDatabase();
146
+ await runStore(db, STORE_EXT, 'readwrite', (store) => store.clear());
147
+ await runStore(db, STORE_BUNDLES, 'readwrite', (store) => store.clear());
148
+ }
149
+ }
150
+
151
+ function bundleKey(id: string, version: string): string {
152
+ return `${id}@${version}`;
153
+ }
154
+
155
+ function openDatabase(): Promise<IDBDatabase> {
156
+ if (dbPromise) return dbPromise;
157
+ dbPromise = new Promise((resolve, reject) => {
158
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
159
+ request.onerror = () => {
160
+ console.error('[extensions/idb] Failed to open database:', request.error);
161
+ dbPromise = null;
162
+ reject(request.error);
163
+ };
164
+ request.onupgradeneeded = (event) => {
165
+ const db = request.result;
166
+ // Migrations are append-only. When DB_VERSION bumps:
167
+ // case 1: ... // run v1→v2 migration here (e.g. add an index)
168
+ // case 2: ... // run v2→v3 here
169
+ // The createObjectStore calls below seed a fresh database (when
170
+ // event.oldVersion is 0) and remain idempotent for users who land
171
+ // here because recovery deleted the database below.
172
+ switch (event.oldVersion) {
173
+ case 0:
174
+ db.createObjectStore(STORE_EXT, { keyPath: 'id' });
175
+ db.createObjectStore(STORE_BUNDLES);
176
+ break;
177
+ default:
178
+ // No-op until a v2+ migration exists. Leaving the switch
179
+ // explicit keeps the contract visible: every future bump
180
+ // adds its own case.
181
+ break;
182
+ }
183
+ };
184
+ request.onsuccess = () => {
185
+ const db = request.result;
186
+ if (!db.objectStoreNames.contains(STORE_EXT) || !db.objectStoreNames.contains(STORE_BUNDLES)) {
187
+ // Recovery: delete and recreate.
188
+ db.close();
189
+ dbPromise = null;
190
+ const del = indexedDB.deleteDatabase(DB_NAME);
191
+ del.onsuccess = () => openDatabase().then(resolve).catch(reject);
192
+ del.onerror = () => reject(new Error('Failed to recreate extensions database.'));
193
+ // Without onblocked, another tab holding a connection makes the
194
+ // delete request hang indefinitely and openDatabase() never
195
+ // resolves. Reject explicitly so the caller can surface the issue.
196
+ del.onblocked = () => reject(new Error(
197
+ 'Extensions database recreation is blocked by another open tab. Close other tabs and reload.',
198
+ ));
199
+ return;
200
+ }
201
+ resolve(db);
202
+ };
203
+ });
204
+ return dbPromise;
205
+ }
206
+
207
+ function runStore<T = unknown>(
208
+ db: IDBDatabase,
209
+ storeName: string,
210
+ mode: IDBTransactionMode,
211
+ fn: (store: IDBObjectStore) => IDBRequest | void,
212
+ ): Promise<T> {
213
+ return new Promise<T>((resolve, reject) => {
214
+ const tx = db.transaction(storeName, mode);
215
+ const store = tx.objectStore(storeName);
216
+ let value: unknown;
217
+ const req = fn(store);
218
+ if (req instanceof IDBRequest) {
219
+ req.onsuccess = () => {
220
+ value = req.result;
221
+ };
222
+ req.onerror = () => reject(req.error);
223
+ }
224
+ tx.oncomplete = () => resolve(value as T);
225
+ tx.onerror = () => reject(tx.error);
226
+ tx.onabort = () => reject(tx.error);
227
+ });
228
+ }
@@ -0,0 +1,26 @@
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
+ * Helpers for turning runtime errors thrown by extension command
7
+ * execution into user-facing messages. The sandbox throws
8
+ * `CapabilityDeniedError` (and friends) by `.name`; we don't want
9
+ * every callsite re-implementing the discrimination, and we want a
10
+ * single place to refine the wording when we revisit copy.
11
+ */
12
+
13
+ export function describeRunCommandError(commandId: string, err: unknown): string {
14
+ if (err && typeof err === 'object' && 'name' in err) {
15
+ const name = (err as { name?: string }).name;
16
+ const message = err instanceof Error ? err.message : String(err);
17
+ if (name === 'CapabilityDeniedError') {
18
+ return (
19
+ `"${commandId}" tried to use a capability that wasn't granted. ` +
20
+ `Re-install with the missing capability checked, or skip this action. (${message})`
21
+ );
22
+ }
23
+ }
24
+ const message = err instanceof Error ? err.message : String(err);
25
+ return `Failed to run "${commandId}": ${message}`;
26
+ }
@@ -0,0 +1,217 @@
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
+ * Adapter — `RuntimeSandboxFactory` backed by `@ifc-lite/sandbox`.
7
+ *
8
+ * The viewer's production sandbox is the QuickJS-WASM runtime that the
9
+ * existing `@ifc-lite/sandbox` package already wraps. This module
10
+ * adapts that surface into the runtime contract defined by
11
+ * `@ifc-lite/extensions`:
12
+ *
13
+ * - `RuntimeSandboxHandle.setGlobal(name, value)` is implemented by
14
+ * wrapping the value as a JSON literal and pre-defining it on the
15
+ * QuickJS realm before each `run`.
16
+ * - `RuntimeSandboxHandle.run(source)` evaluates the wrapped source
17
+ * via `Sandbox.eval`, maps the returned log entries into the
18
+ * runtime's `RuntimeLogEntry` shape.
19
+ * - `RuntimeSandboxHandle.dispose()` calls `Sandbox.dispose()`.
20
+ *
21
+ * The factory accepts a `BimContext` at construction; that SDK is
22
+ * passed to every Sandbox created. The capability layer (in
23
+ * `@ifc-lite/extensions/host`) enforces granular access; the sandbox's
24
+ * coarse permission flags act as the outer ring.
25
+ */
26
+
27
+ import {
28
+ assertMethodCall,
29
+ CapabilityDeniedError,
30
+ type Capability,
31
+ type RuntimeRunOptions,
32
+ type RuntimeRunResult,
33
+ type RuntimeSandboxCreateOptions,
34
+ type RuntimeSandboxFactory,
35
+ type RuntimeSandboxHandle,
36
+ } from '@ifc-lite/extensions';
37
+ import { createSandbox, type Sandbox } from '@ifc-lite/sandbox';
38
+ import type { BimContext } from '@ifc-lite/sdk';
39
+
40
+ export interface SandboxFactoryOptions {
41
+ sdk: BimContext;
42
+ }
43
+
44
+ export function createBimSandboxFactory(opts: SandboxFactoryOptions): RuntimeSandboxFactory {
45
+ return {
46
+ async create(createOpts: RuntimeSandboxCreateOptions): Promise<RuntimeSandboxHandle> {
47
+ // Wrap the SDK with a per-method capability gate using the
48
+ // create-time grants. The outer-ring permission flags already
49
+ // gate at namespace level (model/viewer/etc.); this Proxy is
50
+ // the inner ring that flags fine-grained denials like
51
+ // "granted viewer.colorize but called viewer.fly".
52
+ const gatedSdk = createOpts.grants
53
+ ? wrapWithCapabilityGate(opts.sdk, createOpts.grants, createOpts.extensionId)
54
+ : opts.sdk;
55
+ const sandbox = await createSandbox(gatedSdk, {
56
+ permissions: createOpts.permissions,
57
+ limits: createOpts.limits,
58
+ });
59
+ return new BimSandboxHandle(sandbox);
60
+ },
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Wrap the BimContext so each object-namespace method runs
66
+ * `assertMethodCall(namespace, method, grants)` before forwarding.
67
+ *
68
+ * Implemented as a Proxy on the SDK ROOT — not by enumerating
69
+ * `Object.keys(sdk)`. The BimContext is a class instance: its
70
+ * namespaces and top-level methods (`query`, `entity`, `viewer`, …)
71
+ * live on the prototype, so `Object.keys` returns none of them and a
72
+ * key-enumerated copy comes out empty — every `bim.*` call then fails
73
+ * with "<x> is not a function". The root Proxy resolves members via
74
+ * the prototype chain, so nothing is dropped.
75
+ *
76
+ * Top-level functions (e.g. `sdk.query()`, `sdk.entity()`) pass
77
+ * through ungated — the coarse permission ring already gates whole
78
+ * namespaces; this inner ring only wraps object-namespace methods
79
+ * (`sdk.viewer.colorize`, etc.).
80
+ */
81
+ function wrapWithCapabilityGate(
82
+ sdk: BimContext,
83
+ grants: readonly Capability[],
84
+ extensionId: string,
85
+ ): BimContext {
86
+ const nsCache = new Map<string, unknown>();
87
+ return new Proxy(sdk as object, {
88
+ get(target, prop) {
89
+ const value = (target as Record<string | symbol, unknown>)[prop];
90
+ if (typeof prop !== 'string') return value;
91
+ // Functions / primitives pass through; only object namespaces
92
+ // get the per-method capability gate.
93
+ if (value === null || typeof value !== 'object') return value;
94
+ let wrapped = nsCache.get(prop);
95
+ if (!wrapped) {
96
+ wrapped = new Proxy(value as object, {
97
+ get(nsTarget, method) {
98
+ const m = (nsTarget as Record<string | symbol, unknown>)[method];
99
+ if (typeof m !== 'function' || typeof method !== 'string') return m;
100
+ return function gated(this: unknown, ...args: unknown[]) {
101
+ try {
102
+ assertMethodCall(prop, method, grants);
103
+ } catch (err) {
104
+ if (err instanceof CapabilityDeniedError) {
105
+ console.warn(`[ext:${extensionId}] denied ${prop}.${method}: ${err.message}`);
106
+ }
107
+ throw err;
108
+ }
109
+ return (m as (...a: unknown[]) => unknown).apply(nsTarget, args);
110
+ };
111
+ },
112
+ });
113
+ nsCache.set(prop, wrapped);
114
+ }
115
+ return wrapped;
116
+ },
117
+ }) as unknown as BimContext;
118
+ }
119
+
120
+ class BimSandboxHandle implements RuntimeSandboxHandle {
121
+ /**
122
+ * Globals pre-defined for the next `run`, keyed by name so re-setting
123
+ * a global REPLACES its assignment instead of appending a duplicate.
124
+ * (A plain accumulating string grew the wrapped source ~54 chars on
125
+ * every run as `__ifclite_ctx__` was re-set.)
126
+ */
127
+ private globals = new Map<string, string>();
128
+ private disposed = false;
129
+
130
+ constructor(private sandbox: Sandbox) {}
131
+
132
+ setGlobal(name: string, value: unknown): void {
133
+ if (this.disposed) throw new Error('Sandbox disposed.');
134
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) {
135
+ throw new Error(`Invalid global name: ${name}`);
136
+ }
137
+ // Special-case `__ifclite_ctx__` — the runtime calls
138
+ // `setGlobal('__ifclite_ctx__', { bim: <host SDK> })` for every
139
+ // activate / command-run. The host SDK is the wrapped BimContext
140
+ // (cyclic Proxies for the inner-ring capability gate) so JSON
141
+ // serialisation crashes. The bridge has already installed `bim`
142
+ // inside the QuickJS realm — synthesize ctx from that instead.
143
+ if (name === '__ifclite_ctx__') {
144
+ this.globals.set(name, `globalThis.__ifclite_ctx__ = { bim: globalThis.bim };`);
145
+ return;
146
+ }
147
+ // Other globals (test args, synthetic-spec data) are JSON-safe;
148
+ // serialise so the value crosses the realm boundary intact.
149
+ let serialised: string;
150
+ try {
151
+ serialised = JSON.stringify(value ?? null);
152
+ } catch (err) {
153
+ throw new Error(
154
+ `setGlobal("${name}"): value is not JSON-serialisable (${err instanceof Error ? err.message : err}).`,
155
+ );
156
+ }
157
+ this.globals.set(name, `globalThis.${name} = ${serialised};`);
158
+ }
159
+
160
+ async run(source: string, _opts?: RuntimeRunOptions): Promise<RuntimeRunResult> {
161
+ if (this.disposed) throw new Error('Sandbox disposed.');
162
+ const prelude = [...this.globals.values()].join('\n');
163
+ const wrapped = prelude ? `${prelude}\n${source}` : source;
164
+ let result;
165
+ try {
166
+ result = await this.sandbox.eval(wrapped, { typescript: false });
167
+ } catch (err) {
168
+ // QuickJS throws "Lifetime not alive" (QuickJSUseAfterFree) when
169
+ // a handle is touched after its underlying realm was disposed —
170
+ // typically because a prior run / flavor switch tore down this
171
+ // sandbox while the host still held the activation record. Mark
172
+ // ourselves disposed so the runtime knows to reactivate on the
173
+ // next call, and surface a clear retry-friendly message.
174
+ const msg = err instanceof Error ? err.message : String(err);
175
+ if (/Lifetime not alive|QuickJSUseAfterFree/i.test(msg)) {
176
+ this.disposed = true;
177
+ try { this.sandbox.dispose(); } catch { /* already torn down */ }
178
+ throw new Error(
179
+ 'Sandbox was torn down between activate and run. Click Run again — the runtime will reactivate.',
180
+ );
181
+ }
182
+ throw err;
183
+ }
184
+ return {
185
+ value: result.value,
186
+ logs: result.logs.map((log) => ({
187
+ level: log.level === 'log' ? 'log' : log.level,
188
+ message: log.args.map(stringifyArg).join(' '),
189
+ timestamp: log.timestamp,
190
+ })),
191
+ durationMs: result.durationMs,
192
+ };
193
+ }
194
+
195
+ /** True iff the sandbox has been torn down (host-disposed or auto-disposed on a Lifetime crash). */
196
+ get isDisposed(): boolean {
197
+ return this.disposed;
198
+ }
199
+
200
+ dispose(): void {
201
+ if (this.disposed) return;
202
+ this.disposed = true;
203
+ this.sandbox.dispose();
204
+ }
205
+ }
206
+
207
+ function stringifyArg(arg: unknown): string {
208
+ if (typeof arg === 'string') return arg;
209
+ try {
210
+ return JSON.stringify(arg);
211
+ } catch (err) {
212
+ // JSON.stringify throws on cycles / BigInt — fall back to String()
213
+ // but log so we can spot pathological logging in dev.
214
+ console.warn('[sandbox-factory] non-stringifiable log arg:', err);
215
+ return String(arg);
216
+ }
217
+ }
@@ -147,13 +147,55 @@ export const UI_DEFAULTS = {
147
147
  // Type Visibility Defaults
148
148
  // ============================================================================
149
149
 
150
+ /**
151
+ * localStorage keys for the type-visibility toggles. Each maps to a
152
+ * single boolean preference; same persistence pattern as
153
+ * `MERGE_LAYERS_STORAGE_KEY` (`'true'` / `'false'` string, anything
154
+ * else falls back to the semantic default). One key per toggle so a
155
+ * user can clear an individual preference without nuking the rest.
156
+ */
157
+ export const TYPE_VISIBILITY_STORAGE_KEYS = {
158
+ spaces: 'ifc-lite-ifc-spaces-visible',
159
+ openings: 'ifc-lite-ifc-openings-visible',
160
+ site: 'ifc-lite-ifc-site-visible',
161
+ ifcAnnotations: 'ifc-lite-ifc-annotations-visible',
162
+ } as const;
163
+
164
+ /** Legacy alias — kept until external callers migrate. */
165
+ export const IFC_ANNOTATIONS_STORAGE_KEY = TYPE_VISIBILITY_STORAGE_KEYS.ifcAnnotations;
166
+
167
+ function readPersistedBool(key: string, fallback: boolean): boolean {
168
+ if (typeof window === 'undefined') return fallback;
169
+ try {
170
+ const raw = localStorage.getItem(key);
171
+ if (raw === 'true') return true;
172
+ if (raw === 'false') return false;
173
+ return fallback;
174
+ } catch {
175
+ return fallback;
176
+ }
177
+ }
178
+
179
+ // Semantic defaults applied when no localStorage preference is set.
180
+ // IfcSpace / IfcOpeningElement off — they cover walls and confuse novices
181
+ // on first load. IfcSite + IfcAnnotation/IfcGrid on — both convey
182
+ // design intent users expect to see by default.
183
+ const SEMANTIC_DEFAULTS = {
184
+ spaces: false,
185
+ openings: false,
186
+ site: true,
187
+ ifcAnnotations: true,
188
+ } as const;
189
+
150
190
  export const TYPE_VISIBILITY_DEFAULTS = {
151
- /** IfcSpace visibility - off by default */
152
- SPACES: false,
153
- /** IfcOpeningElement visibility - off by default */
154
- OPENINGS: false,
155
- /** IfcSite visibility - on by default (when has geometry) */
156
- SITE: true,
191
+ /** IfcSpace visibility persisted across reloads. */
192
+ SPACES: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.spaces, SEMANTIC_DEFAULTS.spaces),
193
+ /** IfcOpeningElement visibility persisted across reloads. */
194
+ OPENINGS: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.openings, SEMANTIC_DEFAULTS.openings),
195
+ /** IfcSite visibility persisted across reloads. */
196
+ SITE: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.site, SEMANTIC_DEFAULTS.site),
197
+ /** IfcAnnotation + IfcGrid visibility — persisted across reloads. */
198
+ IFC_ANNOTATIONS: readPersistedBool(TYPE_VISIBILITY_STORAGE_KEYS.ifcAnnotations, SEMANTIC_DEFAULTS.ifcAnnotations),
157
199
  } as const;
158
200
 
159
201
  // ============================================================================
@@ -29,6 +29,7 @@ import { createDrawing2DSlice, type Drawing2DSlice } from './slices/drawing2DSli
29
29
  import { createSheetSlice, type SheetSlice } from './slices/sheetSlice.js';
30
30
  import { createBcfSlice, type BCFSlice } from './slices/bcfSlice.js';
31
31
  import { createIdsSlice, type IDSSlice } from './slices/idsSlice.js';
32
+ import { createExtensionsSlice, type ExtensionsSlice } from './slices/extensionsSlice.js';
32
33
  import { createListSlice, type ListSlice } from './slices/listSlice.js';
33
34
  import { createPinboardSlice, type PinboardSlice } from './slices/pinboardSlice.js';
34
35
  import { createLensSlice, type LensSlice } from './slices/lensSlice.js';
@@ -140,7 +141,8 @@ export type ViewerState = LoadingSlice &
140
141
  AddElementSlice &
141
142
  SplitToolSlice &
142
143
  LevelDisplaySlice &
143
- PointCloudSlice & {
144
+ PointCloudSlice &
145
+ ExtensionsSlice & {
144
146
  resetViewerState: () => void;
145
147
  };
146
148
 
@@ -180,6 +182,7 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
180
182
  ...createSplitToolSlice(...args),
181
183
  ...createLevelDisplaySlice(...args),
182
184
  ...createPointCloudSlice(...args),
185
+ ...createExtensionsSlice(...args),
183
186
 
184
187
  // Reset all viewer state when loading new file
185
188
  // Note: Does NOT clear models - use clearAllModels() for that
@@ -204,6 +207,7 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
204
207
  spaces: TYPE_VISIBILITY_DEFAULTS.SPACES,
205
208
  openings: TYPE_VISIBILITY_DEFAULTS.OPENINGS,
206
209
  site: TYPE_VISIBILITY_DEFAULTS.SITE,
210
+ ifcAnnotations: TYPE_VISIBILITY_DEFAULTS.IFC_ANNOTATIONS,
207
211
  },
208
212
 
209
213
  // Visibility (multi-model)
@@ -301,6 +305,7 @@ const createViewerStore = () => create<ViewerState>()((...args) => ({
301
305
  show3DOverlay: true,
302
306
  scale: 100,
303
307
  useSymbolicRepresentations: false,
308
+ showIfcAnnotations: true,
304
309
  },
305
310
  // Graphic overrides (keep presets, reset active and custom)
306
311
  activePresetId: 'preset-3d-colors',
@@ -91,6 +91,13 @@ export interface Drawing2DState {
91
91
  scale: number;
92
92
  /** Use authored symbolic representations (Plan/Annotation) when available instead of section cut */
93
93
  useSymbolicRepresentations: boolean;
94
+ /**
95
+ * Whether to overlay IfcAnnotation curves, text, and fills on the 2D
96
+ * section view. Filtered to annotations whose world position falls
97
+ * inside the section's view-range on the cut axis (issue #812 follow-up
98
+ * to the IfcAnnotation text feature).
99
+ */
100
+ showIfcAnnotations: boolean;
94
101
  };
95
102
  /** Available graphic override presets */
96
103
  graphicOverridePresets: GraphicOverridePreset[];
@@ -236,6 +243,7 @@ const getDefaultDisplayOptions = (): Drawing2DState['drawing2DDisplayOptions'] =
236
243
  show3DOverlay: true, // Show 3D overlay by default
237
244
  scale: 100, // 1:100 default
238
245
  useSymbolicRepresentations: false, // Default to section cut (Body geometry)
246
+ showIfcAnnotations: true, // Mirror the 3D Class Visibility default
239
247
  });
240
248
 
241
249
  const getDefaultState = (): Drawing2DState => ({
@@ -0,0 +1,90 @@
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
+ * Extensions panel visibility slice.
7
+ *
8
+ * The extension system's actual state lives in the host service
9
+ * (services/extensions/host.ts) — installed bundles, audit log, slot
10
+ * contributions. The store only owns the UI-toggle state so panel
11
+ * visibility behaves like every other dock panel (IDS, BCF, Lens,
12
+ * Lists, Script).
13
+ */
14
+
15
+ import type { StateCreator } from 'zustand';
16
+
17
+ export type ExtensionsTabView = 'installed' | 'ideas' | 'audit' | 'repair' | 'privacy';
18
+
19
+ export interface ExtensionsSlice {
20
+ extensionsPanelVisible: boolean;
21
+ setExtensionsPanelVisible: (visible: boolean) => void;
22
+ toggleExtensionsPanel: () => void;
23
+ /**
24
+ * Bytes of an authored bundle waiting for the user to review. The
25
+ * chat-side authoring loop sets this on success; the Extensions
26
+ * panel picks it up on mount, routes through CapabilityReview, and
27
+ * clears the slot after install or cancel.
28
+ */
29
+ pendingAuthoredBundle: Uint8Array | null;
30
+ setPendingAuthoredBundle: (bytes: Uint8Array | null) => void;
31
+ /**
32
+ * Which tab the Extensions panel should show. Set by deep-link
33
+ * entry points (Command Palette "Author an extension…", chat
34
+ * routing after a successful authoring loop) so the panel mounts
35
+ * straight into the right surface.
36
+ */
37
+ extensionsRequestedView: ExtensionsTabView | null;
38
+ setExtensionsRequestedView: (view: ExtensionsTabView | null) => void;
39
+ /**
40
+ * When true, the IdeasPanel should open the Plan Card in
41
+ * empty-plan mode on mount. Consumed once then cleared.
42
+ */
43
+ ideasOpenEmptyPlan: boolean;
44
+ setIdeasOpenEmptyPlan: (open: boolean) => void;
45
+ /**
46
+ * When true, the FlavorDialog should auto-open. Consumed once
47
+ * then cleared by the dialog wrapper.
48
+ */
49
+ flavorDialogRequested: boolean;
50
+ setFlavorDialogRequested: (open: boolean) => void;
51
+ /**
52
+ * Post-authoring "install" handoff. After the chat finishes an
53
+ * authoring turn, it sets this so the chat panel can render a
54
+ * prominent inline CTA — the user no longer has to hunt for a
55
+ * Promote button in another panel.
56
+ *
57
+ * kind 'bundle' — a full `.iflx` bundle was synthesised and is
58
+ * waiting in `pendingAuthoredBundle`; the CTA
59
+ * routes to the Extensions panel review.
60
+ * kind 'script' — the assistant wrote a one-shot script into the
61
+ * editor; the CTA opens PromoteToolDialog.
62
+ *
63
+ * Cleared when the user acts on it, dismisses it, or starts a new
64
+ * chat.
65
+ */
66
+ chatToolReady: { kind: 'bundle' | 'script'; name: string } | null;
67
+ setChatToolReady: (v: { kind: 'bundle' | 'script'; name: string } | null) => void;
68
+ }
69
+
70
+ export const createExtensionsSlice: StateCreator<
71
+ ExtensionsSlice,
72
+ [],
73
+ [],
74
+ ExtensionsSlice
75
+ > = (set) => ({
76
+ extensionsPanelVisible: false,
77
+ setExtensionsPanelVisible: (extensionsPanelVisible) => set({ extensionsPanelVisible }),
78
+ toggleExtensionsPanel: () =>
79
+ set((state) => ({ extensionsPanelVisible: !state.extensionsPanelVisible })),
80
+ pendingAuthoredBundle: null,
81
+ setPendingAuthoredBundle: (pendingAuthoredBundle) => set({ pendingAuthoredBundle }),
82
+ extensionsRequestedView: null,
83
+ setExtensionsRequestedView: (extensionsRequestedView) => set({ extensionsRequestedView }),
84
+ ideasOpenEmptyPlan: false,
85
+ setIdeasOpenEmptyPlan: (ideasOpenEmptyPlan) => set({ ideasOpenEmptyPlan }),
86
+ flavorDialogRequested: false,
87
+ setFlavorDialogRequested: (flavorDialogRequested) => set({ flavorDialogRequested }),
88
+ chatToolReady: null,
89
+ setChatToolReady: (chatToolReady) => set({ chatToolReady }),
90
+ });