@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.
Files changed (115) hide show
  1. package/README.md +59 -59
  2. package/dist/built-in/index.d.ts +486 -563
  3. package/dist/cli.js +10 -0
  4. package/dist/index.cjs.js +2 -2
  5. package/dist/index.es.js +3 -5
  6. package/dist/vite-env.d.ts +1 -1
  7. package/docs/builtin/paths.json +74 -70
  8. package/docs/cli/create.md +28 -28
  9. package/docs/cli/local-server.md +38 -38
  10. package/docs/cli/login.md +32 -32
  11. package/docs/cli/publish.md +31 -31
  12. package/docs/cli/run.md +26 -26
  13. package/docs/cli/serve.md +43 -43
  14. package/docs/cli/swap.md +30 -30
  15. package/package.json +100 -100
  16. package/resources/AGENTS.md +40 -40
  17. package/src/built-in/CDEManager/example.ts +214 -214
  18. package/src/built-in/CDEManager/examples/FileEditing/example.ts +226 -226
  19. package/src/built-in/CDEManager/examples/FileMoves/example.ts +186 -186
  20. package/src/built-in/CDEManager/examples/FileOperations/example.ts +295 -295
  21. package/src/built-in/CDEManager/examples/FileTransfers/example.ts +210 -210
  22. package/src/built-in/CDEManager/examples/FolderManagement/example.ts +230 -230
  23. package/src/built-in/CDEManager/examples/MetadataSchema/example.ts +230 -230
  24. package/src/built-in/CDEManager/examples/MetadataViews/example.ts +166 -166
  25. package/src/built-in/CDEManager/examples/ProjectConfig/example.ts +177 -177
  26. package/src/built-in/UIManager/src/app/example.ts +99 -99
  27. package/src/built-in/UIManager/src/app/examples/AppState/example.ts +142 -142
  28. package/src/built-in/UIManager/src/app/examples/Banner/example.ts +161 -0
  29. package/src/built-in/UIManager/src/app/examples/Contexts/example.ts +125 -125
  30. package/src/built-in/UIManager/src/app/examples/FileDrop/example.ts +126 -0
  31. package/src/built-in/UIManager/src/app/examples/FloatingLayout/example.ts +92 -92
  32. package/src/built-in/UIManager/src/app/examples/Modal/example.ts +165 -0
  33. package/src/built-in/UIManager/src/app/examples/Notifications/example.ts +135 -161
  34. package/src/built-in/UIManager/src/app/examples/Sidebar/example.ts +104 -104
  35. package/src/built-in/index.ts +649 -755
  36. package/src/cli/templates/app/README.md +241 -241
  37. package/src/cli/templates/app/index.html +35 -27
  38. package/src/cli/templates/app/package.json +31 -27
  39. package/src/cli/templates/app/src/app.ts +37 -0
  40. package/src/cli/templates/app/src/assets/tool-placeholder.ts +50 -0
  41. package/src/cli/templates/app/src/bim-components/CloudRunner/index.ts +95 -87
  42. package/src/cli/templates/app/src/bim-components/CloudRunner/src/index.ts +1 -1
  43. package/src/cli/templates/app/src/bim-components/CloudRunner/src/types.ts +5 -5
  44. package/src/cli/templates/app/src/bim-components/index.ts +1 -1
  45. package/src/cli/templates/app/src/globals.ts +1 -0
  46. package/src/cli/templates/app/src/main.ts +340 -56
  47. package/src/cli/templates/app/src/setups/active-tool-hud.ts +69 -0
  48. package/src/cli/templates/app/src/setups/camera-tools.ts +63 -0
  49. package/src/cli/templates/app/src/setups/camera-views.ts +201 -0
  50. package/src/cli/templates/app/src/setups/card-header.ts +35 -0
  51. package/src/cli/templates/app/src/setups/clipper-panel.ts +167 -0
  52. package/src/cli/templates/app/src/setups/clipper-tool.ts +754 -0
  53. package/src/cli/templates/app/src/setups/clipper.ts +75 -0
  54. package/src/cli/templates/app/src/setups/cloud-runner.ts +13 -9
  55. package/src/cli/templates/app/src/setups/commands-panel.ts +239 -0
  56. package/src/cli/templates/app/src/setups/data-table-panel.ts +2224 -0
  57. package/src/cli/templates/app/src/setups/exploded-view.ts +246 -0
  58. package/src/cli/templates/app/src/setups/file-format-icons.ts +95 -0
  59. package/src/cli/templates/app/src/setups/files-panel.ts +1503 -0
  60. package/src/cli/templates/app/src/setups/fps-indicator.ts +63 -0
  61. package/src/cli/templates/app/src/setups/graphics-panel.ts +490 -0
  62. package/src/cli/templates/app/src/setups/helper-panel.ts +88 -0
  63. package/src/cli/templates/app/src/setups/hider.ts +98 -0
  64. package/src/cli/templates/app/src/setups/index.ts +33 -3
  65. package/src/cli/templates/app/src/setups/inspection.ts +136 -0
  66. package/src/cli/templates/app/src/setups/measurement-panel.ts +188 -0
  67. package/src/cli/templates/app/src/setups/measurement-settings-panel.ts +161 -0
  68. package/src/cli/templates/app/src/setups/measurement-tool.ts +570 -0
  69. package/src/cli/templates/app/src/setups/measurements.ts +133 -0
  70. package/src/cli/templates/app/src/setups/model-tree.ts +797 -0
  71. package/src/cli/templates/app/src/setups/navigation-gizmo.ts +316 -0
  72. package/src/cli/templates/app/src/setups/objects-panel.ts +127 -0
  73. package/src/cli/templates/app/src/setups/plans-panel.ts +619 -0
  74. package/src/cli/templates/app/src/setups/properties-panel.ts +1048 -0
  75. package/src/cli/templates/app/src/setups/reality-capture/lib/hidden-tiles-plugin.ts +88 -0
  76. package/src/cli/templates/app/src/setups/reality-capture/lib/point-tile-plugin.ts +166 -0
  77. package/src/cli/templates/app/src/setups/reality-capture/lib/render-frame.ts +31 -0
  78. package/src/cli/templates/app/src/setups/reality-capture/lib/splat-tile-plugin.ts +51 -0
  79. package/src/cli/templates/app/src/setups/reality-capture-viewer.ts +1383 -0
  80. package/src/cli/templates/app/src/setups/right-sidebar.ts +178 -0
  81. package/src/cli/templates/app/src/setups/settings-panel.ts +94 -0
  82. package/src/cli/templates/app/src/setups/styles-panel.ts +110 -0
  83. package/src/cli/templates/app/src/setups/styles.ts +375 -0
  84. package/src/cli/templates/app/src/setups/tool-mode-manager.ts +150 -0
  85. package/src/cli/templates/app/src/setups/tool-mode.ts +23 -0
  86. package/src/cli/templates/app/src/setups/toolbar.ts +229 -0
  87. package/src/cli/templates/app/src/setups/ui-manager.ts +27 -0
  88. package/src/cli/templates/app/src/setups/visibility-toolbar.ts +498 -0
  89. package/src/cli/templates/app/src/setups/walkthrough.ts +528 -0
  90. package/src/cli/templates/app/src/ui-components/app-info-section/index.ts +26 -0
  91. package/src/cli/templates/app/src/ui-components/app-info-section/src/index.ts +1 -0
  92. package/src/cli/templates/app/src/ui-components/app-info-section/src/types.ts +15 -0
  93. package/src/cli/templates/app/src/ui-components/cloud-runner-section/index.ts +37 -0
  94. package/src/cli/templates/app/src/ui-components/cloud-runner-section/src/index.ts +1 -0
  95. package/src/cli/templates/app/src/ui-components/cloud-runner-section/src/types.ts +14 -0
  96. package/src/cli/templates/app/src/ui-components/index.ts +2 -1
  97. package/src/cli/templates/app/tsconfig.json +16 -17
  98. package/src/cli/templates/app/vite.config.js +45 -42
  99. package/src/cli/templates/cloud-component/README.md +205 -205
  100. package/src/cli/templates/cloud-component/_thatopen +5 -5
  101. package/src/cli/templates/cloud-component/declarations.json +4 -4
  102. package/src/cli/templates/cloud-component/package.json +21 -21
  103. package/src/cli/templates/cloud-component/src/main.ts +70 -70
  104. package/src/cli/templates/cloud-component/tsconfig.json +16 -16
  105. package/src/cli/templates/cloud-component/vite.config.js +46 -46
  106. package/src/cli/templates/shared/AGENTS.md +8 -8
  107. package/src/cli/templates/shared/CLAUDE.md +1 -1
  108. package/src/cli/templates/shared/_gitignore +5 -5
  109. package/src/built-in/AppManager/example.ts +0 -86
  110. package/src/built-in/FileList/example.ts +0 -107
  111. package/src/built-in/TabbedNavigation/example.ts +0 -115
  112. package/src/built-in/ViewportsManager/example.ts +0 -106
  113. package/src/cli/templates/app/src/setups/fragments.ts +0 -18
  114. package/src/cli/templates/app/src/setups/ifc-loader.ts +0 -12
  115. package/src/cli/templates/app/src/ui-components/AppPanel/index.ts +0 -160
