ai-chatbot/packages/editor/src/lib/glb-export.ts
Wassim SAMAD 5c071ecad5
feat(plugins): plugin contract + first-party Nature pack (trees/flowers/grass) (#457)
* feat(plugins): trees tracer-bullet plugin + minimal host panel surface

Ship one real first-party plugin and only the host surface it forces
into existence, proving the plugin contribution paths every future
plugin (mint.gg generator, Home Assistant, environments, room volume)
will reuse.

Host surface (core + editor):
- core: Plugin.panels + observable panelRegistry; loadPlugin routes
  panels (namespaced by plugin id, dup-throws like nodes).
- editor: AppSidebar merges panelRegistry into the icon rail via
  useSyncExternalStore; each plugin panel lazy-loaded behind an error
  boundary; host extraPanels keep precedence.
- editor: widen Tool to `KnownTool | (string & {})` so plugin tool ids
  (e.g. 'trees:tree') typecheck; dispatch already registry-first.

Trees plugin (packages/plugin-trees), structurally a third-party pack
(peer-deps on @pascal-app/*):
- trees:tree node — procedural low-poly geometry (oak/pine/birch/palm),
  free parametric inspector (preset/height/seed + Randomize), placement
  tool + ghost preview built from public primitives only.
- presets rail panel — panel -> plugin store -> def.tool -> SceneApi ->
  scene -> reactive read-back ("N planted").

Loading + docs:
- apps/editor: setPluginDiscovery([treesPlugin]) in bootstrap;
  transpilePackages + dep.
- wiki/architecture/plugin-authoring.md: panels field, error-boundary
  contract, styling, tool-from-panel note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plugins): merge plugin panels into the v2 sidebar layout too

The community shell renders <Editor layoutVersion="v2" sidebarTabs={...}>,
a separate rail from AppSidebar. Merge registry panels into the v2
tabMap + tab bar (and thus mobile) so plugin panels show up there as
well, not only in the v1 AppSidebar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plugin-trees): ez-tree geometry + instanced rendering + snapping

Replace the placeholder low-poly geometry with dgreenheck/ez-tree
(self-contained — bark/leaf textures inlined as base64, no assets to
host) and render the forest with true GPU instancing.

Rendering:
- def.system (system.tsx): one collective renderer that groups every
  trees:tree node by (preset, seed) variant and draws each variant as
  one InstancedMesh per ez-tree sub-mesh, composing the parent level's
  world matrix into per-instance matrices. ~1 draw call per variant.
- def.renderer (proxy-renderer.tsx): an invisible, raycastable per-node
  proxy so the host's existing selection / outline / zone machinery
  keeps working — no instanceId bookkeeping. (Outline highlights the
  proxy bbox; documented.)
- geometry per variant generated once by ez-tree and cached; seeds drawn
  from a bounded pool so trees actually share variants.

Presets remapped to ez-tree built-ins (oak/pine/aspen/ash/bush).

Placement now respects the active snap mode — isGridSnapActive() +
gridSnapStep + snapPointToGrid, like the built-in item/shelf tools.

transpilePackages += @dgreenheck/ez-tree in the editor app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plugin-trees): true-silhouette hover/select + richer presets panel

Selection/hover now outlines the real tree shape, not the bbox. The
proxy splits into an outer group (stable invisible box collider +
pointer handlers, the raycast target) and an inner registered group that
mounts the real ez-tree geometry (invisible, non-raycasting) only while
the node is hovered or selected. The host outline pass reads the
registered inner group, so it traces the true silhouette; picking stays
on the steady box. Geometry for the highlight reuses the cached variant.

Panel: redesigned cards with gradient swatches + selected state, a
"planted" count chip, and a height slider that seeds the next tree's
height (a per-instance scale — never touches placed trees or instancing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plugin-trees): curated tree params + procedural flowers (sibling kind)

Tree params: expose foliageDensity, trunkThickness, and a leafless toggle
in the inspector and the panel brush. Each is folded into the instancing
variant key and mapped onto ez-tree options (radius scale, leaf count),
so editing one tree only re-buckets that tree — instancing degrades
gracefully, never worse than per-node.

Flowers: add a `trees:flower` sibling kind — simple procedural geometry
(stem + petals + center, merged per material), presets daisy / tulip /
lavender, instanced + selectable exactly like trees.

Refactor: extract the instanced renderer + selection proxy into a generic
`instanced.tsx` (InstancedKindSystem + KindProxy) and the snap/level/grid
placement wiring into `placement.tsx` (usePlacement). Trees and flowers
are now thin bindings — the template for future plant kinds.

Panel: a Trees / Flowers toggle, gradient preset cards, per-kind planted
count, and brush sliders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plugin-trees): native inspector controls in panel + preset thumbnails + ez-tree credit

- swap the panel's custom slider/checkbox/toggle for the host's exported
  SliderControl/ToggleControl/SegmentedControl so the brush matches the
  right-hand inspector pixel-for-pixel
- replaceable preset thumbnails (inline SVG data URIs, no asset hosting) render
  as <img> cards instead of gradient swatches
- credit footer linking @dgreenheck/ez-tree (Daniel Greenheck, MIT)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugin-trees): all ez-tree presets + type + leaf/branch colours, flower petal colour, grass kind

- tree presets now cover all of ez-tree's built-ins via species × size
  (Small/Medium/Large + Bush 1/2/3 + Trellis), plus a Deciduous/Evergreen type
- edit-only leaf & branch colour tints (leaves.tint / bark.tint), all folded
  into the instancing variant key
- flowers gain a per-flower petal colour (baked from preset at placement)
- new trees:grass instanced kind — procedural blade tufts (meadow/fescue/reed)
  with a per-tuft blade colour, reusing the generic instanced + placement core
- panel: Trees/Flowers/Grass segmented switch, size + type controls, native
  host controls throughout; credit links Daniel Greenheck's X

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(plugin-trees): tolerate nodes persisted before new fields existed

treeSpecOf/flowerPetalColor now default every geometry field, and hexToInt
guards undefined — trees/flowers placed before size/type/colour fields were
added load with neutral defaults instead of crashing the instanced renderer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugin-trees): shared always-on wind + pin credits to panel bottom

- wind.ts injects a sway into each material's begin_vertex driven by one shared
  uTime uniform (advanced per frame in the instanced system); USE_INSTANCING
  guards keep the same material valid for the non-instanced placement ghost.
  Applied to tree, flower, and grass materials — a whole scene sways like ez-tree.
- credits footer is now sticky to the bottom of the panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugin-trees): rename rail panel Trees -> Nature (covers trees/flowers/grass)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugin-trees): rename panel header Plant -> Nature to match the rail

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: bake-exportable instanced plants + renderer-agnostic wind

Bake (GLB export): add a transient useViewer.isExporting flag. BakeExporter
flips it, waits for the commit, then exports. KindProxy watches it and, during
export, emits each plant's REAL visible geometry under scene-renderer (which the
exporter clones) instead of the invisible collider — so instanced kinds
(def.system) that live outside that subtree are captured. The collider box is
dropped during export so it doesn't bake as a phantom solid.

Wind: the previous onBeforeCompile wind was a no-op under the editor's WebGPU
renderer (WebGL-only hook). Replace it with a renderer-agnostic per-instance
base-pivot tilt animated in useFrame (which FrameLimiter drives continuously).
Reads as wind under any renderer; removes the dead wind.ts + applyWind calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugin-trees): TSL vertex-bend wind (WebGPU), replacing CPU tilt

wind-node.ts adds a TSL positionNode that bends each plant proportional to
height above its base, phased per-instance (instanceIndex) and per-vertex, so
tips sway and roots stay planted — animated on the GPU via the renderer's time
node. ez-tree is untouched: its materials are copied into MeshStandardNodeMaterial
(toWindMaterial); flowers/grass build node materials directly (windStandardMaterial).
Instance matrices go back to static.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(plugin-trees): tree materials render black under wind (Phong not Standard)

ez-tree's bark/leaves are MeshPhongMaterial; copying them into a
MeshStandardNodeMaterial swapped the shading model (specular/shininess ->
roughness/metalness) and rendered black. Convert into the matching node
material type (Phong->Phong, Lambert->Lambert, else Standard) so map/color/
alphaTest/side are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(plugin-trees): trees still black — build node material from map/color explicitly

Material.copy() doesn't transfer a classic material's map/color onto a node
material, so textured trees rendered black (flowers/grass were fine — they carry
no map). Re-create the ez-tree materials as MeshStandardNodeMaterial with map,
alphaMap, color, side, alphaTest passed explicitly in the constructor — the same
proven path the flower/grass materials use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(plugin-trees): tree wind — per-leaf flutter (ez-tree style), static trunk

The whole-tree height-based bend was a rigid rotation about the base ('rotating
in place'). Replicate ez-tree's actual approach: scale sway by the leaf card's
uv.y and apply it ONLY to the 'leaves' material (multi-frequency wave, phased per
instance + per leaf) so leaves flutter from their attachment while bark/branches
stay static. Flowers/grass keep the gentle whole-plant stem bend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugins): Plugin.onSceneLoad hook + viewer application; trees wind via it

Adds a broad, plugin-declared post-load scene hook so effects survive GLB export:
- core: Plugin.onSceneLoad (lazy, three-free) + observable sceneHookRegistry + loadPlugin routing.
- viewer: applyPluginSceneHooks util; PluginSceneHooksSystem re-runs it on the live
  scene as nodes change; GlbScene runs it on each loaded baked GLB.
- plugin-trees: declare onSceneLoad; wind is no longer welded into the materials —
  the hook re-attaches LEAF_FLUTTER/STEM_BEND by material name (leaves / flower-* /
  grass-*), so it now animates in the baked /viewer too, not just live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(plugin-trees): scale-invariant leaf wind (÷ modelScale) so baked GLB matches editor

The bake's quantiser renormalises each plant mesh to [-1,1] and moves real size
into a large node scale (~3 on trees). LEAF_FLUTTER added a constant local offset,
so in the baked GLB it was multiplied by that scale → ~3x over-swing. Divide the
sway by modelScale.x to keep a constant world-space amplitude; editor (scale 1)
is unchanged, baked now matches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* revert: onSceneLoad wind approach → back to wind-in-materials

Reverts the Plugin.onSceneLoad hook + viewer application + modelScale compensation
(commits 893ca839, 6c9ee003). Superseded by the bake-policy 'replace' model
(plans/editor-plugin-trees-example.md Part D): trees render live in the viewer via
their own path, so wind is a plain positionNode on the material again — no hook,
no baked-geometry decoration, no coordinate-space fights.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(bake): per-kind bake policy (static/strip/replace)

Adds NodeDefinition.bake ('static' default | 'strip' | 'replace') + registry
helpers bakePolicyOf/kindsWithBakePolicy in core. Generalises the previously
hardcoded scan/guide handling:
- glb-export strips kinds with bake==='strip' from the artifact (was a
  scan/guide type check); 'replace' kinds stay baked (portable static snapshot).
- glb-reference-nodes selects rebuild candidates by policy instead of type.
- scan/guide declare bake:'strip'; trees/flower/grass declare bake:'replace'.

Viewer-side 'replace' swap (strip baked meshes + live-rebuild) is the next slice.
Part of plans/editor-plugin-trees-example.md Part D.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(bake): viewer 'replace' swap — hide baked meshes, re-render live

Adds NodeDefinition.bakeReplaceRenderer (falls back to renderer). The baked
/viewer now, for kinds with bake:'replace' and a loaded plugin:
- hides the static baked meshes (bakePolicyOf(kind)==='replace') in GlbScene's
  identity pass — capability-gated, so with no plugin the meshes stay;
- re-renders each node live via buildGlbReplaceNodes + the shared portal-into-
  baked-level path (GlbReferenceNode now prefers bakeReplaceRenderer), so trees
  ride level stacking automatically and wind runs in real coordinate space.

plugin-trees ships a hookless KindStatic (real geometry + wind materials, no
collider/selection) and per-kind static-renderer binds; tree/flower/grass point
bakeReplaceRenderer at them. Completes plans/editor-plugin-trees-example.md Part D.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* perf(viewer): memoize GlbReferenceNode(s) — skip forest re-render on camera move

GlbScene re-renders per frame during camera movement (hover raycast / walkthrough
HUD). The rebuilt nodes reconciled every frame — fine for 1-2 scans/guides, but a
bake:'replace' forest puts dozens of nodes here (profiler: KindStatic x56 6ms,
GlbReferenceNode x57 4ms per frame). memo at the node boundary skips the whole
subtree (incl. the plugin renderer) when (node, anchor) are unchanged; both props
are stable refs, so it short-circuits cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* perf(plugin-trees): NO_RAYCAST on KindStatic meshes — cheap hover over a forest

The baked scene's <primitive> has pointer handlers, so R3F recursively raycasts
every descendant on each pointer move (incl. orbit drags). The live replace trees
had fully-raycastable dense ez-tree geometry, so hover cost ~400ms/frame over a
forest (profiler: 'JavaScript' 413ms during camera move). They carry no pascalId
(a hit resolves to the level, not the tree), so they're scenery, not pick targets
— mirror KindProxy, which already NO_RAYCASTs its real geometry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(bake): viewer 'replace' renders instanced per level (was per-node)

Replaces the per-node KindStatic viewer path with a collective instanced one,
mirroring the editor's system instead of diverging from it:

- extract InstancedNodes from InstancedKindSystem (shared by both) with a
  localSpace flag: editor folds parent world matrix (root instances); viewer uses
  level-local matrices + NO_RAYCAST, portaled into the baked level.
- bakeReplaceRenderer is now a collective renderer (BakeReplaceRenderer<N>,
  receives {nodes}); GlbReplaceInstances groups replace nodes by (level, kind) and
  portals each kind's instanced renderer into that baked level. GlbReferenceNode
  reverts to per-node renderer (strip kinds only).

Fixes: forest hover cost (100+ tree meshes → a few instanced draws, R3F walks far
fewer objects) and per-tree wind phase (instanceIndex varies again; per-node
KindStatic gave every tree phase 0 → unison sway). Trees still ride level stacking
(portaled) and stay static in a plain glTF viewer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* perf(viewer): BVH the baked scene — accelerate hover/pick raycasts

The baked /viewer runs with useBvh={false}, so GlbScene's hover/pick raycasts
against the baked building were brute-force triangle intersection (profiler:
_computeIntersections + intersectTriangle ~30%). The parametric viewer wraps its
scene in <SceneBvh>; the baked one never did. SceneBvh's effect is one-shot on
mount and can't catch the async-loaded GLB, so compute a per-mesh BVH inside
GlbScene keyed on gltf.scene instead. 'replace' instances are NO_RAYCAST, so the
raycast===Mesh.prototype.raycast guard skips them (mirrors SceneBvh).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(plugin-trees): inherit ez-tree preset by default (seed/type/tints)

Our node defaults (seed:1, treeType:deciduous, colors:#ffffff) were applied as
*overrides* on top of loadPreset, discarding each preset's tuned seed, growth
model, and tints — so our trees looked nothing like eztree.dev (pine grew
deciduous, foliage washed white, every silhouette off).

Mirror the flower petalColor pattern: seed/treeType/leafColor/branchColor are now
optional; generateTree only overrides an ez-tree option when the node set it,
otherwise loadPreset's value stands. Placement stores none of them (fresh tree =
pure preset; all same-preset trees share one instancing variant); the deciduous/
evergreen brush toggle is gone (growth model comes from the preset). The inspector
keeps them as per-tree overrides, and Randomize still varies the seed.

Verified against ez-tree: fresh Oak Medium → seed 35729 + preset tints; Pine
Medium → evergreen + seed 13977; overrides apply when set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugin-trees): bundle real preset artwork (webp, in-package)

Replaces the placeholder SVG thumbnails + lucide panel icon with the real nature
art. 13 webp (~126KB total, 256px cards / 128px icon) live in src/assets and are
imported via art.ts — both consumers are Next, so transpilePackages runs them
through the image pipeline (hashed, cached /_next/static/media URLs). No CDN, no
per-app public/ mirroring; the assets travel with the package.

- art.ts: central webp imports → TREE_ART / FLOWER_ART / GRASS_ART / NATURE_ICON
- presets/flower-presets/grass-presets: thumbnail ← bundled art (was *Thumbnail())
- index.ts: Nature panel icon ← NATURE_ICON (was lucide:leaf)
- assets.d.ts: ambient *.webp decl (no next type dep)
- remove thumbnails.ts (placeholder generators, now dead)

Verified on the running dev server: webp emitted + served (HTTP 200, image/webp).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugin-trees): square preset cards on light-gray flattened thumbnails

Regenerate the 12 thumbnails with the transparency flattened onto gray-100
(#f3f4f6) and render the card square (aspect-square, was h-16 crop). The
nature-icon keeps its transparency for the icon rail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(plugin-trees): correct move-drag + outline behavior for instanced plants

Two selection defects, one root cause each:

1. Outline moved mirror-wise during a move drag. The host move tool drives the
   registered Object3D imperatively with absolute level-local positions (and
   mirrors them via useLiveTransforms) — the contract ParametricNodeRenderer
   satisfies by putting position + registration on the same group. KindProxy
   registered a child nested inside the transform-carrying group, so drag
   positions landed in the node's rotated frame (placement gives every plant a
   random Y rotation → deltas rotated up to 180°). Restructure: the registered
   group now carries position/rotation (live-transform aware); the box collider
   is a positioned sibling, staying out of the outline mask.

2. Outline froze while the mesh swayed. The outline mask pass renders with a
   shared override material, so it can never follow the material positionNode
   wind. Instead, while hovered/selected the collective system skips the node
   and the proxy mounts the real geometry with static twins of the wind
   materials (toStaticMaterial — explicit property transfer; node-material
   clone() drops map/color). Outlined mesh == visible mesh, both still.

Bonus from the same restructure: a move drag now animates the actual plant in
realtime (the proxy is what the tool drives), not just the drag box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugin-trees): 2D plan symbols + trunk-sized footprint

- def.floorplan for the three kinds — the registry floor-plan layer renders any
  kind that provides one, so this is all plugin nodes need to appear in 2D.
  Tree: dashed canopy ring (dashed = overhead element) in the preset swatch +
  solid trunk dot; ring is pointer-events:stroke so the large disc doesn't
  steal clicks from what's under the canopy. Flower/grass: small colour dots.
  Selected → palette stroke + move-handle; hovered → hover stroke.
- Tree floorPlaced.footprint is trunk-sized (treeTrunkRadius) instead of
  canopy-sized, so the move/placement drag box hugs where the tree actually
  plants instead of spanning the crown. The invisible hover collider keeps its
  larger radius — only the displayed box shrinks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugin-trees): trunk-sized drag box, 45°-aligned rotation, rotate gizmo

- The move drag box ignored floorPlaced.footprint: with collides:false the tool
  auto-measures the rendered mesh — which, since the selection swap-out, is the
  whole canopy. Declare capabilities.dragBounds (trunk-sized, node-local) so the
  box hugs the trunk and rotates with the node instead of wrapping the crown as
  a world AABB.
- Placement wrote a fully random Y rotation, so the box/gizmo never sat on a 45°
  step. Snap the random rotation to 45° increments (variety preserved, alignment
  restored) and widen rotatable.snapAngles to 8×45° on all three kinds.
- Add the standard rotate gizmo (def.handles, shelf-style arc handle): ring
  around the trunk near the ground — not the canopy, which would put the handle
  meters out on a large oak.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugin-trees): live rotate preview + ground-level rotate ring

- KindProxy now folds useLiveNodeOverrides into its transform (mirrors
  ParametricNodeRenderer) — the rotate/resize gizmos publish mid-drag patches
  there, so the plant turns in realtime instead of snapping on commit. The
  snap-on-commit (with the arc delta wrapping to ±180°) is also what made long
  drags land 'the wrong way'; with live feedback the direction reads correctly
  and matches the item gizmo pipeline exactly.
- Rotate ring drops from mid-trunk to 0.25m — a floor affordance like the item
  gizmo, not a waist-height hoop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(registry): panelForKind — 'find in catalog' opens a plugin kind's panel

loadPlugin now associates every node kind with its plugin's first (namespaced)
panel id; panelRegistry.panelForKind(kind) exposes it. A host's find-node
handler can open the right panel for any plugin kind with zero per-plugin
knowledge.

plugin-trees uses it end-to-end: a module-level selection:find-node listener
(find-sync.ts, imported by the manifest so it's live from plugin load) points
the panel store at the found node's section + preset — panel section state
moved from panel-local useState into the store to make it addressable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(registry): plugin panels declare their workspaces (default edit-only)

Plugin panels rode into the studio rail: the host swaps its own sidebar tabs by
workspaceMode, but usePluginPanels appended registry panels unconditionally.

PluginPanel gains workspaces?: readonly ('edit'|'studio')[] — manifest metadata,
default ['edit'] (an authoring panel has no business in the clean render
workspace; a plugin shipping studio tooling opts in explicitly). usePluginPanels
filters by the current workspaceMode, covering both the v1 AppSidebar and v2 tab
bar paths. Nature declares nothing and disappears from studio via the default;
the v2 layout's existing active-tab fallback handles a panel vanishing mid-use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: link plugin-authoring guide from README + CONTRIBUTING, fix stale wiki path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugin-trees): keep ez-tree off the eager SSR path

ez-tree loads its inlined textures at module scope (needs document), so any
eager import chain reaching geometry.ts crashed Next prerender
(ReferenceError: document is not defined on /_not-found). Move the pure
helpers (mulberry32, naturalHeight) to variant-utils.ts so the
flower/grass builders and floorplan no longer pull ez-tree, and drop the
generateTree re-export from the package barrel (no external consumers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 13:45:20 -04:00

783 lines
29 KiB
TypeScript

import {
type AnyNode,
bakePolicyOf,
type DoorNode,
emitter,
getLevelDisplayName,
isOperationDoorType,
itemClipRegistry,
type LevelNode,
sceneRegistry,
type WindowNode,
type ZoneNode,
} from '@pascal-app/core'
import {
poseDoorMovingParts,
poseWindowMovingParts,
SCENE_LAYER,
snapLevelsToTruePositions,
} from '@pascal-app/viewer'
import type { Object3D } from 'three'
import * as THREE from 'three'
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
/**
* Two TRS samples (closed vs open) differing by less than this are treated as
* stationary, so only genuinely moving parts get an animation track.
*/
const POSE_EPSILON = 1e-5
/**
* Marker stamped on a door's swing-leaf group by the door system. `axis` is the
* hinge axis and `openRotationY` is the fully-open angle (radians). The export
* reads it to bake an open clip from a single closed pose; see `door-system`.
*/
type SwingLeafMarker = { axis: 'y'; openRotationY: number }
export type GlbExport = {
scene: THREE.Object3D
animations: THREE.AnimationClip[]
}
export async function exportSceneToGlb(
sceneGroup: Object3D,
nodes: Record<string, AnyNode>,
): Promise<ArrayBuffer> {
emitter.emit('thumbnail:before-capture', undefined)
// Snap levels to their true stacked positions (like thumbnail capture) so the
// export always reflects the clean stacked building, regardless of the live
// levelMode (exploded/solo) or an unsettled level lerp that could otherwise
// bake a level at a stray offset.
const restoreLevels = snapLevelsToTruePositions()
let prepared: ReturnType<typeof prepareSceneForExport>
try {
prepared = prepareSceneForExport(sceneGroup, nodes)
} finally {
restoreLevels()
emitter.emit('thumbnail:after-capture', undefined)
}
const { scene: exportScene, animations } = prepared
const exporter = new GLTFExporter()
// Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read
// those directly. WebGPUTextureUtils blits each one to RGBA on its own
// offscreen renderer (passing the live renderer would resize/draw over the
// editor canvas), letting the exporter embed standard textures.
exporter.setTextureUtils(WebGPUTextureUtils)
return new Promise<ArrayBuffer>((resolve, reject) => {
exporter.parse(
exportScene,
(gltf) => {
resolve(gltf as ArrayBuffer)
},
(error) => {
reject(error)
},
{ binary: true, animations },
)
})
}
/**
* Build an engine-agnostic export tree from the live scene graph. The result is
* a standalone three.js scene plus glTF animation clips, ready for
* `GLTFExporter` — it carries no Pascal runtime dependency.
*
* - Clones the source so live objects are never mutated.
* - Converts WebGPU NodeMaterials to classic glTF-standard materials.
* `GLTFExporter` only recognises `isMeshStandardMaterial` /
* `isMeshBasicMaterial`; the viewer's `MeshStandard/LambertNodeMaterial` set
* `isNodeMaterial` instead, so without this every surface exports as a blank
* default material.
* - Bakes each openable door/window's open motion into a glTF animation clip
* via the build-once + pose-at-t primitives (`pascalSwingLeaf` for doors,
* `poseWindowMovingParts` for windows).
* - Stamps `name` + `extras` identity from `sceneRegistry` so selection/hover
* survive the bake with no in-memory registry, and strips all other userData
* so editor/runtime ephemera never leak into glTF extras.
*/
export function prepareSceneForExport(
source: THREE.Object3D,
nodes: Record<string, AnyNode>,
): GlbExport {
const scene = source.clone(true)
const cloneByOriginal = pairClones(source, scene)
// Kinds with `def.bake === 'strip'` (scans/LiDAR, guides/floorplan) are heavy
// reference assets stored elsewhere and aren't part of the compiled building.
// Drop them from the artifact entirely — `/viewer` re-adds them from the scene
// graph, gated by the project's public-visibility flags, so they never bloat
// the shared GLB nor slip past those flags into a static public file.
// (`'replace'` kinds are *kept*: static for portability, the viewer swaps them
// for a live render.)
for (const [id, original] of sceneRegistry.nodes) {
const node = nodes[id]
if (node && bakePolicyOf(node.type) === 'strip') {
cloneByOriginal.get(original)?.removeFromParent()
}
}
// Object3Ds that carry node identity — never strip these even when they sit on
// a non-scene layer. Some are metadata-only: a zone's visible fill/wall meshes
// are stripped, but its identity node stays to carry the polygon that /viewer
// reconstructs the room from.
const identityNodes = new Set<THREE.Object3D>()
for (const original of sceneRegistry.nodes.values()) {
const clone = cloneByOriginal.get(original)
if (clone) identityNodes.add(clone)
}
pruneNonRenderableMeshes(scene, identityNodes)
convertMaterials(scene)
const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes)
stampIdentity(scene, cloneByOriginal, nodes, clipNamesByNode)
return { scene, animations: clips }
}
/**
* Pair each original Object3D with its clone. `clone(true)` builds children in
* source order, so parallel pre-order traversals line up 1:1 — this is how we
* map `sceneRegistry`'s live refs onto the export tree without mutating either.
*/
function pairClones(
source: THREE.Object3D,
clone: THREE.Object3D,
): Map<THREE.Object3D, THREE.Object3D> {
const originals: THREE.Object3D[] = []
const clones: THREE.Object3D[] = []
source.traverse((object) => originals.push(object))
clone.traverse((object) => clones.push(object))
const map = new Map<THREE.Object3D, THREE.Object3D>()
for (let i = 0; i < originals.length; i++) {
const target = clones[i]
if (target) map.set(originals[i]!, target)
}
return map
}
// A single empty geometry shared by every container mesh we neutralise below —
// it has no attributes, so GLTFExporter's processMesh returns null and emits a
// plain transform node instead of a primitive.
const EMPTY_GEOMETRY = new THREE.BufferGeometry()
// Hidden placeholder for a neutralised renderable that has no material: a valid
// material keeps GLTFExporter from crashing on `material.isShaderMaterial`, while
// EMPTY_GEOMETRY makes it emit a transform node instead of a primitive.
const PLACEHOLDER_MATERIAL = new THREE.MeshBasicMaterial({ visible: false })
/**
* Strip everything that must not bake into the model:
* - Editor overlays on non-scene layers (gizmos, selection handles, ground
* grid, zone fills). The editor camera shows them via extra layers; a
* thumbnail/bake is layer 0 only. Scene-layer affordances that can't be
* layer-filtered (ceiling/site brackets) are hidden by the caller's
* `thumbnail:before-capture` emit before the clone instead.
* - Selection hitboxes, whose invisibility lives on `material.visible = false`
* (which GLTFExporter's `onlyVisible` does not catch). A door/window's hitbox
* root is a box spanning the wall opening — left in, it plugs the cutout.
* With children (it parents the visible frame + leaf) it keeps its node but
* loses its geometry; childless ones are removed outright.
*/
function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE.Object3D>) {
const toRemove: THREE.Object3D[] = []
root.traverse((object) => {
// Editor-only overlays (gizmos, selection handles, ground grid, zone fills)
// live off the scene layer; the editor camera shows them via extra layers
// but a thumbnail/bake only wants layer 0. Drop the whole overlay subtree —
// except identity nodes, which we keep (their off-layer mesh children are
// still pruned as the traversal continues).
if (!object.layers.isEnabled(SCENE_LAYER)) {
if (identityNodes.has(object)) return
toRemove.push(object)
return
}
// A renderable (Mesh / Line / Points) with no material can't produce valid
// glTF and crashes GLTFExporter, which reads `material.isShaderMaterial`
// unconditionally — e.g. an imported sub-model that left a mesh material-less.
// Non-Mesh renderables also slip past the `isMesh` checks below and the
// material conversion. Neutralise it: keep the node (so children survive) but
// strip its geometry + give it the hidden placeholder, or drop it if a leaf.
const renderable = object as THREE.Mesh & { isLine?: boolean; isPoints?: boolean }
if (
(renderable.isMesh === true || renderable.isLine === true || renderable.isPoints === true) &&
renderable.material == null
) {
if (object.children.length > 0) {
renderable.geometry = EMPTY_GEOMETRY
renderable.material = PLACEHOLDER_MATERIAL
} else {
toRemove.push(object)
}
return
}
const mesh = object as THREE.Mesh
if (!mesh.isMesh || isRenderableMesh(mesh)) return
if (mesh.children.length > 0) {
mesh.geometry = EMPTY_GEOMETRY
} else {
toRemove.push(mesh)
}
})
for (const object of toRemove) {
object.removeFromParent()
}
}
function isRenderableMesh(mesh: THREE.Mesh): boolean {
const position = mesh.geometry?.getAttribute('position')
if (!position || position.count === 0) return false
const material = mesh.material
return Array.isArray(material)
? material.some((m) => m?.visible !== false)
: material?.visible !== false
}
// --- Material conversion -------------------------------------------------
const STANDARD_MAP_SLOTS = [
'map',
'normalMap',
'roughnessMap',
'metalnessMap',
'aoMap',
'emissiveMap',
'alphaMap',
'lightMap',
'displacementMap',
'bumpMap',
] as const
function convertMaterials(root: THREE.Object3D) {
const cache = new Map<THREE.Material, THREE.Material>()
root.traverse((object) => {
const mesh = object as THREE.Mesh
if (!mesh.isMesh) return
const material = mesh.material
if (Array.isArray(material)) {
mesh.material = material.map((m) => convertMaterial(m, cache))
return
}
// glTF has no BackSide — GLTFExporter renders the *front* face for any
// non-DoubleSide material, which inverts a BackSide surface (e.g. the
// ceiling underside, meant to be seen from the room). Flip the mesh winding
// so the intended face shows with the FrontSide material convertMaterial
// produces. Per-mesh geometry clone keeps shared geometry untouched.
if (
(material as { isNodeMaterial?: boolean }).isNodeMaterial &&
material.side === THREE.BackSide
) {
mesh.geometry = flipGeometryWinding(mesh.geometry)
}
mesh.material = convertMaterial(material, cache)
})
}
/**
* Reverse triangle winding and negate normals so a surface authored for
* `BackSide` reads correctly once exported as `FrontSide` (glTF can't express
* back-face-only rendering).
*/
function flipGeometryWinding(geometry: THREE.BufferGeometry): THREE.BufferGeometry {
const flipped = geometry.clone()
const index = flipped.getIndex()
if (index) {
const a = index.array
for (let i = 0; i < a.length; i += 3) {
const tmp = a[i]!
a[i] = a[i + 2]!
a[i + 2] = tmp
}
index.needsUpdate = true
} else {
for (const attribute of Object.values(flipped.attributes)) {
const { array, itemSize } = attribute
for (let i = 0; i < array.length; i += itemSize * 3) {
for (let k = 0; k < itemSize; k++) {
const tmp = array[i + k]!
array[i + k] = array[i + 2 * itemSize + k]!
array[i + 2 * itemSize + k] = tmp
}
}
attribute.needsUpdate = true
}
}
const normal = flipped.getAttribute('normal')
if (normal) {
for (let i = 0; i < normal.array.length; i++) normal.array[i] = -normal.array[i]!
normal.needsUpdate = true
}
return flipped
}
/**
* Convert a viewer NodeMaterial into the classic `MeshStandardMaterial` the
* glTF exporter understands. Classic materials pass through untouched, and the
* cache preserves material sharing (one source instance -> one target), so the
* exporter still dedups shared surfaces.
*/
function convertMaterial(
material: THREE.Material,
cache: Map<THREE.Material, THREE.Material>,
): THREE.Material {
if ((material as { isNodeMaterial?: boolean }).isNodeMaterial !== true) return material
const cached = cache.get(material)
if (cached) return cached
const src = material as THREE.Material & Record<string, unknown>
const target = new THREE.MeshStandardMaterial()
target.name = material.name
if (src.color instanceof THREE.Color) target.color.copy(src.color)
if (src.emissive instanceof THREE.Color) target.emissive.copy(src.emissive)
if (typeof src.emissiveIntensity === 'number') target.emissiveIntensity = src.emissiveIntensity
// Lambert (solid-shading / glass) node materials carry no PBR scalars; a fully
// rough, non-metallic surface is the faithful lit fallback.
target.roughness = typeof src.roughness === 'number' ? src.roughness : 1
target.metalness = typeof src.metalness === 'number' ? src.metalness : 0
// Only genuinely see-through surfaces stay transparent. Several viewer
// materials set `transparent: true` while fully opaque (opacity 1); exporting
// those as alphaMode=BLEND makes them render see-through with no depth write
// (e.g. the ceiling looked semi-transparent). Glass (opacity < 1) is kept.
target.transparent = material.transparent && material.opacity < 1
target.opacity = material.opacity
// BackSide is flipped to FrontSide (with the mesh winding reversed in
// convertMaterials) because glTF has no back-face-only mode.
target.side = material.side === THREE.BackSide ? THREE.FrontSide : material.side
target.alphaTest = material.alphaTest
target.depthWrite = material.depthWrite
target.depthTest = material.depthTest
target.vertexColors = material.vertexColors
target.toneMapped = material.toneMapped
if (src.normalScale instanceof THREE.Vector2) target.normalScale.copy(src.normalScale)
if (typeof src.aoMapIntensity === 'number') target.aoMapIntensity = src.aoMapIntensity
if (typeof src.displacementScale === 'number') target.displacementScale = src.displacementScale
for (const slot of STANDARD_MAP_SLOTS) {
const texture = src[slot]
if (texture instanceof THREE.Texture) {
;(target as unknown as Record<string, THREE.Texture>)[slot] = texture
}
}
cache.set(material, target)
return target
}
// --- Animation clip baking ----------------------------------------------
function bakeAnimationClips(
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
nodes: Record<string, AnyNode>,
): { clips: THREE.AnimationClip[]; clipNamesByNode: Map<string, string[]> } {
const clips: THREE.AnimationClip[] = []
const clipNamesByNode = new Map<string, string[]>()
for (const [id, original] of sceneRegistry.nodes) {
const node = nodes[id]
const target = cloneByOriginal.get(original)
if (!node || !target) continue
const clip =
node.type === 'door'
? bakeDoorClip(id, node, target)
: node.type === 'window'
? bakeWindowClip(id, node as WindowNode, target)
: node.type === 'item'
? bakeItemClip(id, target)
: null
if (clip) {
clips.push(clip)
clipNamesByNode.set(id, [clip.name])
}
}
return { clips, clipNamesByNode }
}
/**
* Re-emit a catalog item's ambient clip (e.g. a fan's spin) onto the baked
* subtree. The source clip targets the item GLB's nodes by name (`lamp_018`);
* since every fan shares those names, we rebind each track to the specific
* cloned node's uuid so multiple fans animate independently. The clip is named
* per node (`<id>: loop`) so the baked viewer can drive each one on its own.
*/
function bakeItemClip(id: string, itemObject: THREE.Object3D): THREE.AnimationClip | null {
const entry = itemClipRegistry.get(id)
if (!entry) return null
const tracks: THREE.KeyframeTrack[] = []
// The catalog node names (e.g. "lamp_018") repeat across every instance of the
// item, and the glTF export→import roundtrip rebinds clip tracks by node name —
// so a shared name would make all fans share one clip. Uniquify the targeted
// node's name per item once, then bind tracks by its (stable) uuid.
const renamed = new Map<string, THREE.Object3D>()
for (const track of entry.clip.tracks) {
const dot = track.name.lastIndexOf('.')
if (dot < 0) continue
const targetName = track.name.slice(0, dot)
const property = track.name.slice(dot + 1)
let targetNode = renamed.get(targetName)
if (!targetNode) {
const found = itemObject.getObjectByName(targetName)
if (!found) continue
found.name = `${id}__${targetName}`
renamed.set(targetName, found)
targetNode = found
}
const retargeted = track.clone()
retargeted.name = `${targetNode.uuid}.${property}`
tracks.push(retargeted)
}
if (tracks.length === 0) return null
const clip = new THREE.AnimationClip(`${id}: loop`, entry.clip.duration, tracks)
clip.userData = { loop: entry.loop }
return clip
}
/**
* Bake a door's open motion. Swing doors (hinged/double/french) carry a
* `pascalSwingLeaf` marker and bake a single quaternion track per leaf;
* operation doors (sliding/pocket/barn/folding/garage-*) build their moving
* parts in named groups posed by `poseDoorMovingParts`, sampled here into
* keyframes (their motion is non-linear, e.g. the sectional's overhead curve).
*/
function bakeDoorClip(
id: string,
node: AnyNode,
doorObject: THREE.Object3D,
): THREE.AnimationClip | null {
if (node.type === 'door' && isOperationDoorType((node as DoorNode).doorType)) {
return bakeOperationDoorClip(id, node as DoorNode, doorObject)
}
return bakeSwingDoorClip(id, node, doorObject)
}
/** Number of keyframes sampled across an operation door's 0→1 open motion. */
const OPERATION_DOOR_SAMPLES = 16
/**
* Sample an operation door's open motion into keyframe tracks by posing the
* export clone with `poseDoorMovingParts` at evenly-spaced fractions. Only the
* named moving groups change (their children are rigid), so a track is emitted
* per group whose position / rotation / scale actually moves. The clone is left
* posed closed so the GLB's rest state is shut.
*/
function bakeOperationDoorClip(
id: string,
node: DoorNode,
doorObject: THREE.Object3D,
): THREE.AnimationClip | null {
if (!poseDoorMovingParts(node, doorObject, 0)) return null
const objects: THREE.Object3D[] = []
doorObject.traverse((object) => objects.push(object))
const basePoses = objects.map((object) => ({
position: object.position.clone(),
quaternion: object.quaternion.clone(),
scale: object.scale.clone(),
}))
const times: number[] = []
const positionSamples = objects.map(() => [] as number[])
const quaternionSamples = objects.map(() => [] as number[])
const scaleSamples = objects.map(() => [] as number[])
for (let step = 0; step <= OPERATION_DOOR_SAMPLES; step++) {
const t = step / OPERATION_DOOR_SAMPLES
times.push(t)
poseDoorMovingParts(node, doorObject, t)
for (let i = 0; i < objects.length; i++) {
const object = objects[i]!
positionSamples[i]!.push(...object.position.toArray())
quaternionSamples[i]!.push(...object.quaternion.toArray())
scaleSamples[i]!.push(...object.scale.toArray())
}
}
const tracks: THREE.KeyframeTrack[] = []
for (let i = 0; i < objects.length; i++) {
const object = objects[i]!
const base = basePoses[i]!
if (samplesMovePosition(positionSamples[i]!, base.position)) {
tracks.push(
new THREE.VectorKeyframeTrack(`${object.uuid}.position`, times, positionSamples[i]!),
)
}
if (samplesMoveQuaternion(quaternionSamples[i]!, base.quaternion)) {
tracks.push(
new THREE.QuaternionKeyframeTrack(
`${object.uuid}.quaternion`,
times,
quaternionSamples[i]!,
),
)
}
if (samplesMoveScale(scaleSamples[i]!, base.scale)) {
tracks.push(new THREE.VectorKeyframeTrack(`${object.uuid}.scale`, times, scaleSamples[i]!))
}
}
poseDoorMovingParts(node, doorObject, 0)
if (tracks.length === 0) return null
return openClip(id, tracks)
}
function samplesMovePosition(flat: number[], base: THREE.Vector3): boolean {
const point = new THREE.Vector3()
for (let i = 0; i < flat.length; i += 3) {
point.set(flat[i]!, flat[i + 1]!, flat[i + 2]!)
if (point.distanceToSquared(base) > POSE_EPSILON) return true
}
return false
}
function samplesMoveQuaternion(flat: number[], base: THREE.Quaternion): boolean {
const quaternion = new THREE.Quaternion()
for (let i = 0; i < flat.length; i += 4) {
quaternion.set(flat[i]!, flat[i + 1]!, flat[i + 2]!, flat[i + 3]!)
if (base.angleTo(quaternion) > POSE_EPSILON) return true
}
return false
}
function samplesMoveScale(flat: number[], base: THREE.Vector3): boolean {
const point = new THREE.Vector3()
for (let i = 0; i < flat.length; i += 3) {
point.set(flat[i]!, flat[i + 1]!, flat[i + 2]!)
if (point.distanceToSquared(base) > POSE_EPSILON) return true
}
return false
}
/**
* Bake a swing door's open motion. Each marked leaf is rotated from closed
* (rest pose) to its fully-open angle and emitted as a 1-second quaternion
* track; the leaf is left at the closed pose so the GLB's rest state is shut.
*/
function bakeSwingDoorClip(
id: string,
node: AnyNode,
doorObject: THREE.Object3D,
): THREE.AnimationClip | null {
const tracks: THREE.KeyframeTrack[] = []
doorObject.traverse((object) => {
const marker = object.userData.pascalSwingLeaf as SwingLeafMarker | undefined
if (marker?.axis !== 'y') return
object.rotation.y = 0
const closed = object.quaternion.clone()
object.rotation.y = marker.openRotationY
const open = object.quaternion.clone()
object.rotation.y = 0
tracks.push(
new THREE.QuaternionKeyframeTrack(
`${object.uuid}.quaternion`,
[0, 1],
[...closed.toArray(), ...open.toArray()],
),
)
})
if (tracks.length === 0) return null
return openClip(id, tracks)
}
/**
* Wrap an open motion in a named 1-second clip. The name is keyed by the node id
* (`<id>: open`), NOT the node's display name: clip names must be unique because
* the baked viewer drives playback by clip name (`useAnimations` maps name →
* action), so two same-named openables (e.g. several "Window 1"s) would collapse
* to a single action and a trigger on one would animate another. The
* human-readable name lives in `extras.label` instead. glTF has no core loop
* flag — the player decides — so we stamp `extras.loop = false` (via the clip's
* userData, which `GLTFExporter` serialises onto the animation): Pascal's
* `/viewer` and any extras-aware consumer play it once and hold the open pose; a
* dumb glTF player still loops. Consumers map a clip back to its node by walking
* up from a channel's target to the nearest ancestor carrying `extras.pascalId`.
*/
function openClip(id: string, tracks: THREE.KeyframeTrack[]): THREE.AnimationClip {
const clip = new THREE.AnimationClip(`${id}: open`, 1, tracks)
clip.userData = { loop: false }
return clip
}
/**
* Bake a window's open motion generically: snapshot every part's pose closed,
* pose the subtree open, and emit a track for whichever parts actually moved
* (translation for sliding/hung sashes, rotation for casement/awning/louvre).
* Reusing the live `poseWindowMovingParts` keeps one source of truth for window
* kinematics. The subtree is left posed closed as the GLB's rest state.
*/
function bakeWindowClip(
id: string,
node: WindowNode,
windowObject: THREE.Object3D,
): THREE.AnimationClip | null {
poseWindowMovingParts(node, windowObject, 0)
const closedPoses = new Map<
THREE.Object3D,
{ position: THREE.Vector3; quaternion: THREE.Quaternion }
>()
windowObject.traverse((object) => {
closedPoses.set(object, {
position: object.position.clone(),
quaternion: object.quaternion.clone(),
})
})
if (!poseWindowMovingParts(node, windowObject, 1)) return null
const tracks: THREE.KeyframeTrack[] = []
windowObject.traverse((object) => {
const closed = closedPoses.get(object)
if (!closed) return
if (object.position.distanceToSquared(closed.position) > POSE_EPSILON) {
tracks.push(
new THREE.VectorKeyframeTrack(
`${object.uuid}.position`,
[0, 1],
[...closed.position.toArray(), ...object.position.toArray()],
),
)
}
if (closed.quaternion.angleTo(object.quaternion) > POSE_EPSILON) {
tracks.push(
new THREE.QuaternionKeyframeTrack(
`${object.uuid}.quaternion`,
[0, 1],
[...closed.quaternion.toArray(), ...object.quaternion.toArray()],
),
)
}
})
poseWindowMovingParts(node, windowObject, 0)
if (tracks.length === 0) return null
return openClip(id, tracks)
}
// --- Identity stamping ---------------------------------------------------
/**
* Replace every clone's userData with `{}`, then stamp identity onto the nodes
* that `sceneRegistry` tracks. Wiping first guarantees no editor/runtime marker
* (e.g. `pascalSwingLeaf`, cached-material flags) leaks into glTF extras — the
* file describes itself with exactly the fields a consumer needs.
*/
/**
* Human-readable label for a baked node, mirroring the viewer's `getNodeName`:
* an explicit name wins, items fall back to their catalog asset name, other
* kinds to a capitalized type. Levels override this with their display name.
*/
function nodeDisplayLabel(node: AnyNode): string {
if (node.name) return node.name
switch (node.type) {
case 'item':
return (node as { asset?: { name?: string } }).asset?.name || 'Item'
case 'wall':
return 'Wall'
case 'door':
return 'Door'
case 'window':
return 'Window'
case 'slab':
return 'Slab'
case 'ceiling':
return 'Ceiling'
case 'roof':
return 'Roof'
case 'fence':
return 'Fence'
case 'column':
return 'Column'
case 'stair':
return 'Stairs'
default:
return node.type
}
}
function stampIdentity(
scene: THREE.Object3D,
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
nodes: Record<string, AnyNode>,
clipNamesByNode: Map<string, string[]>,
) {
scene.traverse((object) => {
object.userData = {}
})
for (const [id, original] of sceneRegistry.nodes) {
const node = nodes[id]
const target = cloneByOriginal.get(original)
if (!node || !target) continue
target.name = id
const extras: Record<string, unknown> = { pascalId: id, kind: node.type }
// Stamp a human label for every node (catalog name for items, a type label
// otherwise) so the viewer breadcrumb/hover read names, not raw pascalIds.
extras.label = nodeDisplayLabel(node)
// Camera bookmarks ride on the identity node (any kind can carry one) so the
// baked viewer flies to a saved pose on selection without a side file.
if (node.camera) extras.camera = node.camera
// Levels carry no stored name; stamp the editor's display name ("Level 1")
// so the baked viewer's level/breadcrumb UI reads the same labels. Force the
// node visible: the bake must capture every floor regardless of the editor's
// current level mode (solo/hidden floors would otherwise be dropped by
// GLTFExporter's `onlyVisible`).
if (node.type === 'level') {
extras.label = getLevelDisplayName(node as LevelNode)
target.visible = true
}
// Only doors/windows that actually baked an open clip are openable. A cased
// opening (no leaf) or a fixed window (no operable sash) produces no clip, so
// it stays unflagged — the file never claims a part opens when nothing moves.
if (node.type === 'door' || node.type === 'window') {
const clipNames = clipNamesByNode.get(id)
if (clipNames?.length) {
extras.openable = true
extras.clips = clipNames
}
}
// Items with a baked ambient clip (a fan's spin) carry the clip name but no
// `openable` flag — nothing opens; the clip just loops.
if (node.type === 'item') {
const clipNames = clipNamesByNode.get(id)
if (clipNames?.length) extras.clips = clipNames
}
if (node.type === 'zone') {
// Zone fills are stripped from the bake; /viewer rebuilds the room from
// this polygon. Force the identity node visible so GLTFExporter's
// `onlyVisible` keeps it even when the editor had zones hidden at export.
const zone = node as ZoneNode
extras.polygon = zone.polygon
extras.color = zone.color
target.visible = true
}
if (node.type === 'spawn') {
// The spawn marker's visible mesh lives on a non-scene overlay layer (and
// is pruned), so this identity node is an empty transform. Keep it + force
// visible so the baked walkthrough can read its world position/yaw and
// start the player there (`extras.rotation` mirrors the node's yaw).
extras.rotation = (node as { rotation?: number }).rotation ?? 0
target.visible = true
}
target.userData = extras
}
}