@thatopen/services 0.3.11 → 0.3.12

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 (38) hide show
  1. package/dist/cli/commands/revit.d.ts +9 -0
  2. package/dist/cli/lib/revit-addin.d.ts +20 -0
  3. package/dist/cli.js +101 -0
  4. package/dist/cloud/LasToPotree.d.ts +59 -0
  5. package/dist/cloud/SplatToFq.d.ts +38 -0
  6. package/dist/cloud/index.d.ts +2 -0
  7. package/docs/builtin/paths.json +86 -86
  8. package/docs/cloud/GaussianSplatConverter.md +43 -0
  9. package/docs/cloud/PointCloudConverter.md +53 -0
  10. package/docs/revit-collab-quickstart.md +151 -0
  11. package/package.json +95 -95
  12. package/src/built-in/ClashesManager/example.ts +283 -283
  13. package/src/built-in/UIManager/src/clashes-panel/example.ts +160 -160
  14. package/src/built-in/UIManager/src/models-list/example.ts +122 -118
  15. package/src/cli/templates/app/package.json +33 -33
  16. package/src/cli/templates/app/src/setups/camera-tools.ts +0 -63
  17. package/src/cli/templates/app/src/setups/camera-views.ts +0 -201
  18. package/src/cli/templates/app/src/setups/card-header.ts +0 -35
  19. package/src/cli/templates/app/src/setups/clipper-tool.ts +0 -773
  20. package/src/cli/templates/app/src/setups/clipper.ts +0 -75
  21. package/src/cli/templates/app/src/setups/cloud-runner.ts +0 -13
  22. package/src/cli/templates/app/src/setups/exploded-view.ts +0 -246
  23. package/src/cli/templates/app/src/setups/helper-panel.ts +0 -88
  24. package/src/cli/templates/app/src/setups/hider.ts +0 -98
  25. package/src/cli/templates/app/src/setups/index.ts +0 -23
  26. package/src/cli/templates/app/src/setups/inspection.ts +0 -136
  27. package/src/cli/templates/app/src/setups/measurement-panel.ts +0 -188
  28. package/src/cli/templates/app/src/setups/measurement-tool.ts +0 -588
  29. package/src/cli/templates/app/src/setups/measurements.ts +0 -133
  30. package/src/cli/templates/app/src/setups/navigation-gizmo.ts +0 -316
  31. package/src/cli/templates/app/src/setups/plans-panel.ts +0 -619
  32. package/src/cli/templates/app/src/setups/right-sidebar.ts +0 -178
  33. package/src/cli/templates/app/src/setups/styles-panel.ts +0 -110
  34. package/src/cli/templates/app/src/setups/styles.ts +0 -375
  35. package/src/cli/templates/app/src/setups/tool-mode-manager.ts +0 -156
  36. package/src/cli/templates/app/src/setups/tool-mode.ts +0 -23
  37. package/src/cli/templates/app/src/setups/toolbar.ts +0 -229
  38. package/src/cli/templates/app/src/setups/ui-manager.ts +0 -27
