@thatopen/services 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/built-in/index.d.ts +22 -2
- package/dist/cli.js +3 -3
- package/docs/ai-quickstart.md +86 -0
- package/docs/cli/create.md +4 -3
- package/package.json +3 -3
- package/src/built-in/index.ts +669 -649
- package/src/cli/templates/app/package.json +31 -31
- package/src/cli/templates/app/src/app.ts +37 -37
- package/src/cli/templates/app/src/assets/tool-placeholder.ts +50 -50
- package/src/cli/templates/app/src/main.ts +223 -340
- package/src/cli/templates/app/src/setups/camera-tools.ts +63 -63
- package/src/cli/templates/app/src/setups/clipper-tool.ts +20 -1
- package/src/cli/templates/app/src/setups/clipper.ts +75 -75
- package/src/cli/templates/app/src/setups/index.ts +23 -33
- package/src/cli/templates/app/src/setups/inspection.ts +136 -136
- package/src/cli/templates/app/src/setups/measurement-tool.ts +19 -1
- package/src/cli/templates/app/src/setups/measurements.ts +133 -133
- package/src/cli/templates/app/src/setups/reality-capture/lib/hidden-tiles-plugin.ts +88 -88
- package/src/cli/templates/app/src/setups/reality-capture/lib/point-tile-plugin.ts +166 -166
- package/src/cli/templates/app/src/setups/reality-capture/lib/render-frame.ts +31 -31
- package/src/cli/templates/app/src/setups/reality-capture/lib/splat-tile-plugin.ts +51 -51
- package/src/cli/templates/app/src/setups/reality-capture-viewer.ts +1383 -1383
- package/src/cli/templates/app/src/setups/right-sidebar.ts +178 -178
- package/src/cli/templates/app/src/setups/styles-panel.ts +110 -110
- package/src/cli/templates/app/src/setups/tool-mode-manager.ts +15 -9
- package/src/cli/templates/app/src/setups/tool-mode.ts +23 -23
- package/src/cli/templates/app/src/setups/active-tool-hud.ts +0 -69
- package/src/cli/templates/app/src/setups/clipper-panel.ts +0 -167
- package/src/cli/templates/app/src/setups/commands-panel.ts +0 -239
- package/src/cli/templates/app/src/setups/data-table-panel.ts +0 -2224
- package/src/cli/templates/app/src/setups/file-format-icons.ts +0 -95
- package/src/cli/templates/app/src/setups/files-panel.ts +0 -1503
- package/src/cli/templates/app/src/setups/fps-indicator.ts +0 -63
- package/src/cli/templates/app/src/setups/graphics-panel.ts +0 -490
- package/src/cli/templates/app/src/setups/measurement-settings-panel.ts +0 -161
- package/src/cli/templates/app/src/setups/model-tree.ts +0 -797
- package/src/cli/templates/app/src/setups/objects-panel.ts +0 -127
- package/src/cli/templates/app/src/setups/properties-panel.ts +0 -1048
- package/src/cli/templates/app/src/setups/settings-panel.ts +0 -94
- package/src/cli/templates/app/src/setups/visibility-toolbar.ts +0 -498
- package/src/cli/templates/app/src/setups/walkthrough.ts +0 -528
|
@@ -1,797 +0,0 @@
|
|
|
1
|
-
import * as THREE from "three";
|
|
2
|
-
import * as OBC from "@thatopen/components";
|
|
3
|
-
import * as OBF from "@thatopen/components-front";
|
|
4
|
-
import * as 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
-
};
|