@thatopen/services 0.3.0 → 0.3.2

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 (42) hide show
  1. package/README.md +3 -0
  2. package/dist/built-in/index.d.ts +22 -2
  3. package/dist/cli.js +3 -3
  4. package/docs/ai-quickstart.md +86 -0
  5. package/docs/cli/create.md +4 -3
  6. package/package.json +3 -3
  7. package/src/built-in/index.ts +669 -649
  8. package/src/cli/templates/app/package.json +31 -31
  9. package/src/cli/templates/app/src/app.ts +37 -37
  10. package/src/cli/templates/app/src/assets/tool-placeholder.ts +50 -50
  11. package/src/cli/templates/app/src/main.ts +223 -340
  12. package/src/cli/templates/app/src/setups/camera-tools.ts +63 -63
  13. package/src/cli/templates/app/src/setups/clipper-tool.ts +20 -1
  14. package/src/cli/templates/app/src/setups/clipper.ts +75 -75
  15. package/src/cli/templates/app/src/setups/index.ts +23 -33
  16. package/src/cli/templates/app/src/setups/inspection.ts +136 -136
  17. package/src/cli/templates/app/src/setups/measurement-tool.ts +19 -1
  18. package/src/cli/templates/app/src/setups/measurements.ts +133 -133
  19. package/src/cli/templates/app/src/setups/reality-capture/lib/hidden-tiles-plugin.ts +88 -88
  20. package/src/cli/templates/app/src/setups/reality-capture/lib/point-tile-plugin.ts +166 -166
  21. package/src/cli/templates/app/src/setups/reality-capture/lib/render-frame.ts +31 -31
  22. package/src/cli/templates/app/src/setups/reality-capture/lib/splat-tile-plugin.ts +51 -51
  23. package/src/cli/templates/app/src/setups/reality-capture-viewer.ts +1383 -1383
  24. package/src/cli/templates/app/src/setups/right-sidebar.ts +178 -178
  25. package/src/cli/templates/app/src/setups/styles-panel.ts +110 -110
  26. package/src/cli/templates/app/src/setups/tool-mode-manager.ts +15 -9
  27. package/src/cli/templates/app/src/setups/tool-mode.ts +23 -23
  28. package/src/cli/templates/app/src/setups/active-tool-hud.ts +0 -69
  29. package/src/cli/templates/app/src/setups/clipper-panel.ts +0 -167
  30. package/src/cli/templates/app/src/setups/commands-panel.ts +0 -239
  31. package/src/cli/templates/app/src/setups/data-table-panel.ts +0 -2224
  32. package/src/cli/templates/app/src/setups/file-format-icons.ts +0 -95
  33. package/src/cli/templates/app/src/setups/files-panel.ts +0 -1503
  34. package/src/cli/templates/app/src/setups/fps-indicator.ts +0 -63
  35. package/src/cli/templates/app/src/setups/graphics-panel.ts +0 -490
  36. package/src/cli/templates/app/src/setups/measurement-settings-panel.ts +0 -161
  37. package/src/cli/templates/app/src/setups/model-tree.ts +0 -797
  38. package/src/cli/templates/app/src/setups/objects-panel.ts +0 -127
  39. package/src/cli/templates/app/src/setups/properties-panel.ts +0 -1048
  40. package/src/cli/templates/app/src/setups/settings-panel.ts +0 -94
  41. package/src/cli/templates/app/src/setups/visibility-toolbar.ts +0 -498
  42. package/src/cli/templates/app/src/setups/walkthrough.ts +0 -528