@@ -1,619 +0,0 @@
1
- import * as THREE from "three";
2
- import * as OBC from "@thatopen/components";
3
- import * as OBF from "@thatopen/components-front";
4
- import * as BUI from "@thatopen/ui";
5
- import { ensureSectionStyle, STYLE_NAME } from "./clipper-tool";
6
-
7
- /**
8
- * FLOOR PLANS panel — 2D plan navigation per IFCBUILDINGSTOREY.
9
- *
10
- * Built on `OBC.Views`: `createFromIfcStoreys()` generates one View per storey
11
- * (an orthographic, top-down "Plan"-mode camera + a section clip plane at the
12
- * storey's elevation). Clicking a level `open()`s its view (Views snapshots the
13
- * current camera pose); "Exit to 3D" `close()`s it (Views restores the prior
14
- * orbit camera + pose). The panel just lists the levels (name + elevation) and
15
- * drives open/close. The active plan's cut is drawn FILLED + OUTLINED via
16
- * `OBF.ClipStyler.createFromView` reusing the global "Section" style (registered
17
- * idempotently by `ensureSectionStyle`), so the section reads like the 3D clipper.
18
- *
19
- * UI: vanilla BUI, native panel header (label+icon), muted section band, 1px
20
- * contrast-20 hairlines, #3C3C41 scrollbar — matching the docked-panel refactor.
21
- * Factory returns the element WITHOUT self-mounting (like graphics/files panels).
22
- *
23
- * @param components engine components
24
- */
25
-
26
- interface Level {
27
- name: string; // = the View id (Views keys storey views by Name)
28
- elevation: number; // raw IFC Elevation value (model length unit)
29
- }
30
-
31
- interface PanelState {
32
- status: "loading" | "empty" | "ready";
33
- levels: Level[];
34
- active: string | null; // name of the open plan, or null (3D)
35
- filter: string; // level-list search query (by name + elevation)
36
- viewDepth: number; // extra depth (model units) revealed BELOW the storey cut
37
- cutOffset: number; // top-cut height (model units) ABOVE the active storey floor
38
- }
39
-
40
- // View-depth slider bounds (model units). 0 = the storey's default range (only
41
- // that storey); higher reveals floors below down to the chosen depth. Defaults
42
- // to 30 below the cut on open.
43
- const VIEW_DEPTH_MAX = 100;
44
- const VIEW_DEPTH_STEP = 0.5;
45
- const VIEW_DEPTH_DEFAULT = 30;
46
-
47
- // Cut-plane offset (top cut, model units above the storey floor). The slider
48
- // DEFAULTS to 1.5m above each floor; CREATION_OFFSET is the createFromIfcStoreys
49
- // offset the captured base plane reflects (the formula raises the cut from there).
50
- const CUT_OFFSET_DEFAULT = 1.5;
51
- const CREATION_OFFSET = 0.25;
52
- const CUT_OFFSET_MAX = 3;
53
- const CUT_OFFSET_STEP = 0.05;
54
-
55
- // Margin fraction added around the model AABB when framing a plan (breathing room).
56
- const PLAN_FIT_MARGIN = 0.06;
57
-
58
- const elevText = (e: number) => {
59
- if (!Number.isFinite(e)) return "";
60
- return `${e >= 0 ? "+" : ""}${e.toFixed(2)}`;
61
- };
62
-
63
- export const plansPanel = (components: OBC.Components) => {
64
- const fragments = components.get(OBC.FragmentsManager);
65
- const views = components.get(OBC.Views);
66
- const styler = components.get(OBF.ClipStyler);
67
- // No camera animations in the plans path: Views' close-restore otherwise
68
- // tweens the 3D camera back via fromJSON(json, true). The default 3D camera is
69
- // untouched during a plan (we drive the plan camera), so disabling the restore
70
- // means close() just snaps back to the default camera's existing pose — instant.
71
- views.restoreCameraOnClose = false;
72
-
73
- let rebuildToken = 0;
74
- let searchTimer: number | undefined;
75
-
76
- const getWorld = () =>
77
- ([...components.get(OBC.Worlds).list.values()][0] as OBC.World) ?? null;
78
-
79
- // ── Shared 2D camera memory (session, in-memory) ───────────────
80
- // ONE shared plan-view camera state (toJSON: position + target + ortho zoom)
81
- // across ALL floor plans. The FIRST time the user enters plan mode there's no
82
- // snapshot → fit-to-plan. After that, switching between levels KEEPS the same
83
- // XY pan + zoom (only each level's own section-clip height changes); restoring
84
- // the shared pose re-applies pan/zoom while Views' per-level clip drives the cut.
85
- let sharedPlanCam: string | null = null;
86
- let currentLevel: string | null = null;
87
- let restAttached = false;
88
-
89
- const capturePlanCam = () => {
90
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
91
- const c = getWorld()?.camera?.controls as any;
92
- if (c && typeof c.toJSON === "function") {
93
- try {
94
- sharedPlanCam = c.toJSON();
95
- } catch {
96
- /* controls without (de)serialization — memory just no-ops */
97
- }
98
- }
99
- };
100
- const restorePlanCam = (): boolean => {
101
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
- const c = getWorld()?.camera?.controls as any;
103
- if (c && sharedPlanCam && typeof c.fromJSON === "function") {
104
- try {
105
- c.fromJSON(sharedPlanCam, false); // INSTANT restore of XY + zoom (no tween)
106
- return true;
107
- } catch {
108
- return false;
109
- }
110
- }
111
- return false;
112
- };
113
- // Keep the shared 2D pose current as the user pans/zooms within ANY plan —
114
- // camera-controls 'rest' fires once motion settles. Attached lazily, once.
115
- const ensureRestListener = () => {
116
- if (restAttached) return;
117
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
118
- const c = getWorld()?.camera?.controls as any;
119
- if (!c?.addEventListener) return;
120
- c.addEventListener("rest", () => {
121
- if (currentLevel) capturePlanCam();
122
- });
123
- restAttached = true;
124
- };
125
-
126
- // Fit the (now active) plan camera to the model AABB — fitToBox frames the
127
- // box's footprint TIGHTLY in the ortho top-down view (the bounding SPHERE used
128
- // before over-encloses ~1.7× and lands too far). Instant (no fly-in).
129
- const fitActive = async () => {
130
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
131
- const controls = getWorld()?.camera?.controls as any;
132
- if (!controls?.fitToBox) return;
133
- const box = new THREE.Box3();
134
- for (const [, model] of fragments.list) {
135
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
136
- const b = (model as any).box as THREE.Box3 | undefined;
137
- if (b && !b.isEmpty()) box.union(b);
138
- }
139
- if (box.isEmpty()) return;
140
- const size = box.getSize(new THREE.Vector3());
141
- box.expandByScalar(Math.max(size.x, size.y, size.z) * PLAN_FIT_MARGIN); // margin
142
- await controls.fitToBox(box, false); // INSTANT, AABB-tight
143
- };
144
-
145
- // The postproduction renderer (lazy — the pipeline allocates once it's up).
146
- const getPostproduction = () => {
147
- const r = getWorld()?.renderer as OBF.PostproductionRenderer | undefined;
148
- return r?.postproduction ?? null;
149
- };
150
-
151
- // Plan-mode view tweaks, snapshotted on the FIRST entry (3D → plan) and
152
- // restored on exit (plan → 3D), so 3D mode is left exactly as it was. Direct
153
- // plan→plan switches keep the snapshot.
154
- let inPlan = false;
155
- let savedAnchor: boolean | null = null;
156
- let savedStyle: OBF.PostproductionAspect | null = null;
157
-
158
- // Tame the plan-mode wheel zoom. The PLAN camera is ORTHOGRAPHIC, so the wheel
159
- // maps to the ZOOM action — governed by camera-controls' `zoomSpeed` (scales
160
- // camera.zoom), NOT `dollySpeed` (that's perspective dolly only). Views may use
161
- // a SEPARATE camera-controls instance per plan, so we tame whatever controls is
162
- // ACTIVE after views.open() (tameZoom is called there), tracking each instance
163
- // we touch so every one is restored on exit.
164
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
165
- const tamedControls = new Map<any, { zoom: number; dolly: number }>();
166
- const PLAN_ZOOM_SPEED = 1.0; // snappy wheel zoom (the View defaults dollySpeed=6)
167
- const tameZoom = () => {
168
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
169
- const c = getWorld()?.camera?.controls as any;
170
- if (!c || tamedControls.has(c)) return;
171
- tamedControls.set(c, {
172
- zoom: typeof c.zoomSpeed === "number" ? c.zoomSpeed : NaN,
173
- dolly: typeof c.dollySpeed === "number" ? c.dollySpeed : NaN,
174
- });
175
- if (typeof c.zoomSpeed === "number") c.zoomSpeed = PLAN_ZOOM_SPEED;
176
- if (typeof c.dollySpeed === "number") c.dollySpeed = PLAN_ZOOM_SPEED; // belt + braces
177
- };
178
- const untameZoom = () => {
179
- for (const [c, orig] of tamedControls) {
180
- if (!Number.isNaN(orig.zoom)) c.zoomSpeed = orig.zoom;
181
- if (!Number.isNaN(orig.dolly)) c.dollySpeed = orig.dolly;
182
- }
183
- tamedControls.clear();
184
- };
185
-
186
- const applyPlanLook = () => {
187
- if (inPlan) return;
188
- inPlan = true;
189
- // Hide the dynamic-anchor pivot dot in 2D (the anchor dot only renders off
190
- // world.onDynamicAnchorSet, so disabling dynamicAnchor suppresses it).
191
- const world = getWorld();
192
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
193
- const w = world as any;
194
- if (w && typeof w.dynamicAnchor === "boolean") {
195
- savedAnchor = w.dynamicAnchor;
196
- w.dynamicAnchor = false;
197
- }
198
- // True PEN style for plans — line drawing, no surface colour (snapshot the
199
- // live style to restore on exit).
200
- const pp = getPostproduction();
201
- if (pp) {
202
- savedStyle = pp.style;
203
- pp.style = OBF.PostproductionAspect.PEN;
204
- }
205
- };
206
-
207
- const restorePlanLook = () => {
208
- if (!inPlan) return;
209
- inPlan = false;
210
- untameZoom(); // restore every plan-controls instance's zoom/dolly speed
211
- const world = getWorld();
212
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
213
- const w = world as any;
214
- if (w && savedAnchor !== null) w.dynamicAnchor = savedAnchor;
215
- const pp = getPostproduction();
216
- if (pp && savedStyle !== null) pp.style = savedStyle;
217
- savedAnchor = null;
218
- savedStyle = null;
219
- };
220
-
221
- // ── View depth (Revit "view depth"): extend the lower clip below the storey ──
222
- // Each storey View has a top cut `plane` (the section height) and a `range` =
223
- // how far BELOW the cut is shown (its `farPlane`). depth 0 keeps the storey's
224
- // default range; raising it pushes the far plane down to reveal lower floors.
225
- let viewDepth = VIEW_DEPTH_DEFAULT; // model units below the cut (mirrors state.viewDepth)
226
- // PER-LEVEL pristine default range. Was a single shared value captured from the
227
- // first level visited and then applied to ALL levels — wrong, since each storey
228
- // view has its own natural range (distance below its cut), so upper storeys got
229
- // a bad slab and showed nothing. Capture+restore per level.
230
- const baseRange = new Map<string, number>();
231
- const applyViewDepth = () => {
232
- if (!currentLevel) return;
233
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
234
- const view = views.list.get(currentLevel) as any;
235
- if (!view || typeof view.range !== "number") return;
236
- if (!baseRange.has(currentLevel)) baseRange.set(currentLevel, view.range as number);
237
- view.range = baseRange.get(currentLevel)! + viewDepth;
238
- if (typeof view.update === "function") view.update();
239
- };
240
- // The view-depth slider works where the open path didn't, even though both set
241
- // the SAME range — the difference was the slider ALSO re-rendered the panel
242
- // (update), which is what actually commits the new slab visually. Factor that
243
- // whole body into one function so OPEN and the SLIDER fire the identical commit.
244
- const applyDepthAndCommit = () => {
245
- applyViewDepth();
246
- refreshPlanSection();
247
- update({ viewDepth });
248
- };
249
-
250
- // ── Cut-plane offset (top cut, model units above the floor) — PER PLAN ──────
251
- // The View's `distance` = its cut plane's `constant`. Raising the cut by Δ above
252
- // the floor changes the constant by −normal.y·Δ, measured from CREATION_OFFSET
253
- // (the offset the captured base plane reflects). Default cut height is 1.5m.
254
- const cutOffsets = new Map<string, number>(); // level → offset above floor
255
- const baseCut = new Map<string, number>(); // level → pristine plane.constant
256
- const applyCutOffset = (level: string) => {
257
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
258
- const view = views.list.get(level) as any;
259
- if (!view || typeof view.distance !== "number") return;
260
- if (!baseCut.has(level)) baseCut.set(level, view.distance as number);
261
- const off = cutOffsets.get(level) ?? CUT_OFFSET_DEFAULT;
262
- const ny = view.plane?.normal?.y ?? 1;
263
- view.distance = baseCut.get(level)! - ny * (off - CREATION_OFFSET);
264
- // (the distance setter calls view.update internally)
265
- };
266
-
267
- // ── Plan section fill + edges (OBF.ClipStyler) ─────────────────
268
- // Draw the global "Section" style (fill + outline) at the active plan's cut
269
- // plane via createFromView, so the cut reads filled + outlined like the 3D
270
- // clipper. One ClipEdges at a time (only one plan open); rebuilt on switch and
271
- // disposed on exit. ensureSectionStyle registers "Section" idempotently so we
272
- // don't depend on the clipper tool having been constructed.
273
- let planEdges: OBF.ClipEdges | null = null;
274
- let planEdgesId: string | null = null;
275
- const disposePlanSection = () => {
276
- if (planEdgesId && styler.list.has(planEdgesId)) styler.list.delete(planEdgesId); // DataMap delete disposes
277
- planEdges = null;
278
- planEdgesId = null;
279
- };
280
- const buildPlanSection = (name: string) => {
281
- disposePlanSection();
282
- try {
283
- ensureSectionStyle(components);
284
- const world = getWorld();
285
- styler.world = world;
286
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
287
- const view = views.list.get(name) as any;
288
- if (!view) return;
289
- const id = `plan-section:${name}`;
290
- planEdges = styler.createFromView(view, {
291
- id,
292
- items: { All: { style: STYLE_NAME } }, // no `data` → style ALL cut geometry
293
- link: true, // auto-update/visibility-sync with the view
294
- world: world ?? undefined,
295
- });
296
- planEdgesId = id;
297
- // createFromView (unlike createFromClipping) does NOT set edges.visible —
298
- // and the `visible` setter is what ADDS the group to the scene. The view is
299
- // already open here, so the view.onStateChanged→visible link already fired
300
- // before our ClipEdges existed; set it explicitly so renderOverlay can find
301
- // the section group. Then force a rebuild at the current plane.
302
- planEdges.visible = true;
303
- void Promise.resolve(planEdges.update()).catch((error) =>
304
- console.warn("[plans-panel] section build skipped:", error),
305
- );
306
- } catch (error) {
307
- console.warn("[plans-panel] failed to build plan section", name, error);
308
- }
309
- };
310
- // Redraw the section after the cut plane moves (cut-height / view-depth change).
311
- const refreshPlanSection = () => {
312
- if (!planEdges) return;
313
- void Promise.resolve(planEdges.update()).catch(() => {});
314
- };
315
-
316
- const enterPlan = async (name: string) => {
317
- // Guard: never silently no-op on a level whose view is missing — that's the
318
- // "can't navigate to some levels" symptom. The rebuild reconcile should have
319
- // created it; if not, surface it instead of failing quietly.
320
- if (!views.list.has(name)) {
321
- console.warn("[plans-panel] no plan view for level:", name, "— cannot open");
322
- return;
323
- }
324
- try {
325
- // Capture the current 2D pan/zoom before switching, so it carries over.
326
- if (currentLevel && currentLevel !== name) capturePlanCam();
327
- applyPlanLook(); // anchor off + pen style (snapshot on first entry)
328
- ensureRestListener();
329
- views.open(name); // sets this level's section-clip height
330
- tameZoom(); // NOW the plan camera's controls is active → tame its wheel zoom
331
- currentLevel = name;
332
- buildPlanSection(name); // create the Section edges (positioned in the settle below)
333
- update({ active: name, cutOffset: cutOffsets.get(name) ?? CUT_OFFSET_DEFAULT });
334
- // Apply the per-level SLAB (range + cut), CAMERA framing, and SECTION refresh
335
- // AFTER the view settles (two frames). Doing them synchronously on open hits
336
- // a not-yet-ready view/camera: the per-level range applies to a stale view
337
- // (wrong slab → blank plan) and fitToBox no-ops on the un-sized camera. This
338
- // is exactly the timing the view-depth slider hit — nudging it "fixed" every
339
- // dead level — now run on open for every level.
340
- requestAnimationFrame(() =>
341
- requestAnimationFrame(async () => {
342
- if (currentLevel !== name) return; // user moved on
343
- applyCutOffset(name); // per-level top cut
344
- if (sharedPlanCam) {
345
- restorePlanCam(); // subsequent visits → restore the shared 2D pan/zoom
346
- } else {
347
- await fitActive(); // first visit → frame now that the camera is sized
348
- const c = getWorld()?.camera?.controls as unknown as { update?: (d: number) => void };
349
- try {
350
- c?.update?.(0); // apply the fit before capturing the shared pose
351
- } catch {
352
- /* some builds reject delta 0 */
353
- }
354
- capturePlanCam(); // seed the shared 2D pose from the real fit
355
- }
356
- // Apply depth AND fire the panel update that COMMITS the slab — the exact
357
- // path the view-depth slider runs (range alone, without the commit, never
358
- // took on open). This makes every level correct on first open, no nudge.
359
- applyDepthAndCommit();
360
- }),
361
- );
362
- } catch (error) {
363
- console.warn("[plans-panel] failed to open plan", name, error);
364
- }
365
- };
366
-
367
- const exitPlan = () => {
368
- capturePlanCam(); // remember the shared 2D pan/zoom for the next plan visit
369
- currentLevel = null;
370
- disposePlanSection(); // drop the plan's section fill + edges
371
- if (!views.hasOpenViews) {
372
- restorePlanLook();
373
- update({ active: null });
374
- return;
375
- }
376
- views.close(); // restores the prior orbit camera + pose
377
- restorePlanLook(); // anchor + postproduction style back to the 3D snapshot
378
- update({ active: null });
379
- };
380
-
381
- // ── Build the storey list + register the storey views ──────────
382
- const rebuild = async () => {
383
- const token = ++rebuildToken;
384
- // The model set changed → the remembered 2D pose + clip baselines are stale.
385
- sharedPlanCam = null;
386
- currentLevel = null;
387
- baseRange.clear();
388
- baseCut.clear();
389
- cutOffsets.clear();
390
- disposePlanSection(); // views are about to be cleared/regenerated
391
- const world = getWorld();
392
- const models = [...fragments.list.values()];
393
- if (!world || models.length === 0) {
394
- views.list.clear();
395
- if (token === rebuildToken) update({ status: "empty", levels: [], active: null });
396
- return;
397
- }
398
- update({ status: "loading" });
399
- try {
400
- views.world = world;
401
- views.list.clear(); // drop stale views before regenerating
402
- await views.createFromIfcStoreys({ world });
403
-
404
- const levels: Level[] = [];
405
- const seenNames = new Set<string>(); // views.list is keyed by Name → dedupe
406
- for (const [, model] of fragments.list) {
407
- const ids = Object.values(
408
- await model.getItemsOfCategories([/BUILDINGSTOREY/]),
409
- ).flat();
410
- if (ids.length === 0) continue;
411
- const data = await model.getItemsData(ids, {
412
- attributesDefault: false,
413
- attributes: ["Name", "Elevation"],
414
- });
415
- // RTC coordinate height (same term createFromIfcStoreys uses to place the
416
- // plane), so a reconciled view sits at the right elevation.
417
- let coordHeight = 0;
418
- try {
419
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
420
- const coords = (await (model as any).getCoordinates?.()) as any;
421
- if (Array.isArray(coords)) coordHeight = Number(coords[1]) || 0;
422
- } catch {
423
- /* no coordinates → assume 0 */
424
- }
425
- for (const d of data) {
426
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
427
- const nm = (d as any)?.Name;
428
- if (!(nm && "value" in nm && nm.value != null)) continue;
429
- const name = String(nm.value);
430
- if (seenNames.has(name)) continue; // duplicate storey name → one view only
431
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
432
- const el = (d as any)?.Elevation;
433
- const elevation =
434
- el && "value" in el && el.value != null ? Number(el.value) : NaN;
435
- // RECONCILE: createFromIfcStoreys SKIPS storeys whose Elevation value is
436
- // missing, so they get no view and the level is unreachable. If we know
437
- // the elevation, create the missing plan view here (same plane math as
438
- // the library: normal (0,-1,0), height = elevation + coordHeight + offset).
439
- if (!views.list.has(name) && Number.isFinite(elevation)) {
440
- try {
441
- const plane = new THREE.Plane(
442
- new THREE.Vector3(0, -1, 0),
443
- elevation + coordHeight + CREATION_OFFSET,
444
- );
445
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
446
- (views as any).createFromPlane(plane, { id: name, world });
447
- } catch (e) {
448
- console.warn("[plans-panel] could not create plan view for", name, e);
449
- }
450
- }
451
- if (!views.list.has(name)) {
452
- // Still no view (e.g. no elevation at all) → don't list a dead button.
453
- console.warn(
454
- "[plans-panel] level has no plan view, skipping:",
455
- name,
456
- "elevation:",
457
- elevation,
458
- );
459
- continue;
460
- }
461
- seenNames.add(name);
462
- levels.push({ name, elevation });
463
- }
464
- }
465
- // Top floor first (NaN elevations sort to the bottom).
466
- levels.sort((a, b) => (b.elevation || 0) - (a.elevation || 0));
467
- if (token !== rebuildToken) return;
468
- update({ status: levels.length > 0 ? "ready" : "empty", levels, active: null });
469
- } catch (error) {
470
- if (token !== rebuildToken) return;
471
- console.warn("[plans-panel] failed to build floor plans", error);
472
- update({ status: "empty", levels: [], active: null });
473
- }
474
- };
475
-
476
- const [panel, update] = BUI.Component.create<BUI.Panel, PanelState>(
477
- (state) => {
478
- const levelRow = (lvl: Level) => BUI.html`
479
- <div
480
- class="pl-row ${state.active === lvl.name ? "active" : ""}"
481
- @click=${() => enterPlan(lvl.name)}
482
- title=${lvl.name}
483
- >
484
- <bim-icon class="pl-ico" icon="mdi:layers"></bim-icon>
485
- <span class="pl-name">${lvl.name}</span>
486
- <span class="pl-elev">${elevText(lvl.elevation)}</span>
487
- </div>
488
- `;
489
-
490
- // Filter levels by name + elevation text (the searchbar above the list).
491
- const q = state.filter.trim().toLowerCase();
492
- const shown = q
493
- ? state.levels.filter(
494
- (l) => l.name.toLowerCase().includes(q) || elevText(l.elevation).toLowerCase().includes(q),
495
- )
496
- : state.levels;
497
-
498
- return BUI.html`
499
- <bim-panel
500
- label="Floor Plans"
501
- icon="mdi:floor-plan"
502
- style="width: 100%; height: 100%; pointer-events: auto;"
503
- >
504
- <style>
505
- .pl-vp::-webkit-scrollbar { width: 0.4rem; height: 0.4rem; }
506
- .pl-vp::-webkit-scrollbar-thumb { border-radius: 0.25rem; background-color: var(--bim-scrollbar--c, #3C3C41); }
507
- .pl-vp::-webkit-scrollbar-track { background-color: var(--bim-scrollbar--bgc, var(--bim-ui_bg-contrast-10)); }
508
- .pl-row { box-sizing: border-box; min-height: 30px; display: flex;
509
- align-items: center; gap: 0.45rem; cursor: pointer;
510
- padding: 0.3rem 0.6rem 0.3rem 1.1rem; font-size: 0.78rem;
511
- color: var(--bim-ui_bg-contrast-100, #e3e3e3);
512
- border-bottom: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1)); }
513
- .pl-row:hover { background: var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.08)); }
514
- .pl-row.active { background: var(--bim-ui_accent-base, #6528d7); color: #fff; }
515
- .pl-row.active .pl-ico, .pl-row.active .pl-elev { color: #fff; opacity: 0.9; }
516
- .pl-ico { flex: 0 0 auto; color: var(--bim-ui_bg-contrast-60, #99a0ae); font-size: 0.95rem; }
517
- .pl-name { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
518
- .pl-elev { flex: 0 0 auto; opacity: 0.55; font-variant-numeric: tabular-nums; }
519
- </style>
520
- <div style="display: flex; flex-direction: column; height: 100%; width: 100%;">
521
- <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.4rem;">
522
- <!-- Searchbar + inline back-to-3D icon (mirrors the files panel row). -->
523
- <div style="display: flex; align-items: stretch; gap: 0.4rem; flex: 0 0 auto;">
524
- <bim-text-input
525
- icon="mdi:magnify"
526
- icon-inside
527
- placeholder="Filter levels…"
528
- .value=${state.filter}
529
- style="flex: 1 1 auto;"
530
- @input=${(e: Event) => {
531
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
532
- const v = String((e.target as any).value ?? "");
533
- if (searchTimer !== undefined) clearTimeout(searchTimer);
534
- searchTimer = window.setTimeout(() => update({ filter: v }), 200);
535
- }}
536
- ></bim-text-input>
537
- <bim-button
538
- icon="mdi:cube-outline"
539
- ?disabled=${!state.active}
540
- @click=${exitPlan}
541
- style="flex: 0 0 auto;"
542
- ><bim-tooltip>Back to 3D</bim-tooltip></bim-button>
543
- </div>
544
- <!-- View depth: how far below the storey cut is visible. Only while
545
- a plan is open. -->
546
- ${state.active
547
- ? BUI.html`
548
- <div style="display: flex; align-items: center; gap: 0.5rem; flex: 0 0 auto; font-size: 0.72rem; color: var(--bim-ui_bg-contrast-70, #a7a7ab);">
549
- <span style="white-space: nowrap;"><bim-icon icon="mdi:arrow-expand-down" style="vertical-align: -0.1rem;"></bim-icon> View depth</span>
550
- <bim-number-input
551
- style="flex: 1 1 auto;"
552
- slider
553
- .value=${state.viewDepth}
554
- min="0"
555
- max=${VIEW_DEPTH_MAX}
556
- step=${VIEW_DEPTH_STEP}
557
- @change=${(e: Event) => {
558
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
559
- const v = Number((e.target as any).value);
560
- if (!Number.isFinite(v)) return;
561
- viewDepth = Math.max(0, v);
562
- applyDepthAndCommit(); // applyViewDepth + refreshPlanSection + update
563
- }}
564
- ></bim-number-input>
565
- </div>
566
- <!-- Cut height: how far ABOVE the floor the horizontal cut is taken (per plan). -->
567
- <div style="display: flex; align-items: center; gap: 0.5rem; flex: 0 0 auto; font-size: 0.72rem; color: var(--bim-ui_bg-contrast-70, #a7a7ab);">
568
- <span style="white-space: nowrap;"><bim-icon icon="mdi:content-cut" style="vertical-align: -0.1rem;"></bim-icon> Cut height</span>
569
- <bim-number-input
570
- style="flex: 1 1 auto;"
571
- slider
572
- .value=${state.cutOffset}
573
- min="0"
574
- max=${CUT_OFFSET_MAX}
575
- step=${CUT_OFFSET_STEP}
576
- @change=${(e: Event) => {
577
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
578
- const v = Number((e.target as any).value);
579
- if (!Number.isFinite(v) || !currentLevel) return;
580
- cutOffsets.set(currentLevel, Math.max(0, v));
581
- applyCutOffset(currentLevel);
582
- refreshPlanSection(); // the cut plane moved
583
- update({ cutOffset: Math.max(0, v) });
584
- }}
585
- ></bim-number-input>
586
- </div>`
587
- : null}
588
- <div
589
- class="pl-vp"
590
- style="flex: 1 1 auto; min-height: 0; overflow-y: auto; margin-left: -1.1rem; margin-right: -0.4rem;"
591
- >
592
- ${state.status === "loading"
593
- ? BUI.html`<div style="padding: 0.6rem 1.1rem;"><bim-label style="opacity: 0.6;">Loading…</bim-label></div>`
594
- : state.status === "empty"
595
- ? BUI.html`<div style="padding: 0.6rem 1.1rem;"><bim-label style="opacity: 0.6; white-space: normal;">No storeys found. Load a model with IFC building storeys.</bim-label></div>`
596
- : shown.length === 0
597
- ? BUI.html`<div style="padding: 0.6rem 1.1rem;"><bim-label style="opacity: 0.6; white-space: normal;">No levels match "${state.filter}".</bim-label></div>`
598
- : BUI.html`${shown.map(levelRow)}`}
599
- </div>
600
- </div>
601
- </div>
602
- </bim-panel>
603
- `;
604
- },
605
- { status: "loading", levels: [], active: null, filter: "", viewDepth: VIEW_DEPTH_DEFAULT, cutOffset: CUT_OFFSET_DEFAULT },
606
- );
607
-
608
- // ── Triggers ───────────────────────────────────────────────────
609
- fragments.core.onModelLoaded.add(() => rebuild());
610
- fragments.list.onItemDeleted.add(() => {
611
- if (views.hasOpenViews) views.close();
612
- disposePlanSection(); // drop section edges tied to the now-stale views
613
- restorePlanLook(); // anchor + postproduction style back to 3D
614
- rebuild();
615
- });
616
- rebuild();
617
-
618
- return panel;
619
- };