/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * PlaygroundViewer — collapsible inline 3D viewer for /mcp/playground. * * Loads geometry from a parsed `IfcDataStore` via @ifc-lite/geometry's WASM * processor (`GeometryProcessor.process(buffer)`), renders one Three.js * mesh per IFC entity so each can be coloured / hidden / picked * individually, and exposes an imperative `ViewerController` that the * agent's tool dispatcher drives. * * Why per-entity meshes (not a merged mesh): the agent loop calls things * like `viewer_colorize({ global_ids: [...] })` and we need to flip just * those entities. Sharing one BufferGeometry would force per-vertex colour * attributes + a custom shader pass, which is overkill for the playground * scale (≤ ~1k visible entities for the bundled samples). * * Geometry processing is async + heavy → only fired the first time the * panel is opened, then the result is cached. */ import { forwardRef, useEffect, useImperativeHandle, useRef, useState, } from 'react'; import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { GeometryProcessor, type MeshData } from '@ifc-lite/geometry'; import { EntityNode } from '@ifc-lite/query'; import { cn } from '@/lib/utils'; import type { LoadedPlaygroundModel } from './playground-dispatcher'; const NIGHT = 0x0a0a0c; const ACCENT = 0xd6ff3f; const BG_COLOR = '#0e0e12'; // ── controller surface used by the dispatcher ────────────────────────────── export interface SelectionHit { expressId: number; globalId?: string; ifcType?: string; } export interface ViewerStatus { loaded: boolean; meshCount: number; selection: SelectionHit[]; } export type ColorTuple = [number, number, number, number]; export interface ViewerController { isLoaded(): boolean; status(): ViewerStatus; /** Colour the selected entities. Pass null/undefined to default to all. */ colorize(args: { globalIds?: string[]; expressIds?: number[]; type?: string; color: ColorTuple }): { count: number }; isolate(args: { globalIds?: string[]; expressIds?: number[]; type?: string }): { count: number }; hide(args: { globalIds?: string[]; expressIds?: number[]; type?: string }): { count: number }; show(args: { globalIds?: string[]; expressIds?: number[]; type?: string }): { count: number }; reset(): void; flyTo(args: { globalIds?: string[]; expressIds?: number[] }): { count: number }; setSection(args: { axis: 'x' | 'y' | 'z'; position: number }): void; clearSection(): void; colorByStorey(): { groups: number }; colorByProperty(args: { type: string; pset: string; property: string; sample: (expressId: number) => string | number | boolean | null; }): { legend: Array<{ value: string; count: number; color: ColorTuple }> }; getSelection(): SelectionHit[]; setOnSelectionChange(handler: ((hits: SelectionHit[]) => void) | null): void; /** Multi-subscriber. Returns an unsubscribe — safe to call from tools * that need a temporary listener without clobbering the panel's. */ subscribeSelection(handler: (hits: SelectionHit[]) => void): () => void; } // ── component ────────────────────────────────────────────────────────────── export interface PlaygroundViewerProps { /** Currently loaded model (or null). When this changes, the viewer reloads. */ model: LoadedPlaygroundModel | null; /** Notified once geometry has been processed. */ onReady?: () => void; /** Optional className to control sizing. */ className?: string; } /** * The viewer is mounted/unmounted by the parent. Geometry processing is * triggered the first time `model` becomes non-null AND the parent shows * the panel — driven by the parent unmounting the component when the * panel collapses (saves GPU memory on long sessions). */ export const PlaygroundViewer = forwardRef(function PlaygroundViewer( { model, onReady, className }, ref, ) { const containerRef = useRef(null); const sceneHandleRef = useRef(null); const [phase, setPhase] = useState<'idle' | 'processing' | 'ready' | 'error'>('idle'); const [phaseMsg, setPhaseMsg] = useState(''); const [meshCount, setMeshCount] = useState(0); useImperativeHandle( ref, (): ViewerController => ({ isLoaded: () => sceneHandleRef.current !== null, status: () => ({ loaded: sceneHandleRef.current !== null, meshCount, selection: sceneHandleRef.current?.getSelection() ?? [], }), colorize: (args) => sceneHandleRef.current?.colorize(args) ?? { count: 0 }, isolate: (args) => sceneHandleRef.current?.isolate(args) ?? { count: 0 }, hide: (args) => sceneHandleRef.current?.hide(args) ?? { count: 0 }, show: (args) => sceneHandleRef.current?.show(args) ?? { count: 0 }, reset: () => sceneHandleRef.current?.reset(), flyTo: (args) => sceneHandleRef.current?.flyTo(args) ?? { count: 0 }, setSection: (args) => sceneHandleRef.current?.setSection(args), clearSection: () => sceneHandleRef.current?.clearSection(), colorByStorey: () => sceneHandleRef.current?.colorByStorey() ?? { groups: 0 }, colorByProperty: (args) => sceneHandleRef.current?.colorByProperty(args) ?? { legend: [] }, getSelection: () => sceneHandleRef.current?.getSelection() ?? [], setOnSelectionChange: (h) => sceneHandleRef.current?.setOnSelectionChange(h), subscribeSelection: (h) => sceneHandleRef.current?.subscribeSelection(h) ?? (() => undefined), }), [meshCount], ); // Mount Three.js once. useEffect(() => { const container = containerRef.current; if (!container) return; const handle = createScene(container); sceneHandleRef.current = handle; return () => { handle.dispose(); sceneHandleRef.current = null; }; }, []); // Load geometry whenever the model changes (and the component is mounted — // the parent decides when to mount us). useEffect(() => { let cancelled = false; if (!model) { sceneHandleRef.current?.unloadModel(); setPhase('idle'); setMeshCount(0); return; } void (async () => { setPhase('processing'); setPhaseMsg('booting geometry pipeline…'); try { const processor = new GeometryProcessor({ preferNative: false }); await processor.init(); setPhaseMsg('extracting geometry…'); // Use our owning byte snapshot — store.source can be a sub-view that // the parser detached internally on big files. const result = await processor.process( model.bytes, model.store.entityIndex.byId as unknown as Map, ); if (cancelled) return; const meshes = result.meshes ?? []; // eslint-disable-next-line no-console console.log('[playground-viewer] geometry result:', { meshCount: meshes.length, firstMeshVerts: meshes[0]?.positions?.length, coordinateInfo: result.coordinateInfo, }); if (meshes.length === 0) { setPhase('error'); setPhaseMsg('No drawable geometry — model may be schema-only.'); return; } sceneHandleRef.current?.loadMeshes(meshes, model); setMeshCount(meshes.length); setPhase('ready'); onReady?.(); } catch (err) { if (cancelled) return; // eslint-disable-next-line no-console console.error('[playground-viewer] geometry processing failed', err); setPhase('error'); setPhaseMsg(err instanceof Error ? err.message : String(err)); } })(); return () => { cancelled = true; }; }, [model, onReady]); return ( // The outer wrapper must be a positioning context for the absolute // canvas container below. We use a Tailwind class for `relative` so it // doesn't fight the parent-supplied `className` (the parent typically // passes `absolute inset-0` to drop us into a sized box).
{/* phase HUD — small hairline tag so the user can see whether geometry processing actually landed even when the canvas is dark */} {phase === 'ready' && (
● {meshCount} meshes
)} {phase !== 'ready' && (
{phase === 'processing' && ( {phaseMsg || 'preparing…'} )} {phase === 'error' && ⚠ {phaseMsg}} {phase === 'idle' && load a model first}
)}
); }); // ── scene factory + per-entity book-keeping ──────────────────────────────── interface SceneHandle { loadMeshes(meshes: MeshData[], model: LoadedPlaygroundModel): void; unloadModel(): void; colorize(args: { globalIds?: string[]; expressIds?: number[]; type?: string; color: ColorTuple }): { count: number }; isolate(args: { globalIds?: string[]; expressIds?: number[]; type?: string }): { count: number }; hide(args: { globalIds?: string[]; expressIds?: number[]; type?: string }): { count: number }; show(args: { globalIds?: string[]; expressIds?: number[]; type?: string }): { count: number }; reset(): void; flyTo(args: { globalIds?: string[]; expressIds?: number[] }): { count: number }; setSection(args: { axis: 'x' | 'y' | 'z'; position: number }): void; clearSection(): void; colorByStorey(): { groups: number }; colorByProperty(args: { type: string; pset: string; property: string; sample: (expressId: number) => string | number | boolean | null; }): { legend: Array<{ value: string; count: number; color: ColorTuple }> }; getSelection(): SelectionHit[]; setOnSelectionChange(handler: ((hits: SelectionHit[]) => void) | null): void; subscribeSelection(handler: (hits: SelectionHit[]) => void): () => void; dispose(): void; } interface EntityRecord { expressId: number; globalId?: string; ifcType?: string; storeyName?: string; mesh: THREE.Mesh; baseColor: THREE.Color; baseOpacity: number; } function createScene(container: HTMLElement): SceneHandle { // ── Renderer ───────────────────────────────────────────────────────────── // Mirrors examples/threejs-viewer EXACTLY (renderer setup, lighting, // material settings, camera). The only difference is that we render to // a divs-attached canvas (not document.getElementById('viewer')). const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(container.clientWidth, container.clientHeight, false); renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; renderer.setClearColor(NIGHT, 1); renderer.localClippingEnabled = true; renderer.domElement.style.display = 'block'; renderer.domElement.style.width = '100%'; renderer.domElement.style.height = '100%'; container.appendChild(renderer.domElement); const scene = new THREE.Scene(); scene.background = new THREE.Color(NIGHT); const camera = new THREE.PerspectiveCamera(50, container.clientWidth / Math.max(1, container.clientHeight), 0.1, 10000); camera.position.set(20, 15, 20); camera.lookAt(0, 0, 0); // Lighting (parity with the threejs-viewer example). scene.add(new THREE.AmbientLight(0xffffff, 0.6)); const key = new THREE.DirectionalLight(0xffffff, 0.8); key.position.set(50, 80, 50); scene.add(key); const fill = new THREE.DirectionalLight(0xb0c4de, 0.3); fill.position.set(-30, 10, -20); scene.add(fill); // Reusable group so loadMeshes can clear without affecting lights. // No rotation here: @ifc-lite/geometry already converts IFC Z-up to // Three.js Y-up at the vertex level (swap Y/Z + negate new Z to keep // right-handedness). Adding a second rotation here was tipping the // whole building on its side. const modelGroup = new THREE.Group(); scene.add(modelGroup); // Section plane (Y-axis in three space ↔ Z in IFC after rotation). const sectionPlanes: THREE.Plane[] = []; let activeSectionPlane: THREE.Plane | null = null; // Controls. const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08; // ── per-entity registry ───────────────────────────────────────────────── const records: EntityRecord[] = []; const byExpressId = new Map(); const byGlobalId = new Map(); const byType = new Map(); const byStorey = new Map(); let modelRef: LoadedPlaygroundModel | null = null; let selection: SelectionHit[] = []; // Multi-subscriber so a temporary listener (e.g. viewer_wait_for_selection) // doesn't displace the panel's permanent one. Anything calling // `setOnSelectionChange` keeps that single-handler convenience but // routes through this set. const selectionListeners = new Set<(hits: SelectionHit[]) => void>(); let convenienceListener: ((hits: SelectionHit[]) => void) | null = null; function notifySelection(hits: SelectionHit[]) { convenienceListener?.(hits); for (const l of selectionListeners) l(hits); } // ── Picking ───────────────────────────────────────────────────────────── const raycaster = new THREE.Raycaster(); const pointer = new THREE.Vector2(); const SELECTION_COLOR = new THREE.Color(0xff5cdc); function onPointerUp(e: PointerEvent) { const rect = renderer.domElement.getBoundingClientRect(); pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1; pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(pointer, camera); const visibleMeshes = records.filter((r) => r.mesh.visible).map((r) => r.mesh); const hits = raycaster.intersectObjects(visibleMeshes, false); if (hits.length === 0) { // Empty pick — clear selection. clearSelectionHighlight(); selection = []; notifySelection(selection); return; } const hit = hits[0].object as THREE.Mesh; const rec = records.find((r) => r.mesh === hit); if (!rec) return; clearSelectionHighlight(); (rec.mesh.material as THREE.MeshStandardMaterial).color.copy(SELECTION_COLOR); selection = [{ expressId: rec.expressId, globalId: rec.globalId, ifcType: rec.ifcType }]; notifySelection(selection); } function clearSelectionHighlight() { for (const r of records) { (r.mesh.material as THREE.MeshStandardMaterial).color.copy(r.baseColor); } } // Drag-vs-click discrimination: only treat as click if the pointer didn't // move more than 4 px between down + up. let downX = 0, downY = 0; renderer.domElement.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; }); renderer.domElement.addEventListener('pointerup', (e) => { if (Math.hypot(e.clientX - downX, e.clientY - downY) < 4) onPointerUp(e); }); // ── animation loop ────────────────────────────────────────────────────── let raf = 0; let disposed = false; function tick() { if (disposed) return; raf = requestAnimationFrame(tick); controls.update(); renderer.render(scene, camera); } tick(); // Resize. const ro = new ResizeObserver(() => { const w = container.clientWidth; const h = container.clientHeight; if (w === 0 || h === 0) return; camera.aspect = w / Math.max(1, h); camera.updateProjectionMatrix(); renderer.setSize(w, h, false); }); ro.observe(container); // ── helpers ───────────────────────────────────────────────────────────── function clearModel() { for (const r of records) { r.mesh.geometry.dispose(); const mat = r.mesh.material as THREE.Material; mat.dispose(); modelGroup.remove(r.mesh); } records.length = 0; byExpressId.clear(); byGlobalId.clear(); byType.clear(); byStorey.clear(); selection = []; modelRef = null; } function selectTargets(args: { globalIds?: string[]; expressIds?: number[]; type?: string }): EntityRecord[] { const out = new Set(); if (args.expressIds) for (const id of args.expressIds) { const r = byExpressId.get(id); if (r) out.add(r); } if (args.globalIds) for (const gid of args.globalIds) { const r = byGlobalId.get(gid); if (r) out.add(r); } if (args.type) { // Match by leading IfcType (case-insensitive). The geometry pipeline // strips the "Ifc" prefix or upper-cases freely depending on schema, // so we tolerate either form. const want = args.type.toLowerCase(); for (const [t, list] of byType) { if (t.toLowerCase() === want) for (const r of list) out.add(r); } } if (out.size === 0 && !args.expressIds && !args.globalIds && !args.type) { // No targets specified → all for (const r of records) out.add(r); } return Array.from(out); } /** Compute the world-space bounding box of a set of records. Robust to * the modelGroup's Y-up rotation: forces matrixWorld update first, then * expands the box by each geometry's local bbox transformed into world. */ function worldBox(records: EntityRecord[]): THREE.Box3 { modelGroup.updateMatrixWorld(true); const box = new THREE.Box3(); const tmp = new THREE.Box3(); for (const r of records) { r.mesh.geometry.computeBoundingBox(); const local = r.mesh.geometry.boundingBox; if (!local || !isFinite(local.min.x) || !isFinite(local.max.x)) continue; tmp.copy(local).applyMatrix4(r.mesh.matrixWorld); box.union(tmp); } return box; } /** Fit the camera + orbit target to a record set. If `instant` is true the * camera snaps; otherwise it tweens (used by viewer_fly_to). */ function frameOn(records: EntityRecord[], instant = false) { if (records.length === 0) return; const box = worldBox(records); if (box.isEmpty()) return; const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z); const radius = (maxDim || 1) * 0.6 + 1; // Place camera diagonally above + offset so the building reads in // perspective. Distance scales with the model size so a 5 m hut and a // 200 m bridge both frame nicely. const dir = new THREE.Vector3(0.55, 0.55, 0.62).normalize(); const distance = Math.max(radius * 2.6, maxDim * 1.4 + 4); const target = center.clone().add(dir.multiplyScalar(distance)); // Tighten the camera near/far plane so big georeferenced bboxes don’t // crush precision into one z-buffer slab. camera.near = Math.max(0.05, distance / 5000); camera.far = Math.max(500, distance * 20); camera.updateProjectionMatrix(); if (instant) { camera.position.copy(target); controls.target.copy(center); controls.update(); return; } // Animate camera/target — single tween via lerp on rAF. const startPos = camera.position.clone(); const startTarget = controls.target.clone(); const startedAt = performance.now(); const dur = 600; function tween() { if (disposed) return; const t = Math.min(1, (performance.now() - startedAt) / dur); const e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; camera.position.lerpVectors(startPos, target, e); controls.target.lerpVectors(startTarget, center, e); controls.update(); if (t < 1) requestAnimationFrame(tween); } tween(); } return { loadMeshes(meshes, model) { clearModel(); modelRef = model; // Build per-entity records. // // CRITICAL: side = THREE.DoubleSide for every material, regardless // of opacity. The IFC geometry pipeline produces meshes whose // triangle winding is INCONSISTENT — some triangles are CCW, some // are CW. The native @ifc-lite/renderer pipeline turns culling // off everywhere for the same reason (see // packages/renderer/src/pipeline.ts:141 — "Disable culling to debug // - IFC winding order varies"). Using FrontSide here culls roughly // half the triangles per element, which is exactly the // "see-through, back faces visible" symptom we hit. DoubleSide // costs us a few percent fillrate but renders correctly. // // We also call computeVertexNormals() defensively in case any // mesh's normal buffer is stale or zeroed out — the geometry // pipeline writes them but we want to be sure shading reads right. let opaqueCount = 0; let transparentCount = 0; for (const md of meshes) { const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.BufferAttribute(md.positions, 3)); geom.setAttribute('normal', new THREE.BufferAttribute(md.normals, 3)); geom.setIndex(new THREE.BufferAttribute(md.indices, 1)); // If the supplied normals are degenerate (all zeros), regenerate // from the indexed triangles. Cheap if normals were already good. const n = md.normals; if (n.length === 0 || (Math.abs(n[0]) + Math.abs(n[1]) + Math.abs(n[2])) < 1e-6) { geom.computeVertexNormals(); } geom.computeBoundingSphere(); const [r, g, b, a] = md.color; const baseColor = new THREE.Color(r, g, b); const isTransparent = a < 1; if (isTransparent) transparentCount++; else opaqueCount++; const mat = new THREE.MeshStandardMaterial({ color: baseColor, transparent: isTransparent, opacity: a, side: THREE.DoubleSide, // see comment above depthWrite: !isTransparent, clippingPlanes: sectionPlanes, }); const mesh = new THREE.Mesh(geom, mat); // Geometry buffers stay in the element's local frame for f32 precision; // the per-mesh origin (already in the renderer Y-up frame) goes into the // mesh transform so world = origin + position — mirroring the renderer's // per-batch model-matrix translate. No-op when origin is absent. if (md.origin) mesh.position.set(md.origin[0], md.origin[1], md.origin[2]); mesh.userData.expressId = md.expressId; mesh.userData.ifcType = md.ifcType; modelGroup.add(mesh); let globalId: string | undefined; let ifcType: string | undefined = md.ifcType; let storeyName: string | undefined; // Resolve more accurate IFC metadata from the parsed store. if (model.store.entityIndex.byId.has(md.expressId)) { try { const node = new EntityNode(model.store, md.expressId); globalId = node.globalId || undefined; if (!ifcType || ifcType === 'IfcProduct') ifcType = node.type; const storey = node.storey(); if (storey) storeyName = storey.name; } catch (err) { // Optional metadata enrichment — if EntityNode regresses, fall // back to the geometry-derived fields (already populated). // Surface at debug level so a real parser issue isn't silent. // eslint-disable-next-line no-console console.debug('[playground-viewer] EntityNode metadata lookup failed', { expressId: md.expressId, err }); } } const rec: EntityRecord = { expressId: md.expressId, globalId, ifcType, storeyName, mesh, baseColor, baseOpacity: md.color[3], }; records.push(rec); byExpressId.set(md.expressId, rec); if (globalId) byGlobalId.set(globalId, rec); if (ifcType) { const list = byType.get(ifcType) ?? []; list.push(rec); byType.set(ifcType, list); } if (storeyName) { const list = byStorey.get(storeyName) ?? []; list.push(rec); byStorey.set(storeyName, list); } } // Snap the camera to the loaded model immediately (no tween — there’s // nothing to tween from on first load). frameOn(records, true); // eslint-disable-next-line no-console console.log('[playground-viewer] mounted meshes:', { count: records.length, opaque: opaqueCount, transparent: transparentCount, bbox: (() => { const b = worldBox(records); return b.isEmpty() ? null : { min: b.min.toArray(), max: b.max.toArray() }; })(), camera: camera.position.toArray(), target: controls.target.toArray(), firstColors: meshes.slice(0, 3).map((m) => m.color), }); }, unloadModel() { clearModel(); }, colorize(args) { const targets = selectTargets(args); const c = new THREE.Color(args.color[0], args.color[1], args.color[2]); // Always set transparency state, even when the new alpha is opaque — // otherwise a previous translucent colorize leaves the entity // permanently see-through until reset(). Treat alpha as part of the // base colour so subsequent reset() / clear-selection paths put it // back, not pick a stale opacity from before this call. const alpha = args.color[3] ?? 1; for (const r of targets) { const mat = r.mesh.material as THREE.MeshStandardMaterial; mat.color.copy(c); r.baseColor.copy(c); mat.transparent = alpha < 0.999; mat.opacity = alpha; r.baseOpacity = alpha; } return { count: targets.length }; }, isolate(args) { const targets = new Set(selectTargets(args)); for (const r of records) { r.mesh.visible = targets.has(r); } return { count: targets.size }; }, hide(args) { const targets = selectTargets(args); for (const r of targets) r.mesh.visible = false; return { count: targets.length }; }, show(args) { const targets = selectTargets(args); for (const r of targets) r.mesh.visible = true; return { count: targets.length }; }, reset() { for (const r of records) { r.mesh.visible = true; const mat = r.mesh.material as THREE.MeshStandardMaterial; mat.color.copy(r.baseColor); mat.opacity = r.baseOpacity; mat.transparent = r.baseOpacity < 0.999; } activeSectionPlane = null; sectionPlanes.length = 0; }, flyTo(args) { const targets = selectTargets(args); if (targets.length === 0) return { count: 0 }; frameOn(targets); return { count: targets.length }; }, setSection({ axis, position }) { sectionPlanes.length = 0; // Geometry is in Three.js coordinates (Y is up after the geometry // pipeline's Z-up→Y-up conversion). The agent's `axis` arg is read // in the same convention: 'y' is the horizontal "cut the top off" // plane, 'x' / 'z' are vertical slabs perpendicular to those world // axes. Three.js clipping plane keeps points where n·x + d > 0. const normal = new THREE.Vector3( axis === 'x' ? -1 : 0, axis === 'y' ? -1 : 0, axis === 'z' ? -1 : 0, ); activeSectionPlane = new THREE.Plane(normal, position); sectionPlanes.push(activeSectionPlane); for (const r of records) { const mat = r.mesh.material as THREE.MeshStandardMaterial; mat.clippingPlanes = sectionPlanes; mat.needsUpdate = true; } }, clearSection() { sectionPlanes.length = 0; activeSectionPlane = null; for (const r of records) { const mat = r.mesh.material as THREE.MeshStandardMaterial; mat.clippingPlanes = []; mat.needsUpdate = true; } }, colorByStorey() { // Distinct hue per storey. HSV evenly spaced. const storeyNames = Array.from(byStorey.keys()); storeyNames.forEach((name, i) => { const h = (i / Math.max(1, storeyNames.length)) * 0.85; const c = new THREE.Color().setHSL(h, 0.6, 0.55); for (const r of byStorey.get(name) ?? []) { (r.mesh.material as THREE.MeshStandardMaterial).color.copy(c); r.baseColor.copy(c); } }); return { groups: storeyNames.length }; }, colorByProperty({ type, sample }) { const records = byType.get(type) ?? []; const buckets = new Map(); for (const r of records) { const v = sample(r.expressId); const key = v == null ? '(missing)' : String(v); const list = buckets.get(key) ?? []; list.push(r); buckets.set(key, list); } const PALETTE: ColorTuple[] = [ [0.84, 1.0, 0.25, 1], [0.48, 0.45, 0.95, 1], [1.0, 0.36, 0.86, 1], [0.45, 0.85, 0.79, 1], [1.0, 0.62, 0.39, 1], [0.62, 0.81, 0.42, 1], [0.50, 0.50, 0.55, 1], ]; const legend: Array<{ value: string; count: number; color: ColorTuple }> = []; let i = 0; for (const [value, list] of buckets) { const color = value === '(missing)' ? [0.4, 0.4, 0.45, 1] as ColorTuple : PALETTE[i++ % PALETTE.length]; const c = new THREE.Color(color[0], color[1], color[2]); for (const r of list) { (r.mesh.material as THREE.MeshStandardMaterial).color.copy(c); r.baseColor.copy(c); } legend.push({ value, count: list.length, color }); } return { legend }; }, getSelection() { return selection; }, setOnSelectionChange(h) { convenienceListener = h; }, subscribeSelection(h) { selectionListeners.add(h); return () => selectionListeners.delete(h); }, dispose() { disposed = true; cancelAnimationFrame(raf); ro.disconnect(); controls.dispose(); clearModel(); renderer.dispose(); if (renderer.domElement.parentNode === container) { container.removeChild(renderer.domElement); } }, }; // Avoid "unused" lint flag on the modelRef helper. void modelRef; }