@@ -0,0 +1,797 @@
1
+ import * as THREE from "three";
2
+ import * as OBC from "@thatopen/components";
3
+ import * as OBF from "@thatopen/components-front";
4
+ import * as FRAGS from "@thatopen/fragments";
5
+ import * as BUI from "@thatopen/ui";
6
+ import { cardHeader } from "./card-header";
7
+ import { toolPlaceholderUri } from "../assets/tool-placeholder";
8
+
9
+ /**
10
+ * Model Tree — a VIRTUALIZED (windowed) spatial-structure browser.
11
+ *
12
+ * Why not bim-table: bim-table renders every expanded row as real DOM (with
13
+ * per-row listeners), so expanding a storey with hundreds of elements puts
14
+ * hundreds of DOM rows over the WebGL canvas → per-frame compositing cost. This
15
+ * implementation keeps the model as a plain node tree and renders ONLY the rows
16
+ * currently in the scroll window (recycled on scroll), so DOM rows ≈ viewport
17
+ * height / row height regardless of how big the expanded subtree is.
18
+ *
19
+ * No per-row MutationObserver / requestUpdate (the old idle-CPU feedback loop):
20
+ * the collapsed/expanded label and the selected highlight update by re-rendering
21
+ * the window, and all interaction goes through ONE delegated listener.
22
+ *
23
+ * Features preserved: spatial spine (Project>Site>Building>Storey) with the
24
+ * storey-category flatten + single-child-wrapper skip, the full category icon
25
+ * map, debounced in-memory search (name/category/expressId, ancestors kept),
26
+ * CLICK = select item + all descendants, per-row Focus button (fitToSphere on
27
+ * the subtree), default-expand to storeys, collapsed/expanded label, SELECTION-
28
+ * ONLY (no 3D hover), light-gray text, inset scroll + dividers + card chrome.
29
+ *
30
+ * @param components engine components
31
+ */
32
+
33
+ const FLATTEN_CATEGORIES = ["IFCBUILDINGSTOREY"];
34
+ const FLATTEN_SET = new Set(FLATTEN_CATEGORIES.map((c) => c.toUpperCase()));
35
+ const STOREY = "IFCBUILDINGSTOREY";
36
+
37
+ const ROW_H = 24; // px, fixed row height (enables simple windowing math)
38
+ const INDENT = 12; // px per depth level
39
+ const BASE_PAD = 18; // px left padding at depth 0 (rows are full-bleed; keeps text inset)
40
+ const BUFFER = 6; // extra rows above/below the viewport
41
+
42
+ const prettyCategory = (category: string) => {
43
+ const base = category.replace(/^IFC/i, "");
44
+ if (!base) return "";
45
+ return base.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase());
46
+ };
47
+
48
+ // Representative icon per IFC category (iconify/mdi names bim-icon takes).
49
+ const CATEGORY_ICONS: Record<string, string> = {
50
+ IFCPROJECT: "mdi:sitemap",
51
+ IFCSITE: "mdi:terrain",
52
+ IFCBUILDING: "mdi:office-building",
53
+ IFCBUILDINGSTOREY: "mdi:layers",
54
+ IFCSPACE: "mdi:floor-plan",
55
+ IFCZONE: "mdi:select-group",
56
+ IFCWALL: "mdi:wall",
57
+ IFCWALLSTANDARDCASE: "mdi:wall",
58
+ IFCCURTAINWALL: "mdi:wall",
59
+ IFCSLAB: "mdi:floor-plan",
60
+ IFCROOF: "mdi:home-roof",
61
+ IFCCOLUMN: "mdi:view-column",
62
+ IFCBEAM: "mdi:minus",
63
+ IFCMEMBER: "mdi:minus",
64
+ IFCPLATE: "mdi:rectangle-outline",
65
+ IFCFOOTING: "mdi:foundation",
66
+ IFCPILE: "mdi:format-vertical-align-bottom",
67
+ IFCSTAIR: "mdi:stairs",
68
+ IFCSTAIRFLIGHT: "mdi:stairs",
69
+ IFCRAMP: "mdi:slope-uphill",
70
+ IFCRAMPFLIGHT: "mdi:slope-uphill",
71
+ IFCRAILING: "mdi:fence",
72
+ IFCCOVERING: "mdi:texture-box",
73
+ IFCDOOR: "mdi:door",
74
+ IFCWINDOW: "mdi:window-closed-variant",
75
+ IFCOPENINGELEMENT: "mdi:vector-rectangle",
76
+ IFCFURNISHINGELEMENT: "mdi:sofa",
77
+ IFCFURNITURE: "mdi:sofa",
78
+ IFCSANITARYTERMINAL: "mdi:toilet",
79
+ IFCPIPESEGMENT: "mdi:pipe",
80
+ IFCFLOWSEGMENT: "mdi:pipe",
81
+ IFCDUCTSEGMENT: "mdi:pipe",
82
+ IFCPIPEFITTING: "mdi:pipe-disconnected",
83
+ IFCDUCTFITTING: "mdi:pipe-disconnected",
84
+ IFCCABLECARRIERSEGMENT: "mdi:pipe",
85
+ IFCFLOWTERMINAL: "mdi:water-pump",
86
+ IFCLIGHTFIXTURE: "mdi:lightbulb",
87
+ IFCOUTLET: "mdi:power-socket",
88
+ IFCFLOWCONTROLLER: "mdi:valve",
89
+ IFCBUILDINGELEMENTPROXY: "mdi:cube-outline",
90
+ IFCANNOTATION: "mdi:tag-outline",
91
+ };
92
+ const iconFor = (category: string) =>
93
+ CATEGORY_ICONS[(category || "").toUpperCase()] ?? "mdi:cube-outline";
94
+
95
+ interface TreeNode {
96
+ key: string;
97
+ nm: string; // bare instance Name ("" if none)
98
+ prettyCat: string;
99
+ category: string; // raw IFC category, for the icon
100
+ modelId: string;
101
+ localId: number; // ExpressId (-1 if none)
102
+ ids: number[]; // own + all descendant localIds (select / focus)
103
+ children: TreeNode[];
104
+ defaultExpand: boolean; // ancestor of a storey → start expanded
105
+ }
106
+
107
+ // Collapsed → `Category - Name - ExpressId`; expanded → `Name - ExpressId`.
108
+ const labelFor = (n: TreeNode, collapsed: boolean) => {
109
+ const id = n.localId >= 0 ? String(n.localId) : "";
110
+ const parts = collapsed ? [n.prettyCat, n.nm, id] : [n.nm || n.prettyCat || "Item", id];
111
+ return parts.filter(Boolean).join(" - ");
112
+ };
113
+
114
+ const esc = (s: string) =>
115
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
116
+
117
+ export const modelTree = (components: OBC.Components) => {
118
+ const fragments = components.get(OBC.FragmentsManager);
119
+ const highlighter = components.get(OBF.Highlighter);
120
+ const selectName = highlighter.config.selectName;
121
+
122
+ // ── Tree state ─────────────────────────────────────────────────
123
+ let roots: TreeNode[] = []; // currently displayed tree (spatial OR category)
124
+ let spatialRoots: TreeNode[] = []; // canonical spatial tree (source of truth)
125
+ let mode: "spatial" | "category" = "spatial";
126
+ const nodeByKey = new Map<string, TreeNode>();
127
+ const expanded = new Set<string>(); // keys whose children are shown
128
+ const selectedKeys = new Set<string>(); // clicked rows (row highlight)
129
+ // `anc[c]` = a vertical guide continues at indent column c (ancestor has a
130
+ // following sibling); `last` = this node is the last child (→ half-height
131
+ // elbow). Both are only computed in the normal (non-search) view.
132
+ let visible: { node: TreeNode; depth: number; anc?: boolean[]; last?: boolean }[] = [];
133
+ let query = "";
134
+ let rebuildToken = 0;
135
+ let searchTimer: number | undefined;
136
+ let keyCounter = 0;
137
+
138
+ // Virtualization DOM (created once, managed imperatively).
139
+ const viewport = document.createElement("div");
140
+ viewport.className = "tree-vp";
141
+ const sizer = document.createElement("div");
142
+ sizer.style.position = "relative";
143
+ sizer.style.width = "100%";
144
+ const content = document.createElement("div");
145
+ content.style.position = "absolute";
146
+ content.style.top = "0";
147
+ content.style.left = "0";
148
+ content.style.right = "0";
149
+ sizer.appendChild(content);
150
+ viewport.appendChild(sizer);
151
+
152
+ // ── Build node tree from a model's spatial structure ───────────
153
+ const buildModel = async (model: FRAGS.FragmentsModel): Promise<TreeNode | null> => {
154
+ const structure = await model.getSpatialStructure();
155
+ if (!structure) return null;
156
+
157
+ // One round-trip for all names.
158
+ const ids: number[] = [];
159
+ const collectIds = (node: FRAGS.SpatialTreeItem) => {
160
+ if (node.localId !== null && node.localId !== undefined) ids.push(node.localId);
161
+ node.children?.forEach(collectIds);
162
+ };
163
+ collectIds(structure);
164
+ const nameMap = new Map<number, string>();
165
+ if (ids.length > 0) {
166
+ const data = await model.getItemsData(ids, {
167
+ attributesDefault: false,
168
+ attributes: ["Name"],
169
+ });
170
+ data.forEach((d, i) => {
171
+ const name = (d as Record<string, unknown>)?.Name;
172
+ if (
173
+ name &&
174
+ !Array.isArray(name) &&
175
+ typeof name === "object" &&
176
+ "value" in name &&
177
+ (name as { value: unknown }).value != null
178
+ ) {
179
+ nameMap.set(ids[i], String((name as { value: unknown }).value));
180
+ }
181
+ });
182
+ }
183
+
184
+ // Real per-item category source (same as the data-table): the spatial-tree
185
+ // node's `.category` is only populated for container/spine nodes, so in
186
+ // models where elements sit under a flat container (e.g. BLOXHUB) most
187
+ // element nodes have an EMPTY category and fall through to "Uncategorized".
188
+ // getItemsOfCategories reads each item's actual IFC category from the model's
189
+ // category buffer — use it as the authoritative source, keyed by localId.
190
+ const catMap = new Map<number, string>();
191
+ try {
192
+ const byCat = await model.getItemsOfCategories([/.*/]);
193
+ for (const [cat, lids] of Object.entries(byCat)) {
194
+ for (const lid of lids) catMap.set(lid, cat);
195
+ }
196
+ } catch (error) {
197
+ console.warn("[model-tree] getItemsOfCategories failed", model.modelId, error);
198
+ }
199
+
200
+ // Skip pure category-wrapper nodes (single child, no localId), carrying the
201
+ // meaningful category down (it lives on the wrapper, not the instance).
202
+ const resolveInstance = (node: FRAGS.SpatialTreeItem) => {
203
+ let cur = node;
204
+ let chainCat = cur.category ?? "";
205
+ while (
206
+ cur.children &&
207
+ cur.children.length === 1 &&
208
+ (cur.localId === null || cur.localId === undefined)
209
+ ) {
210
+ cur = cur.children[0];
211
+ if (cur.category) chainCat = cur.category;
212
+ }
213
+ return { inst: cur, chainCat };
214
+ };
215
+
216
+ type Built = { node: TreeNode; cat: string; storeyAncestor: boolean };
217
+
218
+ // Children with whitelisted category-GROUP nodes flattened away.
219
+ const buildChildren = (node: FRAGS.SpatialTreeItem, inheritedCat = ""): Built[] => {
220
+ const out: Built[] = [];
221
+ for (const child of node.children ?? []) {
222
+ const isFlattenGroup =
223
+ (child.localId === null || child.localId === undefined) &&
224
+ !!child.children &&
225
+ child.children.length > 0 &&
226
+ FLATTEN_SET.has((child.category ?? "").toUpperCase());
227
+ if (isFlattenGroup) {
228
+ out.push(...buildChildren(child, child.category ?? inheritedCat));
229
+ } else {
230
+ out.push(toNode(child, inheritedCat));
231
+ }
232
+ }
233
+ return out;
234
+ };
235
+
236
+ const toNode = (raw: FRAGS.SpatialTreeItem, inheritedCat = ""): Built => {
237
+ const { inst, chainCat } = resolveInstance(raw);
238
+ const localId = inst.localId ?? -1;
239
+ // Prefer the real per-item category; fall back to the spatial node's.
240
+ const realCat = localId >= 0 ? catMap.get(localId) : undefined;
241
+ const category = realCat || inst.category || chainCat || inheritedCat || "";
242
+ const rawName = localId >= 0 ? nameMap.get(localId) : undefined;
243
+ const nm = rawName ? String(rawName) : "";
244
+
245
+ const built = buildChildren(inst);
246
+ const rawHasStorey = (inst.children ?? []).some(
247
+ (c) => (c.category ?? "").toUpperCase() === STOREY,
248
+ );
249
+ const storeyAncestor =
250
+ rawHasStorey || built.some((b) => b.cat === STOREY || b.storeyAncestor);
251
+
252
+ const childIds: number[] = [];
253
+ for (const b of built) childIds.push(...b.node.ids);
254
+
255
+ const node: TreeNode = {
256
+ key: `${model.modelId}#${(keyCounter += 1)}`,
257
+ nm,
258
+ prettyCat: prettyCategory(category),
259
+ category,
260
+ modelId: model.modelId,
261
+ localId,
262
+ ids: (localId >= 0 ? [localId] : []).concat(childIds),
263
+ children: built.map((b) => b.node),
264
+ defaultExpand: storeyAncestor,
265
+ };
266
+ return { node, cat: category, storeyAncestor };
267
+ };
268
+
269
+ return toNode(structure).node;
270
+ };
271
+
272
+ // ── Visible-list (flatten by expand state / search filter) ─────
273
+ const indexNodes = (nodes: TreeNode[]) => {
274
+ for (const n of nodes) {
275
+ nodeByKey.set(n.key, n);
276
+ indexNodes(n.children);
277
+ }
278
+ };
279
+
280
+ const matchNode = (n: TreeNode, q: string) =>
281
+ `${n.nm} ${n.prettyCat} ${n.localId >= 0 ? n.localId : ""}`
282
+ .toLowerCase()
283
+ .includes(q);
284
+
285
+ const rebuildVisible = () => {
286
+ const out: { node: TreeNode; depth: number; anc?: boolean[]; last?: boolean }[] = [];
287
+ const q = query.trim().toLowerCase();
288
+
289
+ if (q) {
290
+ // Search: a matching node shows its whole subtree; a non-matching node is
291
+ // kept only as an ancestor of a match. Returns the node's emitted rows, or
292
+ // null if neither it nor any descendant matches.
293
+ const subtree = (n: TreeNode, depth: number, acc: { node: TreeNode; depth: number }[]) => {
294
+ acc.push({ node: n, depth });
295
+ for (const c of n.children) subtree(c, depth + 1, acc);
296
+ };
297
+ const dfs = (n: TreeNode, depth: number): { node: TreeNode; depth: number }[] | null => {
298
+ if (matchNode(n, q)) {
299
+ const acc: { node: TreeNode; depth: number }[] = [];
300
+ subtree(n, depth, acc);
301
+ return acc;
302
+ }
303
+ const childRows: { node: TreeNode; depth: number }[] = [];
304
+ for (const c of n.children) {
305
+ const r = dfs(c, depth + 1);
306
+ if (r) childRows.push(...r);
307
+ }
308
+ return childRows.length ? [{ node: n, depth }, ...childRows] : null;
309
+ };
310
+ for (const r of roots) {
311
+ const res = dfs(r, 0);
312
+ if (res) out.push(...res);
313
+ }
314
+ } else {
315
+ // Carry the connector-guide state: `anc` holds, for each indent column
316
+ // 0..depth-2, whether that ancestor branch continues below; `last` marks a
317
+ // last child (half-height elbow). A child gains a guide column for THIS
318
+ // node only when this node actually has an indent column (depth ≥ 1).
319
+ const dfs = (n: TreeNode, depth: number, anc: boolean[], last: boolean) => {
320
+ out.push({ node: n, depth, anc, last });
321
+ if (!expanded.has(n.key)) return;
322
+ const childAnc = depth >= 1 ? [...anc, !last] : anc;
323
+ n.children.forEach((c, i) =>
324
+ dfs(c, depth + 1, childAnc, i === n.children.length - 1),
325
+ );
326
+ };
327
+ roots.forEach((r, i) => dfs(r, 0, [], i === roots.length - 1));
328
+ }
329
+ visible = out;
330
+ };
331
+
332
+ // ── Render the window ──────────────────────────────────────────
333
+ // Tree connector guides: vertical lines at each continuing ancestor column +
334
+ // an elbow (vertical-to-middle + horizontal) into the node. Absolutely
335
+ // positioned so they cost no layout. Suppressed in search (filtered view).
336
+ const GUIDE_O = 7; // px, aligns a guide with an ancestor caret's centre
337
+ const guideHtml = (
338
+ depth: number,
339
+ anc: boolean[],
340
+ last: boolean,
341
+ expanded: boolean,
342
+ ) => {
343
+ if (query) return "";
344
+ let h = "";
345
+ if (depth > 0) {
346
+ for (let c = 0; c < depth - 1; c += 1) {
347
+ if (anc[c]) {
348
+ h += `<span class="t-guide t-vline" style="left:${BASE_PAD + c * INDENT + GUIDE_O}px"></span>`;
349
+ }
350
+ }
351
+ const ex = BASE_PAD + (depth - 1) * INDENT + GUIDE_O;
352
+ h += `<span class="t-guide t-elbow${last ? "" : " full"}" style="left:${ex}px"></span>`;
353
+ h += `<span class="t-guide t-hline" style="left:${ex}px;width:${INDENT}px"></span>`;
354
+ }
355
+ // Descending stub: when this node is expanded with visible children, draw a
356
+ // half-height vertical at the CHILDREN's column (depth) from the caret centre
357
+ // (50%) to the row bottom, so the down-chevron connects to the first child's
358
+ // full-height ancestor vertical below. x matches the children's elbow column.
359
+ if (expanded) {
360
+ h += `<span class="t-guide t-vstub" style="left:${BASE_PAD + depth * INDENT + GUIDE_O}px"></span>`;
361
+ }
362
+ return h;
363
+ };
364
+ const isCollapsed = (n: TreeNode) => !query && !expanded.has(n.key);
365
+ const rowHtml = (node: TreeNode, depth: number, anc?: boolean[], last?: boolean) => {
366
+ const hasKids = node.children.length > 0;
367
+ const collapsed = isCollapsed(node);
368
+ const showsChildVertical = !query && hasKids && expanded.has(node.key);
369
+ const caret = hasKids
370
+ ? `<span class="t-caret" data-act="toggle"><bim-icon icon="${
371
+ collapsed ? "mdi:chevron-right" : "mdi:chevron-down"
372
+ }"></bim-icon></span>`
373
+ : `<span class="t-caret"></span>`;
374
+ return (
375
+ `<div class="t-row${selectedKeys.has(node.key) ? " sel" : ""}" data-key="${node.key}" ` +
376
+ `style="height:${ROW_H}px;padding-left:${BASE_PAD + depth * INDENT}px;">` +
377
+ guideHtml(depth, anc ?? [], last ?? true, showsChildVertical) +
378
+ caret +
379
+ `<bim-icon class="t-ico" icon="${iconFor(node.category)}"></bim-icon>` +
380
+ `<span class="t-label">${esc(labelFor(node, collapsed))}</span>` +
381
+ `<span class="t-focus" data-act="focus"><bim-icon icon="mdi:image-filter-center-focus"></bim-icon><bim-tooltip placement="top">Focus</bim-tooltip></span>` +
382
+ `</div>`
383
+ );
384
+ };
385
+
386
+ // Build a single row element from its HTML (so entering rows get the exact
387
+ // same markup the old innerHTML path produced — caret, icons, focus tooltip).
388
+ const buildRow = (i: number): HTMLElement => {
389
+ const v = visible[i];
390
+ const tmp = document.createElement("div");
391
+ tmp.innerHTML = rowHtml(v.node, v.depth, v.anc, v.last);
392
+ return tmp.firstElementChild as HTMLElement;
393
+ };
394
+
395
+ // Index→element recycler. A row that stays within the window keeps its exact
396
+ // DOM element (and its already-rendered <bim-icon>s) across scroll, so icons
397
+ // are never recreated → no blink. Only rows ENTERING the window are built and
398
+ // rows LEAVING are removed. A forced rebuild (selection/expand/search/data
399
+ // change) clears this map so the affected rows are rebuilt once.
400
+ const mounted = new Map<number, HTMLElement>();
401
+
402
+ let lastStart = -1;
403
+ let lastEnd = -1;
404
+ const render = (force = false) => {
405
+ const total = visible.length;
406
+ sizer.style.height = `${total * ROW_H}px`;
407
+ const top = viewport.scrollTop;
408
+ const vh = viewport.clientHeight || ROW_H;
409
+ const start = Math.max(0, Math.floor(top / ROW_H) - BUFFER);
410
+ const end = Math.min(total, Math.ceil((top + vh) / ROW_H) + BUFFER);
411
+ if (!force && start === lastStart && end === lastEnd) return;
412
+ // Defensive: a <bim-tooltip> reparents itself into a global body-level
413
+ // container when shown, so removing a row below would NOT remove a tooltip
414
+ // that is currently visible (its row gets detached but the tooltip lives
415
+ // elsewhere). The library self-heals via a host observer, but we also
416
+ // proactively dismiss any visible tooltip here so it can't flicker: fire
417
+ // mouseleave on each current Focus host (where the hide listener is bound).
418
+ if (document.querySelector("bim-tooltip[visible]")) {
419
+ for (const host of content.querySelectorAll<HTMLElement>(".t-focus")) {
420
+ host.dispatchEvent(new MouseEvent("mouseleave"));
421
+ }
422
+ }
423
+ // A forced rebuild means the underlying data/markup for the window changed
424
+ // (selection highlight, expand/collapse, search, rebuild). Drop every
425
+ // mounted row so they are rebuilt once with fresh markup — this only happens
426
+ // off the scroll path, so the (unnoticeable) one-time rebuild is fine; plain
427
+ // scrolling never forces and so always reuses existing rows.
428
+ if (force) {
429
+ mounted.clear();
430
+ content.textContent = "";
431
+ }
432
+ lastStart = start;
433
+ lastEnd = end;
434
+ content.style.transform = `translateY(${start * ROW_H}px)`;
435
+ // Remove rows that have scrolled out of [start, end).
436
+ for (const [i, el] of mounted) {
437
+ if (i < start || i >= end) {
438
+ el.remove();
439
+ mounted.delete(i);
440
+ }
441
+ }
442
+ // Insert rows entering [start, end), keeping DOM order by index. We walk the
443
+ // window in order and splice each new element before the first already-
444
+ // mounted element with a higher index (or append if none).
445
+ let cursor = content.firstElementChild as HTMLElement | null;
446
+ for (let i = start; i < end; i++) {
447
+ const existing = mounted.get(i);
448
+ if (existing) {
449
+ // Already in place (in index order) — advance the cursor past it.
450
+ cursor = existing.nextElementSibling as HTMLElement | null;
451
+ continue;
452
+ }
453
+ const el = buildRow(i);
454
+ content.insertBefore(el, cursor);
455
+ mounted.set(i, el);
456
+ // cursor stays pointing at the same following node, so the next entering
457
+ // index is inserted after this one (preserving ascending DOM order).
458
+ }
459
+ };
460
+
461
+ // ── Interaction (one delegated listener) ───────────────────────
462
+ const idsMap = (n: TreeNode): OBC.ModelIdMap | null => {
463
+ const real = n.ids.filter((id) => id >= 0);
464
+ if (real.length === 0) return null;
465
+ return { [n.modelId]: new Set(real) };
466
+ };
467
+
468
+ const selectNode = (n: TreeNode, additive: boolean) => {
469
+ if (additive) {
470
+ if (selectedKeys.has(n.key)) selectedKeys.delete(n.key);
471
+ else selectedKeys.add(n.key);
472
+ } else {
473
+ selectedKeys.clear();
474
+ selectedKeys.add(n.key);
475
+ }
476
+ const map = idsMap(n);
477
+ if (map) void highlighter.highlightByID(selectName, map, !additive, false);
478
+ else if (!additive) void highlighter.clear(selectName);
479
+ render(true); // refresh row highlight
480
+ };
481
+
482
+ const focusNode = async (n: TreeNode) => {
483
+ const map = idsMap(n);
484
+ if (!map) return;
485
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
486
+ const world = [...components.get(OBC.Worlds).list.values()][0] as any;
487
+ const controls = world?.camera?.controls;
488
+ if (!controls) return;
489
+ try {
490
+ const boxes = (await fragments.getBBoxes(map)) as THREE.Box3[];
491
+ const box = new THREE.Box3();
492
+ for (const b of boxes) box.union(b);
493
+ if (box.isEmpty()) return;
494
+ const sphere = box.getBoundingSphere(new THREE.Sphere());
495
+ await controls.fitToSphere(sphere, true); // animated, preserves view dir
496
+ } catch (error) {
497
+ console.warn("[model-tree] focus failed", error);
498
+ }
499
+ };
500
+
501
+ const toggleExpand = (key: string) => {
502
+ if (expanded.has(key)) expanded.delete(key);
503
+ else expanded.add(key);
504
+ rebuildVisible();
505
+ render(true);
506
+ };
507
+
508
+ content.addEventListener("click", (e) => {
509
+ const target = e.target as HTMLElement;
510
+ const rowEl = target.closest<HTMLElement>(".t-row");
511
+ if (!rowEl) return;
512
+ const node = nodeByKey.get(rowEl.dataset.key ?? "");
513
+ if (!node) return;
514
+ const act = target.closest<HTMLElement>("[data-act]")?.dataset.act;
515
+ if (act === "toggle") {
516
+ toggleExpand(node.key);
517
+ } else if (act === "focus") {
518
+ // Focus also selects the node (and its descendants), like a plain click.
519
+ selectNode(node, false);
520
+ void focusNode(node);
521
+ } else {
522
+ selectNode(node, (e as MouseEvent).ctrlKey || (e as MouseEvent).metaKey);
523
+ }
524
+ });
525
+
526
+ // Re-window on scroll (rAF-coalesced) and on resize.
527
+ let scrollRaf = 0;
528
+ viewport.addEventListener("scroll", () => {
529
+ if (scrollRaf) return;
530
+ scrollRaf = requestAnimationFrame(() => {
531
+ scrollRaf = 0;
532
+ render();
533
+ });
534
+ });
535
+ new ResizeObserver(() => render()).observe(viewport);
536
+
537
+ // Clearing the selection elsewhere clears the tree row highlight.
538
+ highlighter.events.select.onClear.add(() => {
539
+ if (selectedKeys.size === 0) return;
540
+ selectedKeys.clear();
541
+ render(true);
542
+ });
543
+
544
+ // ── Category grouping ──────────────────────────────────────────
545
+ // Flat grouping by IFC category derived from the spatial tree (each element
546
+ // node carries its category), so it covers exactly the elements the spatial
547
+ // view shows — no separate Classifier round-trip, always in sync. Each
548
+ // category becomes an expandable group whose children are its element nodes.
549
+ const buildCategoryRoots = (source: TreeNode[]): TreeNode[] => {
550
+ const byCat = new Map<string, TreeNode[]>();
551
+ const visit = (n: TreeNode) => {
552
+ if (n.localId >= 0) {
553
+ const cat = n.category || "Uncategorized";
554
+ let arr = byCat.get(cat);
555
+ if (!arr) {
556
+ arr = [];
557
+ byCat.set(cat, arr);
558
+ }
559
+ arr.push({
560
+ key: `${n.modelId}#cat#${(keyCounter += 1)}`,
561
+ nm: n.nm,
562
+ prettyCat: n.prettyCat,
563
+ category: n.category,
564
+ modelId: n.modelId,
565
+ localId: n.localId,
566
+ ids: [n.localId],
567
+ children: [],
568
+ defaultExpand: false,
569
+ });
570
+ }
571
+ n.children.forEach(visit);
572
+ };
573
+ source.forEach(visit);
574
+
575
+ const cats = [...byCat.keys()].sort((a, b) =>
576
+ prettyCategory(a).localeCompare(prettyCategory(b)),
577
+ );
578
+ return cats.map((cat) => {
579
+ const kids = byCat.get(cat)!;
580
+ kids.sort((a, b) =>
581
+ (a.nm || String(a.localId)).localeCompare(b.nm || String(b.localId)),
582
+ );
583
+ const ids: number[] = [];
584
+ for (const k of kids) ids.push(...k.ids);
585
+ return {
586
+ key: `catgroup#${(keyCounter += 1)}`,
587
+ nm: "",
588
+ // The label cell shows "Category (count)" via prettyCat; raw category
589
+ // drives the icon.
590
+ prettyCat: `${prettyCategory(cat) || cat} (${kids.length})`,
591
+ category: cat,
592
+ modelId: kids[0]?.modelId ?? "",
593
+ localId: -1,
594
+ ids,
595
+ children: kids,
596
+ defaultExpand: false,
597
+ };
598
+ });
599
+ };
600
+
601
+ // Switch the displayed tree to the current `mode` and re-render. Selection in
602
+ // the 3D scene (driven by the Highlighter) is independent of tree keys, so it
603
+ // persists across a mode switch; only the row-highlight set is reset.
604
+ const applyMode = () => {
605
+ roots = mode === "category" ? buildCategoryRoots(spatialRoots) : spatialRoots;
606
+ nodeByKey.clear();
607
+ indexNodes(roots);
608
+ expanded.clear();
609
+ if (mode === "spatial") {
610
+ const markExpand = (nodes: TreeNode[]) => {
611
+ for (const n of nodes) {
612
+ if (n.defaultExpand) expanded.add(n.key);
613
+ markExpand(n.children);
614
+ }
615
+ };
616
+ markExpand(roots);
617
+ }
618
+ selectedKeys.clear();
619
+ rebuildVisible();
620
+ lastStart = lastEnd = -1;
621
+ viewport.scrollTop = 0;
622
+ render(true);
623
+ update({}); // re-render the panel template so the view-mode buttons' active state refreshes
624
+ };
625
+
626
+ // ── Rebuild from all loaded models ─────────────────────────────
627
+ const rebuild = async () => {
628
+ const token = ++rebuildToken;
629
+ const models = [...fragments.list.values()];
630
+ if (models.length === 0) {
631
+ if (token === rebuildToken) {
632
+ spatialRoots = [];
633
+ roots = [];
634
+ nodeByKey.clear();
635
+ rebuildVisible();
636
+ render(true);
637
+ update({ status: "empty" });
638
+ }
639
+ return;
640
+ }
641
+ update({ status: "loading" });
642
+ try {
643
+ const built = (
644
+ await Promise.all(
645
+ models.map((m) =>
646
+ buildModel(m).catch((error) => {
647
+ if (!/Model not found/i.test(String(error?.message ?? error))) {
648
+ console.warn("[model-tree] failed to build", m.modelId, error);
649
+ }
650
+ return null;
651
+ }),
652
+ ),
653
+ )
654
+ ).filter((n): n is TreeNode => n !== null);
655
+ if (token !== rebuildToken) return;
656
+ spatialRoots = built;
657
+ // Build the displayed tree for the current mode (default-expands storeys
658
+ // in spatial mode; categories start collapsed in category mode).
659
+ applyMode();
660
+ update({ status: built.length > 0 ? "ready" : "empty" });
661
+ } catch (error) {
662
+ if (token !== rebuildToken) return;
663
+ console.warn("[model-tree] failed to build spatial structure", error);
664
+ update({ status: "empty" });
665
+ }
666
+ };
667
+
668
+ // ── Panel chrome (BUI) — the windowed viewport is mounted into it ──
669
+ interface PanelState {
670
+ status: "loading" | "empty" | "ready";
671
+ }
672
+ const [panel, update] = BUI.Component.create<BUI.Panel, PanelState>(
673
+ (state) => {
674
+ const onHostCreated = (el?: Element) => {
675
+ if (!el || el.contains(viewport)) return;
676
+ el.appendChild(viewport);
677
+ render(true);
678
+ };
679
+ return BUI.html`
680
+ <bim-panel
681
+ label="Items"
682
+ icon="mdi:file-tree"
683
+ header-hidden
684
+ style="width: 100%; height: 100%; pointer-events: auto;"
685
+ >
686
+ <style>
687
+ .tree-vp { height: 100%; overflow-y: auto; }
688
+ .tree-vp::-webkit-scrollbar { width: 0.4rem; height: 0.4rem; }
689
+ .tree-vp::-webkit-scrollbar-thumb { border-radius: 0.25rem; background-color: var(--bim-scrollbar--c, #3C3C41); }
690
+ .tree-vp::-webkit-scrollbar-track { background-color: var(--bim-scrollbar--bgc, var(--bim-ui_bg-contrast-10)); }
691
+ .t-row { position: relative; display: flex; align-items: center; gap: 0.3rem; box-sizing: border-box; padding-right: 0.4rem; font-size: 0.72rem; line-height: 1.1; color: var(--bim-ui_bg-contrast-100, #e3e3e3); border-bottom: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1)); cursor: pointer; }
692
+ /* Tree connector guides (absolute → no layout cost). */
693
+ .t-guide { position: absolute; top: 0; height: 100%; pointer-events: none; }
694
+ .t-vline { width: 0; border-left: 1px solid var(--bim-ui_bg-contrast-30, rgba(255,255,255,0.16)); }
695
+ .t-elbow { width: 0; height: 50%; top: 0; border-left: 1px solid var(--bim-ui_bg-contrast-30, rgba(255,255,255,0.16)); }
696
+ .t-elbow.full { height: 100%; }
697
+ .t-hline { height: 0; top: 50%; border-top: 1px solid var(--bim-ui_bg-contrast-30, rgba(255,255,255,0.16)); }
698
+ .t-vstub { width: 0; top: 50%; height: 50%; border-left: 1px solid var(--bim-ui_bg-contrast-30, rgba(255,255,255,0.16)); }
699
+ .t-row:hover { background: var(--bim-ui_bg-contrast-20); }
700
+ .t-row.sel { background: color-mix(in lab, var(--bim-ui_bg-contrast-20) 35%, var(--bim-ui_bg-contrast-70) 12%); }
701
+ /* Caret + icon sit ABOVE the absolutely-positioned guide lines
702
+ (positioned elements paint over the guides regardless of DOM
703
+ order; the guides are pointer-events:none so clicks still land). */
704
+ .t-caret { position: relative; z-index: 1; flex: 0 0 auto; width: 0.95rem; display: inline-flex; align-items: center; justify-content: center; color: var(--bim-ui_bg-contrast-60, #9a9a9a); font-size: 0.85rem; }
705
+ .t-ico { position: relative; z-index: 1; flex: 0 0 auto; color: #99a0ae; font-size: 0.9rem; }
706
+ .t-label { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
707
+ .t-focus { flex: 0 0 auto; display: inline-flex; align-items: center; cursor: pointer; color: #99a0ae; font-size: 0.85rem; margin-right: 0.5rem; }
708
+ </style>
709
+ <div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
710
+ ${cardHeader("mdi:file-tree", "Items", "1.1rem")}
711
+ <div style="flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; padding: 0.4rem 0.4rem 0.4rem 1.1rem; gap: 0.25rem;">
712
+ ${state.status === "empty"
713
+ ? BUI.html`<div style="
714
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
715
+ gap: 0.75rem; height: 100%; min-height: 12rem; padding: 1rem; text-align: center;
716
+ ">
717
+ <img src=${toolPlaceholderUri} alt="" style="width: 7.5rem; height: auto; opacity: 0.9;" />
718
+ <bim-label style="opacity: 0.55; white-space: normal;">No model loaded.</bim-label>
719
+ </div>`
720
+ : state.status === "loading"
721
+ ? BUI.html`<div style="padding: 0.75rem;"><bim-label style="opacity: 0.6;">Loading…</bim-label></div>`
722
+ : null}
723
+ ${state.status === "ready"
724
+ ? BUI.html`<bim-text-input
725
+ icon="mdi:magnify"
726
+ icon-inside
727
+ placeholder="Search tree…"
728
+ style="flex: 0 0 auto; width: 100%; margin: 0 0 0.25rem -0.35rem;"
729
+ @input=${(e: Event) => {
730
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
731
+ const v = String((e.target as any).value ?? "");
732
+ if (searchTimer !== undefined) clearTimeout(searchTimer);
733
+ searchTimer = window.setTimeout(() => {
734
+ query = v;
735
+ rebuildVisible();
736
+ viewport.scrollTop = 0;
737
+ render(true);
738
+ }, 200);
739
+ }}
740
+ ></bim-text-input>
741
+ <!-- View-mode selector: a segmented row of buttons (extensible —
742
+ add more views later). Active view = main-accent filled. -->
743
+ <div style="flex: 0 0 auto; display: flex; gap: 0; width: 100%; box-sizing: border-box; margin: 0 0 0.3rem -0.35rem;">
744
+ <bim-button
745
+ label="Tree"
746
+ icon="mdi:file-tree"
747
+ ?active=${mode === "spatial"}
748
+ @click=${() => {
749
+ if (mode !== "spatial") {
750
+ mode = "spatial";
751
+ applyMode();
752
+ }
753
+ }}
754
+ style="flex: 1 1 0; height: 1.7rem; border-radius: 0.3rem 0 0 0.3rem;"
755
+ ></bim-button>
756
+ <bim-button
757
+ label="Categories"
758
+ icon="mdi:shape-outline"
759
+ ?active=${mode === "category"}
760
+ @click=${() => {
761
+ if (mode !== "category") {
762
+ mode = "category";
763
+ applyMode();
764
+ }
765
+ }}
766
+ style="flex: 1 1 0; height: 1.7rem; border-radius: 0 0.3rem 0.3rem 0;"
767
+ ></bim-button>
768
+ </div>
769
+ <div style="flex: 0 0 auto; border-bottom: 1px solid var(--bim-ui_bg-contrast-20, rgba(255, 255, 255, 0.1)); margin: 0 -0.4rem 0.1rem -1.1rem;"></div>`
770
+ : null}
771
+ <div
772
+ ${BUI.ref(onHostCreated)}
773
+ style=${BUI.styleMap({
774
+ display: state.status === "ready" ? "block" : "none",
775
+ flex: "1 1 auto",
776
+ minHeight: "0",
777
+ // Break out of the container's horizontal padding so the row
778
+ // dividers reach both panel edges (full-bleed).
779
+ marginLeft: "-1.1rem",
780
+ marginRight: "-0.4rem",
781
+ })}
782
+ ></div>
783
+ </div>
784
+ </div>
785
+ </bim-panel>
786
+ `;
787
+ },
788
+ { status: "loading" },
789
+ );
790
+
791
+ // ── Triggers ───────────────────────────────────────────────────
792
+ fragments.core.onModelLoaded.add(() => rebuild());
793
+ fragments.list.onItemDeleted.add(() => rebuild());
794
+ rebuild();
795
+
796
+ return panel;
797
+ };