@thatopen/services 0.3.10 → 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.
- package/dist/cli/commands/revit.d.ts +9 -0
- package/dist/cli/lib/revit-addin.d.ts +20 -0
- package/dist/cli.js +101 -0
- package/dist/cloud/LasToPotree.d.ts +59 -0
- package/dist/cloud/SplatToFq.d.ts +38 -0
- package/dist/cloud/index.d.ts +2 -0
- package/docs/builtin/paths.json +86 -86
- package/docs/cloud/GaussianSplatConverter.md +43 -0
- package/docs/cloud/PointCloudConverter.md +53 -0
- package/docs/revit-collab-quickstart.md +151 -0
- package/package.json +95 -95
- package/src/built-in/ClashesManager/example.ts +283 -283
- package/src/built-in/UIManager/src/clashes-panel/example.ts +160 -160
- package/src/built-in/UIManager/src/models-list/example.ts +122 -118
- package/src/cli/templates/app/README.md +217 -241
- package/src/cli/templates/app/package.json +33 -33
- package/src/cli/templates/app/src/setups/camera-tools.ts +0 -63
- package/src/cli/templates/app/src/setups/camera-views.ts +0 -201
- package/src/cli/templates/app/src/setups/card-header.ts +0 -35
- package/src/cli/templates/app/src/setups/clipper-tool.ts +0 -773
- package/src/cli/templates/app/src/setups/clipper.ts +0 -75
- package/src/cli/templates/app/src/setups/cloud-runner.ts +0 -13
- package/src/cli/templates/app/src/setups/exploded-view.ts +0 -246
- package/src/cli/templates/app/src/setups/helper-panel.ts +0 -88
- package/src/cli/templates/app/src/setups/hider.ts +0 -98
- package/src/cli/templates/app/src/setups/index.ts +0 -23
- package/src/cli/templates/app/src/setups/inspection.ts +0 -136
- package/src/cli/templates/app/src/setups/measurement-panel.ts +0 -188
- package/src/cli/templates/app/src/setups/measurement-tool.ts +0 -588
- package/src/cli/templates/app/src/setups/measurements.ts +0 -133
- package/src/cli/templates/app/src/setups/navigation-gizmo.ts +0 -316
- package/src/cli/templates/app/src/setups/plans-panel.ts +0 -619
- package/src/cli/templates/app/src/setups/right-sidebar.ts +0 -178
- package/src/cli/templates/app/src/setups/styles-panel.ts +0 -110
- package/src/cli/templates/app/src/setups/styles.ts +0 -375
- package/src/cli/templates/app/src/setups/tool-mode-manager.ts +0 -156
- package/src/cli/templates/app/src/setups/tool-mode.ts +0 -23
- package/src/cli/templates/app/src/setups/toolbar.ts +0 -229
- 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
|
-
};
|