@@ -1,1503 +0,0 @@
1
- import * as THREE from "three";
2
- import * as OBC from "@thatopen/components";
3
- import * as OBF from "@thatopen/components-front";
4
- import * as BUI from "@thatopen/ui";
5
- import { cardHeader } from "./card-header";
6
- import { formatGlyph } from "./file-format-icons";
7
- import { toolPlaceholderUri } from "../assets/tool-placeholder";
8
-
9
- /**
10
- * The "Project Files" card (mirrors the properties panel). Lists the project's
11
- * files, lets you upload new ones, and — for IFC files — runs the IFC→fragments
12
- * cloud component. Returns its `bim-panel` element WITHOUT self-mounting — the
13
- * caller (leftStack) places it in the left column stack.
14
- *
15
- * UI behaviour:
16
- * - Each IFC is a top-level row; its generated `.frag` is shown nested under it
17
- * (matched by basename) with a Load action. A `.frag` with no source IFC
18
- * shows as its own top-level row.
19
- * - Conversion is automatic on upload (no manual button). While an upload's IFC
20
- * converts, its row shows a progress bar driven by the polled execution %.
21
- * - Every row has a delete action (archive, with an inline confirm).
22
- *
23
- * Uses the platform client (same instance AppManager holds): listFiles,
24
- * createFile, downloadFile, listComponents, executeComponent, getExecution,
25
- * archiveFile.
26
- *
27
- * @param components engine components
28
- * @param client the PlatformClient (from PlatformClient.fromPlatformContext)
29
- * @returns the `bim-panel` element for the caller to mount
30
- */
31
- interface FileEntry {
32
- id: string;
33
- name: string;
34
- ext: string;
35
- base: string;
36
- }
37
-
38
- interface Job {
39
- progress: number;
40
- label: string;
41
- }
42
-
43
- // An optimistic, not-yet-uploaded file shown the instant it's picked.
44
- interface UploadEntry {
45
- tempId: string;
46
- name: string;
47
- ext: string;
48
- base: string;
49
- }
50
-
51
- interface FilesState {
52
- loading: boolean;
53
- note: string;
54
- files: FileEntry[];
55
- jobs: Record<string, Job>; // keyed by source file id
56
- confirmDelete: string | null; // file id pending an inline delete confirm
57
- loaded: string[]; // modelIds currently in the scene (fragments.list keys = fragIds)
58
- loadedByIfc: Record<string, string>; // IFC fileId → the fragId currently loaded for it
59
- fragOf: Record<string, string>; // IFC fileId → its fragments fileId (from metadata)
60
- loadingModels: string[]; // modelIds with an in-flight loadFrag (Add spinner)
61
- uploads: UploadEntry[]; // optimistic rows for in-flight uploads
62
- // Frags the app can REACH via IFC metadata but which may be absent from
63
- // listFiles (old-converter output created without projectId, hidden-file
64
- // output, or eventual-consistency lag). Keyed by fragId → { name, ifcId }.
65
- // Guarantees a detached frag never becomes unreachable: even with no listFiles
66
- // row, it surfaces as a standalone "orphan" row and stays in the attach picker.
67
- knownFrags: Record<string, { name: string; ifcId: string }>;
68
- associating: string | null; // IFC fileId whose frag-attach picker is open
69
- attachOptions: { fragId: string; label: string }[]; // frags offered in the picker
70
- attachLoading: boolean; // picker is gathering candidate frags
71
- filter: string; // file-list search query (filters rows by name)
72
- }
73
-
74
- const omit = (obj: Record<string, Job>, key: string) => {
75
- const clone = { ...obj };
76
- delete clone[key];
77
- return clone;
78
- };
79
-
80
- // Match the app's standard hairline everywhere (1px contrast-20) — the files
81
- // menu must not draw heavier rules than the rest of the panels.
82
- const BORDER = "1px solid var(--bim-ui_bg-contrast-20, rgba(255, 255, 255, 0.1))";
83
-
84
- // TEMP DIAGNOSTIC — one-shot guard so the frag-visibility probe runs only once
85
- // per page load (not on every silent reload/refocus). Remove with the diagnostic.
86
- let fragDiagRan = false;
87
-
88
- // File name / label text — always theme-coloured (never inherits black).
89
- const nameText = (name: string, muted = false) => BUI.html`
90
- <span
91
- style="
92
- flex: 1 1 auto; min-width: 0;
93
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
94
- color: ${muted
95
- ? "var(--bim-ui_bg-contrast-80, #c9c9c9)"
96
- : "var(--bim-ui_bg-contrast-100, #e3e3e3)"};
97
- "
98
- >${name}<bim-tooltip>${name}</bim-tooltip></span>
99
- `;
100
-
101
- // Visual loading bar fed by the polled conversion %.
102
- const progressBar = (job: Job) => {
103
- const pct = Math.max(0, Math.min(100, Math.round(job.progress)));
104
- return BUI.html`
105
- <span style="display: inline-flex; align-items: center; gap: 0.4rem; white-space: nowrap;">
106
- <span style="color: var(--bim-ui_bg-contrast-80, #c9c9c9); font-size: 0.68rem;">Processing…</span>
107
- <span
108
- title=${`${job.label}…`}
109
- style="
110
- position: relative; display: inline-block; width: 4rem; height: 0.4rem;
111
- border-radius: 0.2rem; overflow: hidden;
112
- background: var(--bim-ui_bg-contrast-40, rgba(255, 255, 255, 0.14));
113
- "
114
- >
115
- <span
116
- style="
117
- position: absolute; left: 0; top: 0; bottom: 0; width: ${pct}%;
118
- background: var(--bim-ui_accent-base);
119
- transition: width 0.3s ease;
120
- "
121
- ></span>
122
- </span>
123
- <span
124
- style="
125
- color: var(--bim-ui_bg-contrast-80, #c9c9c9);
126
- font-size: 0.7rem; font-variant-numeric: tabular-nums;
127
- "
128
- >${pct}%</span>
129
- </span>
130
- `;
131
- };
132
-
133
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
134
- export const filesPanel = (components: OBC.Components, client: any) => {
135
- const fragments = components.get(OBC.FragmentsManager);
136
- const projectId: string | undefined = client?.context?.projectId;
137
-
138
- // Reality-capture (.3tz point cloud / gaussian splat) viewer — opened from a
139
- // .3tz row's "View" action. LAZY-imported on first click: the module spins up
140
- // decode workers at load, so importing it eagerly crashes app startup; the
141
- // dynamic import keeps it off the boot path. Self-contained fullscreen overlay
142
- // with an isolated WebGLRenderer (zero interaction with the pen/MRT world).
143
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
144
- let rcViewer: any = null;
145
- const openThreeTZ = async (fileId: string) => {
146
- try {
147
- if (!rcViewer) {
148
- const { realityCaptureViewer } = await import("./reality-capture-viewer");
149
- rcViewer = realityCaptureViewer(components, client);
150
- }
151
- await rcViewer.loadThreeTZ(fileId);
152
- } catch (error) {
153
- console.error("[files] failed to open .3tz viewer", error);
154
- }
155
- };
156
-
157
- // "View in model": load the reality-capture dataset INTO the BIM world's scene
158
- // (co-located with the .frag models, one shared camera) via loadIntoWorld —
159
- // instead of the standalone fullscreen overlay. Same lazy-imported rcViewer (it
160
- // reaches the world via the components it was constructed with).
161
- const openInModel = async (fileId: string) => {
162
- try {
163
- if (!rcViewer) {
164
- const { realityCaptureViewer } = await import("./reality-capture-viewer");
165
- rcViewer = realityCaptureViewer(components, client);
166
- }
167
- // Persisted alignment: if we've saved a gizmo transform for this dataset,
168
- // restore it (RC skips the auto-fit when `transform` is supplied); otherwise
169
- // RC auto-fits to the world origin. Either way `onTransformChange` fires (on
170
- // gizmo release AND the initial auto-fit), so we save the matrix to app-data
171
- // → the dataset reopens exactly where the user last aligned it.
172
- const saved = appData.alignments[fileId];
173
- // Phase 1 by default: keep postproduction (PEN look) AND occlude the
174
- // splats behind BIM via W3's depth hook (occlusion confirmed correct).
175
- await rcViewer.loadIntoWorld(fileId, {
176
- keepPostproduction: true,
177
- transform: saved ? new THREE.Matrix4().fromArray(saved) : undefined,
178
- onTransformChange: (m: THREE.Matrix4) => {
179
- appData.alignments[fileId] = m.toArray();
180
- void saveAppData();
181
- },
182
- });
183
- } catch (error) {
184
- console.error("[files] failed to load reality-capture into model", error);
185
- }
186
- };
187
-
188
- let converterId: string | null = null;
189
- let converterVersionTag: string | null = null; // latest converter version to run
190
- let current: FilesState = {
191
- loading: true,
192
- note: "",
193
- files: [],
194
- jobs: {},
195
- confirmDelete: null,
196
- loaded: [],
197
- loadedByIfc: {},
198
- fragOf: {},
199
- loadingModels: [],
200
- uploads: [],
201
- knownFrags: {},
202
- associating: null,
203
- attachOptions: [],
204
- attachLoading: false,
205
- filter: "",
206
- };
207
-
208
- const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
209
-
210
- // fragIds with a load/unload currently in progress. Guards against spamming
211
- // Add/Remove: a second op on the same model is ignored until the first settles,
212
- // so we never dispose a model mid-load or double-load/double-dispose.
213
- const inFlight = new Set<string>();
214
-
215
- // ── Project app-data (the source of truth for ifc↔frag links) ──
216
- // The platform has no project-scoped HIDDEN-file API (hidden files attach to a
217
- // parent item; createFile is FILE-only). So we persist a regular project file
218
- // with a dotted name. It never renders in this panel (filtered out by name),
219
- // though it can still appear in the platform's global file browser.
220
- const APPDATA_NAME = ".bim-viewer-appdata.json";
221
- let appData: {
222
- associations: Record<string, string>;
223
- loadedModels: string[];
224
- pending: Record<string, { executionId: string; startedAt: number }>;
225
- detached: string[];
226
- alignments: Record<string, number[]>;
227
- } = {
228
- associations: {}, // ifcFileId → fragFileId
229
- loadedModels: [], // modelIds last present in the scene
230
- pending: {}, // ifcFileId → in-flight conversion execution (survives reload)
231
- // IFCs the user has MANUALLY detached. The converter's `fragmentsFileId` stays
232
- // stamped on the IFC metadata forever, so without this, bootstrapFromMetadata
233
- // would re-link a detached IFC on the next reload. This makes detach stick.
234
- detached: [],
235
- // reality-capture fileId → 16-float local→world matrix: the "View in model"
236
- // gizmo alignment, persisted so a splat dataset reopens at its saved position.
237
- alignments: {},
238
- };
239
- let appDataFileId: string | null = null;
240
- let appDataLoaded = false;
241
-
242
- // First/only world (created by viewports-manager before this panel mounts).
243
- // Used to put loaded models into the scene and to frame the camera.
244
- const worlds = components.get(OBC.Worlds);
245
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
246
- const getWorld = () => [...worlds.list.values()][0] as any;
247
-
248
- // Hidden file picker, triggered by the Upload button.
249
- const fileInput = document.createElement("input");
250
- fileInput.type = "file";
251
- fileInput.accept = ".ifc,.frag";
252
- fileInput.multiple = true;
253
- fileInput.style.display = "none";
254
- document.body.append(fileInput);
255
-
256
- // Trash icon → inline "Delete?" confirm → archive. Renders from `state` so it
257
- // always reflects the latest confirm target.
258
- const deleteControls = (state: FilesState, file: FileEntry) =>
259
- state.confirmDelete === file.id
260
- ? BUI.html`
261
- <span style="color: var(--bim-ui_bg-contrast-80, #c9c9c9); font-size: 0.72rem; white-space: nowrap;">Delete?</span>
262
- <bim-button icon="mdi:check" @click=${() => removeFile(file.id)}></bim-button>
263
- <bim-button icon="mdi:close" @click=${() => apply({ confirmDelete: null })}></bim-button>`
264
- : BUI.html`<bim-button
265
- icon="mdi:trash-can-outline"
266
- @click=${() => apply({ confirmDelete: file.id })}
267
- ></bim-button>`;
268
-
269
- // A `.frag` row. `nested` = shown indented under its source IFC.
270
- const fragRow = (state: FilesState, file: FileEntry, nested: boolean) => BUI.html`
271
- <div
272
- style="
273
- display: flex; align-items: center; gap: 0.5rem;
274
- padding: 0.35rem 0.75rem 0.35rem ${nested ? "2.15rem" : "0.75rem"};
275
- font-size: 0.78rem; line-height: 1.3;
276
- ${nested ? "" : `border-bottom: ${BORDER};`}
277
- "
278
- >
279
- ${nested
280
- ? BUI.html`<span style="color: var(--bim-ui_bg-contrast-60, #9a9a9a); flex-shrink: 0;">↳</span>`
281
- : null}
282
- ${formatGlyph(file.ext)}
283
- ${nameText(file.name, nested)}
284
- <span style="display: flex; align-items: center; gap: 0.35rem; flex-shrink: 0;">
285
- ${state.loaded.includes(file.id)
286
- ? BUI.html`<bim-button
287
- icon="mdi:eye-off-outline"
288
- ?loading=${state.loadingModels.includes(file.id)}
289
- @click=${() => unloadFrag(file.id)}
290
- ><bim-tooltip>Remove from scene</bim-tooltip></bim-button>`
291
- : BUI.html`<bim-button
292
- icon="mdi:eye-outline"
293
- ?loading=${state.loadingModels.includes(file.id)}
294
- @click=${() => loadFrag(file.id)}
295
- ><bim-tooltip>Add to scene</bim-tooltip></bim-button>`}
296
- ${deleteControls(state, file)}
297
- </span>
298
- </div>
299
- `;
300
-
301
- // A reality-capture 3D Tiles row (root `*.tileset.json`). Always top-level.
302
- // Two actions: "View" opens the dataset in a standalone fullscreen overlay
303
- // (loadThreeTZ); "View in model" loads it INTO the BIM world's scene, co-located
304
- // with the .frag models under one shared camera (loadIntoWorld). Both fetch the
305
- // root tileset and stream the hidden tile children on demand.
306
- const tilesRow = (state: FilesState, file: FileEntry) => BUI.html`
307
- <div
308
- style="
309
- display: flex; align-items: center; gap: 0.5rem;
310
- padding: 0.35rem 0.75rem; font-size: 0.78rem; line-height: 1.3;
311
- border-bottom: ${BORDER};
312
- "
313
- >
314
- ${formatGlyph(file.ext)}
315
- ${nameText(file.name)}
316
- <span style="display: flex; align-items: center; gap: 0.35rem; flex-shrink: 0;">
317
- <bim-button
318
- icon="mdi:eye-outline"
319
- label="View"
320
- @click=${() => void openThreeTZ(String(file.id))}
321
- ></bim-button>
322
- <bim-button
323
- icon="mdi:cube-scan"
324
- label="View in model"
325
- @click=${() => void openInModel(String(file.id))}
326
- ></bim-button>
327
- ${deleteControls(state, file)}
328
- </span>
329
- </div>
330
- `;
331
-
332
- // The inline frag-attach picker, shown under an IFC row when its clip is
333
- // clicked. Candidates are gathered from every IFC's metadata + standalone frag
334
- // files (converter output is hidden, so it isn't in the file list) — see
335
- // loadAttachOptions. A flat, indented section that reads as part of the row.
336
- const attachPicker = (state: FilesState, file: FileEntry) => {
337
- const hasFrag = !!state.fragOf[file.id];
338
- const opts = state.attachOptions;
339
- return BUI.html`
340
- <div class="attach-picker">
341
- <span class="attach-label">Attach a fragments file</span>
342
- ${state.attachLoading
343
- ? BUI.html`<span class="attach-empty">Finding fragments…</span>`
344
- : opts.length === 0
345
- ? BUI.html`<span class="attach-empty">No unattached fragments found in this project.</span>`
346
- : BUI.html`<div class="files-scroll attach-list">
347
- ${opts.map((o) => {
348
- const sel = state.fragOf[file.id] === o.fragId;
349
- return BUI.html`<div
350
- class="frag-opt${sel ? " sel" : ""}"
351
- @click=${() => (sel ? undefined : associate(file.id, o.fragId))}
352
- >
353
- ${formatGlyph("frag")}
354
- <span class="lbl">${o.label}</span>
355
- ${sel
356
- ? BUI.html`<bim-icon class="frag-opt-check" icon="mdi:check-circle"></bim-icon>`
357
- : null}
358
- </div>`;
359
- })}
360
- </div>`}
361
- <div class="attach-actions">
362
- ${hasFrag
363
- ? BUI.html`<span class="attach-act" @click=${() => detach(file.id)}>
364
- <bim-icon icon="mdi:link-variant-off"></bim-icon>Detach
365
- </span>`
366
- : null}
367
- <span class="attach-act" @click=${() => apply({ associating: null })}>
368
- <bim-icon icon="mdi:close"></bim-icon>Close
369
- </span>
370
- </div>
371
- </div>
372
- `;
373
- };
374
-
375
- // An IFC row (one row per IFC — the .frag is NOT shown separately). A clip
376
- // toggle is ALWAYS shown: dim when no frag is attached, bright when one is;
377
- // clicking it opens the attach picker. The action is Add (convert-if-needed,
378
- // then load) ↔ Remove, with a progress bar while converting.
379
- const ifcRow = (state: FilesState, file: FileEntry) => {
380
- const job = state.jobs[file.id];
381
- const fragId = state.fragOf[file.id]; // from the app-data associations
382
- const hasFrag = !!fragId;
383
- const fragName =
384
- state.files.find((f) => f.id === fragId)?.name ?? "Fragments attached";
385
- // "Loaded" must reflect the CURRENTLY associated frag — not merely that some
386
- // (possibly stale) frag for this IFC is in the scene. If a different frag is
387
- // loaded for this IFC, the row stays on "Add" so a click loads the new one.
388
- const loaded = !!fragId && state.loadedByIfc[file.id] === fragId;
389
- const picking = state.associating === file.id;
390
- return BUI.html`
391
- <div style="display: flex; flex-direction: column; padding: 0.4rem 0.75rem; border-bottom: ${BORDER};">
392
- <div style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; line-height: 1.3;">
393
- ${formatGlyph(file.ext)}
394
- ${nameText(file.name)}
395
- <span
396
- class="file-clip"
397
- @click=${() => openAttach(file)}
398
- style="position: relative; display: inline-flex; align-items: center; flex-shrink: 0; cursor: pointer;"
399
- ><bim-icon
400
- icon=${hasFrag ? "mdi:paperclip" : "mdi:paperclip-plus"}
401
- style="color: #99a0ae;"
402
- ></bim-icon><bim-tooltip>${hasFrag ? fragName : "Attach a fragments file"}</bim-tooltip></span>
403
- <span style="display: flex; align-items: center; gap: 0.35rem; flex-shrink: 0;">
404
- ${job
405
- ? progressBar(job)
406
- : loaded
407
- ? BUI.html`<bim-button
408
- icon="mdi:eye-off-outline"
409
- ?loading=${!!fragId && state.loadingModels.includes(fragId)}
410
- @click=${() => unloadFrag(fragId!, file.id)}
411
- ><bim-tooltip>Remove from scene</bim-tooltip></bim-button>`
412
- : BUI.html`<bim-button
413
- icon="mdi:eye-outline"
414
- ?loading=${!!fragId && state.loadingModels.includes(fragId)}
415
- @click=${() => addToScene(file)}
416
- ><bim-tooltip>Add to scene</bim-tooltip></bim-button>`}
417
- ${deleteControls(state, file)}
418
- </span>
419
- </div>
420
- ${picking ? attachPicker(state, file) : null}
421
- </div>
422
- `;
423
- };
424
-
425
- // Optimistic row for a file that's still uploading (shown the instant it's
426
- // picked). No real % is available from createFile, so the bar is indeterminate.
427
- const uploadRow = (entry: UploadEntry) => BUI.html`
428
- <div
429
- style="
430
- display: flex; align-items: center; gap: 0.5rem;
431
- padding: 0.4rem 0.75rem; font-size: 0.8rem; line-height: 1.3;
432
- border-bottom: ${BORDER};
433
- "
434
- >
435
- ${formatGlyph(entry.ext)}
436
- ${nameText(entry.name)}
437
- <span style="display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0;">
438
- <span style="color: var(--bim-ui_bg-contrast-80, #c9c9c9); font-size: 0.68rem;">Uploading…</span>
439
- <span class="indet-track"><span class="indet-fill"></span></span>
440
- </span>
441
- </div>
442
- `;
443
-
444
- // Top-level list: every IFC, plus any standalone `.frag` with no source IFC.
445
- const renderList = (state: FilesState) => {
446
- const ifcs = state.files.filter((f) => f.ext === "ifc");
447
- const frags = state.files.filter((f) => f.ext === "frag");
448
- // Reality-capture 3D Tiles — always standalone top-level rows (no IFC
449
- // source). The loose-tiles viewer opens the ROOT `*.tileset.json` (tiles are
450
- // hidden `.spz`/`.pnts` children, streamed on demand). Match the full
451
- // `.tileset.json` suffix — NOT `ext === "json"`, which would catch app-data
452
- // and every other JSON. The old `.3tz` zip format is no longer viewable here
453
- // (the loose-tiles viewer has no unzip path), so it gets no row/View action.
454
- const tiles = state.files.filter((f) =>
455
- f.name.toLowerCase().endsWith(".tileset.json"),
456
- );
457
- // A frag is "linked" if some IFC's metadata points to it; those are
458
- // represented by their IFC row, not a standalone row.
459
- const linked = new Set(Object.values(state.fragOf));
460
- const standalone = frags.filter((f) => !linked.has(f.id));
461
- // ORPHAN frags: reachable via IFC metadata but NOT present as a listFiles row
462
- // (old-converter output with no projectId, hidden-file output, or listFiles
463
- // lag). Once unlinked, they'd otherwise be invisible — so surface them here as
464
- // standalone rows. This is what makes the "a detached frag can never become
465
- // unreachable" guarantee hold even when the frag isn't a listable file.
466
- const listedIds = new Set(state.files.map((f) => f.id));
467
- const orphans: FileEntry[] = Object.entries(state.knownFrags)
468
- .filter(([fid]) => !linked.has(fid) && !listedIds.has(fid))
469
- .map(([fid, info]) => {
470
- const name = info.name || "fragments.frag";
471
- return {
472
- id: fid,
473
- name,
474
- ext: "frag",
475
- base: name.replace(/\.[^.]+$/, ""),
476
- };
477
- });
478
- const all = [
479
- ...ifcs.map((f) => ({ kind: "ifc" as const, file: f })),
480
- ...standalone.map((f) => ({ kind: "frag" as const, file: f })),
481
- ...orphans.map((f) => ({ kind: "frag" as const, file: f })),
482
- ...tiles.map((f) => ({ kind: "3tz" as const, file: f })),
483
- ].sort((a, b) => a.file.name.localeCompare(b.file.name));
484
- const q = state.filter.trim().toLowerCase();
485
- const top = q
486
- ? all.filter((e) => e.file.name.toLowerCase().includes(q))
487
- : all;
488
- return BUI.html`
489
- <div style="display: flex; flex-direction: column; border-top: ${BORDER};">
490
- ${state.uploads.map((u) => uploadRow(u))}
491
- ${top.map((entry) =>
492
- entry.kind === "ifc"
493
- ? ifcRow(state, entry.file)
494
- : entry.kind === "3tz"
495
- ? tilesRow(state, entry.file)
496
- : fragRow(state, entry.file, false),
497
- )}
498
- ${top.length === 0 && all.length > 0
499
- ? BUI.html`<div style="padding: 0.5rem 0.75rem; font-size: 0.75rem; color: var(--bim-ui_bg-contrast-70, #9a9a9a);">No files match "${state.filter}".</div>`
500
- : null}
501
- </div>
502
- `;
503
- };
504
-
505
- // ── Assets coordination settings (increment c) ────────────────
506
- // Base-model + .frag auto-coordinate, wired to W3's FragmentsManager API
507
- // (setBaseModel / setAutoCoordinate / coordinate). Cast `as any` — these are
508
- // new lib methods not yet in the copied dist's types (bounce on wire).
509
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
510
- const frg = fragments as any;
511
- let autoCoordOn = true; // global UX: all models use their embedded placement (default)
512
- const baseModelId = (): string => String(frg.baseCoordinationModel ?? "");
513
- const modelLabel = (st: FilesState, id: string): string =>
514
- st.files.find((f) => f.id === id)?.name ?? id;
515
-
516
- const [panel, update] = BUI.Component.create<BUI.Panel, FilesState>(
517
- (state) => BUI.html`
518
- <bim-panel
519
- label="Project Files"
520
- icon="mdi:folder-multiple-outline"
521
- header-hidden
522
- style="width: 20rem; height: 100%; pointer-events: auto;"
523
- >
524
- <style>
525
- .files-scroll::-webkit-scrollbar { width: 0.4rem; height: 0.4rem; }
526
- .files-scroll::-webkit-scrollbar-thumb {
527
- border-radius: 0.25rem;
528
- background-color: var(--bim-scrollbar--c, #3C3C41);
529
- }
530
- .files-scroll::-webkit-scrollbar-track {
531
- background-color: var(--bim-scrollbar--bgc, var(--bim-ui_bg-contrast-10));
532
- }
533
- /* Indeterminate upload bar (createFile gives no real %). */
534
- .indet-track {
535
- position: relative; display: inline-block;
536
- width: 4rem; height: 0.4rem; border-radius: 0.2rem; overflow: hidden;
537
- background: var(--bim-ui_bg-contrast-40, rgba(255, 255, 255, 0.14));
538
- }
539
- .indet-fill {
540
- position: absolute; top: 0; bottom: 0; left: 0; width: 40%;
541
- border-radius: 0.2rem; background: var(--bim-ui_accent-base);
542
- animation: indet 1.1s ease-in-out infinite;
543
- }
544
- @keyframes indet { 0% { left: -40%; } 100% { left: 100%; } }
545
- /* Frag-attach picker — compact rows styled like the model tree. */
546
- .attach-picker {
547
- display: flex; flex-direction: column; gap: 0.15rem;
548
- margin: 0.35rem 0 0.1rem 0.4rem; padding-left: 0.6rem;
549
- border-left: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1));
550
- }
551
- .attach-label {
552
- font-size: 0.68rem; letter-spacing: 0.02em;
553
- color: var(--bim-ui_bg-contrast-70, #9a9a9a); padding: 0.1rem 0 0.15rem;
554
- }
555
- .attach-empty { font-size: 0.72rem; color: var(--bim-ui_bg-contrast-70, #9a9a9a); padding: 0.1rem 0 0.2rem; }
556
- .attach-list { display: flex; flex-direction: column; max-height: 11rem; overflow-y: auto; padding-right: 0.2rem; }
557
- .frag-opt {
558
- display: flex; align-items: center; gap: 0.4rem;
559
- height: 1.55rem; padding: 0 0.45rem; border-radius: 0.25rem;
560
- font-size: 0.72rem; color: var(--bim-ui_bg-contrast-100, #e3e3e3);
561
- cursor: pointer; user-select: none;
562
- }
563
- .frag-opt:hover { background: var(--bim-ui_bg-contrast-20); }
564
- .frag-opt.sel { background: var(--bim-ui_bg-contrast-20); }
565
- .frag-opt bim-icon { flex: 0 0 auto; font-size: 0.85rem; color: #99a0ae; }
566
- .frag-opt.sel bim-icon { color: var(--bim-ui_accent-base); }
567
- .frag-opt .lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 auto; }
568
- .frag-opt bim-icon.frag-opt-check { flex: 0 0 auto; color: var(--bim-ui_bg-contrast-80); }
569
- .frag-attached { flex: 0 0 auto; font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--bim-ui_accent-base); opacity: 0.9; }
570
- .frag-opt.sel { cursor: default; }
571
- .attach-actions { display: flex; gap: 0.9rem; padding: 0.2rem 0 0.1rem 0.1rem; }
572
- .attach-act {
573
- display: inline-flex; align-items: center; gap: 0.25rem;
574
- font-size: 0.7rem; color: var(--bim-ui_bg-contrast-70, #9a9a9a);
575
- cursor: pointer; user-select: none;
576
- }
577
- .attach-act:hover { color: var(--bim-ui_bg-contrast-100, #e3e3e3); }
578
- .attach-act bim-icon { font-size: 0.8rem; }
579
- /* Row action buttons (Add/Remove, clip, trash, confirm): no background
580
- until hovered, then a subtle gray (override the button's purple
581
- ripple to a neutral). */
582
- .files-scroll bim-button {
583
- /* Muted icon at rest (matches the tree's focus button), brightens on hover. */
584
- }
585
- .file-clip {
586
- padding: 0.2rem; border-radius: var(--bim-ui_size-4xs, 4px);
587
- transition: background 0.12s ease;
588
- }
589
- .file-clip:hover { background: var(--bim-ui_bg-contrast-20); }
590
- /* Assets coordination settings (base model + auto-coordinate). */
591
- .as-settings {
592
- display: flex; flex-direction: column; gap: 0.4rem;
593
- padding: 0.55rem 0.75rem; flex: 0 0 auto;
594
- border-bottom: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1));
595
- }
596
- .as-row { display: flex; align-items: center; gap: 0.5rem; }
597
- .as-toggle { cursor: pointer; justify-content: space-between; }
598
- .as-lbl { font-size: 0.72rem; white-space: nowrap;
599
- color: var(--bim-ui_bg-contrast-80, #c9c9c9); }
600
- </style>
601
- <!-- Outer column: NO overflow. Header fixed; only the file list scrolls
602
- (inset inner region), matching the tree + properties cards. -->
603
- <div style="display: flex; flex-direction: column; height: 100%;">
604
- ${cardHeader("mdi:folder-multiple-outline", "Project Files", "1.1rem")}
605
- <div style="flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column;">
606
- <!-- Assets coordination settings (base model + .frag auto-coordinate),
607
- shown only once a model is loaded. -->
608
- ${fragments.list.size > 0
609
- ? BUI.html`<div class="as-settings">
610
- <div class="as-row">
611
- <span class="as-lbl">Base model</span>
612
- <bim-dropdown
613
- style="flex: 1 1 auto; min-width: 0;"
614
- @change=${(e: Event) => {
615
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
616
- const v = (e.target as any).value?.[0];
617
- if (v) void onBaseChange(String(v));
618
- }}
619
- >
620
- ${[...fragments.list.keys()].map(
621
- (id) => BUI.html`<bim-option .value=${id} label=${modelLabel(state, id)} ?checked=${id === baseModelId()}></bim-option>`,
622
- )}
623
- </bim-dropdown>
624
- </div>
625
- <label class="as-row as-toggle">
626
- <span class="as-lbl">Auto-coordinate (.frag placement)</span>
627
- <bim-checkbox
628
- toggle
629
- ?checked=${autoCoordOn}
630
- @change=${(e: Event) => {
631
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
632
- void onAutoCoord(!!(e.target as any).checked);
633
- }}
634
- ></bim-checkbox>
635
- </label>
636
- </div>`
637
- : null}
638
- <!-- Fixed bits: filter searchbar + icon upload + note. -->
639
- <div style="display: flex; flex-direction: column; gap: 0.5rem; padding: 0.6rem 0.75rem 0.5rem; flex: 0 0 auto;">
640
- <div style="display: flex; align-items: stretch; gap: 0.4rem;">
641
- <bim-text-input
642
- icon="mdi:magnify"
643
- icon-inside
644
- placeholder="Filter files…"
645
- .value=${state.filter}
646
- style="flex: 1 1 auto;"
647
- @input=${(e: Event) => {
648
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
649
- apply({ filter: String((e.target as any).value ?? "") });
650
- }}
651
- ></bim-text-input>
652
- <bim-button
653
- icon="mdi:upload"
654
- style="flex: 0 0 auto;"
655
- @click=${() => fileInput.click()}
656
- ><bim-tooltip>Upload file</bim-tooltip></bim-button>
657
- </div>
658
- ${state.note
659
- ? BUI.html`<bim-label style="opacity: 0.7; white-space: normal;">${state.note}</bim-label>`
660
- : null}
661
- </div>
662
- <!-- Scrolling region: the file list. -->
663
- <div
664
- class="files-scroll"
665
- style="flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0 0 0.6rem;"
666
- >
667
- ${state.loading
668
- ? BUI.html`<bim-label style="opacity: 0.6;">Loading…</bim-label>`
669
- : state.files.length === 0
670
- ? BUI.html`<div style="
671
- display: flex; flex-direction: column; align-items: center; justify-content: center;
672
- gap: 0.75rem; height: 100%; min-height: 12rem; padding: 1rem; text-align: center;
673
- ">
674
- <img src=${toolPlaceholderUri} alt="" style="width: 7.5rem; height: auto; opacity: 0.9;" />
675
- <bim-label style="opacity: 0.55; white-space: normal;">No files yet. Upload an IFC to convert it.</bim-label>
676
- </div>`
677
- : renderList(state)}
678
- </div>
679
- </div>
680
- </div>
681
- </bim-panel>
682
- `,
683
- current,
684
- );
685
-
686
- const apply = (partial: Partial<FilesState>) => {
687
- current = update(partial);
688
- };
689
-
690
- // ── Assets coordination handlers (increment c) ─────────────────
691
- // Re-coordination can shift models a LOT (geo-referenced CRS coords) → re-fit
692
- // the camera to the new union bounds so the model never jumps off-screen.
693
- const refitCamera = async () => {
694
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
695
- const world = [...worlds.list.values()][0] as any;
696
- const controls = world?.camera?.controls;
697
- if (!controls?.fitToSphere) return;
698
- const box = new THREE.Box3();
699
- for (const [, m] of fragments.list) {
700
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
701
- const b = (m as any).box as THREE.Box3 | undefined;
702
- if (b && !b.isEmpty()) box.union(b);
703
- }
704
- if (box.isEmpty()) return;
705
- const sphere = box.getBoundingSphere(new THREE.Sphere());
706
- try {
707
- await controls.fitToSphere(sphere, true);
708
- } catch (error) {
709
- console.warn("[files-panel] re-fit after coordinate failed", error);
710
- }
711
- };
712
- const onBaseChange = async (modelId: string) => {
713
- if (fragments.list.size === 0 || !modelId) return; // guard per W3
714
- try {
715
- await frg.setBaseModel(modelId); // re-coordinates the rest to it (async, self-updates)
716
- } catch (error) {
717
- console.warn("[files-panel] setBaseModel failed", error);
718
- }
719
- await refitCamera();
720
- apply({}); // reflect the new base in the dropdown
721
- };
722
- const onAutoCoord = async (on: boolean) => {
723
- autoCoordOn = on;
724
- // Apply to ALL loaded models (global toggle): on = embedded placement aligned
725
- // to base; off = each parked at three-space origin.
726
- for (const id of fragments.list.keys()) {
727
- try {
728
- await frg.setAutoCoordinate(id, on);
729
- } catch (error) {
730
- console.warn("[files-panel] setAutoCoordinate failed", id, error);
731
- }
732
- }
733
- await refitCamera();
734
- apply({});
735
- };
736
-
737
- // ── Scene wiring + reactive loaded-state ───────────────────────
738
- // `fragments.core.load` only adds a model to `fragments.list`; it does NOT put
739
- // it in the scene. The model must be added to the world scene and told which
740
- // camera to use for culling/LOD. This wiring used to live with the (now
741
- // removed) hardcoded model — its absence is why "Add" loaded nothing visible.
742
- // Subscribing to the list also keeps the Add/Remove buttons in sync with the
743
- // actual scene, even if a model is added/removed elsewhere.
744
- const syncLoaded = () => {
745
- const loaded = [...fragments.list.keys()];
746
- // Drop any ifc→fragId mapping whose model is no longer in the scene (it may
747
- // have been disposed elsewhere), so the IFC row falls back to "Add".
748
- const inScene = new Set(loaded);
749
- const loadedByIfc = Object.fromEntries(
750
- Object.entries(current.loadedByIfc).filter(([, fid]) => inScene.has(fid)),
751
- );
752
- apply({ loaded, loadedByIfc });
753
- };
754
-
755
- // Keep the Add/Remove buttons in sync with the scene. We deliberately do NOT
756
- // wire the model into the scene here: onItemSet fires while fragments.core.load
757
- // is still registering the model in the worker, so driving
758
- // useCamera/scene.add/update(true) now addresses a model the worker doesn't
759
- // have yet → "Model not found". loadFrag does that wiring AFTER load resolves.
760
- fragments.list.onItemSet.add(() => syncLoaded());
761
-
762
- fragments.list.onItemDeleted.add((event) => {
763
- // disposeModel removes it from the list; defensively detach the object from
764
- // the scene too so Remove always clears it.
765
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
766
- (event as any)?.value?.object?.removeFromParent?.();
767
- fragments.core.update(true);
768
- syncLoaded();
769
- });
770
-
771
- // ── Data ───────────────────────────────────────────────────────
772
- async function resolveConverter() {
773
- try {
774
- const comps = await client.listComponents({ projectId });
775
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
776
- const match =
777
- comps.find(
778
- (c: any) => /ifc/i.test(c.name) && /(frag|convert)/i.test(c.name),
779
- ) ?? comps.find((c: any) => /ifc/i.test(c.name));
780
- converterId = match?._id ? String(match._id) : null;
781
- if (!converterId) {
782
- apply({ note: "IFC→fragments component not found in this project." });
783
- return;
784
- }
785
- // Resolve the LATEST version so we don't run pinned v1 (old converter).
786
- // The platform returns versions newest-FIRST, so index-based picking is
787
- // unsafe. Sort by createdAt desc; fall back to the highest numeric tag.
788
- try {
789
- const comp = await client.getComponent(converterId, { showVersions: true });
790
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
791
- const versions: any[] = comp?.versions ?? [];
792
- const tagNum = (t: unknown) => {
793
- const m = /(\d+)/.exec(String(t ?? ""));
794
- return m ? parseInt(m[1], 10) : -1;
795
- };
796
- const newest = versions.slice().sort((a, b) => {
797
- const ta = a?.createdAt ? new Date(a.createdAt).getTime() : NaN;
798
- const tb = b?.createdAt ? new Date(b.createdAt).getTime() : NaN;
799
- if (!Number.isNaN(ta) && !Number.isNaN(tb) && ta !== tb) return tb - ta; // newest first
800
- return tagNum(b?.tag) - tagNum(a?.tag); // fallback: highest tag first
801
- })[0];
802
- converterVersionTag = newest ? String(newest.tag) : null;
803
- } catch (verError) {
804
- console.warn("[files-panel] could not read converter versions", verError);
805
- }
806
- } catch (error) {
807
- console.warn("[files-panel] listComponents failed", error);
808
- }
809
- }
810
-
811
- async function loadFiles(opts: { silent?: boolean } = {}) {
812
- if (!projectId) {
813
- apply({ loading: false, note: "No project context." });
814
- return;
815
- }
816
- if (!opts.silent) apply({ loading: true });
817
- try {
818
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
819
- const items = (await client.listFiles({ projectId })) as any[];
820
- const files: FileEntry[] = items
821
- .map((it) => {
822
- const name: string = it.name ?? "file";
823
- // Use `||` not `??`: converter-created files come back with
824
- // `fileExtension: ""` (empty string, not null), which `??` would keep
825
- // — leaving a `.frag` mis-typed as "" and invisible everywhere.
826
- const ext = (it.fileExtension || name.split(".").pop() || "")
827
- .toString()
828
- .toLowerCase();
829
- return { id: String(it._id), name, ext, base: name.replace(/\.[^.]+$/, "") };
830
- })
831
- .filter((f) => f.name !== APPDATA_NAME) // never surface the app-data store
832
- .sort((a, b) => a.name.localeCompare(b.name));
833
- apply({ loading: false, files, note: current.note });
834
- // The app-data JSON is the source of truth for ifc↔frag links.
835
- await syncAppData(items, files);
836
- // Build the metadata-reachable frag index (safety net for the data-loss
837
- // guarantee). Independent of listFiles, so a detached frag never vanishes.
838
- await discoverKnownFrags(files);
839
- // If the attach picker is open, refresh its candidate frags so a newly
840
- // generated/converted model appears without closing & reopening it.
841
- if (current.associating) {
842
- const openIfc = current.files.find((f) => f.id === current.associating);
843
- if (openIfc) loadAttachOptions(openIfc);
844
- }
845
- } catch (error) {
846
- apply({ loading: false, note: `Could not list files: ${error}` });
847
- }
848
- }
849
-
850
- // Cache of resolved IFC→frag links. A frag link is stable once known, so we
851
- // never re-hit the backend for an IFC we've already resolved — this (plus the
852
- // concurrency cap below) stops discoverKnownFrags from bursting getFile/
853
- // getFileVersionMetadata across every IFC on every refresh and tripping the API
854
- // rate limiter (429). IFCs with no link yet are NOT cached, so a pending
855
- // conversion is still re-checked on later refreshes.
856
- const fragIdCache = new Map<string, string>();
857
-
858
- // Run an async op over items with a small concurrency cap (instead of
859
- // Promise.all firing all at once) — keeps request bursts under the rate limit.
860
- async function mapLimit<T>(
861
- items: T[],
862
- limit: number,
863
- fn: (item: T) => Promise<void>,
864
- ): Promise<void> {
865
- let i = 0;
866
- const run = async () => {
867
- while (i < items.length) await fn(items[i++]);
868
- };
869
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run));
870
- }
871
-
872
- // Read an IFC's version metadata and return its linked fragments fileId, or
873
- // null. The converter stamps `{ fragmentsFileId, conversionStatus }` on the
874
- // IFC's version. Version tag defaults to "v1" (how uploads are tagged) but we
875
- // resolve the file's latest tag to be safe. Cached once resolved (see above).
876
- async function ifcFragId(ifc: FileEntry): Promise<string | null> {
877
- const cached = fragIdCache.get(ifc.id);
878
- if (cached) return cached;
879
- let tag = "v1";
880
- try {
881
- const file = await client.getFile(ifc.id, { showVersions: true });
882
- const versions = file?.versions ?? [];
883
- if (versions.length) tag = String(versions[versions.length - 1].tag);
884
- } catch {
885
- /* fall back to v1 */
886
- }
887
- try {
888
- const meta = await client.getFileVersionMetadata(ifc.id, tag);
889
- const fragId = meta?.fragmentsFileId;
890
- if (fragId && meta?.conversionStatus !== "error") {
891
- fragIdCache.set(ifc.id, String(fragId));
892
- return String(fragId);
893
- }
894
- } catch (error) {
895
- console.warn("[files-panel] getFileVersionMetadata failed", ifc.id, error);
896
- }
897
- return null;
898
- }
899
-
900
- // Discover EVERY frag the app can reach via IFC version metadata and record it
901
- // in `knownFrags` (fragId → name + owning IFC). This is the safety net that
902
- // makes the data-loss guarantee hold: a frag reachable from an IFC's metadata
903
- // is registered here regardless of whether it appears in listFiles, so it can
904
- // ALWAYS surface — as an orphan standalone row once unlinked, and in the attach
905
- // picker — even if it was produced by an old converter (no projectId → not
906
- // listable), lives as a hidden file, or listFiles is lagging. Resolving the
907
- // frag's display name uses the listFiles row if present, else getFile by id
908
- // (works for non-listable files), else the IFC basename.
909
- // Re-entrancy guard: refresh can fire in bursts (onItemSet). Running discovery
910
- // concurrently with itself multiplies the request load → 429. So coalesce: if a
911
- // run is in flight, stash the latest files and run exactly once more when it ends.
912
- let discovering = false;
913
- let discoverNext: FileEntry[] | null = null;
914
- async function discoverKnownFrags(files: FileEntry[]) {
915
- if (discovering) {
916
- discoverNext = files;
917
- return;
918
- }
919
- discovering = true;
920
- try {
921
- const ifcs = files.filter((f) => f.ext === "ifc");
922
- const byId = new Map(files.map((f) => [f.id, f]));
923
- const found: Record<string, { name: string; ifcId: string }> = {};
924
- await mapLimit(ifcs, 3, async (ifc) => {
925
- const fragId = await ifcFragId(ifc);
926
- if (!fragId) return;
927
- let name = byId.get(fragId)?.name ?? "";
928
- if (!name) {
929
- try {
930
- const item = await client.getFile(fragId);
931
- name = item?.name ?? "";
932
- } catch {
933
- /* non-fatal — fall back to a derived name below */
934
- }
935
- }
936
- if (!name) name = `${ifc.base}.frag`;
937
- found[fragId] = { name, ifcId: ifc.id };
938
- });
939
- apply({ knownFrags: found });
940
- } finally {
941
- discovering = false;
942
- const next = discoverNext;
943
- discoverNext = null;
944
- if (next) void discoverKnownFrags(next);
945
- }
946
- }
947
-
948
- // Persist the app-data JSON (create the store file the first time, then bump a
949
- // new version on every save). downloadFile returns the latest version.
950
- async function saveAppData() {
951
- if (!projectId) return;
952
- try {
953
- const blob = new File([JSON.stringify(appData)], APPDATA_NAME, {
954
- type: "application/json",
955
- });
956
- if (appDataFileId) {
957
- await client.updateFile(appDataFileId, {
958
- file: blob,
959
- versionTag: `v${Date.now()}`,
960
- });
961
- } else {
962
- const created = await client.createFile({
963
- file: blob,
964
- name: APPDATA_NAME,
965
- versionTag: "v1",
966
- projectId,
967
- });
968
- appDataFileId = created?.item?._id ? String(created.item._id) : null;
969
- }
970
- } catch (error) {
971
- console.warn("[files-panel] could not save app-data", error);
972
- }
973
- }
974
-
975
- // One-time heal: for any IFC with no JSON association, seed it from the IFC's
976
- // version metadata (the converter also stamps fragmentsFileId there). Covers
977
- // frags converted before this JSON store existed (e.g. school_str).
978
- async function bootstrapFromMetadata(files: FileEntry[]) {
979
- // Skip IFCs the user manually detached — their metadata still points at the
980
- // frag, but we must NOT silently re-attach it.
981
- const detached = new Set(appData.detached);
982
- const ifcs = files.filter(
983
- (f) => f.ext === "ifc" && !appData.associations[f.id] && !detached.has(f.id),
984
- );
985
- let changed = false;
986
- await Promise.all(
987
- ifcs.map(async (ifc) => {
988
- const fragId = await ifcFragId(ifc);
989
- if (fragId) {
990
- appData.associations[ifc.id] = fragId;
991
- changed = true;
992
- }
993
- }),
994
- );
995
- if (changed) await saveAppData();
996
- }
997
-
998
- // Load the app-data store (once), heal from it, then push the
999
- // associations into the UI as the source of truth.
1000
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1001
- async function syncAppData(items: any[], files: FileEntry[]) {
1002
- const adItem = items.find((it) => it?.name === APPDATA_NAME);
1003
- if (adItem) appDataFileId = String(adItem._id);
1004
-
1005
- if (!appDataLoaded) {
1006
- appDataLoaded = true;
1007
- if (appDataFileId) {
1008
- try {
1009
- const resp = await client.downloadFile(appDataFileId);
1010
- const parsed = JSON.parse(await resp.text());
1011
- appData = {
1012
- associations: parsed?.associations ?? {},
1013
- loadedModels: parsed?.loadedModels ?? [],
1014
- pending: parsed?.pending ?? {},
1015
- detached: parsed?.detached ?? [],
1016
- alignments: parsed?.alignments ?? {},
1017
- };
1018
- } catch (error) {
1019
- console.warn("[files-panel] could not read app-data", error);
1020
- }
1021
- }
1022
- await bootstrapFromMetadata(files);
1023
- // Proactively create the store on first open if it doesn't exist yet
1024
- // (bootstrap only saves when it seeded something). The `adItem` lookup
1025
- // above sets appDataFileId when the file already exists, so on later opens
1026
- // this is skipped — created exactly once, never duplicated.
1027
- if (!appDataFileId) await saveAppData();
1028
- // No scene restore — Antonio adds models manually each session.
1029
- // Resume any conversion that was still running when the viewer last closed.
1030
- await resumePending(files);
1031
- }
1032
- // Drop dangling references for files deleted outside the app (CDE / project
1033
- // settings). Runs on every loadFiles, so it self-heals on open + on refocus.
1034
- await reconcileAppData(items);
1035
- apply({ fragOf: { ...appData.associations } });
1036
- }
1037
-
1038
- // Prune appData of references to files that no longer exist in the project.
1039
- // Never touches an IFC with an in-flight conversion, and never calls convert().
1040
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1041
- async function reconcileAppData(items: any[]) {
1042
- const existing = new Set(items.map((it) => String(it._id)));
1043
- let changed = false;
1044
- for (const [ifcId, fragId] of Object.entries(appData.associations)) {
1045
- if (current.jobs[ifcId]) continue; // mid-conversion — leave it alone
1046
- if (!existing.has(ifcId) || !existing.has(fragId)) {
1047
- delete appData.associations[ifcId];
1048
- changed = true;
1049
- }
1050
- }
1051
- // Drop pending conversions whose IFC is gone or already linked (unless a job
1052
- // is actively tracking it this session).
1053
- for (const ifcId of Object.keys(appData.pending)) {
1054
- if (current.jobs[ifcId]) continue;
1055
- if (!existing.has(ifcId) || appData.associations[ifcId]) {
1056
- delete appData.pending[ifcId];
1057
- changed = true;
1058
- }
1059
- }
1060
- // loadedModels is vestigial (scene-restore was removed); keep it empty so it
1061
- // can never hold stale ids.
1062
- if (appData.loadedModels.length) {
1063
- appData.loadedModels = [];
1064
- changed = true;
1065
- }
1066
- if (changed) await saveAppData();
1067
- }
1068
-
1069
- // Poll the IFC metadata until fragmentsFileId appears (backend consistency can
1070
- // lag a beat after the execution reports SUCCESS).
1071
- async function waitForFragId(ifc: FileEntry, tries: number): Promise<string | null> {
1072
- for (let i = 0; i < tries; i += 1) {
1073
- const fragId = await ifcFragId(ifc);
1074
- if (fragId) return fragId;
1075
- await delay(1000);
1076
- }
1077
- return null;
1078
- }
1079
-
1080
- // Open (or toggle) the attach picker for an IFC and gather candidate frags.
1081
- function openAttach(file: FileEntry) {
1082
- const opening = current.associating !== file.id;
1083
- apply({
1084
- associating: opening ? file.id : null,
1085
- confirmDelete: null,
1086
- attachOptions: [],
1087
- attachLoading: opening,
1088
- });
1089
- if (opening) loadAttachOptions(file);
1090
- }
1091
-
1092
- // List the fragments files a user could attach: the project's `.frag` files
1093
- // (from listFiles in loadFiles) MINUS any attached to ANOTHER IFC — a frag
1094
- // belongs to a single IFC. This IFC's OWN attached frag is kept in the list
1095
- // (rendered as "Attached") so the user sees what's linked. Labelled by
1096
- // filename. Synchronous over already-loaded state, so it populates immediately.
1097
- function loadAttachOptions(ifc: FileEntry) {
1098
- if (current.associating !== ifc.id) return; // picker closed/changed meanwhile
1099
- const attachedElsewhere = new Set(
1100
- Object.entries(current.fragOf)
1101
- .filter(([ifcId]) => ifcId !== ifc.id)
1102
- .map(([, fragId]) => fragId),
1103
- );
1104
- const attachOptions = current.files
1105
- .filter((f) => f.ext === "frag" && !attachedElsewhere.has(f.id))
1106
- .map((f) => ({ fragId: f.id, label: f.name }));
1107
- apply({ attachOptions, attachLoading: false });
1108
- }
1109
-
1110
- // Manually link an IFC to an existing fragments file (clip → picker), and the
1111
- // inverse. Persisted in app-data exactly like an auto-converted association.
1112
- async function associate(ifcId: string, fragId: string) {
1113
- appData.associations[ifcId] = fragId;
1114
- // Re-attaching clears any prior manual-detach override for this IFC.
1115
- appData.detached = appData.detached.filter((id) => id !== ifcId);
1116
- await saveAppData();
1117
- apply({ fragOf: { ...appData.associations }, associating: null });
1118
- }
1119
- async function detach(ifcId: string) {
1120
- // The frag this IFC is being detached from = the one that may be in the scene.
1121
- const fragId = appData.associations[ifcId];
1122
- delete appData.associations[ifcId];
1123
- // Remember the detach so bootstrapFromMetadata won't re-link it on reload.
1124
- if (!appData.detached.includes(ifcId)) appData.detached.push(ifcId);
1125
- await saveAppData();
1126
- // Remove that frag from the scene if it's loaded. Models are keyed by fragId,
1127
- // so dispose by fragId directly (covers frags auto-loaded at startup too, not
1128
- // just ones tracked in loadedByIfc).
1129
- if (fragId && fragments.list.has(fragId)) await unloadFrag(fragId, ifcId);
1130
- apply({ fragOf: { ...appData.associations }, associating: null });
1131
- }
1132
-
1133
- // Run the IFC→fragments converter for `fileId`. Fires only from user actions
1134
- // (upload, or Add on an un-converted IFC) — never on init. After success we
1135
- // poll the IFC metadata for the produced `fragmentsFileId` (so the attachment
1136
- // icon appears); with `opts.addToScene` we also load that frag into the scene.
1137
- async function convert(
1138
- fileId: string,
1139
- label: string,
1140
- opts: { ifc?: FileEntry; addToScene?: boolean } = {},
1141
- ) {
1142
- // The converter may not have resolved yet (listComponents in flight). Try
1143
- // once more before giving up, so a quick upload still converts.
1144
- if (!converterId) await resolveConverter();
1145
- if (!converterId) {
1146
- console.error(
1147
- "[files-panel] no IFC→fragments converter component resolved — cannot convert",
1148
- label,
1149
- );
1150
- apply({ note: "IFC→fragments component not found in this project." });
1151
- return;
1152
- }
1153
- apply({ jobs: { ...current.jobs, [fileId]: { progress: 0, label: "Converting" } } });
1154
- try {
1155
- console.log(
1156
- "[files-panel] executing converter version",
1157
- converterVersionTag ?? "(default/v1)",
1158
- );
1159
- const { executionId } = await client.executeComponent(
1160
- converterId,
1161
- { projectId, fileId },
1162
- converterVersionTag ?? undefined,
1163
- );
1164
- // Persist the in-flight execution so re-entering the viewer can resume it
1165
- // (the poll loop dies with the page; this id lets us re-attach on load).
1166
- appData.pending[fileId] = {
1167
- executionId: String(executionId),
1168
- startedAt: Date.now(),
1169
- };
1170
- await saveAppData();
1171
- trackExecution(fileId, String(executionId), opts);
1172
- } catch (error) {
1173
- console.error("[files-panel] executeComponent failed", error);
1174
- apply({ jobs: omit(current.jobs, fileId), note: `Conversion failed: ${error}` });
1175
- }
1176
- }
1177
-
1178
- // Poll a converter execution to completion: drive the row's progress bar and,
1179
- // on success, link (and optionally scene-load) the produced fragments file.
1180
- // Shared by a fresh convert() and by resumePending() on viewer entry; always
1181
- // clears the persisted `pending` entry when the execution ends.
1182
- function trackExecution(
1183
- fileId: string,
1184
- executionId: string,
1185
- opts: { ifc?: FileEntry; addToScene?: boolean } = {},
1186
- ) {
1187
- const finish = async (ok: boolean, note?: string, fragIdFromResult?: string) => {
1188
- const hadPending = !!appData.pending[fileId];
1189
- if (hadPending) delete appData.pending[fileId];
1190
- apply({ jobs: omit(current.jobs, fileId), note: note ?? current.note });
1191
- await loadFiles();
1192
- if (ok && opts.ifc) {
1193
- // Prefer the deterministic id from the execution result; fall back to the
1194
- // IFC metadata (pre-reupload converter), polling for consistency.
1195
- let fragId = fragIdFromResult ?? null;
1196
- if (!fragId) fragId = await waitForFragId(opts.ifc, 6);
1197
- if (fragId) {
1198
- appData.associations[opts.ifc.id] = fragId;
1199
- await saveAppData(); // also persists the pending removal above
1200
- apply({ fragOf: { ...appData.associations } });
1201
- if (opts.addToScene) await loadFrag(fragId, opts.ifc.id);
1202
- return;
1203
- }
1204
- console.warn(
1205
- "[files-panel] conversion SUCCESS but no fragmentsFileId (result token nor metadata)",
1206
- opts.ifc.id,
1207
- );
1208
- apply({
1209
- note: "Converted, but couldn't link the fragments file — try Add again.",
1210
- });
1211
- }
1212
- // Error / no-ifc path still needs to persist the cleared pending entry.
1213
- if (hadPending) await saveAppData();
1214
- };
1215
-
1216
- // Poll execution status until it completes. The realtime WebSocket
1217
- // (onExecutionProgress / socket.io) does not connect from inside the
1218
- // platform's sandboxed iframe, so polling getExecution is the reliable path
1219
- // for progress + result.
1220
- let attempts = 0;
1221
- const poll = async () => {
1222
- attempts += 1;
1223
- try {
1224
- const exec = await client.getExecution(executionId);
1225
- if (typeof exec.progress === "number") {
1226
- apply({
1227
- jobs: {
1228
- ...current.jobs,
1229
- [fileId]: { progress: exec.progress, label: "Converting" },
1230
- },
1231
- });
1232
- }
1233
- if (exec.result) {
1234
- if (exec.result === "ERROR") {
1235
- console.error("[files-panel] conversion ERROR", exec.resultMessage, exec);
1236
- finish(false, `Conversion failed: ${exec.resultMessage ?? "error"}`);
1237
- } else {
1238
- // New converter appends `[fragmentsFileId=<id>]` to resultMessage.
1239
- const m = /fragmentsFileId=([^\]\s]+)/.exec(exec.resultMessage ?? "");
1240
- finish(true, undefined, m?.[1]);
1241
- }
1242
- return;
1243
- }
1244
- } catch (pollError) {
1245
- console.warn("[files-panel] getExecution poll error (transient)", pollError);
1246
- }
1247
- if (attempts < 400) {
1248
- setTimeout(poll, 1500);
1249
- } else {
1250
- finish(false, "Conversion timed out (may still be running on the server).");
1251
- }
1252
- };
1253
- setTimeout(poll, 1200);
1254
- }
1255
-
1256
- // On viewer entry, re-attach to any conversion that was still running when the
1257
- // viewer last closed (persisted in appData.pending). Entries that already
1258
- // finished while away, or whose IFC is gone, are dropped.
1259
- async function resumePending(files: FileEntry[]) {
1260
- let changed = false;
1261
- for (const [ifcId, info] of Object.entries(appData.pending)) {
1262
- const ifc = files.find((f) => f.id === ifcId);
1263
- if (!ifc || appData.associations[ifcId]) {
1264
- delete appData.pending[ifcId];
1265
- changed = true;
1266
- continue;
1267
- }
1268
- console.log("[files-panel] resuming conversion for", ifc.name, info.executionId);
1269
- apply({ jobs: { ...current.jobs, [ifcId]: { progress: 0, label: "Converting" } } });
1270
- trackExecution(ifcId, info.executionId, { ifc, addToScene: false });
1271
- }
1272
- if (changed) await saveAppData();
1273
- }
1274
-
1275
- // Add an IFC's model to the scene: load the CURRENTLY associated frag, or
1276
- // convert first (showing the progress bar on the row) and load the result.
1277
- // If a DIFFERENT (stale) frag is already loaded for this IFC — e.g. the user
1278
- // re-associated it — dispose that one first so the scene matches the UI.
1279
- async function addToScene(ifc: FileEntry) {
1280
- const fragId = current.fragOf[ifc.id];
1281
- if (!fragId) {
1282
- convert(ifc.id, ifc.name, { ifc, addToScene: true });
1283
- return;
1284
- }
1285
- const stale = current.loadedByIfc[ifc.id];
1286
- if (stale && stale !== fragId && fragments.list.has(stale)) {
1287
- await unloadFrag(stale, ifc.id);
1288
- }
1289
- await loadFrag(fragId, ifc.id);
1290
- }
1291
-
1292
- // ADD: download the .frag and load it, keyed by the fragId (so the scene model
1293
- // tracks the actual frag, not the IFC basename — re-association swaps it). The
1294
- // onItemSet handler above puts it in the scene; here we kick off the load and
1295
- // record which frag is loaded for the owning IFC (if any).
1296
- async function loadFrag(fragId: string, ifcId?: string) {
1297
- if (inFlight.has(fragId)) return; // a load/unload is already running — ignore
1298
- if (ifcId) apply({ loadedByIfc: { ...current.loadedByIfc, [ifcId]: fragId } });
1299
- if (fragments.list.has(fragId)) return; // already in the scene
1300
- inFlight.add(fragId);
1301
- apply({ loadingModels: [...current.loadingModels, fragId] }); // Add spinner
1302
- try {
1303
- const response = await client.downloadFile(fragId);
1304
- const buffer = await response.arrayBuffer();
1305
- // Await load so the model is fully registered in the worker BEFORE we
1306
- // address it (useCamera/scene.add/update post worker actions by modelId).
1307
- await fragments.core.load(buffer, { modelId: fragId });
1308
- const model = fragments.list.get(fragId);
1309
- const world = getWorld();
1310
- if (world && model) {
1311
- model.useCamera(world.camera.three);
1312
- world.scene.three.add(model.object);
1313
- }
1314
- await fragments.core.update(true);
1315
- // Camera intentionally left where it is — Antonio adds models manually and
1316
- // doesn't want the view to jump.
1317
- } catch (error) {
1318
- console.error("[files-panel] failed to add model to scene", error);
1319
- apply({ note: `Could not load model: ${error}` });
1320
- } finally {
1321
- inFlight.delete(fragId);
1322
- apply({ loadingModels: current.loadingModels.filter((m) => m !== fragId) });
1323
- }
1324
- }
1325
-
1326
- // REMOVE: dispose the model — drops it from the scene and from fragments.list
1327
- // (onItemDeleted re-syncs the buttons back to "Add"). Also clears the IFC's
1328
- // loaded-frag record so its row reverts to "Add".
1329
- async function unloadFrag(fragId: string, ifcId?: string) {
1330
- if (inFlight.has(fragId)) return; // a load/unload is already running — ignore
1331
- if (ifcId) {
1332
- const { [ifcId]: _drop, ...rest } = current.loadedByIfc;
1333
- apply({ loadedByIfc: rest });
1334
- }
1335
- if (!fragments.list.has(fragId)) return; // nothing in the scene to dispose
1336
- inFlight.add(fragId);
1337
- apply({ loadingModels: [...current.loadingModels, fragId] }); // Remove spinner
1338
- try {
1339
- // Clear the current selection FIRST. The Highlighter/Outliner hold
1340
- // references to the selected items' geometry; disposing the model out from
1341
- // under them crashes the next render. Clearing the select style drops those
1342
- // refs (and, via the wired onClear, the Outliner's) before disposal.
1343
- try {
1344
- const highlighter = components.get(OBF.Highlighter);
1345
- await highlighter.clear(highlighter.config.selectName);
1346
- } catch {
1347
- /* highlighter not set up / nothing selected — safe to ignore */
1348
- }
1349
- await fragments.core.disposeModel(fragId);
1350
- } catch (error) {
1351
- console.error("[files-panel] disposeModel failed", error);
1352
- apply({ note: `Could not remove model: ${error}` });
1353
- } finally {
1354
- inFlight.delete(fragId);
1355
- apply({ loadingModels: current.loadingModels.filter((m) => m !== fragId) });
1356
- }
1357
- }
1358
-
1359
- // Soft-delete (archive) — recoverable server-side. Refreshes the list.
1360
- async function removeFile(fileId: string) {
1361
- apply({ confirmDelete: null });
1362
- try {
1363
- // If a frag is being deleted while it's loaded, drop it from the scene too
1364
- // (models are keyed by fragId). reconcileAppData (in loadFiles) then prunes
1365
- // any IFC association pointing at the now-deleted frag.
1366
- if (fragments.list.has(fileId)) await unloadFrag(fileId);
1367
- await client.archiveFile(fileId);
1368
- await loadFiles();
1369
- } catch (error) {
1370
- apply({ note: `Could not delete file: ${error}` });
1371
- }
1372
- }
1373
-
1374
- fileInput.onchange = async () => {
1375
- const picked = Array.from(fileInput.files ?? []);
1376
- fileInput.value = "";
1377
- if (picked.length === 0 || !projectId) return;
1378
- // Show every picked file INSTANTLY with an "Uploading…" row (optimistic), so
1379
- // there's no wait for the platform upload. If the user closes the app before
1380
- // an upload finishes, that file simply never lands (acceptable).
1381
- const queued = picked.map((file, i) => {
1382
- const ext = (file.name.split(".").pop() ?? "").toLowerCase();
1383
- const base = file.name.replace(/\.[^.]+$/, "");
1384
- return {
1385
- file,
1386
- entry: { tempId: `up-${Date.now()}-${i}`, name: file.name, ext, base },
1387
- };
1388
- });
1389
- apply({ uploads: [...current.uploads, ...queued.map((q) => q.entry)] });
1390
-
1391
- // Upload in parallel; settle each optimistic row as its upload completes.
1392
- await Promise.all(
1393
- queued.map(async ({ file, entry }) => {
1394
- try {
1395
- const created = await client.createFile({
1396
- file,
1397
- name: file.name,
1398
- versionTag: "v1",
1399
- projectId,
1400
- });
1401
- const newId = String(created?.item?._id ?? "");
1402
- apply({ uploads: current.uploads.filter((u) => u.tempId !== entry.tempId) });
1403
- await loadFiles();
1404
- // Conversion fires ONLY on a fresh upload — never on load/init for
1405
- // pre-existing IFCs. Pass the IFC entry so the attachment icon appears
1406
- // once metadata links the produced frag (not auto-added to the scene).
1407
- if (entry.ext === "ifc" && newId) {
1408
- convert(newId, entry.name, {
1409
- ifc: { id: newId, name: entry.name, ext: "ifc", base: entry.base },
1410
- });
1411
- }
1412
- } catch (error) {
1413
- apply({
1414
- uploads: current.uploads.filter((u) => u.tempId !== entry.tempId),
1415
- note: `Upload failed for ${file.name}: ${error}`,
1416
- });
1417
- }
1418
- }),
1419
- );
1420
- };
1421
-
1422
- // Catch external (CDE / project-settings) deletions when Antonio returns to
1423
- // the tab — cheap, no tight polling. Silent reload to avoid UI flicker.
1424
- const onRefocus = () => {
1425
- if (document.visibilityState === "visible") loadFiles({ silent: true });
1426
- };
1427
- window.addEventListener("focus", onRefocus);
1428
- document.addEventListener("visibilitychange", onRefocus);
1429
- // The app runs in the platform iframe, so deletions made in the platform's own
1430
- // file browser (a different frame) never fire our focus/visibility events. A
1431
- // modest silent poll reconciles the list with the backend without a reload.
1432
- window.setInterval(() => {
1433
- if (document.visibilityState === "visible") loadFiles({ silent: true });
1434
- }, 8000);
1435
-
1436
- // TEMP DIAGNOSTIC ─────────────────────────────────────────────────────────
1437
- // Why are some component-created `.frag` files listable project files while
1438
- // others (school_str / BLOXHUB) are discoverable-by-id but absent from the
1439
- // project "Files" overview? This probes the live platform (the shell has no
1440
- // auth) to surface what distinguishes each frag: its projectId / folderId /
1441
- // owner / archived / hidden flags, and whether listFiles({projectId}) returns
1442
- // it. Runs ONCE after the first loadFiles. Search the console for "[frag-diag".
1443
- // Remove this whole block (and the `fragDiagRan` flag) when done.
1444
- async function runFragDiagnostic() {
1445
- if (fragDiagRan) return;
1446
- fragDiagRan = true;
1447
- try {
1448
- // What the project-wide overview uses: listFiles filtered by projectId.
1449
- const listed = (await client.listFiles({ projectId })) as any[]; // eslint-disable-line @typescript-eslint/no-explicit-any
1450
- const listedIds = new Set(listed.map((it) => String(it._id)));
1451
- console.log(
1452
- "[frag-diag] listFiles({projectId}) returned",
1453
- listed.length,
1454
- "items:",
1455
- listed.map((it) => ({ id: String(it._id), name: it.name })),
1456
- );
1457
-
1458
- // For every IFC, resolve its frag id (metadata / app-data) and fetch the
1459
- // frag item directly — getFile works by id even for non-listable files.
1460
- const ifcs = current.files.filter((f) => f.ext === "ifc");
1461
- for (const ifc of ifcs) {
1462
- const fragId =
1463
- appData.associations[ifc.id] ?? (await ifcFragId(ifc)) ?? null;
1464
- if (!fragId) {
1465
- console.log("[frag-diag]", { ifcName: ifc.name, fragId: null, note: "no frag id resolved" });
1466
- continue;
1467
- }
1468
- let item: any = null; // eslint-disable-line @typescript-eslint/no-explicit-any
1469
- try {
1470
- item = await client.getFile(fragId, { showVersions: true });
1471
- } catch (e) {
1472
- console.log("[frag-diag] getFile failed", { ifcName: ifc.name, fragId, error: String(e) });
1473
- continue;
1474
- }
1475
- // Pull whatever distinguishing fields the item actually carries. The
1476
- // exact field names vary by backend, so log the whole item too.
1477
- console.log("[frag-diag]", {
1478
- ifcName: ifc.name,
1479
- fragId,
1480
- fragName: item?.name,
1481
- projectId: item?.projectId,
1482
- folderId: item?.folderId,
1483
- parentItemId: item?.parentItemId ?? item?.parentId ?? item?.parentFileId,
1484
- owningEntity: item?.owningEntity ?? item?.owner ?? item?.createdBy ?? item?.ownerId,
1485
- archived: item?.archived,
1486
- isHidden: item?.isHidden ?? item?.hidden,
1487
- inListFiles: listedIds.has(String(fragId)), // <-- the key signal
1488
- });
1489
- console.log("[frag-diag] full frag item for", ifc.name, item);
1490
- }
1491
- } catch (e) {
1492
- console.warn("[frag-diag] diagnostic failed", e);
1493
- }
1494
- }
1495
- // END TEMP DIAGNOSTIC ──────────────────────────────────────────────────────
1496
-
1497
- // Kick off
1498
- resolveConverter();
1499
- // TEMP DIAGNOSTIC: run the frag-visibility probe once after the first load.
1500
- loadFiles().then(() => runFragDiagnostic());
1501
-
1502
- return panel;
1503
- };