From 9a975515bfb45ddce15986a47f0c706f1c58b10c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20Gonz=C3=A1lez=20Viegas?= Date: Thu, 18 Jun 2026 02:37:54 +0200 Subject: [PATCH 1/3] feat(template): make app template the full BIM viewer (deferred pipeline + tools) Port _platform/bim-viewer into templates/app: 59 setups (clipper, measurements, model tree, properties, files, plans, exploded view, walkthrough, reality-capture, etc.) on stable specifiers so create --beta/swap alias them to the betas (the viewer relies on beta-only render: deferred PEN, FastModelPicker). Added spark/3d-tiles-renderer/camera-controls/web-ifc deps; stripped *.bak + the temp diagnostics profiler. NOT yet build-verified against the betas. Co-Authored-By: Claude Opus 4.8 --- src/cli/templates/app/index.html | 8 + src/cli/templates/app/package.json | 6 +- src/cli/templates/app/src/app.ts | 15 + .../app/src/assets/tool-placeholder.ts | 50 + .../src/bim-components/CloudRunner/index.ts | 22 +- src/cli/templates/app/src/globals.ts | 1 + src/cli/templates/app/src/main.ts | 372 ++- .../app/src/setups/active-tool-hud.ts | 69 + .../templates/app/src/setups/camera-tools.ts | 63 + .../templates/app/src/setups/camera-views.ts | 201 ++ .../templates/app/src/setups/card-header.ts | 35 + .../templates/app/src/setups/clipper-panel.ts | 167 ++ .../templates/app/src/setups/clipper-tool.ts | 740 ++++++ src/cli/templates/app/src/setups/clipper.ts | 75 + .../templates/app/src/setups/cloud-runner.ts | 12 +- .../app/src/setups/commands-panel.ts | 239 ++ .../app/src/setups/data-table-panel.ts | 2224 +++++++++++++++++ .../templates/app/src/setups/exploded-view.ts | 246 ++ .../app/src/setups/file-format-icons.ts | 95 + .../templates/app/src/setups/files-panel.ts | 1503 +++++++++++ .../templates/app/src/setups/fps-indicator.ts | 62 + src/cli/templates/app/src/setups/fragments.ts | 18 - .../app/src/setups/graphics-panel.ts | 490 ++++ .../templates/app/src/setups/helper-panel.ts | 88 + src/cli/templates/app/src/setups/hider.ts | 98 + .../templates/app/src/setups/ifc-loader.ts | 12 - src/cli/templates/app/src/setups/index.ts | 35 +- .../templates/app/src/setups/inspection.ts | 136 + .../app/src/setups/measurement-panel.ts | 188 ++ .../src/setups/measurement-settings-panel.ts | 161 ++ .../app/src/setups/measurement-tool.ts | 570 +++++ .../templates/app/src/setups/measurements.ts | 133 + .../templates/app/src/setups/model-tree.ts | 797 ++++++ .../app/src/setups/navigation-gizmo.ts | 316 +++ .../templates/app/src/setups/objects-panel.ts | 127 + .../templates/app/src/setups/plans-panel.ts | 619 +++++ .../app/src/setups/properties-panel.ts | 1048 ++++++++ .../app/src/setups/reality-capture-viewer.ts | 1383 ++++++++++ .../lib/hidden-tiles-plugin.ts | 88 + .../reality-capture/lib/point-tile-plugin.ts | 166 ++ .../reality-capture/lib/render-frame.ts | 31 + .../reality-capture/lib/splat-tile-plugin.ts | 51 + .../templates/app/src/setups/right-sidebar.ts | 178 ++ .../app/src/setups/settings-panel.ts | 94 + .../templates/app/src/setups/styles-panel.ts | 110 + src/cli/templates/app/src/setups/styles.ts | 373 +++ .../app/src/setups/tool-mode-manager.ts | 150 ++ src/cli/templates/app/src/setups/tool-mode.ts | 23 + src/cli/templates/app/src/setups/toolbar.ts | 229 ++ .../templates/app/src/setups/ui-manager.ts | 27 + .../app/src/setups/viewports-manager.ts | 280 +++ .../app/src/setups/visibility-toolbar.ts | 498 ++++ .../templates/app/src/setups/walkthrough.ts | 528 ++++ .../app/src/ui-components/AppPanel/index.ts | 160 -- .../ui-components/app-info-section/index.ts | 26 + .../app-info-section/src/index.ts | 1 + .../app-info-section/src/types.ts | 15 + .../cloud-runner-section/index.ts | 37 + .../cloud-runner-section/src/index.ts | 1 + .../cloud-runner-section/src/types.ts | 14 + .../templates/app/src/ui-components/index.ts | 3 +- src/cli/templates/app/tsconfig.json | 3 +- src/cli/templates/app/vite.config.js | 19 - 63 files changed, 15264 insertions(+), 265 deletions(-) create mode 100644 src/cli/templates/app/src/app.ts create mode 100644 src/cli/templates/app/src/assets/tool-placeholder.ts create mode 100644 src/cli/templates/app/src/globals.ts create mode 100644 src/cli/templates/app/src/setups/active-tool-hud.ts create mode 100644 src/cli/templates/app/src/setups/camera-tools.ts create mode 100644 src/cli/templates/app/src/setups/camera-views.ts create mode 100644 src/cli/templates/app/src/setups/card-header.ts create mode 100644 src/cli/templates/app/src/setups/clipper-panel.ts create mode 100644 src/cli/templates/app/src/setups/clipper-tool.ts create mode 100644 src/cli/templates/app/src/setups/clipper.ts create mode 100644 src/cli/templates/app/src/setups/commands-panel.ts create mode 100644 src/cli/templates/app/src/setups/data-table-panel.ts create mode 100644 src/cli/templates/app/src/setups/exploded-view.ts create mode 100644 src/cli/templates/app/src/setups/file-format-icons.ts create mode 100644 src/cli/templates/app/src/setups/files-panel.ts create mode 100644 src/cli/templates/app/src/setups/fps-indicator.ts delete mode 100644 src/cli/templates/app/src/setups/fragments.ts create mode 100644 src/cli/templates/app/src/setups/graphics-panel.ts create mode 100644 src/cli/templates/app/src/setups/helper-panel.ts create mode 100644 src/cli/templates/app/src/setups/hider.ts delete mode 100644 src/cli/templates/app/src/setups/ifc-loader.ts create mode 100644 src/cli/templates/app/src/setups/inspection.ts create mode 100644 src/cli/templates/app/src/setups/measurement-panel.ts create mode 100644 src/cli/templates/app/src/setups/measurement-settings-panel.ts create mode 100644 src/cli/templates/app/src/setups/measurement-tool.ts create mode 100644 src/cli/templates/app/src/setups/measurements.ts create mode 100644 src/cli/templates/app/src/setups/model-tree.ts create mode 100644 src/cli/templates/app/src/setups/navigation-gizmo.ts create mode 100644 src/cli/templates/app/src/setups/objects-panel.ts create mode 100644 src/cli/templates/app/src/setups/plans-panel.ts create mode 100644 src/cli/templates/app/src/setups/properties-panel.ts create mode 100644 src/cli/templates/app/src/setups/reality-capture-viewer.ts create mode 100644 src/cli/templates/app/src/setups/reality-capture/lib/hidden-tiles-plugin.ts create mode 100644 src/cli/templates/app/src/setups/reality-capture/lib/point-tile-plugin.ts create mode 100644 src/cli/templates/app/src/setups/reality-capture/lib/render-frame.ts create mode 100644 src/cli/templates/app/src/setups/reality-capture/lib/splat-tile-plugin.ts create mode 100644 src/cli/templates/app/src/setups/right-sidebar.ts create mode 100644 src/cli/templates/app/src/setups/settings-panel.ts create mode 100644 src/cli/templates/app/src/setups/styles-panel.ts create mode 100644 src/cli/templates/app/src/setups/styles.ts create mode 100644 src/cli/templates/app/src/setups/tool-mode-manager.ts create mode 100644 src/cli/templates/app/src/setups/tool-mode.ts create mode 100644 src/cli/templates/app/src/setups/toolbar.ts create mode 100644 src/cli/templates/app/src/setups/ui-manager.ts create mode 100644 src/cli/templates/app/src/setups/viewports-manager.ts create mode 100644 src/cli/templates/app/src/setups/visibility-toolbar.ts create mode 100644 src/cli/templates/app/src/setups/walkthrough.ts delete mode 100644 src/cli/templates/app/src/ui-components/AppPanel/index.ts create mode 100644 src/cli/templates/app/src/ui-components/app-info-section/index.ts create mode 100644 src/cli/templates/app/src/ui-components/app-info-section/src/index.ts create mode 100644 src/cli/templates/app/src/ui-components/app-info-section/src/types.ts create mode 100644 src/cli/templates/app/src/ui-components/cloud-runner-section/index.ts create mode 100644 src/cli/templates/app/src/ui-components/cloud-runner-section/src/index.ts create mode 100644 src/cli/templates/app/src/ui-components/cloud-runner-section/src/types.ts diff --git a/src/cli/templates/app/index.html b/src/cli/templates/app/index.html index ea77511..7819bd5 100644 --- a/src/cli/templates/app/index.html +++ b/src/cli/templates/app/index.html @@ -8,6 +8,14 @@ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } html, body { width: 100%; height: 100%; overflow: hidden; font-family: system-ui, -apple-system, sans-serif; } #that-open-app { width: 100%; height: 100%; } + /* Scrollbar rail = the panel surface (#262629). Every panel (app .xxx-vp + tracks) and every bim-* shadow-DOM scrollbar reads var(--bim-scrollbar--bgc, + …), so defining it once here makes all rails match the surface instead of + falling back to the lighter bg-contrast-40 (#5F5F64). + NOTE: must use --bim-ui_gray-1 (defined UNCONDITIONALLY at :root = #262629), + not --bim-ui_bg-contrast-* (only defined under @media/theme-class scopes, so + it doesn't resolve at :root and left --bim-scrollbar--bgc unset). */ + :root { --bim-scrollbar--bgc: var(--bim-ui_gray-1, #262629); } diff --git a/src/cli/templates/app/package.json b/src/cli/templates/app/package.json index ee06720..dca07d7 100644 --- a/src/cli/templates/app/package.json +++ b/src/cli/templates/app/package.json @@ -17,7 +17,11 @@ "@thatopen/fragments": "~3.4.0", "@thatopen/services": "file:../../../..", "@thatopen/ui": "~3.4.0", - "three": "^0.182.0" + "three": "^0.182.0", + "@sparkjsdev/spark": "^0.1.10", + "3d-tiles-renderer": "^0.4.28", + "camera-controls": "^3.1.2", + "web-ifc": "^0.0.77" }, "devDependencies": { "@types/three": "^0.182.0", diff --git a/src/cli/templates/app/src/app.ts b/src/cli/templates/app/src/app.ts new file mode 100644 index 0000000..a51b2ce --- /dev/null +++ b/src/cli/templates/app/src/app.ts @@ -0,0 +1,15 @@ +import * as OBC from "@thatopen/components"; +import * as BUI from "@thatopen/ui"; +import { AppManager } from "@thatopen/services"; +import { icons } from "./globals"; + +export type App = { + icons: (keyof typeof icons)[]; + grid: BUI.Grid< + ["Explorer", "Files", "Graphics"], + ["viewer", "explorer", "files", "graphics"] + >; +}; + +export const getAppManager = (components: OBC.Components) => + components.get(AppManager); diff --git a/src/cli/templates/app/src/assets/tool-placeholder.ts b/src/cli/templates/app/src/assets/tool-placeholder.ts new file mode 100644 index 0000000..9b1afb1 --- /dev/null +++ b/src/cli/templates/app/src/assets/tool-placeholder.ts @@ -0,0 +1,50 @@ +/** + * Empty-state illustration for the helper panel (from + * "Frame 691314571.svg" — a translucent stacked-cards glyph with a check badge). + * Stored as a string and served as a data URI so it needs no import/build + * config. Theme-safe: all fills are translucent white/#F8F8F8 over transparent, + * so it reads correctly on the dark bg-base card. + */ +export const toolPlaceholderSvg = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +/** The illustration as a data URI, ready for an ``. */ +export const toolPlaceholderUri = `data:image/svg+xml;utf8,${encodeURIComponent( + toolPlaceholderSvg, +)}`; diff --git a/src/cli/templates/app/src/bim-components/CloudRunner/index.ts b/src/cli/templates/app/src/bim-components/CloudRunner/index.ts index 5c949dc..b9ccadb 100644 --- a/src/cli/templates/app/src/bim-components/CloudRunner/index.ts +++ b/src/cli/templates/app/src/bim-components/CloudRunner/index.ts @@ -1,5 +1,5 @@ import * as OBC from "@thatopen/components"; -import { EngineServicesClient } from "@thatopen/services"; +import { AppManager } from "@thatopen/services"; import { CloudRunnerStatus } from "./src"; export class CloudRunner extends OBC.Component { @@ -18,16 +18,17 @@ export class CloudRunner extends OBC.Component { progress = 0; messages: string[] = []; - // Set this before calling run() — injected from the app setup. - client!: EngineServicesClient; - constructor(components: OBC.Components) { super(components); components.add(CloudRunner.uuid, this); } async run(useLocal: boolean) { - this.client.localServerUrl = useLocal ? this.localServerUrl : null; + // Resolve client at call time via AppManager — never store it as a field. + const app = this.components.get(AppManager); + const client = app.client; + + client.localServerUrl = useLocal ? this.localServerUrl : null; this.status = useLocal ? "Starting (local)..." : "Starting (deployed)..."; this.progress = 0; @@ -35,14 +36,15 @@ export class CloudRunner extends OBC.Component { this._trigger(); try { - const { executionId } = await this.client.executeComponent(this.componentId, { + const { executionId } = await client.executeComponent(this.componentId, { greeting: "Hello from the BIM app!", }); this.status = `Running (${executionId.slice(0, 8)}...)`; this._trigger(); - this.client.onExecutionProgress(executionId, (data) => { + // Subscribe to real-time progress updates via WebSocket. + client.onExecutionProgress(executionId, (data) => { if (data.progressUpdate) { this.progress = data.progressUpdate.progress; if (data.progressUpdate.result) { @@ -55,9 +57,11 @@ export class CloudRunner extends OBC.Component { this._trigger(); }); + // Poll once after a short delay to catch fast executions that complete + // before the WebSocket subscription is established. setTimeout(async () => { try { - const exec = await this.client.getExecution(executionId); + const exec = await client.getExecution(executionId); if (exec.result) { this.progress = exec.progress; this.status = `${exec.result}: ${exec.resultMessage ?? "Done"}`; @@ -71,7 +75,7 @@ export class CloudRunner extends OBC.Component { this.status = `Error: ${err}`; this._trigger(); } finally { - this.client.localServerUrl = null; + client.localServerUrl = null; } } diff --git a/src/cli/templates/app/src/globals.ts b/src/cli/templates/app/src/globals.ts new file mode 100644 index 0000000..bd5e5c3 --- /dev/null +++ b/src/cli/templates/app/src/globals.ts @@ -0,0 +1 @@ +export const icons = {}; diff --git a/src/cli/templates/app/src/main.ts b/src/cli/templates/app/src/main.ts index 709e431..62179b5 100644 --- a/src/cli/templates/app/src/main.ts +++ b/src/cli/templates/app/src/main.ts @@ -1,56 +1,354 @@ -import { html } from "lit"; import * as THREE from "three"; import * as OBC from "@thatopen/components"; import * as OBF from "@thatopen/components-front"; import * as FRAGS from "@thatopen/fragments"; +// Inlines the fragments worker so it runs inside the platform's sandboxed iframe. +import "@thatopen/fragments/inline"; import * as BUI from "@thatopen/ui"; -import { PlatformClient, UIManager } from "@thatopen/services"; -import { initFragments } from "./setups/fragments"; -import { initIfcLoader } from "./setups/ifc-loader"; -import "./ui-components"; +import * as MARKERJS from "@markerjs/markerjs3"; +import { + PlatformClient, + AppManager, + ViewportsManager, + UIManager, +} from "@thatopen/services"; +import { getAppManager } from "./app"; +import { + uiManager, + cloudRunner, + viewportsManager, + fpsIndicator, + activeToolHud, + propertiesPanel, + modelTree, + filesPanel, + graphicsPanel, + clipperTool, + clipperPanel, + commandsPanel, + plansPanel, + navigationGizmo, + measurementTool, + measurementPanel, + dataTablePanel, + // explodedView, // removed from toolbar for launch — re-add with the controller in main() + walkthrough, + visibilityToolbar, + rightStack, +} from "./setups"; +// Direct imports (not in the setups barrel): the Objects-outliner panel + W1's +// unified clip-plane/measurement instance API it consumes. +import { objectsPanel } from "./setups/objects-panel"; +import { inspectionInstances, inspectionActions } from "./setups/inspection"; +import { measurementSettingsPanel } from "./setups/measurement-settings-panel"; +import { settingsPanel } from "./setups/settings-panel"; + +// ─── RAW VIEWER (perf baseline) ────────────────────────────────────── +// Stripped to just: platform setup → bare viewport → auto-load one model → +// FPS overlay. NO panels (files/tree/properties), NO toolbar, NO helper/Styles, +// NO frame/hover/selection/postproduction. We'll re-add each piece one at a time +// to find what makes orbiting heavier than the raw example. Full main saved at +// `main.full.ts.bak`. async function main() { const client = PlatformClient.fromPlatformContext(); - const { components } = await client.setup( - { OBC, OBF, BUI, THREE, FRAGS }, + // Brand accent (purple). Theming via the library variable is the sanctioned + // way — drives the layout-selector active state, the grid resize divider, + // input focus rings, toggles, etc. (The library's dark-theme default is lime.) + document.documentElement.style.setProperty("--bim-ui_accent-base", "#6528d7"); + + // DEV: serve the local ViewportsManager built-in from :4100 if running. + const ctx = (globalThis as Record).__THATOPEN_CONTEXT__; + if (ctx?.appId === "local-dev") { + const DEV_BUILTINS: Record = { + [ViewportsManager.uuid]: "http://localhost:4100/ViewportsManager.iife.js", + [AppManager.uuid]: "http://localhost:4100/AppManager.iife.js", + }; + const orig = client.getBuiltInComponent.bind(client); + (client as Record).getBuiltInComponent = async (uuid: string) => { + const url = DEV_BUILTINS[uuid]; + if (url) { + try { + return await (await fetch(url)).text(); + } catch { + /* local bundle server down → fall back to the platform */ + } + } + return orig(uuid); + }; + } + + const { components } = await client.setup( + { OBC, OBF, BUI, THREE, FRAGS, MARKERJS }, + { uuid: ViewportsManager.uuid }, + { uuid: AppManager.uuid }, { uuid: UIManager.uuid }, - ) as { components: OBC.Components }; + ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (components.get(UIManager as any) as any).init(); - - const app = document.createElement("top-app") as any; - - app.setup = (waitUntil: (promise: Promise, label: string) => void) => { - waitUntil(initFragments(components), "Fragments Core"); - waitUntil(initIfcLoader(components), "IFC Loader"); - return { components, client }; - }; - - app.elements = { - viewer: () => html``, - panel: () => html``, - }; - - app.layouts = { - main: { - label: "Main", - icon: "solar:home-bold", - template: `"panel" 1fr / 22rem`, + // Bare viewport (no frame/hover/selection/postproduction). + const viewerElement = await viewportsManager(components); + console.log("[raw] viewport ready"); + + // ── LAYER 5: panels via the platform's LAYOUT system ────────────── + // Two named layouts, each with an icon → the AppManager auto-generates the + // vertical sidebar button bar (VS Code activity bar) that switches between + // them. Each layout docks its panel beside the viewer (real grid column → + // canvas shrinks, perf-friendly). No custom switcher/drag code. + void rightStack; + + // Explorer panel: tree + properties STACKED (both visible together). + const treeEl = modelTree(components); + const propsEl = propertiesPanel(components); + // Tree and properties are TWO SEPARATE GRID AREAS (stacked in the left column; + // see the Explorer layout below), not a hand-rolled split inside one area. So + // they get the exact same inter-area gap as every other area, AND the bim-grid + // gives a draggable divider between them for free (it goes purple on + // hover/drag — the app's resize accent, themed via --bim-grid--divider-c). + + // Files panel (upload / convert / add-to-scene UI). + const filesEl = filesPanel(components, client) as unknown as HTMLElement; + + // FPS counter — mounted INSIDE the viewer, app-styled, and toggleable from the + // Graphics panel (pass the controller in so the "Show FPS" switch can drive it). + const fps = fpsIndicator(viewerElement); + + // Active-tool HUD — shows the current tool's label ("Drawing clipping plane", + // "Measuring length", …), driven by the global toolModeManager. Decoupled: any + // future tool that registers a label shows up here automatically. + activeToolHud(viewerElement, components); + + // press R or resize (log section line resolution vs buffer/overlay sizes). + + // Graphics panel (rendering settings: postproduction, AO, edges, tone/scene, + // grid, selection outline). Docked as a third layout beside the viewer. + const graphicsEl = graphicsPanel(components, fps) as unknown as HTMLElement; + + // Clipping / section-planes (worker 1): the tool drives the planes; the panel + // manages them + per-category section styling. + const clipTool = clipperTool(components); + const clippingEl = clipperPanel(components, clipTool) as unknown as HTMLElement; + + // Commands / keyboard-shortcuts panel (worker 3). + const commandsEl = commandsPanel(components) as unknown as HTMLElement; + + // Floor plans / 2D plan navigation (worker 2). + const plansEl = plansPanel(components) as unknown as HTMLElement; + + // Measurement (worker 1): tool + panel (length/area/angle + list/delete). + const measureTool = measurementTool(components); + const measureEl = measurementPanel(components, measureTool) as unknown as HTMLElement; + // Measurement SETTINGS section for the merged Settings layout (color/units/ + // rounding/snaps/visible — W1's measurement settings API). + const measureSettingsEl = measurementSettingsPanel(measureTool) as unknown as HTMLElement; + + // Element data table (worker 2): docked as a vertical side panel like the + // others (narrow column; wide content scrolls horizontally / panel resizes). + const dataTableEl = dataTablePanel(components) as unknown as HTMLElement; + + // Objects outliner (UI reorg, increment b): lists every clip plane + measurement + // from W1's unified inspectionInstances API, each with hide/disable/delete. + const inspection = inspectionInstances(clipTool, measureTool); + const objectsEl = objectsPanel(inspection) as unknown as HTMLElement; + + // Merged Settings panel (UI-reorg polish): ONE scrolling panel with collapsible + // sections (Graphics · Clip styling · Measurement · Commands), each re-homing + // the existing panel element. Replaces the 4-stacked-panel Settings layout. + const settingsEl = settingsPanel([ + { label: "Graphics", icon: "mdi:tune", el: graphicsEl }, + { label: "Clip styling", icon: "mdi:scissors-cutting", el: clippingEl }, + { label: "Measurement", icon: "mdi:ruler", el: measureSettingsEl }, + { label: "Commands", icon: "mdi:keyboard", el: commandsEl }, + ]) as unknown as HTMLElement; + + // RAW-UI-TEMP: keep panels filling their cell but DON'T flatten the chrome — + // let bim-panel show its default border/radius/shadow. (Was also: border:none, + // borderRadius:0, boxShadow:none + a border-right separator on files/graphics.) + // Only the DOCKED panels fill their grid cell. graphics/clipping/commands/ + // measureSettings are NOT here — they're nested inside settingsEl (which manages + // their height:auto), so forcing height:100% on them would fight that. + for (const el of [treeEl, propsEl, filesEl, dataTableEl, objectsEl, settingsEl] as HTMLElement[]) { + el.style.width = "100%"; + el.style.height = "100%"; + } + + // ── App shell: layout sidebar + docked panel + viewer ───────────── + const app = getAppManager(components); + await app.init({ + client, + icons: [], + componentSetups: { core: [uiManager, cloudRunner] }, + grid: (grid) => { + grid.elements = { + viewer: viewerElement, + tree: treeEl, + properties: propsEl, + files: filesEl, + dataTable: dataTableEl, + objects: objectsEl, + settings: settingsEl, + }; + // UI REORG — activity bar order: Explorer · Assets · Objects · Data · + // Settings (Settings LAST). Files→Assets. Clipping + Measure are no longer + // their own layouts: their TOOLS move to the bottom Inspection toolbar tab, + // their plane/measurement INSTANCES to the Objects outliner, and their + // SETTINGS into the merged Settings layout (Graphics + clip styling + + // Commands; Measurement settings fold in once W1 exposes them). + grid.layouts = { + Explorer: { + // Tree (top) + properties (bottom) are two separate areas stacked in + // the left column; the viewer spans both rows. The shared row edge + // becomes a draggable bim-grid divider, and both areas get the same + // gap as the viewer↔column gap (consistent spacing, by construction). + template: ` + "tree viewer" 1fr + "properties viewer" 1fr + / 22rem 1fr + `, + icon: "mdi:file-tree", + }, + Assets: { + // Project Files (top) + Objects outliner (bottom) STACKED in the left + // column — same shape as Explorer's tree+properties stack — with the + // viewer spanning both rows and a draggable divider on the shared edge. + template: ` + "files viewer" 1fr + "objects viewer" 1fr + / 22rem 1fr + `, + icon: "mdi:folder-multiple-outline", + }, + Data: { + // Element data table docked as a vertical left-column panel like every + // other layout. Wide tables scroll horizontally inside the panel, and + // the shared column edge is a draggable bim-grid divider (resizable). + template: `"dataTable viewer" 1fr / 22rem 1fr`, + icon: "mdi:table", + }, + Settings: { + // ONE scrolling Settings panel with collapsible sections (Graphics · + // Clip styling · Measurement · Commands) — see settings-panel.ts. + template: `"settings viewer" 1fr / 22rem 1fr`, + icon: "mdi:cog", + }, + }; + grid.layout = "Explorer"; + // RAW-UI-TEMP: keep the grid filling the viewport, but drop the cosmetic + // flattening (padding/gap/radius + --bim-grid--g/p) and the purple accent + // override — use the library's defaults. + grid.style.width = "100%"; + grid.style.height = "100%"; + grid.style.margin = "0"; }, - }; + }); + // Show the auto-generated layout-switching sidebar (vertical button bar). + app.showSidebar = true; + console.log("[raw] app.init done — viewer mounted"); - app.base = "viewer"; - app.layout = "main"; + // Walkthrough is now a headless controller (worker 2); create it here so its + // mdi:walk toggle button can live in the bottom visibility toolbar. + let walk; + try { + walk = walkthrough(components); + } catch (e) { + console.warn("[main] walkthrough controller failed to init", e); + } - app.addEventListener("top:app-ready", () => { - app.showToast("App ready", "success"); + // Exploded view removed from the toolbar for launch (re-add after). The + // explodedView controller (worker 3) is intact — to bring back the button, + // uncomment the controller below and pass `explode` (not undefined) to the + // toolbar; the toolbar renders the button only when a controller is supplied. + // let explode; + // try { + // explode = explodedView(components); + // } catch (e) { + // console.warn("[main] explodedView controller failed to init", e); + // } + + // Floating bottom toolbar — now TABBED (bim-tabs): "View" = visibility actions + // (Hide/Show/Ghost/Isolate + Selected⇄Unselected + Walkthrough); "Inspect" = + // Select (default) + Clip plane + Measure length/area/angle, routed through the + // toolModeManager. Self-mounts bottom-center over the viewport. + visibilityToolbar( + components, + viewerElement, + walk, + undefined, + inspectionActions(clipTool, measureTool), + ); + + // Navigation gizmo / view-cube, top-right (worker 2): live orientation + + // click-to-orient preset views (faces/edges/corners) + home/zoom-to-fit. + // Guarded so a gizmo-mount error can't abort viewer init. + try { + navigationGizmo(components, viewerElement); + } catch (e) { + console.warn("[main] navigationGizmo failed to mount", e); + } + + // ── Auto-load one model (no UI), AFTER the viewer is up ─────────── + // Non-blocking: runs in the background so a slow load never affects the + // mounted viewport. Mirrors the minimal scene-add wiring from the Files panel. + void autoLoadFirstModel(components, client); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function autoLoadFirstModel(components: OBC.Components, client: any) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const worlds = components.get(OBC.Worlds); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const world = [...worlds.list.values()][0] as any; + const fragments = components.get(OBC.FragmentsManager); + fragments.list.onItemSet.add((event) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const model = (event as any).value; + if (world && model) { + model.useCamera(world.camera.three); + world.scene.three.add(model.object); + // Worker-side clip cull: let the fragments worker skip streaming/computing + // tiles fully on the clipped-away side of the section planes (today the + // clip is GPU-shader-only; the worker is blind to it). Return the + // renderer's FULL clipping-plane list (BaseRenderer.clippingPlanes), NOT + // three.clippingPlanes — our clipper runs in localClippingPlanes mode, so + // the local planes are filtered out of three.clippingPlanes and the worker + // would see an empty set (cull silently disabled). + model.getClippingPlanesEvent = () => + Array.from(world.renderer?.clippingPlanes ?? []); + } + fragments.core.update(true); }); - const container = document.getElementById("that-open-app") ?? document.body; - container.appendChild(app); - document.body.style.margin = "0"; + const projectId: string | undefined = client?.context?.projectId; + if (!projectId) { + console.warn("[raw] no projectId — skipping auto-load"); + return; + } + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const items = (await client.listFiles({ projectId })) as any[]; + // Prefer BLOXHUB (a medium model) as the default; fall back to first .frag. + const frags = items.filter((it) => + (it.name ?? "").toLowerCase().endsWith(".frag"), + ); + const frag = + frags.find((it) => (it.name ?? "").toLowerCase().includes("bloxhub")) ?? + frags[0]; + if (!frag) { + console.warn("[raw] no .frag in project to auto-load"); + return; + } + const resp = await client.downloadFile(String(frag._id)); + const buffer = await resp.arrayBuffer(); + // Key the model by the frag's fileId (not basename) so the Files panel — + // which keys loaded models by fragId — can manage it (e.g. dispose on detach). + const modelId = String(frag._id); + await fragments.core.load(buffer, { modelId }); + await fragments.core.update(true); + // Do NOT focus the camera on the model — keep the default view on load. + console.log("[raw] auto-loaded model:", frag.name); + } catch (error) { + console.warn("[raw] auto-load failed", error); + } } main().catch(console.error); diff --git a/src/cli/templates/app/src/setups/active-tool-hud.ts b/src/cli/templates/app/src/setups/active-tool-hud.ts new file mode 100644 index 0000000..a3e36fd --- /dev/null +++ b/src/cli/templates/app/src/setups/active-tool-hud.ts @@ -0,0 +1,69 @@ +import * as OBC from "@thatopen/components"; +import { toolModeManager, type ManagedTool } from "./tool-mode-manager"; + +/** + * Active-tool HUD — a floating text overlay (top-left of the viewport) showing + * what the user is currently doing, e.g. "Drawing clipping plane" / + * "Measuring length". + * + * Fully DECOUPLED from any specific tool: it only subscribes to the global + * toolModeManager's `onActiveChanged` and renders the active tool's registered + * {@link ManagedTool.label} (+ optional icon), hidden when no tool is active. Any + * future tool that registers a label with the manager shows up here with zero + * HUD changes. + * + * Styled like the other in-viewer overlays (fps counter): panel surface + 1px + * contrast-20 border + theme text, pointer-events off. + */ +export interface ActiveToolHud { + element: HTMLElement; +} + +export const activeToolHud = ( + parent: HTMLElement, + components: OBC.Components, +): ActiveToolHud => { + const manager = toolModeManager(components); + + const el = document.createElement("div"); + el.style.cssText = ` + position: absolute; top: 0.6rem; left: 0.6rem; z-index: 10; + display: none; align-items: center; gap: 0.4rem; + padding: 0.25rem 0.6rem; border-radius: 0.5rem; + background: var(--bim-ui_bg-contrast-10, #262629); + border: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1)); + color: var(--bim-ui_bg-contrast-100, #e3e3e3); + font: 600 0.74rem/1.2 "Plus Jakarta Sans", sans-serif; + pointer-events: none; user-select: none; + `; + const icon = document.createElement("bim-icon"); + icon.style.fontSize = "0.95rem"; + const text = document.createElement("span"); + el.append(icon, text); + + // The viewer is position:relative, so an absolutely-positioned child overlays + // the canvas correctly. + if (!parent.style.position) parent.style.position = "relative"; + parent.append(el); + + const render = (tool: ManagedTool | null) => { + if (!tool) { + el.style.display = "none"; + return; + } + text.textContent = + typeof tool.label === "function" ? tool.label() : tool.label; + if (tool.icon) { + icon.setAttribute("icon", tool.icon); + icon.style.display = ""; + } else { + icon.style.display = "none"; + } + el.style.display = "flex"; + }; + + manager.onActiveChanged.add(render); + render(manager.active); + + return { element: el }; +}; diff --git a/src/cli/templates/app/src/setups/camera-tools.ts b/src/cli/templates/app/src/setups/camera-tools.ts new file mode 100644 index 0000000..9608ed7 --- /dev/null +++ b/src/cli/templates/app/src/setups/camera-tools.ts @@ -0,0 +1,63 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; + +/** + * Camera tools for the viewer toolbar. Returns controllers matching the + * toolbar's integration contract: + * - `fitAll` — ACTION: fit the camera to the whole loaded model. + * - `orthoToggle` — TOGGLE: switch the projection Perspective ⇄ Orthographic. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const firstWorld = (components: OBC.Components): any => + [...components.get(OBC.Worlds).list.values()][0]; + +export interface CameraTools { + fitAll: { label: string; icon: string; run(): Promise }; + orthoToggle: { + label: string; + icon: string; + active(): boolean; + activate(): Promise; + deactivate(): Promise; + }; +} + +export const cameraTools = (components: OBC.Components): CameraTools => { + const fragments = components.get(OBC.FragmentsManager); + + return { + fitAll: { + label: "Zoom to fit", + icon: "mdi:fit-to-page-outline", + async run() { + const controls = firstWorld(components)?.camera?.controls; + if (!controls) return; + // Union every loaded model's bounding box, then frame its sphere — + // fitToSphere preserves the current view direction (no rotation snap). + const box = new THREE.Box3(); + for (const model of fragments.list.values()) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b = (model as any).box as THREE.Box3 | undefined; + if (b && !b.isEmpty()) box.union(b); + } + if (box.isEmpty()) return; + const sphere = box.getBoundingSphere(new THREE.Sphere()); + await controls.fitToSphere(sphere, true); + }, + }, + orthoToggle: { + label: "Orthographic", + icon: "mdi:angle-right", + active() { + return firstWorld(components)?.camera?.projection?.current === "Orthographic"; + }, + async activate() { + await firstWorld(components)?.camera?.projection?.set?.("Orthographic"); + }, + async deactivate() { + await firstWorld(components)?.camera?.projection?.set?.("Perspective"); + }, + }, + }; +}; diff --git a/src/cli/templates/app/src/setups/camera-views.ts b/src/cli/templates/app/src/setups/camera-views.ts new file mode 100644 index 0000000..2127de7 --- /dev/null +++ b/src/cli/templates/app/src/setups/camera-views.ts @@ -0,0 +1,201 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; +import * as BUI from "@thatopen/ui"; +import { cameraTools } from "./camera-tools"; + +/** + * Camera navigation — a COMPACT floating overlay pinned top-right of the + * viewport (mirrors visibility-toolbar's self-mounting floating-grid overlay). + * + * Controls: + * - View presets (Iso / Top / Bottom / Front / Back / Left / Right) via a + * compact dropdown. Each frames the union bounding box of all loaded models: + * `setLookAt` along the preset axis, then `fitToSphere` to frame precisely + * while keeping the new direction. + * - Zoom-to-fit + Perspective⇄Orthographic toggle — reused from + * `cameraTools(components)` (no reimplementation). + * + * Self-mounts into the viewport overlay. Wire from main.ts with one line + * (after the viewport exists), like the visibility toolbar: + * + * cameraViews(components, viewerElement); + * + * @param components engine components + * @param container optional viewport element to overlay; if omitted, the first + * world's renderer container is used. + */ + +// Preset → camera direction (from target toward camera). Normalized + scaled by +// the model radius at apply time. +const VIEWS: Record = { + Iso: [1, 0.8, 1], + Top: [0, 1, 0], + Bottom: [0, -1, 0], + Front: [0, 0, 1], + Back: [0, 0, -1], + Left: [-1, 0, 0], + Right: [1, 0, 0], +}; + +export const cameraViews = ( + components: OBC.Components, + container?: HTMLElement, +) => { + const fragments = components.get(OBC.FragmentsManager); + const cam = cameraTools(components); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const firstWorld = (): any => [...components.get(OBC.Worlds).list.values()][0]; + + const unionBox = () => { + const box = new THREE.Box3(); + for (const model of fragments.list.values()) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b = (model as any).box as THREE.Box3 | undefined; + if (b && !b.isEmpty()) box.union(b); + } + return box; + }; + + const setView = async (name: string) => { + const dir = VIEWS[name]; + if (!dir) return; + const controls = firstWorld()?.camera?.controls; + if (!controls?.setLookAt) return; + const box = unionBox(); + if (box.isEmpty()) return; + const sphere = box.getBoundingSphere(new THREE.Sphere()); + const r = sphere.radius || 1; + const c = sphere.center; + const v = new THREE.Vector3(dir[0], dir[1], dir[2]) + .normalize() + .multiplyScalar(r * 3); + try { + // Move to the preset direction, then frame the sphere exactly (keeps the + // new direction, snaps the distance to fit). + await controls.setLookAt( + c.x + v.x, c.y + v.y, c.z + v.z, + c.x, c.y, c.z, + true, + ); + await controls.fitToSphere(sphere, true); + } catch (error) { + console.warn("[camera-views] setView failed", error); + } + }; + + let busy = false; + const run = async (fn: () => void | Promise) => { + if (busy) return; + busy = true; + try { + await fn(); + } catch (error) { + console.warn("[camera-views] action failed", error); + } finally { + busy = false; + } + }; + + let tick = 0; + const [bar, barUpdate] = BUI.Component.create( + // Param required: create() returns a single element (not the [element, + // update] tuple) when the template has arity 0. + (_state) => { + // Guard: the very first render can run before a world exists, and reading + // the projection spreads the (possibly empty/not-yet-ready) worlds list. + let ortho = false; + try { + ortho = cam.orthoToggle.active(); + } catch { + /* world not ready yet — default to perspective icon, re-render later */ + } + return BUI.html` + + + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const picked = (e.target as any).value?.[0]; + if (picked) void run(() => setView(String(picked))); + }} + > + ${Object.keys(VIEWS).map( + (name) => BUI.html``, + )} + + + + run(() => cam.fitAll.run())} + style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;" + >${cam.fitAll.label} + + run(async () => { + if (cam.orthoToggle.active()) await cam.orthoToggle.deactivate(); + else await cam.orthoToggle.activate(); + barUpdate({ tick: ++tick }); + })} + style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;" + >${ + ortho + ? "Orthographic — switch to Perspective" + : "Perspective — switch to Orthographic" + } + + + `; + }, + { tick: 0 }, + ); + + // ── Floating grid overlay: bar pinned TOP-RIGHT; empty areas click through ── + const grid = BUI.Component.create(() => { + const onCreated = (element?: Element) => { + if (!element) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = element as any; + g.elements = { bar }; + g.layouts = { + main: { + template: ` + "restL bar" auto + "fillL fillR" 1fr + / 1fr auto + `, + }, + }; + g.layout = "main"; + }; + return BUI.html` + + `; + }); + + const resolveContainer = (): HTMLElement | undefined => { + if (container) return container; + const world = [...components.get(OBC.Worlds).list.values()][0] as + | { renderer?: { three?: { domElement?: HTMLElement } } } + | undefined; + const canvas = world?.renderer?.three?.domElement; + return (canvas?.parentElement as HTMLElement | undefined) ?? undefined; + }; + + const host = resolveContainer(); + if (host) host.append(grid); + else + console.warn( + "[camera-views] no viewport found to overlay; append the returned element manually", + ); + + return bar; +}; diff --git a/src/cli/templates/app/src/setups/card-header.ts b/src/cli/templates/app/src/setups/card-header.ts new file mode 100644 index 0000000..23555e4 --- /dev/null +++ b/src/cli/templates/app/src/setups/card-header.ts @@ -0,0 +1,35 @@ +import * as BUI from "@thatopen/ui"; + +/** + * A card title styled to match the nxt-bld-demo panels: an icon (#99A0AE) + + * a white→#99A0AE gradient text label, semibold with wide letter-spacing. + * Use inside a `header-hidden` `bim-panel` (replaces the default header). Sticky + * to the top so it stays put while the card body scrolls. + * + * @param icon iconify/mdi name (e.g. "mdi:file-tree") + * @param label the title text + * @param padLeft left padding so the title aligns with the card's content inset + */ +export const cardHeader = (icon: string, label: string, padLeft = "0.6rem") => BUI.html` +
+ + ${label} +
+`; diff --git a/src/cli/templates/app/src/setups/clipper-panel.ts b/src/cli/templates/app/src/setups/clipper-panel.ts new file mode 100644 index 0000000..665920b --- /dev/null +++ b/src/cli/templates/app/src/setups/clipper-panel.ts @@ -0,0 +1,167 @@ +import * as OBC from "@thatopen/components"; +import * as BUI from "@thatopen/ui"; +import type { ClipperTool } from "./clipper-tool"; + +/** + * CLIPPING panel — a docked side-panel (a 4th layout alongside + * Explorer/Files/Graphics) for section planes + per-category section styling. + * + * Sections: + * - Section planes — master Enabled, Add plane (placing mode: double-click a + * surface), Clear all. + * - Active planes — one row per plane (per-plane enable + delete). + * - Section styling — master fills/edges visibility + a per-IFC-category list, + * each with a fill colour, an edge colour, and an include toggle. + * + * Vanilla BUI, matching graphics-panel.ts / files-panel.ts: `bim-panel` (native + * header label+icon), muted section bands, 1px contrast-20 hairline rows, the + * #3C3C41 scrollbar, library `bim-checkbox[toggle]` switches, `bim-button`s and + * `bim-color-input`s. Factory returns the element WITHOUT self-mounting. + */ +export const clipperPanel = ( + _components: OBC.Components, + tool: ClipperTool, +) => { + const [panel, update] = BUI.Component.create( + (state) => { + const refresh = () => update({ tick: state.tick + 1 }); + + const enabled = tool.isEnabled(); + const sectionStyle = tool.getSectionStyle(); + const stylingVisible = tool.isStylingVisible(); + + return BUI.html` + + +
+
+
+ +
+ Enabled + + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tool.setEnabled(!!(e.target as any).checked); + refresh(); + }}> + +
+
+ Show fills / edges + + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tool.setStylingVisible(!!(e.target as any).checked); + refresh(); + }}> + +
+
+ Fill color + + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const t = e.target as any; + tool.setSectionFill(String(t.color ?? t.value)); + }}> + +
+
+ Edge color + + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const t = e.target as any; + tool.setSectionLine(String(t.color ?? t.value)); + }}> + +
+
+ Fill opacity + + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tool.setSectionOpacity(Number((e.target as any).value)); + }}> + +
+
+ Edge width + + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tool.setSectionWidth(Number((e.target as any).value)); + }}> + +
+
+
+
+
+ `; + }, + { tick: 0 }, + ); + + tool.onChanged.add(() => update({ tick: 0 })); + tool.onStyleChanged.add(() => update({ tick: 0 })); + + return panel; +}; diff --git a/src/cli/templates/app/src/setups/clipper-tool.ts b/src/cli/templates/app/src/setups/clipper-tool.ts new file mode 100644 index 0000000..92d9fb8 --- /dev/null +++ b/src/cli/templates/app/src/setups/clipper-tool.ts @@ -0,0 +1,740 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; +import { toolModeManager, type ManagedTool } from "./tool-mode-manager"; +import type { InstanceRow } from "./inspection"; + +/** + * Clipping / section-plane TOOL (logic only; the UI lives in clipper-panel.ts). + * + * Two parts: + * + * 1. PLANES (OBC.Clipper) — placing mode (double-click a surface drops a plane on + * the picked face), drag the gizmo to move, Delete removes the hovered plane, + * per-plane + master enable, clear-all. `localClippingPlanes` is on (before any + * plane) so cuts sit exactly on the plane. + * + * 2. SECTION STYLING (OBF.ClipStyler) — one global "Section" style (a fill + an + * edge line) applied to the whole model ("All") at every clip plane, matching + * the stock ClipStyler example. The deferred-overlay treatment (depthTest/ + * depthWrite off, line resolution, renderOrder-on-top, registration into + * `postproduction.basePass.isolatedMaterials`) lives centrally in ClipEdges, so + * here we only supply the fill/edge colours + opacity. Colour changes rebuild + * the section edges (cheap). + */ +type ClipEdges = ReturnType; + +export interface SectionStyle { + fill: string; // "#rrggbb" + line: string; // "#rrggbb" + opacity: number; // 0..1 fill opacity + width: number; // edge line width (px) +} + +export interface ClipperTool { + readonly clipper: OBC.Clipper; + /** Fires when the set of planes (or their enabled state) changes. */ + readonly onChanged: OBC.Event; + /** Fires when the section style (colours/opacity/visibility) changes. */ + readonly onStyleChanged: OBC.Event; + + // ── Planes ── + setPlacing(on: boolean): void; + isPlacing(): boolean; + setEnabled(on: boolean): void; + isEnabled(): boolean; + /** Enable/disable a single plane AND show/hide its section accordingly. */ + setPlaneEnabled(id: string, on: boolean): void; + deletePlane(id: string): void; + clearAll(): void; + /** Per-plane rows for the Objects outliner (W2): hide / enable / delete. */ + instances(): InstanceRow[]; + + // ── Single global section style ── + getSectionStyle(): SectionStyle; + setSectionFill(hex: string): void; + setSectionLine(hex: string): void; + setSectionOpacity(value: number): void; + setSectionWidth(value: number): void; + /** Master visibility of the section fills/edges. */ + setStylingVisible(on: boolean): void; + isStylingVisible(): boolean; +} + +const CLASSIFICATION = "Categories"; // OBC.Classifier.byCategory default +export const STYLE_NAME = "Section"; + +/** + * Register the global "Section" ClipStyler style idempotently, so other + * consumers (e.g. the floor-plans panel calling `createFromView`) can reference + * it by name WITHOUT depending on `clipperTool()` having constructed first. + * No-op if it already exists — `clipperTool` registers a live, user-editable + * instance in its `rebuildStyles`, and this must not clobber that one. + */ +export const ensureSectionStyle = (components: OBC.Components) => { + const styler = components.get(OBF.ClipStyler); + if (styler.styles.get(STYLE_NAME)) return; + styler.styles.set(STYLE_NAME, { + // NearPlaneLineMaterial: a fat line (real px width) that ALSO discards + // segments behind the camera plane in-shader, so it gives thick edges WITHOUT + // the near-plane "infinity streak" the stock fat LineMaterial produced. + linesMaterial: new OBF.NearPlaneLineMaterial({ + color: "#111111", + linewidth: 2, + }), + fillsMaterial: new THREE.MeshBasicMaterial({ + color: "#a96eec", + side: THREE.DoubleSide, + }), + }); +}; + +export const clipperTool = (components: OBC.Components): ClipperTool => { + const clipper = components.get(OBC.Clipper); + clipper.localClippingPlanes = true; // before any plane is created + clipper.enabled = true; + clipper.visible = true; + + const styler = components.get(OBF.ClipStyler); + const classifier = components.get(OBC.Classifier); + const fragments = components.get(OBC.FragmentsManager); + const worlds = components.get(OBC.Worlds); + const getWorld = () => [...worlds.list.values()][0] as OBC.World | undefined; + + const onChanged = new OBC.Event(); + const onStyleChanged = new OBC.Event(); + + const manager = toolModeManager(components); + + // ── Plane placing / canvas binding ─────────────────────────────── + let placing = false; + let canvas: HTMLElement | undefined; + + // Registered with the ToolMode manager: when another tool takes over, exit + // placing mode locally (the manager handles hover/select suppression). + const managed: ManagedTool = { + id: "clipper", + label: "Drawing clipping plane", + icon: "mdi:scissors-cutting", + onDeactivate: () => { + placing = false; + if (canvas) canvas.style.cursor = ""; + onChanged.trigger(); + }, + }; + + const onDblClick = () => { + const world = getWorld(); + if (!placing || !world) return; + void clipper.create(world); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.code !== "Delete" && event.code !== "Backspace") return; + const world = getWorld(); + if (world) void clipper.delete(world); + }; + const ensureCanvas = () => { + if (canvas) return canvas; + const renderer = getWorld()?.renderer as + | OBF.PostproductionRenderer + | undefined; + const c = renderer?.three.domElement; + if (c) { + canvas = c; + canvas.addEventListener("dblclick", onDblClick); + window.addEventListener("keydown", onKeyDown); + } + return canvas; + }; + ensureCanvas(); + + // ── Section styling state ──────────────────────────────────────── + // `categories` is only kept to satisfy buildEdgesFor's classify gate (the + // section builds once the model is classified); it no longer drives styling. + let categories: string[] = []; + const edgesByPlane = new Map(); + const planesWired = new Set(); + // Planes currently being dragged. SimplePlane fires TransformControls "change" + // every drag tick → notifyManager → clipper.list.onItemSet, which would + // re-section the plane LIVE on every frame. That spams overlapping async + // rebuilds (which race and drop edges — the "missing edges while dragging") and + // costs a full re-section per frame. We instead BLINK: hide on drag-start, skip + // the per-frame rebuild while dragging, and rebuild once on release. + const draggingPlanes = new Set(); + // Gizmo (TransformControls) + section-plane-mesh materials we registered into + // the postproduction overlay set per plane, so we can unregister on delete. + const gizmoMatsByPlane = new Map(); + + const sectionStyle: SectionStyle = { + // Pre-compensated purple: the deferred postproduction linearizes scene + // colors (sRGB→linear ≈ c^2.4), so #6528d7 would render as #2105ad. Feeding + // the linear→sRGB inverse (#a96eec) makes the fill GRADE to the app purple + // #6528d7 on screen, while still shaded naturally by the PEN scene. + fill: "#a96eec", + line: "#111111", + opacity: 1, + width: 2, + }; + + // One global "Section" style (fill + edge line) applied to the whole model + // ("All"), matching the stock ClipStyler example. The deferred-overlay + // treatment (depthTest/depthWrite off, line resolution, renderOrder-on-top, + // overlay registration) lives centrally in ClipEdges. + const rebuildStyles = () => { + styler.styles.set(STYLE_NAME, { + // NearPlaneLineMaterial: fat line (real px width via `linewidth`) whose + // shader discards segments behind the camera plane, so we get thick section + // edges with NO near-plane "infinity streak". Re-enables the Edge-width + // control (the setter drives `linewidth`). ClipEdges' fat-line path + // (isLineMaterial) builds a LineSegments2 and keeps `resolution` in sync. + linesMaterial: new OBF.NearPlaneLineMaterial({ + color: sectionStyle.line, + linewidth: sectionStyle.width, + }), + fillsMaterial: new THREE.MeshBasicMaterial({ + color: sectionStyle.fill, + side: THREE.DoubleSide, + transparent: sectionStyle.opacity < 1, + opacity: sectionStyle.opacity, + }), + }); + }; + rebuildStyles(); + + const buildItems = () => { + return { All: { style: STYLE_NAME } } as Record< + string, + { style: string; data?: Record } + >; + }; + + const buildEdgesFor = (planeId: string) => { + const plane = clipper.list.get(planeId); + if (!getWorld() || categories.length === 0 || !plane) return; + // A DISABLED plane applies no cut, so it must show no section — hide its + // existing fill/edges and don't compute a new one. Re-enabling fires + // onItemSet again (via SimplePlane.notifyManager), which rebuilds + shows it. + if (!plane.enabled) { + const existing = edgesByPlane.get(planeId); + if (existing) existing.visible = false; + // Request a frame so the now-hidden section disappears immediately rather + // than lingering until the next camera move (the deferred overlay only + // re-composites on demand). + getWorld()?.renderer?.update(); + return; + } + styler.world = getWorld() ?? null; + // Drop any previous edges for this plane (DataMap delete disposes them). + if (styler.list.has(planeId)) styler.list.delete(planeId); + edgesByPlane.delete(planeId); + + const items = buildItems(); + if (Object.keys(items).length === 0) return; + // link:false → no auto onDraggingEnded/onDisposed listeners (we manage those + // once per plane below, so rebuilds don't accumulate handlers). + const edges = styler.createFromClipping(planeId, { + id: planeId, + items, + link: false, + world: getWorld() ?? undefined, + }); + edgesByPlane.set(planeId, edges); + // edges.update() is async (awaits getSection); the new overlay materials + // only exist once it resolves. Kick a render THEN so the recoloured/rebuilt + // section appears immediately instead of staying blank until a camera move + // invalidates the deferred frame. + void Promise.resolve(edges.update()) + .then(() => getWorld()?.renderer?.update()) + .catch((error) => + console.warn("[clipper] section build skipped:", error), + ); + }; + + const rebuildAllEdges = () => { + for (const [id] of clipper.list) buildEdgesFor(id); + }; + + // One drag listener per plane; it updates whatever edges currently belong to + // the plane (survives rebuilds since it reads edgesByPlane live). + const wirePlane = (planeId: string) => { + if (planesWired.has(planeId)) return; + const plane = clipper.list.get(planeId); + if (!plane) return; + planesWired.add(planeId); + // Suppress the dynamic-anchor pivot dot while the plane gizmo is being + // dragged (it would otherwise pop up on the press). The dot renders off + // world.onDynamicAnchorSet, so turning dynamicAnchor off for the drag stops + // it cleanly with no change to viewports-manager. + plane.onDraggingStarted.add(() => { + draggingPlanes.add(planeId); // suppress the per-frame live re-section (blink) + const world = getWorld() as unknown as { dynamicAnchor?: boolean } | undefined; + if (world) world.dynamicAnchor = false; + // Re-prune the TransformControls guide lines (the ±1e6 AXIS/X/Y/Z helper + // lines that read as thin "infinity" lines): three creates/shows them + // lazily, so the one-shot prune at registerGizmo time can run before they + // exist. By drag time they're present — prune + hide again (idempotent). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const helper = (plane as any).controls?.getHelper?.(); + pruneInfiniteGuides(helper); + hideGizmoHelpers(helper); + tintGizmo(planeId); // keep the handle purple through the drag highlight + // Hide this plane's section fill + edges while dragging — they'd be stale + // mid-move anyway, and hiding dodges the per-frame re-section cost. Re-shown + // + recomputed on release below. + const edges = edgesByPlane.get(planeId); + if (edges) edges.visible = false; + }); + plane.onDraggingEnded.add(() => { + draggingPlanes.delete(planeId); // re-enable rebuilds; do the one rebuild below + const world = getWorld() as unknown as { dynamicAnchor?: boolean } | undefined; + if (world) world.dynamicAnchor = true; + const edges = edgesByPlane.get(planeId); + if (edges) { + // Rebuild the section at the NEW plane position FIRST; only reveal it once + // the rebuild resolves. Showing before the update lets the stale geometry + // (still at the pre-drag position) render for a frame → a flash of the old + // section snapping to the new one. update() is async (awaits getSection), + // so flip visible + kick a render in its completion, never before it. + void Promise.resolve(edges.update()) + .then(() => { + edges.visible = true; + getWorld()?.renderer?.update(); + }) + .catch((error) => + console.warn("[clipper] section update skipped:", error), + ); + } + }); + }; + + // Classify loaded models by IFC category, refresh the category list + styles, + // and (re)build all section edges. Resilient: models can be mid-load or + // disposed when fragments.list fires (the project has multiple frags), so the + // worker may not yet/no longer have a model id — byCategory then throws + // "Model not found". We debounce (coalesce the multi-frag load), wrap in + // try/catch, and retry once the list settles, so a transient miss never + // surfaces as an uncaught runtime error. + let classifyTimer: ReturnType | undefined; + const scheduleClassify = () => { + if (classifyTimer) clearTimeout(classifyTimer); + classifyTimer = setTimeout(() => void classify(), 250); + }; + const classify = async () => { + if (fragments.list.size === 0) { + categories = []; + rebuildAllEdges(); + onStyleChanged.trigger(); + return; + } + try { + await classifier.byCategory(); + } catch (error) { + // A model wasn't queryable yet (mid-load) — retry after it settles. + console.warn("[clipper] classification deferred:", error); + scheduleClassify(); + return; + } + const groups = classifier.list.get(CLASSIFICATION); + categories = groups ? [...groups.keys()].sort() : []; + rebuildAllEdges(); + onStyleChanged.trigger(); + }; + + fragments.list.onItemSet.add(scheduleClassify); + fragments.list.onItemDeleted.add(scheduleClassify); + if (fragments.list.size > 0) scheduleClassify(); + + // The postproduction overlay material set, or undefined on a non-postproduction + // renderer / before it's initialised (the basePass getter throws if early). + const isolatedMaterials = (): THREE.Material[] | undefined => { + const renderer = getWorld()?.renderer as + | { postproduction?: { basePass?: { isolatedMaterials?: THREE.Material[] } } } + | undefined; + try { + return renderer?.postproduction?.basePass?.isolatedMaterials; + } catch { + return undefined; + } + }; + + // The clipper's gizmo (TransformControls helper) + section-plane mesh are basic + // single-output materials. classify() hides them by OBJECT visibility, but + // TransformControls re-asserts handle visibility in updateMatrixWorld, so they + // leak into the deferred MRT capture and spam "missing fragment shader + // outputs". Register their materials into the overlay set so the deferred path + // hides them at MATERIAL level during capture and redraws them on top; mark + // preserveBlending so the overlay redraw keeps the gizmo's normal look. + // three.js TransformControls draws infinite axis/delta GUIDE lines (named + // X/Y/Z/AXIS/DELTA, extending ±1e6) to show the active drag axis. In the + // deferred overlay those render as black streaks running to the horizon. Remove + // them — detected by a huge geometry extent so we drop only the guides, never + // the small draggable handles. + const pruneInfiniteGuides = (root: THREE.Object3D | undefined) => { + if (!root) return; + const doomed: THREE.Object3D[] = []; + root.traverse((o) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anyO = o as any; + if ( + !(anyO.isLine || anyO.isLineSegments || anyO.isLine2 || anyO.isLineSegments2) + ) { + return; + } + const geo = (o as THREE.Line).geometry as THREE.BufferGeometry | undefined; + if (!geo) return; + geo.computeBoundingBox?.(); + const bb = geo.boundingBox; + if (bb && bb.min.distanceTo(bb.max) > 1e4) doomed.push(o); + }); + for (const o of doomed) o.removeFromParent(); + }; + + // The clip plane's TransformControls draws helper clutter that has nothing to do + // with the draggable handle: an opaque WHITE bounds cylinder (renders as a solid + // white volume) plus translucent plane/scale/rotate guide quads, pickers and + // helper octahedrons. With postproduction ON the deferred capture hides them, but + // the forward path (the cheap resize path turns postpro off) draws them — that's + // the "white volume" + "guide quads on resize". Hide every NON-handle helper at + // MATERIAL level and flag it `keepHidden` so the PostproductionRenderer.enabled + // setter no longer force-reveals it. We keep only the actual translate arrows: + // in three's TransformControls the arrow/shaft materials are fully opaque + // (opacity 1), while every guide/picker/helper material is translucent + // (opacity < 1) — a stable signal set once at material creation. The opaque + // white bounds cylinder is the lone opaque exception, caught explicitly. + // material.visible is never touched by TransformControls (it only re-asserts + // object.visible per frame), so this sticks; raycast picking ignores it so + // dragging still works. + const hideGizmoHelpers = (root: THREE.Object3D | undefined) => { + if (!root) return; + root.traverse((o) => { + const mesh = o as THREE.Mesh; + if (!mesh.isMesh) return; + const list = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + for (const m of list) { + if (!m) continue; + const basic = m as THREE.MeshBasicMaterial; + const translucent = basic.transparent && (basic.opacity ?? 1) < 1; + const opaqueWhite = + !basic.transparent && + (basic.opacity ?? 1) >= 1 && + basic.color?.getHex?.() === 0xffffff && + mesh.geometry?.type === "CylinderGeometry"; + if (translucent || opaqueWhite) { + m.visible = false; + m.userData.keepHidden = true; + } + } + }); + }; + + const registerGizmo = (planeId: string) => { + const isolated = isolatedMaterials(); + if (!isolated) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const plane = clipper.list.get(planeId) as any; + if (!plane) return; + // Strip the infinite axis/delta guide lines first so we neither register nor + // draw them, and hide the opaque white bounds cylinder (the white volume). + pruneInfiniteGuides(plane.controls?.getHelper?.()); + hideGizmoHelpers(plane.controls?.getHelper?.()); + const mats: THREE.Material[] = []; + const collect = (root: THREE.Object3D | undefined) => + root?.traverse((o) => { + const m = (o as THREE.Mesh).material; + if (!m) return; + for (const mat of Array.isArray(m) ? m : [m]) if (mat) mats.push(mat); + }); + collect(plane.helper); + collect(plane.controls?.getHelper?.()); + for (const m of mats) { + m.userData.preserveBlending = true; + if (!isolated.includes(m)) isolated.push(m); + } + gizmoMatsByPlane.set(planeId, mats); + + // three creates/shows the ±1e6 AXIS/X/Y/Z guide lines lazily, so they may not + // exist yet at this synchronous pass. Re-prune next frame to catch them even + // before the first drag (they can render visible pre-interaction). + requestAnimationFrame(() => { + const helper = plane.controls?.getHelper?.(); + pruneInfiniteGuides(helper); + hideGizmoHelpers(helper); + getWorld()?.renderer?.update(); + }); + }; + + const unregisterGizmo = (planeId: string) => { + const mats = gizmoMatsByPlane.get(planeId); + if (!mats) return; + const isolated = isolatedMaterials(); + if (isolated) { + for (const m of mats) { + const i = isolated.indexOf(m); + if (i >= 0) isolated.splice(i, 1); + } + } + gizmoMatsByPlane.delete(planeId); + }; + + // ── Gizmo POLISH: recolor the draggable handle to the app purple ────────── + // The clip-plane handle (three TransformControls arrows) ships in stock axis + // colours, which read as "raw". The gizmo is overlay-composited (ungraded), so + // a literal #6528d7 renders as the true app purple. The only catch: three + // re-applies a cached original colour (`material._color`) every + // updateMatrixWorld before layering the hover/active highlight — so we recolour + // BOTH the live colour AND that cache, or our purple snaps back on hover/drag. + // Targets only the opaque, visible arrow handles (the translucent guides + the + // white bounds cylinder are skipped, same signal hideGizmoHelpers uses). + const HANDLE_PURPLE = 0x6528d7; + const tintGizmo = (planeId: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const plane = clipper.list.get(planeId) as any; + const helper = plane?.controls?.getHelper?.(); + if (!helper) return; + // SimplePlane sets showX/showY false → only the Z (normal) translate gizmo + // shows. three's TC translate gizmo puts an arrowhead at BOTH +z and −z (plus + // a thin line); the −z one is the "bodyless" arrow pointing away from the + // plane. Drop it. (object.visible would be re-asserted each frame, so + // removeFromParent like the guide-line prune.) The drag hit-area is the + // separate centered _arrowBoundBox picker, so removing the visible back + // arrowhead doesn't affect dragging. + const doomed: THREE.Object3D[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + helper.traverse((o: any) => { + if (!o.isMesh) return; + const list = Array.isArray(o.material) ? o.material : [o.material]; + const opaqueVisible = list.some( + (m: any) => m && m.visible !== false && (m.opacity ?? 1) >= 1, + ); + // Back arrowhead: three's setupGizmo BAKES the layout offset into the + // GEOMETRY and resets mesh.position to (0,0,0) — so detect by the geometry's + // bbox center, not mesh.position. The translate cones are CylinderGeometry; + // the one baked at z≈-0.5 is the bodyless back arrow (the +0.5 front arrow + // and the z≈0 line/shaft stay; invisible pickers excluded by opaqueVisible). + const geo = o.geometry as THREE.BufferGeometry | undefined; + if (geo && !geo.boundingBox) geo.computeBoundingBox?.(); + const bb = geo?.boundingBox; + const centerZ = bb ? (bb.min.z + bb.max.z) / 2 : 0; + if (opaqueVisible && geo?.type === "CylinderGeometry" && centerZ < -0.2) { + doomed.push(o); + return; + } + for (const m of list) { + if (!m?.color?.setHex) continue; + const opacity = m.opacity ?? 1; + // three's TC arrow materials are transparent:true WITH opacity 1, so we + // must NOT require !transparent (that skipped the handles entirely — why + // the recolor looked like nothing changed). Recolor every VISIBLE handle + // (opacity >= 1), matching hideGizmoHelpers' kept set; skip only the faded + // guides/pickers (opacity < 1) and the opaque white bounds cylinder. + const translucent = m.transparent && opacity < 1; + const whiteCylinder = + opacity >= 1 && + m.color.getHex?.() === 0xffffff && + o.geometry?.type === "CylinderGeometry"; + if (translucent || whiteCylinder) continue; + m.color.setHex(HANDLE_PURPLE); + if (m._color?.setHex) m._color.setHex(HANDLE_PURPLE); // patch TC's cache + } + }); + for (const o of doomed) o.removeFromParent(); + }; + + // Plane lifecycle → edges + panel refresh. + clipper.list.onItemSet.add(({ key }) => { + wirePlane(key); + registerGizmo(key); + tintGizmo(key); + // three creates/colours the handle materials lazily, so re-tint next frame to + // catch anything not present in this synchronous pass. + requestAnimationFrame(() => { + tintGizmo(key); + getWorld()?.renderer?.update(); + }); + // While the plane is being dragged, skip the live re-section (blink): the + // section stays hidden until release, where onDraggingEnded rebuilds it once. + if (!draggingPlanes.has(key)) buildEdgesFor(key); + onChanged.trigger(); + }); + clipper.list.onItemDeleted.add((key) => { + if (styler.list.has(key)) styler.list.delete(key); + edgesByPlane.delete(key); + planesWired.delete(key); + unregisterGizmo(key); + onChanged.trigger(); + }); + + // ── In-place section style mutation ────────────────────────────── + // Mutate the LIVE drawn materials rather than rebuilding the ClipEdges: a + // rebuild tears down + recreates the overlay meshes, which vanish until a + // camera move re-composites. Local-clipping mode CLONES the style materials per + // ClipEdges, so we must touch the actual rendered clones (inside each + // ClipEdges.three), not the style template — and also the template so newly + // built sections (and W2's plan views) match. Colour/width are pure uniforms + // (no recompile); only a transparent-flag flip needs needsUpdate. + // rAF-coalesced render kick: dragging a colour/opacity/width control fires many + // input events per frame, each of which would otherwise force a full deferred + // re-composite. Collapse them to at most one recomposite per frame. + let _renderScheduled = false; + const requestRender = () => { + if (_renderScheduled) return; + _renderScheduled = true; + requestAnimationFrame(() => { + _renderScheduled = false; + getWorld()?.renderer?.update(); + }); + }; + + const templateMaterials = () => + styler.styles.get(STYLE_NAME) as + | { linesMaterial?: THREE.Material; fillsMaterial?: THREE.Material } + | undefined; + + const eachSectionMaterial = ( + which: "line" | "fill", + fn: (m: THREE.Material) => void, + ) => { + // Detect line vs fill by OBJECT type (covers both the fat LineSegments2 and a + // plain THREE.LineSegments) rather than a material flag, so it's robust to + // whichever line material the style uses. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const isLineObj = (o: any) => + !!(o.isLine || o.isLineSegments || o.isLine2 || o.isLineSegments2); + const tpl = templateMaterials(); + const tplMat = which === "line" ? tpl?.linesMaterial : tpl?.fillsMaterial; + if (tplMat) fn(tplMat); + for (const [, edges] of edgesByPlane) { + edges.three.traverse((o) => { + const m = (o as THREE.Mesh).material as THREE.Material | undefined; + if (!m) return; + if ((which === "line") === isLineObj(o)) fn(m); + }); + } + }; + + return { + clipper, + onChanged, + onStyleChanged, + + setPlacing(on) { + placing = on; + const c = ensureCanvas(); + if (c) c.style.cursor = on ? "crosshair" : ""; + // Route through the central manager: entering placing makes this the sole + // active tool (suppressing hover/select + exiting any other tool); exiting + // restores them. + if (on) manager.setActive(managed); + else manager.clearActive(managed); + }, + isPlacing: () => placing, + setEnabled(on) { + clipper.enabled = on; + for (const [, plane] of clipper.list) plane.enabled = on; + // Explicitly resync every section to its plane's enabled state (don't rely + // on SimplePlane.notifyManager → onItemSet, which the DataMap can dedup). + rebuildAllEdges(); + onChanged.trigger(); + }, + isEnabled: () => clipper.enabled, + setPlaneEnabled(id, on) { + const plane = clipper.list.get(id); + if (!plane) return; + plane.enabled = on; + // buildEdgesFor hides the section when disabled, rebuilds + shows it when + // enabled — called directly so it never depends on onItemSet firing. + buildEdgesFor(id); + onChanged.trigger(); + }, + deletePlane(id) { + const world = getWorld(); + if (world) void clipper.delete(world, id); + }, + clearAll() { + clipper.deleteAll(); + onChanged.trigger(); + }, + instances() { + const rows: InstanceRow[] = []; + let i = 0; + for (const [id, plane] of clipper.list) { + const idx = ++i; + const p = plane as unknown as { visible: boolean; enabled: boolean }; + rows.push({ + id, + kind: "clip", + type: "Clip plane", + label: `Clip plane ${idx}`, + visible: p.visible, + enabled: p.enabled, + setVisible: (on) => { + // Hide the gizmo + plane mesh AND the section fill/edges together. + p.visible = on; + const edges = edgesByPlane.get(id); + if (edges) edges.visible = on; + requestRender(); + onChanged.trigger(); + }, + setEnabled: (on) => { + plane.enabled = on; + buildEdgesFor(id); // hides section when off, rebuilds + shows when on + onChanged.trigger(); + }, + remove: () => { + const world = getWorld(); + if (world) void clipper.delete(world, id); + }, + }); + } + return rows; + }, + + getSectionStyle: () => ({ ...sectionStyle }), + setSectionFill(hex) { + sectionStyle.fill = hex; + // MeshBasicMaterial.color is a Color — mutate in place (uniform, no recompile). + eachSectionMaterial("fill", (m) => + (m as THREE.MeshBasicMaterial).color.set(hex), + ); + requestRender(); + onStyleChanged.trigger(); + }, + setSectionLine(hex) { + sectionStyle.line = hex; + // LineMaterial.color assigns straight to the diffuse uniform — give it a + // THREE.Color (a string uploads as black); uniform-only, no recompile. + eachSectionMaterial("line", (m) => { + (m as unknown as { color: THREE.Color }).color = new THREE.Color(hex); + }); + requestRender(); + onStyleChanged.trigger(); + }, + setSectionOpacity(value) { + sectionStyle.opacity = value; + // Only the opacity uniform — keep `transparent` TRUE (set in the lib so the + // section sorts in front of the translucent hover). Opacity still applies: + // the overlay pass writes (colour, alpha) and straight-alpha composites it, + // so the fill stays translucent without toggling the transparent flag (which + // would drop the section back into the opaque group, behind the hover). + eachSectionMaterial("fill", (m) => { + m.opacity = value; + }); + requestRender(); + onStyleChanged.trigger(); + }, + setSectionWidth(value) { + sectionStyle.width = value; + // LineMaterial.linewidth is a uniform — no recompile. + eachSectionMaterial("line", (m) => { + (m as unknown as { linewidth: number }).linewidth = value; + }); + requestRender(); + onStyleChanged.trigger(); + }, + setStylingVisible(on) { + styler.visible = on; + onStyleChanged.trigger(); + }, + isStylingVisible: () => styler.visible, + }; +}; diff --git a/src/cli/templates/app/src/setups/clipper.ts b/src/cli/templates/app/src/setups/clipper.ts new file mode 100644 index 0000000..00017e6 --- /dev/null +++ b/src/cli/templates/app/src/setups/clipper.ts @@ -0,0 +1,75 @@ +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; +import type { ModeTool } from "./tool-mode"; + +/** + * Section / clipping-planes tool. Wraps `OBC.Clipper` as a toolbar mode tool: + * + * - double-click on the model → create a clip plane on the surface under the + * pointer (raycast; the plane's normal is the hit face normal), + * - drag the plane's arrow gizmo to move it along its normal (handled by the + * Clipper's built-in TransformControls while the mode is active), + * - Delete / Backspace → delete the plane under the cursor, + * - Shift + Delete / Shift + Backspace → clear ALL section planes. + * + * Clipping is applied through `renderer.three.clippingPlanes` with + * `localClippingEnabled = true` (set by the SimpleRenderer), so the cut is + * honored during the deferred pipeline's single capture render — no + * deferred-lib change is needed. (Worth an explicit live check that the section + * actually cuts geometry in deferred mode.) + * + * Leaving the mode keeps the existing section planes cutting the model (so a + * section persists while navigating); it only stops creating/dragging new ones. + */ +export const clipper = ( + components: OBC.Components, + world: OBC.World, +): ModeTool => { + const clipperComp = components.get(OBC.Clipper); + + const canvas = (world.renderer as OBF.PostproductionRenderer | null)?.three + .domElement; + if (!canvas) { + throw new Error("clipper setup: world has no renderer with a canvas"); + } + + const onDblClick = () => { + void clipperComp.create(world); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.code !== "Delete" && event.code !== "Backspace") return; + if (event.shiftKey) { + clipperComp.deleteAll(); + } else { + // No-op when nothing is hovered (the Clipper raycasts for a plane). + void clipperComp.delete(world); + } + }; + + let active = false; + + return { + label: "Section", + icon: "mdi:scissors-cutting", + activate() { + if (active) return; + active = true; + clipperComp.enabled = true; // allow create + drag-to-move + clipperComp.visible = true; + canvas.addEventListener("dblclick", onDblClick); + window.addEventListener("keydown", onKeyDown); + canvas.style.cursor = "crosshair"; + }, + deactivate() { + if (!active) return; + active = false; + // Stop creating / dragging, but keep the planes in the renderer's + // clippingPlanes so the section stays cut while navigating. + clipperComp.enabled = false; + canvas.removeEventListener("dblclick", onDblClick); + window.removeEventListener("keydown", onKeyDown); + canvas.style.cursor = ""; + }, + }; +}; diff --git a/src/cli/templates/app/src/setups/cloud-runner.ts b/src/cli/templates/app/src/setups/cloud-runner.ts index a99a36c..5834218 100644 --- a/src/cli/templates/app/src/setups/cloud-runner.ts +++ b/src/cli/templates/app/src/setups/cloud-runner.ts @@ -1,9 +1,13 @@ import * as OBC from "@thatopen/components"; -import { EngineServicesClient } from "@thatopen/services"; import { CloudRunner } from "../bim-components"; +import { getUIManager } from "./ui-manager"; -export const setupCloudRunner = (components: OBC.Components, client: EngineServicesClient) => { +export const cloudRunner = (components: OBC.Components) => { const runner = components.get(CloudRunner); - runner.client = client; - return runner; + const uis = getUIManager(components); + + // Re-render all appInfoSection instances whenever execution state changes. + runner.onExecutionUpdated.add(() => { + uis.custom.get("cloudRunnerSection").updateInstances(); + }); }; diff --git a/src/cli/templates/app/src/setups/commands-panel.ts b/src/cli/templates/app/src/setups/commands-panel.ts new file mode 100644 index 0000000..2e8f263 --- /dev/null +++ b/src/cli/templates/app/src/setups/commands-panel.ts @@ -0,0 +1,239 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; +import * as BUI from "@thatopen/ui"; +import { hider } from "./hider"; + +/** + * COMMANDS — a docked side-panel (alongside Explorer / Files / Graphics) that is + * both the keyboard-shortcut registry and its palette UI. + * + * A single `COMMANDS` table maps each viewer action to a shortcut + an mdi icon + * and a `run()` wired to the EXISTING components (the `hider` controller for + * visibility, the Highlighter for the current selection + clear, camera-controls + * for focus). The panel lists every command with its shortcut (command-palette + * style, click-to-run), and a global `keydown` handler dispatches the shortcuts — + * ignored while typing in an input so search fields aren't hijacked. + * + * Self-contained: returns the panel element WITHOUT self-mounting (like + * modelTree / graphicsPanel) and installs its own window keydown listener. + * + * @param components engine components + */ + +interface ViewerCommand { + id: string; + label: string; + icon: string; + /** Display chips, e.g. ["Shift", "H"]. */ + keys: string[]; + /** Does this keydown event trigger the command? */ + match: (e: KeyboardEvent) => boolean; + run: () => void; +} + +const noMods = (e: KeyboardEvent) => + !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey; + +// Don't hijack shortcuts while the user is typing in a field. +const typingInField = (e: KeyboardEvent) => { + const path = (e.composedPath?.() ?? []) as HTMLElement[]; + const el = (path[0] ?? (e.target as HTMLElement)) || null; + const active = (document.activeElement as HTMLElement) || null; + const isField = (n: HTMLElement | null) => + !!n && + (n.isContentEditable || + /^(input|textarea|select)$/i.test(n.tagName) || + /^bim-(text|number)-input$/i.test(n.tagName)); + return path.some(isField) || isField(el) || isField(active); +}; + +export const commandsPanel = (components: OBC.Components) => { + const fragments = components.get(OBC.FragmentsManager); + const highlighter = components.get(OBF.Highlighter); + const selectName = highlighter.config.selectName; + const h = hider(components); + + const hasSelection = () => { + const map = highlighter.selection[selectName]; + return !!map && Object.values(map).some((s) => s.size > 0); + }; + + const focusSelection = async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const world = [...components.get(OBC.Worlds).list.values()][0] as any; + const controls = world?.camera?.controls; + if (!controls) return; + const map = highlighter.selection[selectName]; + const hasSel = !!map && Object.values(map).some((s) => s.size > 0); + try { + const box = new THREE.Box3(); + if (hasSel) { + const boxes = (await fragments.getBBoxes(map)) as THREE.Box3[]; + for (const b of boxes) box.union(b); + } else { + // Nothing selected → focus = zoom to fit the WHOLE model (union of all + // loaded models' bounds). This makes a dedicated fit/home button unnecessary. + for (const model of fragments.list.values()) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b = (model as any).box as THREE.Box3 | undefined; + if (b && !b.isEmpty()) box.union(b); + } + } + if (box.isEmpty()) return; + const sphere = box.getBoundingSphere(new THREE.Sphere()); + await controls.fitToSphere(sphere, true); + } catch (error) { + console.warn("[commands] focus failed", error); + } + }; + + const clearSelection = () => void highlighter.clear(selectName); + + const COMMANDS: ViewerCommand[] = [ + { + id: "focus", + label: "Focus selection", + icon: "mdi:image-filter-center-focus", + keys: ["F"], + match: (e) => e.key.toLowerCase() === "f" && noMods(e), + run: () => void focusSelection(), + }, + { + id: "hide", + label: "Hide selected", + icon: "mdi:eye-off-outline", + keys: ["H"], + match: (e) => e.key.toLowerCase() === "h" && noMods(e), + run: () => h.hideSelected(), + }, + { + id: "isolate", + label: "Isolate selected", + icon: "mdi:select-search", + keys: ["Shift", "H"], + match: (e) => + e.key.toLowerCase() === "h" && + e.shiftKey && + !e.ctrlKey && + !e.metaKey && + !e.altKey, + run: () => h.isolateSelected(), + }, + { + id: "ghost", + label: "Ghost selected", + icon: "mdi:ghost-outline", + keys: ["G"], + match: (e) => e.key.toLowerCase() === "g" && noMods(e), + run: () => h.ghostSelected(), + }, + { + id: "showAll", + label: "Show all", + icon: "mdi:eye-outline", + keys: ["Shift", "A"], + match: (e) => + e.key.toLowerCase() === "a" && + e.shiftKey && + !e.ctrlKey && + !e.metaKey && + !e.altKey, + run: () => h.showAll(), + }, + { + id: "clear", + label: "Clear selection", + icon: "mdi:close", + keys: ["Esc"], + match: (e) => e.key === "Escape" && noMods(e), + run: () => clearSelection(), + }, + ]; + + // ── Global shortcut dispatch ─────────────────────────────────── + const onKeyDown = (e: KeyboardEvent) => { + if (typingInField(e)) return; + for (const cmd of COMMANDS) { + if (cmd.match(e)) { + e.preventDefault(); + cmd.run(); + return; + } + } + }; + window.addEventListener("keydown", onKeyDown); + + // ── Panel UI (command-palette list; mirrors graphics-panel chrome) ── + const [panel, update] = BUI.Component.create( + (state) => { + const q = state.query.trim().toLowerCase(); + const rows = COMMANDS.filter((c) => !q || c.label.toLowerCase().includes(q)); + return BUI.html` + + +
+
+ { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + update({ query: String((e.target as any).value ?? "") }); + }} + > +
+
+ ${rows.length === 0 + ? BUI.html`
No commands.
` + : rows.map( + (c) => BUI.html` +
c.run()}> + + ${c.label} + + ${c.keys.map( + (k, i) => BUI.html`${i > 0 + ? BUI.html`+` + : null}${k}`, + )} + +
`, + )} +
+
+
+
+ `; + }, + { query: "" }, + ); + + return panel; +}; diff --git a/src/cli/templates/app/src/setups/data-table-panel.ts b/src/cli/templates/app/src/setups/data-table-panel.ts new file mode 100644 index 0000000..30aaf0b --- /dev/null +++ b/src/cli/templates/app/src/setups/data-table-panel.ts @@ -0,0 +1,2224 @@ +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; +import * as FRAGS from "@thatopen/fragments"; +import * as BUI from "@thatopen/ui"; +import { cardHeader } from "./card-header"; +import { getAppManager } from "../app"; + +/** + * ELEMENT DATA TABLE (Trimble-style) — a VIRTUALIZED (windowed) tabular browser. + * + * Rows = elements; columns = attributes + property-set values + quantities. The + * table is sortable (click a header), filterable (text box, all visible columns), + * groupable (None / Category / Storey, with collapsible group bands), exports + * CSV, and is two-way bound to the 3D selection (click a row → highlight in 3D; + * a 3D/tree selection reflects back as selected rows). A COLUMN PICKER popover + * lets the user choose which of the discovered (heterogeneous) attribute / pset / + * quantity columns are shown — and assign each a roll-up function. Per-group + * roll-up cells (sum/avg/min/max/count/distinct, or a shared-value/"varies" + * sentinel for non-aggregated columns) and a footer grand total present the + * aggregation (quantity-takeoff style). Filtering is a structured AND-of-ORs + * query (typed per-column operators) plus a quick contains box. + * + * WHY VIRTUALIZED: bim-table renders every row as real DOM over the WebGL canvas + * (per-frame compositing cost) — see model-tree.ts' header. This hand-rolls the + * same windowed list: fixed-height rows, a scroll viewport + sizer, and only the + * rows in the scroll window are in the DOM (recycled on scroll). Horizontal + * layout is fixed-width cells; one sticky header row scrolls with the body. + * + * DATA LAYER (interned columnar): the worker returns a COLUMNAR + INTERNED table + * — a shared string dictionary plus, per column, an Int32Array of dictionary + * indices (text) and (numeric columns) a Float64Array (num). We keep a master + * dictionary + per-column typed arrays aligned by rowIdx; a "row" is just a + * rowIdx and a cell resolves via cellText/cellNum (no per-row objects). FIRST + * PAINT is attributes-only — `model.getTableData({ mode: "attributes" })`, ~sub- + * second — and pset/quantity columns are filled lazily in the BACKGROUND + * (`model.getTablePsets`, batched per model, merged progressively) so the panel + * is interactive immediately and pset-column ops light up as they land. Each + * payload carries its own dictionary, remapped into the master one on merge. + * Type-level psets are merged worker-side (includeTypePsets, instance wins). The + * store is bounded (MAX_ROWS) so footer aggregation is a plain columnar scan. + * Storey is enriched client-side (cheap spatial-tree walk) — the worker table has + * no storey column. + * + * @param components engine components + */ + +const ROW_H = 26; // px, fixed row height (data + group rows) → simple windowing +const HEAD_H = 30; // px, sticky header row height +const INDENT = 14; // px per group-nesting level (multi-level grouping indent) +const CELL_PAD = 8; // px base left padding of a cell (≈ the .dt-c 0.5rem) +const MIN_COL = 72; // px floor on rendered column width → stays readable in the narrow panel +const BUFFER = 8; // extra rows above/below the viewport +// Hard cap on TOTAL indexed elements across ALL loaded models (surfaced via the +// note). Generous because the interned columnar store is ~6-8x lighter than the +// old array-of-objects, so a single large model can't starve a second model's +// rows out of the budget (the multi-model case). +const MAX_ROWS = 100000; + +// Spatial containers are not table rows (they're structure, not elements). +const CONTAINER_CATEGORIES = new Set([ + "IFCPROJECT", + "IFCSITE", + "IFCBUILDING", + "IFCBUILDINGSTOREY", +]); + +const prettyCategory = (category: string) => { + const base = (category || "").replace(/^IFC/i, ""); + if (!base) return category || ""; + return base.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase()); +}; + +const esc = (s: string) => + s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +// Strict numeric parse: only a bare integer/decimal counts as a number (so "C25/30" +// or "true" stay text). Returns NaN when the value is not purely numeric. +const asNumber = (v: string): number => { + const t = v.trim(); + if (!t || !/^-?\d+(\.\d+)?$/.test(t)) return NaN; + return Number(t); +}; + +const fmtNum = (n: number): string => + Number.isInteger(n) ? n.toLocaleString() : n.toLocaleString(undefined, { maximumFractionDigits: 3 }); + +// ── Filter model (grimoire: filter-operator-set + query-expression-form) ────── +// Normalized-string-compare: case- AND diacritic-insensitive comparison key, so +// "Café" matches "cafe". Shared by the filter and the quick search. +const normText = (s: string) => + s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().trim(); +// Precision-bounded-numeric-compare: round both operands before comparing so +// float error (2.4000001 vs 2.4) doesn't flip an equality / boundary test. +const roundC = (n: number) => Math.round(n * 1e6) / 1e6; + +// Per-column-type operator catalogue (filter-operator-set). Presence operators +// (set/not-set/empty) are shared; the rest are type-gated. Each operator has a +// negated form where relevant, so per-operator negation covers what a NOT node +// would (serializable-normal-form: no standalone NOT combinator). +type Op = + | "contains" | "ncontains" | "eq" | "neq" | "starts" // text + | "eqn" | "neqn" | "lt" | "le" | "gt" | "ge" | "between" // number + | "empty" | "set" | "unset"; // presence (any type) +const TEXT_OPS: { op: Op; label: string }[] = [ + { op: "contains", label: "contains" }, + { op: "ncontains", label: "not contains" }, + { op: "eq", label: "equals" }, + { op: "neq", label: "not equals" }, + { op: "starts", label: "starts with" }, + { op: "empty", label: "is empty" }, + { op: "set", label: "is set" }, + { op: "unset", label: "not set" }, +]; +const NUM_OPS: { op: Op; label: string }[] = [ + { op: "eqn", label: "=" }, + { op: "neqn", label: "≠" }, + { op: "lt", label: "<" }, + { op: "le", label: "≤" }, + { op: "gt", label: ">" }, + { op: "ge", label: "≥" }, + { op: "between", label: "between" }, + { op: "set", label: "is set" }, + { op: "unset", label: "not set" }, +]; +const PRESENCE_OPS = new Set(["empty", "set", "unset"]); + +// One filter condition. The list compiles to AND-of-ORs: an `OR`-tagged +// condition joins the previous disjunctive group; an `AND`-tagged one starts a +// new group; the groups are conjoined (serializable-normal-form). +interface Condition { + id: number; + col: string; + op: Op; + v: string; // operand + v2: string; // second operand (between) + conj: "AND" | "OR"; +} + +// ── Aggregation model (grimoire: aggregation-presentation) ──────────────────── +// per-column-function-selection: each column may carry a roll-up function. sum/ +// avg/min/max apply to numeric columns; count/distinct apply to any. +type AggFn = "sum" | "avg" | "min" | "max" | "count" | "distinct"; +const NUM_FNS: AggFn[] = ["sum", "avg", "min", "max", "count", "distinct"]; +const ANY_FNS: AggFn[] = ["count", "distinct"]; +const fnsFor = (kind: "text" | "number") => (kind === "number" ? NUM_FNS : ANY_FNS); + +// (computeAgg + groupShared live inside the panel closure — they read the +// interned columnar store via the cell accessors.) + +// ── Saved views (grimoire: saved-configuration) ─────────────────────────────── +// The MEANINGFUL config of the table — visible columns, aggregations, the AND-of- +// ORs filter (+ quick text), multi-key sort, and group levels. Incidental state +// (scroll, hover, selection) is excluded so dirty-detection doesn't fire on noise. +interface ViewConfig { + visibleCols: string[]; + aggs: [string, AggFn][]; // sorted by key for canonical serialization + conditions: Omit[]; // volatile `id` dropped (canonical) + query: string; + sortSpec: { col: string; dir: 1 | -1 }[]; + groupCols: string[]; +} +interface SavedView { + name: string; // personal scope (no elevated-permission path needed) + config: ViewConfig; +} + +// ── Column model (INTERNED COLUMNAR) ─────────────────────────────── +// Mirrors the worker's TableData column: UI metadata (key/group/label/kind/ +// width) PLUS the columnar data. `text[rowIdx]` is an index into the panel's +// master `strings` dictionary (-1 = no value); `num[rowIdx]` (numeric columns +// only) is the canonical value for sort/aggregate (NaN = no value). Arrays are +// length === rowCount, aligned by rowIdx. There is NO per-row object: a "row" +// is just a rowIdx, and a cell resolves via cellText/cellNum (see below). +interface Col { + key: string; + group: string; // "Attributes" | "Spatial" | pset name | quantity-set name + label: string; + kind: "text" | "number"; + width: number; // px + text: Int32Array; // dictionary index per row (-1 = no value) + num: Float64Array | null; // numeric per row (NaN = no value); null for text columns +} + +// Fixed attribute columns offered by default / in the picker. +const ATTR_COLUMNS: { attr: string; key: string; label: string; width: number }[] = [ + { attr: "_category", key: "attr:_category", label: "Category", width: 150 }, + { attr: "Name", key: "attr:Name", label: "Name", width: 220 }, + { attr: "_localId", key: "attr:_localId", label: "LocalId", width: 90 }, + { attr: "_guid", key: "attr:_guid", label: "GUID", width: 200 }, + { attr: "ObjectType", key: "attr:ObjectType", label: "Object Type", width: 160 }, + { attr: "PredefinedType", key: "attr:PredefinedType", label: "Predefined Type", width: 150 }, + { attr: "Tag", key: "attr:Tag", label: "Tag", width: 110 }, + { attr: "Description", key: "attr:Description", label: "Description", width: 200 }, +]; +const STOREY_KEY = "meta:storey"; +const DEFAULT_VISIBLE = ["attr:_category", "attr:Name", STOREY_KEY]; + +export const dataTablePanel = (components: OBC.Components) => { + const fragments = components.get(OBC.FragmentsManager); + const highlighter = components.get(OBF.Highlighter); + const selectName = highlighter.config.selectName; + + // ── Interned columnar store ──────────────────────────────────── + // The master string dictionary + per-column typed arrays, aligned by rowIdx. + // A "row" is a rowIdx in [0, rowCount); cells resolve through cellText/cellNum. + let strings: string[] = []; // master dictionary (text cells index into this) + let intern = new Map(); // string -> dictionary index (dedupe) + let rowCount = 0; + let rowLocalId = new Uint32Array(0); // rowIdx -> element localId + let rowModelIdx = new Uint16Array(0); // rowIdx -> index into modelIdsOrder + let modelIdsOrder: string[] = []; // model id per rowModelIdx slot + const columns = new Map(); // discovered RAW columns (per-pset, all) + let visibleCols: string[] = [...DEFAULT_VISIBLE]; // ordered visible column keys + + // ── Name-collapse layer (default ON) ─────────────────────────── + // The pset/quantity columns are keyed by (set, prop), so the same property + // (e.g. "GrossArea") appears once per pset. By default the COLUMN UNIVERSE the + // user sees (picker / filter / group / sort / agg) collapses those to ONE entry + // per property NAME, unioning values across psets; "Show by pset" flips back to + // the raw provenance keys. nameMembers maps a universe key -> its raw member + // keys; collapsedMeta holds the synthetic column metadata. + type ColMeta = { key: string; label: string; group: string; kind: "text" | "number"; width: number; conflict?: boolean }; + let collapseByName = true; + const nameMembers = new Map(); + const collapsedMeta = new Map(); + const rebuildNameIndex = () => { + nameMembers.clear(); + collapsedMeta.clear(); + const byName = new Map(); + for (const c of columns.values()) { + const isProp = c.group !== "Attributes" && c.group !== "Spatial"; + if (!isProp) { + // attr / storey columns are already unique → carry through as themselves. + nameMembers.set(c.key, [c.key]); + collapsedMeta.set(c.key, { key: c.key, label: c.label, group: c.group, kind: c.kind, width: c.width }); + } else { + let arr = byName.get(c.label); + if (!arr) { + arr = []; + byName.set(c.label, arr); + } + arr.push(c); + } + } + for (const [name, cols] of byName) { + const key = `name:${name}`; + const allNum = cols.every((c) => c.kind === "number"); + const anyNum = cols.some((c) => c.kind === "number"); + collapsedMeta.set(key, { + key, + label: name, + group: "Properties", + kind: allNum ? "number" : "text", + width: Math.max(...cols.map((c) => c.width)), + conflict: anyNum && !allNum, // mixed type across psets → flagged, not coerced + }); + nameMembers.set(key, cols.map((c) => c.key)); + } + }; + // Active-universe column metadata for a key (collapsed or raw, per mode). + const colMeta = (key: string): ColMeta | Col | undefined => + collapseByName ? collapsedMeta.get(key) : columns.get(key); + // The active column universe (what the picker / filter / group dropdowns list). + const universe = (): ColMeta[] | Col[] => + collapseByName ? [...collapsedMeta.values()] : [...columns.values()]; + // Whether a key exists in the ACTIVE universe (mode-aware existence check). + const inUniverse = (key: string): boolean => + collapseByName ? collapsedMeta.has(key) : columns.has(key); + + // ── Cell accessors (the ONLY way to read the columnar store) ─── + const internStr = (s: string): number => { + let i = intern.get(s); + if (i === undefined) { + i = strings.length; + strings.push(s); + intern.set(s, i); + } + return i; + }; + const rowKeyOf = (r: number): string => `${modelIdsOrder[rowModelIdx[r]]}:${rowLocalId[r]}`; + // Raw single-column resolution (operates on the per-pset store directly). + const rawText = (r: number, key: string): string => { + const c = columns.get(key); + if (!c) return ""; + const i = c.text[r]; + return i < 0 ? "" : strings[i]; + }; + const rawNum = (r: number, key: string): number => { + const c = columns.get(key); + if (!c) return NaN; + if (c.num) return c.num[r]; + const i = c.text[r]; + return i < 0 ? NaN : asNumber(strings[i]); + }; + const rawHas = (r: number, key: string): boolean => { + const c = columns.get(key); + return !!c && c.text[r] >= 0; + }; + // Collapse-aware resolution (the ONLY accessors the feature code uses). In + // by-pset mode they're 1:1 with raw. In collapse mode a property key unions its + // member psets: present iff ANY member has it; agreed value if all present + // members match, else the "Varies" sentinel; numeric "varies"/missing → NaN + // (excluded from numeric compare/agg — conflicting elements are skipped, not + // zeroed). + const cellText = (r: number, key: string): string => { + if (!collapseByName) return rawText(r, key); + const members = nameMembers.get(key); + if (!members) return ""; + if (members.length === 1) return rawText(r, members[0]); + let val: string | undefined; + let has = false; + let varies = false; + for (const m of members) { + if (!rawHas(r, m)) continue; + const v = rawText(r, m); + if (!has) { + val = v; + has = true; + } else if (v !== val) { + varies = true; + } + } + return !has ? "" : varies ? "Varies" : (val ?? ""); + }; + const cellNum = (r: number, key: string): number => { + if (!collapseByName) return rawNum(r, key); + const members = nameMembers.get(key); + if (!members) return NaN; + if (members.length === 1) return rawNum(r, members[0]); + let val = NaN; + let has = false; + let varies = false; + for (const m of members) { + if (!rawHas(r, m)) continue; + const n = rawNum(r, m); + if (!has) { + val = n; + has = true; + } else if (n !== val) { + varies = true; + } + } + return !has || varies ? NaN : val; + }; + const cellHas = (r: number, key: string): boolean => { + if (!collapseByName) return rawHas(r, key); + const members = nameMembers.get(key); + if (!members) return false; + return members.some((m) => rawHas(r, m)); + }; + + // Retained store size (bytes): dictionary chars (UTF-16 + per-string overhead) + // + every column's typed arrays + the row arrays. The typed-array term is + // O(rows × cols) and DENSE regardless of sparsity (a column allocates a full + // text[rowCount], and num[rowCount] if numeric), so it scales linearly with + // rows and with the number of discovered columns — independent of how many + // cells actually have values. Interning makes the dictionary sub-linear (a + // category/type string is stored once no matter how many rows repeat it). + const storeBytes = () => { + let dict = 0; + for (const s of strings) dict += s.length * 2 + 16; // UTF-16 + ~obj overhead + let arrays = 0; + for (const c of columns.values()) arrays += c.text.byteLength + (c.num?.byteLength ?? 0); + const rowsB = rowLocalId.byteLength + rowModelIdx.byteLength; + return { dict, arrays, rowsB, total: dict + arrays + rowsB }; + }; + const logStoreBytes = (phase: string) => { + const b = storeBytes(); + const mb = (n: number) => (n / 1048576).toFixed(2); + console.log( + `[data-table] retained store (${phase}): ${mb(b.total)} MB ` + + `[dict ${mb(b.dict)} + typed-arrays ${mb(b.arrays)} + rows ${mb(b.rowsB)}] ` + + `· ${rowCount.toLocaleString()} rows × ${columns.size} cols · dict ${strings.length.toLocaleString()} strings`, + ); + }; + + // Register/return a column, allocating its rowCount-length arrays. preferNumber + // upgrades a text column to numeric (heterogeneous psets may mix). + const ensureColumn = ( + key: string, + group: string, + label: string, + kind: "text" | "number", + width: number, + preferNumber = false, + ): Col => { + let col = columns.get(key); + if (!col) { + col = { + key, + group, + label, + kind, + width, + text: new Int32Array(rowCount).fill(-1), + num: kind === "number" ? new Float64Array(rowCount).fill(NaN) : null, + }; + columns.set(key, col); + } else if (preferNumber && col.kind === "text") { + col.kind = "number"; + if (!col.num) col.num = new Float64Array(rowCount).fill(NaN); + } + return col; + }; + + // ── Aggregation over a set of rowIdx (reads the columnar store) ─ + // Numeric reduces use cellNum (skip NaN/"no value"); count = present values; + // distinct = distinct present display strings. Returns "" when nothing to show. + const computeAgg = (arr: number[], col: string, fn: AggFn): string => { + if (fn === "count") { + let n = 0; + for (const r of arr) if (cellHas(r, col)) n++; + return n.toLocaleString(); + } + if (fn === "distinct") { + const s = new Set(); + for (const r of arr) { + if (cellHas(r, col)) s.add(cellText(r, col)); + } + return s.size.toLocaleString(); + } + let acc = 0; + let n = 0; + let mn = Infinity; + let mx = -Infinity; + for (const r of arr) { + const x = cellNum(r, col); + if (Number.isNaN(x)) continue; // "no value" excluded from numeric reduce + acc += x; + n++; + if (x < mn) mn = x; + if (x > mx) mx = x; + } + if (n === 0) return ""; + switch (fn) { + case "sum": return fmtNum(acc); + case "avg": return fmtNum(acc / n); + case "min": return fmtNum(mn); + case "max": return fmtNum(mx); + default: return ""; + } + }; + + // mixed-value-sentinel: a non-aggregated column's group cell shows the shared + // value if all members agree, "Varies" if they differ, or empty if all members + // have "no value" (distinct from "varies"). + const groupShared = (arr: number[], col: string): { text: string; varies: boolean } => { + let first: string | undefined; + let has = false; + for (const r of arr) { + if (!cellHas(r, col)) continue; + const v = cellText(r, col); + if (!has) { + first = v; + has = true; + } else if (v !== first) { + return { text: "Varies", varies: true }; + } + } + return has ? { text: first ?? "", varies: false } : { text: "", varies: false }; + }; + let query = ""; // quick filter (global contains across visible columns) + let conditions: Condition[] = []; // structured filter (AND-of-ORs) + let condId = 0; + // value-faceting (scan-on-demand-distinct): distinct values per column for the + // operand suggestions, cached; invalidated on each index rebuild. + const facetCache = new Map(); + // multi-key-stable-sort: ordered (column, direction) keys; the base JS sort is + // stable, so layering keys composes (first non-equal key decides, ties fall to + // the next, final ties keep prior order). + let sortSpec: { col: string; dir: 1 | -1 }[] = []; + // grouping-key/property-value-key: ordered list of columns to group by (one + // group level per entry, nested); empty = ungrouped. + let groupCols: string[] = []; + // per-column-function-selection: columnKey → roll-up function (footer + group). + const aggs = new Map(); + const collapsedGroups = new Set(); + const selectedRowKeys = new Set(); + + // ── Saved views (persisted to the hidden __project_data/bim-viewer/ folder) ── + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let client: any = null; + let projectId: string | undefined; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const app = getAppManager(components) as any; + client = app?.client ?? null; + projectId = client?.context?.projectId; + } catch { + /* no AppManager (e.g. standalone dev harness) → views just don't persist */ + } + let savedViews: SavedView[] = []; + let activeViewName: string | null = null; + let savingNew = false; // the "Save as…" inline name input is open + let newViewName = ""; + // Which on-demand editor is open below the compact toolbar (one at a time). + let openEditor: "" | "filter" | "group" | "views" = ""; + + // Filtered+sorted data rows, then the flattened visible list (group bands + + // data rows) the virtualizer renders. + let filtered: number[] = []; // rowIdx of rows passing the filter, in sort order + type VRow = + | { + t: "group"; + gkey: string; // full path key (unique across levels) for collapse state + label: string; + count: number; + level: number; // 0-based nesting depth (multi-level grouping) + // per-column group roll-up display (aggregate or shared/"varies" value) + cells: Record; + varies: Set; // columns whose group cell is the "varies" sentinel + } + | { t: "data"; row: number; level: number }; // row = rowIdx; level = indent depth + let visible: VRow[] = []; + + let rebuildToken = 0; + let searchTimer: number | undefined; + // Guard against the select→table→select feedback loop: raised around a + // table-initiated highlight drive, consumed by the echoing onHighlight. + let pendingInternal = 0; + + // ── Virtualization DOM (created once, managed imperatively) ───── + const viewport = document.createElement("div"); + viewport.className = "dt-vp"; + const head = document.createElement("div"); + head.className = "dt-head"; + const body = document.createElement("div"); + body.className = "dt-body"; + body.style.position = "relative"; + const content = document.createElement("div"); + content.style.position = "absolute"; + content.style.top = "0"; + content.style.left = "0"; + content.appendChild(document.createElement("div")); // placeholder, replaced + content.firstElementChild?.remove(); + body.appendChild(content); + viewport.appendChild(head); + viewport.appendChild(body); + + // Re-fill width on panel resize (recompute the last-column flex + repaint). + let resizeRaf = 0; + const resizeObserver = new ResizeObserver(() => { + if (resizeRaf) return; + resizeRaf = requestAnimationFrame(() => { + resizeRaf = 0; + recomputeFlex(); + renderHead(); + render(true); + }); + }); + resizeObserver.observe(viewport); + + // User-dragged column widths, keyed by the active-universe column key (collapsed + // name: key or raw pset key per mode) so they survive re-renders + the by-pset + // toggle. In-memory only for now (persisting across sessions is task #34). + const userWidths = new Map(); + // Rendered column width: the user's dragged width if any, else the column's + // declared width — floored at MIN_COL so a skinny column still reads. + const colW = (k: string) => Math.max(MIN_COL, userWidths.get(k) ?? colMeta(k)?.width ?? 150); + + // Extra px added to the LAST visible column so the table FILLS the panel width + // (no dead gutter) when the columns don't already overflow. Recomputed in + // refreshList from the viewport's client width; 0 when columns overflow (then + // the body scrolls horizontally as normal). + let lastColExtra = 0; + const recomputeFlex = () => { + const avail = viewport.clientWidth || 0; + const base = visibleCols.reduce((w, k) => w + colW(k), 0); + lastColExtra = avail > 0 ? Math.max(0, avail - base) : 0; + }; + // Effective rendered width: colW plus the flex remainder on the last column — + // but a USER-RESIZED last column keeps its dragged width (auto-fill yields to an + // explicit width); the resize of any non-last column still leaves the last one + // filling the gap. + const effColW = (k: string) => { + const isLast = k === visibleCols[visibleCols.length - 1]; + return colW(k) + (isLast && !userWidths.has(k) ? lastColExtra : 0); + }; + + const totalWidth = () => visibleCols.reduce((w, k) => w + effColW(k), 0); + + // ── Header row ───────────────────────────────────────────────── + const renderHead = () => { + const tw = totalWidth(); + head.style.width = `${tw}px`; + head.innerHTML = visibleCols + .map((k) => { + const c = colMeta(k); + if (!c) return ""; + const si = sortSpec.findIndex((s) => s.col === k); + const active = si >= 0; + const arrow = active ? (sortSpec[si].dir === 1 ? "mdi:menu-up" : "mdi:menu-down") : ""; + // Show the 1-based sort order only when more than one key is active. + const ord = active && sortSpec.length > 1 ? `${si + 1}` : ""; + return ( + `
` + + `${esc(c.label)}` + + (arrow ? `` : "") + + ord + + `` + + `
` + ); + }) + .join(""); + }; + + // ── Body rows ────────────────────────────────────────────────── + const dataRowHtml = (r: number, level: number) => { + const cells = visibleCols + .map((k, idx) => { + const c = colMeta(k); + const w = effColW(k); + const v = cellText(r, k); + const num = c?.kind === "number"; + // Indent the first cell by the nesting depth so grouped rows step in. + const pad = idx === 0 ? `padding-left:${CELL_PAD + level * INDENT}px;` : ""; + // Value lives in an inner span: text-overflow:ellipsis needs a block-ish + // child, it never applies to a bare text node in a flex cell (would clip + // with no "…"). The span is the ellipsizing element. + return `
${esc(v)}
`; + }) + .join(""); + const rk = rowKeyOf(r); + return ( + `
${cells}
` + ); + }; + + const groupRowHtml = (g: Extract) => { + const collapsed = collapsedGroups.has(g.gkey); + // Per-column cells: the first visible column carries the group identity + // (caret + label + count); the rest show the column's roll-up (aggregate, or + // shared value / "varies" sentinel) aligned under its header. + const cells = visibleCols + .map((k, idx) => { + const col = colMeta(k); + const w = effColW(k); + const num = col?.kind === "number"; + if (idx === 0) { + return ( + `
` + + `` + + `${esc(g.label)}` + + `${g.count.toLocaleString()}` + + `
` + ); + } + const txt = g.cells[k] ?? ""; + const isVaries = g.varies.has(k); + return `
${esc(txt)}
`; + }) + .join(""); + return ( + `
` + + cells + + `
` + ); + }; + + const vRowHtml = (v: VRow) => (v.t === "group" ? groupRowHtml(v) : dataRowHtml(v.row, v.level)); + + const buildRow = (i: number): HTMLElement => { + const tmp = document.createElement("div"); + tmp.innerHTML = vRowHtml(visible[i]); + return tmp.firstElementChild as HTMLElement; + }; + + // Index→element recycler (fixed height): rows staying in the window keep their + // exact DOM (and rendered bim-icons) across scroll; only entering rows are + // built, leaving rows removed. A forced rebuild drops all (data/filter/sort + // changed) so the window is rebuilt once off the scroll path. + const mounted = new Map(); + let lastStart = -1; + let lastEnd = -1; + + const render = (force = false) => { + const total = visible.length; + body.style.height = `${total * ROW_H}px`; + body.style.width = `${totalWidth()}px`; + const top = viewport.scrollTop; + const vh = (viewport.clientHeight || ROW_H) - HEAD_H; + const start = Math.max(0, Math.floor(top / ROW_H) - BUFFER); + const end = Math.min(total, Math.ceil((top + vh) / ROW_H) + BUFFER); + if (!force && start === lastStart && end === lastEnd) return; + if (force) { + mounted.clear(); + content.textContent = ""; + } + lastStart = start; + lastEnd = end; + content.style.transform = `translateY(${start * ROW_H}px)`; + for (const [i, el] of mounted) { + if (i < start || i >= end) { + el.remove(); + mounted.delete(i); + } + } + let cursor = content.firstElementChild as HTMLElement | null; + for (let i = start; i < end; i++) { + const existing = mounted.get(i); + if (existing) { + cursor = existing.nextElementSibling as HTMLElement | null; + continue; + } + const el = buildRow(i); + content.insertBefore(el, cursor); + mounted.set(i, el); + } + }; + + // ── Filter → sort → group → flatten ──────────────────────────── + // Quick filter: normalized contains across visible columns (the convenience + // bar, ANDed with the structured query below). + const quickMatch = (r: number, q: string) => { + if (!q) return true; + for (const k of visibleCols) { + if (normText(cellText(r, k)).includes(q)) return true; + } + return false; + }; + + // Evaluate one typed predicate against a row (filter-operator-set). "No value" + // (absent column) satisfies only `unset`; value operators exclude it. An + // incomplete value-operator (blank operand) is ignored (treated as pass) so + // the table isn't emptied while the user is still typing the operand. + const evalCondition = (r: number, c: Condition): boolean => { + const col = colMeta(c.col); + if (!col) return true; // stale column → ignore + const present = cellHas(r, c.col); + const raw = cellText(r, c.col); + if (c.op === "unset") return !present; + if (c.op === "set") return present; + if (c.op === "empty") return present && raw === ""; + if (!PRESENCE_OPS.has(c.op) && c.v.trim() === "") return true; // incomplete → ignore + if (col.kind === "number") { + const n = present ? cellNum(r, c.col) : NaN; + if (Number.isNaN(n)) return false; // "no value" satisfies no numeric compare + const x = roundC(n); + const a = roundC(asNumber(c.v)); + switch (c.op) { + case "eqn": return x === a; + case "neqn": return x !== a; + case "lt": return x < a; + case "le": return x <= a; + case "gt": return x > a; + case "ge": return x >= a; + case "between": { + const b = roundC(asNumber(c.v2)); + return x >= Math.min(a, b) && x <= Math.max(a, b); + } + default: return true; + } + } + if (!present) return false; + const hv = normText(raw); + const nv = normText(c.v); + switch (c.op) { + case "contains": return hv.includes(nv); + case "ncontains": return !hv.includes(nv); + case "eq": return hv === nv; + case "neq": return hv !== nv; + case "starts": return hv.startsWith(nv); + default: return true; + } + }; + + // Compile the flat condition list to AND-of-ORs and test a row: walk the list, + // a run of OR-tagged conditions forms a disjunctive group, AND-tagged starts a + // new group; the row passes iff EVERY group has at least one true condition. + const structuredMatch = (r: number): boolean => { + if (conditions.length === 0) return true; + let i = 0; + while (i < conditions.length) { + let groupOk = evalCondition(r, conditions[i]); + let j = i + 1; + while (j < conditions.length && conditions[j].conj === "OR") { + groupOk = groupOk || evalCondition(r, conditions[j]); + j++; + } + if (!groupOk) return false; // a conjunctive group failed + i = j; + } + return true; + }; + + // value-faceting (scan-on-demand-distinct): distinct, present values of a + // column for operand suggestions. Cached; bounded so a huge column can't flood. + const distinctValues = (col: string): string[] => { + const hit = facetCache.get(col); + if (hit) return hit; + const seen = new Set(); + for (let r = 0; r < rowCount; r++) { + if (cellHas(r, col)) seen.add(cellText(r, col)); + if (seen.size > 400) break; + } + const out = [...seen] + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) + .slice(0, 100); + facetCache.set(col, out); + return out; + }; + + // The group-key VALUE for a row on a column (property-value-key). "No value" + // is its own deterministic group; the category column is prettified to match + // how it reads elsewhere. + const groupValueOf = (r: number, col: string): string => { + if (!cellHas(r, col)) return "(no value)"; + const v = cellText(r, col); + if (v === "") return "(no value)"; + return col === "attr:_category" ? prettyCategory(v) || v : v; + }; + + // Type-aware, null-last comparison of two rows on one column for a direction. + // "No value" always sinks last regardless of `dir` (deterministic-null- + // ordering); present values use natural-numeric collation / canonical numeric. + const compareByCol = (a: number, b: number, col: string, dir: 1 | -1): number => { + if (colMeta(col)?.kind === "number") { + const an = cellNum(a, col); + const bn = cellNum(b, col); + const am = Number.isNaN(an); + const bm = Number.isNaN(bn); + if (am && bm) return 0; + if (am) return 1; + if (bm) return -1; + return (an - bn) * dir; + } + const av = cellText(a, col); + const bv = cellText(b, col); + const am = av === ""; + const bm = bv === ""; + if (am && bm) return 0; + if (am) return 1; + if (bm) return -1; + return av.localeCompare(bv, undefined, { numeric: true }) * dir; + }; + + // Per-column group roll-up cells (skip the first visible column — it carries + // the group identity): a selected aggregate, else shared-value / "varies". + const groupRollup = (arr: number[]) => { + const cells: Record = {}; + const varies = new Set(); + visibleCols.forEach((k, idx) => { + if (idx === 0) return; + const fn = aggs.get(k); + if (fn) { + cells[k] = computeAgg(arr, k, fn); + } else { + const s = groupShared(arr, k); + cells[k] = s.text; + if (s.varies) varies.add(k); + } + }); + return { cells, varies }; + }; + + const rebuildVisible = () => { + const q = normText(query); + filtered = []; + for (let r = 0; r < rowCount; r++) { + if (quickMatch(r, q) && structuredMatch(r)) filtered.push(r); + } + + // multi-key-stable-sort: first non-equal key decides; ties fall to the next; + // final ties keep prior order (stable sort). + if (sortSpec.length) { + filtered.sort((a, b) => { + for (const { col, dir } of sortSpec) { + const c = compareByCol(a, b, col, dir); + if (c !== 0) return c; + } + return 0; + }); + } + + const out: VRow[] = []; + if (groupCols.length === 0) { + for (const r of filtered) out.push({ t: "data", row: r, level: 0 }); + } else { + // grouping-materialization/eager-full-tree: build the nested group tree in + // one pass (bounded store), flattening to visible rows honoring collapse. + const build = (arr: number[], level: number, prefix: string) => { + if (level >= groupCols.length) { + for (const r of arr) out.push({ t: "data", row: r, level }); + return; + } + const col = groupCols[level]; + const buckets = new Map(); + for (const r of arr) { + const gv = groupValueOf(r, col); + (buckets.get(gv) ?? buckets.set(gv, []).get(gv)!).push(r); + } + const keys = [...buckets.keys()].sort((a, b) => + a.localeCompare(b, undefined, { numeric: true }), + ); + for (const gv of keys) { + const sub = buckets.get(gv)!; + const path = `${prefix}${level}:${gv}`; // unique across levels + const { cells, varies } = groupRollup(sub); + out.push({ t: "group", gkey: path, label: gv, count: sub.length, level, cells, varies }); + if (!collapsedGroups.has(path)) build(sub, level + 1, path); + } + }; + build(filtered, 0, ""); + } + visible = out; + }; + + // Footer aggregation: count of filtered DATA rows + the grand roll-up of every + // column that has a selected function, over the whole filtered set. Plain + // columnar scan over the bounded store. + const footer = () => { + const count = filtered.length; + const parts: { label: string; value: string }[] = []; + for (const [k, fn] of aggs) { + const col = colMeta(k); + if (!col) continue; + const v = computeAgg(filtered, k, fn); + if (v !== "") parts.push({ label: `${fn} ${col.label}`, value: v }); + } + return { count, parts }; + }; + + const refreshList = (force = true) => { + rebuildNameIndex(); // refresh the name-collapse universe (cheap; columns grow during pset fill) + rebuildVisible(); + recomputeFlex(); // stretch the last column to fill the panel width (no gutter) + lastStart = lastEnd = -1; + renderHead(); + render(force); + syncFooter(); + }; + + // ── 3D selection sync ────────────────────────────────────────── + const rowMapFromKeys = (keys: Iterable): OBC.ModelIdMap => { + const map: OBC.ModelIdMap = {}; + for (const rk of keys) { + const idx = rk.lastIndexOf(":"); + const modelId = rk.slice(0, idx); + const localId = Number(rk.slice(idx + 1)); + if (!Number.isFinite(localId)) continue; + (map[modelId] ??= new Set()).add(localId); + } + return map; + }; + + const driveHighlight = async (keys: Set) => { + pendingInternal++; + try { + if (keys.size === 0) { + await highlighter.clear(selectName); + } else { + await highlighter.highlightByID(selectName, rowMapFromKeys(keys), true, false); + } + } catch (error) { + console.warn("[data-table] highlight failed", error); + } finally { + Promise.resolve().then(() => { + if (pendingInternal > 0) pendingInternal--; + }); + } + }; + + const onRowClick = (rowKey: string, additive: boolean) => { + if (additive) { + if (selectedRowKeys.has(rowKey)) selectedRowKeys.delete(rowKey); + else selectedRowKeys.add(rowKey); + } else { + selectedRowKeys.clear(); + selectedRowKeys.add(rowKey); + } + void driveHighlight(selectedRowKeys); + render(true); + }; + + // Scroll the first selected visible row into view (used when 3D drives us). + const scrollSelectionIntoView = () => { + if (selectedRowKeys.size === 0) return; + const idx = visible.findIndex((v) => v.t === "data" && selectedRowKeys.has(rowKeyOf(v.row))); + if (idx < 0) return; + const rowTop = idx * ROW_H; + const vh = (viewport.clientHeight || ROW_H) - HEAD_H; + const cur = viewport.scrollTop; + if (rowTop < cur || rowTop + ROW_H > cur + vh) { + viewport.scrollTop = Math.max(0, rowTop - vh / 2); + } + }; + + // ── Delegated interaction ────────────────────────────────────── + // ── Column resize (drag a header's right edge) ───────────────── + let rzCol: string | null = null; + let rzStartX = 0; + let rzStartW = 0; + let rzRaf = 0; + let rzMoved = false; // suppress the sort-click that fires after a drag + const onRzMove = (e: PointerEvent) => { + if (rzCol === null) return; + rzMoved = true; + const w = Math.max(MIN_COL, rzStartW + (e.clientX - rzStartX)); + userWidths.set(rzCol, w); + if (rzRaf) return; + rzRaf = requestAnimationFrame(() => { + rzRaf = 0; + recomputeFlex(); + renderHead(); + render(true); // only the ~window of virtualized rows re-emits widths + }); + }; + const onRzUp = () => { + rzCol = null; + document.body.style.cursor = ""; + window.removeEventListener("pointermove", onRzMove); + // Let the trailing click fire, then clear the suppression flag. + setTimeout(() => { + rzMoved = false; + }, 0); + }; + head.addEventListener("pointerdown", (e) => { + const handle = (e.target as HTMLElement).closest(".dt-hc-rz"); + if (!handle?.dataset.col) return; + e.preventDefault(); + e.stopPropagation(); + rzCol = handle.dataset.col; + rzStartX = e.clientX; + rzStartW = colW(rzCol); + rzMoved = false; + document.body.style.cursor = "col-resize"; + window.addEventListener("pointermove", onRzMove); + window.addEventListener("pointerup", onRzUp, { once: true }); + }); + + head.addEventListener("click", (e) => { + // Ignore clicks on the resize handle and the click that ends a resize drag. + if ((e.target as HTMLElement).closest(".dt-hc-rz") || rzMoved) return; + const hc = (e.target as HTMLElement).closest(".dt-hc"); + if (!hc?.dataset.col) return; + const k = hc.dataset.col; + const shift = (e as MouseEvent).shiftKey; + const idx = sortSpec.findIndex((s) => s.col === k); + if (shift) { + // Add a secondary key, then cycle asc → desc → remove on repeat shift-clicks. + if (idx < 0) sortSpec.push({ col: k, dir: 1 }); + else if (sortSpec[idx].dir === 1) sortSpec[idx].dir = -1; + else sortSpec.splice(idx, 1); + } else if (sortSpec.length === 1 && idx === 0) { + sortSpec[0].dir = sortSpec[0].dir === 1 ? -1 : 1; // toggle the sole key + } else { + sortSpec = [{ col: k, dir: 1 }]; // replace with a single primary key + } + refreshList(); + }); + + content.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + const groupEl = target.closest(".dt-group"); + if (groupEl?.dataset.gkey) { + const gk = groupEl.dataset.gkey; + if (collapsedGroups.has(gk)) collapsedGroups.delete(gk); + else collapsedGroups.add(gk); + refreshList(); + return; + } + const rowEl = target.closest(".dt-row"); + if (rowEl?.dataset.rk) { + onRowClick(rowEl.dataset.rk, (e as MouseEvent).ctrlKey || (e as MouseEvent).metaKey); + } + }); + + let scrollRaf = 0; + viewport.addEventListener("scroll", () => { + if (scrollRaf) return; + scrollRaf = requestAnimationFrame(() => { + scrollRaf = 0; + render(); + }); + }); + new ResizeObserver(() => render()).observe(viewport); + + // ── CSV export (delimited-text per export-serialization) ─────── + // RFC 4180 quoting; a UTF-8 BOM so importers detect encoding; FULL-PRECISION + // numerics (raw stored value, not the thousands-separated display string, so + // downstream math isn't corrupted); and when grouped, one leading column per + // group level (the level-column convention) so the group structure survives + // the flat stream and the result is pivot-friendly. + const exportCsv = () => { + const cell = (s: string) => + /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + // Numeric columns export their canonical value (full precision); text keeps + // its display string. + const cellFor = (r: number, k: string) => { + const col = colMeta(k); + if (col?.kind === "number") { + const n = cellNum(r, k); + if (!Number.isNaN(n)) return cell(String(n)); + } + return cell(cellText(r, k)); + }; + const labels = visibleCols.map((k) => cell(colMeta(k)?.label ?? k)); + const lines: string[] = []; + if (groupCols.length === 0) { + lines.push(labels.join(",")); + for (const r of filtered) lines.push(visibleCols.map((k) => cellFor(r, k)).join(",")); + } else { + const gLabels = groupCols.map((k) => cell(`Group: ${colMeta(k)?.label ?? k}`)); + lines.push([...gLabels, ...labels].join(",")); + // Sort the filtered rows by the group tuple so groups are contiguous; emit + // each row with its group values prepended (complete — ignores collapse). + const sorted = [...filtered].sort((a, b) => { + for (const col of groupCols) { + const c = groupValueOf(a, col).localeCompare(groupValueOf(b, col), undefined, { numeric: true }); + if (c !== 0) return c; + } + return 0; + }); + for (const r of sorted) { + lines.push( + [...groupCols.map((k) => cell(groupValueOf(r, k))), ...visibleCols.map((k) => cellFor(r, k))].join(","), + ); + } + } + const blob = new Blob(["" + lines.join("\r\n")], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "elements.csv"; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }; + + // Themed single-select (BUI bim-dropdown) replacing native /. Positioned fixed near the toolbar button. + let picker: HTMLElement | null = null; + let pickerSearch = ""; + const closePicker = () => { + if (picker) { + picker.remove(); + picker = null; + document.removeEventListener("pointerdown", onDocDown, true); + } + }; + const onDocDown = (e: PointerEvent) => { + if (picker && !picker.contains(e.target as Node)) closePicker(); + }; + const setColumnVisible = (key: string, on: boolean) => { + if (on) { + if (!visibleCols.includes(key)) visibleCols.push(key); + } else { + visibleCols = visibleCols.filter((k) => k !== key); + aggs.delete(key); + sortSpec = sortSpec.filter((s) => s.col !== key); // drop sort key on the gone column + } + refreshList(); + update({}); + }; + const setColumnAgg = (key: string, fn: string) => { + if (fn) aggs.set(key, fn as AggFn); + else aggs.delete(key); + refreshList(); + }; + // Flip between name-collapsed (default) and raw per-pset provenance. Keys differ + // between modes, so prune any view state that doesn't exist in the new universe + // (attr/storey keys are valid in both; property selections may drop). + const togglePsetMode = (byPset: boolean) => { + collapseByName = !byPset; + rebuildNameIndex(); + visibleCols = visibleCols.filter(inUniverse); + if (visibleCols.length === 0) visibleCols = [...DEFAULT_VISIBLE].filter(inUniverse); + conditions = conditions.filter((c) => inUniverse(c.col)); + sortSpec = sortSpec.filter((s) => inUniverse(s.col)); + groupCols = groupCols.filter(inUniverse); + for (const k of [...aggs.keys()]) if (!inUniverse(k)) aggs.delete(k); + facetCache.clear(); + refreshList(); + update({}); + }; + const openPicker = (anchor: HTMLElement) => { + if (picker) { + closePicker(); + return; + } + pickerSearch = ""; + const [el, pickerUpdate] = BUI.Component.create( + (_s) => { + rebuildNameIndex(); // current universe (collapsed or by-pset) for this render + const byGroup = new Map(); + for (const c of universe()) { + let arr = byGroup.get(c.group); + if (!arr) { + arr = []; + byGroup.set(c.group, arr); + } + arr.push(c); + } + const order = (g: string) => + g === "Attributes" ? 0 : g === "Spatial" ? 1 : g === "Properties" ? 2 : 3; + const groups = [...byGroup.keys()].sort( + (a, b) => order(a) - order(b) || a.localeCompare(b), + ); + const q = pickerSearch.trim().toLowerCase(); + const blocks = groups + .map((g) => { + const cols = byGroup + .get(g)! + .filter((c) => !q || c.label.toLowerCase().includes(q)) + .sort((a, b) => a.label.localeCompare(b.label)); + if (cols.length === 0) return null; + return BUI.html` +
${g}
+ ${cols.map( + (c) => BUI.html` +
+ setColumnVisible(c.key, !!(e.target as { checked?: boolean }).checked)} + > + ${c.kind === "number" ? BUI.html`#` : null} + ${(c as ColMeta).conflict + ? BUI.html`Mixed types across property sets — shown as text, not coerced` + : null} + ${dropdown( + [{ value: "", label: "∑ —" }, ...fnsFor(c.kind).map((f) => ({ value: f, label: f }))], + aggs.get(c.key) ?? "", + (v) => setColumnAgg(c.key, v), + "5rem", + )} +
`, + )}`; + }) + .filter(Boolean); + return BUI.html` +
+
+ Columns + +
+ { + pickerSearch = String((e.target as { value?: string }).value ?? ""); + pickerUpdate({ tick: 0 }); + }} + > + +
+ ${blocks.length ? blocks : BUI.html`
No columns yet.
`} +
+
`; + }, + { tick: 0 }, + ); + const rect = anchor.getBoundingClientRect(); + el.style.position = "fixed"; + el.style.top = `${Math.min(rect.bottom + 4, window.innerHeight - 360)}px`; + el.style.left = `${Math.min(rect.left, window.innerWidth - 280)}px`; + el.style.zIndex = "9999"; + // The popover is mounted to document.body — OUTSIDE the app's themed subtree — + // so .dt-picker's `var(--bim-ui_bg-base)` doesn't resolve and falls back to the + // dark hardcoded value. Copy the RESOLVED var values from the panel's own + // themed light-DOM content (`viewport` — where the table cells already render + // themed, proving the vars resolve there) onto the popover, so its existing + // var-based CSS resolves to the exact same surface as the panel. (Reading from + // `anchor` failed: it's in shadow DOM where getPropertyValue returns empty.) + const tcs = getComputedStyle(viewport); + for (const v of [ + "--bim-ui_bg-base", + "--bim-ui_bg-contrast-10", + "--bim-ui_bg-contrast-20", + "--bim-ui_bg-contrast-30", + "--bim-ui_bg-contrast-40", + "--bim-ui_bg-contrast-60", + "--bim-ui_bg-contrast-80", + "--bim-ui_bg-contrast-90", + "--bim-ui_bg-contrast-100", + "--bim-ui_accent-base", + "--bim-ui_color-warning", + ]) { + const val = tcs.getPropertyValue(v); + if (val) el.style.setProperty(v, val); + } + // The visible bim-panel SURFACE is #262629 — NOT --bim-ui_bg-base (#19191E, + // which is darker and what .dt-picker's CSS fell back to). Match the real panel + // surface explicitly so this body-mounted popover reads as the same panel. + el.style.background = "#262629"; + document.body.appendChild(el); + picker = el; + document.addEventListener("pointerdown", onDocDown, true); + }; + + // ── Bulk index: interned columnar store from all loaded models ───── + // FIRST PAINT is attributes-only (model.getTableData({ mode: "attributes" })) + // for a sub-second populate; pset/quantity columns are then filled lazily in + // the BACKGROUND (model.getTablePsets, batched per model) and merged in. Both + // return the interned columnar TableData; each payload carries its OWN string + // dictionary, so we remap its indices into the panel's master dictionary. + + // Default rendered width for a worker-reported column. + const ATTR_WIDTH = new Map(ATTR_COLUMNS.map((c) => [c.key, c.width])); + const defaultColWidth = (col: { key: string; kind: "text" | "number" }) => + ATTR_WIDTH.get(col.key) ?? (col.kind === "number" ? 120 : 160); + + const PSET_BATCH = 4000; // localIds per getTablePsets call (background fill) + + const resetStore = () => { + strings = []; + intern = new Map(); + rowCount = 0; + rowLocalId = new Uint32Array(0); + rowModelIdx = new Uint16Array(0); + modelIdsOrder = []; + columns.clear(); + }; + + // Intern a payload's dictionary into the master dictionary; returns the + // payload-index -> master-index remap. + const internDict = (dict: string[]): Int32Array => { + const remap = new Int32Array(dict.length); + for (let i = 0; i < dict.length; i++) remap[i] = internStr(dict[i]); + return remap; + }; + + // Merge one worker TableData column into the master store. `remap` maps payload + // string indices -> master indices; `rowOf(i)` gives the master rowIdx for + // payload row i (-1 to skip); `n` = number of payload rows to read. + const mergeColumn = ( + pc: FRAGS.TableColumn, + remap: Int32Array, + rowOf: (i: number) => number, + n: number, + ) => { + const col = ensureColumn( + pc.key, pc.group, pc.label, pc.kind, defaultColWidth(pc), pc.kind === "number", + ); + for (let i = 0; i < n; i++) { + const r = rowOf(i); + if (r < 0) continue; + const si = pc.text[i]; + col.text[r] = si < 0 ? -1 : remap[si]; + if (col.num && pc.num) col.num[r] = pc.num[i]; + } + }; + + // OPTIONAL storey enrichment (the worker table has no storey column). Cheap: + // walks the spatial tree and fetches ONLY storey-level Names, not per-element + // data. Returns localId -> storeyId and storeyId -> storey name. + const collectStoreys = async (model: FRAGS.FragmentsModel) => { + const storeyOf = new Map(); + const storeyName = new Map(); + const storeyIds: number[] = []; + const structure = await model.getSpatialStructure().catch(() => null); + if (structure) { + const walk = (node: FRAGS.SpatialTreeItem, storey: number | null) => { + const cat = (node.category ?? "").toUpperCase(); + let curStorey = storey; + if (cat === "IFCBUILDINGSTOREY" && node.localId != null) { + curStorey = node.localId; + storeyIds.push(node.localId); + } + if (node.localId != null && !CONTAINER_CATEGORIES.has(cat) && curStorey != null) { + storeyOf.set(node.localId, String(curStorey)); + } + node.children?.forEach((c) => walk(c, curStorey)); + }; + walk(structure, null); + } + if (storeyIds.length > 0) { + const sData = await model.getItemsData(storeyIds, { + attributesDefault: false, + attributes: ["Name"], + }); + sData.forEach((d, i) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nm = (d as any)?.Name; + if (nm && "value" in nm && nm.value != null) { + storeyName.set(String(storeyIds[i]), String(nm.value)); + } + }); + } + return { storeyOf, storeyName }; + }; + + // PASS 1 — attributes only: build the row set + attribute columns + storey. + const indexAttributes = async (token: number) => { + const models = [...fragments.list.values()]; + type P = { + modelId: string; + data: FRAGS.TableData; + storeyOf: Map; + storeyName: Map; + }; + const payloads: P[] = []; + let total = 0; + let notReady = false; + for (const model of models) { + if (total >= MAX_ROWS) break; + try { + const data = await model.getTableData({ mode: "attributes" }); + if (token !== rebuildToken) return { notReady: false }; + const { storeyOf, storeyName } = await collectStoreys(model).catch(() => ({ + storeyOf: new Map(), + storeyName: new Map(), + })); + if (token !== rebuildToken) return { notReady: false }; + payloads.push({ modelId: model.modelId, data, storeyOf, storeyName }); + total += data.localIds.length; + console.log( + `[data-table] getTableData(attributes) ${model.modelId}: ` + + `${data.stats.rowCount} rows, ${data.stats.columnCount} cols, ` + + `${Math.round(data.stats.ms)}ms`, + ); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (/Model not found/i.test(String((error as any)?.message ?? error))) notReady = true; + else console.warn("[data-table] getTableData failed", model.modelId, error); + } + } + // Allocate the master row arrays (capped at MAX_ROWS). + rowCount = Math.min(total, MAX_ROWS); + rowLocalId = new Uint32Array(rowCount); + rowModelIdx = new Uint16Array(rowCount); + modelIdsOrder = payloads.map((p) => p.modelId); + let w = 0; + payloads.forEach((p, mi) => { + const ids = p.data.localIds; + const take = Math.min(ids.length, rowCount - w); + const startRow = w; + const remap = internDict(p.data.strings); + for (let i = 0; i < take; i++) { + const r = startRow + i; + rowLocalId[r] = ids[i]; + rowModelIdx[r] = mi; + } + for (const pc of p.data.columns) { + mergeColumn(pc, remap, (i) => startRow + i, take); + } + if (p.storeyOf.size > 0) { + const sc = ensureColumn(STOREY_KEY, "Spatial", "Storey", "text", 140); + for (let i = 0; i < take; i++) { + const sid = p.storeyOf.get(ids[i]); + const name = sid ? p.storeyName.get(sid) : undefined; + if (name) sc.text[startRow + i] = internStr(name); + } + } + w += take; + }); + return { notReady }; + }; + + // PASS 2 (background) — fetch pset/quantity columns for ALL rows in batches per + // model and merge progressively. Pset-column ops (filter/sort/group/aggregate) + // become correct as each batch lands; a subtle note shows until done. + const fillPsets = async (token: number) => { + for (let mi = 0; mi < modelIdsOrder.length; mi++) { + const modelId = modelIdsOrder[mi]; + const model = fragments.list.get(modelId); + if (!model) continue; + const ids: number[] = []; + const rowOfId = new Map(); + for (let r = 0; r < rowCount; r++) { + if (rowModelIdx[r] === mi) { + ids.push(rowLocalId[r]); + rowOfId.set(rowLocalId[r], r); + } + } + for (let i = 0; i < ids.length; i += PSET_BATCH) { + const batch = ids.slice(i, i + PSET_BATCH); + let data: FRAGS.TableData; + try { + data = await model.getTablePsets(batch, { includeTypePsets: true }); + } catch (error) { + console.warn("[data-table] getTablePsets failed", modelId, error); + continue; + } + if (token !== rebuildToken) return; + const remap = internDict(data.strings); + const bIds = data.localIds; // echoes input order + for (const pc of data.columns) { + mergeColumn(pc, remap, (j) => rowOfId.get(bIds[j]) ?? -1, bIds.length); + } + facetCache.clear(); + // Force a full repaint: merged pset values land in columns that may be + // visible (e.g. a restored saved view) AND may change sort/group order, + // and the row recycler keeps same-index DOM on a soft refresh. + refreshList(true); + update({ status: "ready", note: indexNote(rowCount, false) }); + await new Promise((res) => setTimeout(res, 0)); // yield between batches + if (token !== rebuildToken) return; + } + } + facetCache.clear(); + refreshList(true); + update({ status: rowCount > 0 ? "ready" : "empty", note: indexNote(rowCount, true) }); + if (rowCount > 0) logStoreBytes("full"); + }; + + const indexNote = (n: number, done = true) => { + const base = + n >= MAX_ROWS + ? `${MAX_ROWS.toLocaleString()}+ elements (capped)` + : `${n.toLocaleString()} element${n === 1 ? "" : "s"}`; + return done ? base : `${base} · loading properties…`; + }; + + let readyRetries = 0; // bounded retries when a model isn't worker-ready yet + const rebuild = async () => { + const token = ++rebuildToken; + resetStore(); + facetCache.clear(); + selectedRowKeys.clear(); + collapsedGroups.clear(); + const models = [...fragments.list.values()]; + visibleCols = [...DEFAULT_VISIBLE]; + if (models.length === 0) { + refreshList(); + update({ status: "empty", note: "" }); + return; + } + update({ status: "loading", note: "Indexing…" }); + try { + const { notReady } = await indexAttributes(token); + if (token !== rebuildToken) return; + // A model can throw "Model not found" when an onItemSet fires before its + // worker is ready. If NOTHING indexed, retry from scratch. If SOME rows + // landed (e.g. model #1 ready, model #2 still loading), paint what we have + // now AND schedule a retry to pick up the not-yet-ready model(s) — we don't + // rely solely on onModelLoaded re-firing for the second model. + if (rowCount === 0 && notReady && readyRetries < 8) { + readyRetries++; + setTimeout(() => { + if (token === rebuildToken) void rebuild(); + }, 500); + return; + } + // Show default-visible columns that exist; else the first 3 discovered. + visibleCols = [...DEFAULT_VISIBLE].filter((k) => columns.has(k)); + if (visibleCols.length === 0) visibleCols = [...columns.keys()].slice(0, 3); + // FIRST PAINT now (attributes only) — don't wait for psets. + refreshList(); + update({ status: rowCount > 0 ? "ready" : "empty", note: indexNote(rowCount, rowCount === 0) }); + if (rowCount > 0) logStoreBytes("attributes"); + // Some models weren't ready yet but others gave rows → retry (bounded) to + // fold the missing model(s) in, while the current rows stay on screen. + if (notReady && rowCount > 0 && readyRetries < 8) { + readyRetries++; + setTimeout(() => { + if (token === rebuildToken) void rebuild(); + }, 600); + return; // the retry will run fillPsets once the full set is in + } + readyRetries = 0; // clean pass — every model in the list indexed + // Background pset fill (non-blocking; cancels if a newer rebuild starts). + if (rowCount > 0) void fillPsets(token); + } catch (error) { + if (token !== rebuildToken) return; + console.warn("[data-table] index failed", error); + update({ status: "empty", note: "" }); + } + }; + + // ── Panel chrome (BUI) ───────────────────────────────────────── + interface PanelState { + status: "loading" | "empty" | "ready"; + note: string; + } + let footerEl: HTMLElement | null = null; + const syncFooter = () => { + if (!footerEl) return; + const { count, parts } = footer(); + footerEl.innerHTML = + `${count.toLocaleString()} rows` + + parts + .map((p) => `${esc(p.label)}: ${esc(p.value)}`) + .join(""); + }; + + const [panel, update] = BUI.Component.create( + (state) => { + const onHostCreated = (el?: Element) => { + if (!el || el.contains(viewport)) return; + el.appendChild(viewport); + render(true); + }; + const onFooterCreated = (el?: Element) => { + footerEl = (el as HTMLElement) ?? null; + syncFooter(); + }; + const colLabel = (k: string) => colMeta(k)?.label ?? k; + const opLabel = (op: Op) => [...TEXT_OPS, ...NUM_OPS].find((o) => o.op === op)?.label ?? op; + const condLabel = (c: Condition) => + `${colLabel(c.col)} ${opLabel(c.op)}${ + PRESENCE_OPS.has(c.op) ? "" : ` ${c.op === "between" ? `${c.v}–${c.v2}` : c.v}` + }`; + const toggleEditor = (k: typeof openEditor) => { + openEditor = openEditor === k ? "" : k; + update({}); + }; + + return BUI.html` + + +
+ ${cardHeader("mdi:table", "Element Data", "1.1rem")} + ${state.status === "empty" + ? BUI.html`
No model loaded. Load a model to populate the table.
` + : BUI.html` +
+ +
+ { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const v = String((e.target as any).value ?? ""); + if (searchTimer !== undefined) clearTimeout(searchTimer); + searchTimer = window.setTimeout(() => { + query = v; + viewport.scrollTop = 0; + refreshList(); + }, 200); + }} + > + openPicker(e.currentTarget as HTMLElement)} + >Columns + 0 || openEditor === "filter"} + @click=${() => toggleEditor("filter")} + >Filter + 0 || openEditor === "group"} + @click=${() => toggleEditor("group")} + >Group + ${client && projectId + ? BUI.html` toggleEditor("views")} + >Saved views` + : null} + Export CSV +
+ + + ${conditions.length || groupCols.length + ? BUI.html`
+ ${conditions.map( + (c) => BUI.html` + ${condLabel(c)} + removeCondition(c.id)}>✕ + `, + )} + ${groupCols.map( + (g, i) => BUI.html` + + ${colLabel(g)} + { groupCols.splice(i, 1); regroup(); }}>✕ + `, + )} +
` + : null} + + + ${openEditor === "views" && client && projectId + ? BUI.html` +
+ ${dropdown( + [ + { value: "", label: "— Saved views —" }, + ...savedViews.map((sv) => ({ + value: sv.name, + label: `${sv.name}${activeViewName === sv.name && isDirty() ? " •" : ""}`, + })), + ], + activeViewName ?? "", + (v) => { if (v) restoreView(v); }, + "9rem", + )} + ${savingNew + ? BUI.html` + { newViewName = String((e.target as { value?: string }).value ?? ""); }} + @keydown=${(e: KeyboardEvent) => { + if (e.key === "Enter" && newViewName.trim()) persistView(newViewName.trim()); + else if (e.key === "Escape") { savingNew = false; update({}); } + }}> + { if (newViewName.trim()) persistView(newViewName.trim()); }}> + { savingNew = false; newViewName = ""; update({}); }}>Cancel` + : BUI.html` { savingNew = true; newViewName = activeViewName ?? ""; update({}); }}>`} + ${activeViewName && !savingNew + ? BUI.html` + persistView(activeViewName!)}> + deleteView(activeViewName!)}>` + : null} +
` + : null} + + + ${openEditor === "filter" + ? BUI.html`
+
+ Filters${conditions.length ? ` (${conditions.length})` : ""} + + ${conditions.length + ? BUI.html` { conditions = []; viewport.scrollTop = 0; refreshList(); update({}); }}>` + : null} + + +
+ ${conditions.map((c, i) => { + const kind = colMeta(c.col)?.kind ?? "text"; + const ops = kind === "number" ? NUM_OPS : TEXT_OPS; + const needOperand = !PRESENCE_OPS.has(c.op); + const between = c.op === "between"; + return BUI.html` +
+ ${i > 0 + ? dropdown( + [{ value: "AND", label: "AND" }, { value: "OR", label: "OR" }], + c.conj, + (v) => { c.conj = v as "AND" | "OR"; refreshList(); }, + "3.6rem", + ) + : BUI.html`Where`} + ${dropdown( + allColumns().map((cc) => ({ value: cc.key, label: cc.label })), + c.col, + (v) => onColChange(c, v), + "8rem", + )} + ${dropdown( + ops.map((o) => ({ value: o.op, label: o.label })), + c.op, + (v) => { c.op = v as Op; refreshList(); update({}); }, + "6rem", + )} + ${needOperand + ? BUI.html` { c.v = String((e.target as { value?: string }).value ?? ""); debouncedFilter(); }}>` + : null} + ${between + ? BUI.html`and + { c.v2 = String((e.target as { value?: string }).value ?? ""); debouncedFilter(); }}>` + : null} + removeCondition(c.id)}>Remove condition +
`; + })} +
` + : null} + + + ${openEditor === "group" + ? BUI.html`
+ Group by + ${groupCols.map( + (gc, i) => BUI.html` + + ${i > 0 ? BUI.html`` : null} + ${dropdown( + allColumns().map((cc) => ({ value: cc.key, label: cc.label })), + gc, + (v) => { groupCols[i] = v; regroup(); }, + "8rem", + )} + { groupCols.splice(i, 1); regroup(); }}>Remove level + `, + )} + + ${groupCols.length + ? BUI.html` { groupCols = []; regroup(); }}>` + : BUI.html`none`} +
` + : null} + + ${state.note + ? BUI.html`
${state.note}
` + : null} +
+ ${state.status === "loading" + ? BUI.html`
Indexing…
` + : null} +
+ `} +
+
+ `; + }, + { status: "loading", note: "" }, + ); + + // ── 3D → table selection reflection ──────────────────────────── + highlighter.events.select.onHighlight.add((modelIdMap: OBC.ModelIdMap) => { + // Skip the echo of our own row-click drive (avoids feedback loop). + if (pendingInternal > 0) { + pendingInternal--; + return; + } + selectedRowKeys.clear(); + for (const [modelId, set] of Object.entries(modelIdMap)) { + for (const id of set) selectedRowKeys.add(`${modelId}:${id}`); + } + render(true); + scrollSelectionIntoView(); + }); + + highlighter.events.select.onClear.add(() => { + if (selectedRowKeys.size === 0) return; + selectedRowKeys.clear(); + render(true); + }); + + // ── Triggers ─────────────────────────────────────────────────── + // Index whatever is already in fragments.list at construction (the panel is + // lazy — it may be created AFTER the auto-loaded model arrived, so onModelLoaded + // would never fire for it). Then (re)index on every model load. We listen to + // BOTH the worker-ready signal (onModelLoaded — the reliable one) AND the raw + // list add (onItemSet) as a fallback: an early onItemSet may fail with "Model + // not found", but the rebuildToken makes the later successful pass win, so an + // empty table can never get stuck if onModelLoaded is missed. + // LAZY indexing — the per-model `getTableData` build must NEVER run on the + // model-load path: firing it on every onModelLoaded/onItemSet (even with the + // panel CLOSED) would contend with the fragments worker during streaming and + // make hover/select/auto-anchor crawl for the whole load window. So we only + // index when the Data panel is actually VISIBLE. Model changes just mark the + // index dirty; the (re)build happens the next time the panel is shown — by + // then the model has finished streaming, so there's no contention. + let rebuildScheduled = false; + let indexDirty = true; // models already in fragments.list need a first index + let panelVisible = false; + const maybeRebuild = () => { + if (!panelVisible || !indexDirty || rebuildScheduled) return; + rebuildScheduled = true; + queueMicrotask(() => { + rebuildScheduled = false; + indexDirty = false; + void rebuild(); + }); + }; + const markDirty = () => { + indexDirty = true; + maybeRebuild(); // re-index immediately only if the panel is open right now + }; + fragments.core.onModelLoaded.add(markDirty); + fragments.list.onItemSet.add(markDirty); + fragments.list.onItemDeleted.add(markDirty); + // Flip on when the docked panel is shown by the layout switcher, off when hidden. + const visObserver = new IntersectionObserver((entries) => { + const shown = entries.some((e) => e.isIntersecting); + if (shown) { + panelVisible = true; + maybeRebuild(); + } else { + panelVisible = false; + } + }); + visObserver.observe(panel); + void loadSavedViews(); // hydrate the Views dropdown from __project_data/bim-viewer/ + + return panel; +}; diff --git a/src/cli/templates/app/src/setups/exploded-view.ts b/src/cli/templates/app/src/setups/exploded-view.ts new file mode 100644 index 0000000..f46122d --- /dev/null +++ b/src/cli/templates/app/src/setups/exploded-view.ts @@ -0,0 +1,246 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; +import * as FRAGS from "@thatopen/fragments"; + +/** + * EXPLODED VIEW — a HEADLESS controller (no UI). The toolbar wires a single + * `mdi:arrow-expand-vertical` button to `toggle()` / `isActive()` / `onChange()`. + * + * ── Technique ────────────────────────────────────────────────────────────── + * Fragments has no runtime "translate these items" call; the supported path is + * the Edit API's GLOBAL TRANSFORMS (each item placement is a global transform + * with a `position`). So per model we: + * 1. Group element localIds by IFCBUILDINGSTOREY (from the spatial structure) + * and order storeys by elevation (min-Y of each group's bbox). + * 2. Map items → their global-transform ids; a transform shared across MORE + * than one storey is skipped (it can't be moved per-storey cleanly). + * 3. SNAPSHOT each transform's original position ONCE. + * 4. To explode at magnitude m: set each transform's Y to + * `originalY + storeyIndex × step × m` — ALWAYS absolute from the snapshot, + * never accumulated, so repeated explode/reset never drifts. m = 0 restores + * exactly. + * + * Edits are applied in the background (the Edit API can't do 60/s). Visibility & + * selection are untouched (only transforms change). + * + * const explode = explodedView(components); + * // toolbar: button @click=explode.toggle(); active styling = explode.isActive() + * + * @param components engine components + */ + +const STOREY = "IFCBUILDINGSTOREY"; +const DEFAULT_MAGNITUDE = 1.0; + +export interface ExplodedViewController { + /** Off→on (applies the default magnitude) / on→off (restores exactly). */ + toggle(): void; + /** Whether the model is currently exploded (magnitude > 0). */ + isActive(): boolean; + /** Subscribe to active changes (toggle / auto-reset). Returns an unsubscribe. */ + onChange(cb: (active: boolean) => void): () => void; + /** Graded control (0→1). Kept for a future popover slider; 0 restores exactly. */ + setMagnitude(v: number): void; +} + +interface ModelExplode { + // global-transform localId → its storey index (0 = lowest). Only single-storey + // transforms are included. + gtToStorey: Map; + // global-transform localId → original position [x,y,z] (the snapshot). + snapshot: Map; + // current global-transform objects (other fields preserved on UPDATE). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + gts: Map; + step: number; // vertical units per storey index at magnitude 1 +} + +export const explodedView = ( + components: OBC.Components, +): ExplodedViewController => { + const fragments = components.get(OBC.FragmentsManager); + // The Edit API; opt into the in-place incremental edit path (cheaper than a + // per-edit delta-model rebuild). Harmless if already enabled. + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (fragments.core as any).settings.incrementalEdit = true; + } catch { + /* setting may not exist on older cores — edits still work, just slower */ + } + + let magnitude = 0; + let active = false; + let busy = false; + const prepared = new Map(); + const listeners = new Set<(active: boolean) => void>(); + + const setActive = (a: boolean) => { + if (a === active) return; + active = a; + for (const cb of listeners) cb(a); + }; + + // Collect, per storey node, all descendant element localIds. + const collectStoreys = (root: FRAGS.SpatialTreeItem) => { + const storeys: number[][] = []; + const descendants = (node: FRAGS.SpatialTreeItem, acc: number[]) => { + if (node.localId !== null && node.localId !== undefined && node.localId >= 0) { + acc.push(node.localId); + } + node.children?.forEach((c) => descendants(c, acc)); + }; + const walk = (node: FRAGS.SpatialTreeItem) => { + if ((node.category ?? "").toUpperCase() === STOREY) { + const ids: number[] = []; + descendants(node, ids); + if (ids.length) storeys.push(ids); + } else { + node.children?.forEach(walk); + } + }; + walk(root); + return storeys; + }; + + // Build the explode plan for one model (snapshot + storey→transform map). + const prepareModel = async ( + model: FRAGS.FragmentsModel, + ): Promise => { + const structure = await model.getSpatialStructure(); + if (!structure) return null; + let storeys = collectStoreys(structure); + if (storeys.length < 2) return null; // nothing to separate + + // Order storeys by elevation (min-Y of the group's merged bbox). + const withY = await Promise.all( + storeys.map(async (ids) => { + let minY = Number.POSITIVE_INFINITY; + try { + const boxes = (await fragments.getBBoxes({ + [model.modelId]: new Set(ids), + })) as THREE.Box3[]; + for (const b of boxes) if (!b.isEmpty()) minY = Math.min(minY, b.min.y); + } catch { + /* leave at +Inf → sorts last; harmless */ + } + return { ids, minY }; + }), + ); + withY.sort((a, b) => a.minY - b.minY); + storeys = withY.map((s) => s.ids); + + // Map global-transform ids → storey index; mark cross-storey ones for skip. + const gtToStorey = new Map(); + const skip = new Set(); + for (let i = 0; i < storeys.length; i += 1) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const gtIds: Iterable = await (model as any).getGlobalTranformsIdsOfItems( + storeys[i], + ); + for (const gt of gtIds) { + if (skip.has(gt)) continue; + if (gtToStorey.has(gt) && gtToStorey.get(gt) !== i) { + gtToStorey.delete(gt); // shared across storeys → can't move cleanly + skip.add(gt); + } else { + gtToStorey.set(gt, i); + } + } + } + if (gtToStorey.size === 0) return null; + + // Snapshot original positions. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const gts: Map = await model.getGlobalTransforms(); + const snapshot = new Map(); + for (const [id, gt] of gts) { + if (!gtToStorey.has(id)) continue; + const p = gt.position as number[]; + snapshot.set(id, [p[0], p[1], p[2]]); + } + + // Vertical step per storey index: a fraction of the model's height so the + // explode reads clearly regardless of model scale. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const box = (model as any).box as THREE.Box3 | undefined; + const height = box && !box.isEmpty() ? box.max.y - box.min.y : 10; + const step = (height * 0.45) / Math.max(1, storeys.length - 1); + + return { gtToStorey, snapshot, gts, step }; + }; + + const rebuild = async () => { + prepared.clear(); + for (const model of fragments.list.values()) { + try { + const plan = await prepareModel(model); + if (plan) prepared.set(model.modelId, plan); + } catch (error) { + console.warn("[exploded-view] prepare failed", model.modelId, error); + } + } + // Re-apply the current magnitude to freshly-loaded models. + if (magnitude > 0) void apply(); + }; + + // Apply the current magnitude across all prepared models. Always sets the + // absolute position from the snapshot (no accumulation → no drift). + const apply = async () => { + if (busy) return; + busy = true; + try { + for (const [modelId, plan] of prepared) { + const model = fragments.list.get(modelId); + if (!model) continue; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const requests: any[] = []; + for (const [id, orig] of plan.snapshot) { + const i = plan.gtToStorey.get(id) ?? 0; + const gt = plan.gts.get(id); + if (!gt) continue; + requests.push({ + type: FRAGS.EditRequestType.UPDATE_GLOBAL_TRANSFORM, + localId: id, + data: { + ...gt, + position: [orig[0], orig[1] + i * plan.step * magnitude, orig[2]], + }, + }); + } + if (requests.length === 0) continue; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (fragments.core as any).editor.edit(modelId, requests); + } + await fragments.core.update(true); + } catch (error) { + console.warn("[exploded-view] apply failed", error); + } finally { + busy = false; + } + }; + + const setMagnitude = (v: number) => { + magnitude = Math.max(0, Math.min(1, v)); + void apply(); + setActive(magnitude > 0); + }; + + // Recompute the plan whenever the loaded models change. + fragments.core.onModelLoaded.add(() => void rebuild()); + fragments.list.onItemDeleted.add(() => void rebuild()); + void rebuild(); + + return { + toggle() { + setMagnitude(active ? 0 : DEFAULT_MAGNITUDE); + }, + isActive() { + return active; + }, + onChange(cb) { + listeners.add(cb); + return () => listeners.delete(cb); + }, + setMagnitude, + }; +}; diff --git a/src/cli/templates/app/src/setups/file-format-icons.ts b/src/cli/templates/app/src/setups/file-format-icons.ts new file mode 100644 index 0000000..e20ba49 --- /dev/null +++ b/src/cli/templates/app/src/setups/file-format-icons.ts @@ -0,0 +1,95 @@ +import * as BUI from "@thatopen/ui"; + +/** + * Platform-matching file-format glyphs, ported from the platform's own file + * browser (platform_backend-api .../util/fileIcons). The platform's icons are + * NOT iconify/mdi, so they can't go through bim-icon: + * - ifc / gltf are raster PNG assets — inlined here as data URIs and drawn the + * same way the platform does (`` in a 24×24 SvgIcon, x/y 2, 20×20). + * - json replicates the platform's `FileExtensionLabel` badge (rounded rect + + * centered text), same geometry/colors. + * - anything the platform doesn't map (e.g. .frag) falls back to the platform's + * generic file icon (MUI InsertDriveFileOutlined path), muted gray. + * + * Covered: ifc, gltf/glb, json, generic fallback. More platform formats + * (xml/yaml/csv/doc/xls badges, png/jpg/pdf…) are easy one-liners to add — each + * just needs its label+color or PNG ported the same way. + */ + +// Inlined from platform_backend-api/src/client/src/assets/icons/ifc.png +const IFC_PNG = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAHqADAAQAAAABAAAAHgAAAADKQTcFAAAIbklEQVRIDYVWC1hUxxU+c+++kIcrCwiJGIpS28REW2wgBVk0nyUYESPyKD4CpoIk6cOm1tivVdSY+Gj9UkxY1piwQYTUxCTEfLFC5JVUIaYBDFZE8YURFOQRQVl270zP3N27LKv9Ot9378z88885Z86cOTME/m9hBDIOzwaR/hCo0ALly9o8p9TEx6tmXRtJBBGi7IydCGpv/MyT49knnsC4fs7Xahju2AUg/A6AOYaIuAlKU7YpPDYjxrefSmVA2CIFw3qff3tjrlv/nub/Vpxfo4L2XgsKXA7gQWPkt1C2rKAHlYrM9ikwEod+QSqa56Ri9SYqf/EejU7AQ6ITXXfIC27AUSDECAylEaQptUsSyek7tXsVwrGyXdwhXJqTzmk4tttwvvGPvO1ZBE8AVhwIgR5yTFbKB7lSgEGsS3nDrZgzIp5pQGW9nIGCrjHuCYARN876/ojoP7v1Xc3xijMqHgCmrUGr57oYwHqBCPPhYOpKVLLFDSeV+mnr8kONJQxYOyHUGHC+oUAgjO+tnfO4zZSwbX0Rj9+zank5srDsj0Nh1FaL7XC5L/9YF6iEBfDusjO8ezM8KiIyMmdDp8bvOReHsUEViIvtZSn1CtY3PToPBFaoeJ/jAiF5+nMNRQrHseKsj8LAOlqHoJtScgWYzagoHZ4R84BKhONNLeZZD44OHVAE4LIm2gmtgPT3ZiqY/4UGExNgPQ82XnjFGNs7OCNqhQzgT4AMVGqz16KAHygg0i7guY2DsuXnFWwU7E+hq0MFIHO+bTY95CONVihjWOtBJe5z64OhrfGvGOGblVVTAJWdwVv9P4qO5zwBBNuvUdFDDrsQIawV1LjHJWlXOUEpElCt0maExLU3vaHVMulzBcM6EnKOTHDrg6G9cSsBtpNjfE/x0zHKyllkpBoXQAzuZIyGHWBJ61Ywy3zztH3GwsQz3j+rxanXOc6FoISnzjYV3kLBjRxDf56GWJsPlHyaAZbDcTKGv0nnGjfi2DdOr3M4uG9ACMY9pjUuezhMwASZHz3Om2ajafuInbYzED5r85114l+T5h/E8T7HpqF/7db0lmbzWcS+hJ+ojgKIuDVSOQiqOjhQYYkvLtb1zYgqxvGfcnlyIdDtr6fdInz7QQs8lqZHk6OdQ1qR0FCT/5NGXBmmSvSJfJaZrm1CYPNxX6/KubevRuOYBgWCH7UGVCya+W5vkA9PozqnDPC1jV6q/lthupqyNO4hXtA7FI/eSxNavz4lykjKI1XQFxSGQ7O1lFXtvD5gVzOWwQXzwquLGrXp1WB9wkmfKYmTJGvRnKEu7pXOhOzsytPBwRucNKwAQm4PlbTteX2KRqILxpTC95gPfmVo/8rCOQoOkHpIo1PBy3uu98eIQH/BBx2F2M7o1HsLDL6puPZQB8b6CzuO7jEvjZrWMjkkS5biDN+IW7feaTSZ52D3MS7cCQ/h8UrBSK90CgWV0jB39xvw0D+DzNmoQJ6Bk+7U+epeLdd7/wZ5QQoX6+4uv1gDGfEZcJmOUx7tvmGp2f82D6zpnOtQSnpylyRZ3n905hOwMtmlWF6xOdY8FZVicLCHHU7AnQDo+1DvU1Dpq/09Yn5cEC+4N1/ldw1cCJKkTEwKnTuSA6suB6pWx1zp3PvJgdIkFBgmE/GHMr5Lzcz4sDo8/Hl5qiBshBVJO/g4KU0s9RseHmpCVeGOIOIw9BXrvXc1+Ojyse0IGH47iVCz+9pAgx+lGzmJF5x3uWaWfavpn6XbsRsig/hDAy6tS1y4wRI5+5CCyTUTXoJnk/YIw3eGsxhBpQ4U/+yqmmrmNkzUlmO7WYYJs6H734H52u+3/zJoCmYkPLvckVwBCZvXot40oPY/gh4YQBQfIXCSaqjR8vfN72OO+pNMVH4C3QTmIwECpXQyTpZNlMeIULb6i9X/kTPXwbQnQFLjNqhCIEE8iHkueUBHVuYvDTyLN74rneLssKqAxaH+flIQsdsD8A7+uaH1VKcsb1XSa1gfU/SivRPBi0QKKgLHEcRU6iyM/mF/nDlT6cJ7SzqhZOkttGyqgnVPVGX9JX1yFa7unHPh6ACS8KY+28//4r8HFZ5cl36yCeuEMYzY8cZoFdbU51UTELYwvmr+MaKSCCt9K65o4RgZW6KmBvO4zYExuOkrPL8lJbAWp3TIGIHb3pL38Lg5pRU7gbIt4zDCtuIefydfizl1OVsFJuyUnzechU8JSuCwaa5pzNLlT+M1KWcytI8bCdClV+XuWBxQjfhVNHhbdm228vogYPn4DfSj+wMA45C9jEeKZzh5mbyWi9lYtAsb651dHj/9FNiTefV5TQoW+voHpk6DOhfHHNrRDQG3bZt7X3C+PA+d8IK7N/ejcWPbhQkc578Iq5JdDwHnZIfYfMgXQuKDC1DoC1yr065efL7MuwE32oJZcCFOWPDa4oDaywHqLEW1PJuSdLCOHAMv7T+wn+CaTuAuXsXPwcqn8ZSMlXGKOVwcX6wbZdYSbKYqNAq0G+PgS0zyyxzGkEuvLAms7zSIzyocVGRFYy/iSn/swgDzM4UkyEqu59i0qVNjGKiIN9jP3KOYE/LzceXVIdUY7Eb+TpZJsgNcdCsGR9KaNQ8uAkp5Or1PIT2oIxkyk07ywYenTn/kLkgxaLyXIKia5eDynIWKKZ62hZjJvpDPOFftDCjUP4jfvDV1a6sw/fFrc5/nfAyiTtDYYxWlfNxKmVUgQg8w6YQkSfS+ijkx90juHULETFTyDWYkHun8u47teWvr1sqrQBqDUD+MB1LA58iFsdMgSfGQsbRdgXjdca3jAr6z2gRR9NZM0DS7fOdO8miTImNRtArPmyQKrbmf545PEAr5bXyTi9QLLrdcwb2S39XK0P3q/wL5WCCmp1i6FwAAAABJRU5ErkJggg=="; + +// Inlined from platform_backend-api/src/client/src/assets/icons/gltf.png +const GLTF_PNG = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAWCAYAAAC7ZX7KAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAALKADAAQAAAABAAAAFgAAAAAX/0eEAAAF40lEQVRIDbVXaWxUVRQ+5703M52WUqSYCJoIAomgoBZCSqfLDC0SNmNEhEg0wRgJxjRGWTqlJbVUhkYCoREN1cgP0h8sLhiRKMvMdJlWZY3SRJa4odUKNm2n7Szv3eN5Q990tnQxeJOXe+53lnvevd899z2EUbQD51Zn3Q52Pg8gLUSgXEJ8EIjMI7oihgDEJSRsJ4ATk0PwxXqHJzCi3zAGOIwOdjXb8wlhkyAoRoRxw9mOSkfUgSid1ACrK2xnfxmVT4JRyoRrvYWzNEXaz7aOBPuhIVIPEH7HgAcEXJIRr6GZuju7/untyTaFplgfwom9/rQ+GpiIJE1BpEmagBUA9Cj75CDie1rYtLei6OvfhoKOLCUl7PIVOYmgBgClZHdOkrcWNGn/z+k939bPPx9OtKn1OTZrBPZym3t5os4Y1zbbMoWkrBAgrSBNHKso8H5q6EbqowlXXbRPsPThPkJ6MdkJ+3kV34UMdXd5TtPfyfohxNXiOEZAq8ptnmjsIW2ytLOxeLZG4XBlUeO1ZG0yohiQpR/rOdnVxniox8sSwbqyAvcVHXM1lTwEcrhEAFpNgJe25Lm9+qQgQXbIVtACPm/UdbfbPklYTfdEgQQh1KeFygvPtO/wFs7UnwR1ZJhhst4W5qAU6JYsstnaH0nY1bzoNQKRIlk4BVZ1XdngqrraHJWkqtuZu4q+fCoQ7Gwq+gRRk/lw5sw+emXajfuHplUtuE9oYa4uqZuchsfrvly6xq8MXE1tARDE4EYRAotspWykvjlKbZv9AU0Te5Id8EZ23szlG7A+wtOa5qK3SKPtzO/PFMAyLSDdonTxODP9oADI5BfoTYwhgAYY6xrEM7lX2D/AFUfH+b2hb1AX6XSdNKgzcCEgEMbgEQhaQuPMUrYihPQ010qTYRDtJVhnJMuB0OXDjYBwfVuB5xm24bIaaWdqmkuek1Bt5VFSwuV5npcZ1x9w+eyfc5yVkgQeZ55nqY7pjVfYckfiY45U6rR5PzDGKXq/QkKs5UTiGg+/dy50f2OA77Q6CjnHeyXCQ4wZyUbUFfmn23a2ODoTYMN1TD2v/H1vty7KMZxEf6C7sth3wxjrvV66pscCusxbFWcURpyr4wIpNdeQxlRL9VipGhFWoxDnjUexWj5MtFN4G/gii19ivkqDsYYImsobpr9dyhOPBJa4ZY91HpvchYAG5/n2h45Ed4VAuslrOiVOgTQvbqxKF4BrGwd4JA7nQZVvyUSg4CwW/0jUjX1MW502z3AcBkmAaEgMzInNqPEtWmDg5QURPt9kqqx0tdqnGrjemyhYyqsrx2L/p6zIQjsEslLNSWbFTiQBvV/XttRRmntSv46ZEPg678RRLm1eV5O9jpO/hTKuBEFcZeAHTjrOPzbW3ZQlZ0FzlyCqTApKlONXBw7vPbdksq5z2twfA0klXHs6mcy1/AYHOfmpzOu1nCxzDcPt7Uf5OJCf60iUh0ZcJtQAf/DodTdS1w1c7/nM9OkPn/ZQLJ5Kjpy2KrddsZjxIw76QpIRwWUFpVVbbGejleMIrTb/ed2PpTNPRg6nq8X+E/t1MP/ykvzvMhAtD7tOlWRRhraHiF5KMUcvGx7GfmVT2eLT3bF6l7fkCVLUC0yZet6FDbG60cjVTY7HJFnLqMhr9I3GPpqwYcyXwBtc6nYzp5N0bOPnEnZcyNQAYfEXSvKT/PexmWmhmk2m+W8uGN23LZ+N8f3awDK+0tfIJNdtzT/jNuYfqU+VFOxoyZsug4nLCzpGCsA2v6rB8FPpqPweytIwLaD6M8xz1I7eqzJMgLS0PmW8yRLKDoXxYT4Ducz3fEAxg+UGzEzf5px7Ionvw82ZMmHDoba52KGh+ip/yy/jIpx+B9cvFepkClwEIb6yyKGDAc38LB/GA7wnVsM3Vc+T3WK8QQNp7139RUqcbI9vobWHMjNBlgNVXOZeOTfPNFWbMI00bTHTYT3TJ/6iMQIgf/gT/cgv4mPqNIZzi45VYRUz4b+3fwHU1VgmOrKfrAAAAABJRU5ErkJggg=="; + +const SIZE = "1rem"; +const SVG_STYLE = + "flex-shrink: 0; display: inline-block; vertical-align: middle;"; + +const pngGlyph = (dataUri: string, title: string) => BUI.html` + + ${title} + + `; + +const labelGlyph = (label: string, bg: string) => BUI.html` + + ${label} + + 3 ? "6" : "7"} font-weight="700" + font-family="Arial, sans-serif" + >${label} + `; + +// Fragments (.frag) — the official ThatOpen isotype (the green brand mark, +// extracted from the platform's HeaderLogoIcon — just the two mark paths, no +// wordmark), in the brand colour #BCF124. +const fragGlyph = () => BUI.html` + + Fragments + + + `; + +// 3D Tiles archive (.3tz) — a tiled-format badge, drawn the same way as the +// JSON/label glyphs (rounded rect + centered text), in the platform's tiles/ +// reality-capture accent (teal). Mirrors fragGlyph()/labelGlyph() style; swap +// for a brand isotype later if the platform ships one. +const threetzGlyph = () => labelGlyph("3TZ", "#26A69A"); + +const genericGlyph = () => BUI.html` + + + `; + +/** Leading file-format glyph for a row, matching the platform's file browser. */ +export const formatGlyph = (ext: string) => { + switch (ext) { + case "ifc": + return pngGlyph(IFC_PNG, "IFC"); + case "gltf": + case "glb": + return pngGlyph(GLTF_PNG, "glTF"); + case "frag": + return fragGlyph(); + case "3tz": + return threetzGlyph(); + case "json": + return labelGlyph("JSON", "#FF7043"); + default: + return genericGlyph(); + } +}; diff --git a/src/cli/templates/app/src/setups/files-panel.ts b/src/cli/templates/app/src/setups/files-panel.ts new file mode 100644 index 0000000..8a98ec0 --- /dev/null +++ b/src/cli/templates/app/src/setups/files-panel.ts @@ -0,0 +1,1503 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; +import * as BUI from "@thatopen/ui"; +import { cardHeader } from "./card-header"; +import { formatGlyph } from "./file-format-icons"; +import { toolPlaceholderUri } from "../assets/tool-placeholder"; + +/** + * The "Project Files" card (mirrors the properties panel). Lists the project's + * files, lets you upload new ones, and — for IFC files — runs the IFC→fragments + * cloud component. Returns its `bim-panel` element WITHOUT self-mounting — the + * caller (leftStack) places it in the left column stack. + * + * UI behaviour: + * - Each IFC is a top-level row; its generated `.frag` is shown nested under it + * (matched by basename) with a Load action. A `.frag` with no source IFC + * shows as its own top-level row. + * - Conversion is automatic on upload (no manual button). While an upload's IFC + * converts, its row shows a progress bar driven by the polled execution %. + * - Every row has a delete action (archive, with an inline confirm). + * + * Uses the platform client (same instance AppManager holds): listFiles, + * createFile, downloadFile, listComponents, executeComponent, getExecution, + * archiveFile. + * + * @param components engine components + * @param client the PlatformClient (from PlatformClient.fromPlatformContext) + * @returns the `bim-panel` element for the caller to mount + */ +interface FileEntry { + id: string; + name: string; + ext: string; + base: string; +} + +interface Job { + progress: number; + label: string; +} + +// An optimistic, not-yet-uploaded file shown the instant it's picked. +interface UploadEntry { + tempId: string; + name: string; + ext: string; + base: string; +} + +interface FilesState { + loading: boolean; + note: string; + files: FileEntry[]; + jobs: Record; // keyed by source file id + confirmDelete: string | null; // file id pending an inline delete confirm + loaded: string[]; // modelIds currently in the scene (fragments.list keys = fragIds) + loadedByIfc: Record; // IFC fileId → the fragId currently loaded for it + fragOf: Record; // IFC fileId → its fragments fileId (from metadata) + loadingModels: string[]; // modelIds with an in-flight loadFrag (Add spinner) + uploads: UploadEntry[]; // optimistic rows for in-flight uploads + // Frags the app can REACH via IFC metadata but which may be absent from + // listFiles (old-converter output created without projectId, hidden-file + // output, or eventual-consistency lag). Keyed by fragId → { name, ifcId }. + // Guarantees a detached frag never becomes unreachable: even with no listFiles + // row, it surfaces as a standalone "orphan" row and stays in the attach picker. + knownFrags: Record; + associating: string | null; // IFC fileId whose frag-attach picker is open + attachOptions: { fragId: string; label: string }[]; // frags offered in the picker + attachLoading: boolean; // picker is gathering candidate frags + filter: string; // file-list search query (filters rows by name) +} + +const omit = (obj: Record, key: string) => { + const clone = { ...obj }; + delete clone[key]; + return clone; +}; + +// Match the app's standard hairline everywhere (1px contrast-20) — the files +// menu must not draw heavier rules than the rest of the panels. +const BORDER = "1px solid var(--bim-ui_bg-contrast-20, rgba(255, 255, 255, 0.1))"; + +// TEMP DIAGNOSTIC — one-shot guard so the frag-visibility probe runs only once +// per page load (not on every silent reload/refocus). Remove with the diagnostic. +let fragDiagRan = false; + +// File name / label text — always theme-coloured (never inherits black). +const nameText = (name: string, muted = false) => BUI.html` + ${name}${name} +`; + +// Visual loading bar fed by the polled conversion %. +const progressBar = (job: Job) => { + const pct = Math.max(0, Math.min(100, Math.round(job.progress))); + return BUI.html` + + Processing… + + + + ${pct}% + + `; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const filesPanel = (components: OBC.Components, client: any) => { + const fragments = components.get(OBC.FragmentsManager); + const projectId: string | undefined = client?.context?.projectId; + + // Reality-capture (.3tz point cloud / gaussian splat) viewer — opened from a + // .3tz row's "View" action. LAZY-imported on first click: the module spins up + // decode workers at load, so importing it eagerly crashes app startup; the + // dynamic import keeps it off the boot path. Self-contained fullscreen overlay + // with an isolated WebGLRenderer (zero interaction with the pen/MRT world). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let rcViewer: any = null; + const openThreeTZ = async (fileId: string) => { + try { + if (!rcViewer) { + const { realityCaptureViewer } = await import("./reality-capture-viewer"); + rcViewer = realityCaptureViewer(components, client); + } + await rcViewer.loadThreeTZ(fileId); + } catch (error) { + console.error("[files] failed to open .3tz viewer", error); + } + }; + + // "View in model": load the reality-capture dataset INTO the BIM world's scene + // (co-located with the .frag models, one shared camera) via loadIntoWorld — + // instead of the standalone fullscreen overlay. Same lazy-imported rcViewer (it + // reaches the world via the components it was constructed with). + const openInModel = async (fileId: string) => { + try { + if (!rcViewer) { + const { realityCaptureViewer } = await import("./reality-capture-viewer"); + rcViewer = realityCaptureViewer(components, client); + } + // Persisted alignment: if we've saved a gizmo transform for this dataset, + // restore it (RC skips the auto-fit when `transform` is supplied); otherwise + // RC auto-fits to the world origin. Either way `onTransformChange` fires (on + // gizmo release AND the initial auto-fit), so we save the matrix to app-data + // → the dataset reopens exactly where the user last aligned it. + const saved = appData.alignments[fileId]; + // Phase 1 by default: keep postproduction (PEN look) AND occlude the + // splats behind BIM via W3's depth hook (occlusion confirmed correct). + await rcViewer.loadIntoWorld(fileId, { + keepPostproduction: true, + transform: saved ? new THREE.Matrix4().fromArray(saved) : undefined, + onTransformChange: (m: THREE.Matrix4) => { + appData.alignments[fileId] = m.toArray(); + void saveAppData(); + }, + }); + } catch (error) { + console.error("[files] failed to load reality-capture into model", error); + } + }; + + let converterId: string | null = null; + let converterVersionTag: string | null = null; // latest converter version to run + let current: FilesState = { + loading: true, + note: "", + files: [], + jobs: {}, + confirmDelete: null, + loaded: [], + loadedByIfc: {}, + fragOf: {}, + loadingModels: [], + uploads: [], + knownFrags: {}, + associating: null, + attachOptions: [], + attachLoading: false, + filter: "", + }; + + const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + + // fragIds with a load/unload currently in progress. Guards against spamming + // Add/Remove: a second op on the same model is ignored until the first settles, + // so we never dispose a model mid-load or double-load/double-dispose. + const inFlight = new Set(); + + // ── Project app-data (the source of truth for ifc↔frag links) ── + // The platform has no project-scoped HIDDEN-file API (hidden files attach to a + // parent item; createFile is FILE-only). So we persist a regular project file + // with a dotted name. It never renders in this panel (filtered out by name), + // though it can still appear in the platform's global file browser. + const APPDATA_NAME = ".bim-viewer-appdata.json"; + let appData: { + associations: Record; + loadedModels: string[]; + pending: Record; + detached: string[]; + alignments: Record; + } = { + associations: {}, // ifcFileId → fragFileId + loadedModels: [], // modelIds last present in the scene + pending: {}, // ifcFileId → in-flight conversion execution (survives reload) + // IFCs the user has MANUALLY detached. The converter's `fragmentsFileId` stays + // stamped on the IFC metadata forever, so without this, bootstrapFromMetadata + // would re-link a detached IFC on the next reload. This makes detach stick. + detached: [], + // reality-capture fileId → 16-float local→world matrix: the "View in model" + // gizmo alignment, persisted so a splat dataset reopens at its saved position. + alignments: {}, + }; + let appDataFileId: string | null = null; + let appDataLoaded = false; + + // First/only world (created by viewports-manager before this panel mounts). + // Used to put loaded models into the scene and to frame the camera. + const worlds = components.get(OBC.Worlds); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const getWorld = () => [...worlds.list.values()][0] as any; + + // Hidden file picker, triggered by the Upload button. + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = ".ifc,.frag"; + fileInput.multiple = true; + fileInput.style.display = "none"; + document.body.append(fileInput); + + // Trash icon → inline "Delete?" confirm → archive. Renders from `state` so it + // always reflects the latest confirm target. + const deleteControls = (state: FilesState, file: FileEntry) => + state.confirmDelete === file.id + ? BUI.html` + Delete? + removeFile(file.id)}> + apply({ confirmDelete: null })}>` + : BUI.html` apply({ confirmDelete: file.id })} + >`; + + // A `.frag` row. `nested` = shown indented under its source IFC. + const fragRow = (state: FilesState, file: FileEntry, nested: boolean) => BUI.html` +
+ ${nested + ? BUI.html`` + : null} + ${formatGlyph(file.ext)} + ${nameText(file.name, nested)} + + ${state.loaded.includes(file.id) + ? BUI.html` unloadFrag(file.id)} + >Remove from scene` + : BUI.html` loadFrag(file.id)} + >Add to scene`} + ${deleteControls(state, file)} + +
+ `; + + // A reality-capture 3D Tiles row (root `*.tileset.json`). Always top-level. + // Two actions: "View" opens the dataset in a standalone fullscreen overlay + // (loadThreeTZ); "View in model" loads it INTO the BIM world's scene, co-located + // with the .frag models under one shared camera (loadIntoWorld). Both fetch the + // root tileset and stream the hidden tile children on demand. + const tilesRow = (state: FilesState, file: FileEntry) => BUI.html` +
+ ${formatGlyph(file.ext)} + ${nameText(file.name)} + + void openThreeTZ(String(file.id))} + > + void openInModel(String(file.id))} + > + ${deleteControls(state, file)} + +
+ `; + + // The inline frag-attach picker, shown under an IFC row when its clip is + // clicked. Candidates are gathered from every IFC's metadata + standalone frag + // files (converter output is hidden, so it isn't in the file list) — see + // loadAttachOptions. A flat, indented section that reads as part of the row. + const attachPicker = (state: FilesState, file: FileEntry) => { + const hasFrag = !!state.fragOf[file.id]; + const opts = state.attachOptions; + return BUI.html` +
+ Attach a fragments file + ${state.attachLoading + ? BUI.html`Finding fragments…` + : opts.length === 0 + ? BUI.html`No unattached fragments found in this project.` + : BUI.html`
+ ${opts.map((o) => { + const sel = state.fragOf[file.id] === o.fragId; + return BUI.html`
(sel ? undefined : associate(file.id, o.fragId))} + > + ${formatGlyph("frag")} + ${o.label} + ${sel + ? BUI.html`` + : null} +
`; + })} +
`} +
+ ${hasFrag + ? BUI.html` detach(file.id)}> + Detach + ` + : null} + apply({ associating: null })}> + Close + +
+
+ `; + }; + + // An IFC row (one row per IFC — the .frag is NOT shown separately). A clip + // toggle is ALWAYS shown: dim when no frag is attached, bright when one is; + // clicking it opens the attach picker. The action is Add (convert-if-needed, + // then load) ↔ Remove, with a progress bar while converting. + const ifcRow = (state: FilesState, file: FileEntry) => { + const job = state.jobs[file.id]; + const fragId = state.fragOf[file.id]; // from the app-data associations + const hasFrag = !!fragId; + const fragName = + state.files.find((f) => f.id === fragId)?.name ?? "Fragments attached"; + // "Loaded" must reflect the CURRENTLY associated frag — not merely that some + // (possibly stale) frag for this IFC is in the scene. If a different frag is + // loaded for this IFC, the row stays on "Add" so a click loads the new one. + const loaded = !!fragId && state.loadedByIfc[file.id] === fragId; + const picking = state.associating === file.id; + return BUI.html` +
+
+ ${formatGlyph(file.ext)} + ${nameText(file.name)} + openAttach(file)} + style="position: relative; display: inline-flex; align-items: center; flex-shrink: 0; cursor: pointer;" + >${hasFrag ? fragName : "Attach a fragments file"} + + ${job + ? progressBar(job) + : loaded + ? BUI.html` unloadFrag(fragId!, file.id)} + >Remove from scene` + : BUI.html` addToScene(file)} + >Add to scene`} + ${deleteControls(state, file)} + +
+ ${picking ? attachPicker(state, file) : null} +
+ `; + }; + + // Optimistic row for a file that's still uploading (shown the instant it's + // picked). No real % is available from createFile, so the bar is indeterminate. + const uploadRow = (entry: UploadEntry) => BUI.html` +
+ ${formatGlyph(entry.ext)} + ${nameText(entry.name)} + + Uploading… + + +
+ `; + + // Top-level list: every IFC, plus any standalone `.frag` with no source IFC. + const renderList = (state: FilesState) => { + const ifcs = state.files.filter((f) => f.ext === "ifc"); + const frags = state.files.filter((f) => f.ext === "frag"); + // Reality-capture 3D Tiles — always standalone top-level rows (no IFC + // source). The loose-tiles viewer opens the ROOT `*.tileset.json` (tiles are + // hidden `.spz`/`.pnts` children, streamed on demand). Match the full + // `.tileset.json` suffix — NOT `ext === "json"`, which would catch app-data + // and every other JSON. The old `.3tz` zip format is no longer viewable here + // (the loose-tiles viewer has no unzip path), so it gets no row/View action. + const tiles = state.files.filter((f) => + f.name.toLowerCase().endsWith(".tileset.json"), + ); + // A frag is "linked" if some IFC's metadata points to it; those are + // represented by their IFC row, not a standalone row. + const linked = new Set(Object.values(state.fragOf)); + const standalone = frags.filter((f) => !linked.has(f.id)); + // ORPHAN frags: reachable via IFC metadata but NOT present as a listFiles row + // (old-converter output with no projectId, hidden-file output, or listFiles + // lag). Once unlinked, they'd otherwise be invisible — so surface them here as + // standalone rows. This is what makes the "a detached frag can never become + // unreachable" guarantee hold even when the frag isn't a listable file. + const listedIds = new Set(state.files.map((f) => f.id)); + const orphans: FileEntry[] = Object.entries(state.knownFrags) + .filter(([fid]) => !linked.has(fid) && !listedIds.has(fid)) + .map(([fid, info]) => { + const name = info.name || "fragments.frag"; + return { + id: fid, + name, + ext: "frag", + base: name.replace(/\.[^.]+$/, ""), + }; + }); + const all = [ + ...ifcs.map((f) => ({ kind: "ifc" as const, file: f })), + ...standalone.map((f) => ({ kind: "frag" as const, file: f })), + ...orphans.map((f) => ({ kind: "frag" as const, file: f })), + ...tiles.map((f) => ({ kind: "3tz" as const, file: f })), + ].sort((a, b) => a.file.name.localeCompare(b.file.name)); + const q = state.filter.trim().toLowerCase(); + const top = q + ? all.filter((e) => e.file.name.toLowerCase().includes(q)) + : all; + return BUI.html` +
+ ${state.uploads.map((u) => uploadRow(u))} + ${top.map((entry) => + entry.kind === "ifc" + ? ifcRow(state, entry.file) + : entry.kind === "3tz" + ? tilesRow(state, entry.file) + : fragRow(state, entry.file, false), + )} + ${top.length === 0 && all.length > 0 + ? BUI.html`
No files match "${state.filter}".
` + : null} +
+ `; + }; + + // ── Assets coordination settings (increment c) ──────────────── + // Base-model + .frag auto-coordinate, wired to W3's FragmentsManager API + // (setBaseModel / setAutoCoordinate / coordinate). Cast `as any` — these are + // new lib methods not yet in the copied dist's types (bounce on wire). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const frg = fragments as any; + let autoCoordOn = true; // global UX: all models use their embedded placement (default) + const baseModelId = (): string => String(frg.baseCoordinationModel ?? ""); + const modelLabel = (st: FilesState, id: string): string => + st.files.find((f) => f.id === id)?.name ?? id; + + const [panel, update] = BUI.Component.create( + (state) => BUI.html` + + + +
+ ${cardHeader("mdi:folder-multiple-outline", "Project Files", "1.1rem")} +
+ + ${fragments.list.size > 0 + ? BUI.html`
+
+ Base model + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const v = (e.target as any).value?.[0]; + if (v) void onBaseChange(String(v)); + }} + > + ${[...fragments.list.keys()].map( + (id) => BUI.html``, + )} + +
+ +
` + : null} + +
+
+ { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + apply({ filter: String((e.target as any).value ?? "") }); + }} + > + fileInput.click()} + >Upload file +
+ ${state.note + ? BUI.html`${state.note}` + : null} +
+ +
+ ${state.loading + ? BUI.html`Loading…` + : state.files.length === 0 + ? BUI.html`
+ + No files yet. Upload an IFC to convert it. +
` + : renderList(state)} +
+
+
+
+ `, + current, + ); + + const apply = (partial: Partial) => { + current = update(partial); + }; + + // ── Assets coordination handlers (increment c) ───────────────── + // Re-coordination can shift models a LOT (geo-referenced CRS coords) → re-fit + // the camera to the new union bounds so the model never jumps off-screen. + const refitCamera = async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const world = [...worlds.list.values()][0] as any; + const controls = world?.camera?.controls; + if (!controls?.fitToSphere) return; + const box = new THREE.Box3(); + for (const [, m] of fragments.list) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b = (m as any).box as THREE.Box3 | undefined; + if (b && !b.isEmpty()) box.union(b); + } + if (box.isEmpty()) return; + const sphere = box.getBoundingSphere(new THREE.Sphere()); + try { + await controls.fitToSphere(sphere, true); + } catch (error) { + console.warn("[files-panel] re-fit after coordinate failed", error); + } + }; + const onBaseChange = async (modelId: string) => { + if (fragments.list.size === 0 || !modelId) return; // guard per W3 + try { + await frg.setBaseModel(modelId); // re-coordinates the rest to it (async, self-updates) + } catch (error) { + console.warn("[files-panel] setBaseModel failed", error); + } + await refitCamera(); + apply({}); // reflect the new base in the dropdown + }; + const onAutoCoord = async (on: boolean) => { + autoCoordOn = on; + // Apply to ALL loaded models (global toggle): on = embedded placement aligned + // to base; off = each parked at three-space origin. + for (const id of fragments.list.keys()) { + try { + await frg.setAutoCoordinate(id, on); + } catch (error) { + console.warn("[files-panel] setAutoCoordinate failed", id, error); + } + } + await refitCamera(); + apply({}); + }; + + // ── Scene wiring + reactive loaded-state ─────────────────────── + // `fragments.core.load` only adds a model to `fragments.list`; it does NOT put + // it in the scene. The model must be added to the world scene and told which + // camera to use for culling/LOD. This wiring used to live with the (now + // removed) hardcoded model — its absence is why "Add" loaded nothing visible. + // Subscribing to the list also keeps the Add/Remove buttons in sync with the + // actual scene, even if a model is added/removed elsewhere. + const syncLoaded = () => { + const loaded = [...fragments.list.keys()]; + // Drop any ifc→fragId mapping whose model is no longer in the scene (it may + // have been disposed elsewhere), so the IFC row falls back to "Add". + const inScene = new Set(loaded); + const loadedByIfc = Object.fromEntries( + Object.entries(current.loadedByIfc).filter(([, fid]) => inScene.has(fid)), + ); + apply({ loaded, loadedByIfc }); + }; + + // Keep the Add/Remove buttons in sync with the scene. We deliberately do NOT + // wire the model into the scene here: onItemSet fires while fragments.core.load + // is still registering the model in the worker, so driving + // useCamera/scene.add/update(true) now addresses a model the worker doesn't + // have yet → "Model not found". loadFrag does that wiring AFTER load resolves. + fragments.list.onItemSet.add(() => syncLoaded()); + + fragments.list.onItemDeleted.add((event) => { + // disposeModel removes it from the list; defensively detach the object from + // the scene too so Remove always clears it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (event as any)?.value?.object?.removeFromParent?.(); + fragments.core.update(true); + syncLoaded(); + }); + + // ── Data ─────────────────────────────────────────────────────── + async function resolveConverter() { + try { + const comps = await client.listComponents({ projectId }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const match = + comps.find( + (c: any) => /ifc/i.test(c.name) && /(frag|convert)/i.test(c.name), + ) ?? comps.find((c: any) => /ifc/i.test(c.name)); + converterId = match?._id ? String(match._id) : null; + if (!converterId) { + apply({ note: "IFC→fragments component not found in this project." }); + return; + } + // Resolve the LATEST version so we don't run pinned v1 (old converter). + // The platform returns versions newest-FIRST, so index-based picking is + // unsafe. Sort by createdAt desc; fall back to the highest numeric tag. + try { + const comp = await client.getComponent(converterId, { showVersions: true }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const versions: any[] = comp?.versions ?? []; + const tagNum = (t: unknown) => { + const m = /(\d+)/.exec(String(t ?? "")); + return m ? parseInt(m[1], 10) : -1; + }; + const newest = versions.slice().sort((a, b) => { + const ta = a?.createdAt ? new Date(a.createdAt).getTime() : NaN; + const tb = b?.createdAt ? new Date(b.createdAt).getTime() : NaN; + if (!Number.isNaN(ta) && !Number.isNaN(tb) && ta !== tb) return tb - ta; // newest first + return tagNum(b?.tag) - tagNum(a?.tag); // fallback: highest tag first + })[0]; + converterVersionTag = newest ? String(newest.tag) : null; + } catch (verError) { + console.warn("[files-panel] could not read converter versions", verError); + } + } catch (error) { + console.warn("[files-panel] listComponents failed", error); + } + } + + async function loadFiles(opts: { silent?: boolean } = {}) { + if (!projectId) { + apply({ loading: false, note: "No project context." }); + return; + } + if (!opts.silent) apply({ loading: true }); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const items = (await client.listFiles({ projectId })) as any[]; + const files: FileEntry[] = items + .map((it) => { + const name: string = it.name ?? "file"; + // Use `||` not `??`: converter-created files come back with + // `fileExtension: ""` (empty string, not null), which `??` would keep + // — leaving a `.frag` mis-typed as "" and invisible everywhere. + const ext = (it.fileExtension || name.split(".").pop() || "") + .toString() + .toLowerCase(); + return { id: String(it._id), name, ext, base: name.replace(/\.[^.]+$/, "") }; + }) + .filter((f) => f.name !== APPDATA_NAME) // never surface the app-data store + .sort((a, b) => a.name.localeCompare(b.name)); + apply({ loading: false, files, note: current.note }); + // The app-data JSON is the source of truth for ifc↔frag links. + await syncAppData(items, files); + // Build the metadata-reachable frag index (safety net for the data-loss + // guarantee). Independent of listFiles, so a detached frag never vanishes. + await discoverKnownFrags(files); + // If the attach picker is open, refresh its candidate frags so a newly + // generated/converted model appears without closing & reopening it. + if (current.associating) { + const openIfc = current.files.find((f) => f.id === current.associating); + if (openIfc) loadAttachOptions(openIfc); + } + } catch (error) { + apply({ loading: false, note: `Could not list files: ${error}` }); + } + } + + // Cache of resolved IFC→frag links. A frag link is stable once known, so we + // never re-hit the backend for an IFC we've already resolved — this (plus the + // concurrency cap below) stops discoverKnownFrags from bursting getFile/ + // getFileVersionMetadata across every IFC on every refresh and tripping the API + // rate limiter (429). IFCs with no link yet are NOT cached, so a pending + // conversion is still re-checked on later refreshes. + const fragIdCache = new Map(); + + // Run an async op over items with a small concurrency cap (instead of + // Promise.all firing all at once) — keeps request bursts under the rate limit. + async function mapLimit( + items: T[], + limit: number, + fn: (item: T) => Promise, + ): Promise { + let i = 0; + const run = async () => { + while (i < items.length) await fn(items[i++]); + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run)); + } + + // Read an IFC's version metadata and return its linked fragments fileId, or + // null. The converter stamps `{ fragmentsFileId, conversionStatus }` on the + // IFC's version. Version tag defaults to "v1" (how uploads are tagged) but we + // resolve the file's latest tag to be safe. Cached once resolved (see above). + async function ifcFragId(ifc: FileEntry): Promise { + const cached = fragIdCache.get(ifc.id); + if (cached) return cached; + let tag = "v1"; + try { + const file = await client.getFile(ifc.id, { showVersions: true }); + const versions = file?.versions ?? []; + if (versions.length) tag = String(versions[versions.length - 1].tag); + } catch { + /* fall back to v1 */ + } + try { + const meta = await client.getFileVersionMetadata(ifc.id, tag); + const fragId = meta?.fragmentsFileId; + if (fragId && meta?.conversionStatus !== "error") { + fragIdCache.set(ifc.id, String(fragId)); + return String(fragId); + } + } catch (error) { + console.warn("[files-panel] getFileVersionMetadata failed", ifc.id, error); + } + return null; + } + + // Discover EVERY frag the app can reach via IFC version metadata and record it + // in `knownFrags` (fragId → name + owning IFC). This is the safety net that + // makes the data-loss guarantee hold: a frag reachable from an IFC's metadata + // is registered here regardless of whether it appears in listFiles, so it can + // ALWAYS surface — as an orphan standalone row once unlinked, and in the attach + // picker — even if it was produced by an old converter (no projectId → not + // listable), lives as a hidden file, or listFiles is lagging. Resolving the + // frag's display name uses the listFiles row if present, else getFile by id + // (works for non-listable files), else the IFC basename. + // Re-entrancy guard: refresh can fire in bursts (onItemSet). Running discovery + // concurrently with itself multiplies the request load → 429. So coalesce: if a + // run is in flight, stash the latest files and run exactly once more when it ends. + let discovering = false; + let discoverNext: FileEntry[] | null = null; + async function discoverKnownFrags(files: FileEntry[]) { + if (discovering) { + discoverNext = files; + return; + } + discovering = true; + try { + const ifcs = files.filter((f) => f.ext === "ifc"); + const byId = new Map(files.map((f) => [f.id, f])); + const found: Record = {}; + await mapLimit(ifcs, 3, async (ifc) => { + const fragId = await ifcFragId(ifc); + if (!fragId) return; + let name = byId.get(fragId)?.name ?? ""; + if (!name) { + try { + const item = await client.getFile(fragId); + name = item?.name ?? ""; + } catch { + /* non-fatal — fall back to a derived name below */ + } + } + if (!name) name = `${ifc.base}.frag`; + found[fragId] = { name, ifcId: ifc.id }; + }); + apply({ knownFrags: found }); + } finally { + discovering = false; + const next = discoverNext; + discoverNext = null; + if (next) void discoverKnownFrags(next); + } + } + + // Persist the app-data JSON (create the store file the first time, then bump a + // new version on every save). downloadFile returns the latest version. + async function saveAppData() { + if (!projectId) return; + try { + const blob = new File([JSON.stringify(appData)], APPDATA_NAME, { + type: "application/json", + }); + if (appDataFileId) { + await client.updateFile(appDataFileId, { + file: blob, + versionTag: `v${Date.now()}`, + }); + } else { + const created = await client.createFile({ + file: blob, + name: APPDATA_NAME, + versionTag: "v1", + projectId, + }); + appDataFileId = created?.item?._id ? String(created.item._id) : null; + } + } catch (error) { + console.warn("[files-panel] could not save app-data", error); + } + } + + // One-time heal: for any IFC with no JSON association, seed it from the IFC's + // version metadata (the converter also stamps fragmentsFileId there). Covers + // frags converted before this JSON store existed (e.g. school_str). + async function bootstrapFromMetadata(files: FileEntry[]) { + // Skip IFCs the user manually detached — their metadata still points at the + // frag, but we must NOT silently re-attach it. + const detached = new Set(appData.detached); + const ifcs = files.filter( + (f) => f.ext === "ifc" && !appData.associations[f.id] && !detached.has(f.id), + ); + let changed = false; + await Promise.all( + ifcs.map(async (ifc) => { + const fragId = await ifcFragId(ifc); + if (fragId) { + appData.associations[ifc.id] = fragId; + changed = true; + } + }), + ); + if (changed) await saveAppData(); + } + + // Load the app-data store (once), heal from it, then push the + // associations into the UI as the source of truth. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async function syncAppData(items: any[], files: FileEntry[]) { + const adItem = items.find((it) => it?.name === APPDATA_NAME); + if (adItem) appDataFileId = String(adItem._id); + + if (!appDataLoaded) { + appDataLoaded = true; + if (appDataFileId) { + try { + const resp = await client.downloadFile(appDataFileId); + const parsed = JSON.parse(await resp.text()); + appData = { + associations: parsed?.associations ?? {}, + loadedModels: parsed?.loadedModels ?? [], + pending: parsed?.pending ?? {}, + detached: parsed?.detached ?? [], + alignments: parsed?.alignments ?? {}, + }; + } catch (error) { + console.warn("[files-panel] could not read app-data", error); + } + } + await bootstrapFromMetadata(files); + // Proactively create the store on first open if it doesn't exist yet + // (bootstrap only saves when it seeded something). The `adItem` lookup + // above sets appDataFileId when the file already exists, so on later opens + // this is skipped — created exactly once, never duplicated. + if (!appDataFileId) await saveAppData(); + // No scene restore — Antonio adds models manually each session. + // Resume any conversion that was still running when the viewer last closed. + await resumePending(files); + } + // Drop dangling references for files deleted outside the app (CDE / project + // settings). Runs on every loadFiles, so it self-heals on open + on refocus. + await reconcileAppData(items); + apply({ fragOf: { ...appData.associations } }); + } + + // Prune appData of references to files that no longer exist in the project. + // Never touches an IFC with an in-flight conversion, and never calls convert(). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async function reconcileAppData(items: any[]) { + const existing = new Set(items.map((it) => String(it._id))); + let changed = false; + for (const [ifcId, fragId] of Object.entries(appData.associations)) { + if (current.jobs[ifcId]) continue; // mid-conversion — leave it alone + if (!existing.has(ifcId) || !existing.has(fragId)) { + delete appData.associations[ifcId]; + changed = true; + } + } + // Drop pending conversions whose IFC is gone or already linked (unless a job + // is actively tracking it this session). + for (const ifcId of Object.keys(appData.pending)) { + if (current.jobs[ifcId]) continue; + if (!existing.has(ifcId) || appData.associations[ifcId]) { + delete appData.pending[ifcId]; + changed = true; + } + } + // loadedModels is vestigial (scene-restore was removed); keep it empty so it + // can never hold stale ids. + if (appData.loadedModels.length) { + appData.loadedModels = []; + changed = true; + } + if (changed) await saveAppData(); + } + + // Poll the IFC metadata until fragmentsFileId appears (backend consistency can + // lag a beat after the execution reports SUCCESS). + async function waitForFragId(ifc: FileEntry, tries: number): Promise { + for (let i = 0; i < tries; i += 1) { + const fragId = await ifcFragId(ifc); + if (fragId) return fragId; + await delay(1000); + } + return null; + } + + // Open (or toggle) the attach picker for an IFC and gather candidate frags. + function openAttach(file: FileEntry) { + const opening = current.associating !== file.id; + apply({ + associating: opening ? file.id : null, + confirmDelete: null, + attachOptions: [], + attachLoading: opening, + }); + if (opening) loadAttachOptions(file); + } + + // List the fragments files a user could attach: the project's `.frag` files + // (from listFiles in loadFiles) MINUS any attached to ANOTHER IFC — a frag + // belongs to a single IFC. This IFC's OWN attached frag is kept in the list + // (rendered as "Attached") so the user sees what's linked. Labelled by + // filename. Synchronous over already-loaded state, so it populates immediately. + function loadAttachOptions(ifc: FileEntry) { + if (current.associating !== ifc.id) return; // picker closed/changed meanwhile + const attachedElsewhere = new Set( + Object.entries(current.fragOf) + .filter(([ifcId]) => ifcId !== ifc.id) + .map(([, fragId]) => fragId), + ); + const attachOptions = current.files + .filter((f) => f.ext === "frag" && !attachedElsewhere.has(f.id)) + .map((f) => ({ fragId: f.id, label: f.name })); + apply({ attachOptions, attachLoading: false }); + } + + // Manually link an IFC to an existing fragments file (clip → picker), and the + // inverse. Persisted in app-data exactly like an auto-converted association. + async function associate(ifcId: string, fragId: string) { + appData.associations[ifcId] = fragId; + // Re-attaching clears any prior manual-detach override for this IFC. + appData.detached = appData.detached.filter((id) => id !== ifcId); + await saveAppData(); + apply({ fragOf: { ...appData.associations }, associating: null }); + } + async function detach(ifcId: string) { + // The frag this IFC is being detached from = the one that may be in the scene. + const fragId = appData.associations[ifcId]; + delete appData.associations[ifcId]; + // Remember the detach so bootstrapFromMetadata won't re-link it on reload. + if (!appData.detached.includes(ifcId)) appData.detached.push(ifcId); + await saveAppData(); + // Remove that frag from the scene if it's loaded. Models are keyed by fragId, + // so dispose by fragId directly (covers frags auto-loaded at startup too, not + // just ones tracked in loadedByIfc). + if (fragId && fragments.list.has(fragId)) await unloadFrag(fragId, ifcId); + apply({ fragOf: { ...appData.associations }, associating: null }); + } + + // Run the IFC→fragments converter for `fileId`. Fires only from user actions + // (upload, or Add on an un-converted IFC) — never on init. After success we + // poll the IFC metadata for the produced `fragmentsFileId` (so the attachment + // icon appears); with `opts.addToScene` we also load that frag into the scene. + async function convert( + fileId: string, + label: string, + opts: { ifc?: FileEntry; addToScene?: boolean } = {}, + ) { + // The converter may not have resolved yet (listComponents in flight). Try + // once more before giving up, so a quick upload still converts. + if (!converterId) await resolveConverter(); + if (!converterId) { + console.error( + "[files-panel] no IFC→fragments converter component resolved — cannot convert", + label, + ); + apply({ note: "IFC→fragments component not found in this project." }); + return; + } + apply({ jobs: { ...current.jobs, [fileId]: { progress: 0, label: "Converting" } } }); + try { + console.log( + "[files-panel] executing converter version", + converterVersionTag ?? "(default/v1)", + ); + const { executionId } = await client.executeComponent( + converterId, + { projectId, fileId }, + converterVersionTag ?? undefined, + ); + // Persist the in-flight execution so re-entering the viewer can resume it + // (the poll loop dies with the page; this id lets us re-attach on load). + appData.pending[fileId] = { + executionId: String(executionId), + startedAt: Date.now(), + }; + await saveAppData(); + trackExecution(fileId, String(executionId), opts); + } catch (error) { + console.error("[files-panel] executeComponent failed", error); + apply({ jobs: omit(current.jobs, fileId), note: `Conversion failed: ${error}` }); + } + } + + // Poll a converter execution to completion: drive the row's progress bar and, + // on success, link (and optionally scene-load) the produced fragments file. + // Shared by a fresh convert() and by resumePending() on viewer entry; always + // clears the persisted `pending` entry when the execution ends. + function trackExecution( + fileId: string, + executionId: string, + opts: { ifc?: FileEntry; addToScene?: boolean } = {}, + ) { + const finish = async (ok: boolean, note?: string, fragIdFromResult?: string) => { + const hadPending = !!appData.pending[fileId]; + if (hadPending) delete appData.pending[fileId]; + apply({ jobs: omit(current.jobs, fileId), note: note ?? current.note }); + await loadFiles(); + if (ok && opts.ifc) { + // Prefer the deterministic id from the execution result; fall back to the + // IFC metadata (pre-reupload converter), polling for consistency. + let fragId = fragIdFromResult ?? null; + if (!fragId) fragId = await waitForFragId(opts.ifc, 6); + if (fragId) { + appData.associations[opts.ifc.id] = fragId; + await saveAppData(); // also persists the pending removal above + apply({ fragOf: { ...appData.associations } }); + if (opts.addToScene) await loadFrag(fragId, opts.ifc.id); + return; + } + console.warn( + "[files-panel] conversion SUCCESS but no fragmentsFileId (result token nor metadata)", + opts.ifc.id, + ); + apply({ + note: "Converted, but couldn't link the fragments file — try Add again.", + }); + } + // Error / no-ifc path still needs to persist the cleared pending entry. + if (hadPending) await saveAppData(); + }; + + // Poll execution status until it completes. The realtime WebSocket + // (onExecutionProgress / socket.io) does not connect from inside the + // platform's sandboxed iframe, so polling getExecution is the reliable path + // for progress + result. + let attempts = 0; + const poll = async () => { + attempts += 1; + try { + const exec = await client.getExecution(executionId); + if (typeof exec.progress === "number") { + apply({ + jobs: { + ...current.jobs, + [fileId]: { progress: exec.progress, label: "Converting" }, + }, + }); + } + if (exec.result) { + if (exec.result === "ERROR") { + console.error("[files-panel] conversion ERROR", exec.resultMessage, exec); + finish(false, `Conversion failed: ${exec.resultMessage ?? "error"}`); + } else { + // New converter appends `[fragmentsFileId=]` to resultMessage. + const m = /fragmentsFileId=([^\]\s]+)/.exec(exec.resultMessage ?? ""); + finish(true, undefined, m?.[1]); + } + return; + } + } catch (pollError) { + console.warn("[files-panel] getExecution poll error (transient)", pollError); + } + if (attempts < 400) { + setTimeout(poll, 1500); + } else { + finish(false, "Conversion timed out (may still be running on the server)."); + } + }; + setTimeout(poll, 1200); + } + + // On viewer entry, re-attach to any conversion that was still running when the + // viewer last closed (persisted in appData.pending). Entries that already + // finished while away, or whose IFC is gone, are dropped. + async function resumePending(files: FileEntry[]) { + let changed = false; + for (const [ifcId, info] of Object.entries(appData.pending)) { + const ifc = files.find((f) => f.id === ifcId); + if (!ifc || appData.associations[ifcId]) { + delete appData.pending[ifcId]; + changed = true; + continue; + } + console.log("[files-panel] resuming conversion for", ifc.name, info.executionId); + apply({ jobs: { ...current.jobs, [ifcId]: { progress: 0, label: "Converting" } } }); + trackExecution(ifcId, info.executionId, { ifc, addToScene: false }); + } + if (changed) await saveAppData(); + } + + // Add an IFC's model to the scene: load the CURRENTLY associated frag, or + // convert first (showing the progress bar on the row) and load the result. + // If a DIFFERENT (stale) frag is already loaded for this IFC — e.g. the user + // re-associated it — dispose that one first so the scene matches the UI. + async function addToScene(ifc: FileEntry) { + const fragId = current.fragOf[ifc.id]; + if (!fragId) { + convert(ifc.id, ifc.name, { ifc, addToScene: true }); + return; + } + const stale = current.loadedByIfc[ifc.id]; + if (stale && stale !== fragId && fragments.list.has(stale)) { + await unloadFrag(stale, ifc.id); + } + await loadFrag(fragId, ifc.id); + } + + // ADD: download the .frag and load it, keyed by the fragId (so the scene model + // tracks the actual frag, not the IFC basename — re-association swaps it). The + // onItemSet handler above puts it in the scene; here we kick off the load and + // record which frag is loaded for the owning IFC (if any). + async function loadFrag(fragId: string, ifcId?: string) { + if (inFlight.has(fragId)) return; // a load/unload is already running — ignore + if (ifcId) apply({ loadedByIfc: { ...current.loadedByIfc, [ifcId]: fragId } }); + if (fragments.list.has(fragId)) return; // already in the scene + inFlight.add(fragId); + apply({ loadingModels: [...current.loadingModels, fragId] }); // Add spinner + try { + const response = await client.downloadFile(fragId); + const buffer = await response.arrayBuffer(); + // Await load so the model is fully registered in the worker BEFORE we + // address it (useCamera/scene.add/update post worker actions by modelId). + await fragments.core.load(buffer, { modelId: fragId }); + const model = fragments.list.get(fragId); + const world = getWorld(); + if (world && model) { + model.useCamera(world.camera.three); + world.scene.three.add(model.object); + } + await fragments.core.update(true); + // Camera intentionally left where it is — Antonio adds models manually and + // doesn't want the view to jump. + } catch (error) { + console.error("[files-panel] failed to add model to scene", error); + apply({ note: `Could not load model: ${error}` }); + } finally { + inFlight.delete(fragId); + apply({ loadingModels: current.loadingModels.filter((m) => m !== fragId) }); + } + } + + // REMOVE: dispose the model — drops it from the scene and from fragments.list + // (onItemDeleted re-syncs the buttons back to "Add"). Also clears the IFC's + // loaded-frag record so its row reverts to "Add". + async function unloadFrag(fragId: string, ifcId?: string) { + if (inFlight.has(fragId)) return; // a load/unload is already running — ignore + if (ifcId) { + const { [ifcId]: _drop, ...rest } = current.loadedByIfc; + apply({ loadedByIfc: rest }); + } + if (!fragments.list.has(fragId)) return; // nothing in the scene to dispose + inFlight.add(fragId); + apply({ loadingModels: [...current.loadingModels, fragId] }); // Remove spinner + try { + // Clear the current selection FIRST. The Highlighter/Outliner hold + // references to the selected items' geometry; disposing the model out from + // under them crashes the next render. Clearing the select style drops those + // refs (and, via the wired onClear, the Outliner's) before disposal. + try { + const highlighter = components.get(OBF.Highlighter); + await highlighter.clear(highlighter.config.selectName); + } catch { + /* highlighter not set up / nothing selected — safe to ignore */ + } + await fragments.core.disposeModel(fragId); + } catch (error) { + console.error("[files-panel] disposeModel failed", error); + apply({ note: `Could not remove model: ${error}` }); + } finally { + inFlight.delete(fragId); + apply({ loadingModels: current.loadingModels.filter((m) => m !== fragId) }); + } + } + + // Soft-delete (archive) — recoverable server-side. Refreshes the list. + async function removeFile(fileId: string) { + apply({ confirmDelete: null }); + try { + // If a frag is being deleted while it's loaded, drop it from the scene too + // (models are keyed by fragId). reconcileAppData (in loadFiles) then prunes + // any IFC association pointing at the now-deleted frag. + if (fragments.list.has(fileId)) await unloadFrag(fileId); + await client.archiveFile(fileId); + await loadFiles(); + } catch (error) { + apply({ note: `Could not delete file: ${error}` }); + } + } + + fileInput.onchange = async () => { + const picked = Array.from(fileInput.files ?? []); + fileInput.value = ""; + if (picked.length === 0 || !projectId) return; + // Show every picked file INSTANTLY with an "Uploading…" row (optimistic), so + // there's no wait for the platform upload. If the user closes the app before + // an upload finishes, that file simply never lands (acceptable). + const queued = picked.map((file, i) => { + const ext = (file.name.split(".").pop() ?? "").toLowerCase(); + const base = file.name.replace(/\.[^.]+$/, ""); + return { + file, + entry: { tempId: `up-${Date.now()}-${i}`, name: file.name, ext, base }, + }; + }); + apply({ uploads: [...current.uploads, ...queued.map((q) => q.entry)] }); + + // Upload in parallel; settle each optimistic row as its upload completes. + await Promise.all( + queued.map(async ({ file, entry }) => { + try { + const created = await client.createFile({ + file, + name: file.name, + versionTag: "v1", + projectId, + }); + const newId = String(created?.item?._id ?? ""); + apply({ uploads: current.uploads.filter((u) => u.tempId !== entry.tempId) }); + await loadFiles(); + // Conversion fires ONLY on a fresh upload — never on load/init for + // pre-existing IFCs. Pass the IFC entry so the attachment icon appears + // once metadata links the produced frag (not auto-added to the scene). + if (entry.ext === "ifc" && newId) { + convert(newId, entry.name, { + ifc: { id: newId, name: entry.name, ext: "ifc", base: entry.base }, + }); + } + } catch (error) { + apply({ + uploads: current.uploads.filter((u) => u.tempId !== entry.tempId), + note: `Upload failed for ${file.name}: ${error}`, + }); + } + }), + ); + }; + + // Catch external (CDE / project-settings) deletions when Antonio returns to + // the tab — cheap, no tight polling. Silent reload to avoid UI flicker. + const onRefocus = () => { + if (document.visibilityState === "visible") loadFiles({ silent: true }); + }; + window.addEventListener("focus", onRefocus); + document.addEventListener("visibilitychange", onRefocus); + // The app runs in the platform iframe, so deletions made in the platform's own + // file browser (a different frame) never fire our focus/visibility events. A + // modest silent poll reconciles the list with the backend without a reload. + window.setInterval(() => { + if (document.visibilityState === "visible") loadFiles({ silent: true }); + }, 8000); + + // TEMP DIAGNOSTIC ───────────────────────────────────────────────────────── + // Why are some component-created `.frag` files listable project files while + // others (school_str / BLOXHUB) are discoverable-by-id but absent from the + // project "Files" overview? This probes the live platform (the shell has no + // auth) to surface what distinguishes each frag: its projectId / folderId / + // owner / archived / hidden flags, and whether listFiles({projectId}) returns + // it. Runs ONCE after the first loadFiles. Search the console for "[frag-diag". + // Remove this whole block (and the `fragDiagRan` flag) when done. + async function runFragDiagnostic() { + if (fragDiagRan) return; + fragDiagRan = true; + try { + // What the project-wide overview uses: listFiles filtered by projectId. + const listed = (await client.listFiles({ projectId })) as any[]; // eslint-disable-line @typescript-eslint/no-explicit-any + const listedIds = new Set(listed.map((it) => String(it._id))); + console.log( + "[frag-diag] listFiles({projectId}) returned", + listed.length, + "items:", + listed.map((it) => ({ id: String(it._id), name: it.name })), + ); + + // For every IFC, resolve its frag id (metadata / app-data) and fetch the + // frag item directly — getFile works by id even for non-listable files. + const ifcs = current.files.filter((f) => f.ext === "ifc"); + for (const ifc of ifcs) { + const fragId = + appData.associations[ifc.id] ?? (await ifcFragId(ifc)) ?? null; + if (!fragId) { + console.log("[frag-diag]", { ifcName: ifc.name, fragId: null, note: "no frag id resolved" }); + continue; + } + let item: any = null; // eslint-disable-line @typescript-eslint/no-explicit-any + try { + item = await client.getFile(fragId, { showVersions: true }); + } catch (e) { + console.log("[frag-diag] getFile failed", { ifcName: ifc.name, fragId, error: String(e) }); + continue; + } + // Pull whatever distinguishing fields the item actually carries. The + // exact field names vary by backend, so log the whole item too. + console.log("[frag-diag]", { + ifcName: ifc.name, + fragId, + fragName: item?.name, + projectId: item?.projectId, + folderId: item?.folderId, + parentItemId: item?.parentItemId ?? item?.parentId ?? item?.parentFileId, + owningEntity: item?.owningEntity ?? item?.owner ?? item?.createdBy ?? item?.ownerId, + archived: item?.archived, + isHidden: item?.isHidden ?? item?.hidden, + inListFiles: listedIds.has(String(fragId)), // <-- the key signal + }); + console.log("[frag-diag] full frag item for", ifc.name, item); + } + } catch (e) { + console.warn("[frag-diag] diagnostic failed", e); + } + } + // END TEMP DIAGNOSTIC ────────────────────────────────────────────────────── + + // Kick off + resolveConverter(); + // TEMP DIAGNOSTIC: run the frag-visibility probe once after the first load. + loadFiles().then(() => runFragDiagnostic()); + + return panel; +}; diff --git a/src/cli/templates/app/src/setups/fps-indicator.ts b/src/cli/templates/app/src/setups/fps-indicator.ts new file mode 100644 index 0000000..5a6ee35 --- /dev/null +++ b/src/cli/templates/app/src/setups/fps-indicator.ts @@ -0,0 +1,62 @@ +/** + * Small FPS counter, mounted INSIDE the viewer (top-center overlay). Measures + * the browser animation-frame rate (a good proxy for overall responsiveness). + * + * It is styled like the rest of the app (panel surface + 1px contrast-20 border + * + theme text), not a raw green-on-black badge, and is TOGGLEABLE — the + * returned controller's `setVisible` is wired to a "Show FPS" switch in the + * Graphics panel. The rAF loop keeps running while hidden (cost is negligible); + * only the element's visibility changes. + */ +export interface FpsIndicator { + /** The overlay element (already mounted inside the viewer). */ + element: HTMLElement; + /** Whether the counter is currently shown. */ + readonly visible: boolean; + /** Show / hide the counter. */ + setVisible: (v: boolean) => void; +} + +export const fpsIndicator = (parent: HTMLElement): FpsIndicator => { + const el = document.createElement("div"); + el.style.cssText = ` + position: absolute; top: 0.6rem; left: 50%; transform: translateX(-50%); z-index: 10; + padding: 0.2rem 0.55rem; border-radius: 0.5rem; + background: var(--bim-ui_bg-contrast-10, #262629); + border: 1px solid var(--bim-ui_bg-contrast-20, rgba(255,255,255,0.1)); + color: var(--bim-ui_bg-contrast-100, #e3e3e3); + font: 600 0.72rem/1.2 ui-monospace, monospace; + pointer-events: none; user-select: none; + `; + el.textContent = "-- FPS"; + // The viewer is position:relative (set in viewports-manager for the anchor + // dot), so an absolutely-positioned child overlays the canvas correctly. + if (!parent.style.position) parent.style.position = "relative"; + parent.append(el); + + let visible = true; + let frames = 0; + let last = performance.now(); + const tick = (now: number) => { + frames += 1; + const dt = now - last; + if (dt >= 500) { + el.textContent = `${Math.round((frames * 1000) / dt)} FPS`; + frames = 0; + last = now; + } + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + + return { + element: el, + get visible() { + return visible; + }, + setVisible: (v: boolean) => { + visible = v; + el.style.display = v ? "block" : "none"; + }, + }; +}; diff --git a/src/cli/templates/app/src/setups/fragments.ts b/src/cli/templates/app/src/setups/fragments.ts deleted file mode 100644 index e3fa28f..0000000 --- a/src/cli/templates/app/src/setups/fragments.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as OBC from "@thatopen/components"; -import * as FRAGS from "@thatopen/fragments"; - -export const initFragments = async (components: OBC.Components): Promise => { - const fragments = components.get(OBC.FragmentsManager); - - const moduleWorkerUrl = await FRAGS.FragmentsModels.getWorker(); - const workerUrl = await FRAGS.toClassicWorker(moduleWorkerUrl); - fragments.init(workerUrl, { classicWorker: true }); - - fragments.core.models.materials.list.onItemSet.add(({ value: material }) => { - if (!("isLodMaterial" in material && material.isLodMaterial)) { - material.polygonOffset = true; - material.polygonOffsetUnits = 1; - material.polygonOffsetFactor = Math.random(); - } - }); -}; diff --git a/src/cli/templates/app/src/setups/graphics-panel.ts b/src/cli/templates/app/src/setups/graphics-panel.ts new file mode 100644 index 0000000..71f6833 --- /dev/null +++ b/src/cli/templates/app/src/setups/graphics-panel.ts @@ -0,0 +1,490 @@ +import * as OBC from "@thatopen/components"; +import * as BUI from "@thatopen/ui"; +import { cardHeader } from "./card-header"; +import { styles, StyleSetting } from "./styles"; +import type { FpsIndicator } from "./fps-indicator"; + +/** + * GRAPHICS panel — a docked side-panel (third layout alongside Explorer/Files) + * exposing the viewer's rendering/graphics settings. + * + * It REUSES the typed `styles(components, world)` DESCRIPTOR (the same one that + * backs the bottom-left "Styles" helper tool) so every control here drives the + * live postproduction pipeline directly — postproduction on/off & preset, edges + * (contour), surface color / AO / tonal shading, scene background & grid, and + * the quality block (FXAA, render scale, adaptive resolution, target FPS, the + * half-res selection-outline toggle). No setting is invented; each maps 1:1 to + * a wired pipeline feature. Mutating a control takes effect on the next frame. + * + * The generic `control()` renderer (switch/slider/color/dropdown wired to a + * setting's get/set) mirrors styles-panel.ts so the two stay consistent. + * + * LAYOUT: to keep the panel dense, a feature's dependent controls are STACKED + * onto a single shared row (see ROW_LAYOUT below) — e.g. "Edge color" + "Edge + * strength" sit together, and a feature's toggle pairs with its one dependent + * value ("Grid" + grid color, "Ambient occlusion" + AO strength, …). Booleans + * render as a compact pill TOGGLE SWITCH (purple when on) instead of a checkbox. + * Only the PRESENTATION changes here — every control still drives the exact same + * StyleSetting get/set, so the pipeline wiring is untouched. + * + * Returns the panel ELEMENT WITHOUT self-mounting (like modelTree / filesPanel) + * so main.ts can strip the card chrome and dock it into the grid. + */ + +// ── Document-level theming for the dropdown POPUP ─────────────────────────── +// The Style-preset `bim-dropdown` renders its open menu as a `bim-context-menu` +// that BUI MOVES OUT to a top-level `` appended to +// `document.body` while visible (see ContextMenu.set visible() in +// node_modules/@thatopen/ui/dist/index.js: `document.body.append(lt.dialog)` / +// `lt.dialog.append(this)`). Because the popup leaves this panel's shadow scope, +// the panel's +
+
+
+ ${body} +
+
+
+ + `; + }, + { tick: 0 }, + ); + + // The pipeline configures asynchronously after the first sized frame; the + // panel may render before it's ready. Poll briefly until ready, then re-render + // once so the real controls appear (cheap: stops the moment it succeeds). + let attempts = 0; + const poll = window.setInterval(() => { + attempts += 1; + if (ready(getWorld())) { + update({ tick: 0 }); + window.clearInterval(poll); + } else if (attempts > 120) { + window.clearInterval(poll); // give up after ~60s + } + }, 500); + + return panel; +}; diff --git a/src/cli/templates/app/src/setups/helper-panel.ts b/src/cli/templates/app/src/setups/helper-panel.ts new file mode 100644 index 0000000..22b2bdf --- /dev/null +++ b/src/cli/templates/app/src/setups/helper-panel.ts @@ -0,0 +1,88 @@ +import * as BUI from "@thatopen/ui"; +import { cardHeader } from "./card-header"; +import { toolPlaceholderUri } from "../assets/tool-placeholder"; + +/** + * A contextual helper card — ALWAYS present in the left stack (see left-sidebar). + * It renders the options of whichever panel-tool is active (e.g. Styles); when + * no tool is active it shows an empty-state illustration + hint. Mirrors the + * standardized card chrome: dark `bg-base`, `cardHeader` (with divider) fixed, + * inset `.helper-scroll` `overflow-y:auto` (same scrollbar as the others). + * + * Returns the panel ELEMENT (mounted by the left stack) plus a controller: + * show({ title, icon, render }) — show a tool's content (+ retitle the header) + * clear() — revert to the empty state + * refresh() — re-run the current `render` + */ +export interface HelperContent { + title: string; + icon: string; + render: () => unknown; +} + +export interface HelperPanelController { + element: HTMLElement; + show(content: HelperContent): void; + clear(): void; + refresh(): void; +} + +const DEFAULT = { title: "Tools", icon: "mdi:tune" }; + +const emptyState = () => BUI.html` +
+ + Select a tool to see it here. +
+`; + +export const helperPanel = (): HelperPanelController => { + let renderContent: (() => unknown) | null = null; + + const [panel, update] = BUI.Component.create( + (state) => BUI.html` + + +
+
+ +
+
+ ${renderContent ? renderContent() : emptyState()} +
+
+
+
+
+ `, + { ...DEFAULT }, + ); + + return { + element: panel, + show({ title, icon, render }) { + renderContent = render; + update({ title, icon }); + }, + clear() { + renderContent = null; + update({ ...DEFAULT }); + }, + refresh() { + update({}); // re-runs the template → re-calls renderContent() + }, + }; +}; diff --git a/src/cli/templates/app/src/setups/hider.ts b/src/cli/templates/app/src/setups/hider.ts new file mode 100644 index 0000000..e972498 --- /dev/null +++ b/src/cli/templates/app/src/setups/hider.ts @@ -0,0 +1,98 @@ +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; + +/** + * Visibility actions driven by the CURRENT selection (the Highlighter "select" + * set that the tree/viewport populate). Returns a controller for the toolbar to + * wire to buttons — it does not render any UI itself. + * + * Suggested icons (ACTION buttons): + * - hideSelected → "mdi:eye-off-outline" + * - isolateSelected → "mdi:select-search" (alt: "mdi:eye") + * - showAll → "mdi:eye-outline" (alt: "mdi:restore") + */ +export interface HiderController { + /** Hide the currently selected items. No-op if nothing is selected. */ + hideSelected(): void; + /** Hide everything except the current selection. No-op if nothing is selected. */ + isolateSelected(): void; + /** Reset: make every item visible again. */ + showAll(): void; + /** + * Ghost (x-ray) the current selection via the scalable per-element GPU state + * texture (`model.setGhostItems`) — NO per-item recolor, so it scales to + * millions of elements. No-op if nothing is selected. + */ + ghostSelected(): void; + /** Clear the ghost (x-ray) overlay on all loaded models. */ + clearGhost(): void; +} + +export const hider = (components: OBC.Components): HiderController => { + const hiderComp = components.get(OBC.Hider); + const highlighter = components.get(OBF.Highlighter); + const fragments = components.get(OBC.FragmentsManager); + + const currentSelection = (): OBC.ModelIdMap | undefined => + highlighter.selection.select; + const hasItems = (map?: OBC.ModelIdMap) => + !!map && Object.values(map).some((set) => set.size > 0); + + // Making items visible must also un-ghost them — a shown element should never + // linger as a ghost. unsetGhostItems is async (localId→itemId worker + // conversion), so collect per-model tasks and update once they all land. + const unghost = (map: OBC.ModelIdMap) => { + const tasks: Promise[] = []; + for (const [modelId, set] of Object.entries(map)) { + const model = fragments.list.get(modelId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (model && set.size > 0) tasks.push((model as any).unsetGhostItems?.([...set])); + } + if (tasks.length) void Promise.all(tasks).then(() => fragments.core.update(true)); + }; + + const clearGhostAll = () => { + for (const model of fragments.list.values()) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (model as any).clearGhost?.(); + } + void fragments.core.update(true); + }; + + return { + hideSelected() { + const sel = currentSelection(); + if (!sel || !hasItems(sel)) return; + void hiderComp.set(false, sel); + }, + isolateSelected() { + const sel = currentSelection(); + if (!sel || !hasItems(sel)) return; + void hiderComp.isolate(sel); + // The selection is the only thing visible now → un-ghost it so an isolated + // element is never left ghosted. + unghost(sel); + }, + showAll() { + void hiderComp.set(true); + // Everything is visible again → nothing should stay ghosted. + clearGhostAll(); + }, + ghostSelected() { + const sel = currentSelection(); + if (!sel || !hasItems(sel)) return; + const tasks: Promise[] = []; + for (const [modelId, set] of Object.entries(sel)) { + const model = fragments.list.get(modelId); + // setGhostItems is async (localId→itemId worker conversion) — update + // only after every model's ghost state is written. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (model && set.size > 0) tasks.push((model as any).setGhostItems([...set], false)); + } + void Promise.all(tasks).then(() => fragments.core.update(true)); + }, + clearGhost() { + clearGhostAll(); + }, + }; +}; diff --git a/src/cli/templates/app/src/setups/ifc-loader.ts b/src/cli/templates/app/src/setups/ifc-loader.ts deleted file mode 100644 index 12e1d74..0000000 --- a/src/cli/templates/app/src/setups/ifc-loader.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as OBC from "@thatopen/components"; - -export const initIfcLoader = async (components: OBC.Components): Promise => { - const ifcLoader = components.get(OBC.IfcLoader); - await ifcLoader.setup({ - autoSetWasm: false, - wasm: { - path: "https://unpkg.com/web-ifc@0.0.77/", - absolute: true, - }, - }); -}; diff --git a/src/cli/templates/app/src/setups/index.ts b/src/cli/templates/app/src/setups/index.ts index 8424b9b..e28adf6 100644 --- a/src/cli/templates/app/src/setups/index.ts +++ b/src/cli/templates/app/src/setups/index.ts @@ -1,3 +1,34 @@ +export * from "./ui-manager"; +export * from "./viewports-manager"; export * from "./cloud-runner"; -export * from "./fragments"; -export * from "./ifc-loader"; +export * from "./properties-panel"; +export * from "./fps-indicator"; +export * from "./active-tool-hud"; +export * from "./files-panel"; +export * from "./model-tree"; +export * from "./right-sidebar"; +export * from "./card-header"; +export * from "./toolbar"; +export * from "./visibility-toolbar"; +export * from "./hider"; +export * from "./tool-mode"; +export * from "./clipper"; +export * from "./measurements"; +export * from "./styles"; +export * from "./helper-panel"; +export * from "./styles-panel"; +export * from "./graphics-panel"; +export * from "./commands-panel"; +export * from "./clipper-tool"; +export * from "./clipper-panel"; +export * from "./plans-panel"; +export * from "./navigation-gizmo"; +export * from "./exploded-view"; +export * from "./measurement-tool"; +export * from "./measurement-panel"; +export * from "./data-table-panel"; +export * from "./walkthrough"; +export * from "./tool-mode-manager"; +// reality-capture-viewer is intentionally NOT re-exported here: it spins up +// decode workers at module load, so it must be LAZY-imported (dynamic import in +// files-panel's .3tz "View" handler), never pulled onto the app boot path. diff --git a/src/cli/templates/app/src/setups/inspection.ts b/src/cli/templates/app/src/setups/inspection.ts new file mode 100644 index 0000000..786cff9 --- /dev/null +++ b/src/cli/templates/app/src/setups/inspection.ts @@ -0,0 +1,136 @@ +import * as OBC from "@thatopen/components"; +import type { ClipperTool } from "./clipper-tool"; +import type { MeasurementTool } from "./measurement-tool"; + +/** + * Unified "Objects" outliner API (W2's new panel). Enumerates every created clip + * plane and every measurement as a flat list of {@link InstanceRow}s with + * per-instance HIDE / DISABLE / DELETE, so W2 can render rows without knowing + * about the individual Clipping / Measurement tools. + * + * Shapes are owned here and produced by each tool's `instances()`; this module + * just merges them and fans the two tools' change events into one. + */ +export type InstanceKind = "clip" | "measurement"; + +export interface InstanceRow { + /** Stable id (plane id for clips; per-measurement id) — use as the row key. */ + id: string; + kind: InstanceKind; + /** "Clip plane" | "Length" | "Area" | "Angle". */ + type: string; + /** User-facing row label (includes the measured value for measurements). */ + label: string; + /** Currently shown in the scene. */ + visible: boolean; + /** Clip planes only: whether the plane is actively cutting. `undefined` for measurements. */ + enabled?: boolean; + /** Show/hide this instance without deleting it. */ + setVisible(on: boolean): void; + /** Clip planes only: toggle the cut on/off (present only when `enabled` is defined). */ + setEnabled?(on: boolean): void; + /** Permanently remove this instance. */ + remove(): void; +} + +export interface InspectionInstances { + /** Snapshot of all clip planes + measurements, in that order. */ + list(): InstanceRow[]; + /** Fires whenever the set or state of instances changes (re-query `list()`). */ + readonly onChanged: OBC.Event; +} + +/** + * A toolbar action W2 wires into the new "Inspection" tab. Each `activate()` + * routes through the toolModeManager (exclusive — entering one exits any other + * tool). W2 drives the Select button + active highlighting via + * `toolModeManager.selectMode()` / `getActiveId()` / `onActiveChanged`; each + * action's own `isActive()` is provided for convenience. + */ +export interface InspectionAction { + id: string; + label: string; + /** mdi icon name (e.g. "mdi:ruler"). */ + icon: string; + activate(): void; + isActive(): boolean; +} + +/** + * Inspection toolbar actions: clip plane + the measurement types/modes. Order is + * the toolbar order. measure-edge/face reuse Length/Area with a forced sub-mode; + * measure-length/area are the plain "free" modes (isActive distinguishes them by + * sub-mode). Volume is a distinct measurer. + */ +export const inspectionActions = ( + clipperTool: ClipperTool, + measurementTool: MeasurementTool, +): InspectionAction[] => { + const m = measurementTool; + const isMeasure = (mode: string, sub: string) => + m.getMode() === mode && m.getSubMode() === sub; + return [ + { + id: "clip-plane", + label: "Clip plane", + icon: "mdi:scissors-cutting", + activate: () => clipperTool.setPlacing(true), + isActive: () => clipperTool.isPlacing(), + }, + { + id: "measure-length", + label: "Length", + icon: "mdi:ruler", + activate: () => m.setMode("length", "free"), + isActive: () => isMeasure("length", "free"), + }, + { + id: "measure-area", + label: "Area", + icon: "mdi:vector-polygon", + activate: () => m.setMode("area", "free"), + isActive: () => isMeasure("area", "free"), + }, + { + id: "measure-angle", + label: "Angle", + icon: "mdi:angle-acute", + activate: () => m.setMode("angle"), + isActive: () => m.getMode() === "angle", + }, + { + id: "measure-edge", + label: "Edge", + icon: "mdi:vector-line", + activate: () => m.setMode("length", "edge"), + isActive: () => isMeasure("length", "edge"), + }, + { + id: "measure-face", + label: "Face", + icon: "mdi:vector-square", + activate: () => m.setMode("area", "face"), + isActive: () => isMeasure("area", "face"), + }, + { + id: "volume", + label: "Volume", + icon: "mdi:cube-outline", + activate: () => m.setMode("volume"), + isActive: () => m.getMode() === "volume", + }, + ]; +}; + +export const inspectionInstances = ( + clipperTool: ClipperTool, + measurementTool: MeasurementTool, +): InspectionInstances => { + const onChanged = new OBC.Event(); + clipperTool.onChanged.add(() => onChanged.trigger()); + measurementTool.onChanged.add(() => onChanged.trigger()); + return { + list: () => [...clipperTool.instances(), ...measurementTool.instances()], + onChanged, + }; +}; diff --git a/src/cli/templates/app/src/setups/measurement-panel.ts b/src/cli/templates/app/src/setups/measurement-panel.ts new file mode 100644 index 0000000..b0c034e --- /dev/null +++ b/src/cli/templates/app/src/setups/measurement-panel.ts @@ -0,0 +1,188 @@ +import * as OBC from "@thatopen/components"; +import * as BUI from "@thatopen/ui"; +import type { + MeasurementTool, + MeasureMode, + SnapKind, +} from "./measurement-tool"; + +/** + * MEASUREMENT panel — a docked side-panel (a layout alongside + * Explorer/Files/Graphics/Clipping) for length / area / angle measurements. + * + * Sections: + * - Measure — exclusive mode buttons (Off / Length / Area / Angle); pick one, + * then double-click surfaces in the viewport (Area: Enter to close). + * - Snapping — vertices / edges / faces toggles (precise picks). + * - Measurements — show/hide all, clear all, and a list of every measurement + * (type + value + units) with delete-per-row. + * + * Vanilla BUI, app chrome (native panel header, muted bands, 1px contrast-20 + * hairlines, #3C3C41 scrollbar, library toggle switches + buttons). Factory + * returns the element WITHOUT self-mounting. (A simple row list is used for the + * measurements, matching files-panel / clipper-panel, so each row can carry a + * delete button.) + */ +const MODES: { mode: MeasureMode; label: string; icon: string }[] = [ + { mode: "none", label: "Off", icon: "mdi:cursor-default" }, + { mode: "length", label: "Length", icon: "mdi:ruler" }, + { mode: "area", label: "Area", icon: "mdi:vector-square" }, + { mode: "angle", label: "Angle", icon: "mdi:angle-acute" }, +]; + +const SNAPS: { kind: SnapKind; label: string }[] = [ + { kind: "point", label: "Vertices" }, + { kind: "line", label: "Edges" }, + { kind: "face", label: "Faces" }, +]; + +export const measurementPanel = ( + _components: OBC.Components, + tool: MeasurementTool, +) => { + const [panel, update] = BUI.Component.create( + (state) => { + const refresh = () => update({ tick: state.tick + 1 }); + + const mode = tool.getMode(); + const rows = tool.rows(); + const snaps = tool.getSnaps(); + const visible = tool.isVisible(); + + const modeButtons = MODES.map( + (m) => BUI.html` + { + tool.setMode(m.mode); + refresh(); + }} + > + `, + ); + + const snapRows = SNAPS.map( + (s) => BUI.html` +
+ ${s.label} + + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tool.setSnap(s.kind, !!(e.target as any).checked); + refresh(); + }}> + +
+ `, + ); + + const measurementRows = + rows.length === 0 + ? BUI.html`
No measurements. Pick a mode above, then double-click surfaces.
` + : rows.map( + (r) => BUI.html` +
+ ${r.type} + + ${r.text} + { + r.remove(); + refresh(); + }}> + +
+ `, + ); + + return BUI.html` + + +
+
+
+ +
${modeButtons}
+ +
Snapping
+ ${snapRows} + +
Measurements
+
+ Show all + + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tool.setVisible(!!(e.target as any).checked); + refresh(); + }}> + +
+
+ { + tool.clearAll(); + refresh(); + }}> +
+ ${measurementRows} +
+
+
+
+ `; + }, + { tick: 0 }, + ); + + tool.onChanged.add(() => update({ tick: 0 })); + tool.onModeChanged.add(() => update({ tick: 0 })); + + return panel; +}; diff --git a/src/cli/templates/app/src/setups/measurement-settings-panel.ts b/src/cli/templates/app/src/setups/measurement-settings-panel.ts new file mode 100644 index 0000000..8a1133b --- /dev/null +++ b/src/cli/templates/app/src/setups/measurement-settings-panel.ts @@ -0,0 +1,161 @@ +import * as BUI from "@thatopen/ui"; +import type { MeasurementTool } from "./measurement-tool"; + +/** + * MEASUREMENT settings section for the merged Settings layout (UI-reorg). Color, + * per-type units, rounding, snap toggles, and global visibility — all driven by + * W1's measurementTool settings API (getMeasurementSettings + setters), re-read + * on `onChanged`. (Line thickness lands later with the LineSegments2 conversion.) + * Returns its `bim-panel` WITHOUT self-mounting. + * + * @param tool the measurement tool (worker 1) + * @returns the `bim-panel` element + */ +export const measurementSettingsPanel = (tool: MeasurementTool) => { + const dropdown = ( + opts: string[], + selected: string, + onPick: (v: string) => void, + ) => BUI.html` + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const v = (e.target as any).value?.[0]; + if (v != null) onPick(String(v)); + }} + > + ${opts.map((o) => BUI.html``)} + `; + + const [panel, update] = BUI.Component.create( + // NOTE: the callback MUST take a state param (arity >= 1) — an arity-0 + // callback makes Component.create return a single element instead of + // [el, update], and the destructure throws "object is not iterable". + (_s) => { + // Fail-safe: a settings sub-panel must NEVER crash main(). If the tool's + // settings API is unavailable/throws, render a placeholder instead. + let s: ReturnType | null = null; + try { + s = tool.getMeasurementSettings?.() ?? null; + } catch (error) { + console.warn("[measurement-settings] getMeasurementSettings failed", error); + } + if (!s || !s.unitOptions || !s.units || !s.snaps) { + return BUI.html` + +
+ Measurement settings unavailable. +
+
`; + } + const unitRow = (label: string, type: "length" | "area" | "angle") => BUI.html` +
+ ${label} + ${dropdown(s.unitOptions[type], s.units[type], (v) => tool.setUnits(type, v))} +
`; + return BUI.html` + + +
+
+ Color + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const t = e.target as any; + tool.setColor(String(t.color ?? t.value)); + }} + > +
+ ${unitRow("Length", "length")} + ${unitRow("Area", "area")} + ${unitRow("Angle", "angle")} +
+ Decimals + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tool.setRounding(Number((e.target as any).value)); + }} + > +
+ ${ + typeof s.thickness === "number" && typeof tool.setThickness === "function" + ? BUI.html` +
+ Thickness + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tool.setThickness(Number((e.target as any).value)); + }} + > +
` + : BUI.html`` + } +
+ Snap +
+ ${Object.keys(s.snaps).map( + (kind) => BUI.html``, + )} +
+
+ +
+
`; + }, + { tick: 0 }, + ); + + tool.onChanged.add(() => update({ tick: 0 })); + return panel; +}; diff --git a/src/cli/templates/app/src/setups/measurement-tool.ts b/src/cli/templates/app/src/setups/measurement-tool.ts new file mode 100644 index 0000000..ae5702a --- /dev/null +++ b/src/cli/templates/app/src/setups/measurement-tool.ts @@ -0,0 +1,570 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; +import * as FRAGS from "@thatopen/fragments"; +import { toolModeManager, type ManagedTool } from "./tool-mode-manager"; +import type { InstanceRow } from "./inspection"; + +/** + * Measurement TOOL (logic only; the UI lives in measurement-panel.ts). + * + * Drives the three OBF measurers — Length (point-to-point), Area (polygon) and + * Angle (3-point) — as exclusive MODES: + * - pick a mode → that measurer is enabled and a viewport double-click places + * points (Length: 2 pts; Area: N pts, Enter to close; Angle: 3 pts), + * - Escape cancels the in-progress measurement, Delete removes the one under the + * cursor (Shift+Delete clears all). + * + * SNAPPING (the thing that makes dimensions reliable vs eyeballed) is the + * library's vertex/edge/face snapper, on for all three classes by default; the + * panel can toggle each class. The snapper resolves the nearest snap target under + * the cursor (vertices win ties), which is exactly what we want for precise picks. + * + * Exposes aggregated rows (type + value + units) over all three lists, plus + * onChanged/onModeChanged so the panel re-renders. + */ +export type MeasureMode = "none" | "length" | "area" | "angle" | "volume"; +export type SnapKind = "point" | "line" | "face"; + +export type LengthUnit = "mm" | "cm" | "m" | "km"; +export type AreaUnit = "mm2" | "cm2" | "m2" | "km2"; +export type AngleUnit = "deg" | "rad"; + +/** Snapshot for W2's merged Settings panel (measurement section). */ +export interface MeasurementSettings { + color: string; // "#rrggbb" — applied to all measurement types + units: { length: LengthUnit; area: AreaUnit; angle: AngleUnit }; + /** Valid unit options per type (for the dropdowns). */ + unitOptions: { length: string[]; area: string[]; angle: string[] }; + rounding: number; // decimal places + snaps: Record; + visible: boolean; + /** Line width (px) for Length/Area fat lines. (Angle is 1px until its arc is converted.) */ + thickness: number; +} + +export interface MeasurementRow { + key: string; + type: string; + text: string; // e.g. "5.25 m" + remove(): void; +} + +export interface MeasurementTool { + readonly onChanged: OBC.Event; + readonly onModeChanged: OBC.Event; + /** + * Activate a measurement type. `subMode` forces the measurer's creation mode — + * "edge" for Length (snap to edges), "face" for Area (measure a face) — so the + * toolbar's measure-edge / measure-face actions reuse Length/Area with the right + * mode. Defaults each measurer back to "free" when omitted. + */ + setMode(mode: MeasureMode, subMode?: string): void; + /** Current measurer sub-mode (e.g. "edge"/"face"/"free"), for action isActive(). */ + getSubMode(): string; + getMode(): MeasureMode; + rows(): MeasurementRow[]; + /** Per-measurement rows for the Objects outliner (W2): hide / delete. */ + instances(): InstanceRow[]; + clearAll(): void; + setVisible(on: boolean): void; + isVisible(): boolean; + getSnaps(): Record; + setSnap(kind: SnapKind, on: boolean): void; + // ── Settings (W2 Settings panel) ── + getMeasurementSettings(): MeasurementSettings; + setColor(hex: string): void; + setUnits(type: "length" | "area" | "angle", value: string): void; + setRounding(decimals: number): void; + /** Line width (px) for Length/Area fat lines. */ + setThickness(px: number): void; +} + +const COLOR = 0x6528d7; + +type Measurer = + | OBF.LengthMeasurement + | OBF.AreaMeasurement + | OBF.AngleMeasurement + | OBF.VolumeMeasurement; + +export const measurementTool = ( + components: OBC.Components, +): MeasurementTool => { + const length = components.get(OBF.LengthMeasurement); + const area = components.get(OBF.AreaMeasurement); + const angle = components.get(OBF.AngleMeasurement); + const volume = components.get(OBF.VolumeMeasurement); + const all: Measurer[] = [length, area, angle, volume]; + + const worlds = components.get(OBC.Worlds); + const getWorld = () => [...worlds.list.values()][0] as OBC.World | undefined; + + const onChanged = new OBC.Event(); + const onModeChanged = new OBC.Event(); + + // Snapping: all three classes on by default (vertices, edges, faces). + const snaps: Record = { point: true, line: true, face: true }; + const snapClass: Record = { + point: FRAGS.SnappingClass.POINT, + line: FRAGS.SnappingClass.LINE, + face: FRAGS.SnappingClass.FACE, + }; + const applySnaps = () => { + const list = (Object.keys(snaps) as SnapKind[]) + .filter((k) => snaps[k]) + .map((k) => snapClass[k]); + for (const m of all) m.snappings = list; + }; + + // Stable per-measurement ids (entry object → id) so W2's outliner row keys + // survive deletion of OTHER rows (index-based ids would shift). + const entryIds = new WeakMap(); + let entrySeq = 0; + const idFor = (entry: object): string => { + let id = entryIds.get(entry); + if (!id) { + id = `m${++entrySeq}`; + entryIds.set(entry, id); + } + return id; + }; + + let mode: MeasureMode = "none"; + let subMode = "free"; // measurer creation mode: "free" | "edge" (Length) | "face"/"square" (Area) + let visible = true; + let canvas: HTMLElement | undefined; + let wired = false; + + const manager = toolModeManager(components); + // When another tool takes over, drop measure mode locally (manager handles + // hover/select suppression). + const managed: ManagedTool = { + id: "measure", + label: () => (mode === "none" ? "Measuring" : `Measuring ${mode}`), + icon: "mdi:ruler", + onDeactivate: () => { + for (const m of all) { + m.cancelCreation(); + // Only flip when it actually changes. Some measurers' enabled-setter has + // side effects — VolumeMeasurement toggles the GLOBAL Hoverer on/off — and + // firing that spuriously (disabling an already-disabled measurer) corrupts + // the hover state the toolModeManager captures, so hover stays off after + // returning to Select. Skipping no-op flips keeps that side effect from + // running unless the measurer is genuinely being turned off. + if (m.enabled) m.enabled = false; + } + mode = "none"; + if (canvas) canvas.style.cursor = ""; + onModeChanged.trigger(); + }, + }; + + const active = (): Measurer | null => + mode === "length" + ? length + : mode === "area" + ? area + : mode === "angle" + ? angle + : mode === "volume" + ? volume + : null; + + const onDblClick = () => active()?.create(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.code === "Enter" || event.code === "NumpadEnter") { + // Only the Area polygon needs an explicit close; Length (2 pts) and Angle + // (3 pts) finalize themselves. + if (mode === "area") area.endCreation(); + return; + } + if (event.code === "Escape") { + active()?.cancelCreation(); + return; + } + if (event.code === "Delete" || event.code === "Backspace") { + if (event.shiftKey) { + for (const m of all) m.list.clear(); + } else { + void active()?.delete(); + } + } + }; + + // Bind world + canvas + list events once the viewport exists. + const ensure = (): boolean => { + const world = getWorld(); + if (!world) return false; + for (const m of all) if (m.world !== world) m.world = world; + if (wired) return true; + + for (const m of all) m.color = new THREE.Color(COLOR); + angle.units = "deg"; + applySnaps(); + + for (const m of all) { + m.list.onItemAdded.add(() => onChanged.trigger()); + // onItemDeleted (NOT onBeforeDelete) — the panel's re-render re-reads the + // live list, and onBeforeDelete fires while the entry is still present, so + // the deleted row would linger. onItemDeleted fires after removal. + m.list.onItemDeleted.add(() => onChanged.trigger()); + m.list.onCleared.add(() => onChanged.trigger()); + } + + const renderer = world.renderer as OBF.PostproductionRenderer | undefined; + canvas = renderer?.three.domElement; + if (canvas) canvas.addEventListener("dblclick", onDblClick); + window.addEventListener("keydown", onKeyDown); + + // Deferred-overlay registration. In the deferred pipeline the single-pass + // capture HIDES every non-emitter, and the measurement line (LineBasicMaterial) + // + area fill are non-emitters that are never redrawn — so the dimension line + // is invisible/faint in the deferred app (it only shows in a forward/Simple- + // Renderer example). Register the measurers' shared materials into the + // postproduction overlay set (the same mechanism clip sections / grid / hover + // use), depthTest/depthWrite off so they draw on top. The endpoint markers are + // CSS2D (rendered by three2D) and don't need this. + // Deferred-overlay registration + fat-line resolution must be ROBUST to + // timing. ensure() can run BEFORE postproduction.basePass exists; a one-shot + // register then silently no-ops and never retries (wired=true), leaving the + // measurement line OUT of isolatedMaterials → the capture hides it → invisible + // (while clip edges, registered later by ClipEdges at build time, show fine). + // So: re-run the static material registration + resolution sync before EVERY + // render (both idempotent/cheap) so they take effect the moment postproduction + // is ready. Per-instance materials (area fills, volumes) register on creation. + const fills = ( + area as unknown as { + fills?: FRAGS.DataSet<{ material?: THREE.Material }>; + } + ).fills; + fills?.onItemAdded.add((fill) => { + registerMat(fill.material); + getWorld()?.renderer?.update?.(); + }); + const volumes = ( + volume as unknown as { + volumes?: FRAGS.DataSet<{ material?: THREE.Material }>; + } + ).volumes; + volumes?.onItemAdded.add((v) => { + registerMat(v.material); + getWorld()?.renderer?.update?.(); + }); + + registerStaticMats(); + syncLineResolution(); + renderer?.onBeforeUpdate?.add(() => { + registerStaticMats(); + syncLineResolution(); + }); + + wired = true; + return true; + }; + + // Live lookup of the deferred overlay material set (postproduction may not be + // ready at ensure() time, so never cache this). + const isolatedMaterials = (): THREE.Material[] | undefined => { + const r = getWorld()?.renderer as OBF.PostproductionRenderer | undefined; + try { + return r?.postproduction?.basePass?.isolatedMaterials; + } catch { + return undefined; + } + }; + + // Register a material into the deferred overlay (idempotent). Fat lines are also + // marked transparent so they sort with the hover/overlay group. No-op until the + // overlay set exists. + const registerMat = (mat?: THREE.Material) => { + if (!mat) return; + const isolated = isolatedMaterials(); + if (!isolated) return; + mat.depthTest = false; + mat.depthWrite = false; + if ((mat as { isLineMaterial?: boolean }).isLineMaterial) { + mat.transparent = true; + } + if (!isolated.includes(mat)) isolated.push(mat); + }; + + // (Re)register the shared per-measurer materials: line + fill for each, plus + // Angle's dedicated 1px ray/arc material and Volume's shared mesh material. + const registerStaticMats = () => { + for (const m of all) { + registerMat((m as { linesMaterial?: THREE.Material }).linesMaterial); + registerMat((m as { fillsMaterial?: THREE.Material }).fillsMaterial); + } + registerMat( + (volume as unknown as { volumesMaterial?: THREE.Material }) + .volumesMaterial, + ); + }; + + const _resScratch = new THREE.Vector2(); + const syncLineResolution = () => { + const three = (getWorld()?.renderer as OBF.PostproductionRenderer | undefined) + ?.three; + if (!three) return; + three.getDrawingBufferSize(_resScratch); + // Skip until the viewport is actually sized — setting resolution to (0,0) makes + // the fat line's screen-space quad divide by zero → NaN → invisible. + if (_resScratch.x < 1 || _resScratch.y < 1) return; + for (const m of all) { + const mat = (m as { linesMaterial?: unknown }).linesMaterial as + | { isLineMaterial?: boolean; resolution?: THREE.Vector2 } + | undefined; + if (mat?.isLineMaterial && mat.resolution) { + mat.resolution.set(_resScratch.x, _resScratch.y); + } + } + }; + + const collect = ( + out: MeasurementRow[], + type: string, + measurer: Measurer, + ) => { + let i = 0; + for (const entry of measurer.list) { + const value = (entry as { value: number }).value; + const units = measurer.units ?? ""; + out.push({ + key: `${type}-${i++}`, + type, + text: `${value}${units ? ` ${units}` : ""}`, + // DataSet.delete(value) removes that single measurement. + remove: () => { + (measurer.list as { delete(v: unknown): boolean }).delete(entry); + }, + }); + } + }; + + return { + onChanged, + onModeChanged, + setMode(next, sub) { + ensure(); + for (const m of all) { + m.cancelCreation(); + // Only flip when it actually changes. Some measurers' enabled-setter has + // side effects — VolumeMeasurement toggles the GLOBAL Hoverer on/off — and + // firing that spuriously (disabling an already-disabled measurer) corrupts + // the hover state the toolModeManager captures, so hover stays off after + // returning to Select. Skipping no-op flips keeps that side effect from + // running unless the measurer is genuinely being turned off. + if (m.enabled) m.enabled = false; + } + mode = next; + subMode = sub ?? "free"; + const a = active(); + if (a) { + a.enabled = true; + // Length/Area carry a creation sub-mode ("edge" / "face" / "square"); + // force it so measure-edge / measure-face reuse those measurers correctly. + if (next === "length" || next === "area") { + (a as unknown as { mode: string }).mode = subMode; + } + // Constrain the active measurer's snap set to match the sub-mode: edge mode + // snaps to EDGES only (not vertices/faces), face mode to FACES only. Other + // modes use the user's full snap set (applySnaps below restores it). + if (subMode === "edge") { + a.snappings = [FRAGS.SnappingClass.LINE]; + } else if (subMode === "face") { + a.snappings = [FRAGS.SnappingClass.FACE]; + } else { + applySnaps(); + } + } + if (canvas) canvas.style.cursor = a ? "crosshair" : ""; + // Route through the central manager: a real mode makes this the sole active + // tool (suppressing hover/select + exiting any other tool); "none" exits. + if (next === "none") manager.clearActive(managed); + else manager.setActive(managed); + // Mode may have changed while staying the active tool (length → area); + // refresh so the HUD re-reads the dynamic label. + manager.refresh(); + onModeChanged.trigger(); + }, + getMode: () => mode, + getSubMode: () => subMode, + rows() { + const out: MeasurementRow[] = []; + collect(out, "Length", length); + collect(out, "Area", area); + collect(out, "Angle", angle); + collect(out, "Volume", volume); + return out; + }, + instances() { + ensure(); + const rows: InstanceRow[] = []; + // Per-measurement visibility lives on the visual element, mapped from the + // list entry differently per type: Length/Angle's DimensionLine.line === + // entry (in `lines`); Area's fill.area === entry (in `fills`); Angle stores + // an entry→visual map (`_visuals`) with group/label/endpoints. + type Adapter = { + get: (entry: unknown) => boolean; + set: (entry: unknown, on: boolean) => void; + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const lineAdapter = (m: any): Adapter => ({ + get: (e) => + [...m.lines].find((d: any) => d.line === e)?.visible ?? true, + set: (e, on) => { + const d = [...m.lines].find((x: any) => x.line === e); + if (d) d.visible = on; + }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fillAdapter = (m: any): Adapter => ({ + get: (e) => + [...m.fills].find((f: any) => f.area === e)?.visible ?? true, + set: (e, on) => { + const f = [...m.fills].find((x: any) => x.area === e); + if (f) f.visible = on; + // Area outlines + corner endpoints are separate DimensionLines mapped + // to the area; cascade so hiding the area hides them too (else the fill + // disappears but the wireframe + endpoints stay on screen). DimensionLine + // .visible also un-hides the per-segment length label — but area lines + // are created with labels off, so force them back off after showing. + for (const line of m.getAreaLines?.(e) ?? []) { + line.visible = on; + if (on) line.label.visible = false; + } + }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const angleAdapter = (m: any): Adapter => ({ + get: (e) => m._visuals?.get(e)?.group?.visible ?? true, + set: (e, on) => { + const v = m._visuals?.get(e); + if (!v) return; + v.group.visible = on; + v.label.visible = on; + for (const ep of v.endpoints) ep.visible = on; + }, + }); + // Volume: MeasureVolume.volume === entry (in `volumes`). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const volumeAdapter = (m: any): Adapter => ({ + get: (e) => + [...m.volumes].find((v: any) => v.volume === e)?.visible ?? true, + set: (e, on) => { + const v = [...m.volumes].find((x: any) => x.volume === e); + if (v) v.visible = on; + }, + }); + const add = (m: Measurer, type: string, ad: Adapter) => { + let i = 0; + for (const entry of m.list) { + const idx = ++i; + const value = (entry as { value: number }).value; + const units = m.units ?? ""; + rows.push({ + id: idFor(entry as object), + kind: "measurement", + type, + label: `${type} ${idx} — ${value}${units ? ` ${units}` : ""}`, + visible: ad.get(entry), + setVisible: (on) => { + ad.set(entry, on); + getWorld()?.renderer?.update(); + onChanged.trigger(); + }, + remove: () => { + (m.list as { delete(v: unknown): boolean }).delete(entry); + onChanged.trigger(); + }, + }); + } + }; + add(length, "Length", lineAdapter(length)); + add(area, "Area", fillAdapter(area)); + add(angle, "Angle", angleAdapter(angle)); + add(volume, "Volume", volumeAdapter(volume)); + return rows; + }, + clearAll() { + for (const m of all) m.list.clear(); + onChanged.trigger(); + }, + setVisible(on) { + visible = on; + for (const m of all) m.visible = on; + onChanged.trigger(); + }, + isVisible: () => visible, + getSnaps: () => ({ ...snaps }), + setSnap(kind, on) { + snaps[kind] = on; + ensure(); + applySnaps(); + onChanged.trigger(); + }, + getMeasurementSettings() { + ensure(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const u = (m: Measurer) => (m as any).units as string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const opts = (m: Measurer) => [...((m as any).unitsList as string[])]; + return { + color: `#${(length.color as THREE.Color).getHexString()}`, + units: { + length: u(length) as LengthUnit, + area: u(area) as AreaUnit, + angle: u(angle) as AngleUnit, + }, + unitOptions: { + length: opts(length), + area: opts(area), + angle: opts(angle), + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + rounding: (length as any).rounding as number, + snaps: { ...snaps }, + visible, + thickness: + (length.linesMaterial as unknown as { linewidth?: number }) + .linewidth ?? 1, + }; + }, + setColor(hex) { + const c = new THREE.Color(hex); + for (const m of all) m.color = c.clone(); + getWorld()?.renderer?.update(); + onChanged.trigger(); + }, + setUnits(type, value) { + ensure(); + const target = type === "length" ? length : type === "area" ? area : angle; + // Per-measurer `units` is a narrow string-literal union; the panel feeds a + // value from unitOptions for that type, so cast through unknown. + (target as unknown as { units: string }).units = value; + getWorld()?.renderer?.update(); + onChanged.trigger(); + }, + setRounding(decimals) { + for (const m of all) { + (m as unknown as { rounding: number }).rounding = decimals; + } + getWorld()?.renderer?.update(); + onChanged.trigger(); + }, + setThickness(px) { + // Applies to the fat Length/Area line materials (linewidth uniform). Angle's + // 1px material ignores it until its arc is converted to Line2. + for (const m of all) { + const mat = (m as { linesMaterial?: unknown }).linesMaterial as + | { isLineMaterial?: boolean; linewidth?: number } + | undefined; + if (mat?.isLineMaterial) mat.linewidth = px; + } + getWorld()?.renderer?.update(); + onChanged.trigger(); + }, + }; +}; diff --git a/src/cli/templates/app/src/setups/measurements.ts b/src/cli/templates/app/src/setups/measurements.ts new file mode 100644 index 0000000..86d9b6d --- /dev/null +++ b/src/cli/templates/app/src/setups/measurements.ts @@ -0,0 +1,133 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; +import type { ModeTool } from "./tool-mode"; + +// Brand purple, matching the selection outline so measurements read as "ours". +const MEASURE_COLOR = 0x6528d7; + +const canvasOf = (world: OBC.World): HTMLCanvasElement => { + const canvas = (world.renderer as OBF.PostproductionRenderer | null)?.three + .domElement; + if (!canvas) { + throw new Error("measurements setup: world has no renderer with a canvas"); + } + return canvas; +}; + +/** + * Length-measurement tool. Wraps `OBF.LengthMeasurement` as a toolbar mode tool: + * + * - double-click places measurement points (snapping to vertices/edges); a + * dimension line + label is drawn between them, + * - Escape cancels the in-progress measurement (handled by the component), + * - Delete / Backspace → delete the measurement under the cursor, + * - Shift + Delete / Shift + Backspace → clear ALL measurements. + * + * Finished measurements persist when the mode is exited. + */ +export const lengthMeasurement = ( + components: OBC.Components, + world: OBC.World, +): ModeTool => { + const measurer = components.get(OBF.LengthMeasurement); + measurer.world = world; + measurer.color = new THREE.Color(MEASURE_COLOR); + const canvas = canvasOf(world); + + const onDblClick = () => measurer.create(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.code !== "Delete" && event.code !== "Backspace") return; + if (event.shiftKey) { + measurer.list.clear(); + } else { + void measurer.delete(); + } + }; + + let active = false; + + return { + label: "Measure length", + icon: "mdi:ruler", + activate() { + if (active) return; + active = true; + measurer.enabled = true; + canvas.addEventListener("dblclick", onDblClick); + window.addEventListener("keydown", onKeyDown); + canvas.style.cursor = "crosshair"; + }, + deactivate() { + if (!active) return; + active = false; + measurer.cancelCreation(); // drop any half-finished measurement + measurer.enabled = false; + canvas.removeEventListener("dblclick", onDblClick); + window.removeEventListener("keydown", onKeyDown); + canvas.style.cursor = ""; + }, + }; +}; + +/** + * Area-measurement tool. Wraps `OBF.AreaMeasurement` as a toolbar mode tool: + * + * - double-click adds polygon points; Enter closes/finalizes the polygon and + * shows the filled area + value, + * - Escape cancels the in-progress polygon (handled by the component), + * - Delete / Backspace → delete the area under the cursor, + * - Shift + Delete / Shift + Backspace → clear ALL areas. + * + * NOTE: the click-to-add-point vs. double-click-to-create flow is worth a live + * check; tuned here to mirror the length tool's double-click entry. + */ +export const areaMeasurement = ( + components: OBC.Components, + world: OBC.World, +): ModeTool => { + const measurer = components.get(OBF.AreaMeasurement); + measurer.world = world; + measurer.color = new THREE.Color(MEASURE_COLOR); + const canvas = canvasOf(world); + + const onDblClick = () => measurer.create(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.code === "Enter" || event.code === "NumpadEnter") { + measurer.endCreation(); + return; + } + if (event.code !== "Delete" && event.code !== "Backspace") return; + if (event.shiftKey) { + measurer.list.clear(); + } else { + void measurer.delete(); + } + }; + + let active = false; + + return { + label: "Measure area", + icon: "mdi:vector-square", + activate() { + if (active) return; + active = true; + measurer.enabled = true; + canvas.addEventListener("dblclick", onDblClick); + window.addEventListener("keydown", onKeyDown); + canvas.style.cursor = "crosshair"; + }, + deactivate() { + if (!active) return; + active = false; + measurer.cancelCreation(); + measurer.enabled = false; + canvas.removeEventListener("dblclick", onDblClick); + window.removeEventListener("keydown", onKeyDown); + canvas.style.cursor = ""; + }, + }; +}; diff --git a/src/cli/templates/app/src/setups/model-tree.ts b/src/cli/templates/app/src/setups/model-tree.ts new file mode 100644 index 0000000..ccf10f5 --- /dev/null +++ b/src/cli/templates/app/src/setups/model-tree.ts @@ -0,0 +1,797 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; +import * as FRAGS from "@thatopen/fragments"; +import * as BUI from "@thatopen/ui"; +import { cardHeader } from "./card-header"; +import { toolPlaceholderUri } from "../assets/tool-placeholder"; + +/** + * Model Tree — a VIRTUALIZED (windowed) spatial-structure browser. + * + * Why not bim-table: bim-table renders every expanded row as real DOM (with + * per-row listeners), so expanding a storey with hundreds of elements puts + * hundreds of DOM rows over the WebGL canvas → per-frame compositing cost. This + * implementation keeps the model as a plain node tree and renders ONLY the rows + * currently in the scroll window (recycled on scroll), so DOM rows ≈ viewport + * height / row height regardless of how big the expanded subtree is. + * + * No per-row MutationObserver / requestUpdate (the old idle-CPU feedback loop): + * the collapsed/expanded label and the selected highlight update by re-rendering + * the window, and all interaction goes through ONE delegated listener. + * + * Features preserved: spatial spine (Project>Site>Building>Storey) with the + * storey-category flatten + single-child-wrapper skip, the full category icon + * map, debounced in-memory search (name/category/expressId, ancestors kept), + * CLICK = select item + all descendants, per-row Focus button (fitToSphere on + * the subtree), default-expand to storeys, collapsed/expanded label, SELECTION- + * ONLY (no 3D hover), light-gray text, inset scroll + dividers + card chrome. + * + * @param components engine components + */ + +const FLATTEN_CATEGORIES = ["IFCBUILDINGSTOREY"]; +const FLATTEN_SET = new Set(FLATTEN_CATEGORIES.map((c) => c.toUpperCase())); +const STOREY = "IFCBUILDINGSTOREY"; + +const ROW_H = 24; // px, fixed row height (enables simple windowing math) +const INDENT = 12; // px per depth level +const BASE_PAD = 18; // px left padding at depth 0 (rows are full-bleed; keeps text inset) +const BUFFER = 6; // extra rows above/below the viewport + +const prettyCategory = (category: string) => { + const base = category.replace(/^IFC/i, ""); + if (!base) return ""; + return base.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase()); +}; + +// Representative icon per IFC category (iconify/mdi names bim-icon takes). +const CATEGORY_ICONS: Record = { + IFCPROJECT: "mdi:sitemap", + IFCSITE: "mdi:terrain", + IFCBUILDING: "mdi:office-building", + IFCBUILDINGSTOREY: "mdi:layers", + IFCSPACE: "mdi:floor-plan", + IFCZONE: "mdi:select-group", + IFCWALL: "mdi:wall", + IFCWALLSTANDARDCASE: "mdi:wall", + IFCCURTAINWALL: "mdi:wall", + IFCSLAB: "mdi:floor-plan", + IFCROOF: "mdi:home-roof", + IFCCOLUMN: "mdi:view-column", + IFCBEAM: "mdi:minus", + IFCMEMBER: "mdi:minus", + IFCPLATE: "mdi:rectangle-outline", + IFCFOOTING: "mdi:foundation", + IFCPILE: "mdi:format-vertical-align-bottom", + IFCSTAIR: "mdi:stairs", + IFCSTAIRFLIGHT: "mdi:stairs", + IFCRAMP: "mdi:slope-uphill", + IFCRAMPFLIGHT: "mdi:slope-uphill", + IFCRAILING: "mdi:fence", + IFCCOVERING: "mdi:texture-box", + IFCDOOR: "mdi:door", + IFCWINDOW: "mdi:window-closed-variant", + IFCOPENINGELEMENT: "mdi:vector-rectangle", + IFCFURNISHINGELEMENT: "mdi:sofa", + IFCFURNITURE: "mdi:sofa", + IFCSANITARYTERMINAL: "mdi:toilet", + IFCPIPESEGMENT: "mdi:pipe", + IFCFLOWSEGMENT: "mdi:pipe", + IFCDUCTSEGMENT: "mdi:pipe", + IFCPIPEFITTING: "mdi:pipe-disconnected", + IFCDUCTFITTING: "mdi:pipe-disconnected", + IFCCABLECARRIERSEGMENT: "mdi:pipe", + IFCFLOWTERMINAL: "mdi:water-pump", + IFCLIGHTFIXTURE: "mdi:lightbulb", + IFCOUTLET: "mdi:power-socket", + IFCFLOWCONTROLLER: "mdi:valve", + IFCBUILDINGELEMENTPROXY: "mdi:cube-outline", + IFCANNOTATION: "mdi:tag-outline", +}; +const iconFor = (category: string) => + CATEGORY_ICONS[(category || "").toUpperCase()] ?? "mdi:cube-outline"; + +interface TreeNode { + key: string; + nm: string; // bare instance Name ("" if none) + prettyCat: string; + category: string; // raw IFC category, for the icon + modelId: string; + localId: number; // ExpressId (-1 if none) + ids: number[]; // own + all descendant localIds (select / focus) + children: TreeNode[]; + defaultExpand: boolean; // ancestor of a storey → start expanded +} + +// Collapsed → `Category - Name - ExpressId`; expanded → `Name - ExpressId`. +const labelFor = (n: TreeNode, collapsed: boolean) => { + const id = n.localId >= 0 ? String(n.localId) : ""; + const parts = collapsed ? [n.prettyCat, n.nm, id] : [n.nm || n.prettyCat || "Item", id]; + return parts.filter(Boolean).join(" - "); +}; + +const esc = (s: string) => + s.replace(/&/g, "&").replace(//g, ">"); + +export const modelTree = (components: OBC.Components) => { + const fragments = components.get(OBC.FragmentsManager); + const highlighter = components.get(OBF.Highlighter); + const selectName = highlighter.config.selectName; + + // ── Tree state ───────────────────────────────────────────────── + let roots: TreeNode[] = []; // currently displayed tree (spatial OR category) + let spatialRoots: TreeNode[] = []; // canonical spatial tree (source of truth) + let mode: "spatial" | "category" = "spatial"; + const nodeByKey = new Map(); + const expanded = new Set(); // keys whose children are shown + const selectedKeys = new Set(); // clicked rows (row highlight) + // `anc[c]` = a vertical guide continues at indent column c (ancestor has a + // following sibling); `last` = this node is the last child (→ half-height + // elbow). Both are only computed in the normal (non-search) view. + let visible: { node: TreeNode; depth: number; anc?: boolean[]; last?: boolean }[] = []; + let query = ""; + let rebuildToken = 0; + let searchTimer: number | undefined; + let keyCounter = 0; + + // Virtualization DOM (created once, managed imperatively). + const viewport = document.createElement("div"); + viewport.className = "tree-vp"; + const sizer = document.createElement("div"); + sizer.style.position = "relative"; + sizer.style.width = "100%"; + const content = document.createElement("div"); + content.style.position = "absolute"; + content.style.top = "0"; + content.style.left = "0"; + content.style.right = "0"; + sizer.appendChild(content); + viewport.appendChild(sizer); + + // ── Build node tree from a model's spatial structure ─────────── + const buildModel = async (model: FRAGS.FragmentsModel): Promise => { + const structure = await model.getSpatialStructure(); + if (!structure) return null; + + // One round-trip for all names. + const ids: number[] = []; + const collectIds = (node: FRAGS.SpatialTreeItem) => { + if (node.localId !== null && node.localId !== undefined) ids.push(node.localId); + node.children?.forEach(collectIds); + }; + collectIds(structure); + const nameMap = new Map(); + if (ids.length > 0) { + const data = await model.getItemsData(ids, { + attributesDefault: false, + attributes: ["Name"], + }); + data.forEach((d, i) => { + const name = (d as Record)?.Name; + if ( + name && + !Array.isArray(name) && + typeof name === "object" && + "value" in name && + (name as { value: unknown }).value != null + ) { + nameMap.set(ids[i], String((name as { value: unknown }).value)); + } + }); + } + + // Real per-item category source (same as the data-table): the spatial-tree + // node's `.category` is only populated for container/spine nodes, so in + // models where elements sit under a flat container (e.g. BLOXHUB) most + // element nodes have an EMPTY category and fall through to "Uncategorized". + // getItemsOfCategories reads each item's actual IFC category from the model's + // category buffer — use it as the authoritative source, keyed by localId. + const catMap = new Map(); + try { + const byCat = await model.getItemsOfCategories([/.*/]); + for (const [cat, lids] of Object.entries(byCat)) { + for (const lid of lids) catMap.set(lid, cat); + } + } catch (error) { + console.warn("[model-tree] getItemsOfCategories failed", model.modelId, error); + } + + // Skip pure category-wrapper nodes (single child, no localId), carrying the + // meaningful category down (it lives on the wrapper, not the instance). + const resolveInstance = (node: FRAGS.SpatialTreeItem) => { + let cur = node; + let chainCat = cur.category ?? ""; + while ( + cur.children && + cur.children.length === 1 && + (cur.localId === null || cur.localId === undefined) + ) { + cur = cur.children[0]; + if (cur.category) chainCat = cur.category; + } + return { inst: cur, chainCat }; + }; + + type Built = { node: TreeNode; cat: string; storeyAncestor: boolean }; + + // Children with whitelisted category-GROUP nodes flattened away. + const buildChildren = (node: FRAGS.SpatialTreeItem, inheritedCat = ""): Built[] => { + const out: Built[] = []; + for (const child of node.children ?? []) { + const isFlattenGroup = + (child.localId === null || child.localId === undefined) && + !!child.children && + child.children.length > 0 && + FLATTEN_SET.has((child.category ?? "").toUpperCase()); + if (isFlattenGroup) { + out.push(...buildChildren(child, child.category ?? inheritedCat)); + } else { + out.push(toNode(child, inheritedCat)); + } + } + return out; + }; + + const toNode = (raw: FRAGS.SpatialTreeItem, inheritedCat = ""): Built => { + const { inst, chainCat } = resolveInstance(raw); + const localId = inst.localId ?? -1; + // Prefer the real per-item category; fall back to the spatial node's. + const realCat = localId >= 0 ? catMap.get(localId) : undefined; + const category = realCat || inst.category || chainCat || inheritedCat || ""; + const rawName = localId >= 0 ? nameMap.get(localId) : undefined; + const nm = rawName ? String(rawName) : ""; + + const built = buildChildren(inst); + const rawHasStorey = (inst.children ?? []).some( + (c) => (c.category ?? "").toUpperCase() === STOREY, + ); + const storeyAncestor = + rawHasStorey || built.some((b) => b.cat === STOREY || b.storeyAncestor); + + const childIds: number[] = []; + for (const b of built) childIds.push(...b.node.ids); + + const node: TreeNode = { + key: `${model.modelId}#${(keyCounter += 1)}`, + nm, + prettyCat: prettyCategory(category), + category, + modelId: model.modelId, + localId, + ids: (localId >= 0 ? [localId] : []).concat(childIds), + children: built.map((b) => b.node), + defaultExpand: storeyAncestor, + }; + return { node, cat: category, storeyAncestor }; + }; + + return toNode(structure).node; + }; + + // ── Visible-list (flatten by expand state / search filter) ───── + const indexNodes = (nodes: TreeNode[]) => { + for (const n of nodes) { + nodeByKey.set(n.key, n); + indexNodes(n.children); + } + }; + + const matchNode = (n: TreeNode, q: string) => + `${n.nm} ${n.prettyCat} ${n.localId >= 0 ? n.localId : ""}` + .toLowerCase() + .includes(q); + + const rebuildVisible = () => { + const out: { node: TreeNode; depth: number; anc?: boolean[]; last?: boolean }[] = []; + const q = query.trim().toLowerCase(); + + if (q) { + // Search: a matching node shows its whole subtree; a non-matching node is + // kept only as an ancestor of a match. Returns the node's emitted rows, or + // null if neither it nor any descendant matches. + const subtree = (n: TreeNode, depth: number, acc: { node: TreeNode; depth: number }[]) => { + acc.push({ node: n, depth }); + for (const c of n.children) subtree(c, depth + 1, acc); + }; + const dfs = (n: TreeNode, depth: number): { node: TreeNode; depth: number }[] | null => { + if (matchNode(n, q)) { + const acc: { node: TreeNode; depth: number }[] = []; + subtree(n, depth, acc); + return acc; + } + const childRows: { node: TreeNode; depth: number }[] = []; + for (const c of n.children) { + const r = dfs(c, depth + 1); + if (r) childRows.push(...r); + } + return childRows.length ? [{ node: n, depth }, ...childRows] : null; + }; + for (const r of roots) { + const res = dfs(r, 0); + if (res) out.push(...res); + } + } else { + // Carry the connector-guide state: `anc` holds, for each indent column + // 0..depth-2, whether that ancestor branch continues below; `last` marks a + // last child (half-height elbow). A child gains a guide column for THIS + // node only when this node actually has an indent column (depth ≥ 1). + const dfs = (n: TreeNode, depth: number, anc: boolean[], last: boolean) => { + out.push({ node: n, depth, anc, last }); + if (!expanded.has(n.key)) return; + const childAnc = depth >= 1 ? [...anc, !last] : anc; + n.children.forEach((c, i) => + dfs(c, depth + 1, childAnc, i === n.children.length - 1), + ); + }; + roots.forEach((r, i) => dfs(r, 0, [], i === roots.length - 1)); + } + visible = out; + }; + + // ── Render the window ────────────────────────────────────────── + // Tree connector guides: vertical lines at each continuing ancestor column + + // an elbow (vertical-to-middle + horizontal) into the node. Absolutely + // positioned so they cost no layout. Suppressed in search (filtered view). + const GUIDE_O = 7; // px, aligns a guide with an ancestor caret's centre + const guideHtml = ( + depth: number, + anc: boolean[], + last: boolean, + expanded: boolean, + ) => { + if (query) return ""; + let h = ""; + if (depth > 0) { + for (let c = 0; c < depth - 1; c += 1) { + if (anc[c]) { + h += ``; + } + } + const ex = BASE_PAD + (depth - 1) * INDENT + GUIDE_O; + h += ``; + h += ``; + } + // Descending stub: when this node is expanded with visible children, draw a + // half-height vertical at the CHILDREN's column (depth) from the caret centre + // (50%) to the row bottom, so the down-chevron connects to the first child's + // full-height ancestor vertical below. x matches the children's elbow column. + if (expanded) { + h += ``; + } + return h; + }; + const isCollapsed = (n: TreeNode) => !query && !expanded.has(n.key); + const rowHtml = (node: TreeNode, depth: number, anc?: boolean[], last?: boolean) => { + const hasKids = node.children.length > 0; + const collapsed = isCollapsed(node); + const showsChildVertical = !query && hasKids && expanded.has(node.key); + const caret = hasKids + ? `` + : ``; + return ( + `
` + + guideHtml(depth, anc ?? [], last ?? true, showsChildVertical) + + caret + + `` + + `${esc(labelFor(node, collapsed))}` + + `Focus` + + `
` + ); + }; + + // Build a single row element from its HTML (so entering rows get the exact + // same markup the old innerHTML path produced — caret, icons, focus tooltip). + const buildRow = (i: number): HTMLElement => { + const v = visible[i]; + const tmp = document.createElement("div"); + tmp.innerHTML = rowHtml(v.node, v.depth, v.anc, v.last); + return tmp.firstElementChild as HTMLElement; + }; + + // Index→element recycler. A row that stays within the window keeps its exact + // DOM element (and its already-rendered s) across scroll, so icons + // are never recreated → no blink. Only rows ENTERING the window are built and + // rows LEAVING are removed. A forced rebuild (selection/expand/search/data + // change) clears this map so the affected rows are rebuilt once. + const mounted = new Map(); + + let lastStart = -1; + let lastEnd = -1; + const render = (force = false) => { + const total = visible.length; + sizer.style.height = `${total * ROW_H}px`; + const top = viewport.scrollTop; + const vh = viewport.clientHeight || ROW_H; + const start = Math.max(0, Math.floor(top / ROW_H) - BUFFER); + const end = Math.min(total, Math.ceil((top + vh) / ROW_H) + BUFFER); + if (!force && start === lastStart && end === lastEnd) return; + // Defensive: a reparents itself into a global body-level + // container when shown, so removing a row below would NOT remove a tooltip + // that is currently visible (its row gets detached but the tooltip lives + // elsewhere). The library self-heals via a host observer, but we also + // proactively dismiss any visible tooltip here so it can't flicker: fire + // mouseleave on each current Focus host (where the hide listener is bound). + if (document.querySelector("bim-tooltip[visible]")) { + for (const host of content.querySelectorAll(".t-focus")) { + host.dispatchEvent(new MouseEvent("mouseleave")); + } + } + // A forced rebuild means the underlying data/markup for the window changed + // (selection highlight, expand/collapse, search, rebuild). Drop every + // mounted row so they are rebuilt once with fresh markup — this only happens + // off the scroll path, so the (unnoticeable) one-time rebuild is fine; plain + // scrolling never forces and so always reuses existing rows. + if (force) { + mounted.clear(); + content.textContent = ""; + } + lastStart = start; + lastEnd = end; + content.style.transform = `translateY(${start * ROW_H}px)`; + // Remove rows that have scrolled out of [start, end). + for (const [i, el] of mounted) { + if (i < start || i >= end) { + el.remove(); + mounted.delete(i); + } + } + // Insert rows entering [start, end), keeping DOM order by index. We walk the + // window in order and splice each new element before the first already- + // mounted element with a higher index (or append if none). + let cursor = content.firstElementChild as HTMLElement | null; + for (let i = start; i < end; i++) { + const existing = mounted.get(i); + if (existing) { + // Already in place (in index order) — advance the cursor past it. + cursor = existing.nextElementSibling as HTMLElement | null; + continue; + } + const el = buildRow(i); + content.insertBefore(el, cursor); + mounted.set(i, el); + // cursor stays pointing at the same following node, so the next entering + // index is inserted after this one (preserving ascending DOM order). + } + }; + + // ── Interaction (one delegated listener) ─────────────────────── + const idsMap = (n: TreeNode): OBC.ModelIdMap | null => { + const real = n.ids.filter((id) => id >= 0); + if (real.length === 0) return null; + return { [n.modelId]: new Set(real) }; + }; + + const selectNode = (n: TreeNode, additive: boolean) => { + if (additive) { + if (selectedKeys.has(n.key)) selectedKeys.delete(n.key); + else selectedKeys.add(n.key); + } else { + selectedKeys.clear(); + selectedKeys.add(n.key); + } + const map = idsMap(n); + if (map) void highlighter.highlightByID(selectName, map, !additive, false); + else if (!additive) void highlighter.clear(selectName); + render(true); // refresh row highlight + }; + + const focusNode = async (n: TreeNode) => { + const map = idsMap(n); + if (!map) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const world = [...components.get(OBC.Worlds).list.values()][0] as any; + const controls = world?.camera?.controls; + if (!controls) return; + try { + const boxes = (await fragments.getBBoxes(map)) as THREE.Box3[]; + const box = new THREE.Box3(); + for (const b of boxes) box.union(b); + if (box.isEmpty()) return; + const sphere = box.getBoundingSphere(new THREE.Sphere()); + await controls.fitToSphere(sphere, true); // animated, preserves view dir + } catch (error) { + console.warn("[model-tree] focus failed", error); + } + }; + + const toggleExpand = (key: string) => { + if (expanded.has(key)) expanded.delete(key); + else expanded.add(key); + rebuildVisible(); + render(true); + }; + + content.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + const rowEl = target.closest(".t-row"); + if (!rowEl) return; + const node = nodeByKey.get(rowEl.dataset.key ?? ""); + if (!node) return; + const act = target.closest("[data-act]")?.dataset.act; + if (act === "toggle") { + toggleExpand(node.key); + } else if (act === "focus") { + // Focus also selects the node (and its descendants), like a plain click. + selectNode(node, false); + void focusNode(node); + } else { + selectNode(node, (e as MouseEvent).ctrlKey || (e as MouseEvent).metaKey); + } + }); + + // Re-window on scroll (rAF-coalesced) and on resize. + let scrollRaf = 0; + viewport.addEventListener("scroll", () => { + if (scrollRaf) return; + scrollRaf = requestAnimationFrame(() => { + scrollRaf = 0; + render(); + }); + }); + new ResizeObserver(() => render()).observe(viewport); + + // Clearing the selection elsewhere clears the tree row highlight. + highlighter.events.select.onClear.add(() => { + if (selectedKeys.size === 0) return; + selectedKeys.clear(); + render(true); + }); + + // ── Category grouping ────────────────────────────────────────── + // Flat grouping by IFC category derived from the spatial tree (each element + // node carries its category), so it covers exactly the elements the spatial + // view shows — no separate Classifier round-trip, always in sync. Each + // category becomes an expandable group whose children are its element nodes. + const buildCategoryRoots = (source: TreeNode[]): TreeNode[] => { + const byCat = new Map(); + const visit = (n: TreeNode) => { + if (n.localId >= 0) { + const cat = n.category || "Uncategorized"; + let arr = byCat.get(cat); + if (!arr) { + arr = []; + byCat.set(cat, arr); + } + arr.push({ + key: `${n.modelId}#cat#${(keyCounter += 1)}`, + nm: n.nm, + prettyCat: n.prettyCat, + category: n.category, + modelId: n.modelId, + localId: n.localId, + ids: [n.localId], + children: [], + defaultExpand: false, + }); + } + n.children.forEach(visit); + }; + source.forEach(visit); + + const cats = [...byCat.keys()].sort((a, b) => + prettyCategory(a).localeCompare(prettyCategory(b)), + ); + return cats.map((cat) => { + const kids = byCat.get(cat)!; + kids.sort((a, b) => + (a.nm || String(a.localId)).localeCompare(b.nm || String(b.localId)), + ); + const ids: number[] = []; + for (const k of kids) ids.push(...k.ids); + return { + key: `catgroup#${(keyCounter += 1)}`, + nm: "", + // The label cell shows "Category (count)" via prettyCat; raw category + // drives the icon. + prettyCat: `${prettyCategory(cat) || cat} (${kids.length})`, + category: cat, + modelId: kids[0]?.modelId ?? "", + localId: -1, + ids, + children: kids, + defaultExpand: false, + }; + }); + }; + + // Switch the displayed tree to the current `mode` and re-render. Selection in + // the 3D scene (driven by the Highlighter) is independent of tree keys, so it + // persists across a mode switch; only the row-highlight set is reset. + const applyMode = () => { + roots = mode === "category" ? buildCategoryRoots(spatialRoots) : spatialRoots; + nodeByKey.clear(); + indexNodes(roots); + expanded.clear(); + if (mode === "spatial") { + const markExpand = (nodes: TreeNode[]) => { + for (const n of nodes) { + if (n.defaultExpand) expanded.add(n.key); + markExpand(n.children); + } + }; + markExpand(roots); + } + selectedKeys.clear(); + rebuildVisible(); + lastStart = lastEnd = -1; + viewport.scrollTop = 0; + render(true); + update({}); // re-render the panel template so the view-mode buttons' active state refreshes + }; + + // ── Rebuild from all loaded models ───────────────────────────── + const rebuild = async () => { + const token = ++rebuildToken; + const models = [...fragments.list.values()]; + if (models.length === 0) { + if (token === rebuildToken) { + spatialRoots = []; + roots = []; + nodeByKey.clear(); + rebuildVisible(); + render(true); + update({ status: "empty" }); + } + return; + } + update({ status: "loading" }); + try { + const built = ( + await Promise.all( + models.map((m) => + buildModel(m).catch((error) => { + if (!/Model not found/i.test(String(error?.message ?? error))) { + console.warn("[model-tree] failed to build", m.modelId, error); + } + return null; + }), + ), + ) + ).filter((n): n is TreeNode => n !== null); + if (token !== rebuildToken) return; + spatialRoots = built; + // Build the displayed tree for the current mode (default-expands storeys + // in spatial mode; categories start collapsed in category mode). + applyMode(); + update({ status: built.length > 0 ? "ready" : "empty" }); + } catch (error) { + if (token !== rebuildToken) return; + console.warn("[model-tree] failed to build spatial structure", error); + update({ status: "empty" }); + } + }; + + // ── Panel chrome (BUI) — the windowed viewport is mounted into it ── + interface PanelState { + status: "loading" | "empty" | "ready"; + } + const [panel, update] = BUI.Component.create( + (state) => { + const onHostCreated = (el?: Element) => { + if (!el || el.contains(viewport)) return; + el.appendChild(viewport); + render(true); + }; + return BUI.html` + + +
+ ${cardHeader("mdi:file-tree", "Items", "1.1rem")} +
+ ${state.status === "empty" + ? BUI.html`
+ + No model loaded. +
` + : state.status === "loading" + ? BUI.html`
Loading…
` + : null} + ${state.status === "ready" + ? BUI.html` { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const v = String((e.target as any).value ?? ""); + if (searchTimer !== undefined) clearTimeout(searchTimer); + searchTimer = window.setTimeout(() => { + query = v; + rebuildVisible(); + viewport.scrollTop = 0; + render(true); + }, 200); + }} + > + +
+ { + if (mode !== "spatial") { + mode = "spatial"; + applyMode(); + } + }} + style="flex: 1 1 0; height: 1.7rem; border-radius: 0.3rem 0 0 0.3rem;" + > + { + if (mode !== "category") { + mode = "category"; + applyMode(); + } + }} + style="flex: 1 1 0; height: 1.7rem; border-radius: 0 0.3rem 0.3rem 0;" + > +
+
` + : null} +
+
+
+
+ `; + }, + { status: "loading" }, + ); + + // ── Triggers ─────────────────────────────────────────────────── + fragments.core.onModelLoaded.add(() => rebuild()); + fragments.list.onItemDeleted.add(() => rebuild()); + rebuild(); + + return panel; +}; diff --git a/src/cli/templates/app/src/setups/navigation-gizmo.ts b/src/cli/templates/app/src/setups/navigation-gizmo.ts new file mode 100644 index 0000000..4f84a92 --- /dev/null +++ b/src/cli/templates/app/src/setups/navigation-gizmo.ts @@ -0,0 +1,316 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; + +/** + * NAVIGATION GIZMO — a Blender/Spline-style axis-ball orientation widget pinned + * TOP-RIGHT of the viewport. Replaces the old camera-views overlay. + * + * A small rounded dark DISC contains a soft axis triad that reflects the LIVE + * camera orientation (it rotates with the camera): a neutral hub and three muted + * axes (soft red/green/blue), each a ball on a SHORT, THICK rounded bar. No + * labels, no outer ring. Colour is FIXED BY AXIS SIGN — +X/+Y/+Z are coloured + * (ball + arm); −X/−Y/−Z are small flat-gray dots (no arm), regardless of facing. + * Click a ball → orient + frame the model's union bbox in a SINGLE animated move + * (one `setLookAt` whose distance is computed to frame the sphere — no separate + * fitToSphere). Hovering an end gently brightens it. No home/fit button (the + * Focus command zoom-to-fits the whole model when nothing is selected) and no + * ortho/persp toggle (the bottom toolbar owns it). + * + * Same orient-on-click technique as a view-cube; this is the axis-ball variant. + * The widget renders in its OWN tiny WebGL renderer (a fixed ~60px disc overlay, + * tight around the triad) fully decoupled from the main viewport — a constant + * on-screen size. The gizmo camera is ALWAYS ORTHOGRAPHIC (axonometric — the arms + * never foreshorten), posed each frame to mirror the main camera's orientation, + * so the end facing you is the axis the camera is looking down. Picking is a + * single raycast against the six ball meshes → the nearest ball's axis direction. + * + * Self-mounts into the viewport overlay (like the old camera-views). Wire from + * main.ts with one line, after the viewport exists: + * + * navigationGizmo(components, viewerElement); + * + * @param components engine components + * @param container optional viewport element to overlay; defaults to the first + * world's renderer container. + */ + +const SIZE = 60; // px, fixed on-screen widget size (~50% smaller) +const DIST = 5; // gizmo camera distance from the triad +const FRUSTUM = 1.08; // ortho half-extent — tight around the triad (little padding) +const R = 0.82; // ball distance from the hub + +// Colour is FIXED BY AXIS SIGN: +X/+Y/+Z are coloured (ball + arm), the negatives +// are always flat gray — independent of facing. +const AXES: { dir: [number, number, number]; color: number; positive: boolean }[] = [ + { dir: [1, 0, 0], color: 0xcf6f6f, positive: true }, // +X soft red + { dir: [-1, 0, 0], color: 0xcf6f6f, positive: false }, + { dir: [0, 1, 0], color: 0x86b572, positive: true }, // +Y soft green + { dir: [0, -1, 0], color: 0x86b572, positive: false }, + { dir: [0, 0, 1], color: 0x7095c9, positive: true }, // +Z soft blue + { dir: [0, 0, -1], color: 0x7095c9, positive: false }, +]; +const GRAY = new THREE.Color(0x6e7177); // negatives are always flat gray + +interface Axis { + ball: THREE.Mesh; + arm: THREE.Mesh; + baseColor: THREE.Color; // fixed appearance colour (axis colour, or gray for −axes) + baseScale: number; + dir: THREE.Vector3; +} + +export const navigationGizmo = ( + components: OBC.Components, + container?: HTMLElement, +) => { + const fragments = components.get(OBC.FragmentsManager); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const firstWorld = (): any => [...components.get(OBC.Worlds).list.values()][0]; + + // ── Overlay DOM (pinned top-right, click-through except on the widget) ── + const root = document.createElement("div"); + root.className = "nav-gizmo"; + root.style.cssText = [ + "position: absolute", + "top: 1rem", + "right: 1rem", + "z-index: 20", + "display: flex", + "flex-direction: column", + "align-items: center", + "gap: 0.25rem", + "pointer-events: none", + ].join(";"); + + // The subtle dark DISC is the canvas itself (the alpha renderer clears + // transparent, so this soft CSS background + round clip shows through). No bold + // outer ring — just the disc + a faint shadow for separation. + const canvas = document.createElement("canvas"); + canvas.style.cssText = [ + `width:${SIZE}px`, + `height:${SIZE}px`, + "pointer-events:auto", + "cursor:pointer", + "border-radius:50%", + // Opaque circular background matching the panels (BUI theme base colour). + "background:var(--bim-ui_bg-base, var(--bim-ui_bg-contrast-20, #1b1b1f))", + "box-shadow:0 1px 6px rgba(0,0,0,0.28)", + ].join(";"); + root.appendChild(canvas); + + // ── Gizmo scene (own renderer, fully decoupled from the main viewport) ── + const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true }); + renderer.setPixelRatio(window.devicePixelRatio || 1); + renderer.setSize(SIZE, SIZE, false); + renderer.outputColorSpace = THREE.SRGBColorSpace; + + const scene = new THREE.Scene(); + const gizmoCam = new THREE.OrthographicCamera( + -FRUSTUM, FRUSTUM, FRUSTUM, -FRUSTUM, 0.1, 100, + ); + + // Small neutral center hub. + scene.add( + new THREE.Mesh( + new THREE.SphereGeometry(0.06, 16, 12), + new THREE.MeshBasicMaterial({ color: 0x8a8f96 }), + ), + ); + + // Six axis ends: a ball + a SHORT, THICK rounded bar (capsule) from the hub. + // Appearance is FIXED BY SIGN: +axes coloured (ball + arm), −axes flat gray + // (small ball, no arm). + const BALL_R = 0.12; // ball radius + const ARM_R = 0.05; // arm (capsule) radius — fat + const ARM_INNER = 0.08; // bar starts just outside the hub + const ARM_OUTER = R - BALL_R; // bar ends just inside the ball + const ARM_LEN = ARM_OUTER - ARM_INNER - 2 * ARM_R; // cylinder length (caps add 2·R) + const ARM_MID = (ARM_INNER + ARM_OUTER) / 2; // centre offset along the axis + const _yAxis = new THREE.Vector3(0, 1, 0); + const axes: Axis[] = []; + for (const a of AXES) { + const dir = new THREE.Vector3(...a.dir); + const baseColor = a.positive ? new THREE.Color(a.color) : GRAY.clone(); + const baseScale = 1; // negatives are full-size too — just gray + no arm + + const ball = new THREE.Mesh( + new THREE.SphereGeometry(BALL_R, 20, 16), + new THREE.MeshBasicMaterial({ color: baseColor.clone(), transparent: true, opacity: a.positive ? 1 : 0.7 }), + ); + ball.position.copy(dir).multiplyScalar(R); + ball.scale.setScalar(baseScale); + ball.renderOrder = 2; + scene.add(ball); + + // Capsule points along +Y by default → rotate to the axis, centre it on the + // hub→ball midpoint. Only the coloured (+) axes show an arm. + const arm = new THREE.Mesh( + new THREE.CapsuleGeometry(ARM_R, ARM_LEN, 6, 12), + new THREE.MeshBasicMaterial({ color: baseColor.clone(), transparent: true }), + ); + arm.quaternion.setFromUnitVectors(_yAxis, dir); + arm.position.copy(dir).multiplyScalar(ARM_MID); + arm.renderOrder = 1; + arm.visible = a.positive; + scene.add(arm); + + axes.push({ ball, arm, dir, baseColor, baseScale }); + } + + // ── Pose the gizmo camera to mirror the main camera each frame ── + const _fwd = new THREE.Vector3(); + const _up = new THREE.Vector3(); + const _lastQuat = new THREE.Quaternion(0, 0, 0, 0); + const poseGizmo = (): boolean => { + const main = firstWorld()?.camera?.three as THREE.Camera | undefined; + if (!main) return false; + main.getWorldDirection(_fwd); // direction the main camera looks (into scene) + gizmoCam.position.copy(_fwd).multiplyScalar(-DIST); // mirror that viewpoint + _up.set(0, 1, 0).applyQuaternion(main.quaternion); // honor camera roll + gizmoCam.up.copy(_up); + gizmoCam.lookAt(0, 0, 0); + return true; + }; + + const WHITE = new THREE.Color(0xffffff); + let hovered: Axis | null = null; + + let hoverDirty = false; + const render = () => { + if (!poseGizmo()) return; + renderer.render(scene, gizmoCam); + }; + + // Render only when the orientation changed (or a hover changed) — cheap. + let raf = 0; + const loop = () => { + raf = requestAnimationFrame(loop); + void raf; + const main = firstWorld()?.camera?.three as THREE.Camera | undefined; + if (!main) return; + if (hoverDirty || !main.quaternion.equals(_lastQuat)) { + _lastQuat.copy(main.quaternion); + hoverDirty = false; + render(); + } + }; + + // ── Picking: nearest ball under the cursor ───────────────────── + const raycaster = new THREE.Raycaster(); + const ndc = new THREE.Vector2(); + const ballMeshes = axes.map((ax) => ax.ball); + const pickBall = (ev: PointerEvent | MouseEvent): Axis | null => { + const rect = canvas.getBoundingClientRect(); + ndc.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1; + ndc.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1; + raycaster.setFromCamera(ndc, gizmoCam); + const hit = raycaster.intersectObjects(ballMeshes, false)[0]; + if (!hit) return null; + return axes.find((ax) => ax.ball === hit.object) ?? null; + }; + + const setHover = (ax: Axis | null) => { + if (hovered === ax) return; + if (hovered) { + hovered.ball.scale.setScalar(hovered.baseScale); + hovered.ball.material.color.copy(hovered.baseColor); + } + hovered = ax; + if (ax) { + ax.ball.scale.setScalar(ax.baseScale * 1.18); // subtle grow + ax.ball.material.color.copy(ax.baseColor).lerp(WHITE, 0.22); // gentle brighten + } + canvas.style.cursor = ax ? "pointer" : "default"; + hoverDirty = true; + }; + + // ── Orient + frame in ONE animated move ──────────────────────── + // Compute the framing distance ourselves and issue a SINGLE setLookAt (which + // tweens position + target together) — no separate fitToSphere, so a gizmo + // click is one continuous orient-and-frame motion, not two. + const unionBox = () => { + const box = new THREE.Box3(); + for (const model of fragments.list.values()) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b = (model as any).box as THREE.Box3 | undefined; + if (b && !b.isEmpty()) box.union(b); + } + return box; + }; + + const orientTo = (dir: THREE.Vector3) => { + const world = firstWorld(); + const controls = world?.camera?.controls; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const main = world?.camera?.three as any; + if (!controls?.setLookAt || !main) return; + try { + const box = unionBox(); + if (box.isEmpty()) return; // nothing loaded → no-op gracefully + // Frame the actual MODEL AABB (not its bounding sphere — the sphere over- + // estimates ~1.7× for a box, landing the camera too far). For an axis view + // the distance is driven by the box's two PERPENDICULAR extents (fit the + // larger against the narrower fov half-angle) plus half the depth along the + // axis so the camera sits just outside the box. ONE animated setLookAt. + const v = dir.clone().normalize(); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const dims = [size.x, size.y, size.z]; + const ax = Math.abs(v.x) > 0.5 ? 0 : Math.abs(v.y) > 0.5 ? 1 : 2; // dominant axis + const depth = dims[ax] || 1; + const perp = dims.filter((_, i) => i !== ax); + const maxPerp = Math.max(perp[0] || 0, perp[1] || 0, 1e-3); + if (main.isPerspectiveCamera) { + const vHalf = THREE.MathUtils.degToRad(main.fov || 50) / 2; + const aspect = main.aspect || 1; + const hHalf = Math.atan(Math.tan(vHalf) * aspect); + const fit = Math.max(0.001, Math.min(vHalf, hHalf)); + const d = (maxPerp / 2 / Math.tan(fit) + depth / 2) * 1.12; + const eye = v.multiplyScalar(d).add(center); + void controls.setLookAt(eye.x, eye.y, eye.z, center.x, center.y, center.z, true); + } else { + // Orthographic: distance is cosmetic; place outside the box and tween the + // ortho zoom CONCURRENTLY (same frame → one smooth motion) to fit it. + const d = depth / 2 + maxPerp * 2; + const eye = v.multiplyScalar(d).add(center); + void controls.setLookAt(eye.x, eye.y, eye.z, center.x, center.y, center.z, true); + const fw = main.right - main.left; + const fh = main.top - main.bottom; + const zoom = Math.min(fw, fh) / (maxPerp * 1.12); + if (typeof controls.zoomTo === "function" && Number.isFinite(zoom) && zoom > 0) { + void controls.zoomTo(zoom, true); + } + } + } catch (error) { + console.warn("[navigation-gizmo] orient failed", error); + } + }; + + // ── Interaction ──────────────────────────────────────────────── + canvas.addEventListener("pointermove", (ev) => setHover(pickBall(ev))); + canvas.addEventListener("pointerleave", () => setHover(null)); + canvas.addEventListener("click", (ev) => { + const b = pickBall(ev); + if (b) void orientTo(b.dir); + }); + + // ── Mount + start ────────────────────────────────────────────── + const resolveContainer = (): HTMLElement | undefined => { + if (container) return container; + const world = firstWorld(); + const canvasEl = world?.renderer?.three?.domElement as HTMLElement | undefined; + return (canvasEl?.parentElement as HTMLElement | undefined) ?? undefined; + }; + const host = resolveContainer(); + if (host) { + if (getComputedStyle(host).position === "static") host.style.position = "relative"; + host.appendChild(root); + } else { + console.warn("[navigation-gizmo] no viewport found to overlay; append the returned element manually"); + } + + render(); // initial paint + loop(); + + return root; +}; diff --git a/src/cli/templates/app/src/setups/objects-panel.ts b/src/cli/templates/app/src/setups/objects-panel.ts new file mode 100644 index 0000000..2255a2a --- /dev/null +++ b/src/cli/templates/app/src/setups/objects-panel.ts @@ -0,0 +1,127 @@ +import * as BUI from "@thatopen/ui"; +import type { InspectionInstances, InstanceRow } from "./inspection"; +import { toolPlaceholderUri } from "../assets/tool-placeholder"; +import { cardHeader } from "./card-header"; + +/** + * OBJECTS outliner panel (UI-reorg increment b). Lists every created clip plane + * and measurement from the unified {@link InspectionInstances} API, each row with + * hide/show, enable/disable (clip planes only) and delete. Re-renders on the + * inspection `onChanged` event (the row actions themselves fire it via the tools, + * so handlers don't re-render manually). Returns its `bim-panel` WITHOUT + * self-mounting — main.ts docks it in the activity-bar "Objects" layout. + * + * @param inspection unified clip-plane + measurement instance list (from W1) + * @returns the `bim-panel` element + */ +const iconFor = (r: InstanceRow): string => { + if (r.kind === "clip") return "mdi:scissors-cutting"; + switch (r.type) { + case "Length": return "mdi:ruler"; + case "Area": return "mdi:vector-square"; + case "Angle": return "mdi:angle-acute"; + default: return "mdi:cube-outline"; + } +}; + +export const objectsPanel = (inspection: InspectionInstances) => { + // bim-tooltip portals itself into a body-level container while shown and only + // hides on its parent button's `mouseleave`. A row action (delete / toggle) + // fires inspection.onChanged → the row re-renders and the button is removed — + // so the parent never sees `mouseleave` and the portaled tooltip is left + // orphaned on screen. Firing `mouseleave` on the button first runs the + // tooltip's hide (re-parents + clears it) BEFORE the row removes, so no stale + // tooltip lingers after delete/toggle. + const dismissTip = (e: Event) => + (e.currentTarget as HTMLElement | null)?.dispatchEvent(new MouseEvent("mouseleave")); + + const rowHtml = (r: InstanceRow) => BUI.html` +
+ + ${r.label} + { dismissTip(e); r.setVisible(!r.visible); }} + >${r.visible ? "Hide" : "Show"} + ${r.enabled !== undefined + ? BUI.html` { dismissTip(e); r.setEnabled?.(!r.enabled); }} + >${r.enabled ? "Disable cut" : "Enable cut"}` + : null} + { dismissTip(e); r.remove(); }} + >Delete +
`; + + // A titled group ("Clip planes" / "Measurements") when its rows are present. + const group = (title: string, rows: InstanceRow[]) => + rows.length === 0 + ? null + : BUI.html` +
${title}
+ ${rows.map(rowHtml)}`; + + const [panel, update] = BUI.Component.create( + // Arity >= 1 (state param) is REQUIRED — an arity-0 callback makes + // Component.create return a single element, not [el, update]. + (_s) => { + const rows = inspection.list(); + const clips = rows.filter((r) => r.kind === "clip"); + const measures = rows.filter((r) => r.kind === "measurement"); + return BUI.html` + + +
+ ${cardHeader("mdi:cube-outline", "Objects", "1.1rem")} +
+ ${rows.length === 0 + ? BUI.html`
+ + No clip planes or measurements yet. Create them from the Inspection toolbar. +
` + : BUI.html`${group("Clip planes", clips)}${group("Measurements", measures)}`} +
+
+
`; + }, + { tick: 0 }, + ); + + // Re-render whenever an instance is added/removed or its state changes. + inspection.onChanged.add(() => update({ tick: 0 })); + + return panel; +}; diff --git a/src/cli/templates/app/src/setups/plans-panel.ts b/src/cli/templates/app/src/setups/plans-panel.ts new file mode 100644 index 0000000..d8ef80e --- /dev/null +++ b/src/cli/templates/app/src/setups/plans-panel.ts @@ -0,0 +1,619 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; +import * as BUI from "@thatopen/ui"; +import { ensureSectionStyle, STYLE_NAME } from "./clipper-tool"; + +/** + * FLOOR PLANS panel — 2D plan navigation per IFCBUILDINGSTOREY. + * + * Built on `OBC.Views`: `createFromIfcStoreys()` generates one View per storey + * (an orthographic, top-down "Plan"-mode camera + a section clip plane at the + * storey's elevation). Clicking a level `open()`s its view (Views snapshots the + * current camera pose); "Exit to 3D" `close()`s it (Views restores the prior + * orbit camera + pose). The panel just lists the levels (name + elevation) and + * drives open/close. The active plan's cut is drawn FILLED + OUTLINED via + * `OBF.ClipStyler.createFromView` reusing the global "Section" style (registered + * idempotently by `ensureSectionStyle`), so the section reads like the 3D clipper. + * + * UI: vanilla BUI, native panel header (label+icon), muted section band, 1px + * contrast-20 hairlines, #3C3C41 scrollbar — matching the docked-panel refactor. + * Factory returns the element WITHOUT self-mounting (like graphics/files panels). + * + * @param components engine components + */ + +interface Level { + name: string; // = the View id (Views keys storey views by Name) + elevation: number; // raw IFC Elevation value (model length unit) +} + +interface PanelState { + status: "loading" | "empty" | "ready"; + levels: Level[]; + active: string | null; // name of the open plan, or null (3D) + filter: string; // level-list search query (by name + elevation) + viewDepth: number; // extra depth (model units) revealed BELOW the storey cut + cutOffset: number; // top-cut height (model units) ABOVE the active storey floor +} + +// View-depth slider bounds (model units). 0 = the storey's default range (only +// that storey); higher reveals floors below down to the chosen depth. Defaults +// to 30 below the cut on open. +const VIEW_DEPTH_MAX = 100; +const VIEW_DEPTH_STEP = 0.5; +const VIEW_DEPTH_DEFAULT = 30; + +// Cut-plane offset (top cut, model units above the storey floor). The slider +// DEFAULTS to 1.5m above each floor; CREATION_OFFSET is the createFromIfcStoreys +// offset the captured base plane reflects (the formula raises the cut from there). +const CUT_OFFSET_DEFAULT = 1.5; +const CREATION_OFFSET = 0.25; +const CUT_OFFSET_MAX = 3; +const CUT_OFFSET_STEP = 0.05; + +// Margin fraction added around the model AABB when framing a plan (breathing room). +const PLAN_FIT_MARGIN = 0.06; + +const elevText = (e: number) => { + if (!Number.isFinite(e)) return ""; + return `${e >= 0 ? "+" : ""}${e.toFixed(2)}`; +}; + +export const plansPanel = (components: OBC.Components) => { + const fragments = components.get(OBC.FragmentsManager); + const views = components.get(OBC.Views); + const styler = components.get(OBF.ClipStyler); + // No camera animations in the plans path: Views' close-restore otherwise + // tweens the 3D camera back via fromJSON(json, true). The default 3D camera is + // untouched during a plan (we drive the plan camera), so disabling the restore + // means close() just snaps back to the default camera's existing pose — instant. + views.restoreCameraOnClose = false; + + let rebuildToken = 0; + let searchTimer: number | undefined; + + const getWorld = () => + ([...components.get(OBC.Worlds).list.values()][0] as OBC.World) ?? null; + + // ── Shared 2D camera memory (session, in-memory) ─────────────── + // ONE shared plan-view camera state (toJSON: position + target + ortho zoom) + // across ALL floor plans. The FIRST time the user enters plan mode there's no + // snapshot → fit-to-plan. After that, switching between levels KEEPS the same + // XY pan + zoom (only each level's own section-clip height changes); restoring + // the shared pose re-applies pan/zoom while Views' per-level clip drives the cut. + let sharedPlanCam: string | null = null; + let currentLevel: string | null = null; + let restAttached = false; + + const capturePlanCam = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const c = getWorld()?.camera?.controls as any; + if (c && typeof c.toJSON === "function") { + try { + sharedPlanCam = c.toJSON(); + } catch { + /* controls without (de)serialization — memory just no-ops */ + } + } + }; + const restorePlanCam = (): boolean => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const c = getWorld()?.camera?.controls as any; + if (c && sharedPlanCam && typeof c.fromJSON === "function") { + try { + c.fromJSON(sharedPlanCam, false); // INSTANT restore of XY + zoom (no tween) + return true; + } catch { + return false; + } + } + return false; + }; + // Keep the shared 2D pose current as the user pans/zooms within ANY plan — + // camera-controls 'rest' fires once motion settles. Attached lazily, once. + const ensureRestListener = () => { + if (restAttached) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const c = getWorld()?.camera?.controls as any; + if (!c?.addEventListener) return; + c.addEventListener("rest", () => { + if (currentLevel) capturePlanCam(); + }); + restAttached = true; + }; + + // Fit the (now active) plan camera to the model AABB — fitToBox frames the + // box's footprint TIGHTLY in the ortho top-down view (the bounding SPHERE used + // before over-encloses ~1.7× and lands too far). Instant (no fly-in). + const fitActive = async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const controls = getWorld()?.camera?.controls as any; + if (!controls?.fitToBox) return; + const box = new THREE.Box3(); + for (const [, model] of fragments.list) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b = (model as any).box as THREE.Box3 | undefined; + if (b && !b.isEmpty()) box.union(b); + } + if (box.isEmpty()) return; + const size = box.getSize(new THREE.Vector3()); + box.expandByScalar(Math.max(size.x, size.y, size.z) * PLAN_FIT_MARGIN); // margin + await controls.fitToBox(box, false); // INSTANT, AABB-tight + }; + + // The postproduction renderer (lazy — the pipeline allocates once it's up). + const getPostproduction = () => { + const r = getWorld()?.renderer as OBF.PostproductionRenderer | undefined; + return r?.postproduction ?? null; + }; + + // Plan-mode view tweaks, snapshotted on the FIRST entry (3D → plan) and + // restored on exit (plan → 3D), so 3D mode is left exactly as it was. Direct + // plan→plan switches keep the snapshot. + let inPlan = false; + let savedAnchor: boolean | null = null; + let savedStyle: OBF.PostproductionAspect | null = null; + + // Tame the plan-mode wheel zoom. The PLAN camera is ORTHOGRAPHIC, so the wheel + // maps to the ZOOM action — governed by camera-controls' `zoomSpeed` (scales + // camera.zoom), NOT `dollySpeed` (that's perspective dolly only). Views may use + // a SEPARATE camera-controls instance per plan, so we tame whatever controls is + // ACTIVE after views.open() (tameZoom is called there), tracking each instance + // we touch so every one is restored on exit. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tamedControls = new Map(); + const PLAN_ZOOM_SPEED = 1.0; // snappy wheel zoom (the View defaults dollySpeed=6) + const tameZoom = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const c = getWorld()?.camera?.controls as any; + if (!c || tamedControls.has(c)) return; + tamedControls.set(c, { + zoom: typeof c.zoomSpeed === "number" ? c.zoomSpeed : NaN, + dolly: typeof c.dollySpeed === "number" ? c.dollySpeed : NaN, + }); + if (typeof c.zoomSpeed === "number") c.zoomSpeed = PLAN_ZOOM_SPEED; + if (typeof c.dollySpeed === "number") c.dollySpeed = PLAN_ZOOM_SPEED; // belt + braces + }; + const untameZoom = () => { + for (const [c, orig] of tamedControls) { + if (!Number.isNaN(orig.zoom)) c.zoomSpeed = orig.zoom; + if (!Number.isNaN(orig.dolly)) c.dollySpeed = orig.dolly; + } + tamedControls.clear(); + }; + + const applyPlanLook = () => { + if (inPlan) return; + inPlan = true; + // Hide the dynamic-anchor pivot dot in 2D (the anchor dot only renders off + // world.onDynamicAnchorSet, so disabling dynamicAnchor suppresses it). + const world = getWorld(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = world as any; + if (w && typeof w.dynamicAnchor === "boolean") { + savedAnchor = w.dynamicAnchor; + w.dynamicAnchor = false; + } + // True PEN style for plans — line drawing, no surface colour (snapshot the + // live style to restore on exit). + const pp = getPostproduction(); + if (pp) { + savedStyle = pp.style; + pp.style = OBF.PostproductionAspect.PEN; + } + }; + + const restorePlanLook = () => { + if (!inPlan) return; + inPlan = false; + untameZoom(); // restore every plan-controls instance's zoom/dolly speed + const world = getWorld(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = world as any; + if (w && savedAnchor !== null) w.dynamicAnchor = savedAnchor; + const pp = getPostproduction(); + if (pp && savedStyle !== null) pp.style = savedStyle; + savedAnchor = null; + savedStyle = null; + }; + + // ── View depth (Revit "view depth"): extend the lower clip below the storey ── + // Each storey View has a top cut `plane` (the section height) and a `range` = + // how far BELOW the cut is shown (its `farPlane`). depth 0 keeps the storey's + // default range; raising it pushes the far plane down to reveal lower floors. + let viewDepth = VIEW_DEPTH_DEFAULT; // model units below the cut (mirrors state.viewDepth) + // PER-LEVEL pristine default range. Was a single shared value captured from the + // first level visited and then applied to ALL levels — wrong, since each storey + // view has its own natural range (distance below its cut), so upper storeys got + // a bad slab and showed nothing. Capture+restore per level. + const baseRange = new Map(); + const applyViewDepth = () => { + if (!currentLevel) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const view = views.list.get(currentLevel) as any; + if (!view || typeof view.range !== "number") return; + if (!baseRange.has(currentLevel)) baseRange.set(currentLevel, view.range as number); + view.range = baseRange.get(currentLevel)! + viewDepth; + if (typeof view.update === "function") view.update(); + }; + // The view-depth slider works where the open path didn't, even though both set + // the SAME range — the difference was the slider ALSO re-rendered the panel + // (update), which is what actually commits the new slab visually. Factor that + // whole body into one function so OPEN and the SLIDER fire the identical commit. + const applyDepthAndCommit = () => { + applyViewDepth(); + refreshPlanSection(); + update({ viewDepth }); + }; + + // ── Cut-plane offset (top cut, model units above the floor) — PER PLAN ────── + // The View's `distance` = its cut plane's `constant`. Raising the cut by Δ above + // the floor changes the constant by −normal.y·Δ, measured from CREATION_OFFSET + // (the offset the captured base plane reflects). Default cut height is 1.5m. + const cutOffsets = new Map(); // level → offset above floor + const baseCut = new Map(); // level → pristine plane.constant + const applyCutOffset = (level: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const view = views.list.get(level) as any; + if (!view || typeof view.distance !== "number") return; + if (!baseCut.has(level)) baseCut.set(level, view.distance as number); + const off = cutOffsets.get(level) ?? CUT_OFFSET_DEFAULT; + const ny = view.plane?.normal?.y ?? 1; + view.distance = baseCut.get(level)! - ny * (off - CREATION_OFFSET); + // (the distance setter calls view.update internally) + }; + + // ── Plan section fill + edges (OBF.ClipStyler) ───────────────── + // Draw the global "Section" style (fill + outline) at the active plan's cut + // plane via createFromView, so the cut reads filled + outlined like the 3D + // clipper. One ClipEdges at a time (only one plan open); rebuilt on switch and + // disposed on exit. ensureSectionStyle registers "Section" idempotently so we + // don't depend on the clipper tool having been constructed. + let planEdges: OBF.ClipEdges | null = null; + let planEdgesId: string | null = null; + const disposePlanSection = () => { + if (planEdgesId && styler.list.has(planEdgesId)) styler.list.delete(planEdgesId); // DataMap delete disposes + planEdges = null; + planEdgesId = null; + }; + const buildPlanSection = (name: string) => { + disposePlanSection(); + try { + ensureSectionStyle(components); + const world = getWorld(); + styler.world = world; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const view = views.list.get(name) as any; + if (!view) return; + const id = `plan-section:${name}`; + planEdges = styler.createFromView(view, { + id, + items: { All: { style: STYLE_NAME } }, // no `data` → style ALL cut geometry + link: true, // auto-update/visibility-sync with the view + world: world ?? undefined, + }); + planEdgesId = id; + // createFromView (unlike createFromClipping) does NOT set edges.visible — + // and the `visible` setter is what ADDS the group to the scene. The view is + // already open here, so the view.onStateChanged→visible link already fired + // before our ClipEdges existed; set it explicitly so renderOverlay can find + // the section group. Then force a rebuild at the current plane. + planEdges.visible = true; + void Promise.resolve(planEdges.update()).catch((error) => + console.warn("[plans-panel] section build skipped:", error), + ); + } catch (error) { + console.warn("[plans-panel] failed to build plan section", name, error); + } + }; + // Redraw the section after the cut plane moves (cut-height / view-depth change). + const refreshPlanSection = () => { + if (!planEdges) return; + void Promise.resolve(planEdges.update()).catch(() => {}); + }; + + const enterPlan = async (name: string) => { + // Guard: never silently no-op on a level whose view is missing — that's the + // "can't navigate to some levels" symptom. The rebuild reconcile should have + // created it; if not, surface it instead of failing quietly. + if (!views.list.has(name)) { + console.warn("[plans-panel] no plan view for level:", name, "— cannot open"); + return; + } + try { + // Capture the current 2D pan/zoom before switching, so it carries over. + if (currentLevel && currentLevel !== name) capturePlanCam(); + applyPlanLook(); // anchor off + pen style (snapshot on first entry) + ensureRestListener(); + views.open(name); // sets this level's section-clip height + tameZoom(); // NOW the plan camera's controls is active → tame its wheel zoom + currentLevel = name; + buildPlanSection(name); // create the Section edges (positioned in the settle below) + update({ active: name, cutOffset: cutOffsets.get(name) ?? CUT_OFFSET_DEFAULT }); + // Apply the per-level SLAB (range + cut), CAMERA framing, and SECTION refresh + // AFTER the view settles (two frames). Doing them synchronously on open hits + // a not-yet-ready view/camera: the per-level range applies to a stale view + // (wrong slab → blank plan) and fitToBox no-ops on the un-sized camera. This + // is exactly the timing the view-depth slider hit — nudging it "fixed" every + // dead level — now run on open for every level. + requestAnimationFrame(() => + requestAnimationFrame(async () => { + if (currentLevel !== name) return; // user moved on + applyCutOffset(name); // per-level top cut + if (sharedPlanCam) { + restorePlanCam(); // subsequent visits → restore the shared 2D pan/zoom + } else { + await fitActive(); // first visit → frame now that the camera is sized + const c = getWorld()?.camera?.controls as unknown as { update?: (d: number) => void }; + try { + c?.update?.(0); // apply the fit before capturing the shared pose + } catch { + /* some builds reject delta 0 */ + } + capturePlanCam(); // seed the shared 2D pose from the real fit + } + // Apply depth AND fire the panel update that COMMITS the slab — the exact + // path the view-depth slider runs (range alone, without the commit, never + // took on open). This makes every level correct on first open, no nudge. + applyDepthAndCommit(); + }), + ); + } catch (error) { + console.warn("[plans-panel] failed to open plan", name, error); + } + }; + + const exitPlan = () => { + capturePlanCam(); // remember the shared 2D pan/zoom for the next plan visit + currentLevel = null; + disposePlanSection(); // drop the plan's section fill + edges + if (!views.hasOpenViews) { + restorePlanLook(); + update({ active: null }); + return; + } + views.close(); // restores the prior orbit camera + pose + restorePlanLook(); // anchor + postproduction style back to the 3D snapshot + update({ active: null }); + }; + + // ── Build the storey list + register the storey views ────────── + const rebuild = async () => { + const token = ++rebuildToken; + // The model set changed → the remembered 2D pose + clip baselines are stale. + sharedPlanCam = null; + currentLevel = null; + baseRange.clear(); + baseCut.clear(); + cutOffsets.clear(); + disposePlanSection(); // views are about to be cleared/regenerated + const world = getWorld(); + const models = [...fragments.list.values()]; + if (!world || models.length === 0) { + views.list.clear(); + if (token === rebuildToken) update({ status: "empty", levels: [], active: null }); + return; + } + update({ status: "loading" }); + try { + views.world = world; + views.list.clear(); // drop stale views before regenerating + await views.createFromIfcStoreys({ world }); + + const levels: Level[] = []; + const seenNames = new Set(); // views.list is keyed by Name → dedupe + for (const [, model] of fragments.list) { + const ids = Object.values( + await model.getItemsOfCategories([/BUILDINGSTOREY/]), + ).flat(); + if (ids.length === 0) continue; + const data = await model.getItemsData(ids, { + attributesDefault: false, + attributes: ["Name", "Elevation"], + }); + // RTC coordinate height (same term createFromIfcStoreys uses to place the + // plane), so a reconciled view sits at the right elevation. + let coordHeight = 0; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const coords = (await (model as any).getCoordinates?.()) as any; + if (Array.isArray(coords)) coordHeight = Number(coords[1]) || 0; + } catch { + /* no coordinates → assume 0 */ + } + for (const d of data) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nm = (d as any)?.Name; + if (!(nm && "value" in nm && nm.value != null)) continue; + const name = String(nm.value); + if (seenNames.has(name)) continue; // duplicate storey name → one view only + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const el = (d as any)?.Elevation; + const elevation = + el && "value" in el && el.value != null ? Number(el.value) : NaN; + // RECONCILE: createFromIfcStoreys SKIPS storeys whose Elevation value is + // missing, so they get no view and the level is unreachable. If we know + // the elevation, create the missing plan view here (same plane math as + // the library: normal (0,-1,0), height = elevation + coordHeight + offset). + if (!views.list.has(name) && Number.isFinite(elevation)) { + try { + const plane = new THREE.Plane( + new THREE.Vector3(0, -1, 0), + elevation + coordHeight + CREATION_OFFSET, + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (views as any).createFromPlane(plane, { id: name, world }); + } catch (e) { + console.warn("[plans-panel] could not create plan view for", name, e); + } + } + if (!views.list.has(name)) { + // Still no view (e.g. no elevation at all) → don't list a dead button. + console.warn( + "[plans-panel] level has no plan view, skipping:", + name, + "elevation:", + elevation, + ); + continue; + } + seenNames.add(name); + levels.push({ name, elevation }); + } + } + // Top floor first (NaN elevations sort to the bottom). + levels.sort((a, b) => (b.elevation || 0) - (a.elevation || 0)); + if (token !== rebuildToken) return; + update({ status: levels.length > 0 ? "ready" : "empty", levels, active: null }); + } catch (error) { + if (token !== rebuildToken) return; + console.warn("[plans-panel] failed to build floor plans", error); + update({ status: "empty", levels: [], active: null }); + } + }; + + const [panel, update] = BUI.Component.create( + (state) => { + const levelRow = (lvl: Level) => BUI.html` +
enterPlan(lvl.name)} + title=${lvl.name} + > + + ${lvl.name} + ${elevText(lvl.elevation)} +
+ `; + + // Filter levels by name + elevation text (the searchbar above the list). + const q = state.filter.trim().toLowerCase(); + const shown = q + ? state.levels.filter( + (l) => l.name.toLowerCase().includes(q) || elevText(l.elevation).toLowerCase().includes(q), + ) + : state.levels; + + return BUI.html` + + +
+
+ +
+ { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const v = String((e.target as any).value ?? ""); + if (searchTimer !== undefined) clearTimeout(searchTimer); + searchTimer = window.setTimeout(() => update({ filter: v }), 200); + }} + > + Back to 3D +
+ + ${state.active + ? BUI.html` +
+ View depth + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const v = Number((e.target as any).value); + if (!Number.isFinite(v)) return; + viewDepth = Math.max(0, v); + applyDepthAndCommit(); // applyViewDepth + refreshPlanSection + update + }} + > +
+ +
+ Cut height + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const v = Number((e.target as any).value); + if (!Number.isFinite(v) || !currentLevel) return; + cutOffsets.set(currentLevel, Math.max(0, v)); + applyCutOffset(currentLevel); + refreshPlanSection(); // the cut plane moved + update({ cutOffset: Math.max(0, v) }); + }} + > +
` + : null} +
+ ${state.status === "loading" + ? BUI.html`
Loading…
` + : state.status === "empty" + ? BUI.html`
No storeys found. Load a model with IFC building storeys.
` + : shown.length === 0 + ? BUI.html`
No levels match "${state.filter}".
` + : BUI.html`${shown.map(levelRow)}`} +
+
+
+
+ `; + }, + { status: "loading", levels: [], active: null, filter: "", viewDepth: VIEW_DEPTH_DEFAULT, cutOffset: CUT_OFFSET_DEFAULT }, + ); + + // ── Triggers ─────────────────────────────────────────────────── + fragments.core.onModelLoaded.add(() => rebuild()); + fragments.list.onItemDeleted.add(() => { + if (views.hasOpenViews) views.close(); + disposePlanSection(); // drop section edges tied to the now-stale views + restorePlanLook(); // anchor + postproduction style back to 3D + rebuild(); + }); + rebuild(); + + return panel; +}; diff --git a/src/cli/templates/app/src/setups/properties-panel.ts b/src/cli/templates/app/src/setups/properties-panel.ts new file mode 100644 index 0000000..0757484 --- /dev/null +++ b/src/cli/templates/app/src/setups/properties-panel.ts @@ -0,0 +1,1048 @@ +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; +import * as BUI from "@thatopen/ui"; +import { cardHeader } from "./card-header"; +import { toolPlaceholderUri } from "../assets/tool-placeholder"; + +// ── Category icons / labels (copied from model-tree.ts so the two stay in +// sync without a cross-import; same iconify/mdi names bim-icon takes). ── +const prettyCategory = (category: string) => { + const base = category.replace(/^IFC/i, ""); + if (!base) return category || ""; + return base.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase()); +}; +const CATEGORY_ICONS: Record = { + IFCPROJECT: "mdi:sitemap", + IFCSITE: "mdi:terrain", + IFCBUILDING: "mdi:office-building", + IFCBUILDINGSTOREY: "mdi:layers", + IFCSPACE: "mdi:floor-plan", + IFCZONE: "mdi:select-group", + IFCWALL: "mdi:wall", + IFCWALLSTANDARDCASE: "mdi:wall", + IFCCURTAINWALL: "mdi:wall", + IFCSLAB: "mdi:floor-plan", + IFCROOF: "mdi:home-roof", + IFCCOLUMN: "mdi:view-column", + IFCBEAM: "mdi:minus", + IFCMEMBER: "mdi:minus", + IFCPLATE: "mdi:rectangle-outline", + IFCFOOTING: "mdi:foundation", + IFCPILE: "mdi:format-vertical-align-bottom", + IFCSTAIR: "mdi:stairs", + IFCSTAIRFLIGHT: "mdi:stairs", + IFCRAMP: "mdi:slope-uphill", + IFCRAMPFLIGHT: "mdi:slope-uphill", + IFCRAILING: "mdi:fence", + IFCCOVERING: "mdi:texture-box", + IFCDOOR: "mdi:door", + IFCWINDOW: "mdi:window-closed-variant", + IFCOPENINGELEMENT: "mdi:vector-rectangle", + IFCFURNISHINGELEMENT: "mdi:sofa", + IFCFURNITURE: "mdi:sofa", + IFCSANITARYTERMINAL: "mdi:toilet", + IFCPIPESEGMENT: "mdi:pipe", + IFCFLOWSEGMENT: "mdi:pipe", + IFCDUCTSEGMENT: "mdi:pipe", + IFCPIPEFITTING: "mdi:pipe-disconnected", + IFCDUCTFITTING: "mdi:pipe-disconnected", + IFCCABLECARRIERSEGMENT: "mdi:pipe", + IFCFLOWTERMINAL: "mdi:water-pump", + IFCLIGHTFIXTURE: "mdi:lightbulb", + IFCOUTLET: "mdi:power-socket", + IFCFLOWCONTROLLER: "mdi:valve", + IFCBUILDINGELEMENTPROXY: "mdi:cube-outline", + IFCANNOTATION: "mdi:tag-outline", +}; +const iconFor = (category: string) => + CATEGORY_ICONS[(category || "").toUpperCase()] ?? "mdi:cube-outline"; + +/** + * Properties panel — VIRTUALIZED (windowed) like the model tree. Multi-selecting + * many elements stacks hundreds of attribute/pset rows; rendering them all as + * DOM over the WebGL canvas costs per-frame compositing. So we flatten the + * selected elements into a single uniform-height row list (element header, + * attribute rows, pset header, pset rows) and render only the rows in the scroll + * window, recycled on scroll. + * + * Trade-off for fixed-height windowing: values render on one line with ellipsis + * (full text on hover via `title`), rather than wrapping. + * + * Listens to the Highlighter "select" set (tree/viewport drive it), reads + * attributes + IFC psets via `getItemsData` (IsDefinedBy relation). Returns the + * panel element; the caller mounts it. + */ +interface PropertyRow { + name: string; + value: string; +} +interface PropertySet { + name: string; + props: PropertyRow[]; +} +interface ElementProps { + title: string; // Name, else Category, else #localId + attributes: PropertyRow[]; + psets: PropertySet[]; +} + +interface PanelState { + empty: boolean; + loading: boolean; + message: string; + note: string; // e.g. "Showing 40 of 120 selected" +} + +const EMPTY_MESSAGE = "Select an element to see its properties."; +const MAX_ELEMENTS = 40; // cap fetch+parse (a storey select can be hundreds) +const BUFFER = 6; +// Rows wrap (no ellipsis), so heights vary. The virtualizer starts from these +// per-type estimates and corrects each row to its real height after it renders. +const EST: Record = { + elh: 30, + psh: 28, + kv: 26, + kvvar: 26, // aggregated "Varies" property row (muted) + msg: 26, + sp: 10, + cat: 30, // category breakdown row (click-to-sub-select) + crumb: 28, // breadcrumb / back chip +}; + +// Multi-select aggregation: we fetch the `_category` attribute for the selected +// ids in the fragments WORKER (off the main thread) and tally them in a single +// bounded main-thread pass. Very large selections are SAMPLED — we fetch+tally +// at most AGG_CAP ids (with a "showing N of M" note) so the main-thread tally +// is always bounded and never blocks on a million-item synchronous loop. +const AGG_CAP = 30000; // max ids fetched + tallied for the category breakdown +const AGG_BATCH = 5000; // ids per worker getItemsData call (per model, batched) +// Property aggregation (shared-vs-varying) fetches FULL item data — attributes + +// IsDefinedBy psets, the same expensive shape single-select uses — so its sample +// cap is far smaller than the cheap _category-only breakdown. We fetch+parse at +// most PROP_AGG_CAP items (per-model batched) and tally them in one bounded pass. +const PROP_AGG_CAP = 300; // max items fetched + tallied for property aggregation +const PROP_AGG_BATCH = 100; // ids per worker getItemsData call (full-data path) +const PROP_VARIES_SHOW = 4; // ≤ this many distinct values → list them, else "N values" + +const ATTR_LABELS: Record = { + _category: "Category", + _localId: "LocalId", + _guid: "Guid", +}; + +const esc = (s: string) => + s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +// Parse one element's getItemsData result into attributes + psets. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const parseElement = (data: any, localId: number): ElementProps => { + const attributes: PropertyRow[] = []; + let name = ""; + let category = ""; + for (const [key, attr] of Object.entries(data ?? {})) { + if (Array.isArray(attr)) continue; + if (attr && typeof attr === "object" && "value" in attr) { + const value = (attr as { value: unknown }).value; + if (value === null || value === undefined || value === "") continue; + attributes.push({ name: ATTR_LABELS[key] ?? key, value: String(value) }); + if (key === "Name") name = String(value); + if (key === "_category") category = String(value); + } + } + const psets: PropertySet[] = []; + const rawPsets = data?.IsDefinedBy; + if (Array.isArray(rawPsets)) { + for (const pset of rawPsets) { + const psetName = pset?.Name; + const hasProperties = pset?.HasProperties; + if (!(psetName && "value" in psetName && Array.isArray(hasProperties))) continue; + const props: PropertyRow[] = []; + for (const prop of hasProperties) { + const pName = prop?.Name; + const nominal = prop?.NominalValue; + if (!(pName && "value" in pName && nominal && "value" in nominal)) continue; + if (pName.value == null || nominal.value == null) continue; + props.push({ name: String(pName.value), value: String(nominal.value) }); + } + psets.push({ name: String(psetName.value), props }); + } + } + const title = name || category || `#${localId}`; + return { title, attributes, psets }; +}; + +// One flat row of the windowed list. +type FlatRow = + | { t: "elh"; title: string } + | { t: "psh"; name: string } + | { t: "kv"; name: string; value: string } + | { t: "kvvar"; name: string; value: string } // muted "Varies" agg-property row + | { t: "msg"; text: string } + | { t: "sp" } + | { t: "crumb"; depth: number } // breadcrumb / back chip (multi-select) + | { t: "cat"; category: string; count: number }; // clickable category breakdown row + +// ── Multi-select aggregation model ───────────────────────────────── +// A per-model selection: modelId → Set. We aggregate by asking the +// fragments WORKER for each item's `_category` (minimal attribute fetch, off +// the main thread) and tallying the returned categories in one bounded pass. +// This is the foundation for property aggregation generally: swap/extend the +// fetched attribute(s) and the per-item tally to break down by any property. +type Aggregation = { + total: number; // total ids in the live selection + sampled: number; // ids actually fetched + tallied (≤ total, capped at AGG_CAP) + models: number; // distinct models in the set + cats: { category: string; count: number }[]; // sorted desc by count + // Property aggregation (shared-vs-varying), computed lazily on a SECOND, much + // smaller sample (PROP_AGG_CAP) via the expensive full-data fetch. Cached on + // the same fingerprint entry as the breakdown above. `undefined` = not yet + // requested/in flight; once set it stays on the cached Aggregation. + props?: PropAggregation; +}; + +// One aggregated property: shared across the whole sample (a single `value`) or +// VARYING (`varies` true, `distinct` distinct-value count, optional small sample +// of the values for a compact preview). +type AggProp = { + name: string; + varies: boolean; + value: string; // the shared value (when !varies) + distinct: number; // number of distinct non-empty values seen (when varies) + values?: string[]; // up to PROP_VARIES_SHOW distinct values, for a compact hint + present: number; // how many sampled items had this property at all +}; + +// Shared-vs-varying aggregation grouped like the single-select view: an +// attributes band, then one band per property-set name. +type PropAggregation = { + sampled: number; // items fetched + parsed for the property tally (≤ PROP_AGG_CAP) + total: number; // total ids in the live selection (for the "N of M" note) + attributes: AggProp[]; + psets: { name: string; props: AggProp[] }[]; +}; + +export const propertiesPanel = (components: OBC.Components) => { + const fragments = components.get(OBC.FragmentsManager); + const highlighter = components.get(OBF.Highlighter); + const selectName = highlighter.config.selectName; + + let elements: ElementProps[] = []; + let query = ""; + let flat: FlatRow[] = []; + + // ── Multi-select aggregation state ───────────────────────────────── + // `mode` switches the windowed list between the single-element property + // view ("props") and the aggregation/insights view ("agg"). + let mode: "props" | "agg" = "props"; + let agg: Aggregation | null = null; + // Non-null while the property aggregation is unavailable for a reason other + // than "still loading" (e.g. the full-data fetch failed) — shown in place of + // the "Aggregating properties…" placeholder. Cleared on each new selection. + let propAggError: string | null = null; + // Selection stack: each entry is the OBC.ModelIdMap that produced the view + // BELOW the current one, so "Back" restores the previous (wider) selection. + // Index-clicking a category pushes the current map and re-selects the subset. + // BOUNDED to SELSTACK_MAX so a runaway loop can never grow it without limit. + const SELSTACK_MAX = 32; + const selStack: OBC.ModelIdMap[] = []; + + // Aggregation result cache, keyed on a cheap fingerprint of the selection + // (per-model size + a sampled-id hash) → re-selecting the same set is instant. + // BOUNDED tiny LRU: only the current + a few recent results are retained so + // the cache can never grow without bound (a crash cause we are fixing). + const AGG_CACHE_MAX = 8; + const aggCache = new Map(); + const cacheGet = (key: string): Aggregation | undefined => { + const hit = aggCache.get(key); + if (hit) { + // Refresh LRU recency: re-insert so it becomes most-recently-used. + aggCache.delete(key); + aggCache.set(key, hit); + } + return hit; + }; + const cachePut = (key: string, value: Aggregation) => { + aggCache.delete(key); + aggCache.set(key, value); + while (aggCache.size > AGG_CACHE_MAX) { + const oldest = aggCache.keys().next().value; + if (oldest === undefined) break; + aggCache.delete(oldest); + } + }; + // Variable-height windowing: per-row measured heights + their prefix-sum + // offsets (offsets[i] = top of row i; offsets[flat.length] = total height). + let heights: number[] = []; + let offsets: number[] = [0]; + + // Virtualization DOM (created once). + const viewport = document.createElement("div"); + viewport.className = "prop-vp"; + const sizer = document.createElement("div"); + sizer.style.position = "relative"; + sizer.style.width = "100%"; + const content = document.createElement("div"); + content.style.position = "absolute"; + content.style.top = "0"; + content.style.left = "0"; + content.style.right = "0"; + sizer.appendChild(content); + viewport.appendChild(sizer); + + // ── Flatten the aggregation → uniform rows (category breakdown + + // shared-vs-varying property aggregation). READ-ONLY: no row here drives + // selection (cat rows render but, see content click handler, are inert). ── + const buildAggFlat = () => { + const out: FlatRow[] = []; + const a = agg; + if (!a) { + flat = out; + return; + } + const q = query.trim().toLowerCase(); + out.push({ t: "psh", name: "Category breakdown" }); + if (a.cats.length === 0) { + out.push({ t: "msg", text: "No categories found in selection." }); + } + // Search filters the category list (cheap; list is small). + for (const c of a.cats) { + if (q && !prettyCategory(c.category).toLowerCase().includes(q) && !c.category.toLowerCase().includes(q)) { + continue; + } + out.push({ t: "cat", category: c.category, count: c.count }); + } + flat = out; + }; + + // ── Flatten selected elements → uniform rows (with the search filter) ── + const buildFlat = () => { + if (mode === "agg") { + buildAggFlat(); + return; + } + const q = query.trim().toLowerCase(); + const match = (r: PropertyRow) => + !q || r.name.toLowerCase().includes(q) || r.value.toLowerCase().includes(q); + const out: FlatRow[] = []; + let first = true; + for (const el of elements) { + const attrs = q ? el.attributes.filter(match) : el.attributes; + const psets = q + ? el.psets.map((p) => ({ ...p, props: p.props.filter(match) })).filter((p) => p.props.length) + : el.psets; + if (q && attrs.length === 0 && psets.length === 0) continue; + if (!first) out.push({ t: "sp" }); + first = false; + out.push({ t: "elh", title: el.title }); + if (attrs.length === 0 && !q) out.push({ t: "msg", text: "No attributes." }); + for (const a of attrs) out.push({ t: "kv", name: a.name, value: a.value }); + for (const p of psets) { + out.push({ t: "sp" }); // gap above each pset header → groups its rows + out.push({ t: "psh", name: p.name }); + for (const pr of p.props) out.push({ t: "kv", name: pr.name, value: pr.value }); + } + } + if (elements.length > 0 && out.length === 0) { + out.push({ t: "msg", text: "No matching properties." }); + } + flat = out; + }; + + // ── Render the window ────────────────────────────────────────── + const rowHtml = (r: FlatRow) => { + switch (r.t) { + case "elh": + return `
${esc(r.title)}
`; + case "psh": + return `
${esc(r.name)}
`; + case "kv": + return `
${esc(r.name)}${esc(r.value)}
`; + case "kvvar": + return `
${esc(r.name)}${esc(r.value)}
`; + case "msg": + return `
${esc(r.text)}
`; + case "sp": + return `
`; + case "crumb": + return `
Back to previous selection${r.depth > 1 ? ` (${r.depth} levels)` : ""}
`; + case "cat": + return ( + `
` + + `` + + `${esc(prettyCategory(r.category) || r.category)}` + + `${r.count.toLocaleString()}` + + `
` + ); + } + }; + + // Rebuild the prefix-sum offsets from row `from` onward. + const recomputeOffsets = (from = 0) => { + if (from <= 0) { + offsets[0] = 0; + from = 0; + } + for (let i = from; i < flat.length; i++) offsets[i + 1] = offsets[i] + heights[i]; + offsets.length = flat.length + 1; + }; + + // Largest row index whose top offset is ≤ y (binary search). + const rowAt = (y: number) => { + let lo = 0; + let hi = flat.length - 1; + let res = 0; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (offsets[mid] <= y) { + res = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return res; + }; + + // After a window renders, correct the real (post-wrap) height of rows that were + // just CREATED this pass (reused rows keep their already-measured height, so we + // never re-measure them). If any height changed, refresh offsets + total so + // scrolling and the scrollbar stay accurate. Only rows ≥ start change here, so + // the current window's top offset never moves (no scroll jump). + const measureNew = (newIdx: number[]) => { + let changedFrom = -1; + for (const i of newIdx) { + const el = mounted.get(i); + if (!el) continue; + const h = el.offsetHeight; + if (h && h !== heights[i]) { + heights[i] = h; + if (changedFrom < 0 || i < changedFrom) changedFrom = i; + } + } + if (changedFrom >= 0) { + recomputeOffsets(changedFrom); + sizer.style.height = `${offsets[flat.length]}px`; + } + }; + + // Build one row element from its HTML (entering rows get the exact same markup + // the old innerHTML path produced — including each row's ). + const buildRow = (i: number): HTMLElement => { + const tmp = document.createElement("div"); + tmp.innerHTML = rowHtml(flat[i]); + return tmp.firstElementChild as HTMLElement; + }; + + // Index→element recycler (variable height). A row that stays within the window + // keeps its exact DOM element (and its already-rendered icon + measured height) + // across scroll, so icons are never recreated → no blink. Only rows ENTERING + // the window are built (and measured); rows LEAVING are removed. A forced + // rebuild (new selection / search / data change) clears this map. + const mounted = new Map(); + + let lastStart = -1; + let lastEnd = -1; + const render = (force = false) => { + const total = flat.length; + sizer.style.height = `${offsets[total]}px`; + const top = viewport.scrollTop; + const vh = viewport.clientHeight || 300; + const start = Math.max(0, rowAt(top) - BUFFER); + const end = Math.min(total, rowAt(top + vh) + BUFFER + 1); + if (!force && start === lastStart && end === lastEnd) return; + // A forced rebuild means flat[]/heights[] were just rebuilt, so any mounted + // element is stale — drop them all and rebuild the window once. This only + // happens off the scroll path, so the one-time rebuild is unnoticeable; + // plain scrolling never forces and so always reuses existing rows. + if (force) { + mounted.clear(); + content.textContent = ""; + } + lastStart = start; + lastEnd = end; + content.style.transform = `translateY(${offsets[start]}px)`; + // Remove rows that scrolled out of [start, end). + for (const [i, el] of mounted) { + if (i < start || i >= end) { + el.remove(); + mounted.delete(i); + } + } + // Insert entering rows in ascending DOM order, splicing each before the next + // already-mounted element. Track which indices are new so only those get + // measured (reused rows keep their measured height). + const newIdx: number[] = []; + let cursor = content.firstElementChild as HTMLElement | null; + for (let i = start; i < end; i++) { + const existing = mounted.get(i); + if (existing) { + cursor = existing.nextElementSibling as HTMLElement | null; + continue; + } + const el = buildRow(i); + content.insertBefore(el, cursor); + mounted.set(i, el); + newIdx.push(i); + } + if (newIdx.length) measureNew(newIdx); + }; + + const refreshList = () => { + buildFlat(); + heights = flat.map((r) => EST[r.t]); + offsets = new Array(flat.length + 1); + recomputeOffsets(0); + lastStart = lastEnd = -1; + render(true); + }; + + let scrollRaf = 0; + viewport.addEventListener("scroll", () => { + if (scrollRaf) return; + scrollRaf = requestAnimationFrame(() => { + scrollRaf = 0; + render(); + }); + }); + new ResizeObserver(() => render()).observe(viewport); + + // ── Panel chrome (BUI) — windowed viewport mounted into the host ── + let searchTimer: number | undefined; + const [panel, update] = BUI.Component.create( + (state) => { + const onHostCreated = (el?: Element) => { + if (!el || el.contains(viewport)) return; + el.appendChild(viewport); + render(true); + }; + return BUI.html` + + +
+ ${cardHeader("mdi:information-outline", "Properties", "1.1rem")} +
+ ${state.empty + ? BUI.html`
+ + ${state.message} +
` + : BUI.html` + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const v = String((e.target as any).value ?? ""); + if (searchTimer !== undefined) clearTimeout(searchTimer); + searchTimer = window.setTimeout(() => { + query = v; + viewport.scrollTop = 0; + refreshList(); + }, 200); + }} + > +
+ ${state.note + ? BUI.html`
${state.note}
` + : null} + ${state.loading + ? BUI.html`
Loading…
` + : null} +
`} +
+
+
+ `; + }, + { empty: true, loading: false, message: EMPTY_MESSAGE, note: "" }, + ); + + // ── Multi-select aggregation engine ──────────────────────────── + let selToken = 0; + // Counter of panel-initiated Highlighter drives that are still in flight. + // It is INCREMENTED immediately before a highlightByID call and DECREMENTED + // in a `finally`, so it can never be stranded "true" if the call early-returns + // (highlighter disabled) or throws. The next onHighlight echo treats the + // selection as panel-initiated iff this counter is > 0. No persistent boolean + // flag exists that a failed call could leave permanently set. + let pendingInternalSelects = 0; + // Drive the Highlighter from the panel (sub-select / back) with a bulletproof + // guard: the in-flight counter is raised around the call and always lowered, + // even on early-return or throw. Returns whether the drive actually changed + // the highlight (so the caller can roll back its stack push if it didn't). + const driveHighlight = async (map: OBC.ModelIdMap): Promise => { + pendingInternalSelects++; + try { + await highlighter.highlightByID(selectName, map, true, false); + } finally { + // If highlightByID early-returned/threw without emitting an onHighlight + // echo, the echo handler never decremented us — settle on next microtask + // so a real synchronous echo (which decrements) wins first, then clamp. + Promise.resolve().then(() => { + if (pendingInternalSelects > 0) pendingInternalSelects--; + }); + } + }; + + // Cheap fingerprint: per-model size + a sampled-id xor hash. Two different + // selections of the same sizes hashing equal is astronomically unlikely for + // the sampled stride; a collision only re-shows a cached (correct-shape) + // breakdown, never corrupts the live Highlighter selection. + const fingerprint = (map: OBC.ModelIdMap) => { + const parts: string[] = []; + for (const modelId of Object.keys(map).sort()) { + const set = map[modelId]; + let h = set.size >>> 0; + let i = 0; + const stride = Math.max(1, Math.floor(set.size / 64)); // ≤64 samples + for (const id of set) { + if (i % stride === 0) h = (h * 31 + id) >>> 0; + i++; + } + parts.push(`${modelId}:${set.size}:${h}`); + } + return parts.join("|"); + }; + + // Aggregate a selection into a category breakdown by fetching each item's + // `_category` in the fragments WORKER (model.getItemsData runs off the main + // thread) and doing ONE bounded tally of the returned values. Very large + // selections are SAMPLED: at most AGG_CAP ids are fetched + tallied (per-model + // batched calls), with `sampled < total` surfaced to the UI. Bails (returns + // null) if `token` is stale — a superseded selection never schedules more + // work. Latency is fine; the only main-thread loop is the tally over results. + const buildAggregation = async ( + map: OBC.ModelIdMap, + token: number, + ): Promise => { + const fp = fingerprint(map); + const hit = cacheGet(fp); + if (hit) return hit; + const entries = Object.entries(map).filter(([, s]) => s.size > 0); + const total = entries.reduce((n, [, s]) => n + s.size, 0); + const counts = new Map(); + let sampled = 0; + let budget = AGG_CAP; // global cap across all models in the selection + + for (const [modelId, set] of entries) { + if (budget <= 0) break; + const model = fragments.list.get(modelId); + if (!model) continue; + // Take at most `budget` ids from this model's set (sampling cap). + const ids: number[] = []; + for (const id of set) { + if (ids.length >= budget) break; + ids.push(id); + } + budget -= ids.length; + // Fetch `_category` in the worker, batched, so no single call is huge. + for (let i = 0; i < ids.length; i += AGG_BATCH) { + const batch = ids.slice(i, i + AGG_BATCH); + const datas = await model.getItemsData(batch, { + attributesDefault: false, + attributes: ["_category"], + }); + if (token !== selToken) return null; // superseded → bail, no more work + // Single bounded main-thread pass over the returned results. + for (const data of datas) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const attr = (data as any)?._category; + const cat = + attr && typeof attr === "object" && "value" in attr && attr.value != null + ? String(attr.value) + : "Unknown"; + counts.set(cat, (counts.get(cat) ?? 0) + 1); + sampled++; + } + } + } + const cats = [...counts.entries()] + .map(([category, count]) => ({ category, count })) + .sort((a, b) => b.count - a.count); + const result: Aggregation = { total, sampled, models: entries.length, cats }; + cachePut(fp, result); + return result; + }; + + // Build the shared-vs-varying PROPERTY aggregation for a selection. This is the + // EXPENSIVE path: it fetches FULL item data (attributes + IsDefinedBy psets, + // the same shape single-select uses) in the fragments WORKER, so it is capped + // hard at PROP_AGG_CAP items (per-model batched) and tallied in ONE bounded + // pass over the parsed sample. It mutates+caches `.props` onto the SAME + // fingerprint entry as the category breakdown (extending that cache slot), so a + // re-selection is instant. Bails (returns null) if `token` is stale. + const buildPropAggregation = async ( + map: OBC.ModelIdMap, + token: number, + ): Promise => { + const fp = fingerprint(map); + const cached = cacheGet(fp); + if (cached?.props) return cached.props; // already computed for this selection + + const entries = Object.entries(map).filter(([, s]) => s.size > 0); + const total = entries.reduce((n, [, s]) => n + s.size, 0); + + // Tally accumulators. For each property key we keep the first value seen, a + // `varies` flag, a bounded set of distinct values (capped so it can never + // grow large), and how many sampled items had it. ONE entry per distinct + // property name — never per item — so memory is bounded by the schema, not + // the selection size. + type Tally = { first: string; varies: boolean; distinct: Set; present: number }; + const newTally = (): Tally => ({ first: "", varies: false, distinct: new Set(), present: 0 }); + const DISTINCT_CAP = 64; // stop growing a distinct-set past this (we only need ≤ PROP_VARIES_SHOW + a count) + // Map insertion order gives stable first-seen ordering for free, so we read + // names back off the Maps directly — no separate order arrays needed. + const attrTally = new Map(); + const psetTally = new Map>(); // psetName → propName → Tally + + const record = (bucket: Map, name: string, value: string) => { + let t = bucket.get(name); + if (!t) { + t = newTally(); + t.first = value; + bucket.set(name, t); + } + t.present++; + if (value !== t.first) t.varies = true; + if (t.distinct.size < DISTINCT_CAP) t.distinct.add(value); + }; + + let sampled = 0; + let budget = PROP_AGG_CAP; // global cap across all models + for (const [modelId, set] of entries) { + if (budget <= 0) break; + const model = fragments.list.get(modelId); + if (!model) continue; + const ids: number[] = []; + for (const id of set) { + if (ids.length >= budget) break; + ids.push(id); + } + budget -= ids.length; + for (let i = 0; i < ids.length; i += PROP_AGG_BATCH) { + const batch = ids.slice(i, i + PROP_AGG_BATCH); + // SAME expensive fetch shape as single-select (see onHighlight props path). + const datas = await model.getItemsData(batch, { + attributesDefault: true, + relations: { + IsDefinedBy: { + attributes: true, + relations: { + HasProperties: { attributes: true, relations: false }, + }, + }, + DefinesOccurrence: { attributes: false, relations: false }, + }, + }); + if (token !== selToken) return null; // superseded → bail, no more work + // ONE bounded pass: parse each item (reusing parseElement) and fold its + // attributes + pset props into the per-name tallies. + for (let j = 0; j < datas.length; j++) { + const el = parseElement(datas[j], batch[j]); + for (const a of el.attributes) record(attrTally, a.name, a.value); + for (const ps of el.psets) { + let bucket = psetTally.get(ps.name); + if (!bucket) { + bucket = new Map(); + psetTally.set(ps.name, bucket); + } + for (const pr of ps.props) record(bucket, pr.name, pr.value); + } + } + sampled += datas.length; + } + } + + const finalize = (t: Tally, name: string): AggProp => { + const values = [...t.distinct]; + return { + name, + varies: t.varies, + value: t.first, + distinct: t.distinct.size, + values: t.varies && values.length <= PROP_VARIES_SHOW ? values : undefined, + present: t.present, + }; + }; + const attributes = [...attrTally.entries()].map(([name, t]) => finalize(t, name)); + const psets = [...psetTally.entries()].map(([psName, bucket]) => ({ + name: psName, + props: [...bucket.entries()].map(([n, t]) => finalize(t, n)), + })); + + const propAgg: PropAggregation = { sampled, total, attributes, psets }; + // Extend the SAME cached Aggregation entry (bounded by the existing LRU). + const slot = cacheGet(fp); + if (slot) slot.props = propAgg; + return propAgg; + }; + + // Sub-select all ids of one category WITHIN the current selection. We resolve + // the per-category id set the same worker way: fetch `_category` for the live + // selection (batched, capped) and keep the ids whose category matches. + const subSelectCategory = async (category: string) => { + const current = lastMap; // the live selection we're narrowing + if (!current) return; + const token = selToken; // bail if the selection changes under us + const subset: OBC.ModelIdMap = {}; + let budget = AGG_CAP; + for (const [modelId, set] of Object.entries(current)) { + if (budget <= 0) break; + const model = fragments.list.get(modelId); + if (!model) continue; + const all: number[] = []; + for (const id of set) { + if (all.length >= budget) break; + all.push(id); + } + budget -= all.length; + const ids = new Set(); + for (let i = 0; i < all.length; i += AGG_BATCH) { + const batch = all.slice(i, i + AGG_BATCH); + const datas = await model.getItemsData(batch, { + attributesDefault: false, + attributes: ["_category"], + }); + if (token !== selToken) return; // superseded → bail + datas.forEach((data, j) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const attr = (data as any)?._category; + const value = + attr && typeof attr === "object" && "value" in attr ? String(attr.value) : "Unknown"; + if (value === category) ids.add(batch[j]); + }); + } + if (ids.size > 0) subset[modelId] = ids; + } + if (token !== selToken) return; + if (Object.keys(subset).length === 0) return; + if (selStack.length >= SELSTACK_MAX) selStack.shift(); // bound depth + selStack.push(current); // remember the wider set for "Back" + await driveHighlight(subset); + }; + + // Restore the previous (wider) selection from the breadcrumb stack. + const goBack = async () => { + const prev = selStack.pop(); + if (!prev) return; + await driveHighlight(prev); + }; + + // Delegated click on the windowed rows (category sub-select / back chip). + content.addEventListener("click", (e) => { + if (mode !== "agg") return; + const target = e.target as HTMLElement; + const row = target.closest("[data-act]"); + if (!row) return; + const act = row.dataset.act; + if (act === "back") void goBack(); + else if (act === "cat" && row.dataset.cat) void subSelectCategory(row.dataset.cat); + }); + + // ── Selection (one or many) → flat rows ──────────────────────── + // Remembers the live selection map (for sub-select intersection). + let lastMap: OBC.ModelIdMap | null = null; + highlighter.events.select.onHighlight.add(async (modelIdMap: OBC.ModelIdMap) => { + const token = ++selToken; + const total = Object.values(modelIdMap).reduce((n, set) => n + set.size, 0); + if (total === 0) return; + query = ""; + lastMap = modelIdMap; + // Multi-select → aggregation/insights view. The category breakdown is built + // by fetching `_category` in the fragments WORKER (off the main thread) and + // tallying the results in one bounded pass — latency is fine, the main + // thread never blocks. A "Aggregating…" state shows while it runs. + if (total > 1) { + // Was this onHighlight echo produced by the panel itself (sub-select / + // back)? Read+consume one pending internal drive. The counter is raised + // in driveHighlight around the call and always lowered in a finally, so it + // can never be stranded; an external selection sees it at 0. + const wasInternal = pendingInternalSelects > 0; + if (wasInternal) pendingInternalSelects--; + // External selection (tree/viewport) starts a fresh breadcrumb stack; + // our own sub-select/back keeps the stack it just edited. + if (!wasInternal) selStack.length = 0; + mode = "agg"; + elements = []; + agg = null; + propAggError = null; + update({ + empty: false, + loading: true, + message: "", + note: `${total.toLocaleString()} elements selected — aggregating…`, + }); + try { + const result = await buildAggregation(modelIdMap, token); + if (token !== selToken) return; + if (!result) return; + agg = result; + const modelNote = result.models > 1 ? ` across ${result.models} models` : ""; + const sampleNote = + result.sampled < result.total + ? ` · showing ${result.sampled.toLocaleString()} of ${result.total.toLocaleString()}` + : ""; + update({ + empty: false, + loading: false, + message: "", + note: `${result.total.toLocaleString()} elements selected${modelNote} · ${result.cats.length} categor${result.cats.length === 1 ? "y" : "ies"}${sampleNote}`, + }); + viewport.scrollTop = 0; + refreshList(); // shows the read-only category breakdown + } catch (error) { + if (token !== selToken) return; + console.warn("[properties-panel] aggregation failed", error); + agg = null; + update({ empty: true, loading: false, message: "Could not aggregate selection.", note: "" }); + } + return; + } + // Single-select → unchanged full property view. A category sub-select that + // narrows down to exactly one element keeps the breadcrumb stack (so "Back" + // still works from the property view); an external single-click resets it. + mode = "props"; + const wasInternalSingle = pendingInternalSelects > 0; + if (wasInternalSingle) pendingInternalSelects--; + if (!wasInternalSingle) selStack.length = 0; + update({ empty: false, loading: true, note: "" }); + try { + const parsed: ElementProps[] = []; + let budget = MAX_ELEMENTS; + for (const [modelId, set] of Object.entries(modelIdMap)) { + if (budget <= 0) break; + const model = fragments.list.get(modelId); + if (!model) continue; + const ids = [...set].slice(0, budget); + budget -= ids.length; + const datas = await model.getItemsData(ids, { + attributesDefault: true, + relations: { + // Expand ONLY the property set's HasProperties (what the panel + // shows), not the pset's whole relation graph. `relations: true` + // would also pull the pset's inverse back-reference to every item + // that shares it (thousands of siblings for a repeated family) — + // the dominant cost. Relies on the fragments behaviour where an + // explicit nested relations object expands only the relations it names. + IsDefinedBy: { + attributes: true, + relations: { + HasProperties: { attributes: true, relations: false }, + }, + }, + DefinesOccurrence: { attributes: false, relations: false }, + }, + }); + datas.forEach((data, i) => parsed.push(parseElement(data, ids[i]))); + } + if (token !== selToken) return; + elements = parsed; + const note = + total > parsed.length + ? `Showing ${parsed.length} of ${total} selected` + : total > 1 + ? `${total} elements selected` + : ""; + update({ empty: parsed.length === 0, loading: false, note }); + viewport.scrollTop = 0; + refreshList(); + } catch (error) { + if (token !== selToken) return; + console.warn("[properties-panel] failed to read item data", error); + elements = []; + update({ empty: true, loading: false, message: "Could not read properties." }); + } + }); + + highlighter.events.select.onClear.add(() => { + selToken++; + elements = []; + query = ""; + mode = "props"; + agg = null; + propAggError = null; + lastMap = null; + selStack.length = 0; + pendingInternalSelects = 0; + refreshList(); + update({ empty: true, loading: false, message: EMPTY_MESSAGE, note: "" }); + }); + + // A model unload/reload invalidates any cached aggregations (localIds may be + // reassigned). Drop the cache; it rebuilds lazily on the next selection. + fragments.list.onItemDeleted.add(() => { + aggCache.clear(); + }); + + return panel; +}; diff --git a/src/cli/templates/app/src/setups/reality-capture-viewer.ts b/src/cli/templates/app/src/setups/reality-capture-viewer.ts new file mode 100644 index 0000000..794c027 --- /dev/null +++ b/src/cli/templates/app/src/setups/reality-capture-viewer.ts @@ -0,0 +1,1383 @@ +import * as OBC from "@thatopen/components"; +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { TransformControls } from "three/examples/jsm/controls/TransformControls.js"; +import { TilesRenderer } from "3d-tiles-renderer"; +import { SparkRenderer } from "@sparkjsdev/spark"; + +import { HiddenTilesPlugin } from "./reality-capture/lib/hidden-tiles-plugin"; +import { PointTilePlugin, setPointSize as setPointSizePx } from "./reality-capture/lib/point-tile-plugin"; +import { SplatTilePlugin } from "./reality-capture/lib/splat-tile-plugin"; +import { frame } from "./reality-capture/lib/render-frame"; + +// Reality-capture (standard loose 3D Tiles) viewer for bim-viewer. +// +// SELF-CONTAINED + ISOLATED. This renders a standard 3D Tiles dataset (point +// clouds or Gaussian splats) in its OWN three.js WebGLRenderer + Scene + camera +// + orbit loop, mounted in an overlay over the app — it deliberately does NOT +// touch bim-viewer's OBC world. That world runs a deferred PEN/MRT +// postproduction pipeline that points/splats can't render through, so they get +// an isolated forward renderer instead. +// +// STORAGE MODEL: the main visible file is a standard `tileset.json`; each tile's +// `content.uri` is a relative path (`tiles/n…_d….spz` for splats, `…_d….pnt` +// for points), and the tileset carries a top-level `hiddenFiles` map of +// `content.uri -> hiddenFileId`. Tiles live as platform HIDDEN files and are +// streamed ONE FILE PER TILE via `client.downloadHiddenFile(id)` through the +// HiddenTilesPlugin's fetchData hook. The Q1 LRU/count-budget governor bounds +// how many tiles are resident/loading at once. +// +// The render core (Point/Splat plugins, decode worker, render-frame) is the +// platform_cde tiles-viewer prototype copied into ./reality-capture/lib. Only +// this outer controller is new: it mounts an overlay (or a caller-supplied +// container) instead of a CDEManager dialog, and takes the file bytes from the +// platform client. +// +// CLIENT WIRING: bim-viewer creates its PlatformClient in main.ts via +// `PlatformClient.fromPlatformContext()` and hands it to the panels (e.g. +// `filesPanel(components, client)`). There is no clean singleton/OBC component +// holding it, so this factory takes the client too — pass it in, or call +// `controller.setClient(client)` before `loadThreeTZ`. See the report for the +// exact wiring point the files-panel owner must hook. + +const BG = new THREE.Color(0x11151a); + +type Format = "points" | "splats"; + +// --------------------------------------------------------------------------- +// NAV-PERF tuning (ported from the reality-capture-viewer prototype's main.ts). +// Spark re-sorts EVERY resident splat each frame, so smoothness is bounded by +// the splat COUNT actually drawn — not resolution. Three levers, in order of +// impact: +// 1. LRU cache so non-visible tiles get evicted -> Spark's global buffer only +// holds the visible cut (the 4.6 -> 70 fps win in the prototype). +// 2. A count-budget governor: while MOVING, raise errorTarget to cap the +// visible primitive count to a motion budget; while PARKED, step +// errorTarget DOWN toward a floor to refine coarse -> fine. +// 3. An idle snapshot: once parked + refined + tile queues empty, render the +// heavy scene ONCE into a render target and just BLIT that texture each +// frame (cheap), capping the live set back to "light" so the next motion +// is instant. On camera move, drop the frozen texture and render live. +// --------------------------------------------------------------------------- + +// 100% slider reference unused here; budgets are fixed to the prototype values. +const SPLAT_MOTION_BUDGET = 200_000; // splat-count cap while moving +const POINT_MOTION_BUDGET = 1_500_000; // point-count cap while moving (points cheaper than splats) +const IDLE_POINT_BUDGET = 25_000_000; // cap when parked (bounds the snapshot capture) + +// errorTarget clamps (mirror the prototype's scaleET clamp + refine floors). +const ET_MIN = 8; +const ET_MAX = 8192; +const SPLAT_REFINE_FLOOR = 8; +const POINT_REFINE_FLOOR = 14; +const SPLAT_REFINE_STEP = 0.78; // gentle (Spark rebuilds its global sort on each add) +const POINT_REFINE_STEP = 0.55; // bigger jumps -> reads as "all at once" + +// errorTarget the governor settles on while moving — remembered so we can snap +// the live set back to it after taking the idle snapshot. +const SPLAT_MOTION_ET = 48; +const POINT_MOTION_ET = 16; + +// PHASE 1 occlusion pass depth convention. W3's deferred depth is REVERSED-Z +// (DEPTH_COMPONENT32F, far=0/near=1). Per W3's converged hook (d2528f06): Spark's +// draw doesn't ride three's auto depthFunc inversion, so we set GreaterEqualDepth +// on the splat materials OURSELVES to match reversed-Z. Set this false ONLY if a +// live test shows occlusion INVERTED (splats hidden where they should show), i.e. +// three did auto-invert after all → default LessEqualDepth would be correct. +const OCCLUSION_USE_GEQUAL = true; + +// Apply the reversed-Z depth state to every resident SplatMesh in a scene so a +// separate splat render call (the deferred occlusion pass OR the postpro-off +// fallback) occludes correctly. depthWrite stays off (never touch the borrowed +// BIM depth). Cheap per-call property assigns. +function applySplatDepthState(scene: THREE.Object3D) { + if (!OCCLUSION_USE_GEQUAL) return; + scene.traverse((o: any) => { + const m = o.material; + if (m && typeof o.opacity === "number" && o.userData?.splatCount) { + m.depthFunc = THREE.GreaterEqualDepth; + m.depthWrite = false; + } + }); +} + +// Set tiles.lruCache min/max sizes so non-visible tiles are evicted and Spark's +// global buffer only holds the visible cut. Mirrors prototype configureCache(). +function configureCache(t: TilesRenderer, maxItems: number, maxMB: number) { + const lru = (t as any).lruCache; + if (!lru) return; + lru.maxSize = maxItems; + lru.minSize = Math.floor(maxItems * 0.66); + lru.maxBytesSize = maxMB * 1024 * 1024; + lru.minBytesSize = Math.floor(maxMB * 0.66) * 1024 * 1024; +} + +// Tile streaming concurrency: 1 while moving (spread the per-tile rebuild spikes +// for smoothness), higher while parked (burst-load the refine in one go). Points +// have no Spark global rebuild, so they can stream more aggressively. +function setSplatJobs(t: TilesRenderer, n: number) { + try { + (t as any).parseQueue.maxJobs = n; + (t as any).downloadQueue.maxJobs = Math.max(n, 4); + } catch { + /* queues not present yet */ + } +} + +// Are both tile queues drained? (snapshot gate) +function queuesEmpty(t: TilesRenderer): boolean { + const dq = (t as any).downloadQueue?.items?.length || 0; + const pq = (t as any).parseQueue?.items?.length || 0; + return dq + pq === 0; +} + +// Visible-primitive counters (mirror the prototype). Splats sum userData.splatCount; +// points sum the position-attribute vertex count over visible THREE.Points. +function countSplats(group: THREE.Object3D): number { + let splats = 0; + group.traverse((o: any) => { + if (o.visible && o.userData?.splatCount) splats += o.userData.splatCount; + }); + return splats; +} +function countPoints(group: THREE.Object3D): number { + let pts = 0; + group.traverse((o: any) => { + if (o.visible && o instanceof THREE.Points) { + pts += (o.geometry.getAttribute("position") as THREE.BufferAttribute).count; + } + }); + return pts; +} + +// Minimal structural type for the platform client: download the tileset main +// file by id (downloadFile) + each tile blob by hidden-file id +// (downloadHiddenFile). PlatformClient / EngineServicesClient from +// "@thatopen/services" both satisfy this. +export interface ThreeTZClient { + downloadFile(fileId: string, params?: any): Promise; + downloadHiddenFile(hiddenId: string): Promise; +} + +export interface RealityCaptureController { + /** Download a tileset.json by file id and render it in an isolated overlay (or `container`). */ + loadThreeTZ(fileId: string, container?: HTMLElement): Promise; + /** + * PHASE 0 co-located mode: download a tileset.json by id and render it INSIDE + * the bim-viewer OBC world scene, driven by the world camera, so the splats / + * point cloud sit in the SAME 3D space as the BIM model. Returns when the + * tileset has begun streaming. See `loadIntoWorld` for the occlusion model. + */ + loadIntoWorld(fileId: string, opts?: LoadIntoWorldOpts): Promise; + /** + * Set the alignment transform of a co-located dataset (manual registration). + * `matrix` maps dataset-local space → BIM world space. `fileId` selects the + * dataset (default: the active one). Persist/restore to remember an alignment. + */ + setTransform(matrix: THREE.Matrix4, fileId?: string): void; + /** Current alignment transform of a dataset (default active), or null. */ + getTransform(fileId?: string): THREE.Matrix4 | null; + /** Show/hide the interactive align gizmo (attached to the active dataset). */ + showGizmo(on: boolean): void; + /** Set the align gizmo mode. */ + setGizmoMode(mode: "translate" | "rotate" | "scale"): void; + /** Make a loaded dataset the active one (gizmo target). */ + setActiveDataset(fileId: string): void; + /** Unload one co-located dataset (tears down the world view if it was last). */ + removeDataset(fileId: string): void; + /** Ids of all currently co-located datasets. */ + listDatasets(): string[]; + /** Show/hide a co-located dataset without unloading it (default active). */ + setDatasetVisible(on: boolean, fileId?: string): void; + /** Is a co-located dataset visible? (default active; false if none). */ + isDatasetVisible(fileId?: string): boolean; + /** Point size in px (point clouds; global to all point datasets). */ + setPointSize(px: number): void; + /** Global splat opacity 0..1 for a dataset (default active). */ + setSplatOpacity(opacity: number, fileId?: string): void; + /** Tune the governor's while-moving primitive budget for a dataset (default active). */ + setMotionBudget(count: number, fileId?: string): void; + /** Provide the platform client if it wasn't passed to the factory. */ + setClient(client: ThreeTZClient): void; + /** Stop the loop, dispose everything, remove the overlay / co-located group. Idempotent. */ + clear(): void; +} + +export interface LoadIntoWorldOpts { + /** + * Initial alignment transform (e.g. restored from app-data). If omitted, an + * auto-fit is applied on first load: the dataset is centred at the BIM world + * origin so it lands roughly where the model is, ready for manual nudging. + */ + transform?: THREE.Matrix4; + /** + * Keep the deferred postproduction pipeline ON. Default false → we switch the + * world to a forward render while the dataset is shown, which gives correct + * splat-vs-BIM occlusion FOR FREE (three draws opaque BIM first writing depth, + * then Spark's splats with depthTest:true are clipped behind it). With + * postproduction ON, splats can't go through the capture pass, so they'd draw + * over the BIM (no occlusion) until the deferred depth target is exposed (W1/W3). + */ + keepPostproduction?: boolean; + /** + * Show the interactive align gizmo on load (default true). Drag to register the + * dataset against the BIM model; W/E/R switch translate/rotate/scale. + */ + gizmo?: boolean; + /** + * Called whenever the user finishes moving the align gizmo, with the new + * dataset-local → world transform. Persist it (e.g. into app-data) and feed it + * back as `opts.transform` next time to remember the alignment. + */ + onTransformChange?: (matrix: THREE.Matrix4) => void; +} + +// Recursively collect every content URI referenced by a tileset (root + children). +function collectContentURIs(node: any, out: string[]) { + if (!node || typeof node !== "object") return; + const c = node.content; + if (c) { + const uri = c.uri ?? c.url; + if (typeof uri === "string") out.push(uri); + } + if (Array.isArray(node.children)) { + for (const child of node.children) collectContentURIs(child, out); + } +} + +function detectFormat(uris: string[]): Format { + for (const uri of uris) { + const u = uri.toLowerCase(); + if (u.includes(".spz")) return "splats"; + if (u.includes(".pnt")) return "points"; + } + return "points"; +} + +/** + * Build an isolated 3D Tiles viewer controller for bim-viewer. + * + * @param _components the engine components (kept for parity/future wiring; the + * overlay is intentionally independent of the OBC world's renderer). + * @param client OPTIONAL platform client (PlatformClient/EngineServicesClient). + * If omitted, call `controller.setClient(client)` before `loadThreeTZ`. + */ +export function realityCaptureViewer( + _components: OBC.Components, + client?: ThreeTZClient, +): RealityCaptureController { + let activeClient: ThreeTZClient | undefined = client; + + // Per-session disposables (one tileset at a time). clear() tears these down. + interface Session { + overlay: HTMLElement; + ownsOverlay: boolean; // true => we created the fullscreen overlay and must remove it + renderer: THREE.WebGLRenderer; + controls: OrbitControls; + tiles: TilesRenderer | null; + sparkRenderer: any; + onResize: () => void; + disposed: boolean; + // idle-snapshot disposables (created lazily on first load) + snapRT: THREE.WebGLRenderTarget | null; + blitScene: THREE.Scene | null; + blitQuad: THREE.Mesh | null; + blitMat: THREE.ShaderMaterial | null; + blitGeom: THREE.BufferGeometry | null; + } + let session: Session | null = null; + + // PHASE 0 co-located mode (it borrows the OBC world's renderer/scene/camera + // instead of owning them). MULTIPLE datasets can be loaded into the BIM world + // at once — each is a `WorldDataset` in `worldDatasets`. A single shared + // `WorldCtx` holds the world handles, the ONE align gizmo (attached to the + // ACTIVE dataset), the camera-controls listeners, and the shared rAF tick that + // updates every dataset. The ctx is created lazily on the first load and torn + // down (restoring postproduction) when the last dataset is removed. + interface WorldDataset { + id: string; + root: THREE.Group; // alignment-transform target (dataset-local → BIM world) + splatContainer: THREE.Group; + pointContainer: THREE.Group; + tiles: TilesRenderer | null; + spark: any; + format: Format; + splatBudget: number; + pointBudget: number; + splatOpacity: number; + visible: boolean; + originFitted: boolean; + hasExplicitTransform: boolean; + parentScene: THREE.Scene; // scene the root/spark were added to (world or splat) + onTransformChange?: (m: THREE.Matrix4) => void; + emitTransform: () => void; + dispose: () => void; + } + interface WorldCtx { + world: any; + scene3: THREE.Scene; + renderer3: THREE.WebGLRenderer; + getCam: () => THREE.Camera; + controls: any; + gizmo: TransformControls; + gizmoHelper: THREE.Object3D; + gizmoEnabled: boolean; + postpro: any; + prevPostpro: boolean; + disabledPostpro: boolean; // did WE turn postproduction off? (restore on teardown) + keepPostproduction: boolean; + // PHASE 1 — "both" PEN + occluded splats. When on, postproduction stays + // enabled; splats live in `splatScene` and are drawn by the deferred + // pipeline's `splatOcclusionPass` hook (after composite, BIM depth borrowed), + // so they're occluded by BIM with the PEN look kept. Off → forward path + // (postproduction disabled, splats in world.scene, free occlusion, no PEN). + deferredOcclusion: boolean; + splatScene: THREE.Scene | null; // holds dataset roots + Spark when deferredOcclusion + lastMoveT: number; + frameNo: number; + raf: number; + disposed: boolean; + overlay: { el: HTMLElement; refresh: () => void; dispose: () => void } | null; + onCtrl: () => void; + onRest: () => void; + onKey: (e: KeyboardEvent) => void; + } + const worldDatasets = new Map(); + let worldCtx: WorldCtx | null = null; + let activeDatasetId: string | null = null; + // Assigned to the returned controller so the in-viewport control overlay can + // drive the public API. Non-null by the time any overlay handler fires. + let rcApi: RealityCaptureController | null = null; + + // Tear down the fullscreen-overlay session (loadThreeTZ), if any. + function clearOverlay() { + const s = session; + if (!s) return; + session = null; + s.disposed = true; + window.removeEventListener("resize", s.onResize); + try { s.tiles?.dispose(); } catch { /* noop */ } + try { s.controls.dispose(); } catch { /* noop */ } + try { s.sparkRenderer?.dispose?.(); } catch { /* noop */ } + try { + s.snapRT?.dispose(); + s.blitGeom?.dispose(); + s.blitMat?.dispose(); + } catch { /* noop */ } + try { + s.renderer.dispose(); + s.renderer.forceContextLoss(); + } catch { /* noop */ } + try { + if (s.ownsOverlay) s.overlay.remove(); + else s.overlay.replaceChildren(); // caller-owned container: just empty it + } catch { /* noop */ } + } + + const activeDataset = (): WorldDataset | null => + (activeDatasetId && worldDatasets.get(activeDatasetId)) || null; + const targetDataset = (id?: string): WorldDataset | null => + id ? worldDatasets.get(id) ?? null : activeDataset(); + + // Attach the shared gizmo to a dataset's alignment root + make it the gizmo's + // persistence target. No-op if the id isn't loaded. + function setActiveDataset(id: string) { + const ds = worldDatasets.get(id); + const ctx = worldCtx; + if (!ds || !ctx) return; + activeDatasetId = id; + ctx.gizmo.attach(ds.root); + ctx.gizmoHelper.visible = ctx.gizmoEnabled && ds.visible; + ctx.overlay?.refresh(); + } + + // Remove ONE co-located dataset; tear the shared ctx down when the last goes. + function removeDataset(id: string) { + const ds = worldDatasets.get(id); + if (!ds) return; + worldDatasets.delete(id); + try { ds.dispose(); } catch { /* noop */ } + if (activeDatasetId === id) { + const next = worldDatasets.keys().next(); + activeDatasetId = next.done ? null : next.value; + if (activeDatasetId) setActiveDataset(activeDatasetId); + } + if (worldDatasets.size === 0) teardownWorldCtx(); + else { worldCtx?.overlay?.refresh(); worldCtx?.world?.renderer?.update?.(); } + } + + function teardownWorldCtx() { + const ctx = worldCtx; + if (!ctx) return; + worldCtx = null; + activeDatasetId = null; + ctx.disposed = true; + cancelAnimationFrame(ctx.raf); + ctx.controls?.removeEventListener?.("control", ctx.onCtrl); + ctx.controls?.removeEventListener?.("controlstart", ctx.onCtrl); + ctx.controls?.removeEventListener?.("rest", ctx.onRest); + ctx.controls?.removeEventListener?.("sleep", ctx.onRest); + window.removeEventListener("keydown", ctx.onKey); + if (ctx.controls) ctx.controls.enabled = true; // in case teardown lands mid-drag + // Unregister the Phase-1 occlusion hook so the pipeline stops calling us. + if (ctx.deferredOcclusion && ctx.postpro && "splatOcclusionPass" in ctx.postpro) { + ctx.postpro.splatOcclusionPass = undefined; + } + try { + ctx.gizmo.detach(); + (ctx.splatScene ?? ctx.scene3).remove(ctx.gizmoHelper); + ctx.gizmo.dispose(); + } catch { /* noop */ } + if (ctx.postpro && ctx.disabledPostpro) ctx.postpro.enabled = ctx.prevPostpro; + ctx.overlay?.dispose(); + try { ctx.world.renderer.update(); } catch { /* noop */ } + } + + // Remove ALL co-located datasets (and the shared ctx). + function clearWorldAll() { + for (const id of [...worldDatasets.keys()]) removeDataset(id); + } + + // Public clear(): tear down everything (overlay + all co-located datasets). + function clear() { + clearWorldAll(); + clearOverlay(); + } + + async function loadThreeTZ(fileId: string, container?: HTMLElement) { + if (!activeClient) { + throw new Error( + "realityCaptureViewer: no platform client — pass it to the factory or call setClient() first", + ); + } + // Only one session at a time. + clear(); + + // --- overlay / mount -------------------------------------------------- + let overlay: HTMLElement; + let mount: HTMLElement; + const ownsOverlay = !container; + let status: HTMLElement | null = null; + + if (container) { + overlay = container; + overlay.replaceChildren(); + mount = container; + } else { + overlay = document.createElement("div"); + Object.assign(overlay.style, { + position: "absolute", + inset: "0", + zIndex: "1000", + background: BG.getStyle(), + display: "flex", + flexDirection: "column", + }); + + const header = document.createElement("div"); + Object.assign(header.style, { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "0.75rem 1rem", + flexShrink: "0", + borderBottom: "1px solid var(--bim-ui_bg-contrast-20)", + color: "var(--bim-ui_main-base, #fff)", + }); + + status = document.createElement("div"); + status.style.opacity = "0.7"; + status.textContent = "Loading…"; + + const closeBtn = document.createElement("button"); + closeBtn.textContent = "Close"; + Object.assign(closeBtn.style, { + cursor: "pointer", + padding: "0.4rem 0.9rem", + borderRadius: "0.375rem", + border: "1px solid var(--bim-ui_bg-contrast-40, #555)", + background: "var(--bim-ui_bg-contrast-20, #333)", + color: "var(--bim-ui_main-base, #fff)", + }); + closeBtn.addEventListener("click", () => clear()); + + header.appendChild(status); + header.appendChild(closeBtn); + + mount = document.createElement("div"); + Object.assign(mount.style, { + flex: "1", + overflow: "hidden", + position: "relative", + minHeight: "0", + }); + + overlay.appendChild(header); + overlay.appendChild(mount); + document.body.appendChild(overlay); + } + + // --- three.js core (own renderer; NOT the OBC world) ------------------ + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setClearColor(BG, 1); + Object.assign(renderer.domElement.style, { + width: "100%", + height: "100%", + display: "block", + }); + mount.appendChild(renderer.domElement); + + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 50000); + camera.position.set(15, 12, 15); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.14; + controls.target.set(0, 0, 0); + + const pointContainer = new THREE.Group(); + pointContainer.matrixAutoUpdate = false; + scene.add(pointContainer); + const splatContainer = new THREE.Group(); + splatContainer.rotation.x = Math.PI; // splats are Y-up + scene.add(splatContainer); + + const s: Session = { + overlay, + ownsOverlay, + renderer, + controls, + tiles: null, + sparkRenderer: null, + onResize: () => {}, + disposed: false, + snapRT: null, + blitScene: null, + blitQuad: null, + blitMat: null, + blitGeom: null, + }; + session = s; + + let format: Format = "points"; + let originSet = false; + + // --- idle-snapshot machinery (full-screen blit of a frozen RT) --------- + // Allocate the RT at the drawing-buffer size; a tiny ortho quad samples it. + const blitMat = new THREE.ShaderMaterial({ + uniforms: { uTex: { value: null as THREE.Texture | null } }, + vertexShader: + "varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }", + fragmentShader: + "precision highp float; varying vec2 vUv; uniform sampler2D uTex; void main(){ gl_FragColor = texture2D(uTex, vUv); }", + depthTest: false, + depthWrite: false, + }); + const blitGeom = new THREE.PlaneGeometry(2, 2); + const blitQuad = new THREE.Mesh(blitGeom, blitMat); + blitQuad.frustumCulled = false; + const blitScene = new THREE.Scene(); + blitScene.add(blitQuad); + const blitCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + s.blitScene = blitScene; + s.blitQuad = blitQuad; + s.blitMat = blitMat; + s.blitGeom = blitGeom; + + // Snapshot state machine flags. + let showFrozen = false; // currently blitting the frozen texture + let snapshotDone = false; // a valid snapshot has been captured at this pose + let lastMoveT = performance.now(); + + function ensureSnapRT(w: number, h: number) { + if (!s.snapRT) { + s.snapRT = new THREE.WebGLRenderTarget(w, h, { depthBuffer: true }); + blitMat.uniforms.uTex.value = s.snapRT.texture; + } else if (s.snapRT.width !== w || s.snapRT.height !== h) { + s.snapRT.setSize(w, h); + } + } + + function resize() { + const w = Math.max(1, mount.clientWidth); + const h = Math.max(1, mount.clientHeight); + renderer.setSize(w, h, false); + camera.aspect = w / h; + camera.updateProjectionMatrix(); + if (s.tiles) s.tiles.setResolutionFromRenderer(camera, renderer); + // snapshot is resolution-specific: resize the RT and invalidate it. + const db = new THREE.Vector2(); + renderer.getDrawingBufferSize(db); + ensureSnapRT(Math.max(1, Math.floor(db.x)), Math.max(1, Math.floor(db.y))); + showFrozen = false; + snapshotDone = false; + } + s.onResize = () => resize(); + window.addEventListener("resize", s.onResize); + + // Any user input invalidates the frozen snapshot and snaps the live set + // back to "light" so the resulting motion is immediately smooth. + const onInteractStart = () => { + showFrozen = false; + snapshotDone = false; + lastMoveT = performance.now(); + if (s.tiles) { + const floorET = format === "splats" ? SPLAT_MOTION_ET : POINT_MOTION_ET; + s.tiles.errorTarget = Math.max(s.tiles.errorTarget, floorET); + setSplatJobs(s.tiles, 1); // spread spikes while moving + } + }; + const onInteractEnd = () => { + if (s.tiles) setSplatJobs(s.tiles, 16); // burst-load the refine + }; + controls.addEventListener("start", onInteractStart); + controls.addEventListener("end", onInteractEnd); + + function frameCamera(center: THREE.Vector3, radius: number) { + const r = Math.max(2, radius); + controls.target.copy(center); + camera.position.set(center.x + r * 0.9, center.y + r * 0.7, center.z + r * 0.9); + camera.near = r / 500; + camera.far = r * 200; + camera.updateProjectionMatrix(); + controls.update(); + } + + // --- motion detection: compare the camera world transform frame-to-frame. + // controls.update() under-reports wheel-zoom, so also diff position+quaternion + // (position changes on orbit/pan/zoom; target via controls covers pan). + const _prevPos = new THREE.Vector3(Infinity, Infinity, Infinity); + const _prevQuat = new THREE.Quaternion(2, 2, 2, 2); + const _prevTarget = new THREE.Vector3(Infinity, Infinity, Infinity); + let frameNo = 0; + + function animate() { + if (s.disposed) return; + requestAnimationFrame(animate); + + const now = performance.now(); + const fromControls = controls.update(); + const moved = + _prevPos.distanceToSquared(camera.position) > 1e-10 || + _prevTarget.distanceToSquared(controls.target) > 1e-10 || + Math.abs(_prevQuat.dot(camera.quaternion)) < 0.9999999; + _prevPos.copy(camera.position); + _prevTarget.copy(controls.target); + _prevQuat.copy(camera.quaternion); + const ctrlChanged = fromControls || moved; + if (ctrlChanged) lastMoveT = now; + // ~130ms tail so the damping glide still counts as "moving". + const cameraMoving = now - lastMoveT < 130; + + const tiles = s.tiles; + if (tiles) { + tiles.setCamera(camera); + tiles.update(); + + const isSplat = format === "splats"; + const group = isSplat ? splatContainer : pointContainer; + const budget = isSplat ? SPLAT_MOTION_BUDGET : POINT_MOTION_BUDGET; + const motionET = isSplat ? SPLAT_MOTION_ET : POINT_MOTION_ET; + const refineFloor = isSplat ? SPLAT_REFINE_FLOOR : POINT_REFINE_FLOOR; + const refineStep = isSplat ? SPLAT_REFINE_STEP : POINT_REFINE_STEP; + + // --- count-budget governor (every ~6 frames) ----------------------- + if (frameNo % 6 === 0) { + const vis = isSplat ? countSplats(group) : countPoints(group); + const scaleET = (f: number) => { + tiles.errorTarget = THREE.MathUtils.clamp(tiles.errorTarget * f, ET_MIN, ET_MAX); + }; + if (cameraMoving) { + // MOVING: hard-cap the primitive COUNT to the motion budget by + // raising errorTarget proportionally; ease back down if well under. + const ratio = vis / budget; + if (ratio > 1.1) scaleET(Math.min(2.5, ratio)); + else if (ratio < 0.6) scaleET(0.9); + } else if (!snapshotDone) { + // PARKED + not yet snapshotted: refine coarse -> fine toward the floor, + // bounded by the idle budget so a wide view doesn't request the world. + const underIdleCap = isSplat || vis < IDLE_POINT_BUDGET; + if (underIdleCap && tiles.errorTarget > refineFloor) { + tiles.errorTarget = Math.max(tiles.errorTarget * refineStep, refineFloor); + } + } + } + + // --- idle-snapshot state machine ----------------------------------- + if (cameraMoving) { + showFrozen = false; + snapshotDone = false; + } else if (!snapshotDone) { + // parked: capture once refined (near the floor) AND queues drained, + // OR after a 2.5s safety dwell if streaming never fully settles. + const refined = tiles.errorTarget <= refineFloor * 2.5 && queuesEmpty(tiles); + const stable = refined || now - lastMoveT > 2500; + if (stable) { + const db = new THREE.Vector2(); + renderer.getDrawingBufferSize(db); + ensureSnapRT(Math.max(1, Math.floor(db.x)), Math.max(1, Math.floor(db.y))); + renderer.setRenderTarget(s.snapRT); + renderer.clear(true, true, true); + renderer.render(scene, camera); + renderer.setRenderTarget(null); + showFrozen = true; + snapshotDone = true; + // cap the live set back to "light" so the next motion is instant. + tiles.errorTarget = motionET; + } + } + + if (showFrozen && s.snapRT) { + // BLIT the frozen full-detail texture (cheap; no re-sort). + renderer.setRenderTarget(null); + renderer.render(blitScene, blitCam); + } else { + renderer.render(scene, camera); + } + } else { + renderer.render(scene, camera); + } + frameNo++; + } + + resize(); + animate(); + + // --- load ------------------------------------------------------------- + try { + if (status) status.textContent = "Downloading…"; + // The main visible file is a standard tileset.json. Download its bytes, + // parse the JSON for the content URIs (format detection) + the hiddenFiles + // map (content.uri -> hidden file id) used to stream tiles per-file. + const res = await activeClient.downloadFile(fileId); + const tilesetBytes = new Uint8Array(await res.arrayBuffer()); + if (s.disposed) return; + + if (status) status.textContent = "Inspecting…"; + const ts = JSON.parse(new TextDecoder().decode(tilesetBytes)); + const hiddenFiles: Record = ts.hiddenFiles || {}; + const uris: string[] = []; + collectContentURIs(ts.root, uris); + format = detectFormat(uris); + if (s.disposed) return; + + if (status) status.textContent = `Streaming (${format})…`; + + // HiddenTilesPlugin serves the root tileset from the bytes we already have, + // and each tile content fetch by downloading the matching platform HIDDEN + // file (one downloadHiddenFile per visible tile). The synthetic "mem://t" + // base lets relative content URIs resolve to dataset-relative paths the + // plugin strips back to look up in the hiddenFiles map. + const tiles = new TilesRenderer("mem://t/tileset.json"); + s.tiles = tiles; + tiles.registerPlugin( + new HiddenTilesPlugin({ + baseUrl: "mem://t", + tilesetBytes, + hiddenFiles, + client: activeClient, + }), + ); + + if (format === "splats") { + // clipXY 1.0 frustum-culls splat centers (default 1.4 keeps a 40% + // off-screen overdraw margin we don't need here). + s.sparkRenderer = new SparkRenderer({ renderer, clipXY: 1.0 } as any); + scene.add(s.sparkRenderer); + tiles.registerPlugin(new SplatTilePlugin()); + tiles.errorTarget = SPLAT_MOTION_ET; + // LRU sized to hold a full small dataset once (no reload churn); the + // count-budget governor still bounds what's actually DRAWN each frame. + // This is THE big win — Spark only re-sorts the visible cut. + configureCache(tiles, 280, 420); + // Spark rebuilds a merged buffer per tile: cap concurrent parses to + // avoid bunching spikes (raised to 16 on interaction-end to burst-refine). + setSplatJobs(tiles, 1); + splatContainer.add(tiles.group); + } else { + tiles.registerPlugin(new PointTilePlugin()); + tiles.errorTarget = POINT_MOTION_ET; + // Bigger cache for dense point clouds so revisits don't reload. + configureCache(tiles, 1500, 768); + // Points load fast (no Spark rebuild to spread); match the decode pool. + try { + (tiles as any).parseQueue.maxJobs = 16; + } catch { + /* queue not present */ + } + pointContainer.add(tiles.group); + } + + tiles.setCamera(camera); + tiles.setResolutionFromRenderer(camera, renderer); + + tiles.addEventListener("load-tileset", () => { + if (originSet || s.disposed) return; + originSet = true; + const sphere = new THREE.Sphere(); + tiles.getBoundingSphere(sphere); + + if (format === "points") { + // RTC: recenter the float64 origin on the tileset centre so the point + // nodes (placed at float64 world anchors) render jitter-free. The + // pointContainer shifts by -origin; framing is around the origin. + const c = sphere.center; + frame.origin = [c.x, c.y, c.z]; + pointContainer.position.set(-c.x, -c.y, -c.z); + pointContainer.updateMatrix(); + frameCamera(new THREE.Vector3(0, 0, 0), sphere.radius); + } else { + // Splats: the container applies the Y-up flip (rotation.x = PI). Frame + // on the flipped centre so the camera looks at the visible geometry. + const c = sphere.center; + const flipped = new THREE.Vector3(c.x, -c.y, -c.z); + frameCamera(flipped, sphere.radius); + } + if (status) status.textContent = ""; + }); + } catch (e: any) { + if (!s.disposed && status) status.textContent = `Error: ${e?.message ?? e}`; + console.error("[reality-capture-viewer]", e); + } + } + + // ─────────────────────────────────────────────────────────────────────── + // PHASE 0 — CO-LOCATED MODE: splats/points in the BIM world scene. + // + // Borrows the OBC world's renderer + scene + camera instead of owning them, so + // the dataset sits in the same 3D space as the .frag model under one camera. + // Occlusion: with postproduction OFF (default), three renders opaque BIM first + // (writes depth) then Spark's transparent splats (depthTest:true) — splats + // behind walls are clipped FOR FREE, no custom depth pass. The governor (LRU + + // count budget + park-refine) is re-driven off the OBC camera-controls events; + // the overlay's idle-snapshot is dropped (it would freeze the whole BIM scene). + // ─────────────────────────────────────────────────────────────────────── + // Self-contained in-viewport control strip for the co-located experience. + // Plain DOM themed with the --bim-ui_* vars (matches BUI) — deliberately does + // NOT touch files-panel or the app panel system (avoids collision; W2 owns + // panels). Drives the public controller API (rcApi) and always reflects the + // ACTIVE dataset. Owned by the WorldCtx: appears on first load, torn down with + // the ctx. Bottom-centre of the viewport. + function buildControlOverlay(ctx: WorldCtx): { + el: HTMLElement; + refresh: () => void; + dispose: () => void; + } { + const mount = ctx.renderer3.domElement.parentElement ?? document.body; + const bar = document.createElement("div"); + Object.assign(bar.style, { + position: "absolute", bottom: "12px", left: "50%", transform: "translateX(-50%)", + zIndex: "20", display: "flex", alignItems: "center", gap: "0.5rem", + padding: "0.4rem 0.6rem", borderRadius: "0.5rem", + background: "var(--bim-ui_bg-base, #1a1f26)", + border: "1px solid var(--bim-ui_bg-contrast-20, #333)", + color: "var(--bim-ui_main-base, #e6e6e6)", + font: "12px/1.2 system-ui, sans-serif", + boxShadow: "0 2px 12px rgba(0,0,0,0.4)", userSelect: "none", + }); + const mkBtn = (text: string, title: string) => { + const b = document.createElement("button"); + b.textContent = text; b.title = title; + Object.assign(b.style, { + cursor: "pointer", padding: "0.3rem 0.55rem", borderRadius: "0.35rem", + border: "1px solid var(--bim-ui_bg-contrast-40, #555)", + background: "var(--bim-ui_bg-contrast-20, #2a2f37)", color: "inherit", font: "inherit", + }); + return b; + }; + const hl = (b: HTMLButtonElement, on: boolean) => { + b.style.background = on ? "var(--bim-ui_accent-base, #4b7bec)" : "var(--bim-ui_bg-contrast-20, #2a2f37)"; + b.style.borderColor = on ? "var(--bim-ui_accent-base, #4b7bec)" : "var(--bim-ui_bg-contrast-40, #555)"; + }; + const sep = () => { const s = document.createElement("span"); s.textContent = "|"; s.style.opacity = "0.3"; return s; }; + + const label = document.createElement("span"); + Object.assign(label.style, { opacity: "0.85", maxWidth: "160px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }); + const prev = mkBtn("‹", "Previous dataset"); + const next = mkBtn("›", "Next dataset"); + const cycle = (dir: number) => { + const ids = rcApi?.listDatasets() ?? []; + if (ids.length < 2) return; + const i = Math.max(0, ids.indexOf(activeDatasetId ?? "")); + rcApi?.setActiveDataset(ids[(i + dir + ids.length) % ids.length]); + }; + prev.onclick = () => cycle(-1); + next.onclick = () => cycle(1); + + const eye = mkBtn("Hide", "Show/hide this dataset"); + eye.onclick = () => { rcApi?.setDatasetVisible(!(rcApi?.isDatasetVisible() ?? true)); refresh(); }; + + const align = mkBtn("Align", "Toggle the align gizmo (W/E/R = move/rotate/scale)"); + align.onclick = () => { ctx.gizmoEnabled = !ctx.gizmoEnabled; rcApi?.showGizmo(ctx.gizmoEnabled); refresh(); }; + const mMove = mkBtn("Move", "Translate (W)"); + const mRot = mkBtn("Rotate", "Rotate (E)"); + const mScale = mkBtn("Scale", "Scale (R)"); + mMove.onclick = () => { rcApi?.setGizmoMode("translate"); refresh(); }; + mRot.onclick = () => { rcApi?.setGizmoMode("rotate"); refresh(); }; + mScale.onclick = () => { rcApi?.setGizmoMode("scale"); refresh(); }; + + const opWrap = document.createElement("label"); + Object.assign(opWrap.style, { display: "flex", alignItems: "center", gap: "0.3rem" }); + opWrap.title = "Splat opacity"; + const opTxt = document.createElement("span"); opTxt.textContent = "Opacity"; + const op = document.createElement("input"); + op.type = "range"; op.min = "0"; op.max = "1"; op.step = "0.05"; op.value = "1"; op.style.width = "80px"; + op.oninput = () => rcApi?.setSplatOpacity(parseFloat(op.value)); + opWrap.append(opTxt, op); + + const unload = mkBtn("Unload", "Remove this dataset from the model"); + unload.style.borderColor = "var(--bim-ui_color-error, #c0392b)"; + unload.onclick = () => { if (activeDatasetId) rcApi?.removeDataset(activeDatasetId); }; + + bar.append(prev, label, next, sep(), eye, sep(), align, mMove, mRot, mScale, sep(), opWrap, sep(), unload); + mount.appendChild(bar); + + function refresh() { + const ids = rcApi?.listDatasets() ?? []; + const multi = ids.length > 1; + prev.style.display = next.style.display = label.style.display = multi ? "" : "none"; + if (multi && activeDatasetId) label.textContent = `${ids.indexOf(activeDatasetId) + 1}/${ids.length} · …${activeDatasetId.slice(-6)}`; + eye.textContent = (rcApi?.isDatasetVisible() ?? true) ? "Hide" : "Show"; + hl(align, ctx.gizmoEnabled); + const mode = (ctx.gizmo as any).mode ?? "translate"; + for (const [b, m] of [[mMove, "translate"], [mRot, "rotate"], [mScale, "scale"]] as [HTMLButtonElement, string][]) { + b.disabled = !ctx.gizmoEnabled; + b.style.opacity = ctx.gizmoEnabled ? "1" : "0.4"; + hl(b, ctx.gizmoEnabled && mode === m); + } + const ds = activeDataset(); + const isSplat = ds?.format === "splats"; + opWrap.style.display = isSplat ? "flex" : "none"; + if (isSplat && ds) op.value = String(ds.splatOpacity); + } + refresh(); + + return { el: bar, refresh, dispose() { try { bar.remove(); } catch { /* noop */ } } }; + } + + // Lazily create the shared world context: OBC-world handles + the single align + // gizmo (re-attached to the active dataset) + camera-controls listeners + the + // rAF tick that updates EVERY loaded dataset. Created on first load; torn down + // (restoring postproduction) when the last dataset is removed. + function ensureWorldCtx(keepPostproduction: boolean): WorldCtx { + if (worldCtx) return worldCtx; + const world = [...(_components.get(OBC.Worlds).list.values() as any)][0] as any; + if (!world?.scene?.three || !world?.renderer?.three || !world?.camera) { + throw new Error("loadIntoWorld: no OBC world available"); + } + const scene3: THREE.Scene = world.scene.three; + const renderer3: THREE.WebGLRenderer = world.renderer.three; + const getCam = (): THREE.Camera => world.camera.three ?? world.camera.threePersp; + const controls: any = world.camera.controls; + + // Mode: PHASE-1 deferred-occlusion ("both") requires keepPostproduction AND + // the deferred pipeline's splatOcclusionPass hook (W3). Otherwise → forward + // path (disable postproduction; splats in world.scene; free occlusion). + const postpro = world.renderer.postproduction; + const prevPostpro = !!postpro?.enabled; + const deferredOcclusion = + keepPostproduction && !!postpro && "splatOcclusionPass" in postpro; + // Disable postproduction for the forward path (free occlusion). Also covers + // the fallback where "both" was requested but the deferred hook is missing + // (older dist) — better splats-with-free-occlusion than splats hidden by the + // capture pass. Only deferred-occlusion mode keeps postproduction on. + const disabledPostpro = !!postpro && !deferredOcclusion; + if (disabledPostpro) postpro.enabled = false; + + // In deferred-occlusion mode the splats live in a PRIVATE scene drawn by the + // splatOcclusionPass (after the PEN composite, BIM depth borrowed); the + // deferred capture would otherwise hide them. In forward mode they live in + // world.scene and render with the BIM in one pass. + const splatScene: THREE.Scene | null = deferredOcclusion ? new THREE.Scene() : null; + const contentScene = splatScene ?? scene3; + + // ONE align gizmo, re-attached to the active dataset's root on selection. + // Put its helper in the same scene as the dataset content so it stays visible + // (in deferred mode the PEN capture would hide a helper added to world.scene). + const gizmo = new TransformControls(getCam(), renderer3.domElement); + const gizmoHelper = gizmo.getHelper(); + contentScene.add(gizmoHelper); + gizmo.addEventListener("dragging-changed", (e: any) => { + if (controls) controls.enabled = !e.value; + }); + gizmo.addEventListener("mouseUp", () => activeDataset()?.emitTransform()); + + const onCtrl = () => { + if (worldCtx) worldCtx.lastMoveT = performance.now(); + for (const ds of worldDatasets.values()) { + if (!ds.tiles) continue; + const floorET = ds.format === "splats" ? SPLAT_MOTION_ET : POINT_MOTION_ET; + ds.tiles.errorTarget = Math.max(ds.tiles.errorTarget, floorET); + setSplatJobs(ds.tiles, 1); // spread spikes while moving + } + }; + const onRest = () => { + for (const ds of worldDatasets.values()) if (ds.tiles) setSplatJobs(ds.tiles, 16); + }; + const onKey = (e: KeyboardEvent) => { + if (!gizmoHelper.visible) return; + if (e.key === "w" || e.key === "W") gizmo.setMode("translate"); + else if (e.key === "e" || e.key === "E") gizmo.setMode("rotate"); + else if (e.key === "r" || e.key === "R") gizmo.setMode("scale"); + }; + controls?.addEventListener?.("control", onCtrl); + controls?.addEventListener?.("controlstart", onCtrl); + controls?.addEventListener?.("rest", onRest); + controls?.addEventListener?.("sleep", onRest); + window.addEventListener("keydown", onKey); + + const ctx: WorldCtx = { + world, scene3, renderer3, getCam, controls, + gizmo, gizmoHelper, gizmoEnabled: true, + postpro, prevPostpro, disabledPostpro, keepPostproduction, + deferredOcclusion, splatScene, + lastMoveT: performance.now(), frameNo: 0, raf: 0, disposed: false, + overlay: null, + onCtrl, onRest, onKey, + }; + worldCtx = ctx; + ctx.overlay = buildControlOverlay(ctx); // self-contained in-viewport controls + + // PHASE 1 — register the deferred occlusion draw (W3 converged hook d2528f06). + // Fires AFTER composite/FXAA/overlays (PEN frame done), into `ctx.target` — a + // SEPARATE borrowed-depth colour LAYER the pipeline has already bound + cleared + // transparent with the BIM DepthTexture attached; the pipeline then composites + // that layer premultiplied OVER the PEN frame. We just draw the private + // splatScene into the (already-bound) target: Spark's premultiplied, + // depth-tested splats are occluded by the real BIM depth. We NEVER clear the + // depth and DON'T cache the layer FBO across frames (depth detached per-frame). + if (deferredOcclusion) { + postpro.splatOcclusionPass = (c: { + renderer: THREE.WebGLRenderer; + camera: THREE.Camera; + target: THREE.WebGLRenderTarget; + depth: THREE.DepthTexture; + width: number; + height: number; + }) => { + if (ctx.disposed || !splatScene || !c?.depth || !c?.target) return; // guard until pipeline up + // Reversed-Z: Spark's draw manages its own GL depth state (doesn't ride + // three's auto depthFunc inversion), so set GreaterEqualDepth ourselves. + applySplatDepthState(splatScene); + // The pipeline has already bound c.target (borrowed BIM depth, cleared + // transparent, autoClear off); just draw — renderer.render keeps the + // current target. The pipeline composites it premultiplied over PEN. + c.renderer.render(splatScene, c.camera); + }; + } + + // Motion detection for the idle gate: diff the camera transform frame-to- + // frame (covers user orbit AND programmatic moves, not just controls events). + const _prevPos = new THREE.Vector3(Infinity, Infinity, Infinity); + const _prevQuat = new THREE.Quaternion(2, 2, 2, 2); + // Frames still rendered after activity stops, so the settled/refined frame is + // actually presented before we go idle. + let trailing = 0; + + // Shared tick: govern EVERY loaded dataset, then re-composite ONLY while the + // camera is moving or tiles are streaming (+ a short trailing tail). When idle + // and drained we stop forcing renderer.update() — the deferred PEN pipeline + + // splat sort no longer run every frame, restoring idle fps. A camera move (or + // a streaming tile) flips us back on within a frame, so the next move always + // re-sorts; the on-demand render path keeps the canvas correct meanwhile. + function tick() { + if (ctx.disposed) return; + ctx.raf = requestAnimationFrame(tick); + const cam = ctx.getCam(); + if (ctx.gizmo.camera !== cam) ctx.gizmo.camera = cam; // follow persp/ortho + const now = performance.now(); + // Camera moved since last frame? (transform diff + the controls events that + // also set lastMoveT). Damping glide keeps changing the transform → caught. + const moved = + _prevPos.distanceToSquared(cam.position) > 1e-12 || + Math.abs(_prevQuat.dot(cam.quaternion)) < 0.9999999; + _prevPos.copy(cam.position); + _prevQuat.copy(cam.quaternion); + if (moved) ctx.lastMoveT = now; + const cameraMoving = now - ctx.lastMoveT < 180; + const runGovernor = ctx.frameNo % 6 === 0; + let anyStreaming = false; + for (const ds of worldDatasets.values()) { + const tiles = ds.tiles; + if (!tiles || !ds.visible) continue; // hidden datasets pause streaming + tiles.setCamera(cam); + tiles.setResolutionFromRenderer(cam, ctx.renderer3); + tiles.update(); + if (!queuesEmpty(tiles)) anyStreaming = true; // a tile in flight → keep rendering + if (!runGovernor) continue; + const isSplat = ds.format === "splats"; + const group = isSplat ? ds.splatContainer : ds.pointContainer; + const budget = isSplat ? ds.splatBudget : ds.pointBudget; + const refineFloor = isSplat ? SPLAT_REFINE_FLOOR : POINT_REFINE_FLOOR; + const refineStep = isSplat ? SPLAT_REFINE_STEP : POINT_REFINE_STEP; + const vis = isSplat ? countSplats(group) : countPoints(group); + const scaleET = (f: number) => { + tiles.errorTarget = THREE.MathUtils.clamp(tiles.errorTarget * f, ET_MIN, ET_MAX); + }; + if (cameraMoving) { + const ratio = vis / budget; + if (ratio > 1.1) scaleET(Math.min(2.5, ratio)); + else if (ratio < 0.6) scaleET(0.9); + } else if (tiles.errorTarget > refineFloor) { + tiles.errorTarget = Math.max(tiles.errorTarget * refineStep, refineFloor); + } + if (isSplat && ds.splatOpacity !== 1) { + group.traverse((o: any) => { + if (o.userData?.splatCount && typeof o.opacity === "number") o.opacity = ds.splatOpacity; + }); + } + } + ctx.frameNo++; + + // IDLE GATE: re-composite only when something changed. Refinement counts as + // streaming (park-refine lowers errorTarget → loads finer tiles → keeps + // rendering until refined + drained, then we idle). `trailing` presents the + // final settled frame before stopping. + const needsRender = cameraMoving || anyStreaming; + if (needsRender) trailing = 3; + else if (trailing > 0) trailing--; + if (!needsRender && trailing <= 0) return; // idle → let the world render on-demand + + // Drive the OBC render so new tiles appear + Spark re-sorts for this camera. + ctx.world.renderer.update(); + + // POSTPRO-OFF FALLBACK: in deferred-occlusion mode the splats live in the + // private splatScene and only draw via the splatOcclusionPass — which the + // pipeline fires only while postproduction is ON. If the user turns + // postproduction OFF at runtime (graphics panel), draw splatScene ourselves + // in a forward pass over the canvas (autoClear off so we keep the BIM the + // world just rendered; depth-tested against the BIM depth it wrote). + if ( + ctx.deferredOcclusion && + ctx.splatScene && + ctx.postpro && + ctx.postpro.enabled === false + ) { + applySplatDepthState(ctx.splatScene); + const r = ctx.renderer3; + const prevAutoClear = r.autoClear; + r.autoClear = false; + r.setRenderTarget(null); + r.render(ctx.splatScene, cam); + r.autoClear = prevAutoClear; + } + } + tick(); + return ctx; + } + + // ─────────────────────────────────────────────────────────────────────── + // PHASE 0 — CO-LOCATED MODE: splats/points in the BIM world scene. + // + // Borrows the OBC world's renderer + scene + camera instead of owning them, so + // the dataset sits in the same 3D space as the .frag model under one camera. + // MULTIPLE datasets can be loaded at once (each a WorldDataset under the shared + // WorldCtx). Occlusion: with postproduction OFF (default), three renders opaque + // BIM first (writes depth) then Spark's transparent splats (depthTest:true) — + // splats behind walls are clipped FOR FREE. Governor re-driven off the OBC + // camera-controls events; the overlay's idle-snapshot is dropped. + // ─────────────────────────────────────────────────────────────────────── + async function loadIntoWorld(fileId: string, opts: LoadIntoWorldOpts = {}) { + if (!activeClient) { + throw new Error( + "realityCaptureViewer: no platform client — pass it to the factory or call setClient() first", + ); + } + clearOverlay(); // overlay and co-located are alternative screens + if (worldDatasets.has(fileId)) removeDataset(fileId); // reload replaces in place + + const ctx = ensureWorldCtx(opts.keepPostproduction === true); + + // Root group carries the user alignment transform (dataset-local → world). + // matrixAutoUpdate stays ON so TransformControls can drive it via TRS; we set + // the transform by DECOMPOSING a matrix. Splats are Y-up → flip the inner + // container so the alignment transform on `root` stays in BIM world space. + // In deferred-occlusion mode the dataset lives in the private splatScene + // (drawn by the occlusion pass); otherwise in world.scene (forward path). + const contentScene = ctx.splatScene ?? ctx.scene3; + const root = new THREE.Group(); + if (opts.transform) opts.transform.decompose(root.position, root.quaternion, root.scale); + contentScene.add(root); + const splatContainer = new THREE.Group(); + splatContainer.rotation.x = Math.PI; + const pointContainer = new THREE.Group(); + root.add(splatContainer, pointContainer); + + const ds: WorldDataset = { + id: fileId, root, splatContainer, pointContainer, + tiles: null, spark: null, format: "points", + splatBudget: SPLAT_MOTION_BUDGET, pointBudget: POINT_MOTION_BUDGET, splatOpacity: 1, + visible: true, originFitted: false, hasExplicitTransform: !!opts.transform, + parentScene: contentScene, + onTransformChange: opts.onTransformChange, + emitTransform() { + root.updateMatrix(); + this.onTransformChange?.(root.matrix.clone()); + }, + dispose() { + try { this.tiles?.dispose(); } catch { /* noop */ } + try { this.spark?.dispose?.(); } catch { /* noop */ } + try { + if (this.spark) this.parentScene.remove(this.spark); + this.parentScene.remove(root); + } catch { /* noop */ } + }, + }; + worldDatasets.set(fileId, ds); + setActiveDataset(fileId); // attach the gizmo to the newest dataset + if (opts.gizmo === false) { + ctx.gizmoEnabled = false; + ctx.gizmoHelper.visible = false; + } else { + ctx.gizmoHelper.visible = ctx.gizmoEnabled; + } + + // --- download + parse the tileset ------------------------------------ + // Guarded: a missing/forbidden fileId, a network failure, or a malformed + // tileset.json must not throw unhandled or leave a half-set-up dataset + // (ctx/gizmo/occlusion-pass registered but no tiles). On any failure we tear + // the partial dataset down (removeDataset → also drops the ctx if it was the + // only one) and rethrow so the caller (files-panel) surfaces it. + let tilesetBytes: Uint8Array; + let tsj: any; + try { + const res = await activeClient.downloadFile(fileId); + tilesetBytes = new Uint8Array(await res.arrayBuffer()); + if (!worldDatasets.has(fileId)) return; // removed while downloading + tsj = JSON.parse(new TextDecoder().decode(tilesetBytes)); + if (!tsj?.root) throw new Error("tileset.json has no root"); + } catch (e) { + removeDataset(fileId); + throw new Error( + `reality-capture: failed to load tileset ${fileId} into the world: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } + const hiddenFiles: Record = tsj.hiddenFiles || {}; + const uris: string[] = []; + collectContentURIs(tsj.root, uris); + ds.format = detectFormat(uris); + + const tiles = new TilesRenderer("mem://t/tileset.json"); + ds.tiles = tiles; + tiles.registerPlugin( + new HiddenTilesPlugin({ baseUrl: "mem://t", tilesetBytes, hiddenFiles, client: activeClient }), + ); + // A tile that fails to download/parse mid-stream should degrade (that tile is + // skipped) rather than spam — log once per tile via the renderer's own error. + tiles.addEventListener("load-error", (e: any) => { + console.warn("[reality-capture] tile load error:", e?.url ?? e?.error ?? e); + }); + + if (ds.format === "splats") { + ds.spark = new SparkRenderer({ renderer: ctx.renderer3, clipXY: 1.0 } as any); + ds.parentScene.add(ds.spark); // world.scene (forward) or splatScene (deferred) + tiles.registerPlugin(new SplatTilePlugin()); + tiles.errorTarget = SPLAT_MOTION_ET; + configureCache(tiles, 280, 420); + setSplatJobs(tiles, 1); + splatContainer.add(tiles.group); + } else { + tiles.registerPlugin(new PointTilePlugin()); + tiles.errorTarget = POINT_MOTION_ET; + configureCache(tiles, 1500, 768); + try { (tiles as any).parseQueue.maxJobs = 16; } catch { /* noop */ } + pointContainer.add(tiles.group); + } + ctx.overlay?.refresh(); // format now known → show splat-only controls (opacity) + + // AUTO-FIT (only when no explicit transform): centre the dataset at the BIM + // world origin so it lands near the model, ready to be nudged. + tiles.addEventListener("load-tileset", () => { + if (ds.originFitted || !worldDatasets.has(fileId)) return; + ds.originFitted = true; + if (!ds.hasExplicitTransform) { + const sphere = new THREE.Sphere(); + tiles.getBoundingSphere(sphere); + const c = sphere.center; // splat container flips Y/Z; account for it + const worldCenter = + ds.format === "splats" ? new THREE.Vector3(c.x, -c.y, -c.z) : c.clone(); + root.position.set(-worldCenter.x, -worldCenter.y, -worldCenter.z); + ds.emitTransform(); // persist the auto-fit as the initial alignment + } + }); + } + + rcApi = { + loadThreeTZ, + loadIntoWorld, + // All co-located controls target a specific dataset by fileId, or the ACTIVE + // one (the most recently loaded / last selected) when fileId is omitted. + setTransform(matrix: THREE.Matrix4, fileId?: string) { + const ds = targetDataset(fileId); + if (ds) { + // root.matrixAutoUpdate is on (for the gizmo) → drive TRS, not .matrix. + matrix.decompose(ds.root.position, ds.root.quaternion, ds.root.scale); + ds.hasExplicitTransform = true; + } + }, + getTransform(fileId?: string) { + const ds = targetDataset(fileId); + if (!ds) return null; + ds.root.updateMatrix(); + return ds.root.matrix.clone(); + }, + showGizmo(on: boolean) { + if (!worldCtx) return; + worldCtx.gizmoEnabled = on; + worldCtx.gizmoHelper.visible = on && (activeDataset()?.visible ?? false); + }, + setGizmoMode(mode: "translate" | "rotate" | "scale") { + worldCtx?.gizmo.setMode(mode); + }, + setActiveDataset(fileId: string) { + setActiveDataset(fileId); + }, + removeDataset(fileId: string) { + removeDataset(fileId); + }, + listDatasets() { + return [...worldDatasets.keys()]; + }, + setDatasetVisible(on: boolean, fileId?: string) { + const ds = targetDataset(fileId); + if (!ds || !worldCtx) return; + ds.visible = on; + ds.root.visible = on; + if (ds.spark) ds.spark.visible = on; + if (ds === activeDataset()) { + worldCtx.gizmoHelper.visible = on && worldCtx.gizmoEnabled; + } + try { worldCtx.world.renderer.update(); } catch { /* noop */ } + }, + isDatasetVisible(fileId?: string) { + return targetDataset(fileId)?.visible ?? false; + }, + setPointSize(px: number) { + setPointSizePx(px); + }, + setSplatOpacity(opacity: number, fileId?: string) { + const ds = targetDataset(fileId); + if (!ds) return; + ds.splatOpacity = THREE.MathUtils.clamp(opacity, 0, 1); + ds.splatContainer.traverse((o: any) => { + if (o.userData?.splatCount && typeof o.opacity === "number") o.opacity = ds.splatOpacity; + }); + }, + setMotionBudget(count: number, fileId?: string) { + const ds = targetDataset(fileId); + if (!ds) return; + const v = Math.max(1000, Math.floor(count)); + if (ds.format === "splats") ds.splatBudget = v; + else ds.pointBudget = v; + }, + setClient(c: ThreeTZClient) { + activeClient = c; + }, + clear, + }; + return rcApi; +} diff --git a/src/cli/templates/app/src/setups/reality-capture/lib/hidden-tiles-plugin.ts b/src/cli/templates/app/src/setups/reality-capture/lib/hidden-tiles-plugin.ts new file mode 100644 index 0000000..9a5bbc2 --- /dev/null +++ b/src/cli/templates/app/src/setups/reality-capture/lib/hidden-tiles-plugin.ts @@ -0,0 +1,88 @@ +// 3DTilesRendererJS plugin: serve a STANDARD loose 3D Tiles dataset whose tiles +// are stored as platform HIDDEN files, streamed one-file-per-tile. +// +// Seam: the renderer's `fetchData(url, options)` hook (intercepts BOTH the root +// tileset and every tile content fetch). Point the TilesRenderer at +// `/tileset.json`; relative content URIs then resolve to +// `/` (e.g. "mem://t/tiles/n12_d3.spz"), which we strip back to +// the dataset-relative path (`relUri`) and look up in the `hiddenFiles` map to +// find the platform hidden-file id to download. +// +// SHARED CONTRACT (matches the converters): the main visible file is a standard +// `tileset.json`; each tile `content.uri` is a relative path under "tiles/". +// tileset.json carries a top-level `hiddenFiles: { "": "" }` +// map. We fetch a tile blob with `client.downloadHiddenFile(id)` -> Response. +// +// Per-tile streaming = one `downloadHiddenFile` per visible tile; the controller's +// Q1 LRU/count-budget governor bounds how many are resident/loading at once. + +// Structural type for the platform client: download a normal file by id (the +// tileset main file) and a hidden file by id (each tile). PlatformClient / +// EngineServicesClient satisfy this. +export interface HiddenTilesClient { + downloadFile(fileId: string, params?: any): Promise; + downloadHiddenFile(hiddenId: string): Promise; +} + +export interface HiddenTilesOptions { + baseUrl: string; // synthetic base the TilesRenderer is pointed at, e.g. "mem://t" + tilesetBytes: Uint8Array; // the already-downloaded tileset.json bytes + hiddenFiles: Record; // content.uri -> hidden file id + client: HiddenTilesClient; +} + +export class HiddenTilesPlugin { + name = "HIDDEN_TILES_PLUGIN"; + private baseUrl: string; + private tilesetBytes: Uint8Array; + private hiddenFiles: Record; + private client: HiddenTilesClient; + + constructor({ baseUrl, tilesetBytes, hiddenFiles, client }: HiddenTilesOptions) { + this.baseUrl = baseUrl.replace(/\/$/, ""); + this.tilesetBytes = tilesetBytes; + this.hiddenFiles = hiddenFiles; + this.client = client; + } + + // dataset-relative path for URLs under our synthetic base, else null. + private rel(url: string): string | null { + const prefix = this.baseUrl + "/"; + return url.startsWith(prefix) ? url.slice(prefix.length) : null; + } + + // The renderer fetch hook. Return a Response, or null to let the default fetch + // run (URLs that aren't under our base). + async fetchData(url: string, _options: any): Promise { + const rel = this.rel(url); + if (rel === null) return null; + + // Root tileset request: serve the bytes we already downloaded. + if (rel === "tileset.json") { + // copy out into a standalone ArrayBuffer for the Response + const b = this.tilesetBytes; + const ab = (b.byteOffset === 0 && b.byteLength === b.buffer.byteLength + ? b.buffer + : b.slice().buffer) as ArrayBuffer; + return new Response(ab, { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + // Content (tile) request: strip query/fragment, look up the hidden id. + const relUri = decodeURIComponent(rel.split("?")[0].split("#")[0]); + const hiddenId = this.hiddenFiles[relUri]; + if (!hiddenId) { + // not a known tile -> 404 so the renderer treats it as a failed fetch. + return new Response(null, { status: 404, statusText: "Not Found" }); + } + + const res = await this.client.downloadHiddenFile(hiddenId); + const buf = await res.arrayBuffer(); + return new Response(buf, { + status: 200, + headers: { "Content-Type": "application/octet-stream" }, + }); + } +} diff --git a/src/cli/templates/app/src/setups/reality-capture/lib/point-tile-plugin.ts b/src/cli/templates/app/src/setups/reality-capture/lib/point-tile-plugin.ts new file mode 100644 index 0000000..22dd0ca --- /dev/null +++ b/src/cli/templates/app/src/setups/reality-capture/lib/point-tile-plugin.ts @@ -0,0 +1,166 @@ +import * as THREE from "three"; +import { frame, placeAtAnchor } from "./render-frame"; + +// ---- decode worker pool ----------------------------------------------------- +// Dequantizes node blobs off the main thread (grimoire: worker-zero-copy decode). +// +// INLINE worker: the decode logic is bundled as a source STRING -> Blob -> object +// URL classic worker. NO `new URL("./worker.ts", import.meta.url)` — that breaks +// under esbuild bundling + the platform's sandboxed iframe (Invalid URL). This +// blob-URL form works in every bundler/sandbox (same approach Spark uses). +// Blob: f64 anchor[3] | f32 scale[3] | u32 count | (u16 x,y,z + u8 r,g,b)*count. +const WORKER_SRC = ` +self.onmessage = function (e) { + var id = e.data.id, buffer = e.data.buffer; + var dv = new DataView(buffer); + var ax = dv.getFloat64(0, true), ay = dv.getFloat64(8, true), az = dv.getFloat64(16, true); + var sx = dv.getFloat32(24, true), sy = dv.getFloat32(28, true), sz = dv.getFloat32(32, true); + var count = dv.getUint32(36, true); + var positions = new Float32Array(count * 3); + var colors = new Uint8Array(count * 3); + var o = 40; + for (var k = 0; k < count; k++) { + positions[k * 3] = dv.getUint16(o, true) * sx; + positions[k * 3 + 1] = dv.getUint16(o + 2, true) * sy; + positions[k * 3 + 2] = dv.getUint16(o + 4, true) * sz; + colors[k * 3] = dv.getUint8(o + 6); + colors[k * 3 + 1] = dv.getUint8(o + 7); + colors[k * 3 + 2] = dv.getUint8(o + 8); + o += 9; + } + self.postMessage({ id: id, positions: positions, colors: colors, count: count, anchor: [ax, ay, az] }, [positions.buffer, colors.buffer]); +}; +`; +let _blobURL: string | null = null; +function workerURL(): string { + return (_blobURL ??= URL.createObjectURL(new Blob([WORKER_SRC], { type: "text/javascript" }))); +} + +type Decoded = { positions: Float32Array; colors: Uint8Array; count: number; anchor: [number, number, number] }; +class DecodePool { + private workers: Worker[] = []; + private idle: Worker[] = []; + private queue: { id: number; buffer: ArrayBuffer; resolve: (d: Decoded) => void }[] = []; + private pending = new Map void>(); + private nextId = 0; + constructor(n: number) { + for (let i = 0; i < n; i++) { + const w = new Worker(workerURL()); // classic blob worker (no import.meta.url, no module type) + w.onmessage = (e: MessageEvent) => { + const r = this.pending.get(e.data.id); + this.pending.delete(e.data.id); + this.idle.push(w); + r?.(e.data); + this.drain(); + }; + this.workers.push(w); this.idle.push(w); + } + } + decode(buffer: ArrayBuffer): Promise { + return new Promise((resolve) => { this.queue.push({ id: this.nextId++, buffer, resolve }); this.drain(); }); + } + private drain() { + while (this.idle.length && this.queue.length) { + const w = this.idle.pop()!; const job = this.queue.shift()!; + this.pending.set(job.id, job.resolve); + w.postMessage({ id: job.id, buffer: job.buffer }, [job.buffer]); // transfer in (zero-copy) + } + } +} +// LAZY: workers are created on the FIRST decode (a points tile), never at module +// load — so merely importing this module (e.g. for the splat path) spins up nothing. +let _pool: DecodePool | null = null; +function getPool(): DecodePool { + return (_pool ??= new DecodePool(Math.min(16, Math.max(3, (navigator.hardwareConcurrency || 4) - 1)))); +} +export function decodeStats() { + const p = _pool as any; + return p ? { workers: p.workers.length, idle: p.idle.length, queued: p.queue.length, pending: p.pending.size } + : { workers: 0, idle: 0, queued: 0, pending: 0 }; +} + +// 3DTilesRendererJS plugin: decodes our quantized point-node format into a +// node-local THREE.Points, tagged with its float64 world anchor for RTC. +// +// Node blob (sliced out of cloud.bin by byte range): +// header: f64 anchor[3] | f32 scale[3] | u32 count (40 B) +// body: per point u16 x,y,z + u8 r,g,b (9 B) +// Decoded position = stored * scale (node-local, small, float32-safe). +// +// Same `parseToMesh` seam the experiment proved with Spark: return a +// THREE.Object3D and the renderer owns transform/cull/cache/eviction. We must +// NOT be async at the top level (invokeOnePlugin treats a Promise as truthy and +// stops iterating), so non-matching content returns null synchronously. + +// Shared material for all point nodes. Writes rgb + view-space log-depth (alpha) +// so the EDL post pass gets its depth field for free (no second target). +const pointUniforms = { uPointSize: { value: frame.pointSize } }; + +const pointMaterial = new THREE.ShaderMaterial({ + uniforms: pointUniforms, + vertexShader: /* glsl */ ` + attribute vec3 color; + uniform float uPointSize; + varying vec3 vColor; + varying float vLogZ; + void main() { + vColor = color; + vec4 mv = modelViewMatrix * vec4(position, 1.0); + vLogZ = log2(max(1e-6, -mv.z)); + gl_Position = projectionMatrix * mv; + gl_PointSize = uPointSize; + } + `, + fragmentShader: /* glsl */ ` + varying vec3 vColor; + varying float vLogZ; + void main() { + gl_FragColor = vec4(vColor, vLogZ); + } + `, +}); + +export function setPointSize(px: number) { + pointUniforms.uPointSize.value = px; +} + +export class PointTilePlugin { + name = "POINT_TILE_PLUGIN"; + tiles: any = null; + + init(tiles: any) { + this.tiles = tiles; + } + + // parseToMesh: hand the blob to the decode WORKER POOL (off-thread dequant + + // zero-copy transfer back) so tile loads never hitch the main thread. Must + // return null synchronously for non-matching content (a Promise is truthy and + // would stop plugin iteration); matching content returns a Promise. + parseToMesh( + buffer: ArrayBuffer, + _tile: any, + _extension: string, + uri: string, + signal: AbortSignal + ) { + if (!uri.includes(".pnt")) return null; + if (signal.aborted) return null; + return getPool().decode(buffer).then((d) => { + if (signal.aborted) return null; + const geom = new THREE.BufferGeometry(); + geom.setAttribute("position", new THREE.BufferAttribute(d.positions, 3)); + geom.setAttribute("color", new THREE.BufferAttribute(d.colors, 3, true)); + const points = new THREE.Points(geom, pointMaterial); + points.matrixAutoUpdate = false; + points.frustumCulled = false; // renderer culls per tile already + points.userData.worldAnchor = d.anchor; // float64 world anchor for RTC + placeAtAnchor(points, d.anchor); + return points; + }); + } + + disposeTile(tile: any) { + const scene = tile.engineData?.scene; + if (scene && scene.geometry) scene.geometry.dispose(); + } +} diff --git a/src/cli/templates/app/src/setups/reality-capture/lib/render-frame.ts b/src/cli/templates/app/src/setups/reality-capture/lib/render-frame.ts new file mode 100644 index 0000000..1437e95 --- /dev/null +++ b/src/cli/templates/app/src/setups/reality-capture/lib/render-frame.ts @@ -0,0 +1,31 @@ +import * as THREE from "three"; + +// Shared floating-origin (RTC) state for the whole viewer. +// +// The precision stack: authoritative coordinates live in float64 (plain JS +// numbers). `origin` is a float64 world point near the camera. Every renderable +// (point node, splat) is placed at worldAnchor - origin computed in float64, +// then assigned to a float32 Object3D.position — which is small and safe. When +// the camera travels far, we rebase `origin` and shift everything, so GPU +// coordinates never grow large no matter the absolute world position. + +export const POINT_LAYER = 1; +export const SPLAT_LAYER = 2; + +export const frame = { + // float64 render origin in world space + origin: [0, 0, 0] as [number, number, number], + pointSize: 3.0, + edlStrength: 0.35, + edlRadius: 1.4, + edlOn: true, +}; + +// Place a point node at its FULL float64 world anchor. The recentering (origin +// subtraction) is done ONCE by pointContainer.position = -origin each frame — so +// world = -origin + anchor + localOffset = (worldPoint - origin), small + precise. +// (Do NOT subtract origin here too, or it gets subtracted twice.) +export function placeAtAnchor(obj: THREE.Object3D, anchor: [number, number, number]) { + obj.position.set(anchor[0], anchor[1], anchor[2]); + obj.updateMatrix(); +} diff --git a/src/cli/templates/app/src/setups/reality-capture/lib/splat-tile-plugin.ts b/src/cli/templates/app/src/setups/reality-capture/lib/splat-tile-plugin.ts new file mode 100644 index 0000000..a535bb3 --- /dev/null +++ b/src/cli/templates/app/src/setups/reality-capture/lib/splat-tile-plugin.ts @@ -0,0 +1,51 @@ +import * as THREE from "three"; +import { SplatMesh } from "@sparkjsdev/spark"; + +// 3DTilesRendererJS plugin that renders .ply Gaussian-splat tiles via Spark — +// the proven seam from Antonio's 3d-tiles experiment, kept intact. The point of +// the slice: the SAME streaming foundation (3DTilesRendererJS) drives this splat +// branch and the point-cloud branch as peers, differing only at parseToMesh. +// +// parseToMesh must not be async at the top level (a returned Promise is treated +// as truthy and stops plugin iteration) — return null synchronously for non-ply. +export class SplatTilePlugin { + name = "SPLAT_TILE_PLUGIN"; + tiles: any = null; + + init(tiles: any) { + this.tiles = tiles; + } + + parseToMesh( + buffer: ArrayBuffer, + _tile: any, + extension: string, + _uri: string, + signal: AbortSignal + ) { + if (extension !== "ply" && extension !== "spz") return null; + // Spark's SplatMesh sniffs the actual file type (PLY/SPZ/SPLAT/…) from the + // bytes, so both load the same way. + return (async () => { + if (signal.aborted) return null; + const splatMesh = new SplatMesh({ fileBytes: new Uint8Array(buffer) }); + await splatMesh.initialized; + if (signal.aborted) { + splatMesh.dispose(); + return null; + } + // Real splat count from the LOADED data — format-agnostic. (A byte probe of + // the header is fragile: SPZ is gzipped, so reading bytes off the top gives + // garbage ~2.6e9, which poisons the motion-budget governor's count and makes + // the Motion-detail slider a no-op.) packedSplats.numSplats is correct for + // every format. + splatMesh.userData.splatCount = (splatMesh as any).packedSplats?.numSplats ?? 0; + return splatMesh as unknown as THREE.Object3D; + })(); + } + + disposeTile(tile: any) { + const scene = tile.engineData?.scene; + if (scene && typeof scene.dispose === "function") scene.dispose(); + } +} diff --git a/src/cli/templates/app/src/setups/right-sidebar.ts b/src/cli/templates/app/src/setups/right-sidebar.ts new file mode 100644 index 0000000..60089d3 --- /dev/null +++ b/src/cli/templates/app/src/setups/right-sidebar.ts @@ -0,0 +1,178 @@ +import * as BUI from "@thatopen/ui"; + +/** + * Mounts several panels as SEPARATE cards stacked vertically on the RIGHT, + * together spanning the full viewport height. One right-docked `bim-grid + * floating` holds one row per region (heights split by `grow`); the floating + * grid's built-in 1rem gap shows between the cards, and its empty "rest" column + * clicks through to the model. Each region is its own `bim-panel` (own + * header/border), so they read as distinct cards — not one card with a divider. + * + * Each panel element should be `height: 100%` so it fills its row and scrolls + * internally when its content is tall. + * + * @param container the viewport element to overlay + * @param regions the panels to stack, top to bottom + * @returns the floating grid element (already appended to the container) + */ +export interface StackRegion { + /** The panel element for this region. */ + element: HTMLElement; + /** Relative height share (CSS grid `fr`). Defaults to 1 (equal split). */ + grow?: number; +} + +export const rightStack = (container: HTMLElement, regions: StackRegion[]) => { + // ── Collapse toggle (mirror of the left files panel's) ───────── + // A bare chevron in its own thin grid column in the gutter, just OUTSIDE the + // cards (to their left). Collapsing swaps the grid layout to drop the card + // columns entirely; the chevron column stays. When collapsed the chevron sits + // inset from the right edge (the grid's 1rem padding), so it stays in view. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let gridEl: any = null; + let collapsed = false; + + const [toggle, toggleUpdate] = BUI.Component.create< + HTMLElement, + { collapsed: boolean } + >( + (s) => BUI.html` +
+ setCollapsed(!s.collapsed)} + style=" + pointer-events: auto; cursor: pointer; display: inline-flex; + color: var(--bim-ui_bg-contrast-80, #c9c9c9); + " + > +
+ `, + { collapsed: false }, + ); + + const setCollapsed = (v: boolean) => { + collapsed = v; + if (gridEl) gridEl.layout = v ? "collapsed" : "main"; + toggleUpdate({ collapsed: v }); + }; + + // ── Floating grid: cards docked full-height right, chevron in the gutter ── + const grid = BUI.Component.create(() => { + const onCreated = (element?: Element) => { + if (!element) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = element as any; + gridEl = g; + const names = regions.map((_, i) => `r${i}`); + g.elements = { + toggle, + ...Object.fromEntries(regions.map((r, i) => [names[i], r.element])), + }; + // One row per region; columns: rest (1fr, click-through) | toggle | cards. + // The "toggle" area repeats across rows → one full-height gutter column. + const rows = regions + .map((r, i) => `"rest toggle ${names[i]}" ${r.grow ?? 1}fr`) + .join("\n"); + g.layouts = { + main: { template: `${rows}\n/ 1fr auto auto` }, + // Collapsed: drop the card columns; keep rest + toggle (toggle ends up + // near the right edge, inset by the grid's 1rem padding). + collapsed: { template: `"rest toggle" 1fr\n/ 1fr auto` }, + }; + g.layout = collapsed ? "collapsed" : "main"; + }; + return BUI.html` + + `; + }); + container.append(grid); + + return { grid }; +}; + +/** + * Mirror of {@link rightStack} for the LEFT side: cards stacked vertically on + * the left (full height), same 1rem gap + outer margins, with a collapse chevron + * in the gutter to the RIGHT of the cards (facing the viewport). Used for the + * Files (top) + helper (bottom) stack. + */ +export const leftStack = (container: HTMLElement, regions: StackRegion[]) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let gridEl: any = null; + let collapsed = false; + + const [toggle, toggleUpdate] = BUI.Component.create< + HTMLElement, + { collapsed: boolean } + >( + (s) => BUI.html` +
+ setCollapsed(!s.collapsed)} + style=" + pointer-events: auto; cursor: pointer; display: inline-flex; + color: var(--bim-ui_bg-contrast-80, #c9c9c9); + " + > +
+ `, + { collapsed: false }, + ); + + const setCollapsed = (v: boolean) => { + collapsed = v; + if (gridEl) gridEl.layout = v ? "collapsed" : "main"; + toggleUpdate({ collapsed: v }); + }; + + const grid = BUI.Component.create(() => { + const onCreated = (element?: Element) => { + if (!element) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = element as any; + gridEl = g; + const names = regions.map((_, i) => `r${i}`); + g.elements = { + toggle, + ...Object.fromEntries(regions.map((r, i) => [names[i], r.element])), + }; + // Columns: cards | toggle (gutter, facing viewport) | rest (click-through). + const rows = regions + .map((r, i) => `"${names[i]} toggle rest" ${r.grow ?? 1}fr`) + .join("\n"); + g.layouts = { + main: { template: `${rows}\n/ auto auto 1fr` }, + // Collapsed: drop the card columns; toggle stays at the left edge (inset + // by the grid's 1rem padding). + collapsed: { template: `"toggle rest" 1fr\n/ auto 1fr` }, + }; + g.layout = collapsed ? "collapsed" : "main"; + }; + return BUI.html` + + `; + }); + container.append(grid); + + return { grid }; +}; diff --git a/src/cli/templates/app/src/setups/settings-panel.ts b/src/cli/templates/app/src/setups/settings-panel.ts new file mode 100644 index 0000000..4b40bee --- /dev/null +++ b/src/cli/templates/app/src/setups/settings-panel.ts @@ -0,0 +1,94 @@ +import * as BUI from "@thatopen/ui"; +import { cardHeader } from "./card-header"; + +/** + * Merged SETTINGS panel (UI-reorg) — ONE scrolling bim-panel with a collapsible + * section per source panel (Graphics / Clip styling / Measurement / Commands). + * Reuses each panel's existing element verbatim. + * + * Sections use a hand-rolled light-DOM header (NOT bim-panel-section, whose + * header/content padding is hardcoded in shadow DOM and can't be flattened) so + * the section title row + its divider span the FULL panel width with no inset, + * and the section content sits flush. The nested bim-panel's own chrome is + * removed (transparent bg / no border / no radius) and its height:100% / inner + * scroll neutralised so it flows and the Settings panel does the single scroll — + * all scoped to light-DOM descendants, so bim-* widget shadow internals (sliders, + * dropdowns, color inputs) are untouched. Returns the `bim-panel` (no self-mount). + * + * @param sections ordered settings sections (label + icon + the panel element) + * @returns the merged Settings `bim-panel` + */ +export const settingsPanel = ( + sections: { label: string; icon: string; el: HTMLElement }[], +) => { + for (const s of sections) { + s.el.setAttribute("header-hidden", ""); // our section header replaces it + s.el.style.height = "auto"; + s.el.style.width = "100%"; + } + const collapsed = new Set(); // section labels currently collapsed + + const [panel, update] = BUI.Component.create( + (_s) => BUI.html` + + +
+ ${cardHeader("mdi:cog", "Settings", "0.75rem")} + ${sections.map((s, i) => { + const open = !collapsed.has(s.label); + return BUI.html` +
{ + if (collapsed.has(s.label)) collapsed.delete(s.label); + else collapsed.add(s.label); + update({ tick: 0 }); + }}> + + ${s.label} + +
+
${s.el}
`; + })} +
+
`, + { tick: 0 }, + ); + + return panel; +}; diff --git a/src/cli/templates/app/src/setups/styles-panel.ts b/src/cli/templates/app/src/setups/styles-panel.ts new file mode 100644 index 0000000..737f494 --- /dev/null +++ b/src/cli/templates/app/src/setups/styles-panel.ts @@ -0,0 +1,110 @@ +import * as OBC from "@thatopen/components"; +import * as BUI from "@thatopen/ui"; +import { styles, StyleSetting } from "./styles"; + +/** + * UI side of the Styles tool. Consumes Worker 1's style DESCRIPTOR + * (`styles(components, world).settings`) and renders it generically in the + * bottom-left helper panel: checkbox (bool), slider (number), color picker + * (color), dropdown (enum) — each wired live to the setting's get/set. Settings + * are grouped by their `group` field. + * + * Returned as a "panel tool" `{ label, icon, render }` for the toolbar; `render` + * receives a `refresh` callback to re-read all controls after a change (needed + * because e.g. the preset enum rewrites several other settings). + */ +const control = (s: StyleSetting, refresh: () => void) => { + switch (s.type) { + case "bool": + return BUI.html` { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + s.set(!!(e.target as any).value); + refresh(); + }} + >`; + case "number": + return BUI.html` { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + s.set(Number((e.target as any).value)); + refresh(); + }} + >`; + case "color": + return BUI.html` { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const t = e.target as any; + s.set(String(t.color ?? t.value)); + refresh(); + }} + >`; + case "enum": + return BUI.html` { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const picked = (e.target as any).value?.[0]; + const match = s.options.find((o) => o.label === picked); + if (match) { + s.set(match.value); + refresh(); + } + }} + > + ${s.options.map( + (o) => BUI.html``, + )} + `; + } +}; + +export const stylesTool = (components: OBC.Components, world: OBC.World) => ({ + label: "Styles", + icon: "mdi:palette", + render: (refresh: () => void) => { + const { settings } = styles(components, world); + // Group settings by their `group` field, preserving first-seen order. + const groups: { name: string; items: StyleSetting[] }[] = []; + for (const s of settings) { + let g = groups.find((x) => x.name === s.group); + if (!g) { + g = { name: s.group, items: [] }; + groups.push(g); + } + g.items.push(s); + } + return BUI.html` +
+ ${groups.map( + (g) => BUI.html` +
+
${g.name}
+
+ ${g.items.map( + (s) => BUI.html`
${control(s, refresh)}
`, + )} +
+
+ `, + )} +
+ `; + }, +}); diff --git a/src/cli/templates/app/src/setups/styles.ts b/src/cli/templates/app/src/setups/styles.ts new file mode 100644 index 0000000..bd04590 --- /dev/null +++ b/src/cli/templates/app/src/setups/styles.ts @@ -0,0 +1,373 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; + +/** + * STYLES tool — a typed, generic descriptor over the viewer's visual settings so + * a UI (the bottom-left helper panel) can render controls without poking the + * postproduction renderer internals. + * + * Every entry reads/writes LIVE: the deferred composite re-reads its `settings` + * each frame, the AO/FXAA flags are read per-frame, and `renderScale` / + * `adaptiveResolution` apply on assignment — so no rebuild or lib change is + * needed. Mutating a setting takes effect on the next rendered frame. + * + * NOTE on presets: setting `Style preset` runs the engine's + * `_applyDeferredStyle`, which overwrites `Surface color`, `Edges` and + * `Ambient occlusion` to the preset's combination. After changing the preset, + * re-read all settings to refresh the panel. + */ +export type StyleSetting = + | { + key: string; + label: string; + group: string; + type: "bool"; + default: boolean; + get(): boolean; + set(v: boolean): void; + } + | { + key: string; + label: string; + group: string; + type: "number"; + default: number; + min: number; + max: number; + step: number; + get(): number; + set(v: number): void; + } + | { + key: string; + label: string; + group: string; + type: "color"; // value is a "#rrggbb" hex string + default: string; + get(): string; + set(v: string): void; + } + | { + key: string; + label: string; + group: string; + type: "enum"; + default: string | number; + options: { label: string; value: string | number }[]; + get(): string | number; + set(v: string | number): void; + }; + +const hex = (c: THREE.Color) => `#${c.getHexString()}`; + +// Background state is MODULE-scoped so it survives styles() re-creation (the +// graphics panel rebuilds the descriptor on every refresh — a per-instance +// `lastBg` re-clobbered a user-picked background to 0x202020 one frame after the +// pick, #30). `stylesLastBg` is the remembered OPAQUE colour: it persists even +// while transparent mode zeroes the renderer's clear colour, so toggling back to +// opaque restores the picked hue. The 0x202020 default is applied to the +// renderer exactly once. +let stylesBgInitialized = false; +const stylesLastBg = new THREE.Color(0x202020); + +export const styles = ( + components: OBC.Components, + world: OBC.World, +): { settings: StyleSetting[] } => { + const renderer = world.renderer as OBF.PostproductionRenderer; + + // Lazy accessors — resolved at call time so they always hit the live objects + // (the deferred pipeline is allocated once postproduction is up). + const pp = () => renderer.postproduction; + const deferred = () => renderer.postproduction.deferred; + const composite = () => renderer.postproduction.deferred.composite.settings; + const grid = () => components.get(OBC.Grids).list.get(world.uuid); + const scene = world.scene.three as THREE.Scene; + const three = () => renderer.three as THREE.WebGLRenderer; + + // Background is driven through the RENDERER CLEAR colour/alpha, not + // scene.background. Under the deferred pen pipeline the composite passes the + // captured clear straight through for background pixels, so the clear colour IS + // the viewport background; setting scene.background to a THREE.Color instead + // makes three force-clear the capture G-buffer mid-pass and the whole model + // vanishes (#30). Keep scene.background null and own the clear here. + scene.background = null; + // Apply the default opaque dark background ONCE (never on a rebuild — that was + // the #30 reset). The remembered colour itself is the module-scoped + // `stylesLastBg`, so a rebuild never clobbers a user pick. + if (!stylesBgInitialized) { + stylesBgInitialized = true; + three().setClearColor(stylesLastBg, 1); + } + + const A = OBF.PostproductionAspect; + + const settings: StyleSetting[] = [ + // ── Preset ────────────────────────────────────────────────────── + { + key: "preset", + label: "Style preset", + group: "Preset", + type: "enum", + default: A.COLOR_PEN_SHADOWS, + options: [ + { label: "Color + edges + shadows", value: A.COLOR_PEN_SHADOWS }, + { label: "Color", value: A.COLOR }, + { label: "Color + edges", value: A.COLOR_PEN }, + { label: "Color + shadows", value: A.COLOR_SHADOWS }, + { label: "Pen (edges only)", value: A.PEN }, + { label: "Pen + shadows", value: A.PEN_SHADOWS }, + ], + get: () => pp().style, + set: (v) => { + pp().style = v as OBF.PostproductionAspect; + }, + }, + { + key: "postproductionEnabled", + label: "Postproduction", + group: "Preset", + type: "bool", + default: true, + get: () => pp().enabled, + set: (v) => { + pp().enabled = v; + }, + }, + + // ── Edges (contour) ───────────────────────────────────────────── + { + key: "edges", + label: "Edges", + group: "Edges", + type: "bool", + default: true, + get: () => composite().contourEnabled, + set: (v) => { + composite().contourEnabled = v; + }, + }, + { + key: "edgeColor", + label: "Edge color", + group: "Edges", + type: "color", + default: "#000000", + get: () => hex(composite().edgeColor), + set: (v) => composite().edgeColor.set(v), + }, + { + key: "edgeStrength", + label: "Edge strength", + group: "Edges", + type: "number", + default: 0.8, + min: 0, + max: 1, + step: 0.05, + get: () => composite().edgeStrength, + set: (v) => { + composite().edgeStrength = v; + }, + }, + + // ── Shading ───────────────────────────────────────────────────── + { + key: "surfaceColor", + label: "Surface color (off = paper)", + group: "Shading", + type: "bool", + default: true, + get: () => composite().colorEnabled, + set: (v) => { + composite().colorEnabled = v; + }, + }, + { + key: "ao", + label: "Ambient occlusion", + group: "Shading", + type: "bool", + default: true, + get: () => deferred().settings.aoEnabled, + set: (v) => { + deferred().settings.aoEnabled = v; + }, + }, + { + key: "aoStrength", + label: "AO strength", + group: "Shading", + type: "number", + default: 1.0, + min: 0, + max: 2, + step: 0.05, + get: () => composite().aoStrength, + set: (v) => { + composite().aoStrength = v; + }, + }, + { + key: "tonalShading", + label: "Tonal shading", + group: "Shading", + type: "bool", + default: true, + get: () => composite().tonalShadingEnabled, + set: (v) => { + composite().tonalShadingEnabled = v; + }, + }, + { + key: "tonalFloor", + label: "Tonal floor", + group: "Shading", + type: "number", + default: 0.7, + min: 0, + max: 1, + step: 0.05, + get: () => composite().tonalFloor, + set: (v) => { + composite().tonalFloor = v; + }, + }, + + // ── Scene ─────────────────────────────────────────────────────── + { + key: "transparentBackground", + label: "Transparent background", + group: "Scene", + type: "bool", + default: true, + // Transparent === clear alpha 0 (page/scene behind shows through). + get: () => three().getClearAlpha() === 0, + set: (v) => { + scene.background = null; + if (v) { + // TRUE transparency: zero the RGB too, not just alpha. The composite + // passes the clear straight through and the canvas composites + // premultiplied, so a non-zero rgb at alpha 0 ADDS that colour over the + // page (the picked colour bled through as a tint). (0,0,0,0) = clean + // page-through with no tint. The hue is remembered in stylesLastBg. + three().setClearColor(0x000000, 0); + } else { + three().setClearColor(stylesLastBg, 1); + } + }, + }, + { + key: "backgroundColor", + label: "Background color", + group: "Scene", + type: "color", + // Reflect the remembered hue even while transparent (clear rgb is zeroed). + default: `#${stylesLastBg.getHexString()}`, + get: () => hex(stylesLastBg), + set: (v) => { + stylesLastBg.set(v); + scene.background = null; + // Choosing a colour means you want to SEE it → make the background + // opaque (alpha 1). The Transparent toggle re-reads alpha on refresh and + // flips off to match. + three().setClearColor(stylesLastBg, 1); + }, + }, + { + key: "grid", + label: "Grid", + group: "Scene", + type: "bool", + default: true, + get: () => grid()?.config.visible ?? false, + set: (v) => { + const g = grid(); + if (g) g.config.visible = v; + }, + }, + { + key: "gridColor", + label: "Grid color", + group: "Scene", + type: "color", + default: "#d3d3d3", + get: () => { + const g = grid(); + return g ? hex(g.config.color) : "#d3d3d3"; + }, + set: (v) => { + const g = grid(); + if (g) g.config.color = new THREE.Color(v); + }, + }, + + // ── Quality / performance ─────────────────────────────────────── + { + key: "fxaa", + label: "Anti-aliasing (FXAA)", + group: "Quality", + type: "bool", + default: true, + get: () => deferred().settings.fxaaEnabled, + set: (v) => { + deferred().settings.fxaaEnabled = v; + }, + }, + { + key: "renderScale", + label: "Render scale", + group: "Quality", + type: "number", + default: 1.0, + min: 0.25, + max: 1, + step: 0.05, + get: () => deferred().renderScale, + set: (v) => { + deferred().renderScale = v; + }, + }, + { + key: "adaptiveResolution", + label: "Adaptive resolution", + group: "Quality", + type: "bool", + default: true, + get: () => renderer.adaptiveResolution, + set: (v) => { + renderer.adaptiveResolution = v; + }, + }, + { + key: "targetFps", + label: "Target FPS", + group: "Quality", + type: "number", + default: 60, + min: 24, + max: 120, + step: 5, + get: () => renderer.adaptiveTargetFps, + set: (v) => { + renderer.adaptiveTargetFps = v; + }, + }, + { + key: "highResOutline", + label: "High-res selection outline", + group: "Quality", + type: "bool", + default: true, + // false → render the selection outline at half resolution (cheaper when a + // selection covers much of the screen, slightly softer); true → full res. + get: () => components.get(OBF.Outliner).resolutionScale >= 1, + set: (v) => { + components.get(OBF.Outliner).resolutionScale = v ? 1 : 0.5; + }, + }, + ]; + + return { settings }; +}; diff --git a/src/cli/templates/app/src/setups/tool-mode-manager.ts b/src/cli/templates/app/src/setups/tool-mode-manager.ts new file mode 100644 index 0000000..1327216 --- /dev/null +++ b/src/cli/templates/app/src/setups/tool-mode-manager.ts @@ -0,0 +1,150 @@ +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; + +/** + * Central single-active-tool manager (explicit-state discipline per the grimoire + * interaction-editing/modal-tools/state-machine-discipline): exactly ONE modal + * tool (Section / Measure / future walkthrough …) is active at a time, and while + * ANY tool is active the viewer's passive interactions — the Hoverer overlay and + * the Highlighter (hover + click-select) — are suppressed so they don't fight the + * tool's own picking. Both are restored when the last tool exits. + * + * A tool registers a {@link ManagedTool} and calls {@link setActive} on enter / + * {@link clearActive} on exit. When another tool takes over, the manager calls + * the previous tool's {@link ManagedTool.onDeactivate} (LOCAL teardown only — it + * must not call back into the manager, to avoid re-entrancy). + * + * Reachable as a per-Components singleton via {@link toolModeManager} so panels + * (and later walkthrough/others) can route through the same instance. (Flagging: + * a plain memoised singleton, not an OBC.Component — say if you'd rather it be + * components.get()-able and I'll wrap it.) + */ +export interface ManagedTool { + readonly id: string; + /** + * Human-readable description of what the user is doing while this tool is + * active (e.g. "Drawing clipping plane", "Measuring length"). Surfaced by the + * active-tool HUD — which only reads this, so any future tool that registers a + * label shows up there with no HUD changes. May be a function for tools whose + * label varies while active (call {@link ToolModeManager.refresh} to update). + */ + readonly label: string | (() => string); + /** Optional mdi icon name (e.g. "mdi:scissors-cutting") shown next to the label. */ + readonly icon?: string; + /** Called when another tool takes over; do LOCAL teardown only (no manager calls). */ + onDeactivate(): void; +} + +export class ToolModeManager { + // Always a real tool — the resting default is `selectTool` (never null), so + // SELECT is a first-class state: reflected by the HUD, and entered via the same + // setActive() exclusivity path as every other tool (so clicking Select + // deactivates whatever modal tool was active, just like clip↔measure). + private _active: ManagedTool; + private _suppressed = false; + private _prevHovererEnabled = true; + private _prevHighlighterEnabled = true; + + /** Fires whenever the active tool changes (carries the new active tool, which + * is `selectTool` in the default mode). `| null` kept for HUD back-compat. */ + readonly onActiveChanged = new OBC.Event(); + + /** + * The default SELECT tool — plain object hover + click-select. It is the + * resting active tool (on load and after any modal tool exits) and is the ONE + * tool that does NOT suppress viewer interaction (Select IS that mode). + */ + private readonly selectTool: ManagedTool = { + id: ToolModeManager.SELECT_ID, + label: "Select", + icon: "mdi:cursor-default", + onDeactivate: () => {}, + }; + + constructor(private readonly components: OBC.Components) { + this._active = this.selectTool; + } + + /** + * Id of the implicit default mode: idle / plain object-selection. Active on load + * and whenever no modal tool is engaged. The Select toolbar button maps to it, + * and the HUD reflects it as the resting state. + */ + static readonly SELECT_ID = "select"; + + get active(): ManagedTool { + return this._active; + } + + /** The active tool's id ({@link ToolModeManager.SELECT_ID} in the default mode). */ + getActiveId(): string { + return this._active.id; + } + + /** True in the default object-selection mode (no modal tool active). */ + get isSelectMode(): boolean { + return this._active === this.selectTool; + } + + /** + * Return to SELECT (the default): exit whatever modal tool is active so the + * viewer is back to idle hover + click-select. No-op if already in select. The + * Select toolbar button calls this; individual tools still exit via clearActive. + */ + selectMode() { + this.setActive(this.selectTool); + } + + /** Make `tool` the single active tool, deactivating the previous one. */ + setActive(tool: ManagedTool) { + if (this._active === tool) return; + this._active.onDeactivate(); // tear down the previous tool (selectTool: no-op) + this._active = tool; + // Suppress passive hover/select for MODAL tools only — selectTool IS that mode. + this._applySuppression(tool !== this.selectTool); + this.onActiveChanged.trigger(this._active); + } + + /** Exit `tool` back to the default SELECT tool (no-op unless it's the active one). */ + clearActive(tool: ManagedTool) { + if (this._active !== tool) return; + this.setActive(this.selectTool); + } + + /** + * Re-emit the current active tool without changing it — for a tool whose + * dynamic {@link ManagedTool.label} changed while it stayed active (e.g. the + * measurement tool switching length → area), so the HUD re-reads the label. + */ + refresh() { + this.onActiveChanged.trigger(this._active); + } + + private _applySuppression(suppress: boolean) { + if (suppress === this._suppressed) return; // only act on a real transition + const hoverer = this.components.get(OBF.Hoverer); + const highlighter = this.components.get(OBF.Highlighter); + if (suppress) { + this._prevHovererEnabled = hoverer.enabled; + this._prevHighlighterEnabled = highlighter.enabled; + hoverer.enabled = false; + highlighter.enabled = false; + } else { + hoverer.enabled = this._prevHovererEnabled; + highlighter.enabled = this._prevHighlighterEnabled; + } + this._suppressed = suppress; + } +} + +const instances = new WeakMap(); + +/** Per-Components singleton accessor. */ +export const toolModeManager = (components: OBC.Components): ToolModeManager => { + let manager = instances.get(components); + if (!manager) { + manager = new ToolModeManager(components); + instances.set(components, manager); + } + return manager; +}; diff --git a/src/cli/templates/app/src/setups/tool-mode.ts b/src/cli/templates/app/src/setups/tool-mode.ts new file mode 100644 index 0000000..0b4d95b --- /dev/null +++ b/src/cli/templates/app/src/setups/tool-mode.ts @@ -0,0 +1,23 @@ +/** + * Controller returned by each toolbar mode-tool setup (`clipper`, + * `lengthMeasurement`, `areaMeasurement`, …). The toolbar — owned by Worker 2 — + * calls {@link activate} to enter the tool's mode and {@link deactivate} to exit + * it, and guarantees only one mode is active at a time (mutual exclusion). + * + * Contract: + * - `activate()` — enable the tool's pointer/keyboard listeners + cursor. + * - `deactivate()` — remove those listeners and drop any transient in-progress + * state. It must NOT discard finished results (e.g. existing + * section planes / measurements persist across mode switches). + * Both are idempotent. + */ +export interface ModeTool { + /** Human label, used for the toolbar button tooltip. */ + label: string; + /** Iconify icon name, e.g. "mdi:scissors-cutting". */ + icon: string; + /** Enter the tool's mode. */ + activate(): void; + /** Exit the tool's mode and clean up transient state. */ + deactivate(): void; +} diff --git a/src/cli/templates/app/src/setups/toolbar.ts b/src/cli/templates/app/src/setups/toolbar.ts new file mode 100644 index 0000000..676a5c5 --- /dev/null +++ b/src/cli/templates/app/src/setups/toolbar.ts @@ -0,0 +1,229 @@ +import * as OBC from "@thatopen/components"; +import * as BUI from "@thatopen/ui"; +import { cameraTools } from "./camera-tools"; +import { hider } from "./hider"; +import { clipper } from "./clipper"; +import { lengthMeasurement, areaMeasurement } from "./measurements"; +import type { HelperPanelController } from "./helper-panel"; +import { stylesTool } from "./styles-panel"; + +/** + * A floating tool palette centered at the BOTTOM-CENTER of the viewport. Built + * on BUI's native `bim-toolbar` (icon-only `bim-button`s with hover tooltips), + * placed in a `bim-grid floating` overlay so the bar is interactive while empty + * areas click through to the 3D scene. Styled to match the cards (dark + * `bg-base`, rounded, subtle shadow). + * + * Tool kinds (the modules' integration contract): + * - ACTION `{ label, icon, run() }` — fire-and-forget. + * - MODE `{ label, icon, activate(), deactivate() }` — mutually exclusive: + * activating one deactivates the current; clicking the active one + * turns it off. + * - TOGGLE `{ label, icon, activate(), deactivate(), active?() }` — independent + * on/off (e.g. ortho). + * - PANEL `{ label, icon, render(refresh) }` — toggles the bottom-left helper + * panel showing that tool's content (e.g. Styles). Mutually exclusive + * among panel tools; INDEPENDENT of viewport modes (opening Styles + * doesn't cancel an active section/measure mode, and vice versa). + * + * Only this file assembles the toolbar; other workers deliver tool controllers + * (camera here; hider from hider.ts; clipper/measure to be added when ready). + * + * @param components engine components + * @param container the viewport element to overlay + */ +type ActionTool = { + kind: "action"; + label: string; + icon: string; + run: () => void | Promise; +}; +type ModeTool = { + kind: "mode"; + label: string; + icon: string; + activate: () => void | Promise; + deactivate: () => void | Promise; +}; +type ToggleTool = { + kind: "toggle"; + label: string; + icon: string; + activate: () => void | Promise; + deactivate: () => void | Promise; + active?: () => boolean; +}; +type PanelTool = { + kind: "panel"; + label: string; + icon: string; + render: (refresh: () => void) => unknown; +}; +type Tool = ActionTool | ModeTool | ToggleTool | PanelTool; + +export const toolbar = ( + components: OBC.Components, + container: HTMLElement, + helper: HelperPanelController, // the left-stack helper card (panel tools drive it) +) => { + const cam = cameraTools(components); + const vis = hider(components); + // World for the MODE tools (clipper/measurements need a world + its canvas). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const world = [...components.get(OBC.Worlds).list.values()][0] as any; + // MODE controllers ({ label, icon, activate, deactivate }) — only built when a + // world exists (it does: the viewport mounts before the toolbar). + const modeTools: Tool[] = world + ? [ + { kind: "mode", ...clipper(components, world) }, + { kind: "mode", ...lengthMeasurement(components, world) }, + { kind: "mode", ...areaMeasurement(components, world) }, + ] + : []; + + // Groups → rendered as separate toolbar sections (auto-divider between them). + // Order: [view: fit, ortho] | [tools: section/measure — pending] | [visibility]. + const groups: Tool[][] = [ + [ + { kind: "action", label: "Zoom to fit", icon: cam.fitAll.icon, run: cam.fitAll.run }, + { + kind: "toggle", + label: "Orthographic", + icon: cam.orthoToggle.icon, + activate: cam.orthoToggle.activate, + deactivate: cam.orthoToggle.deactivate, + active: cam.orthoToggle.active, + }, + ] as Tool[], + // Mode tools (section / measure) — mutually exclusive. + modeTools, + [ + { kind: "action", label: "Hide", icon: "mdi:eye-off-outline", run: vis.hideSelected }, + { kind: "action", label: "Isolate", icon: "mdi:select-search", run: vis.isolateSelected }, + { kind: "action", label: "Show all", icon: "mdi:eye-outline", run: vis.showAll }, + ] as Tool[], + // Panel tools (open the bottom-left helper). Styles needs a world. + world + ? ([{ kind: "panel", ...stylesTool(components, world) }] as Tool[]) + : [], + ].filter((g) => g.length > 0); + + // ── Active-state bookkeeping ─────────────────────────────────── + let activeMode: ModeTool | null = null; // mutual exclusion across all modes + let activePanel: PanelTool | null = null; // exclusive among panel tools + const onToggle = new Set(); // independent toggles that are on + + const isActive = (t: Tool) => + t.kind === "mode" + ? t === activeMode + : t.kind === "panel" + ? t === activePanel + : t.kind === "toggle" + ? (t.active ? t.active() : onToggle.has(t)) + : false; + + const onClick = async (t: Tool) => { + if (t.kind === "action") { + await t.run(); + } else if (t.kind === "mode") { + if (activeMode === t) { + await t.deactivate(); + activeMode = null; + } else { + if (activeMode) await activeMode.deactivate(); + await t.activate(); + activeMode = t; + } + } else if (t.kind === "panel") { + // Toggle the helper panel; exclusive among panel tools, independent of modes. + if (activePanel === t) { + activePanel = null; + helper.clear(); + } else { + activePanel = t; + helper.show({ + title: t.label, + icon: t.icon, + render: () => t.render(() => helper.refresh()), + }); + } + } else { + const on = isActive(t); + if (on) { + await t.deactivate(); + onToggle.delete(t); + } else { + await t.activate(); + onToggle.add(t); + } + } + refresh(); + }; + + let tick = 0; + const refresh = () => barUpdate({ tick: (tick += 1) }); + + const [bar, barUpdate] = BUI.Component.create( + // Param required: BUI.Component.create returns a single element (not the + // [element, update] tuple) when the template has arity 0. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_state) => BUI.html` + + ${groups.map( + (group) => BUI.html` + + ${group.map( + (t) => BUI.html` + onClick(t)} + style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;" + >${t.label} + `, + )} + + `, + )} + + `, + { tick: 0 }, + ); + + // Floating grid: bar docked bottom-center. Row 1 (1fr) empty filler above; + // row 2 (auto) holds the bar in the center column, flanked by 1fr columns so + // it stays horizontally centered. Empty areas click through. + const grid = BUI.Component.create(() => { + const onCreated = (element?: Element) => { + if (!element) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = element as any; + g.elements = { bar }; + g.layouts = { + main: { + template: ` + "fillL fillC fillR" 1fr + "restL bar restR" auto + / 1fr auto 1fr + `, + }, + }; + g.layout = "main"; + }; + return BUI.html` + + `; + }); + container.append(grid); + + return bar; +}; diff --git a/src/cli/templates/app/src/setups/ui-manager.ts b/src/cli/templates/app/src/setups/ui-manager.ts new file mode 100644 index 0000000..f099151 --- /dev/null +++ b/src/cli/templates/app/src/setups/ui-manager.ts @@ -0,0 +1,27 @@ +import * as OBC from "@thatopen/components"; +import * as BUI from "@thatopen/ui"; +import { UIManager } from "@thatopen/services"; +import { + appInfoSectionTemplate, + AppInfoSectionState, + cloudRunnerSectionTemplate, + CloudRunnerSectionState, +} from "../ui-components"; + +export type CustomUIs = { + appInfoSection: { type: BUI.PanelSection; state: AppInfoSectionState }; + cloudRunnerSection: { type: BUI.PanelSection; state: CloudRunnerSectionState }; +}; + +export const getUIManager = (components: OBC.Components) => + components.get(UIManager); + +export const uiManager = (components: OBC.Components) => { + const uis = getUIManager(components); + uis.registerTemplate("appInfoSection", { + template: appInfoSectionTemplate, + }); + uis.registerTemplate("cloudRunnerSection", { + template: cloudRunnerSectionTemplate, + }); +}; diff --git a/src/cli/templates/app/src/setups/viewports-manager.ts b/src/cli/templates/app/src/setups/viewports-manager.ts new file mode 100644 index 0000000..fab3edc --- /dev/null +++ b/src/cli/templates/app/src/setups/viewports-manager.ts @@ -0,0 +1,280 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; +import { ViewportsManager } from "@thatopen/services"; + +// ─── RAW VIEWER + LAYER 1: deferred postproduction ─────────────────── +// Building back up from the bare viewer one block at a time to find the orbit +// cost. ADDED so far: deferred postproduction (COLOR_PEN_SHADOWS). Still OMITTED: +// adaptive resolution, Hoverer, Highlighter/Raycaster, Outliner, frame overlay. +// Full version saved at `viewports-manager.full.ts.bak`. +export const viewportsManager = async (components: OBC.Components) => { + const viewports = components.get(ViewportsManager); + const { element, world } = await viewports.create(); + + const renderer = world.renderer!; + renderer.showLogo = false; + + // A defaults to display:inline, which leaves a few px of baseline + // descender space below it → a small margin at the viewer's bottom. Block kills it. + renderer.three.domElement.style.display = "block"; + + // Give the viewer the same card chrome as the bim-panels (1px contrast-20 + // border + 0.75rem radius), so it reads as a card alongside them. + element.style.border = "1px solid var(--bim-ui_bg-contrast-20)"; + element.style.borderRadius = "0.75rem"; + element.style.overflow = "hidden"; + + // Auto-anchor: library dynamicAnchor picks the surface on left-press (single + // pick, no per-move cost) and sets the orbit pivot; we render a dot off its + // onDynamicAnchorSet/Clear events (wired below). + world.dynamicAnchor = true; + world.camera.threePersp.near = 1; + world.camera.threePersp.updateProjectionMatrix(); + + await world.camera.controls.setLookAt(20, 20, 20, 0, 0, 0); + + // ── Enhanced camera controls (library feature, opt-in) ─────────────── + // Ortho-only: faster, proportional frustum zoom in orthographic (fixes the slow + // ortho wheel zoom). Perspective zoom + orbit use the library defaults. Plan + // mode is left untouched. Cleaned up automatically on camera dispose. + world.camera.setupEnhancedControls({ orthoZoomSpeed: 0.15 }); + + // ── Resize reconcile + "light resize" ──────────────────────────── + // While the window/container is actively resizing, the per-frame reconcile + // would reallocate the deferred G-buffer every frame (expensive) → fps drops + // during the drag. So: the MOMENT the size starts changing we turn + // postproduction OFF (cheap forward render) and only do the lightweight + // setSize each frame; the heavy `applyPostproductionSize` + postproduction + // restore happen ONCE, after a quiet BUFFER (no size change for ~250ms), so it + // never flickers on/off during a continuous drag. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let postpro: any = null; // captured once the deferred pipeline is configured + let resizing = false; + let postproWasEnabled = true; + let hovererWasEnabled = true; + let settleTimer: ReturnType | undefined; + const RESIZE_BUFFER = 250; // ms of stillness before restoring postproduction + + let lastW = -1; + let lastH = -1; + let lastDpr = -1; + let lastChangeAt = -Infinity; // performance.now() of the previous size change + const RAPID_MS = 200; // back-to-back changes faster than this ⇒ a live drag + const applyResize = () => { + if (!renderer.currentWorld) return; + const w = element.clientWidth; + const h = element.clientHeight; + const dpr = Math.min(window.devicePixelRatio, 2); + if (w === 0 || h === 0) return; + if (w === lastW && h === lastH && dpr === lastDpr) return; + lastW = w; + lastH = h; + lastDpr = dpr; + + const now = performance.now(); + const rapid = now - lastChangeAt < RAPID_MS; + lastChangeAt = now; + + if (rapid && !resizing) { + // SUSTAINED changing (a live window/container DRAG): switch to the cheap + // path — the per-frame `renderer.resize()` realloc is what tanks fps, so we + // do NOT touch the buffer during the drag. The canvas is set to CSS 100% + // and simply STRETCHES the existing (fixed-size) buffer — cheap, slightly + // soft. We also kill mouse events and drop postproduction. The one real + // resize happens on settle. + resizing = true; + const canvas = renderer.three.domElement; + canvas.style.width = "100%"; + canvas.style.height = "100%"; + element.style.pointerEvents = "none"; + if (postpro) { + postproWasEnabled = postpro.enabled; + postpro.enabled = false; + } + // No hover-highlight while actively resizing/dragging. + try { + const hv = components.get(OBF.Hoverer); + hovererWasEnabled = hv.enabled; + hv.enabled = false; + } catch { + /* hoverer not set up yet */ + } + } else if (!rapid && !resizing) { + // ISOLATED, one-shot change — e.g. a LAYOUT switch (panel docks/undocks), + // which is INSTANTANEOUS. Do the full, correct resize right now and KEEP + // postproduction on, so there's no flicker/disable on layout changes. If + // this turns out to be the first frame of a drag, the next (rapid) change + // flips us into the cheap path above. + renderer.three.setPixelRatio(dpr); + renderer.resize(); + world.camera.updateAspect(); + renderer.applyPostproductionSize?.(); + // Force one re-composite at the new size. The deferred pipeline only + // recomposites on demand, so without this a stale frame (e.g. the clip + // section's overlay rendering white at the wrong resolution) persists until + // the next camera move. + renderer.update(); + } + + // (Re)arm the settle buffer: finalize a DRAG once the size has been stable + // for RESIZE_BUFFER ms (one real resize + restore interaction/postproduction). + // For a one-shot change `resizing` stays false, so this is a no-op. + if (settleTimer) clearTimeout(settleTimer); + settleTimer = setTimeout(() => { + settleTimer = undefined; + if (!resizing) return; // one-shot already handled immediately above + resizing = false; + element.style.pointerEvents = ""; + renderer.three.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.resize(); // reallocates to the settled container size + world.camera.updateAspect(); + renderer.applyPostproductionSize?.(); + if (postpro) postpro.enabled = postproWasEnabled; + // Re-composite at the settled size so the section overlay (and everything) + // refreshes immediately instead of staying stale/white until a camera move. + renderer.update(); + try { + components.get(OBF.Hoverer).enabled = hovererWasEnabled; + } catch { + /* hoverer not set up */ + } + }, RESIZE_BUFFER); + }; + window.addEventListener("resize", applyResize); + new ResizeObserver(applyResize).observe(element); + renderer.onBeforeUpdate.add(applyResize); + + // ── LAYER 3: Hover highlight (Hoverer) ──────────────────────────── + // Animated proxy-mesh overlay on whichever element is under the cursor. Its + // material is isolated into the base pass (below) so it shows over the + // deferred composite. The Hoverer self-suppresses during camera drags. + const hoverer = components.get(OBF.Hoverer); + hoverer.world = world; + // Continuous hover-follow (MOUSE_MOVE, the default). This used to drop FPS + // because the picker's per-frame GPU readback stalled the frame — now fixed in + // the library (FastModelPicker reads back asynchronously), so continuous hover + // runs at full framerate. + hoverer.enabled = true; + hoverer.material = new THREE.MeshBasicMaterial({ + color: 0xb79bf0, + transparent: true, + opacity: 0.3, + depthTest: false, + }); + + // ── LAYER 4: click selection (Highlighter + Raycaster + Outliner) ── + // GPU-picked click selection; rendered as an outline via the Outliner (wired + // in setupPostproduction once the pipeline exists). selectMaterialDefinition + // null = no fragment recolor (the Outliner draws the selection instead). + components.get(OBC.Raycasters).get(world); + const highlighter = components.get(OBF.Highlighter); + highlighter.setup({ world, selectMaterialDefinition: null }); + + // ── Auto-anchor pivot dot (driven by the library's dynamicAnchor) ── + // dynamicAnchor picks the surface on left-press and fires onDynamicAnchorSet + // with the pivot; we render a dot there. The dot is an HTML overlay projected + // from the 3D pivot each frame — NOT a 3D mesh — so its colour is the EXACT app + // accent purple (#6528d7); a 3D mesh gets recoloured by the deferred composite. + if (!element.style.position) element.style.position = "relative"; + const anchorDotEl = document.createElement("div"); + anchorDotEl.style.cssText = + "position:absolute; width:13px; height:13px; border-radius:50%;" + + "background:#6528d7; transform:translate(-50%,-50%);" + + "pointer-events:none; display:none; z-index:5;" + + "box-shadow:0 0 0 2px rgba(255,255,255,0.35);"; + element.appendChild(anchorDotEl); + let anchorWorld: THREE.Vector3 | null = null; + const positionAnchorDot = () => { + if (!anchorWorld) return; + const ndc = anchorWorld.clone().project(world.camera.three); + anchorDotEl.style.left = `${(ndc.x * 0.5 + 0.5) * element.clientWidth}px`; + anchorDotEl.style.top = `${(-ndc.y * 0.5 + 0.5) * element.clientHeight}px`; + }; + // Cache the pivot on press; show the dot only once a real drag starts (so a + // click-to-select doesn't flash it). + let anchorShown = false; + world.onDynamicAnchorSet.add((point: THREE.Vector3) => { + anchorWorld = point.clone(); + anchorShown = false; + positionAnchorDot(); + }); + world.onDynamicAnchorClear.add(() => { + anchorWorld = null; + anchorShown = false; + anchorDotEl.style.display = "none"; + }); + const DRAG_THRESHOLD = 6; // px before the dot appears + let pressStart: { x: number; y: number } | null = null; + element.addEventListener("pointerdown", (e) => { + if (e.button === 0) pressStart = { x: e.clientX, y: e.clientY }; + }); + element.addEventListener("pointermove", (e) => { + if (!pressStart || !anchorWorld || anchorShown) return; + const dx = e.clientX - pressStart.x; + const dy = e.clientY - pressStart.y; + if (dx * dx + dy * dy >= DRAG_THRESHOLD * DRAG_THRESHOLD) { + anchorShown = true; + positionAnchorDot(); + anchorDotEl.style.display = "block"; + } + }); + const clearPress = () => { + pressStart = null; + }; + element.addEventListener("pointerup", clearPress); + element.addEventListener("pointercancel", clearPress); + // Keep the dot glued to the 3D pivot as the camera orbits around it. + renderer.onBeforeUpdate.add(positionAnchorDot); + + // ── LAYER 1: deferred postproduction (NO adaptive resolution yet) ── + // Allocate the deferred pipeline once the viewport has a real (non-zero) size. + const size = new THREE.Vector2(); + let configured = false; + const setupPostproduction = () => { + if (configured || !renderer.currentWorld) return; + renderer.three.getSize(size); + if (size.x < 2 || size.y < 2) return; + + const { postproduction } = renderer; + // The platform's app iframe can fire an early resize before the deferred + // pipeline is allocated. Bail WITHOUT marking `configured` so a later + // resize / world-change retries once it exists — instead of throwing on + // `postproduction.enabled` and leaving the pipeline half-configured. + if (!postproduction) return; + configured = true; + + postpro = postproduction; // let the resize handler toggle it during drags + postproduction.enabled = true; + postproduction.style = OBF.PostproductionAspect.COLOR_PEN_SHADOWS; + + // Keep the floor grid + hover overlay visible over the deferred composite. + const grid = components.get(OBC.Grids).list.get(world.uuid); + if (grid) postproduction.basePass.isolatedMaterials.push(grid.material); + postproduction.basePass.isolatedMaterials.push(hoverer.material); + + void postproduction.deferred; + postproduction.mode = OBF.PostproductionMode.DEFERRED; + + // ── LAYER 4 (cont.): selection Outline ────────────────────────── + // The Outliner draws through the postproduction pipeline, so wire it after + // the pipeline is up. Selecting (via the Highlighter "select" style) outlines + // the element; deselecting removes it. + const outliner = components.get(OBF.Outliner); + outliner.world = world; + outliner.color = new THREE.Color(0x6528d7); + outliner.fillColor = new THREE.Color(0x6528d7); + outliner.fillOpacity = 0.4; + outliner.enabled = true; + highlighter.events.select.onHighlight.add((map) => outliner.addItems(map)); + highlighter.events.select.onClear.add((map) => outliner.removeItems(map)); + + // NOTE: adaptive resolution intentionally OFF here to measure the pipeline's + // raw orbit cost. `renderer.cssSize`/`adaptiveResolution` come in a later layer. + }; + renderer.onWorldChanged.add(setupPostproduction); + renderer.onResize.add(setupPostproduction); + setupPostproduction(); + + return element; +}; diff --git a/src/cli/templates/app/src/setups/visibility-toolbar.ts b/src/cli/templates/app/src/setups/visibility-toolbar.ts new file mode 100644 index 0000000..e0611e3 --- /dev/null +++ b/src/cli/templates/app/src/setups/visibility-toolbar.ts @@ -0,0 +1,498 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; +import * as BUI from "@thatopen/ui"; +import type { WalkthroughController } from "./walkthrough"; +import { toolModeManager } from "./tool-mode-manager"; +import type { InspectionAction } from "./inspection"; + +/** + * Floating "visibility" toolbar — bottom-center over the viewport. + * + * A self-mounting `bim-toolbar` (matching the look of `toolbar.ts`: dark + * `bg-base`, rounded, subtle shadow, icon-only `bim-button`s with standalone + * `` hover tooltips) placed inside a floating `bim-grid` overlay so + * empty areas click through to the 3D scene. + * + * ── MODE TOGGLE ──────────────────────────────────────────────────────────── + * One labeled toggle button flips the *target* every action operates on: + * - "Selected" → the current Highlighter select set (`selection.select`). + * If nothing is selected the target is EMPTY (actions no-op). + * - "Unselected" → everything that is NOT selected. If nothing is selected the + * target is the WHOLE model. + * + * ── ACTIONS (each reversible via Show / Reset) ───────────────────────────── + * - HIDE → hide the target set. + * - SHOW → un-hide the target set. + * - ISOLATE → show only the target set, hide everything else. + * - GHOST → render the target set semi-transparent; opaque rest. + * - RESET → restore full visibility + opacity (clears hide AND ghost). + * + * Combos give full control, e.g.: + * - Unselected + Hide = isolate the selection. + * - Selected + Hide = hide the selection. + * - Show / Reset = restore. + * + * ── VISIBILITY API ───────────────────────────────────────────────────────── + * Visibility (hide/show/isolate) goes through `OBC.Hider` (`set` / `isolate`), + * which drives the fragments' per-item visibility flag and triggers the core + * redraw itself — NOT the Highlighter recolor-on-hover path (honoring the perf + * rule). Ghosting uses a dedicated transparent Highlighter style ("ghost") — + * the idiomatic flat x-ray the library's Hoverer example calls "ghost mode". + * It renders a uniform faint translucent gray shell as a flat overlay (NOT + * through the deferred pen-shadows emitter, which looked muddy), is fully + * reversible via `highlighter.clear("ghost")`, and coexists with the "select" + * style so selection still works while items are ghosted. + * + * ── MOUNTING ─────────────────────────────────────────────────────────────── + * `visibilityToolbar(components)` builds the floating overlay AND appends it to + * the viewport itself (it locates the viewport like the other setups do), then + * returns the `bim-toolbar` element. To wire it from main.ts, add this single + * line after the viewport + Highlighter exist (e.g. next to the `toolbar(...)` + * call): + * + * visibilityToolbar(components); + * + * @param components engine components + * @param container optional viewport element to overlay; if omitted, the first + * world's renderer container (the viewport) is used. + */ + +type TargetMode = "selected" | "unselected"; + +// Minimal toggle-controller shape (exploded view, etc.) — a single toolbar +// button can drive any controller exposing this surface. +type ToggleCtrl = { + toggle(): void; + isActive(): boolean; + onChange(cb: (active: boolean) => void): () => void; +}; + +const isModelIdMapEmpty = (map?: OBC.ModelIdMap) => + !map || !Object.values(map).some((set) => set.size > 0); + +export const visibilityToolbar = ( + components: OBC.Components, + container?: HTMLElement, + walk?: WalkthroughController, + explode?: ToggleCtrl, + inspection?: InspectionAction[], +) => { + const fragments = components.get(OBC.FragmentsManager); + const hider = components.get(OBC.Hider); + const highlighter = components.get(OBF.Highlighter); + + // ── Ghost / x-ray (scalable per-element GPU state texture) ────────────── + // The legacy approach recolored fragments per item via a Highlighter "ghost" + // style (highlightByID) — O(elements) CPU churn that breaks batching. Ghost + // now lives on the GPU: `model.setGhostItems(localIds)` flips a per-element + // state texture sampled in the SHELL shader (faint screen-door + desaturate), + // so it scales to millions of elements with no per-item CPU work. See + // engine_fragments-beta ghost-emission.ts / material-manager.ts. + let mode: TargetMode = "selected"; + + // ── Target-set resolution ────────────────────────────────────────────── + const selection = (): OBC.ModelIdMap | undefined => highlighter.selection.select; + + /** + * The ModelIdMap the current mode targets. + * - selected: the select set (empty → empty map). + * - unselected: every loaded item minus the select set (nothing selected → + * the whole model). + * Built async because the "unselected" set is derived from each model's full + * item list (via getItemsByVisibility(true|false) union = all items). + */ + const targetMap = async (): Promise => { + const sel = selection(); + if (mode === "selected") { + // Clone defensively (Hider/iteration shouldn't mutate the live set). + const out: OBC.ModelIdMap = {}; + if (sel) { + for (const [modelId, set] of Object.entries(sel)) { + if (set.size > 0) out[modelId] = new Set(set); + } + } + return out; + } + // unselected = all items in every model, minus the selected ones. + const out: OBC.ModelIdMap = {}; + for (const [modelId, model] of fragments.list) { + // Union of visible + hidden = every item the model knows about. + const [vis, hid] = await Promise.all([ + model.getItemsByVisibility(true), + model.getItemsByVisibility(false), + ]); + const all = new Set([...vis, ...hid]); + const selSet = sel?.[modelId]; + if (selSet) for (const id of selSet) all.delete(id); + if (all.size > 0) out[modelId] = all; + } + return out; + }; + + // ── Ghost helpers (scalable GPU hash table) ───────────────────────────── + // Always hash the SMALL key set (the selection) + an invert flag, so the + // table stays tiny whether we ghost the selection or everything-but it: + // - "selected" → ghost the selection (invert=false). + // - "unselected" → ghost everything EXCEPT the selection (invert=true), + // applied to every loaded model (a model with no selection → empty key set + // + invert=true → fully ghosted). + const applyGhost = async () => { + const sel = selection() ?? {}; + const invert = mode === "unselected"; + const tasks: Promise[] = []; + for (const model of fragments.list.values()) { + const set = sel[model.modelId]; + const ids = set ? [...set] : []; + if (!invert && ids.length === 0) continue; // ghost-selected with nothing selected + // setGhostItems is async (localId→itemId conversion) — update after all. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tasks.push((model as any).setGhostItems(ids, invert)); + } + await Promise.all(tasks); + await fragments.core.update(true); + }; + + /** Clear the ghost overlay on every loaded model. */ + const clearGhost = async () => { + for (const model of fragments.list.values()) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (model as any).clearGhost?.(); + } + await fragments.core.update(true); + }; + + /** + * Un-ghost the given items in place (without disturbing the rest of the ghost + * state). Making items visible should also clear their ghost, so a shown + * element never lingers as a ghost. + */ + const unghost = async (map: OBC.ModelIdMap) => { + const tasks: Promise[] = []; + for (const model of fragments.list.values()) { + const set = map[model.modelId]; + if (!set || set.size === 0) continue; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tasks.push((model as any).unsetGhostItems?.([...set])); + } + if (!tasks.length) return; + await Promise.all(tasks); + await fragments.core.update(true); + }; + + // ── Actions ───────────────────────────────────────────────────────────── + const doHide = async () => { + const map = await targetMap(); + if (isModelIdMapEmpty(map)) return; // nothing to hide (e.g. Selected w/ no selection) + await hider.set(false, map); + }; + + const doShow = async () => { + const map = await targetMap(); + // For "unselected" w/ no selection this is the whole model → show all. + if (isModelIdMapEmpty(map)) { + await hider.set(true); + await clearGhost(); // all visible → nothing should stay ghosted + return; + } + await hider.set(true, map); + await unghost(map); // shown items un-ghost + }; + + const doIsolate = async () => { + const map = await targetMap(); + if (isModelIdMapEmpty(map)) return; // isolating an empty set would blank the model + await hider.isolate(map); + await unghost(map); // the isolated (visible) items un-ghost + }; + + const doGhost = async () => { + await applyGhost(); + }; + + // Frame the camera on the target set's merged bounding box (same fit the tree's + // Focus button uses). Targets selected or unselected per the mode toggle. + const doFocus = async () => { + const map = await targetMap(); + if (isModelIdMapEmpty(map)) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const world = [...components.get(OBC.Worlds).list.values()][0] as any; + const controls = world?.camera?.controls; + if (!controls?.fitToSphere) return; + try { + const boxes = (await fragments.getBBoxes(map)) as THREE.Box3[]; + const box = new THREE.Box3(); + for (const b of boxes) box.union(b); + if (box.isEmpty()) return; + const sphere = box.getBoundingSphere(new THREE.Sphere()); + await controls.fitToSphere(sphere, true); // animated, preserves view dir + } catch (error) { + console.warn("[visibility-toolbar] focus failed", error); + } + }; + + // Reset: restore full visibility AND clear every ghost override. + const doReset = async () => { + await hider.set(true); + await clearGhost(); + }; + + // ── Toolbar UI ────────────────────────────────────────────────────────── + type Action = { label: string; icon: string; run: () => void | Promise }; + const actions: Action[] = [ + { label: "Focus", icon: "mdi:image-filter-center-focus", run: doFocus }, + { label: "Hide", icon: "mdi:eye-off-outline", run: doHide }, + { label: "Show", icon: "mdi:eye-outline", run: doShow }, + { label: "Isolate", icon: "mdi:select-search", run: doIsolate }, + { label: "Ghost", icon: "mdi:ghost-outline", run: doGhost }, + ]; + + let busy = false; + const onAction = async (run: () => void | Promise) => { + if (busy) return; + busy = true; + try { + await run(); + } catch (error) { + console.warn("[visibility-toolbar] action failed", error); + } finally { + busy = false; + } + }; + + const toggleMode = () => { + mode = mode === "selected" ? "unselected" : "selected"; + barUpdate({ tick: ++tick }); + }; + + // ── Camera projection (perspective ⇄ orthographic) ────────────────────── + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cameraProjection = (): any => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ([...components.get(OBC.Worlds).list.values()][0] as any)?.camera?.projection; + const isOrtho = () => cameraProjection()?.current === "Orthographic"; + + // AO now reconstructs correctly under orthographic projection (fixed at source + // in the beta postproduction shaders via the `uOrtho` branch), so we no longer + // force it off in ortho — the user's AO setting is honored in both projections. + const toggleProjection = async () => { + const proj = cameraProjection(); + if (!proj?.set) return; + const goingOrtho = !isOrtho(); + await proj.set(goingOrtho ? "Orthographic" : "Perspective"); + barUpdate({ tick: ++tick }); + }; + + let tick = 0; + + const [bar, barUpdate] = BUI.Component.create( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_state) => { + const modeLabel = mode === "selected" ? "Selected" : "Unselected"; + const modeIcon = + mode === "selected" ? "mdi:cursor-default-click-outline" : "mdi:select-remove"; + const modeTip = + mode === "selected" + ? "Target: SELECTED elements — click to switch to Unselected" + : "Target: UNSELECTED elements (rest of model) — click to switch to Selected"; + return BUI.html` + + + ${modeTip} + + + ${actions.map( + (a) => BUI.html` + onAction(a.run)} + style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;" + >${`${a.label} ${modeLabel.toLowerCase()}`} + `, + )} + + + onAction(doReset)} + style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;" + >Reset: show all + clear ghost + + + ${ + isOrtho() + ? "Orthographic view — switch to Perspective" + : "Perspective view — switch to Orthographic" + } + + ${walk + ? BUI.html` + walk.toggle()} + style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;" + >${ + walk.isActive() + ? "Exit walkthrough" + : "Walkthrough — first-person navigation" + } + ` + : BUI.html``} + ${explode + ? BUI.html` + explode.toggle()} + style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;" + >${ + explode.isActive() + ? "Collapse — un-explode" + : "Exploded view" + } + ` + : BUI.html``} + + `; + }, + { tick: 0 }, + ); + + // Keep the walkthrough button's active state in sync (incl. auto-exit on + // model unload) without polling. + if (walk) walk.onChange(() => barUpdate({ tick: ++tick })); + if (explode) explode.onChange(() => barUpdate({ tick: ++tick })); + + // ── Inspection tab (Select default · Clip · Measure length/area/angle) ────── + // A second toolbar holding the inspection tools, routed through W1's + // toolModeManager (exclusive). Built only when `inspection` actions are passed; + // the bottom bar then becomes a bim-tabs with View (visibility) + Inspect tabs. + const manager = toolModeManager(components); + let itick = 0; + const [inspectionBar, inspUpdate] = BUI.Component.create( + // Arity >= 1 (state param) required, else create returns a single element and + // the [inspectionBar, inspUpdate] destructure throws "object is not iterable". + (_s) => { + const selActive = manager.getActiveId() === "select"; + const actionButton = (a: InspectionAction) => BUI.html` + a.activate()} + style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;" + >${a.label}`; + // Split the actions into a Clip section + a Measure section so the toolbar's + // section divider draws a line between the cut button and the measure tools. + const acts = inspection ?? []; + const clipActions = acts.filter((a) => a.id.startsWith("clip")); + const measureActions = acts.filter((a) => a.id.startsWith("measure")); + return BUI.html` + + + manager.selectMode()} + style="width: 1.9rem; min-width: 1.9rem; height: 1.9rem;" + >Select + + ${clipActions.length + ? BUI.html` + ${clipActions.map(actionButton)} + ` + : BUI.html``} + ${measureActions.length + ? BUI.html` + ${measureActions.map(actionButton)} + ` + : BUI.html``} + `; + }, + { tick: 0 }, + ); + // Refresh ALL button active-states (Select + tool buttons) when the active + // tool changes (W1 fires onActiveChanged through the manager). + manager.onActiveChanged.add(() => inspUpdate({ tick: ++itick })); + + // The element docked in the floating grid: a tabbed bar when inspection actions + // are supplied (View = visibility, Inspect = tools), else just the bar. + const dock: HTMLElement = inspection + ? (BUI.Component.create( + () => BUI.html` + + ${bar} + ${inspectionBar} + `, + ) as unknown as HTMLElement) + : (bar as unknown as HTMLElement); + + // ── Floating grid overlay: bar docked bottom-center, empty areas click through ── + const grid = BUI.Component.create(() => { + const onCreated = (element?: Element) => { + if (!element) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g = element as any; + g.elements = { bar: dock }; + g.layouts = { + main: { + template: ` + "fillL fillC fillR" 1fr + "restL bar restR" auto + / 1fr auto 1fr + `, + }, + }; + g.layout = "main"; + }; + return BUI.html` + + `; + }); + + // Resolve the viewport to overlay (the renderer container of the first world). + const resolveContainer = (): HTMLElement | undefined => { + if (container) return container; + const world = [...components.get(OBC.Worlds).list.values()][0] as + | { renderer?: { three?: { domElement?: HTMLElement } } } + | undefined; + const canvas = world?.renderer?.three?.domElement; + // Overlay the canvas's parent (the viewport element), so the floating grid + // sits on top of the 3D scene. + return (canvas?.parentElement as HTMLElement | undefined) ?? undefined; + }; + + const host = resolveContainer(); + if (host) { + host.append(grid); + } else { + console.warn( + "[visibility-toolbar] no viewport found to overlay; append the returned element manually", + ); + } + + // Refresh the tooltips/state when the selection changes (so the Selected-mode + // action tooltips reflect whether there's anything to act on). + highlighter.events.select.onHighlight.add(() => barUpdate({ tick: ++tick })); + highlighter.events.select.onClear.add(() => barUpdate({ tick: ++tick })); + + return bar; +}; diff --git a/src/cli/templates/app/src/setups/walkthrough.ts b/src/cli/templates/app/src/setups/walkthrough.ts new file mode 100644 index 0000000..f52d1ab --- /dev/null +++ b/src/cli/templates/app/src/setups/walkthrough.ts @@ -0,0 +1,528 @@ +import * as THREE from "three"; +import * as OBC from "@thatopen/components"; +import * as OBF from "@thatopen/components-front"; + +/** + * WALKTHROUGH / first-person navigation — a headless CONTROLLER (no UI). + * + * FLY mode (no raycasts): WASD / arrow keys move the eye along the view + * direction — forward/back on the full look vector (so looking up + W ascends), + * strafe on the camera right — E/Q add explicit world up/down, and LEFT-button + * drag turns the view. There is NO collision and NO floor-follow/gravity: it's + * pure free flight (collision can be layered back on later as an option). The + * orbit anchor (pivot dot) is off for the whole session, and the Hoverer is + * GATED on stillness — off while moving/looking, on when standing still so you + * can inspect elements from where you stand. `toggle()` enters/exits; entering + * seeds the view from the current orbit pose, and exiting KEEPS the walk camera — + * orbit controls are re-enabled from wherever the user walked to (no snap back). + * Move speed (and an as-yet unused eye height, kept for the future collision + * mode) are set via setters. + * + * This module owns ONLY the engine + state — it renders no button. It returns a + * controller so a host (e.g. the bottom action toolbar) can drive it from a + * single mdi:walk icon and reflect the active state via `onChange`. + * + * Movement is frame-rate independent (everything scales by dt). Defaults are in + * MODEL UNITS (meters-ish); the speed setter adapts to other units. + * + * CAMERA DRIVE: drives the LIVE viewport camera. Orbit INPUT is disabled + * (controls.enabled = false) AND the OBC camera component is paused + * (camera.enabled = false) so its per-frame controls.update() can't revert us; + * we then write `world.camera.three` (position + quaternion + matrix) directly + * each frame — that's what the renderer reads, so movement is immediate. + * Mouse-look is LEFT-button press-and-drag only (NO pointer lock — the platform + * iframe is sandboxed without it, and requesting it only logs a "Blocked pointer + * lock" error). On exit, orbit controls resume FROM the current walk pose (the + * pivot is seated a short distance ahead of the eye); projection stays perspective. + * + * @param components engine components + * @returns a {@link WalkthroughController} + */ + +export interface WalkthroughController { + /** Enter walkthrough if inactive, exit if active. */ + toggle(): void; + /** Explicitly enter (no-op if already active). */ + enter(): void; + /** Explicitly exit (no-op if inactive). */ + exit(): void; + /** Whether walkthrough mode is currently active. */ + isActive(): boolean; + /** Subscribe to active-state changes. Returns an unsubscribe fn. */ + onChange(listener: (active: boolean) => void): () => void; + /** Set the eye height (model units, > 0). */ + setEyeHeight(value: number): void; + /** Set the horizontal move speed (model units/sec, > 0). */ + setSpeed(value: number): void; +} + +// Defaults in model units (assume meters). Eye height + speed are user-adjustable. +const DEFAULT_EYE = 1.7; // eye height above the floor +const DEFAULT_SPEED = 3.5; // horizontal move speed, units/sec +const RUN_MULT = 2.4; // Shift = run +const EXIT_PIVOT_DIST = 5; // model units ahead to seat the orbit pivot on exit +const MOUSE_SENS = 0.0022; // radians per pixel of mouse movement +const PITCH_LIMIT = Math.PI / 2 - 0.05; // clamp look up/down just shy of straight + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyWorld = any; + +export const walkthrough = (components: OBC.Components): WalkthroughController => { + const fragments = components.get(OBC.FragmentsManager); + const hoverer = components.get(OBF.Hoverer); + + const firstWorld = (): AnyWorld => + [...components.get(OBC.Worlds).list.values()][0]; + + // ── State ────────────────────────────────────────────────────── + let active = false; + let eyeHeight = DEFAULT_EYE; + let moveSpeed = DEFAULT_SPEED; + + // Active-state listeners (host UI reflects the toggle through these). + const listeners = new Set<(active: boolean) => void>(); + const notify = () => { + for (const cb of listeners) { + try { + cb(active); + } catch (error) { + console.warn("[walkthrough] onChange listener threw", error); + } + } + }; + + // First-person look (radians). Built into a YXZ euler each frame. + let yaw = 0; + let pitch = 0; + const pos = new THREE.Vector3(); + + const keys = new Set(); + let raf = 0; + let lastT = 0; + let lookDirty = false; + // Latches true on the FIRST real input (move or look). Until then the rendered + // camera is left EXACTLY as it was on enter — so entering, and spamming the + // toggle, never nudges/rotates the camera (the per-frame write is gated on it, + // and exit only re-seats the orbit pivot when the camera was actually driven). + let touched = false; + + // The OBC camera component's own `enabled` flag, paused during walkthrough so + // its per-frame controls.update() can't revert our direct camera writes. + let savedCamEnabled: boolean | null = null; + // The Hoverer's original `enabled`. During walkthrough hover is GATED on + // stillness (off while moving/looking, on when standing still); restored on exit. + let savedHovererEnabled: boolean | null = null; + // The world's `dynamicAnchor` (orbit pivot dot), disabled for the whole session + // so no pivot appears while walking; restored on exit. + let savedAnchor: boolean | null = null; + let loggedKey = false; // one-shot: confirm WASD actually reaches the handler + + // Hover gate: enable the Hoverer only when STILL (no movement keys, not + // dragging), and only if it was originally enabled. Guarded so we only write + + // clear on a transition, not every frame. + const applyHoverGate = (allowHover: boolean) => { + if (savedHovererEnabled === null) return; // hoverer unavailable + const want = allowHover ? savedHovererEnabled : false; + try { + if (hoverer.enabled !== want) { + hoverer.enabled = want; + if (!want) hoverer.clear?.(); + } + } catch { + /* non-fatal */ + } + }; + + // Reusable scratch (no per-frame allocation in the hot path). + const _euler = new THREE.Euler(0, 0, 0, "YXZ"); + const _quat = new THREE.Quaternion(); + const _forward = new THREE.Vector3(); + const _right = new THREE.Vector3(); + const _step = new THREE.Vector3(); + const _WORLD_UP = new THREE.Vector3(0, 1, 0); // E/Q vertical axis + + // ── Direction vectors from yaw/pitch ─────────────────────────── + // FLY mode: forward is the FULL view direction (includes pitch), so looking up + // + W ascends and looking down + S descends — true free flight, no raycasts. + const updateDirs = () => { + _euler.set(pitch, yaw, 0, "YXZ"); + _quat.setFromEuler(_euler); + _forward.set(0, 0, -1).applyQuaternion(_quat); + _right.set(1, 0, 0).applyQuaternion(_quat); + }; + + // ── Per-frame step ───────────────────────────────────────────── + const tick = (now: number) => { + if (!active) return; + const world = firstWorld(); + const controls = world?.camera?.controls; + if (!controls) { + raf = requestAnimationFrame(tick); + return; + } + const dt = Math.min((now - lastT) / 1000, 0.05); // clamp big gaps (tab switch) + lastT = now; + updateDirs(); + + // FLY mode (no raycasts): WASD moves the eye along the view direction — + // forward/back on _forward (full look dir, so up/down too), strafe on _right. + // E / Q add explicit WORLD vertical (+Y / −Y) so the user can ascend/descend + // without relying on pitch+W. + let mf = 0; + let mr = 0; + let mv = 0; + if (keys.has("w") || keys.has("arrowup")) mf += 1; + if (keys.has("s") || keys.has("arrowdown")) mf -= 1; + if (keys.has("d") || keys.has("arrowright")) mr += 1; + if (keys.has("a") || keys.has("arrowleft")) mr -= 1; + if (keys.has("e")) mv += 1; + if (keys.has("q")) mv -= 1; + let moved = false; + if (mf !== 0 || mr !== 0 || mv !== 0) { + const speed = (keys.has("shift") ? moveSpeed * RUN_MULT : moveSpeed) * dt; + _step + .set(0, 0, 0) + .addScaledVector(_forward, mf) + .addScaledVector(_right, mr) + .addScaledVector(_WORLD_UP, mv); + if (_step.lengthSq() > 0) { + _step.normalize().multiplyScalar(speed); + pos.add(_step); + moved = true; + } + } + + // Hover only when STANDING STILL — off while moving (any WASD/E/Q held) or + // looking (left-drag). Lets the user inspect elements from where they stand. + applyHoverGate(mf === 0 && mr === 0 && mv === 0 && !dragging); + + // Drive the LIVE camera DIRECTLY every frame — but ONLY once the user has + // actually moved or looked. The OBC camera component's update() is paused + // (see enter), so camera-controls' update() can't run and revert us — we + // own the camera. Gating on `touched` means entering (and spamming + // enter/exit) leaves the rendered pose byte-for-byte untouched: no + // yaw/pitch round-trip re-orientation, no drift. `_quat` (from yaw/pitch) + // orients it; pos is the eye. Writing the THREE camera + its world matrix + // is what the renderer reads, so movement is immediate and never fought. + if (moved || lookDirty) touched = true; + lookDirty = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cam = world?.camera?.three as any; + if (cam && touched) { + cam.position.set(pos.x, pos.y, pos.z); + cam.quaternion.copy(_quat); + cam.updateMatrixWorld(true); + } + raf = requestAnimationFrame(tick); + }; + + // ── Enter / exit ─────────────────────────────────────────────── + const onKeyDown = (e: KeyboardEvent) => { + const k = e.key.toLowerCase(); + if (["w", "a", "s", "d", "e", "q", "arrowup", "arrowdown", "arrowleft", "arrowright", "shift"].includes(k)) { + if (!loggedKey) { + loggedKey = true; + console.log("[walkthrough] movement key reached handler:", k); // confirms key capture + } + keys.add(k); + if (k.startsWith("arrow")) e.preventDefault(); + } + }; + const onKeyUp = (e: KeyboardEvent) => keys.delete(e.key.toLowerCase()); + + // Mouse-look: only while a LEFT-button drag is in progress (see pointerdown). + // movementX/Y are valid during a drag with no pointer lock needed. + let dragging = false; + const onMouseMove = (e: MouseEvent) => { + if (!active || !dragging) return; + yaw -= e.movementX * MOUSE_SENS; + pitch -= e.movementY * MOUSE_SENS; + pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, pitch)); + lookDirty = true; + }; + // Look = LEFT-button press-and-drag ONLY. We never request pointer lock — the + // platform sandboxes the viewer in an iframe without `allow-pointer-lock`, so + // requestPointerLock only logs a "Blocked pointer lock" error. movementX/Y are + // valid during a drag regardless, so drag-look needs no lock. + const onCanvasPointerDown = (e: PointerEvent) => { + if (!active || e.button !== 0) return; // left button only + dragging = true; + hideHint(); + }; + const onWindowPointerUp = () => { + dragging = false; + }; + + let canvas: HTMLCanvasElement | null = null; + + // ── Hint overlay (discoverability: how to drive walkthrough) ─── + let hintEl: HTMLElement | null = null; + let hintTimer: number | undefined; + const hideHint = () => { + if (hintTimer !== undefined) { + clearTimeout(hintTimer); + hintTimer = undefined; + } + if (hintEl) { + hintEl.remove(); + hintEl = null; + } + }; + const showHint = () => { + if (!canvas) return; + const host = canvas.parentElement; + if (!host) return; + hideHint(); + const el = document.createElement("div"); + el.textContent = "Left-drag to look · WASD move · E/Q up/down · click Walk to exit"; + el.style.cssText = [ + "position: absolute", + "left: 50%", + "bottom: 4.5rem", + "transform: translateX(-50%)", + "z-index: 30", + "pointer-events: none", + "padding: 0.35rem 0.7rem", + "font-size: 0.75rem", + "white-space: nowrap", + "color: var(--bim-ui_bg-contrast-100, #e3e3e3)", + "background: rgba(20,20,24,0.85)", + "border: 1px solid var(--bim-ui_bg-contrast-40, rgba(255,255,255,0.2))", + "border-radius: var(--bim-ui_size-2xs, 0.375rem)", + "box-shadow: 0 2px 10px rgba(0,0,0,0.35)", + ].join(";"); + if (getComputedStyle(host).position === "static") host.style.position = "relative"; + host.appendChild(el); + hintEl = el; + hintTimer = window.setTimeout(hideHint, 4500); + }; + + const enter = () => { + const world = firstWorld(); + const controls = world?.camera?.controls; + const projection = world?.camera?.projection; + canvas = world?.renderer?.three?.domElement ?? null; + if (!controls || !canvas) { + console.warn("[walkthrough] no world camera/canvas to drive"); + return; + } + console.log( + "[walkthrough] enter — driving live camera", + world?.camera?.three?.type, + "| controls bound to rendered camera; loop starting", + ); + // Seed the first-person view from the ACTUAL rendered camera transform (world + // position + quaternion-forward), NOT controls.getPosition/getTarget — so the + // view doesn't move at all on enter (no eye-height reset, no re-orient). Same + // ground-truth source exit uses, so enter and exit are symmetric. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cam = world?.camera?.three as any; + const p = new THREE.Vector3(); + if (cam) { + // Force the world matrix current first — reading a stale matrixWorld (e.g. + // mid-frame right after the previous exit's setLookAt) would seed a pose + // that's slightly off, and repeated enter/exit would compound it. + cam.updateMatrixWorld(true); + cam.getWorldPosition(p); + _forward.set(0, 0, -1).applyQuaternion(cam.quaternion).normalize(); + } else { + controls.getPosition(p); + const t = new THREE.Vector3(); + controls.getTarget(t); + _forward.copy(t).sub(p).normalize(); + } + + // First-person must be perspective. (Only an Orthographic orbit re-projects on + // enter; a Perspective orbit keeps the exact same view.) + if (projection?.current === "Orthographic") void projection.set?.("Perspective"); + + // Reconstruct yaw/pitch from the live forward → the first tick re-applies the + // identical orientation (orbit cameras have no roll, so this is exact). + yaw = Math.atan2(_forward.x, -_forward.z); + pitch = Math.asin(Math.max(-1, Math.min(1, _forward.y))); + pos.copy(p); + // FLY mode: enter exactly at the current eye point — no floor snap. + + controls.enabled = false; // stop orbit INPUT + // Pause the OBC camera component's per-frame update so its controls.update() + // can't re-apply the orbit pose and revert our direct camera writes. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const camComp = world?.camera as any; + // Snapshot the Hoverer's enabled state; the per-frame gate (applyHoverGate) + // then toggles it by stillness. Clear any stuck overlay on entry. + try { + savedHovererEnabled = typeof hoverer?.enabled === "boolean" ? hoverer.enabled : null; + hoverer?.clear?.(); + } catch { + /* hoverer not ready — non-fatal */ + } + // Disable the orbit anchor (pivot dot) for the whole walkthrough — no pivot + // should appear while walking. Restored on exit. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = world as any; + if (w && typeof w.dynamicAnchor === "boolean") { + savedAnchor = w.dynamicAnchor; + w.dynamicAnchor = false; + } + savedCamEnabled = typeof camComp?.enabled === "boolean" ? camComp.enabled : null; + if (savedCamEnabled !== null) camComp.enabled = false; + active = true; + dragging = false; + loggedKey = false; + // Start "untouched": the first tick must NOT write the camera (that would + // re-orient via the lossy yaw/pitch round-trip). Only real input sets it. + touched = false; + lookDirty = false; + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("pointerup", onWindowPointerUp); + document.addEventListener("mousemove", onMouseMove); + canvas.addEventListener("pointerdown", onCanvasPointerDown); + // Start the movement loop + notify. Look is press-and-drag only — no + // pointer-lock request (the sandboxed iframe blocks it and only logs an + // error); WASD fly + drag-look need no lock. + lastT = performance.now(); + raf = requestAnimationFrame(tick); + notify(); + showHint(); + }; + + const exit = () => { + if (!active) return; + active = false; + dragging = false; + keys.clear(); + hideHint(); + if (raf) cancelAnimationFrame(raf); + raf = 0; + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("pointerup", onWindowPointerUp); + document.removeEventListener("mousemove", onMouseMove); + canvas?.removeEventListener("pointerdown", onCanvasPointerDown); + + const world = firstWorld(); + const controls = world?.camera?.controls; + // Re-enable the OBC camera component so its update loop (and orbit) resumes. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const camComp = world?.camera as any; + if (savedCamEnabled !== null && camComp) camComp.enabled = savedCamEnabled; + savedCamEnabled = null; + // Restore the Hoverer's original enabled state and the orbit anchor. + try { + if (savedHovererEnabled !== null) hoverer.enabled = savedHovererEnabled; + } catch { + /* non-fatal */ + } + savedHovererEnabled = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = world as any; + if (w && savedAnchor !== null) w.dynamicAnchor = savedAnchor; + savedAnchor = null; + if (controls) { + controls.enabled = true; + } + // Only re-seat the orbit pivot if the user ACTUALLY drove the camera this + // session. If nothing was touched (e.g. spamming the toggle), the camera is + // exactly where orbit left it, its target is still valid, and re-posing would + // only risk nudging it — so we leave it completely alone (true no-op exit). + if (controls && touched) { + // KEEP the walk camera: re-seed orbit controls FROM the ACTUAL rendered + // first-person transform (ground truth — not our yaw/pitch state, in case + // they ever diverge), with the orbit pivot a short distance ahead along the + // look direction. No snap-back to the pre-walk pose, no re-frame. Stays in + // perspective (the walk projection). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cam = world?.camera?.three as any; + const eye = new THREE.Vector3(); + const fwd = new THREE.Vector3(0, 0, -1); + if (cam) { + cam.getWorldPosition(eye); + fwd.applyQuaternion(cam.quaternion).normalize(); + } else { + updateDirs(); + eye.copy(pos); + fwd.copy(_forward); + } + // The orbit pivot sits `dist` ahead along the look dir. CRITICAL: the eye↔ + // target distance must stay within the controls' min/max, or setLookAt CLAMPS + // it by MOVING the eye (the intermittent exit jump — only when dolly limits + // are active). Clamp the pivot distance into [minDistance, maxDistance] so the + // resting distance is valid and the eye stays EXACTLY where the user stood. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cc = controls as any; + const minD = typeof cc.minDistance === "number" ? cc.minDistance : 0; + const maxD = typeof cc.maxDistance === "number" ? cc.maxDistance : Infinity; + let dist = EXIT_PIVOT_DIST; + if (Number.isFinite(minD) && dist < minD) dist = minD; + if (Number.isFinite(maxD) && dist > maxD) dist = maxD; + const setPose = () => + controls.setLookAt( + eye.x, eye.y, eye.z, + eye.x + fwd.x * dist, + eye.y + fwd.y * dist, + eye.z + fwd.z * dist, + false, + ); + void setPose(); + // Apply immediately so the next render is already at the walk pose... + try { + controls.update(0); + } catch { + /* some controls builds reject delta 0 — harmless */ + } + // ...and guard against any engine system that reframes the camera on the + // orbit→resume transition (auto-anchor / aspect / fit). Re-assert the pose + // for a few frames; stop as soon as it holds. Logs once if it ever drifts, + // so a persistent reframer is visible instead of silently winning. + if (cam) { + let tries = 0; + const hold = () => { + if (active) return; // a new walkthrough started — stop + const cur = new THREE.Vector3(); + cam.getWorldPosition(cur); + if (cur.distanceToSquared(eye) > 1e-6) { + if (tries === 0) { + console.log("[walkthrough] exit pose drifted — re-asserting", cur.distanceTo(eye)); + } + void setPose(); + // Cap tight: the transition reframe happens in the first frame(s); + // a low cap avoids fighting a fast user orbit started right after. + if (++tries < 3) requestAnimationFrame(hold); + } + }; + requestAnimationFrame(hold); + } + } + notify(); + }; + + const toggle = () => (active ? exit() : enter()); + + // A model unload while walking → bail out safely (geometry it stood on is gone). + fragments.list.onItemDeleted.add(() => { + if (active) exit(); + }); + + // ── Controller (no UI) ───────────────────────────────────────── + return { + toggle, + enter: () => { + if (!active) enter(); + }, + exit: () => { + if (active) exit(); + }, + isActive: () => active, + onChange: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + setEyeHeight: (value) => { + if (Number.isFinite(value) && value > 0) eyeHeight = value; + }, + setSpeed: (value) => { + if (Number.isFinite(value) && value > 0) moveSpeed = value; + }, + }; +}; diff --git a/src/cli/templates/app/src/ui-components/AppPanel/index.ts b/src/cli/templates/app/src/ui-components/AppPanel/index.ts deleted file mode 100644 index 5b1b7d6..0000000 --- a/src/cli/templates/app/src/ui-components/AppPanel/index.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { LitElement, html } from "lit"; -import { state } from "lit/decorators.js"; -import { consume, createContext } from "@lit/context"; -import * as OBC from "@thatopen/components"; -import * as BUI from "@thatopen/ui"; -import { PlatformClient } from "@thatopen/services"; -import type { Item } from "@thatopen/services"; -import { CloudRunner } from "../../bim-components"; - -const componentsContext = createContext("obc-components"); -const clientContext = createContext("platform-client"); - -type FileRow = { Name: string; Extension: string; Load: string }; - -export class AppPanel extends LitElement { - @consume({ context: componentsContext, subscribe: true }) - @state() private _components?: OBC.Components; - - @consume({ context: clientContext, subscribe: true }) - @state() private _client?: PlatformClient; - - @state() private _files: Item[] = []; - @state() private _loaded = new Set(); - @state() private _loading = new Set(); - - @state() private _runnerStatus = "Idle"; - @state() private _runnerProgress = 0; - @state() private _runnerMessages: string[] = []; - @state() private _runnerComponentId = "your-component-id"; - @state() private _running = false; - - private _runner?: CloudRunner; - - async updated(changed: Map) { - if ((changed.has("_components") || changed.has("_client")) && this._components && this._client) { - if (!this._runner) { - this._runner = this._components.get(CloudRunner); - this._runner.client = this._client; - this._runner.onExecutionUpdated.add(({ status, progress, messages }) => { - this._runnerStatus = status; - this._runnerProgress = progress; - this._runnerMessages = messages; - if (status.startsWith("SUCCESS") || status.startsWith("ERROR") || status.startsWith("WARNING")) { - this._running = false; - } - }); - } else { - this._runner.client = this._client; - } - await this._fetchFiles(); - } - } - - private async _fetchFiles() { - const projectId = (window as any).__THATOPEN_CONTEXT__?.projectId; - if (!this._client || !projectId) return; - const all = await this._client.listFiles({ projectId }); - this._files = all.filter(f => f.fileExtension === "ifc" || f.fileExtension === "frag"); - } - - private async _toggleModel(file: Item) { - const id = file._id; - if (this._loaded.has(id)) { - await this._components!.get(OBC.FragmentsManager).core.disposeModel(id).catch(() => {}); - this._loaded = new Set([...this._loaded].filter(x => x !== id)); - } else { - this._loading = new Set([...this._loading, id]); - try { - const response = await this._client!.downloadFile(id); - const bytes = new Uint8Array(await response.arrayBuffer()); - if (file.fileExtension === "frag") { - await this._components!.get(OBC.FragmentsManager).core.load(bytes, { modelId: id }); - } else { - await this._components!.get(OBC.IfcLoader).load(bytes, true, id); - } - this._loaded = new Set([...this._loaded, id]); - } finally { - this._loading = new Set([...this._loading].filter(x => x !== id)); - } - } - } - - private async _run(useLocal: boolean) { - if (!this._runner) return; - this._runner.componentId = this._runnerComponentId; - this._running = true; - await this._runner.run(useLocal); - } - - private get _tableData(): BUI.TableGroupData[] { - return this._files.map(f => ({ - data: { Name: f.name, Extension: f.fileExtension ?? "", Load: f._id }, - })); - } - - private get _dataTransform(): BUI.TableDataTransform { - return { - Load: (fileId) => { - const file = this._files.find(f => f._id === fileId)!; - const loaded = this._loaded.has(fileId); - const loading = this._loading.has(fileId); - return BUI.html` - this._toggleModel(file)} - > - `; - }, - }; - } - - render() { - return html` - - - - - - - - - - { this._runnerComponentId = (e.target as HTMLInputElement).value; }} - > -
- this._run(false)} - > - this._run(true)} - > -
-
- - ${this._runnerStatus} - ${this._runnerMessages.map(m => html`${m}`)} - -
-
-
- `; - } -} - -customElements.define("app-panel", AppPanel); diff --git a/src/cli/templates/app/src/ui-components/app-info-section/index.ts b/src/cli/templates/app/src/ui-components/app-info-section/index.ts new file mode 100644 index 0000000..f6f5e52 --- /dev/null +++ b/src/cli/templates/app/src/ui-components/app-info-section/index.ts @@ -0,0 +1,26 @@ +import * as BUI from "@thatopen/ui"; +import { AppInfoSectionComponent } from "./src"; +import { getAppManager } from "../../app"; + +export const appInfoSectionTemplate: AppInfoSectionComponent = ({ + components, +}) => { + const app = getAppManager(components); + + const fileItems = + app.projectData?.files.map( + (f: { name: string }) => BUI.html`${f.name}`, + ) ?? []; + + return BUI.html` + + App ID: ${app.client?.context.appId} + Project ID: ${app.client?.context.projectId} + API URL: ${app.client?.context.apiUrl} + Files: ${fileItems.length} + ${fileItems} + + `; +}; + +export * from "./src"; diff --git a/src/cli/templates/app/src/ui-components/app-info-section/src/index.ts b/src/cli/templates/app/src/ui-components/app-info-section/src/index.ts new file mode 100644 index 0000000..eea524d --- /dev/null +++ b/src/cli/templates/app/src/ui-components/app-info-section/src/index.ts @@ -0,0 +1 @@ +export * from "./types"; diff --git a/src/cli/templates/app/src/ui-components/app-info-section/src/types.ts b/src/cli/templates/app/src/ui-components/app-info-section/src/types.ts new file mode 100644 index 0000000..1974347 --- /dev/null +++ b/src/cli/templates/app/src/ui-components/app-info-section/src/types.ts @@ -0,0 +1,15 @@ +import * as BUI from "@thatopen/ui"; +import * as OBC from "@thatopen/components"; + +export interface AppInfoSectionState { + components: OBC.Components; +} + +export type AppInfoSectionComponent = + BUI.StatefullComponent; + +// Used in App type (app.ts) to type this element slot in the grid. +export type AppInfoSectionGridElement = { + name: "appInfoSection"; + state: AppInfoSectionState; +}; diff --git a/src/cli/templates/app/src/ui-components/cloud-runner-section/index.ts b/src/cli/templates/app/src/ui-components/cloud-runner-section/index.ts new file mode 100644 index 0000000..21f2101 --- /dev/null +++ b/src/cli/templates/app/src/ui-components/cloud-runner-section/index.ts @@ -0,0 +1,37 @@ +import * as BUI from "@thatopen/ui"; +import { CloudRunner } from "../../bim-components"; +import { CloudRunnerSectionComponent } from "./src"; + +export const cloudRunnerSectionTemplate: CloudRunnerSectionComponent = ({ + components, +}) => { + const runner = components.get(CloudRunner); + + const messageItems = runner.messages.map( + (m) => BUI.html`${m}`, + ); + + return BUI.html` + + Component ID: ${runner.componentId} + Local server: ${runner.localServerUrl} +
+ runner.run(true)} + > + runner.run(false)} + > +
+ ${runner.status} + Progress: ${runner.progress}% + ${messageItems} +
+ `; +}; + +export * from "./src"; diff --git a/src/cli/templates/app/src/ui-components/cloud-runner-section/src/index.ts b/src/cli/templates/app/src/ui-components/cloud-runner-section/src/index.ts new file mode 100644 index 0000000..eea524d --- /dev/null +++ b/src/cli/templates/app/src/ui-components/cloud-runner-section/src/index.ts @@ -0,0 +1 @@ +export * from "./types"; diff --git a/src/cli/templates/app/src/ui-components/cloud-runner-section/src/types.ts b/src/cli/templates/app/src/ui-components/cloud-runner-section/src/types.ts new file mode 100644 index 0000000..d98748c --- /dev/null +++ b/src/cli/templates/app/src/ui-components/cloud-runner-section/src/types.ts @@ -0,0 +1,14 @@ +import * as OBC from "@thatopen/components"; +import * as BUI from "@thatopen/ui"; + +export interface CloudRunnerSectionState { + components: OBC.Components; +} + +export type CloudRunnerSectionComponent = + BUI.StatefullComponent; + +export type CloudRunnerSectionGridElement = { + name: "cloudRunnerSection"; + state: CloudRunnerSectionState; +}; diff --git a/src/cli/templates/app/src/ui-components/index.ts b/src/cli/templates/app/src/ui-components/index.ts index 9fa387e..a76d9d1 100644 --- a/src/cli/templates/app/src/ui-components/index.ts +++ b/src/cli/templates/app/src/ui-components/index.ts @@ -1 +1,2 @@ -export * from "./AppPanel"; +export * from "./app-info-section"; +export * from "./cloud-runner-section"; diff --git a/src/cli/templates/app/tsconfig.json b/src/cli/templates/app/tsconfig.json index 30ee6d6..ba32540 100644 --- a/src/cli/templates/app/tsconfig.json +++ b/src/cli/templates/app/tsconfig.json @@ -10,8 +10,7 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "strict": true, - "experimentalDecorators": true + "strict": true }, "include": ["src"] } diff --git a/src/cli/templates/app/vite.config.js b/src/cli/templates/app/vite.config.js index 0581e9b..a597e40 100644 --- a/src/cli/templates/app/vite.config.js +++ b/src/cli/templates/app/vite.config.js @@ -3,27 +3,8 @@ // Do NOT run "vite" or "vite build --watch" directly for dev. import { defineConfig } from 'vite'; import { resolve } from 'path'; -import { existsSync, readFileSync } from 'fs'; - -function getBetaAliases() { - if (!existsSync('.thatopen')) return {}; - try { - const config = JSON.parse(readFileSync('.thatopen', 'utf-8')); - if (!config.beta) return {}; - return { - '@thatopen/components': '@thatopen-platform/components-beta', - '@thatopen/components-front': '@thatopen-platform/components-front-beta', - '@thatopen/fragments': '@thatopen-platform/fragments-beta', - }; - } catch { - return {}; - } -} export default defineConfig({ - resolve: { - alias: getBetaAliases(), - }, build: { lib: { entry: resolve(__dirname, 'src/main.ts'), From 2670379c1a8598b38747149fc97323e48c96db85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20Gonz=C3=A1lez=20Viegas?= Date: Fri, 19 Jun 2026 11:45:29 +0200 Subject: [PATCH 2/3] feat(template): sync app template to current viewer (A2/UIManager) + gate app to --beta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-sync the app template from the canonical bim-viewer (post-A2: top-app/ top-viewer/UIManager), replacing the stale pre-A2 port. - Strip the local-dev :4100 builtin-override block from the template main.ts. - Gate the 'app' template to --beta: it depends on beta-only engine APIs (deferred postproduction, adaptive resolution, Outliner extras, fragments Table API, components-front NearPlaneLineMaterial, nested-relations props spec). Without --beta, error clearly — public engine support lands in Oct. cloud-component template is unaffected. --- src/cli/commands/create.ts | 13 + src/cli/templates/app/src/app.ts | 48 +- .../src/bim-components/CloudRunner/index.ts | 12 +- src/cli/templates/app/src/main.ts | 472 +++++++++--------- .../templates/app/src/setups/clipper-tool.ts | 16 +- .../templates/app/src/setups/diagnostics.ts | 115 +++++ .../templates/app/src/setups/fps-indicator.ts | 3 +- .../app/src/setups/graphics-panel.ts | 2 +- src/cli/templates/app/src/setups/index.ts | 1 + src/cli/templates/app/src/setups/styles.ts | 10 +- src/cli/templates/app/vite.config.js | 22 + 11 files changed, 447 insertions(+), 267 deletions(-) create mode 100644 src/cli/templates/app/src/setups/diagnostics.ts diff --git a/src/cli/commands/create.ts b/src/cli/commands/create.ts index 9dccb64..8159085 100644 --- a/src/cli/commands/create.ts +++ b/src/cli/commands/create.ts @@ -34,6 +34,19 @@ export const createCommand = new Command('create') process.exit(1); } + // The app template (the full BIM viewer) currently depends on engine APIs + // that only exist in the beta libraries. Until the public engine catches up + // (October release), scaffolding the app without --beta would produce a + // project that doesn't type-check or run — so require --beta explicitly. + if (template === 'app' && !opts.beta) { + console.error( + 'The "app" template currently requires the beta engine libraries.\n\n' + + ` thatopen create ${projectName} --beta\n\n` + + 'Public (non-beta) engine support is coming with the October release.', + ); + process.exit(1); + } + const isCloud = template === 'cloud-component'; const projectKind = isCloud ? 'cloud component' : 'app'; const useCurrentDir = projectName === '.'; diff --git a/src/cli/templates/app/src/app.ts b/src/cli/templates/app/src/app.ts index a51b2ce..9652b81 100644 --- a/src/cli/templates/app/src/app.ts +++ b/src/cli/templates/app/src/app.ts @@ -1,15 +1,37 @@ -import * as OBC from "@thatopen/components"; -import * as BUI from "@thatopen/ui"; -import { AppManager } from "@thatopen/services"; -import { icons } from "./globals"; - -export type App = { - icons: (keyof typeof icons)[]; - grid: BUI.Grid< - ["Explorer", "Files", "Graphics"], - ["viewer", "explorer", "files", "graphics"] - >; +import { PlatformClient, ProjectData } from "@thatopen/services"; + +// ─── A2 migration shim ─────────────────────────────────────────────────────── +// Pre-A2 the platform `AppManager` built-in held the platform `client` + +// `projectData`, reached via `components.get(AppManager)`. Juan removed +// AppManager (consolidated into UIManager / top-app). top-app now owns that +// state and exposes it via lit contexts — but a few non-lit call sites +// (CloudRunner, data-table-panel, app-info-section) just need `.client` / +// `.projectData` synchronously. This tiny module holds them, set once from +// main.ts, and keeps `getAppManager(...)` returning the same `{ client, +// projectData }` shape those call sites expect. + +let _client: PlatformClient | undefined; +let _projectData: ProjectData | undefined; + +/** Called once from main.ts after the platform client (and projectData) exist. */ +export const setAppContext = ( + client: PlatformClient, + projectData?: ProjectData, +) => { + _client = client; + _projectData = projectData; }; -export const getAppManager = (components: OBC.Components) => - components.get(AppManager); +/** + * Back-compat accessor mirroring the old `components.get(AppManager)` surface + * the remaining call sites use (`.client` / `.projectData`). The `components` + * arg is ignored — kept only for signature compatibility with existing callers. + */ +export const getAppManager = (_components?: unknown) => ({ + get client() { + return _client; + }, + get projectData() { + return _projectData; + }, +}); diff --git a/src/cli/templates/app/src/bim-components/CloudRunner/index.ts b/src/cli/templates/app/src/bim-components/CloudRunner/index.ts index b9ccadb..bae4d18 100644 --- a/src/cli/templates/app/src/bim-components/CloudRunner/index.ts +++ b/src/cli/templates/app/src/bim-components/CloudRunner/index.ts @@ -1,5 +1,5 @@ import * as OBC from "@thatopen/components"; -import { AppManager } from "@thatopen/services"; +import { getAppManager } from "../../app"; import { CloudRunnerStatus } from "./src"; export class CloudRunner extends OBC.Component { @@ -24,9 +24,13 @@ export class CloudRunner extends OBC.Component { } async run(useLocal: boolean) { - // Resolve client at call time via AppManager — never store it as a field. - const app = this.components.get(AppManager); - const client = app.client; + // Resolve client at call time via the app shim — never store it as a field. + const client = getAppManager(this.components).client; + if (!client) { + this.status = "No platform client available."; + this._trigger(); + return; + } client.localServerUrl = useLocal ? this.localServerUrl : null; diff --git a/src/cli/templates/app/src/main.ts b/src/cli/templates/app/src/main.ts index 62179b5..ab59771 100644 --- a/src/cli/templates/app/src/main.ts +++ b/src/cli/templates/app/src/main.ts @@ -6,20 +6,9 @@ import * as FRAGS from "@thatopen/fragments"; import "@thatopen/fragments/inline"; import * as BUI from "@thatopen/ui"; import * as MARKERJS from "@markerjs/markerjs3"; +import { PlatformClient, UIManager } from "@thatopen/services"; +import { setAppContext } from "./app"; import { - PlatformClient, - AppManager, - ViewportsManager, - UIManager, -} from "@thatopen/services"; - -import { getAppManager } from "./app"; -import { - uiManager, - cloudRunner, - viewportsManager, - fpsIndicator, - activeToolHud, propertiesPanel, modelTree, filesPanel, @@ -27,133 +16,127 @@ import { clipperTool, clipperPanel, commandsPanel, - plansPanel, - navigationGizmo, measurementTool, - measurementPanel, dataTablePanel, - // explodedView, // removed from toolbar for launch — re-add with the controller in main() - walkthrough, + fpsIndicator, + activeToolHud, + navigationGizmo, visibilityToolbar, - rightStack, + walkthrough, } from "./setups"; -// Direct imports (not in the setups barrel): the Objects-outliner panel + W1's -// unified clip-plane/measurement instance API it consumes. import { objectsPanel } from "./setups/objects-panel"; import { inspectionInstances, inspectionActions } from "./setups/inspection"; import { measurementSettingsPanel } from "./setups/measurement-settings-panel"; import { settingsPanel } from "./setups/settings-panel"; -// ─── RAW VIEWER (perf baseline) ────────────────────────────────────── -// Stripped to just: platform setup → bare viewport → auto-load one model → -// FPS overlay. NO panels (files/tree/properties), NO toolbar, NO helper/Styles, -// NO frame/hover/selection/postproduction. We'll re-add each piece one at a time -// to find what makes orbiting heavier than the raw example. Full main saved at -// `main.full.ts.bak`. +// ─── A2 migration — PHASES 1+2: boot on UIManager + re-dock panels ─────────── +// Juan consolidated the old AppManager (layout) + ViewportsManager (viewport) +// built-ins into the single UIManager built-in, which ships `top-app` (shell + +// layout) and `top-viewer` (deferred-PEN viewport). This boots the bim-viewer on +// that model. +// Phase 1 — top-app shell hosting one top-viewer + auto-load. [done] +// Phase 2 — re-dock the side panels (tree/properties/files/data/objects/ +// settings) into top-app's layouts + sidebar. [this file] +// Phase 3 — the viewer-overlay tools (fps/HUD/gizmo/bottom toolbar/measure + +// clip handles) that mount over the canvas. [next] +// The pre-A2 rich main is preserved at `main.rich.ts.bak`. async function main() { const client = PlatformClient.fromPlatformContext(); - // Brand accent (purple). Theming via the library variable is the sanctioned - // way — drives the layout-selector active state, the grid resize divider, - // input focus rings, toggles, etc. (The library's dark-theme default is lime.) + // Brand accent (purple) — drives layout-selector active state, dividers, etc. document.documentElement.style.setProperty("--bim-ui_accent-base", "#6528d7"); - // DEV: serve the local ViewportsManager built-in from :4100 if running. - const ctx = (globalThis as Record).__THATOPEN_CONTEXT__; - if (ctx?.appId === "local-dev") { - const DEV_BUILTINS: Record = { - [ViewportsManager.uuid]: "http://localhost:4100/ViewportsManager.iife.js", - [AppManager.uuid]: "http://localhost:4100/AppManager.iife.js", - }; - const orig = client.getBuiltInComponent.bind(client); - (client as Record).getBuiltInComponent = async (uuid: string) => { - const url = DEV_BUILTINS[uuid]; - if (url) { - try { - return await (await fetch(url)).text(); - } catch { - /* local bundle server down → fall back to the platform */ - } - } - return orig(uuid); - }; - } + // The dev `thatopen serve` wrapper HTML doesn't zero the UA body margin (8px), + // which insets the whole app inside the platform iframe. Kill it here so it's + // fixed in both dev and production regardless of the host page. + document.body.style.margin = "0"; + // UIManager must be in the setup call: it registers the platform web + // components (top-app, top-viewer, top-viewer-tools, …) before the DOM renders. const { components } = await client.setup( { OBC, OBF, BUI, THREE, FRAGS, MARKERJS }, - { uuid: ViewportsManager.uuid }, - { uuid: AppManager.uuid }, { uuid: UIManager.uuid }, ); + components.get(UIManager).init(); - // Bare viewport (no frame/hover/selection/postproduction). - const viewerElement = await viewportsManager(components); - console.log("[raw] viewport ready"); - - // ── LAYER 5: panels via the platform's LAYOUT system ────────────── - // Two named layouts, each with an icon → the AppManager auto-generates the - // vertical sidebar button bar (VS Code activity bar) that switches between - // them. Each layout docks its panel beside the viewer (real grid column → - // canvas shrinks, perf-friendly). No custom switcher/drag code. - void rightStack; + // One STABLE top-viewer node, returned by reference so re-rendering top-app + // (when we add the panels below) reuses it instead of disposing/recreating + // its world. No : the bim-viewer mounts its own tabbed + // visibility/inspect toolbar (see below), so the platform default would just + // duplicate it. + const viewerEl = document.createElement("top-viewer"); - // Explorer panel: tree + properties STACKED (both visible together). - const treeEl = modelTree(components); - const propsEl = propertiesPanel(components); - // Tree and properties are TWO SEPARATE GRID AREAS (stacked in the left column; - // see the Explorer layout below), not a hand-rolled split inside one area. So - // they get the exact same inter-area gap as every other area, AND the bim-grid - // gives a draggable divider between them for free (it goes purple on - // hover/drag — the app's resize accent, themed via --bim-grid--divider-c). + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const app = document.createElement("top-app") as any; - // Files panel (upload / convert / add-to-scene UI). - const filesEl = filesPanel(components, client) as unknown as HTMLElement; + app.setup = (waitUntil: (p: Promise, label?: string) => void) => { + waitUntil( + (async () => { + const fragments = components.get(OBC.FragmentsManager); + const workerUrl = await FRAGS.FragmentsModels.getWorker(); + fragments.init(await FRAGS.toClassicWorker(workerUrl), { + classicWorker: true, + }); + })(), + "Fragments Core", + ); + return { components, client }; + }; - // FPS counter — mounted INSIDE the viewer, app-styled, and toggleable from the - // Graphics panel (pass the controller in so the "Show FPS" switch can drive it). - const fps = fpsIndicator(viewerElement); + // Mount minimally first (viewer only) so top-viewer creates the world; the + // panels + their tools are built once that world exists (below). + app.elements = { viewer: () => BUI.html`${viewerEl}` }; + app.layouts = { + Main: { + label: "Main", + icon: "solar:3d-square-bold", + template: `"viewer" 1fr / 1fr`, + }, + }; + app.layout = "Main"; - // Active-tool HUD — shows the current tool's label ("Drawing clipping plane", - // "Measuring length", …), driven by the global toolModeManager. Decoupled: any - // future tool that registers a label shows up here automatically. - activeToolHud(viewerElement, components); + const container = document.getElementById("that-open-app") ?? document.body; + container.appendChild(app); - // press R or resize (log section line resolution vs buffer/overlay sizes). + // Wait for top-viewer's world before building world-dependent tools/panels. + const world = await firstWorld(components.get(OBC.Worlds)); + // Auto-anchor: the orbit pivot follows the surface picked on left-press + // (library dynamicAnchor). top-viewer doesn't enable it, so set it here. + world.dynamicAnchor = true; + // …and show that pivot as an on-screen dot (projected from 3D each frame). + setupAnchorDot(world, viewerEl); - // Graphics panel (rendering settings: postproduction, AO, edges, tone/scene, - // grid, selection outline). Docked as a third layout beside the viewer. - const graphicsEl = graphicsPanel(components, fps) as unknown as HTMLElement; + // Platform client + project data for the AppManager-shim consumers + // (CloudRunner, data-table-panel, app-info-section). + const projectId: string | undefined = client?.context?.projectId; + let projectData; + try { + if (projectId) projectData = await client.getProjectData(projectId); + } catch { + /* dev/no-project → consumers degrade gracefully */ + } + setAppContext(client, projectData); - // Clipping / section-planes (worker 1): the tool drives the planes; the panel - // manages them + per-category section styling. + // Headless tools — drive the panels; they resolve the world via `components`. const clipTool = clipperTool(components); - const clippingEl = clipperPanel(components, clipTool) as unknown as HTMLElement; - - // Commands / keyboard-shortcuts panel (worker 3). - const commandsEl = commandsPanel(components) as unknown as HTMLElement; - - // Floor plans / 2D plan navigation (worker 2). - const plansEl = plansPanel(components) as unknown as HTMLElement; - - // Measurement (worker 1): tool + panel (length/area/angle + list/delete). const measureTool = measurementTool(components); - const measureEl = measurementPanel(components, measureTool) as unknown as HTMLElement; - // Measurement SETTINGS section for the merged Settings layout (color/units/ - // rounding/snaps/visible — W1's measurement settings API). - const measureSettingsEl = measurementSettingsPanel(measureTool) as unknown as HTMLElement; - // Element data table (worker 2): docked as a vertical side panel like the - // others (narrow column; wide content scrolls horizontally / panel resizes). + // Panels. + const treeEl = modelTree(components); + const propsEl = propertiesPanel(components); + const filesEl = filesPanel(components, client) as unknown as HTMLElement; const dataTableEl = dataTablePanel(components) as unknown as HTMLElement; - - // Objects outliner (UI reorg, increment b): lists every clip plane + measurement - // from W1's unified inspectionInstances API, each with hide/disable/delete. + // FPS counter — mounted into the viewer overlay; the Graphics panel "Show FPS" + // switch drives it (pass the controller in). + const fps = fpsIndicator(viewerEl); + const graphicsEl = graphicsPanel(components, fps) as unknown as HTMLElement; + const clippingEl = clipperPanel(components, clipTool) as unknown as HTMLElement; + const commandsEl = commandsPanel(components) as unknown as HTMLElement; + const measureSettingsEl = measurementSettingsPanel( + measureTool, + ) as unknown as HTMLElement; const inspection = inspectionInstances(clipTool, measureTool); const objectsEl = objectsPanel(inspection) as unknown as HTMLElement; - - // Merged Settings panel (UI-reorg polish): ONE scrolling panel with collapsible - // sections (Graphics · Clip styling · Measurement · Commands), each re-homing - // the existing panel element. Replaces the 4-stacked-panel Settings layout. const settingsEl = settingsPanel([ { label: "Graphics", icon: "mdi:tune", el: graphicsEl }, { label: "Clip styling", icon: "mdi:scissors-cutting", el: clippingEl }, @@ -161,172 +144,179 @@ async function main() { { label: "Commands", icon: "mdi:keyboard", el: commandsEl }, ]) as unknown as HTMLElement; - // RAW-UI-TEMP: keep panels filling their cell but DON'T flatten the chrome — - // let bim-panel show its default border/radius/shadow. (Was also: border:none, - // borderRadius:0, boxShadow:none + a border-right separator on files/graphics.) - // Only the DOCKED panels fill their grid cell. graphics/clipping/commands/ - // measureSettings are NOT here — they're nested inside settingsEl (which manages - // their height:auto), so forcing height:100% on them would fight that. - for (const el of [treeEl, propsEl, filesEl, dataTableEl, objectsEl, settingsEl] as HTMLElement[]) { + for (const el of [ + treeEl, + propsEl, + filesEl, + dataTableEl, + objectsEl, + settingsEl, + ] as HTMLElement[]) { el.style.width = "100%"; el.style.height = "100%"; + // top-app's grid areas are overflow:hidden; border-box keeps the panel's own + // border inside the area so it isn't clipped on the bottom/right edges. + el.style.boxSizing = "border-box"; } - // ── App shell: layout sidebar + docked panel + viewer ───────────── - const app = getAppManager(components); - await app.init({ - client, - icons: [], - componentSetups: { core: [uiManager, cloudRunner] }, - grid: (grid) => { - grid.elements = { - viewer: viewerElement, - tree: treeEl, - properties: propsEl, - files: filesEl, - dataTable: dataTableEl, - objects: objectsEl, - settings: settingsEl, - }; - // UI REORG — activity bar order: Explorer · Assets · Objects · Data · - // Settings (Settings LAST). Files→Assets. Clipping + Measure are no longer - // their own layouts: their TOOLS move to the bottom Inspection toolbar tab, - // their plane/measurement INSTANCES to the Objects outliner, and their - // SETTINGS into the merged Settings layout (Graphics + clip styling + - // Commands; Measurement settings fold in once W1 exposes them). - grid.layouts = { - Explorer: { - // Tree (top) + properties (bottom) are two separate areas stacked in - // the left column; the viewer spans both rows. The shared row edge - // becomes a draggable bim-grid divider, and both areas get the same - // gap as the viewer↔column gap (consistent spacing, by construction). - template: ` - "tree viewer" 1fr - "properties viewer" 1fr - / 22rem 1fr - `, - icon: "mdi:file-tree", - }, - Assets: { - // Project Files (top) + Objects outliner (bottom) STACKED in the left - // column — same shape as Explorer's tree+properties stack — with the - // viewer spanning both rows and a draggable divider on the shared edge. - template: ` - "files viewer" 1fr - "objects viewer" 1fr - / 22rem 1fr - `, - icon: "mdi:folder-multiple-outline", - }, - Data: { - // Element data table docked as a vertical left-column panel like every - // other layout. Wide tables scroll horizontally inside the panel, and - // the shared column edge is a draggable bim-grid divider (resizable). - template: `"dataTable viewer" 1fr / 22rem 1fr`, - icon: "mdi:table", - }, - Settings: { - // ONE scrolling Settings panel with collapsible sections (Graphics · - // Clip styling · Measurement · Commands) — see settings-panel.ts. - template: `"settings viewer" 1fr / 22rem 1fr`, - icon: "mdi:cog", - }, - }; - grid.layout = "Explorer"; - // RAW-UI-TEMP: keep the grid filling the viewport, but drop the cosmetic - // flattening (padding/gap/radius + --bim-grid--g/p) and the purple accent - // override — use the library's defaults. - grid.style.width = "100%"; - grid.style.height = "100%"; - grid.style.margin = "0"; + // Re-dock: same stable viewer + the panels, under the bim-viewer's named + // layouts with the activity-bar sidebar (Explorer · Assets · Data · Settings). + const wrap = (el: HTMLElement) => () => BUI.html`${el}`; + app.elements = { + viewer: () => BUI.html`${viewerEl}`, + tree: wrap(treeEl), + properties: wrap(propsEl), + files: wrap(filesEl), + dataTable: wrap(dataTableEl), + objects: wrap(objectsEl), + settings: wrap(settingsEl), + }; + // No `label` → the sidebar renders icon-only activity-bar buttons (matching + // the pre-A2 look), background only on the active one. + app.layouts = { + Explorer: { + icon: "mdi:file-tree", + template: `"tree viewer" 1fr "properties viewer" 1fr / 22rem 1fr`, }, - }); - // Show the auto-generated layout-switching sidebar (vertical button bar). - app.showSidebar = true; - console.log("[raw] app.init done — viewer mounted"); + Assets: { + icon: "mdi:folder-multiple-outline", + template: `"files viewer" 1fr "objects viewer" 1fr / 22rem 1fr`, + }, + Data: { + icon: "mdi:table", + template: `"dataTable viewer" 1fr / 22rem 1fr`, + }, + Settings: { + icon: "mdi:cog", + template: `"settings viewer" 1fr / 22rem 1fr`, + }, + }; + app.layout = "Explorer"; + app.sidebar = true; + + // ── Viewer-overlay tools — appended to the top-viewer host, so they slot into + // top-viewer's pointer-events:none overlay over the canvas (interactive parts + // opt back into pointer events themselves). ──────────────────────────────── + activeToolHud(viewerEl, components); - // Walkthrough is now a headless controller (worker 2); create it here so its - // mdi:walk toggle button can live in the bottom visibility toolbar. let walk; try { walk = walkthrough(components); } catch (e) { - console.warn("[main] walkthrough controller failed to init", e); + console.warn("[a2] walkthrough controller failed to init", e); } - // Exploded view removed from the toolbar for launch (re-add after). The - // explodedView controller (worker 3) is intact — to bring back the button, - // uncomment the controller below and pass `explode` (not undefined) to the - // toolbar; the toolbar renders the button only when a controller is supplied. - // let explode; - // try { - // explode = explodedView(components); - // } catch (e) { - // console.warn("[main] explodedView controller failed to init", e); - // } - - // Floating bottom toolbar — now TABBED (bim-tabs): "View" = visibility actions - // (Hide/Show/Ghost/Isolate + Selected⇄Unselected + Walkthrough); "Inspect" = - // Select (default) + Clip plane + Measure length/area/angle, routed through the - // toolModeManager. Self-mounts bottom-center over the viewport. + // Floating bottom toolbar (tabbed): "View" = visibility (hide/show/ghost/ + // isolate + projection + walkthrough); "Inspect" = Select / Clip / Measure, + // routed through the tool-mode manager. visibilityToolbar( components, - viewerElement, + viewerEl, walk, undefined, inspectionActions(clipTool, measureTool), ); - // Navigation gizmo / view-cube, top-right (worker 2): live orientation + - // click-to-orient preset views (faces/edges/corners) + home/zoom-to-fit. - // Guarded so a gizmo-mount error can't abort viewer init. + // Navigation gizmo / view-cube (top-right): live orientation + click-to-orient. try { - navigationGizmo(components, viewerElement); + navigationGizmo(components, viewerEl); } catch (e) { - console.warn("[main] navigationGizmo failed to mount", e); + console.warn("[a2] navigationGizmo failed to mount", e); } - // ── Auto-load one model (no UI), AFTER the viewer is up ─────────── - // Non-blocking: runs in the background so a slow load never affects the - // mounted viewport. Mirrors the minimal scene-add wiring from the Files panel. + // Auto-load one model (top-viewer's world wires fragments→scene itself). void autoLoadFirstModel(components, client); } +/** Resolves with the first world once it exists (top-viewer creates it async). */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -async function autoLoadFirstModel(components: OBC.Components, client: any) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const worlds = components.get(OBC.Worlds); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const world = [...worlds.list.values()][0] as any; - const fragments = components.get(OBC.FragmentsManager); - fragments.list.onItemSet.add((event) => { +function firstWorld(worlds: any): Promise { + const existing = [...worlds.list.values()][0]; + if (existing) return Promise.resolve(existing); + return new Promise((resolve) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const model = (event as any).value; - if (world && model) { - model.useCamera(world.camera.three); - world.scene.three.add(model.object); - // Worker-side clip cull: let the fragments worker skip streaming/computing - // tiles fully on the clipped-away side of the section planes (today the - // clip is GPU-shader-only; the worker is blind to it). Return the - // renderer's FULL clipping-plane list (BaseRenderer.clippingPlanes), NOT - // three.clippingPlanes — our clipper runs in localClippingPlanes mode, so - // the local planes are filtered out of three.clippingPlanes and the worker - // would see an empty set (cull silently disabled). - model.getClippingPlanesEvent = () => - Array.from(world.renderer?.clippingPlanes ?? []); + const handler = ({ value }: any) => { + worlds.list.onItemSet.remove(handler); + resolve(value); + }; + worlds.list.onItemSet.add(handler); + }); +} + +/** + * On-screen pivot dot for the dynamic anchor: an HTML element projected from the + * 3D anchor point each frame. Appended to the top-viewer host so it slots into + * the viewer overlay over the canvas. Revealed once a drag starts (so a click-to- + * select doesn't flash it) and kept glued to the pivot as the camera orbits. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function setupAnchorDot(world: any, element: HTMLElement) { + const dot = document.createElement("div"); + dot.style.cssText = + "position:absolute; width:13px; height:13px; border-radius:50%;" + + "background:#6528d7; transform:translate(-50%,-50%);" + + "pointer-events:none; display:none; z-index:5;" + + "box-shadow:0 0 0 2px rgba(255,255,255,0.35);"; + element.appendChild(dot); + + let anchor: THREE.Vector3 | null = null; + const place = () => { + if (!anchor) return; + const ndc = anchor.clone().project(world.camera.three); + dot.style.left = `${(ndc.x * 0.5 + 0.5) * element.clientWidth}px`; + dot.style.top = `${(-ndc.y * 0.5 + 0.5) * element.clientHeight}px`; + }; + + let shown = false; + world.onDynamicAnchorSet.add((p: THREE.Vector3) => { + anchor = p.clone(); + shown = false; + place(); + }); + world.onDynamicAnchorClear.add(() => { + anchor = null; + shown = false; + dot.style.display = "none"; + }); + + // Reveal only once a real drag starts (DRAG px), so a click-to-select doesn't + // flash the dot. + const DRAG = 6; + let press: { x: number; y: number } | null = null; + element.addEventListener("pointerdown", (e) => { + if (e.button === 0) press = { x: e.clientX, y: e.clientY }; + }); + element.addEventListener("pointermove", (e) => { + if (!press || !anchor || shown) return; + const dx = e.clientX - press.x; + const dy = e.clientY - press.y; + if (dx * dx + dy * dy >= DRAG * DRAG) { + shown = true; + place(); + dot.style.display = "block"; } - fragments.core.update(true); }); + const clearPress = () => { + press = null; + }; + element.addEventListener("pointerup", clearPress); + element.addEventListener("pointercancel", clearPress); + + // Keep the dot glued to the 3D pivot as the camera orbits around it. + world.renderer?.onBeforeUpdate.add(place); +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function autoLoadFirstModel(components: OBC.Components, client: any) { + const fragments = components.get(OBC.FragmentsManager); const projectId: string | undefined = client?.context?.projectId; if (!projectId) { - console.warn("[raw] no projectId — skipping auto-load"); + console.warn("[a2] no projectId — skipping auto-load"); return; } try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const items = (await client.listFiles({ projectId })) as any[]; - // Prefer BLOXHUB (a medium model) as the default; fall back to first .frag. const frags = items.filter((it) => (it.name ?? "").toLowerCase().endsWith(".frag"), ); @@ -334,20 +324,16 @@ async function autoLoadFirstModel(components: OBC.Components, client: any) { frags.find((it) => (it.name ?? "").toLowerCase().includes("bloxhub")) ?? frags[0]; if (!frag) { - console.warn("[raw] no .frag in project to auto-load"); + console.warn("[a2] no .frag in project to auto-load"); return; } const resp = await client.downloadFile(String(frag._id)); const buffer = await resp.arrayBuffer(); - // Key the model by the frag's fileId (not basename) so the Files panel — - // which keys loaded models by fragId — can manage it (e.g. dispose on detach). - const modelId = String(frag._id); - await fragments.core.load(buffer, { modelId }); + await fragments.core.load(buffer, { modelId: String(frag._id) }); await fragments.core.update(true); - // Do NOT focus the camera on the model — keep the default view on load. - console.log("[raw] auto-loaded model:", frag.name); + console.log("[a2] auto-loaded model:", frag.name); } catch (error) { - console.warn("[raw] auto-load failed", error); + console.warn("[a2] auto-load failed", error); } } diff --git a/src/cli/templates/app/src/setups/clipper-tool.ts b/src/cli/templates/app/src/setups/clipper-tool.ts index 92d9fb8..ff27879 100644 --- a/src/cli/templates/app/src/setups/clipper-tool.ts +++ b/src/cli/templates/app/src/setups/clipper-tool.ts @@ -94,6 +94,11 @@ export const clipperTool = (components: OBC.Components): ClipperTool => { clipper.localClippingPlanes = true; // before any plane is created clipper.enabled = true; clipper.visible = true; + // Constant-size plane representation (world units): disable auto-scaling (which + // otherwise sizes each plane to the model/surface) and pin a fixed size — tune + // the single value to taste. + clipper.autoScalePlanes = false; + clipper.size = 5; const styler = components.get(OBF.ClipStyler); const classifier = components.get(OBC.Classifier); @@ -244,7 +249,16 @@ export const clipperTool = (components: OBC.Components): ClipperTool => { // section appears immediately instead of staying blank until a camera move // invalidates the deferred frame. void Promise.resolve(edges.update()) - .then(() => getWorld()?.renderer?.update()) + .then(() => { + // The regenerated section fill/edge meshes keep a stale bounding sphere, + // so three.js frustum-culls them even when they're on screen (the fill + // flickers out as the camera moves). Disable culling on them — they're + // tiny section overlays, so the cull saving is irrelevant. + edges.three.traverse((o) => { + o.frustumCulled = false; + }); + getWorld()?.renderer?.update(); + }) .catch((error) => console.warn("[clipper] section build skipped:", error), ); diff --git a/src/cli/templates/app/src/setups/diagnostics.ts b/src/cli/templates/app/src/setups/diagnostics.ts new file mode 100644 index 0000000..6bddff5 --- /dev/null +++ b/src/cli/templates/app/src/setups/diagnostics.ts @@ -0,0 +1,115 @@ +import * as OBC from "@thatopen/components"; + +/** + * TEMPORARY measurement-perf profiler (the clip/gizmo streak diagnostics have been + * removed now that those bugs are fixed; this stays one more round to confirm the + * overlay-only cursor-repaint win, then it goes too). + * + * - Press "t": ARM the profiler (resets counters, starts collecting). Move the + * cursor while measuring for a few seconds, then press "t" again to print the + * breakdown: per-phase deferred render cost, renders-per-pointer-move (cursor + * vs pick), snap-pick (castRay) latency, and per-pick phases (cpu=main-thread, + * gpu=async readback wait). + * - Press "T" (Shift+T): same, but with gpuSync ON — adds a gl.finish per frame + * for the TRUE GPU+CPU total/render (at the cost of a pipeline stall). + */ +export const diagnostics = (_components: OBC.Components) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const G = globalThis as any; + + const armPerf = (gpuSync: boolean) => { + G.__measPerf = { + on: true, + gpuSync, + frames: [], + pickMs: [], + gpuTotalMs: [], + pointerEvents: 0, + cursorRenders: 0, + pickRenders: 0, + }; + console.log( + `[meas-perf] ARMED${gpuSync ? " (gpuSync ON)" : ""} — measure now, press T again to report`, + ); + }; + + const avg = (a: number[]) => + a.length ? a.reduce((x, y) => x + y, 0) / a.length : 0; + + const reportPerf = () => { + const p = G.__measPerf; + if (!p) return; + p.on = false; + const frames: Record[] = p.frames ?? []; + const f2 = (v: number) => v.toFixed(2); + const keys = [ + "setup", + "capture", + "ao", + "composite", + "fxaa", + "depthOverlay", + "overlay", + "tail", + "total", + ]; + console.log( + `[meas-perf] ===== ${frames.length} deferred render(s) over ${p.pointerEvents} pointer move(s) =====`, + ); + for (const k of keys) { + const vals = frames.map((fr) => fr[k] ?? 0); + console.log(` ${k.padEnd(13)} avg ${f2(avg(vals))} ms`); + } + const cpuTotal = avg(frames.map((fr) => fr.total ?? 0)); + console.log( + `[meas-perf] CPU-submit total/render ${f2(cpuTotal)} ms (${f2(1000 / (cpuTotal || 1))} fps if CPU-bound)`, + ); + if (p.gpuTotalMs?.length) { + const g = avg(p.gpuTotalMs); + console.log( + `[meas-perf] GPU+CPU total/render ${f2(g)} ms (${f2(1000 / (g || 1))} fps real) [gl.finish, ${p.gpuTotalMs.length} samples]`, + ); + } else { + console.log( + "[meas-perf] (no gpuSync samples — use Shift+T for true GPU-bound fps)", + ); + } + const rendersPerMove = + p.pointerEvents > 0 + ? (p.cursorRenders + p.pickRenders) / p.pointerEvents + : 0; + console.log( + `[meas-perf] renders/move ${f2(rendersPerMove)} (cursor=${p.cursorRenders} + pick=${p.pickRenders} over ${p.pointerEvents} moves)`, + ); + console.log( + `[meas-perf] snap-pick (castRay) avg ${f2(avg(p.pickMs ?? []))} ms over ${(p.pickMs ?? []).length} picks`, + ); + const pick = p.pick as Record | undefined; + if (pick) { + console.log( + "[meas-perf] --- per-pick phases (cpu=main-thread, gpu=async wait) ---", + ); + for (const k of Object.keys(pick)) { + const e = pick[k]; + if (e.n) console.log(` ${k.padEnd(18)} ${f2(e.sum / e.n)} ms avg (n=${e.n})`); + } + } else { + console.log("[meas-perf] (no per-pick phase data captured)"); + } + G.__measPerf = undefined; + }; + + window.addEventListener("keydown", (e) => { + if (e.key === "T") { + if (!G.__measPerf) armPerf(true); + else reportPerf(); + } else if (e.key === "t") { + if (!G.__measPerf) armPerf(false); + else reportPerf(); + } + }); + + console.log( + "[diagnostics] ready — t (measurement-perf) / Shift+T (true GPU-bound fps)", + ); +}; diff --git a/src/cli/templates/app/src/setups/fps-indicator.ts b/src/cli/templates/app/src/setups/fps-indicator.ts index 5a6ee35..fc04897 100644 --- a/src/cli/templates/app/src/setups/fps-indicator.ts +++ b/src/cli/templates/app/src/setups/fps-indicator.ts @@ -27,6 +27,7 @@ export const fpsIndicator = (parent: HTMLElement): FpsIndicator => { color: var(--bim-ui_bg-contrast-100, #e3e3e3); font: 600 0.72rem/1.2 ui-monospace, monospace; pointer-events: none; user-select: none; + display: none; `; el.textContent = "-- FPS"; // The viewer is position:relative (set in viewports-manager for the anchor @@ -34,7 +35,7 @@ export const fpsIndicator = (parent: HTMLElement): FpsIndicator => { if (!parent.style.position) parent.style.position = "relative"; parent.append(el); - let visible = true; + let visible = false; let frames = 0; let last = performance.now(); const tick = (now: number) => { diff --git a/src/cli/templates/app/src/setups/graphics-panel.ts b/src/cli/templates/app/src/setups/graphics-panel.ts index 71f6833..8bd528e 100644 --- a/src/cli/templates/app/src/setups/graphics-panel.ts +++ b/src/cli/templates/app/src/setups/graphics-panel.ts @@ -279,7 +279,7 @@ export const graphicsPanel = (components: OBC.Components, fps?: FpsIndicator) => label: "Show FPS", group: "Quality", type: "bool", - default: true, + default: false, get: () => fps.visible, set: (v: boolean) => fps.setVisible(v), }); diff --git a/src/cli/templates/app/src/setups/index.ts b/src/cli/templates/app/src/setups/index.ts index e28adf6..9eb1bf6 100644 --- a/src/cli/templates/app/src/setups/index.ts +++ b/src/cli/templates/app/src/setups/index.ts @@ -4,6 +4,7 @@ export * from "./cloud-runner"; export * from "./properties-panel"; export * from "./fps-indicator"; export * from "./active-tool-hud"; +export * from "./diagnostics"; export * from "./files-panel"; export * from "./model-tree"; export * from "./right-sidebar"; diff --git a/src/cli/templates/app/src/setups/styles.ts b/src/cli/templates/app/src/setups/styles.ts index bd04590..0b3107d 100644 --- a/src/cli/templates/app/src/setups/styles.ts +++ b/src/cli/templates/app/src/setups/styles.ts @@ -93,12 +93,14 @@ export const styles = ( // makes three force-clear the capture G-buffer mid-pass and the whole model // vanishes (#30). Keep scene.background null and own the clear here. scene.background = null; - // Apply the default opaque dark background ONCE (never on a rebuild — that was - // the #30 reset). The remembered colour itself is the module-scoped - // `stylesLastBg`, so a rebuild never clobbers a user pick. + // Apply the default background ONCE (never on a rebuild — that was the #30 + // reset). Default is TRANSPARENT (clear alpha 0) to match the + // `transparentBackground` setting's default:true, so on load the viewport shows + // whatever is behind it instead of an opaque black. The remembered hue lives in + // the module-scoped `stylesLastBg` for when transparency is toggled off. if (!stylesBgInitialized) { stylesBgInitialized = true; - three().setClearColor(stylesLastBg, 1); + three().setClearColor(0x000000, 0); } const A = OBF.PostproductionAspect; diff --git a/src/cli/templates/app/vite.config.js b/src/cli/templates/app/vite.config.js index a597e40..d0178a3 100644 --- a/src/cli/templates/app/vite.config.js +++ b/src/cli/templates/app/vite.config.js @@ -3,8 +3,30 @@ // Do NOT run "vite" or "vite build --watch" directly for dev. import { defineConfig } from 'vite'; import { resolve } from 'path'; +import { existsSync, readFileSync } from 'fs'; + +// When `.thatopen` marks the project as beta, alias the public engine imports +// to their `@thatopen-platform/*-beta` packages at build time, so the source +// can use the stable import names in both stable and beta modes. +function getBetaAliases() { + if (!existsSync('.thatopen')) return {}; + try { + const config = JSON.parse(readFileSync('.thatopen', 'utf-8')); + if (!config.beta) return {}; + return { + '@thatopen/components': '@thatopen-platform/components-beta', + '@thatopen/components-front': '@thatopen-platform/components-front-beta', + '@thatopen/fragments': '@thatopen-platform/fragments-beta', + }; + } catch { + return {}; + } +} export default defineConfig({ + resolve: { + alias: getBetaAliases(), + }, build: { lib: { entry: resolve(__dirname, 'src/main.ts'), From 84c39a49591002514a11995c87419a4622aba87e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20Gonz=C3=A1lez=20Viegas?= Date: Fri, 19 Jun 2026 11:54:09 +0200 Subject: [PATCH 3/3] chore(template): remove dead viewports-manager/diagnostics setups (unused post-A2) --- .../templates/app/src/setups/diagnostics.ts | 115 ------- src/cli/templates/app/src/setups/index.ts | 2 - .../app/src/setups/viewports-manager.ts | 280 ------------------ 3 files changed, 397 deletions(-) delete mode 100644 src/cli/templates/app/src/setups/diagnostics.ts delete mode 100644 src/cli/templates/app/src/setups/viewports-manager.ts diff --git a/src/cli/templates/app/src/setups/diagnostics.ts b/src/cli/templates/app/src/setups/diagnostics.ts deleted file mode 100644 index 6bddff5..0000000 --- a/src/cli/templates/app/src/setups/diagnostics.ts +++ /dev/null @@ -1,115 +0,0 @@ -import * as OBC from "@thatopen/components"; - -/** - * TEMPORARY measurement-perf profiler (the clip/gizmo streak diagnostics have been - * removed now that those bugs are fixed; this stays one more round to confirm the - * overlay-only cursor-repaint win, then it goes too). - * - * - Press "t": ARM the profiler (resets counters, starts collecting). Move the - * cursor while measuring for a few seconds, then press "t" again to print the - * breakdown: per-phase deferred render cost, renders-per-pointer-move (cursor - * vs pick), snap-pick (castRay) latency, and per-pick phases (cpu=main-thread, - * gpu=async readback wait). - * - Press "T" (Shift+T): same, but with gpuSync ON — adds a gl.finish per frame - * for the TRUE GPU+CPU total/render (at the cost of a pipeline stall). - */ -export const diagnostics = (_components: OBC.Components) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const G = globalThis as any; - - const armPerf = (gpuSync: boolean) => { - G.__measPerf = { - on: true, - gpuSync, - frames: [], - pickMs: [], - gpuTotalMs: [], - pointerEvents: 0, - cursorRenders: 0, - pickRenders: 0, - }; - console.log( - `[meas-perf] ARMED${gpuSync ? " (gpuSync ON)" : ""} — measure now, press T again to report`, - ); - }; - - const avg = (a: number[]) => - a.length ? a.reduce((x, y) => x + y, 0) / a.length : 0; - - const reportPerf = () => { - const p = G.__measPerf; - if (!p) return; - p.on = false; - const frames: Record[] = p.frames ?? []; - const f2 = (v: number) => v.toFixed(2); - const keys = [ - "setup", - "capture", - "ao", - "composite", - "fxaa", - "depthOverlay", - "overlay", - "tail", - "total", - ]; - console.log( - `[meas-perf] ===== ${frames.length} deferred render(s) over ${p.pointerEvents} pointer move(s) =====`, - ); - for (const k of keys) { - const vals = frames.map((fr) => fr[k] ?? 0); - console.log(` ${k.padEnd(13)} avg ${f2(avg(vals))} ms`); - } - const cpuTotal = avg(frames.map((fr) => fr.total ?? 0)); - console.log( - `[meas-perf] CPU-submit total/render ${f2(cpuTotal)} ms (${f2(1000 / (cpuTotal || 1))} fps if CPU-bound)`, - ); - if (p.gpuTotalMs?.length) { - const g = avg(p.gpuTotalMs); - console.log( - `[meas-perf] GPU+CPU total/render ${f2(g)} ms (${f2(1000 / (g || 1))} fps real) [gl.finish, ${p.gpuTotalMs.length} samples]`, - ); - } else { - console.log( - "[meas-perf] (no gpuSync samples — use Shift+T for true GPU-bound fps)", - ); - } - const rendersPerMove = - p.pointerEvents > 0 - ? (p.cursorRenders + p.pickRenders) / p.pointerEvents - : 0; - console.log( - `[meas-perf] renders/move ${f2(rendersPerMove)} (cursor=${p.cursorRenders} + pick=${p.pickRenders} over ${p.pointerEvents} moves)`, - ); - console.log( - `[meas-perf] snap-pick (castRay) avg ${f2(avg(p.pickMs ?? []))} ms over ${(p.pickMs ?? []).length} picks`, - ); - const pick = p.pick as Record | undefined; - if (pick) { - console.log( - "[meas-perf] --- per-pick phases (cpu=main-thread, gpu=async wait) ---", - ); - for (const k of Object.keys(pick)) { - const e = pick[k]; - if (e.n) console.log(` ${k.padEnd(18)} ${f2(e.sum / e.n)} ms avg (n=${e.n})`); - } - } else { - console.log("[meas-perf] (no per-pick phase data captured)"); - } - G.__measPerf = undefined; - }; - - window.addEventListener("keydown", (e) => { - if (e.key === "T") { - if (!G.__measPerf) armPerf(true); - else reportPerf(); - } else if (e.key === "t") { - if (!G.__measPerf) armPerf(false); - else reportPerf(); - } - }); - - console.log( - "[diagnostics] ready — t (measurement-perf) / Shift+T (true GPU-bound fps)", - ); -}; diff --git a/src/cli/templates/app/src/setups/index.ts b/src/cli/templates/app/src/setups/index.ts index 9eb1bf6..802add4 100644 --- a/src/cli/templates/app/src/setups/index.ts +++ b/src/cli/templates/app/src/setups/index.ts @@ -1,10 +1,8 @@ export * from "./ui-manager"; -export * from "./viewports-manager"; export * from "./cloud-runner"; export * from "./properties-panel"; export * from "./fps-indicator"; export * from "./active-tool-hud"; -export * from "./diagnostics"; export * from "./files-panel"; export * from "./model-tree"; export * from "./right-sidebar"; diff --git a/src/cli/templates/app/src/setups/viewports-manager.ts b/src/cli/templates/app/src/setups/viewports-manager.ts deleted file mode 100644 index fab3edc..0000000 --- a/src/cli/templates/app/src/setups/viewports-manager.ts +++ /dev/null @@ -1,280 +0,0 @@ -import * as THREE from "three"; -import * as OBC from "@thatopen/components"; -import * as OBF from "@thatopen/components-front"; -import { ViewportsManager } from "@thatopen/services"; - -// ─── RAW VIEWER + LAYER 1: deferred postproduction ─────────────────── -// Building back up from the bare viewer one block at a time to find the orbit -// cost. ADDED so far: deferred postproduction (COLOR_PEN_SHADOWS). Still OMITTED: -// adaptive resolution, Hoverer, Highlighter/Raycaster, Outliner, frame overlay. -// Full version saved at `viewports-manager.full.ts.bak`. -export const viewportsManager = async (components: OBC.Components) => { - const viewports = components.get(ViewportsManager); - const { element, world } = await viewports.create(); - - const renderer = world.renderer!; - renderer.showLogo = false; - - // A defaults to display:inline, which leaves a few px of baseline - // descender space below it → a small margin at the viewer's bottom. Block kills it. - renderer.three.domElement.style.display = "block"; - - // Give the viewer the same card chrome as the bim-panels (1px contrast-20 - // border + 0.75rem radius), so it reads as a card alongside them. - element.style.border = "1px solid var(--bim-ui_bg-contrast-20)"; - element.style.borderRadius = "0.75rem"; - element.style.overflow = "hidden"; - - // Auto-anchor: library dynamicAnchor picks the surface on left-press (single - // pick, no per-move cost) and sets the orbit pivot; we render a dot off its - // onDynamicAnchorSet/Clear events (wired below). - world.dynamicAnchor = true; - world.camera.threePersp.near = 1; - world.camera.threePersp.updateProjectionMatrix(); - - await world.camera.controls.setLookAt(20, 20, 20, 0, 0, 0); - - // ── Enhanced camera controls (library feature, opt-in) ─────────────── - // Ortho-only: faster, proportional frustum zoom in orthographic (fixes the slow - // ortho wheel zoom). Perspective zoom + orbit use the library defaults. Plan - // mode is left untouched. Cleaned up automatically on camera dispose. - world.camera.setupEnhancedControls({ orthoZoomSpeed: 0.15 }); - - // ── Resize reconcile + "light resize" ──────────────────────────── - // While the window/container is actively resizing, the per-frame reconcile - // would reallocate the deferred G-buffer every frame (expensive) → fps drops - // during the drag. So: the MOMENT the size starts changing we turn - // postproduction OFF (cheap forward render) and only do the lightweight - // setSize each frame; the heavy `applyPostproductionSize` + postproduction - // restore happen ONCE, after a quiet BUFFER (no size change for ~250ms), so it - // never flickers on/off during a continuous drag. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let postpro: any = null; // captured once the deferred pipeline is configured - let resizing = false; - let postproWasEnabled = true; - let hovererWasEnabled = true; - let settleTimer: ReturnType | undefined; - const RESIZE_BUFFER = 250; // ms of stillness before restoring postproduction - - let lastW = -1; - let lastH = -1; - let lastDpr = -1; - let lastChangeAt = -Infinity; // performance.now() of the previous size change - const RAPID_MS = 200; // back-to-back changes faster than this ⇒ a live drag - const applyResize = () => { - if (!renderer.currentWorld) return; - const w = element.clientWidth; - const h = element.clientHeight; - const dpr = Math.min(window.devicePixelRatio, 2); - if (w === 0 || h === 0) return; - if (w === lastW && h === lastH && dpr === lastDpr) return; - lastW = w; - lastH = h; - lastDpr = dpr; - - const now = performance.now(); - const rapid = now - lastChangeAt < RAPID_MS; - lastChangeAt = now; - - if (rapid && !resizing) { - // SUSTAINED changing (a live window/container DRAG): switch to the cheap - // path — the per-frame `renderer.resize()` realloc is what tanks fps, so we - // do NOT touch the buffer during the drag. The canvas is set to CSS 100% - // and simply STRETCHES the existing (fixed-size) buffer — cheap, slightly - // soft. We also kill mouse events and drop postproduction. The one real - // resize happens on settle. - resizing = true; - const canvas = renderer.three.domElement; - canvas.style.width = "100%"; - canvas.style.height = "100%"; - element.style.pointerEvents = "none"; - if (postpro) { - postproWasEnabled = postpro.enabled; - postpro.enabled = false; - } - // No hover-highlight while actively resizing/dragging. - try { - const hv = components.get(OBF.Hoverer); - hovererWasEnabled = hv.enabled; - hv.enabled = false; - } catch { - /* hoverer not set up yet */ - } - } else if (!rapid && !resizing) { - // ISOLATED, one-shot change — e.g. a LAYOUT switch (panel docks/undocks), - // which is INSTANTANEOUS. Do the full, correct resize right now and KEEP - // postproduction on, so there's no flicker/disable on layout changes. If - // this turns out to be the first frame of a drag, the next (rapid) change - // flips us into the cheap path above. - renderer.three.setPixelRatio(dpr); - renderer.resize(); - world.camera.updateAspect(); - renderer.applyPostproductionSize?.(); - // Force one re-composite at the new size. The deferred pipeline only - // recomposites on demand, so without this a stale frame (e.g. the clip - // section's overlay rendering white at the wrong resolution) persists until - // the next camera move. - renderer.update(); - } - - // (Re)arm the settle buffer: finalize a DRAG once the size has been stable - // for RESIZE_BUFFER ms (one real resize + restore interaction/postproduction). - // For a one-shot change `resizing` stays false, so this is a no-op. - if (settleTimer) clearTimeout(settleTimer); - settleTimer = setTimeout(() => { - settleTimer = undefined; - if (!resizing) return; // one-shot already handled immediately above - resizing = false; - element.style.pointerEvents = ""; - renderer.three.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - renderer.resize(); // reallocates to the settled container size - world.camera.updateAspect(); - renderer.applyPostproductionSize?.(); - if (postpro) postpro.enabled = postproWasEnabled; - // Re-composite at the settled size so the section overlay (and everything) - // refreshes immediately instead of staying stale/white until a camera move. - renderer.update(); - try { - components.get(OBF.Hoverer).enabled = hovererWasEnabled; - } catch { - /* hoverer not set up */ - } - }, RESIZE_BUFFER); - }; - window.addEventListener("resize", applyResize); - new ResizeObserver(applyResize).observe(element); - renderer.onBeforeUpdate.add(applyResize); - - // ── LAYER 3: Hover highlight (Hoverer) ──────────────────────────── - // Animated proxy-mesh overlay on whichever element is under the cursor. Its - // material is isolated into the base pass (below) so it shows over the - // deferred composite. The Hoverer self-suppresses during camera drags. - const hoverer = components.get(OBF.Hoverer); - hoverer.world = world; - // Continuous hover-follow (MOUSE_MOVE, the default). This used to drop FPS - // because the picker's per-frame GPU readback stalled the frame — now fixed in - // the library (FastModelPicker reads back asynchronously), so continuous hover - // runs at full framerate. - hoverer.enabled = true; - hoverer.material = new THREE.MeshBasicMaterial({ - color: 0xb79bf0, - transparent: true, - opacity: 0.3, - depthTest: false, - }); - - // ── LAYER 4: click selection (Highlighter + Raycaster + Outliner) ── - // GPU-picked click selection; rendered as an outline via the Outliner (wired - // in setupPostproduction once the pipeline exists). selectMaterialDefinition - // null = no fragment recolor (the Outliner draws the selection instead). - components.get(OBC.Raycasters).get(world); - const highlighter = components.get(OBF.Highlighter); - highlighter.setup({ world, selectMaterialDefinition: null }); - - // ── Auto-anchor pivot dot (driven by the library's dynamicAnchor) ── - // dynamicAnchor picks the surface on left-press and fires onDynamicAnchorSet - // with the pivot; we render a dot there. The dot is an HTML overlay projected - // from the 3D pivot each frame — NOT a 3D mesh — so its colour is the EXACT app - // accent purple (#6528d7); a 3D mesh gets recoloured by the deferred composite. - if (!element.style.position) element.style.position = "relative"; - const anchorDotEl = document.createElement("div"); - anchorDotEl.style.cssText = - "position:absolute; width:13px; height:13px; border-radius:50%;" + - "background:#6528d7; transform:translate(-50%,-50%);" + - "pointer-events:none; display:none; z-index:5;" + - "box-shadow:0 0 0 2px rgba(255,255,255,0.35);"; - element.appendChild(anchorDotEl); - let anchorWorld: THREE.Vector3 | null = null; - const positionAnchorDot = () => { - if (!anchorWorld) return; - const ndc = anchorWorld.clone().project(world.camera.three); - anchorDotEl.style.left = `${(ndc.x * 0.5 + 0.5) * element.clientWidth}px`; - anchorDotEl.style.top = `${(-ndc.y * 0.5 + 0.5) * element.clientHeight}px`; - }; - // Cache the pivot on press; show the dot only once a real drag starts (so a - // click-to-select doesn't flash it). - let anchorShown = false; - world.onDynamicAnchorSet.add((point: THREE.Vector3) => { - anchorWorld = point.clone(); - anchorShown = false; - positionAnchorDot(); - }); - world.onDynamicAnchorClear.add(() => { - anchorWorld = null; - anchorShown = false; - anchorDotEl.style.display = "none"; - }); - const DRAG_THRESHOLD = 6; // px before the dot appears - let pressStart: { x: number; y: number } | null = null; - element.addEventListener("pointerdown", (e) => { - if (e.button === 0) pressStart = { x: e.clientX, y: e.clientY }; - }); - element.addEventListener("pointermove", (e) => { - if (!pressStart || !anchorWorld || anchorShown) return; - const dx = e.clientX - pressStart.x; - const dy = e.clientY - pressStart.y; - if (dx * dx + dy * dy >= DRAG_THRESHOLD * DRAG_THRESHOLD) { - anchorShown = true; - positionAnchorDot(); - anchorDotEl.style.display = "block"; - } - }); - const clearPress = () => { - pressStart = null; - }; - element.addEventListener("pointerup", clearPress); - element.addEventListener("pointercancel", clearPress); - // Keep the dot glued to the 3D pivot as the camera orbits around it. - renderer.onBeforeUpdate.add(positionAnchorDot); - - // ── LAYER 1: deferred postproduction (NO adaptive resolution yet) ── - // Allocate the deferred pipeline once the viewport has a real (non-zero) size. - const size = new THREE.Vector2(); - let configured = false; - const setupPostproduction = () => { - if (configured || !renderer.currentWorld) return; - renderer.three.getSize(size); - if (size.x < 2 || size.y < 2) return; - - const { postproduction } = renderer; - // The platform's app iframe can fire an early resize before the deferred - // pipeline is allocated. Bail WITHOUT marking `configured` so a later - // resize / world-change retries once it exists — instead of throwing on - // `postproduction.enabled` and leaving the pipeline half-configured. - if (!postproduction) return; - configured = true; - - postpro = postproduction; // let the resize handler toggle it during drags - postproduction.enabled = true; - postproduction.style = OBF.PostproductionAspect.COLOR_PEN_SHADOWS; - - // Keep the floor grid + hover overlay visible over the deferred composite. - const grid = components.get(OBC.Grids).list.get(world.uuid); - if (grid) postproduction.basePass.isolatedMaterials.push(grid.material); - postproduction.basePass.isolatedMaterials.push(hoverer.material); - - void postproduction.deferred; - postproduction.mode = OBF.PostproductionMode.DEFERRED; - - // ── LAYER 4 (cont.): selection Outline ────────────────────────── - // The Outliner draws through the postproduction pipeline, so wire it after - // the pipeline is up. Selecting (via the Highlighter "select" style) outlines - // the element; deselecting removes it. - const outliner = components.get(OBF.Outliner); - outliner.world = world; - outliner.color = new THREE.Color(0x6528d7); - outliner.fillColor = new THREE.Color(0x6528d7); - outliner.fillOpacity = 0.4; - outliner.enabled = true; - highlighter.events.select.onHighlight.add((map) => outliner.addItems(map)); - highlighter.events.select.onClear.add((map) => outliner.removeItems(map)); - - // NOTE: adaptive resolution intentionally OFF here to measure the pipeline's - // raw orbit cost. `renderer.cssSize`/`adaptiveResolution` come in a later layer. - }; - renderer.onWorldChanged.add(setupPostproduction); - renderer.onResize.add(setupPostproduction); - setupPostproduction(); - - return element; -};