@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,498 @@
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 type { WalkthroughController } from "./walkthrough";
6
+ import { toolModeManager } from "./tool-mode-manager";
7
+ import type { InspectionAction } from "./inspection";
8
+
9
+ /**
10
+ * Floating "visibility" toolbar — bottom-center over the viewport.
11
+ *
12
+ * A self-mounting `bim-toolbar` (matching the look of `toolbar.ts`: dark
13
+ * `bg-base`, rounded, subtle shadow, icon-only `bim-button`s with standalone
14
+ * `<bim-tooltip>` hover tooltips) placed inside a floating `bim-grid` overlay so
15
+ * empty areas click through to the 3D scene.
16
+ *
17
+ * ── MODE TOGGLE ────────────────────────────────────────────────────────────
18
+ * One labeled toggle button flips the *target* every action operates on:
19
+ * - "Selected" → the current Highlighter select set (`selection.select`).
20
+ * If nothing is selected the target is EMPTY (actions no-op).
21
+ * - "Unselected" → everything that is NOT selected. If nothing is selected the
22
+ * target is the WHOLE model.
23
+ *
24
+ * ── ACTIONS (each reversible via Show / Reset) ─────────────────────────────
25
+ * - HIDE → hide the target set.
26
+ * - SHOW → un-hide the target set.
27
+ * - ISOLATE → show only the target set, hide everything else.
28
+ * - GHOST → render the target set semi-transparent; opaque rest.
29
+ * - RESET → restore full visibility + opacity (clears hide AND ghost).
30
+ *
31
+ * Combos give full control, e.g.:
32
+ * - Unselected + Hide = isolate the selection.
33
+ * - Selected + Hide = hide the selection.
34
+ * - Show / Reset = restore.
35
+ *
36
+ * ── VISIBILITY API ─────────────────────────────────────────────────────────
37
+ * Visibility (hide/show/isolate) goes through `OBC.Hider` (`set` / `isolate`),
38
+ * which drives the fragments' per-item visibility flag and triggers the core
39
+ * redraw itself — NOT the Highlighter recolor-on-hover path (honoring the perf
40
+ * rule). Ghosting uses a dedicated transparent Highlighter style ("ghost") —
41
+ * the idiomatic flat x-ray the library's Hoverer example calls "ghost mode".
42
+ * It renders a uniform faint translucent gray shell as a flat overlay (NOT
43
+ * through the deferred pen-shadows emitter, which looked muddy), is fully
44
+ * reversible via `highlighter.clear("ghost")`, and coexists with the "select"
45
+ * style so selection still works while items are ghosted.
46
+ *
47
+ * ── MOUNTING ───────────────────────────────────────────────────────────────
48
+ * `visibilityToolbar(components)` builds the floating overlay AND appends it to
49
+ * the viewport itself (it locates the viewport like the other setups do), then
50
+ * returns the `bim-toolbar` element. To wire it from main.ts, add this single
51
+ * line after the viewport + Highlighter exist (e.g. next to the `toolbar(...)`
52
+ * call):
53
+ *
54
+ * visibilityToolbar(components);
55
+ *
56
+ * @param components engine components
57
+ * @param container optional viewport element to overlay; if omitted, the first
58
+ * world's renderer container (the viewport) is used.
59
+ */
60
+
61
+ type TargetMode = "selected" | "unselected";
62
+
63
+ // Minimal toggle-controller shape (exploded view, etc.) — a single toolbar
64
+ // button can drive any controller exposing this surface.
65
+ type ToggleCtrl = {
66
+ toggle(): void;
67
+ isActive(): boolean;
68
+ onChange(cb: (active: boolean) => void): () => void;
69
+ };
70
+
71
+ const isModelIdMapEmpty = (map?: OBC.ModelIdMap) =>
72
+ !map || !Object.values(map).some((set) => set.size > 0);
73
+
74
+ export const visibilityToolbar = (
75
+ components: OBC.Components,
76
+ container?: HTMLElement,
77
+ walk?: WalkthroughController,
78
+ explode?: ToggleCtrl,
79
+ inspection?: InspectionAction[],
80
+ ) => {
81
+ const fragments = components.get(OBC.FragmentsManager);
82
+ const hider = components.get(OBC.Hider);
83
+ const highlighter = components.get(OBF.Highlighter);
84
+
85
+ // ── Ghost / x-ray (scalable per-element GPU state texture) ──────────────
86
+ // The legacy approach recolored fragments per item via a Highlighter "ghost"
87
+ // style (highlightByID) — O(elements) CPU churn that breaks batching. Ghost
88
+ // now lives on the GPU: `model.setGhostItems(localIds)` flips a per-element
89
+ // state texture sampled in the SHELL shader (faint screen-door + desaturate),
90
+ // so it scales to millions of elements with no per-item CPU work. See
91
+ // engine_fragments-beta ghost-emission.ts / material-manager.ts.
92
+ let mode: TargetMode = "selected";
93
+
94
+ // ── Target-set resolution ──────────────────────────────────────────────
95
+ const selection = (): OBC.ModelIdMap | undefined => highlighter.selection.select;
96
+
97
+ /**
98
+ * The ModelIdMap the current mode targets.
99
+ * - selected: the select set (empty → empty map).
100
+ * - unselected: every loaded item minus the select set (nothing selected →
101
+ * the whole model).
102
+ * Built async because the "unselected" set is derived from each model's full
103
+ * item list (via getItemsByVisibility(true|false) union = all items).
104
+ */
105
+ const targetMap = async (): Promise<OBC.ModelIdMap> => {
106
+ const sel = selection();
107
+ if (mode === "selected") {
108
+ // Clone defensively (Hider/iteration shouldn't mutate the live set).
109
+ const out: OBC.ModelIdMap = {};
110
+ if (sel) {
111
+ for (const [modelId, set] of Object.entries(sel)) {
112
+ if (set.size > 0) out[modelId] = new Set(set);
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+ // unselected = all items in every model, minus the selected ones.
118
+ const out: OBC.ModelIdMap = {};
119
+ for (const [modelId, model] of fragments.list) {
120
+ // Union of visible + hidden = every item the model knows about.
121
+ const [vis, hid] = await Promise.all([
122
+ model.getItemsByVisibility(true),
123
+ model.getItemsByVisibility(false),
124
+ ]);
125
+ const all = new Set<number>([...vis, ...hid]);
126
+ const selSet = sel?.[modelId];
127
+ if (selSet) for (const id of selSet) all.delete(id);
128
+ if (all.size > 0) out[modelId] = all;
129
+ }
130
+ return out;
131
+ };
132
+
133
+ // ── Ghost helpers (scalable GPU hash table) ─────────────────────────────
134
+ // Always hash the SMALL key set (the selection) + an invert flag, so the
135
+ // table stays tiny whether we ghost the selection or everything-but it:
136
+ // - "selected" → ghost the selection (invert=false).
137
+ // - "unselected" → ghost everything EXCEPT the selection (invert=true),
138
+ // applied to every loaded model (a model with no selection → empty key set
139
+ // + invert=true → fully ghosted).
140
+ const applyGhost = async () => {
141
+ const sel = selection() ?? {};
142
+ const invert = mode === "unselected";
143
+ const tasks: Promise<unknown>[] = [];
144
+ for (const model of fragments.list.values()) {
145
+ const set = sel[model.modelId];
146
+ const ids = set ? [...set] : [];
147
+ if (!invert && ids.length === 0) continue; // ghost-selected with nothing selected
148
+ // setGhostItems is async (localId→itemId conversion) — update after all.
149
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
150
+ tasks.push((model as any).setGhostItems(ids, invert));
151
+ }
152
+ await Promise.all(tasks);
153
+ await fragments.core.update(true);
154
+ };
155
+
156
+ /** Clear the ghost overlay on every loaded model. */
157
+ const clearGhost = async () => {
158
+ for (const model of fragments.list.values()) {
159
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
160
+ (model as any).clearGhost?.();
161
+ }
162
+ await fragments.core.update(true);
163
+ };
164
+
165
+ /**
166
+ * Un-ghost the given items in place (without disturbing the rest of the ghost
167
+ * state). Making items visible should also clear their ghost, so a shown
168
+ * element never lingers as a ghost.
169
+ */
170
+ const unghost = async (map: OBC.ModelIdMap) => {
171
+ const tasks: Promise<unknown>[] = [];
172
+ for (const model of fragments.list.values()) {
173
+ const set = map[model.modelId];
174
+ if (!set || set.size === 0) continue;
175
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
176
+ tasks.push((model as any).unsetGhostItems?.([...set]));
177
+ }
178
+ if (!tasks.length) return;
179
+ await Promise.all(tasks);
180
+ await fragments.core.update(true);
181
+ };
182
+
183
+ // ── Actions ─────────────────────────────────────────────────────────────
184
+ const doHide = async () => {
185
+ const map = await targetMap();
186
+ if (isModelIdMapEmpty(map)) return; // nothing to hide (e.g. Selected w/ no selection)
187
+ await hider.set(false, map);
188
+ };
189
+
190
+ const doShow = async () => {
191
+ const map = await targetMap();
192
+ // For "unselected" w/ no selection this is the whole model → show all.
193
+ if (isModelIdMapEmpty(map)) {
194
+ await hider.set(true);
195
+ await clearGhost(); // all visible → nothing should stay ghosted
196
+ return;
197
+ }
198
+ await hider.set(true, map);
199
+ await unghost(map); // shown items un-ghost
200
+ };
201
+
202
+ const doIsolate = async () => {
203
+ const map = await targetMap();
204
+ if (isModelIdMapEmpty(map)) return; // isolating an empty set would blank the model
205
+ await hider.isolate(map);
206
+ await unghost(map); // the isolated (visible) items un-ghost
207
+ };
208
+
209
+ const doGhost = async () => {
210
+ await applyGhost();
211
+ };
212
+
213
+ // Frame the camera on the target set's merged bounding box (same fit the tree's
214
+ // Focus button uses). Targets selected or unselected per the mode toggle.
215
+ const doFocus = async () => {
216
+ const map = await targetMap();
217
+ if (isModelIdMapEmpty(map)) return;
218
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
219
+ const world = [...components.get(OBC.Worlds).list.values()][0] as any;
220
+ const controls = world?.camera?.controls;
221
+ if (!controls?.fitToSphere) return;
222
+ try {
223
+ const boxes = (await fragments.getBBoxes(map)) as THREE.Box3[];
224
+ const box = new THREE.Box3();
225
+ for (const b of boxes) box.union(b);
226
+ if (box.isEmpty()) return;
227
+ const sphere = box.getBoundingSphere(new THREE.Sphere());
228
+ await controls.fitToSphere(sphere, true); // animated, preserves view dir
229
+ } catch (error) {
230
+ console.warn("[visibility-toolbar] focus failed", error);
231
+ }
232
+ };
233
+
234
+ // Reset: restore full visibility AND clear every ghost override.
235
+ const doReset = async () => {
236
+ await hider.set(true);
237
+ await clearGhost();
238
+ };
239
+
240
+ // ── Toolbar UI ──────────────────────────────────────────────────────────
241
+ type Action = { label: string; icon: string; run: () => void | Promise<void> };
242
+ const actions: Action[] = [
243
+ { label: "Focus", icon: "mdi:image-filter-center-focus", run: doFocus },
244
+ { label: "Hide", icon: "mdi:eye-off-outline", run: doHide },
245
+ { label: "Show", icon: "mdi:eye-outline", run: doShow },
246
+ { label: "Isolate", icon: "mdi:select-search", run: doIsolate },
247
+ { label: "Ghost", icon: "mdi:ghost-outline", run: doGhost },
248
+ ];
249
+
250
+ let busy = false;
251
+ const onAction = async (run: () => void | Promise<void>) => {
252
+ if (busy) return;
253
+ busy = true;
254
+ try {
255
+ await run();
256
+ } catch (error) {
257
+ console.warn("[visibility-toolbar] action failed", error);
258
+ } finally {
259
+ busy = false;
260
+ }
261
+ };
262
+
263
+ const toggleMode = () => {
264
+ mode = mode === "selected" ? "unselected" : "selected";
265
+ barUpdate({ tick: ++tick });
266
+ };
267
+
268
+ // ── Camera projection (perspective ⇄ orthographic) ──────────────────────
269
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
270
+ const cameraProjection = (): any =>
271
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
272
+ ([...components.get(OBC.Worlds).list.values()][0] as any)?.camera?.projection;
273
+ const isOrtho = () => cameraProjection()?.current === "Orthographic";
274
+
275
+ // AO now reconstructs correctly under orthographic projection (fixed at source
276
+ // in the beta postproduction shaders via the `uOrtho` branch), so we no longer
277
+ // force it off in ortho — the user's AO setting is honored in both projections.
278
+ const toggleProjection = async () => {
279
+ const proj = cameraProjection();
280
+ if (!proj?.set) return;
281
+ const goingOrtho = !isOrtho();
282
+ await proj.set(goingOrtho ? "Orthographic" : "Perspective");
283
+ barUpdate({ tick: ++tick });
284
+ };
285
+
286
+ let tick = 0;
287
+
288
+ const [bar, barUpdate] = BUI.Component.create<HTMLElement, { tick: number }>(
289
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
290
+ (_state) => {
291
+ const modeLabel = mode === "selected" ? "Selected" : "Unselected";
292
+ const modeIcon =
293
+ mode === "selected" ? "mdi:cursor-default-click-outline" : "mdi:select-remove";
294
+ const modeTip =
295
+ mode === "selected"
296
+ ? "Target: SELECTED elements — click to switch to Unselected"
297
+ : "Target: UNSELECTED elements (rest of model) — click to switch to Selected";
298
+ return BUI.html`
299
+ <bim-toolbar
300
+ style="
301
+ pointer-events: auto;
302
+ overflow: visible;
303
+ padding: 0.15rem;
304
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
305
+ "
306
+ >
307
+ <bim-toolbar-section label-hidden style="background: transparent;">
308
+ <bim-button
309
+ label=${modeLabel}
310
+ icon=${modeIcon}
311
+ @click=${toggleMode}
312
+ style="height: 1.9rem;"
313
+ ><bim-tooltip placement="top">${modeTip}</bim-tooltip></bim-button>
314
+ </bim-toolbar-section>
315
+ <bim-toolbar-section label-hidden style="background: transparent;">
316
+ ${actions.map(
317
+ (a) => BUI.html`
318
+ <bim-button
319
+ icon=${a.icon}
320
+ @click=${() => onAction(a.run)}
321
+ style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;"
322
+ ><bim-tooltip placement="top">${`${a.label} ${modeLabel.toLowerCase()}`}</bim-tooltip></bim-button>
323
+ `,
324
+ )}
325
+ </bim-toolbar-section>
326
+ <bim-toolbar-section label-hidden style="background: transparent;">
327
+ <bim-button
328
+ icon="mdi:restore"
329
+ @click=${() => onAction(doReset)}
330
+ style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;"
331
+ ><bim-tooltip placement="top">Reset: show all + clear ghost</bim-tooltip></bim-button>
332
+ </bim-toolbar-section>
333
+ <bim-toolbar-section label-hidden style="background: transparent;">
334
+ <bim-button
335
+ icon=${isOrtho() ? "mdi:perspective-less" : "mdi:perspective-more"}
336
+ @click=${toggleProjection}
337
+ style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;"
338
+ ><bim-tooltip placement="top">${
339
+ isOrtho()
340
+ ? "Orthographic view — switch to Perspective"
341
+ : "Perspective view — switch to Orthographic"
342
+ }</bim-tooltip></bim-button>
343
+ </bim-toolbar-section>
344
+ ${walk
345
+ ? BUI.html`<bim-toolbar-section label-hidden style="background: transparent;">
346
+ <bim-button
347
+ icon="mdi:walk"
348
+ ?active=${walk.isActive()}
349
+ @click=${() => walk.toggle()}
350
+ style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;"
351
+ ><bim-tooltip placement="top">${
352
+ walk.isActive()
353
+ ? "Exit walkthrough"
354
+ : "Walkthrough — first-person navigation"
355
+ }</bim-tooltip></bim-button>
356
+ </bim-toolbar-section>`
357
+ : BUI.html``}
358
+ ${explode
359
+ ? BUI.html`<bim-toolbar-section label-hidden style="background: transparent;">
360
+ <bim-button
361
+ icon="mdi:arrow-expand-vertical"
362
+ ?active=${explode.isActive()}
363
+ @click=${() => explode.toggle()}
364
+ style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;"
365
+ ><bim-tooltip placement="top">${
366
+ explode.isActive()
367
+ ? "Collapse — un-explode"
368
+ : "Exploded view"
369
+ }</bim-tooltip></bim-button>
370
+ </bim-toolbar-section>`
371
+ : BUI.html``}
372
+ </bim-toolbar>
373
+ `;
374
+ },
375
+ { tick: 0 },
376
+ );
377
+
378
+ // Keep the walkthrough button's active state in sync (incl. auto-exit on
379
+ // model unload) without polling.
380
+ if (walk) walk.onChange(() => barUpdate({ tick: ++tick }));
381
+ if (explode) explode.onChange(() => barUpdate({ tick: ++tick }));
382
+
383
+ // ── Inspection tab (Select default · Clip · Measure length/area/angle) ──────
384
+ // A second toolbar holding the inspection tools, routed through W1's
385
+ // toolModeManager (exclusive). Built only when `inspection` actions are passed;
386
+ // the bottom bar then becomes a bim-tabs with View (visibility) + Inspect tabs.
387
+ const manager = toolModeManager(components);
388
+ let itick = 0;
389
+ const [inspectionBar, inspUpdate] = BUI.Component.create<BUI.Toolbar, { tick: number }>(
390
+ // Arity >= 1 (state param) required, else create returns a single element and
391
+ // the [inspectionBar, inspUpdate] destructure throws "object is not iterable".
392
+ (_s) => {
393
+ const selActive = manager.getActiveId() === "select";
394
+ const actionButton = (a: InspectionAction) => BUI.html`
395
+ <bim-button
396
+ icon=${a.icon}
397
+ ?active=${a.isActive()}
398
+ @click=${() => a.activate()}
399
+ style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;"
400
+ ><bim-tooltip placement="top">${a.label}</bim-tooltip></bim-button>`;
401
+ // Split the actions into a Clip section + a Measure section so the toolbar's
402
+ // section divider draws a line between the cut button and the measure tools.
403
+ const acts = inspection ?? [];
404
+ const clipActions = acts.filter((a) => a.id.startsWith("clip"));
405
+ const measureActions = acts.filter((a) => a.id.startsWith("measure"));
406
+ return BUI.html`
407
+ <bim-toolbar
408
+ style="pointer-events: auto; overflow: visible; padding: 0.15rem; box-shadow: 0 2px 8px rgba(0,0,0,0.25);"
409
+ >
410
+ <bim-toolbar-section label-hidden style="background: transparent;">
411
+ <bim-button
412
+ icon="mdi:cursor-default"
413
+ ?active=${selActive}
414
+ @click=${() => manager.selectMode()}
415
+ style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;"
416
+ ><bim-tooltip placement="top">Select</bim-tooltip></bim-button>
417
+ </bim-toolbar-section>
418
+ ${clipActions.length
419
+ ? BUI.html`<bim-toolbar-section label-hidden style="background: transparent;">
420
+ ${clipActions.map(actionButton)}
421
+ </bim-toolbar-section>`
422
+ : BUI.html``}
423
+ ${measureActions.length
424
+ ? BUI.html`<bim-toolbar-section label-hidden style="background: transparent;">
425
+ ${measureActions.map(actionButton)}
426
+ </bim-toolbar-section>`
427
+ : BUI.html``}
428
+ </bim-toolbar>`;
429
+ },
430
+ { tick: 0 },
431
+ );
432
+ // Refresh ALL button active-states (Select + tool buttons) when the active
433
+ // tool changes (W1 fires onActiveChanged through the manager).
434
+ manager.onActiveChanged.add(() => inspUpdate({ tick: ++itick }));
435
+
436
+ // The element docked in the floating grid: a tabbed bar when inspection actions
437
+ // are supplied (View = visibility, Inspect = tools), else just the bar.
438
+ const dock: HTMLElement = inspection
439
+ ? (BUI.Component.create(
440
+ () => BUI.html`
441
+ <bim-tabs floating style="pointer-events: auto;">
442
+ <bim-tab name="view" label="View" icon="mdi:eye-outline">${bar}</bim-tab>
443
+ <bim-tab name="inspect" label="Inspect" icon="mdi:cursor-default-click">${inspectionBar}</bim-tab>
444
+ </bim-tabs>`,
445
+ ) as unknown as HTMLElement)
446
+ : (bar as unknown as HTMLElement);
447
+
448
+ // ── Floating grid overlay: bar docked bottom-center, empty areas click through ──
449
+ const grid = BUI.Component.create(() => {
450
+ const onCreated = (element?: Element) => {
451
+ if (!element) return;
452
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
453
+ const g = element as any;
454
+ g.elements = { bar: dock };
455
+ g.layouts = {
456
+ main: {
457
+ template: `
458
+ "fillL fillC fillR" 1fr
459
+ "restL bar restR" auto
460
+ / 1fr auto 1fr
461
+ `,
462
+ },
463
+ };
464
+ g.layout = "main";
465
+ };
466
+ return BUI.html`
467
+ <bim-grid style="padding: 1rem;" ${BUI.ref(onCreated)} floating></bim-grid>
468
+ `;
469
+ });
470
+
471
+ // Resolve the viewport to overlay (the renderer container of the first world).
472
+ const resolveContainer = (): HTMLElement | undefined => {
473
+ if (container) return container;
474
+ const world = [...components.get(OBC.Worlds).list.values()][0] as
475
+ | { renderer?: { three?: { domElement?: HTMLElement } } }
476
+ | undefined;
477
+ const canvas = world?.renderer?.three?.domElement;
478
+ // Overlay the canvas's parent (the viewport element), so the floating grid
479
+ // sits on top of the 3D scene.
480
+ return (canvas?.parentElement as HTMLElement | undefined) ?? undefined;
481
+ };
482
+
483
+ const host = resolveContainer();
484
+ if (host) {
485
+ host.append(grid);
486
+ } else {
487
+ console.warn(
488
+ "[visibility-toolbar] no viewport found to overlay; append the returned element manually",
489
+ );
490
+ }
491
+
492
+ // Refresh the tooltips/state when the selection changes (so the Selected-mode
493
+ // action tooltips reflect whether there's anything to act on).
494
+ highlighter.events.select.onHighlight.add(() => barUpdate({ tick: ++tick }));
495
+ highlighter.events.select.onClear.add(() => barUpdate({ tick: ++tick }));
496
+
497
+ return bar;
498
+ };