@thatopen/services 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -59
- package/dist/built-in/index.d.ts +486 -563
- package/dist/cli.js +10 -0
- package/dist/index.cjs.js +2 -2
- package/dist/index.es.js +3 -5
- package/dist/vite-env.d.ts +1 -1
- package/docs/builtin/paths.json +74 -70
- package/docs/cli/create.md +28 -28
- package/docs/cli/local-server.md +38 -38
- package/docs/cli/login.md +32 -32
- package/docs/cli/publish.md +31 -31
- package/docs/cli/run.md +26 -26
- package/docs/cli/serve.md +43 -43
- package/docs/cli/swap.md +30 -30
- package/package.json +100 -100
- package/resources/AGENTS.md +40 -40
- package/src/built-in/CDEManager/example.ts +214 -214
- package/src/built-in/CDEManager/examples/FileEditing/example.ts +226 -226
- package/src/built-in/CDEManager/examples/FileMoves/example.ts +186 -186
- package/src/built-in/CDEManager/examples/FileOperations/example.ts +295 -295
- package/src/built-in/CDEManager/examples/FileTransfers/example.ts +210 -210
- package/src/built-in/CDEManager/examples/FolderManagement/example.ts +230 -230
- package/src/built-in/CDEManager/examples/MetadataSchema/example.ts +230 -230
- package/src/built-in/CDEManager/examples/MetadataViews/example.ts +166 -166
- package/src/built-in/CDEManager/examples/ProjectConfig/example.ts +177 -177
- package/src/built-in/UIManager/src/app/example.ts +99 -99
- package/src/built-in/UIManager/src/app/examples/AppState/example.ts +142 -142
- package/src/built-in/UIManager/src/app/examples/Banner/example.ts +161 -0
- package/src/built-in/UIManager/src/app/examples/Contexts/example.ts +125 -125
- package/src/built-in/UIManager/src/app/examples/FileDrop/example.ts +126 -0
- package/src/built-in/UIManager/src/app/examples/FloatingLayout/example.ts +92 -92
- package/src/built-in/UIManager/src/app/examples/Modal/example.ts +165 -0
- package/src/built-in/UIManager/src/app/examples/Notifications/example.ts +135 -161
- package/src/built-in/UIManager/src/app/examples/Sidebar/example.ts +104 -104
- package/src/built-in/index.ts +649 -755
- package/src/cli/templates/app/README.md +241 -241
- package/src/cli/templates/app/index.html +35 -27
- package/src/cli/templates/app/package.json +31 -27
- package/src/cli/templates/app/src/app.ts +37 -0
- package/src/cli/templates/app/src/assets/tool-placeholder.ts +50 -0
- package/src/cli/templates/app/src/bim-components/CloudRunner/index.ts +95 -87
- package/src/cli/templates/app/src/bim-components/CloudRunner/src/index.ts +1 -1
- package/src/cli/templates/app/src/bim-components/CloudRunner/src/types.ts +5 -5
- package/src/cli/templates/app/src/bim-components/index.ts +1 -1
- package/src/cli/templates/app/src/globals.ts +1 -0
- package/src/cli/templates/app/src/main.ts +340 -56
- package/src/cli/templates/app/src/setups/active-tool-hud.ts +69 -0
- package/src/cli/templates/app/src/setups/camera-tools.ts +63 -0
- package/src/cli/templates/app/src/setups/camera-views.ts +201 -0
- package/src/cli/templates/app/src/setups/card-header.ts +35 -0
- package/src/cli/templates/app/src/setups/clipper-panel.ts +167 -0
- package/src/cli/templates/app/src/setups/clipper-tool.ts +754 -0
- package/src/cli/templates/app/src/setups/clipper.ts +75 -0
- package/src/cli/templates/app/src/setups/cloud-runner.ts +13 -9
- package/src/cli/templates/app/src/setups/commands-panel.ts +239 -0
- package/src/cli/templates/app/src/setups/data-table-panel.ts +2224 -0
- package/src/cli/templates/app/src/setups/exploded-view.ts +246 -0
- package/src/cli/templates/app/src/setups/file-format-icons.ts +95 -0
- package/src/cli/templates/app/src/setups/files-panel.ts +1503 -0
- package/src/cli/templates/app/src/setups/fps-indicator.ts +63 -0
- package/src/cli/templates/app/src/setups/graphics-panel.ts +490 -0
- package/src/cli/templates/app/src/setups/helper-panel.ts +88 -0
- package/src/cli/templates/app/src/setups/hider.ts +98 -0
- package/src/cli/templates/app/src/setups/index.ts +33 -3
- package/src/cli/templates/app/src/setups/inspection.ts +136 -0
- package/src/cli/templates/app/src/setups/measurement-panel.ts +188 -0
- package/src/cli/templates/app/src/setups/measurement-settings-panel.ts +161 -0
- package/src/cli/templates/app/src/setups/measurement-tool.ts +570 -0
- package/src/cli/templates/app/src/setups/measurements.ts +133 -0
- package/src/cli/templates/app/src/setups/model-tree.ts +797 -0
- package/src/cli/templates/app/src/setups/navigation-gizmo.ts +316 -0
- package/src/cli/templates/app/src/setups/objects-panel.ts +127 -0
- package/src/cli/templates/app/src/setups/plans-panel.ts +619 -0
- package/src/cli/templates/app/src/setups/properties-panel.ts +1048 -0
- package/src/cli/templates/app/src/setups/reality-capture/lib/hidden-tiles-plugin.ts +88 -0
- package/src/cli/templates/app/src/setups/reality-capture/lib/point-tile-plugin.ts +166 -0
- package/src/cli/templates/app/src/setups/reality-capture/lib/render-frame.ts +31 -0
- package/src/cli/templates/app/src/setups/reality-capture/lib/splat-tile-plugin.ts +51 -0
- package/src/cli/templates/app/src/setups/reality-capture-viewer.ts +1383 -0
- package/src/cli/templates/app/src/setups/right-sidebar.ts +178 -0
- package/src/cli/templates/app/src/setups/settings-panel.ts +94 -0
- package/src/cli/templates/app/src/setups/styles-panel.ts +110 -0
- package/src/cli/templates/app/src/setups/styles.ts +375 -0
- package/src/cli/templates/app/src/setups/tool-mode-manager.ts +150 -0
- package/src/cli/templates/app/src/setups/tool-mode.ts +23 -0
- package/src/cli/templates/app/src/setups/toolbar.ts +229 -0
- package/src/cli/templates/app/src/setups/ui-manager.ts +27 -0
- package/src/cli/templates/app/src/setups/visibility-toolbar.ts +498 -0
- package/src/cli/templates/app/src/setups/walkthrough.ts +528 -0
- package/src/cli/templates/app/src/ui-components/app-info-section/index.ts +26 -0
- package/src/cli/templates/app/src/ui-components/app-info-section/src/index.ts +1 -0
- package/src/cli/templates/app/src/ui-components/app-info-section/src/types.ts +15 -0
- package/src/cli/templates/app/src/ui-components/cloud-runner-section/index.ts +37 -0
- package/src/cli/templates/app/src/ui-components/cloud-runner-section/src/index.ts +1 -0
- package/src/cli/templates/app/src/ui-components/cloud-runner-section/src/types.ts +14 -0
- package/src/cli/templates/app/src/ui-components/index.ts +2 -1
- package/src/cli/templates/app/tsconfig.json +16 -17
- package/src/cli/templates/app/vite.config.js +45 -42
- package/src/cli/templates/cloud-component/README.md +205 -205
- package/src/cli/templates/cloud-component/_thatopen +5 -5
- package/src/cli/templates/cloud-component/declarations.json +4 -4
- package/src/cli/templates/cloud-component/package.json +21 -21
- package/src/cli/templates/cloud-component/src/main.ts +70 -70
- package/src/cli/templates/cloud-component/tsconfig.json +16 -16
- package/src/cli/templates/cloud-component/vite.config.js +46 -46
- package/src/cli/templates/shared/AGENTS.md +8 -8
- package/src/cli/templates/shared/CLAUDE.md +1 -1
- package/src/cli/templates/shared/_gitignore +5 -5
- package/src/built-in/AppManager/example.ts +0 -86
- package/src/built-in/FileList/example.ts +0 -107
- package/src/built-in/TabbedNavigation/example.ts +0 -115
- package/src/built-in/ViewportsManager/example.ts +0 -106
- package/src/cli/templates/app/src/setups/fragments.ts +0 -18
- package/src/cli/templates/app/src/setups/ifc-loader.ts +0 -12
- package/src/cli/templates/app/src/ui-components/AppPanel/index.ts +0 -160
|
@@ -0,0 +1,1503 @@
|
|
|
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
|
+
};
|