Commit Graph
100 Commits
Author SHA1 Message Date
51fddc2d9c fix: capture-mode polish for studio & presets + pausable render loop (#469)
* fix(editor): keep snapshot pitch out of preset thumbnail capture

The capture overlay's snapshot hint and 'Take snapshot' shutter label
rendered in every capture variant, clashing with the save-as-preset
flow's own 'Frame your item — click Capture' banner. Gate them on the
existing isPreset discriminator and label the shutter 'Capture' there.

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

* feat(viewer): renderPaused flag suspends the frame loop

Hosts that fully cover the canvas (e.g. the studio gallery overlay) can
set renderPaused to stop FrameLimiter's RAF loop — a heavy animated
scene otherwise keeps starving the GPU behind the overlay and makes it
stutter.

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

* fix(editor): drop the crop/format HUD in preset capture

The pills crowded the save-as-preset banner and repeat what the fixed
square frame already shows.

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

* fix(editor): no contextual shortcut hints in the studio workspace

Holding Shift surfaced select-mode/snapping hints over the studio
compose panel and gallery, where there is no scene selection or tool to
act on.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:10:53 -04:00
f6a58489b8 fix(viewer): survive block-misaligned KTX2 textures on WebGPU (#467)
WebGPU rejects block-compressed textures whose base dimensions aren't
multiples of 4; three's KTX2Loader only warns and transcodes anyway, so a
baked GLB carrying one odd-sized user-item texture (e.g. 299x399) poisoned
every render pass — endless 'Invalid BindGroup … Invalid CommandBuffer'
spam. Route such payloads through a fallback transcode to uncompressed
RGBA32 and repackage the pixels as a plain DataTexture (three's WebGPU
upload path crashes on a CompressedTexture holding rgba8 data).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:39:52 -04:00
f6ffb93a39 fix(export): unbreak bakes and STL/OBJ exports on malformed/placeholder meshes (#466)
- roof-system: replace BoxGeometry placeholders (initial swap + empty-roof
  merged shell) with a group-less degenerate geometry — a Box's 6 groups
  against the roof's 4-material array crashed GLTFExporter on every scene
  with a segment-less roof node (prod: 'reading isShaderMaterial'), and
  count-0 groups crash MeshBVH's packed-tree build.
- glb-export: sanitizeMaterialGroups pass repairs any mesh whose geometry
  groups don't line up with its material array before GLTFExporter runs.
- export-manager: give neutralised (attribute-less) meshes an empty position
  attribute before STL/OBJ export — both exporters read position.count
  unconditionally.
- bake-exporter: log the full error stack so the bake worker's console relay
  captures it in the job's error trail.
- viewer: host-controlled disablePostFx prop (Viewer → PostProcessing) that
  skips building the SSGI/denoise pipeline entirely; the ?disable=postFx URL
  flag previously only bypassed it per-frame while still allocating it.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:07:08 -04:00
9b5e229770 feat(editor): stage overlay slot + pro capture overlay for studio (#465)
* feat(editor): stage overlay slot, preselectable snapshot crop, pro capture overlay

- Editor/EditorLayoutV2 gain a stageOverlay slot rendered over the canvas
  but under the viewer toolbar (z-10 < z-20), so hosts can swap in a
  full-stage surface (e.g. studio gallery) without unmounting the WebGL
  canvas; FloatingLevelSelector hides while a stage overlay is active
- CaptureMode 'standard' accepts an optional crop preselection
  (SnapshotCropMode, exported) so hosts can enter capture with
  standard/viewport/area already chosen
- SnapshotCaptureOverlay restyle: letterboxed 16:9 frame with corner
  accents + rule-of-thirds for standard mode, thirds grid in viewport
  and area modes, top-center crop/format HUD chips, shutter button with
  capture/saved states — all existing crop, drag, resize and preset
  behaviors preserved

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

* feat(editor): standard-capture aspect presets, capture scrim, caption fix

- Standard crop gains aspect presets (16:9, 9:16, 4:3, 3:4, 1:1) — clicking
  the active Standard chip opens the picker; the letterbox frame, HUD and
  output resolution follow the choice
- ThumbnailGenerateEvent carries standardSize; ThumbnailGenerator
  center-crops to it (default stays 1920×1080)
- Subtle bottom scrim keeps shutter/caption readable on bright scenes
- Fix missing space in the snapshot caption

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

* feat(editor): standard-capture aspect preselect via CaptureMode

Hosts (the studio capbar) can now pass standardAspect alongside crop when
entering capture mode; the overlay initializes its aspect picker from it.
SnapshotStandardAspect exported.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:09:56 -04:00
22af31dea8 editor: snapping overhaul — shared-wall rooms, deterministic slab elevation, cursor-true transfers (#458)
* fix(snapping): show alignment lines in all modes, snap only in lines; north export; R/T hint; grid default

- Alignment guides display in every snapping mode (grid/lines/angles/off);
  magnetic pull applies only in 'lines'. New isAlignmentGuideActive() predicate
  decouples guide display from the snap delta across all placement/move/draw
  producers (item, wall, fence, slab/ceiling/roof, column/shelf/spawn, door/
  window, MEP, 2D floorplan drafting, surface snap).
- Floorplan PDF export now rotates to north-up (FLOORPLAN_VIEW_ROTATION_DEG -
  building rotation), matching the on-screen aligned-to-north view.
- Item placement rotate hint collapsed to a single 'R / T  Rotate' row.
- Default item snapping mode changed lines -> grid.

* feat(walls): detect rooms across shared walls + tight connect-snap in all modes

Room detection: planarize the wall graph before face-finding — split straight
walls at T-junctions where another wall ends mid-span, so a room closed against
the middle of an existing wall is detected, not just isolated 4-wall rooms.

Auto-close: wire the wall builder's "Room (auto-close)" to the same room graph
via wallClosesRoom(), so drafting stops when a segment seals a room against the
existing structure — not only when the chain returns to its own start.

Connectivity snap: add a tight wall-connect snap (WALL_CONNECT_SNAP_RADIUS 0.05)
that also runs in grid/off/angles — within range of a wall (body or corner,
uniform radius) the endpoint sticks onto it and the beacon shows, so rooms close
in every mode. Lines keeps its wider magnetic radii. Gate alignment guides in
non-magnetic modes to the same connect distance so a corner dot no longer
magnetises the cursor from far.

Tests: cover T-junction detection + wallClosesRoom; update the item-default and
wall-split tests for the mode-driven behaviour.

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

* feat(surfaces): gate alignment guides to connect distance in non-magnetic modes

Port the wall drafting/endpoint gating to the shared slab/ceiling/roof snap
(resolveSurfacePlanPointSnap): in grid/off/angles, only anchors within the
connect distance are fed to the alignment resolver, so guides form to nearby
points and far corner dots stop lighting up from across the plan. Filtering the
candidates (local-frame, like the cursor) rather than the resolved guides avoids
the floor-plan view rotation baked into the guide coords. Lines mode keeps the
full-range guides; the surface wall connect-snap is unaffected.

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

* fix(slabs): deterministic wall-slab overlap via clipped overlap length

Wall elevation used ray-cast point samples that landed exactly on the
slab boundary for perpendicular walls, so whether a butting wall
followed the slab's elevation depended on which side of the slab it
touched. Clip the wall centerline and face lines against the polygon
and require >=5cm of on/inside length instead: walls along the slab
edge follow it on every side, point contact never does.

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

* fix(grid): lift lattice 1mm off slab tops; wall-align on wall-item move start

The horizontal snap lattice sat exactly at the followed surface Y, so it
z-fought elevated slab tops while moving items. The visual mesh now rides
1mm above; the grid event plane keeps the true height.

Moving a wall-hosted item showed a horizontal grid until the first
pointer move published a wall surface — the mesh fallback assumed UP.
Derive the host wall normal from the item mesh's world orientation
(local +Z faces out of the wall) so the lattice is wall-aligned from
the first frame.

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

* feat(editor): unify selection shortcuts + truthful select-mode hints

Shift+click now toggles selection membership in 3D like Cmd/Ctrl (and
appends on box-select release), matching the 2D floorplan. The HUD rows
describe what actually works in both views: move is plain left-drag (the
grip/dot), the two vague selection rows collapse into one Cmd/Shift
or-group, and the modifier-held variants (freely / with guides / bypass
snaps) are gone — guides follow the snapping mode now. Key pills join
with + for combos and / for alternatives; Shift renders as the shift
icon.

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

* fix(editor): group gizmos — reliable rotate grabs, mutual hide, snapping-mode integration

The group-rotate gizmo never received pointer events through the shared
invisible hit-area path (EDITOR_LAYER + custom raycast) in its portalled
context; its handlers now live on the visible arrow plus a plain
default-layer invisible torus, so hover and drag work with a fat target.

Group drags begin a handle-drag scope: each gizmo hides while its
sibling drags (the frozen corner goes stale), idle hints leave the HUD,
and the drag gets contextual hints — Shift free-rotation for rotate, the
snapping chips for move.

Group move joins the snapping-mode system via the 'item' context: Shift
cycles the mode, Ctrl the grid step (both read live mid-drag), and
'lines' runs the same Figma-style alignment as single-node moves against
the group's bbox anchors. Chip clicks now tick like the keyboard cycles.

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

* fix(editor): group-move ticks in every snapping mode

Mirror the single-node move's sfx: emit per delta change rather than
only on grid crossings, so lines/off get the same rate-limited texture.

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

* fix(items): keep host transfers glued to the cursor

Moving between hosts used to diverge from the pointer and mangle state:

- Grab offsets: every surface anchor (wall / ceiling / shelf / item
  surface / floor) preserved the grab offset by re-seeding from the
  item's carried-over position on each new host, landing it far from
  the cursor. The grab offset now survives only on the original host
  and only until the item anchors anywhere else — after that every
  host (the original included) centers the item under the cursor.
  Applied uniformly to the placement coordinator and the window/door
  move tools.

- Rotation: detaching from a rotated shelf/table back to the floor kept
  the HOST-local yaw as the level yaw, visibly spinning the item. The
  detach now re-expresses the item's world yaw in the level frame.

- Elevation: the same detach wrote the level parentId to the store but
  not the draft ref, so the floor-elevation resolver bailed on its
  parent-must-be-a-level guard — the snap grid and the item stopped
  following slab elevations for the rest of the drag.

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

* fix(items): latch grab-offset forgetting across every host kind

Review follow-up (PR #458): unify the per-surface grab-forget rule into
one grabForgotten latch. A wall/ceiling item could not actually reach a
shelf or the floor (item-surface enter rejects attachTo assets), but a
wall item CAN anchor on a roof face — and returning to its original
wall then restored the stale grab offset. Roof-wall transitions (and
floor landings after a host visit) now trip the latch in the
coordinator and the window/door move tools alike.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:18:37 -04:00
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
Wassim SAMADandGitHub c51e673421 Merge pull request #451 from pascalorg/feat/slower-viewer-walk-speed
viewer: slow first-person walk/run/jump speeds
2026-06-29 12:33:01 -04:00
Wassim SAMADandClaude Opus 4.8 36132bbcea viewer: slow first-person walk/run/jump speeds
Walk 4→2 m/s, run 5.5→5 m/s, jump 6→5 (~1.27 m peak). Applied to both
the GLB walkthrough controller (viewer) and the editor first-person
controls so the two surfaces stay consistent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:30:39 -04:00
Wassim SAMADandGitHub 08731b5bde Merge pull request #450 from pascalorg/feat/placement-interaction-polish
fix(editor): wall-item placement & floorplan polish
2026-06-29 10:30:45 -04:00
Wassim SAMADandClaude Opus 4.8 ba464ef47e fix(editor): wall-item placement & floorplan polish
Polish pass on the placement/interaction overhaul. Five fixes:

- Item-on-item rotation box: the cursor box held the host-local yaw instead of
  world yaw, so it diverged from the item by the host's rotation when stacked on
  another item (fine on the floor). The box now derives world yaw from the
  mesh/host quaternion in both the R/T handler and the move-start sync.

- 2D floorplan move now respects the snapping mode (parity with 3D): grid
  quantization only in grid mode, alignment guides only in lines/magnetic mode;
  Shift/Alt no longer hard-bypass. Item move also plays the move "tick" SFX on
  any resolved-position change, like the 3D move.

- Wall-side item footprint side: the 2D footprint depth offset extended toward the
  wall (centerLocalZ -depth/2) instead of into the room, mirroring the item across
  the wall; flipped to +depth/2. Aligned the undefined-side anchor to the 3D
  convention (front +1 / else -1).

- 3D placement preview side: the wall-side preview bounds + base plane used a -Z
  (into-wall) convention; flipped to +Z (into-room) to match the body and the
  fixed 2D footprint. The 2D live preview during a 3D wall placement now publishes
  the plan rotation (wall angle + item yaw) instead of the world cursor yaw, which
  was pi off on a wall face and flipped the footprint to the far side.

- Cmd/Ctrl+R no longer rotates/flips the selected node (it reaches the browser
  reload); guard added to the global selected-node handler and the 2D move overlay.

Verified: core/editor/nodes tsc, biome, editor 162/0 + nodes 292/0 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:27:27 -04:00
Wassim SAMADandGitHub 9084396a82 Merge pull request #449 from pascalorg/feat/floorplan-export
feat(editor): floorplan export (multi-page PDF, per level)
2026-06-29 08:53:22 -04:00
Wassim SAMADandClaude Opus 4.8 b87b1f999d feat(editor): floorplan export (multi-page PDF, per level)
Add a Floorplan group to the settings Export panel alongside the 3D
model exports, with "Full floorplan" and "Structure only" buttons.

Export re-runs the live registry-driven floorplan pipeline
(def.floorplan -> FloorplanGeometryRenderer) headlessly with a neutral
viewState, fits each level to its own page, and titles each page with
the level label. Every level of the active building becomes a page in
one landscape A4 PDF. "Structure only" keeps category === 'structure'
nodes; "Full" keeps every visible node with a floorplan builder.

- new lib/floorplan/floorplan-export.tsx; jsPDF + svg2pdf dynamically
  imported so they only load on export
- export five pure helpers from floorplan-registry-layer for reuse
  (buildContext, getFloorplanLevelData, floorplanLayerRank,
  splitFloorplanOverlay, isFloorplanNodeVisible) — no behaviour change
- bake vector-effect:non-scaling-stroke widths into real units before
  svg2pdf (which ignores the hint and would otherwise draw door/window/
  stair linework as metre-wide strokes)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 08:49:53 -04:00
Wassim SAMADandGitHub a6023356b9 Merge pull request #444 from pascalorg/fix/bake-door-window-anims
fix(doors): bake open-animation clips for every operation door + un-flipped rest pose
2026-06-28 16:41:44 -04:00
Wassim SAMADandClaude Opus 4.8 b0313a13dc chore(editor): sort imports in glb-export.test.ts after main merge
Fixes the lone biome organizeImports error so the quality gate passes on the
post-#448 base. Type-check (9/9), biome, and all suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:36:35 -04:00
Wassim SAMAD db35af9a05 Merge remote-tracking branch 'origin/main' into fix/bake-door-window-anims 2026-06-28 16:32:41 -04:00
Wassim SAMADandGitHub 2dbe4677f4 Merge pull request #448 from pascalorg/feat/placement-interaction-overhaul
Placement & interaction overhaul: FSM scope spine, per-context snapping, 2D/floorplan perf
2026-06-28 16:24:26 -04:00
Wassim SAMADandClaude Opus 4.8 43fa2b1253 Merge origin/main into feat/placement-interaction-overhaul
Resolve 7 conflicts keeping our snapping migration + floorplan perf work as
source of truth, combined with main's MEP run-continuation / Alt-detach /
latch handles. Rebuilt two import blocks the auto-merge silently truncated
(node-arrow-handles.tsx, duct-fitting/move-tool.tsx).

Verified: tsc clean across core/viewer/editor/nodes/mcp, 451 tests pass,
biome clean. Floorplan view-transform re-render storm confirmed pre-existing
(not introduced by this merge).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:22:47 -04:00
Wassim SAMADandClaude Opus 4.8 15d36c12b1 fix(editor): zone drafting respects the snapping mode + shows its HUD chip
Two zone fixes.

helper-manager: the contextual HUD only rendered for tools with `def.toolHints`,
so zone (none) showed no HUD and no snapping chip even though it resolves a snap
context. Render the generic RegisteredToolHelper whenever the tool has hints OR a
snap/continuation context; hoist the legacy `roof` RoofHelper above it so the new
fallback doesn't capture it. Any snappable hint-less draft tool now advertises
Shift = cycle.

zone-tool: a not-yet-migrated legacy tool — it used Shift as a snap bypass and
applied `gridSnapStep` unconditionally, so Off mode still snapped to grid.
Migrated to the mode-driven exclusive-modes convention (zone resolves to the
'wall' context): grid quantize gated on isGridSnapActive(), 15° ray gated on
isAngleSnapActive(), Off/Lines leave the raw cursor. Dropped the Shift-bypass and
its key listeners — Shift now cycles the mode globally. Recorded as migrated in
the review skill's known-legacy list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:29:50 -04:00
Wassim SAMADandClaude Opus 4.8 a2f1ef1683 docs(architecture): codify live-overrides drag protocol + floorplan per-node perf
Capture the durable patterns from the placement-interaction overhaul so reviews
and new devs don't regress them:

- tools.md: new "Data-driven live drag" section — kinds whose geometry is
  recomputed from fields (wall/opening/endpoint) preview via useLiveNodeOverrides
  (merged by getEffectiveWall/getEffectiveNode), store written once on commit;
  per-tick useScene.updateNodes is the documented anti-pattern (churns the nodes
  ref → app-wide re-render flood). Plus "Floorplan registry: per-node
  subscriptions" — each entry subscribes to its own live slice, memo'd with
  stable props, sibling-epoch invalidation; widening to the whole Map / dropping
  memo is a regression. Plus a note that the HUD snapping chip renders for any
  snap-context tool, not only those with def.toolHints.
- review SKILL.md: matching blockers in §C (data-driven drag / no per-tick store
  write) and §D (per-node list subscriptions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:19:39 -04:00
Wassim SAMADandClaude Opus 4.8 dc468a084b feat(editor): show placement grid for any armed grid-mode tool
The grid only appeared while a ghost was in flight (moving node / placing scope /
armed GLB item), so a merely-armed draft tool (wall / slab / fence / ceiling /
zone / column / MEP) showed no lattice. Gate visibility on isGridSnapActive()
alone — it already derives the snap context from the interaction scope OR the
armed build tool and is true only when that context resolves to grid (false for
select / idle / paint / lines / off), so it is exactly the right condition.
Draft tools publish no surface; the grid's horizontal branch already falls back
to the active level floor, with the reveal patch following the cursor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:05:01 -04:00
Wassim SAMADandClaude Opus 4.8 81c5b2d22e fix(nodes): match item floorplan sprite rotation to its footprint box
The footprint polygon uses `rotateVec` (R(-angle)) but the sprite was drawn via
SVG `rotate(+angle)`, so image and box counter-rotated and diverged by 2x the
item's rotation in the 2D floorplan. Negate the image rotation so the sprite
tracks its box (and the 3D orientation). Scoped to items — the only emitter of
the `image` floorplan geometry kind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:49:33 -04:00
Wassim SAMADandClaude Opus 4.8 03284553aa perf(editor): per-node memo + own-slice subscriptions for floorplan registry
In split view, dragging any node re-rendered ALL ~150 per-node
InteractiveGeometry instances every tick (profiler: InteractiveGeometry ×3192),
because FloorplanRegistryLayer subscribed to the whole useLiveTransforms /
useLiveNodeOverrides maps and the per-node component wasn't memoized.

Extract a memoized FloorplanRegistryEntry that subscribes to ONLY its own slice
(useLiveTransforms(s => s.transforms.get(id)) / overrides.get(id)). The live
stores write a fresh value object for the changed node only, so unchanged nodes
keep identity and don't re-render. The parent now watches just the stable
node-id list; sibling invalidation (wall miters / opening cuts) moves to a
store-subscribe that bumps only affected siblings' epoch. InteractiveGeometry is
also memoized. Geometry cache, affected-sibling epochs, the base/overlay
two-pass, hit-testing, and selection/hover are all preserved verbatim.

An item move now re-renders only its own entry; a wall drag only the moving wall
plus its linked-corner siblings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:47:05 -04:00
Wassim SAMADandClaude Opus 4.8 5f603e83c4 fix(editor): unify wall-endpoint move activation; stop wall-move from co-firing
Two fixes to the wall-endpoint reshape interaction.

selection-manager: a node:click is synthesized on R3F pointer-up, so an endpoint
handle that sits on the wall body lets the wall mesh (raycast-hit behind it from
a 3D angle) emit its own click on the same release — selecting the wall and
arming its move tool on top of the endpoint move. Ignore the body click while an
`endpoint` reshape owns the pointer. Scoped to `endpoint` so hole-edit (which
relies on node clicks to exit) is unaffected.

move-endpoint-tool: a press-drag committed on release but a tap dismissed, and
whether the tap's release ran at all raced the window pointer-up listener
mounting a tick after the handle's pointerdown ("works once, then needs a long
press"). Unify on one rule: commit only when the endpoint actually moved,
otherwise stay armed. A tap now grabs the endpoint (it follows the cursor; the
next click after a move commits) exactly like a press-drag — both engage
identically. Drops the now-dead hasDraggedRef.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:08:17 -04:00
Wassim SAMADandClaude Opus 4.8 eeadec11a7 perf+fix(editor): gate 2D scene in 3D mode; wall-endpoint move via live overrides
Two changes from the split-view / wall-endpoint perf + UX pass.

floorplan-panel: render the heavy 2D <svg> scene only when the panel is visible
(`isFloorplanOpen`, i.e. viewMode !== '3d'). The panel stays mounted in 3D
(display:none) to keep the portalled compass + viewport state warm, but the
registry layer / per-node InteractiveGeometry / handle layers no longer
reconcile on every scene change while invisible. Renders fully in 2D and split;
viewport pan/zoom is preserved across the toggle.

wall move-endpoint-tool: preview via `useLiveNodeOverrides` instead of writing
`useScene.updateNodes` every grid:move tick. The per-tick store write handed a
fresh `nodes` ref to every `useScene(s => s.nodes)` subscriber (WallPanel, the
contextual HUD, tooltips, floor plan), rebuilding them all each frame. Overrides
are merged by the wall system, wall panel, and 2D floor plan, so the preview
still tracks live with no store churn; the store is written once on commit, and
one Ctrl-Z reverts to the original endpoint. Also swallow the click that follows
every endpoint-tool release so it can't fall through to the wall body and arm
the wall move tool (no-drag tap or post-commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:39:10 -04:00
Wassim SAMADandClaude Opus 4.8 b384bc8178 perf(editor): skip floorplan viewport sync while the 2D panel is hidden
Camera-zoom hitch: the FloorplanPanel stays mounted (display:none in 3D mode),
so its navigation-pose subscriber fired every camera onUpdate. During zoom the
view-width changes continuously, so the epsilon guard never short-circuited and
`syncFloorplanViewportToNavigationPose` ran each frame → setViewport/
setFloorplanUserRotationDeg → a full re-render of the ~10k-line floorplan SVG,
even though nothing is visible (React reconciles display:none subtrees).

Gate the viewport sync on `isFloorplanOpen` via a ref the per-frame subscriber
reads, and re-run the mount catch-up effect when the panel reopens so the
viewport snaps to the current camera. The compass is unaffected — it's portaled
to the always-visible viewer area and still receives the pose; only the panel's
own viewport sync is skipped while hidden.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 17:34:42 -04:00
Wassim SAMADandClaude Opus 4.8 bbe5b9a8eb fix(placement): wall-item facing via useFacingPose + right-click no longer cancels camera orbit
BUG 1 — wall-attached furniture facing. The coordinator drew its own inline
facing triangle and published the wall grid normal from the cursor ghost's own
yaw, which is the symmetric-wireframe yaw — π off the item's true facing for a
wall (and a different frame for a roof-segment face). The triangle pointed into
the wall and the grid normal was sign-flipped while the box still looked right.

Fold the coordinator into the unified `useFacingPose` overlay (drop the inline
triangle, geometry/material/constants). The per-frame surface publisher now
derives the true outward facing from the draft mesh's world orientation (its
local +Z faces out of the host surface) for wall/roof-wall and feeds that single
yaw to BOTH the grid normal and the facing triangle. Floor/ceiling/item-surface/
shelf paths are unchanged behaviourally — same cursor yaw and Y, just routed
through the overlay.

Right-click cancel vs camera orbit. The right button also orbits the camera, so
the old unconditional `contextmenu → onCancel` cancelled placement on every
right-drag. Gate the cancel on pointerup: only when the right press moved ≤4px
within ≤200ms (a quick stationary click); a longer/further press is an orbit and
is left alone. contextmenu now only suppresses the OS menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:59:06 -04:00
Wassim SAMADandClaude Opus 4.8 8cc6c41b51 fix(editor): unstick empty-click deselect after click-to-move
Validated live by Wassim. The click-to-move branch set `clickHandledRef = true`
then returned early, skipping the 50ms reset the normal select path runs at the
end. The flag stayed true, so `onGridClick`'s guard silently blocked every
empty-click deselect until the next normal select cleared it. Schedule the reset
right after the flag is set so EVERY branch (incl. the early return) clears it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:09:11 -04:00
Wassim SAMADandClaude Opus 4.8 5933a00247 fix(editor): placement grid — wall-anchored, perf, first-frame wall seed
Validated live by Wassim.

- Wall grid is anchored to the wall PLANE (its foot), not the moving ghost, so
  sliding a door/window only moves the reveal patch — the lattice stays a fixed
  snap reference instead of "following" the opening.
- DoubleSide so the lattice renders when an opening is handled from the far side.
- depthTest is conditional: ON for the floor (the ground occludes a sub-floor
  lattice) and OFF on a wall (visible through the wall from the opposite side).
- Resolution change is a uniform write only — `cellSize` no longer rebuilds the
  uniform + material (which recompiled the shader and stalled on every step).
- Y follow snaps instantly (was a lerp); `gridY` state only updates on change.
- Reveal radius 5 → 12.
- Door/window publish the wall surface on mount (+ claim the pointer for the
  wall) so the grid is vertical from the FIRST frame — no horizontal flash.
- Export the active-placement-surface module so the opening tools can publish.
- Drop the editor-side "Show Grid" setting: the 3D grid is now purely a
  placement aid (shown only while placing/moving in grid-snap mode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:08:59 -04:00
Wassim SAMADandClaude Opus 4.8 67a558a883 feat(editor): route door/window facing triangle through the unified overlay
Door and window are placed via preset and moved with a bespoke wall-bound
`move-tool` (affordanceTools.move), not the draw tool — so the previous inline
triangle never showed for the paths actually used. Migrate both:

- move-tool (move + preset, the community path): publish the on-wall ghost pose
  to `useFacingPose` in the same building-local frame the ghost renders in,
  dropped to the floor under the wall (the ghost Y is the opening centre); clear
  on every off-wall / hide / reveal / unmount path
- draw tool (standalone from-scratch path): publish the on-host pose, clear on
  fallback/hide; frame depth read via a ref to keep the setup effect deps clean

Removes the now-dead `FacingIndicator` public export (the editor-side overlay is
its only consumer, via relative import). The unified overlay now covers every
placement/move path: items, column/shelf, stair, and door/window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:12:08 -04:00
Wassim SAMADandClaude Opus 4.8 68e5c6ca67 feat(editor): unify placement/move facing triangle into one editor-side renderer
Every placement and move path now publishes its ghost pose to a single
`useFacingPose` store, drawn by one editor-side `<FacingPoseIndicator>` overlay,
instead of each path drawing its own triangle (which left the nodes-package and
PlacementBox paths invisible):

- column/shelf presets + all moves (PlacementBox via move-registry, and
  DragBoundingBox) now publish the pose, so the triangle finally shows
- stair create + move use a declarative `facingIndicator: { reversed: true }`
  (new registry resolver) so the triangle sits before the entry pointing out —
  resolved in one place, so create and move match automatically
- stair placement defaults to single and respects the shared `point`
  continuation (C) toggle, like the other placement tools

Checkpoint on the placement-interaction epic: also carries the in-flight
continuation-profile extraction (lib/continuation), grid surface (item #8), and
HUD work. Door/window still render their own legacy inline triangle and are
migrated to the overlay next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:48:58 -04:00
Wassim SAMADandGitHub 61f3921a83 Merge pull request #445 from pascalorg/polish/viewer-baking
feat(viewer): walkthrough FOV 60° + refresh ceiling-fan model
2026-06-26 15:58:06 -04:00
Wassim SAMADandClaude Opus 4.8 70acf894d1 feat(viewer): widen first-person walkthrough FOV to 60°
The walkthrough rode the default 50° orbit camera, which feels cramped on
foot. Both walkthrough controllers (baked GlbWalkthroughController and the
parametric WalkthroughControls fallback) now set a shared WALKTHROUGH_FOV = 60
on enter and restore the prior FOV on exit, leaving orbit framing untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:51:12 -04:00
Wassim SAMADandClaude Opus 4.8 69c483b5f2 chore(items): refresh ceiling-fan model (On clip + base paint slot)
The bundled ceiling-fan model.glb was stale (no animation clip, two slots).
Replace it with the variant matching production storage: an `On` animation
clip and a third `slot_base` paint slot. Same dimensions/offset, so no catalog
metadata change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:51:05 -04:00
Wassim SAMADandClaude Opus 4.8 7530de5348 fix(viewer): bake folding/garage doors at an un-flipped rest pose
`poseDoorMovingParts` assigned a single euler axis (`group.rotation.y` /
`.x`). The live system was fine because the group's euler stays a clean
(0, y, 0). But the GLB exporter clones the door and decomposes its matrix,
which re-derives a gimbal-flipped euler (x=z=π) for any rotation beyond
±90° — folding panels reach ~158°. The reset to t=0 then only zeroed `.y`,
leaving the π residue on x/z and baking a 180°-flipped rest pose (panels
folded out toward a wrong position even when closed).

Set the full euler triple via `.set()` in every pose branch so the other
two axes are always zeroed, clearing any decomposed residue. Add a
regression test that exports an open folding door and asserts an identity
rest pose for all panels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:12:11 -04:00
Wassim SAMADandClaude Opus 4.8 76089d85ea docs(arch): record wall/fence Alt→chain-toggle + altKey-alignment known-legacy
interaction-scope.md + review-architecture skill: the sanctioned Alt-as-toggle
(wall/fence chain mode, the one place Alt-as-force is meaningless), and a second
known-legacy pattern — `event.altKey` alignment-bypass in the roof/polygon/slab
previews + ceiling/slab snap paths (migrate-on-touch; wall+fence already done).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:58:11 -04:00
Wassim SAMADandClaude Opus 4.8 2666b07479 feat(editor): wall room/single + fence continuous/single chain toggles
Replace the legacy held-Alt mechanism on wall and fence drafting with a
mode toggle, mirroring the snapping-mode chip:

- Wall: `wallChainMode` room (auto-close on loop) / single. Room mode
  finishes automatically when the new endpoint lands within the join-snap
  radius of the chain's first vertex; single commits one wall per click.
- Fence: `fenceChainMode` continuous (chain until double-click/Esc) /
  single. Fences are linear barriers, so continuous has no auto-close.
- Both: Alt-tap cycles the active drafting tool's chain mode (clean-tap,
  scoped to wall/fence drafting); a clickable HUD chip shows the mode.
  Persisted + migrated in `useEditor`.

Migrate wall and fence off held-Alt-bypass-alignment to the unified
convention: alignment now follows the magnetic snap mode, which frees Alt
for the toggle. 2D floorplan parity kept in sync with the 3D tools.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:27:12 -04:00
Wassim SAMADandClaude Opus 4.8 4c81435e3b fix(doors): correct folding fold direction + make open-clip names unique per node
Two issues surfaced testing the baked viewer:

- Folding door folded toward +z (into the room) — the joint rotation sign
  was inverted, so the accordion opened the wrong way ("weird position").
  Flip to `(prevDirection - direction) * foldAngle` so leaves fold toward
  −z, matching the original inline rig. Verified panel-for-panel against the
  original formula at every operationState.

- Openable clips were named by display name (`<name>: open`), but the baked
  viewer drives playback by clip name (`useAnimations` maps name → action).
  Several windows share the name "Window 1", so their clips collapsed to one
  action and triggering any one opened the first. Key the clip name by node
  id (`<id>: open`) — unique, matching the item-loop convention; the
  human-readable name still lives in `extras.label`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:12:47 -04:00
Wassim SAMADandClaude Opus 4.8 c629171607 feat(doors): bake open-animation clips for sliding/garage/folding/pocket/barn doors
Only swing doors (hinged/double/french) baked an open clip into the GLB —
they carry a `pascalSwingLeaf` marker the exporter reads. Every operation
door type (sliding, pocket, barn, folding, garage-sectional/rollup/tiltup)
baked its `operationState` straight into mesh vertex positions at build
time, so the exporter had no re-poseable node to sample and the artifact
never flagged them `openable`.

Give operation doors the same build-once + pose-at-t split windows already
use. Each builder now emits its moving parts in a named group at the CLOSED
pose, and `poseDoorMovingParts` (the single source of truth, shared by the
live system and the GLB exporter) drives the open motion:

- sliding/pocket/barn: rigid leaf translation
- garage-tiltup: rigid hinge about the lintel
- folding: hinged accordion chain (nested groups, per-joint fold)
- garage-sectional: per-panel groups posed along the overhead curve
- garage-rollup: the one type whose live geometry changes (slats roll onto
  a drum, which a glTF clip can't express) keeps its full-detail live
  rebuild; the curtain is wrapped in a top-pivoted group the exporter
  scales up into the lintel as the baked approximation.

The exporter samples each operation door's motion into keyframe tracks
(16 segments) so the non-linear rigs (curve, accordion) stay faithful, and
stamps `extras.openable` + `extras.clips` so any glTF consumer can play it.

Tests: per-type kinematics (groups build, rest closed, open) +
sliding/roll-up clip baking (sampled position/scale tracks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:24:08 -04:00
Wassim SAMADandClaude Opus 4.8 6d02ad424e docs(editor): correct stale Alt "free place" comment in placement coordinator
The floor grab-offset comment claimed floorStrategy.move reads localPosition
"under Alt (free place)"; it reads event.position with mode-governed snapToGrid
and has no Alt branch (Alt is force-place-only). Describe the real reason both
frames carry the offset: it's computed local-space but the strategy consumes the
world point.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:59:05 -04:00
Wassim SAMADandClaude Opus 4.8 f0206dc6eb docs(arch): codify the unified snapping/modifier convention + enforce in review
The snapping model (Shift = cycle mode, Alt = force/free, mode-driven reads via
isGridSnapActive/isMagneticSnapActive/isAngleSnapActive, snapProfile-declared
context) lived only in code and the plan; tools.md still preached the legacy
"Shift = bypass snapping". Close the drift so the architecture review refuses
tool changes that revert to the old pattern:

- tools.md: replace the held-Shift-bypass manipulation policy with the unified
  mode-driven model + the single snap read path.
- interaction-scope.md: new "Snapping mode & modifiers" section (contexts, read
  path, modifiers, the chip-needs-a-scope rule) + a Rules bullet + the
  known-legacy MEP movers (migrate-on-touch) incl. the dual-path constraint
  (a bespoke mover must not open a `moving` scope — it re-mounts the generic
  mover via useMovingNode).
- review-architecture skill: add interaction-scope.md to the reads and a new
  "F. Interaction scope, snapping & modifiers" checklist — new shiftKey-bypass,
  ungated grid step, missing snapProfile, a new useEditor interaction flag, or a
  bespoke mover opening a moving scope are blockers; touching the legacy MEP
  movers forces migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:28:14 -04:00
Wassim SAMADandClaude Opus 4.8 1de1923e47 fix(nodes): raise MEP placement HUD pill clear of the cursor
The MEP run/fitting/terminal tools anchored their cursor readout pill at
~+0.35m above the placement point, so with the tall CursorSphere line
(badge at +2.7m) the pill sat right on the cursor and overlapped it,
especially when zoomed in. Editor main (#438) already raised the duct
pill to +1.45m; this long-lived branch predates that merge. Bring every
MEP tool that uses the tall cursor onto the same +1.45m anchor:
duct/pipe/liquid/lineset runs, duct/pipe fittings, and duct-terminal.
hvac-equipment (height-aware anchor) and pipe-trap (no cursor line) are
left as-is — their HUDs already clear the ghost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:04:33 -04:00
Wassim SAMADandGitHub a3aecf1907 Merge pull request #443 from pascalorg/fix/glb-export-missing-material
fix(editor): don't crash GLB export on a material-less renderable
2026-06-25 16:51:19 -04:00
Wassim SAMADandClaude Opus 4.8 f75cffed9f fix(editor): don't crash GLB export on a material-less renderable
Baking some projects failed with "Cannot read properties of undefined (reading
'isShaderMaterial')". GLTFExporter reads material.isShaderMaterial unconditionally,
so a renderable (Mesh / Line / Points) with no material crashes the export — and a
non-Mesh renderable slips past both the isMesh prune check and material conversion.

Guard it in pruneNonRenderableMeshes: a material-less renderable is dropped if it's
a leaf, or neutralised (empty geometry + a hidden placeholder material) if it has
children so its subtree survives. Post-FX disable now lets these scenes reach the
exporter, which is why it surfaced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:48:39 -04:00
Wassim SAMADandClaude Opus 4.8 0fb3604586 perf(editor): move 2D marquee + reference-scale draft out of panel state
The last two hot per-pointer-move 2D states still lived in FloorplanPanel's
useState, re-rendering the ~10k-line panel on every move:

- marquee (box-select): the whole drag struct moves to a dedicated
  use-floorplan-marquee store; down/move/up/cancel read+write it via
  getState() (panel holds nothing), and a FloorplanMarqueeOverlay leaf
  subscribes to the moving corner and renders the rect alone. Drops the 3
  bounds memos + the useState.
- reference-scale: the rubber-band's moving end was always equal to the
  shared cursorPoint (written every move anyway), so drop the `cursor` field
  from the draft and read it from useFloorplanDraftPreview in a new
  FloorplanReferenceScaleDraftLine leaf. The draft now carries only the
  per-click guide + start anchor, so it no longer re-renders the panel.

Closes out the 2D edition perf pass — every build/edit/select hot path now
writes a store, not panel state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:38:42 -04:00
Wassim SAMADandClaude Opus 4.8 84cdf4519d feat(nodes): mode-aware roof-segment edit + 2D rotation parity with 3D
Roof-segment edit/move ignored the active snap mode and showed no chip:
- add snapProfile:'structural' so a body-move resolves the no-angle polygon
  context (grid/lines/off) like every other structural move;
- resize uses getSegmentGridStep() (0 outside grid mode = the "smooth" resize
  that used to need a held Shift), dropping the captured gridSnapStep + Shift;
- move drops its Shift bypass;
- the affordance dispatcher opens a boundary reshape scope for the resize so
  the snapping chip shows and the context resolves.

2D rotation handles now match the 3D gizmo across all six rotate affordances
(column / elevator / roof-segment / shelf / spawn / stair): a shared
rotateAffordanceDelta snaps to the 15° step unless Shift (free), the
dispatcher opens the same ROTATE_HANDLE_DRAG_LABEL handle-drag scope the 3D
gizmo uses so the contextual HUD shows the "Shift = rotate freely" hint, and
the live degree readout snaps to match the committed rotation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:12:26 -04:00
Wassim SAMADandClaude Opus 4.8 7512fccdaf feat(nodes): mode-aware snapping for wall/fence endpoint moves in 2D
Wall and fence endpoint-move affordances hard-snapped to the grid via a
hardcoded WALL_GRID_STEP and always ran Figma line-alignment, ignoring the
active snapping mode. Now:

- grid step follows getSegmentGridStep() (0 outside grid mode), so lines /
  angles / off no longer force a grid snap the mode chip says is inactive;
- Figma alignment is gated on isMagneticSnapActive() (the lines mode);
- angles mode angle-locks the endpoint off the fixed corner (free length),
  mirroring the draft tool;
- fence drops its legacy Shift-bypass to match the wall's unified model
  (Alt stays as linked-segment detach).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:41:10 -04:00
Wassim SAMADandClaude Opus 4.8 a80924e011 feat(editor): wire reshape scope for edit-tool chips + 2D-only wall commit
The 2D affordance dispatcher (`startAffordanceDrag`) now begins the matching
reshaping interaction scope (boundary / hole / curve / endpoint) on pointer-down
and tears it down on release/cancel, matched by node id. This makes the
contextual snapping HUD show the right chip during polygon vertex/edge and wall
endpoint/curve edits, and lets `getActiveSnapContext()` resolve the correct
per-context snapping mode the affordance snap math already reads.

Wall creation is owned by the 3D `WallTool`, which is dead in 2D-only view
(canvas `display:none`). Mirror the slab/ceiling 2D-only committers: commit
locally via `createWallOnCurrentLevel`, gated on `viewMode === '2d'`, chaining
the next segment from the committed wall's resolved end. Split/3D keep their
single-owner tool commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:24:35 -04:00
Wassim SAMADandClaude Opus 4.8 6f37783fbd fix(editor): commit ceiling locally in 2D-only view (3D tool can't)
Same fix as slab: ceiling is committed by its 3D registry tool, dead in 2D-only
view. Commit it from the panel on both close paths, gated to viewMode==='2d'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:57:24 -04:00
Wassim SAMADandClaude Opus 4.8 0e778ab526 fix(editor): commit slab locally in 2D-only view (3D tool can't)
Slab is committed by its 3D registry tool, which accumulates the grid:click
vertices the 2D panel emits and commits on close. That path is dead in 2D-only
view — the 3D canvas is display:none, so the tool never commits and the slab is
never created (split/3D work because the 3D side is live). Mirror the zone
pattern: the panel commits the slab itself on both close paths (double-click +
click-first-vertex), gated to viewMode==='2d' so split/3D keep their single-owner
tool commit (no double-create).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:56:24 -04:00
Wassim SAMADandClaude Opus 4.8 ac14443fa6 fix(editor): polygon vertex/edge edit honors the active snapping mode
The shared polygon-vertex affordance snapped via snapPointToGrid(rawPoint), whose
default step is the hardcoded WALL_GRID_STEP (0.5m) — so slab/zone/ceiling vertex,
edge, and add-vertex edits always quantized to half-meters regardless of the
active mode OR the user's grid-step setting (plan open bugs #1-2). Use the
mode-aware getSegmentGridStep() (0 in non-grid modes) so grid quantizes to the
live step, lines/off pass through to the wall-snap/alignment resolver. Drop the
legacy shiftKey bypass from the slab/ceiling magnetic resolvers (they already
gate on isMagneticSnapActive).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:46:16 -04:00
Wassim SAMADandClaude Opus 4.8 167f0868ef perf+fix(editor): wall/fence/roof 2D draft to store+leaf, finish snapping-mode parity
Perf: move the per-move wall/fence/roof draft END points into
useFloorplanDraftPreview; a new FloorplanLinearDraftLayer leaf owns the live
draft polygon + fence segment + wall measurement, subscribing to the store. The
shared FloorplanDraftLayer keeps only the per-click anchors. Wall/fence/roof
drafts now have zero per-move panel setState — buttery smooth like slab/zone.

Parity: migrate the remaining legacy Shift=bypass paths to the unified
mode-driven model. roof (move + click) honored only always-grid + bypassSnap —
now grid/lines/off (footprint → no angle). wall + fence click-commit still used
the legacy bypass while their move-preview didn't — now consistent. Wall Alt
stays 'commit single wall' (open product decision, untouched).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:13:58 -04:00
Wassim SAMADandClaude Opus 4.8 bc9e075b2d fix(editor): 2D slab/zone/ceiling drafting honors the active snapping mode
The polygon-draft snap path used the legacy model: bypassSnap = shiftPressed and
angleSnap = pointCount > 0 && !bypassSnap, so the 15deg angle lock engaged after
the first vertex regardless of mode — hijacking grid/lines/off into angle-snap
even though the HUD chip showed the right mode.

Migrate all three placement paths (move preview, single-click vertex, double-
click close) to the unified model: angleSnap = isAngleSnapActive(); grid flows
through snapToHalf (step 0 in non-grid modes); wall-snap/alignment already gates
on isMagneticSnapActive(). Behavior now matches the chip — grid quantizes,
angles locks 15deg rays, lines snaps to walls/alignment, off is free. Drop the
now-dead bypassSnap param from snapPolygonDraftPoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:45:30 -04:00
Wassim SAMADandClaude Opus 4.8 a769b45756 perf(editor): extract 2D draft cursor state to store + leaves — kill per-move panel re-render
The 2D build/edit tools republish the snapped cursor point AND the screen-space
coordinate-badge position on every pointer move. Both lived in FloorplanPanel
useState, so each move re-rendered the whole ~310ms panel (the badge fires on
every pointermove while any build tool is active — the dominant culprit).

Move both into a useFloorplanDraftPreview store written via getState() (no panel
re-render); render the crosshair + live polygon-draft edge from a
FloorplanDraftCursorLayer leaf and the coordinate badge from a
FloorplanCursorIndicator leaf, each subscribing to the store. Same store+leaf
pattern as the stair build preview. Slab/zone/ceiling drafts now have zero
per-move panel setState. Wall/fence/roof draftEnd are follow-up slices.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:35:36 -04:00
Wassim SAMADandClaude Opus 4.8 6d5294d43f perf(editor): extract stair 2D build preview to store + leaf — kill per-move panel re-render
The stair tool held its 2D build preview in FloorplanPanel useState, so every
grid:move re-rendered the whole ~120-220ms panel. Move the preview into a
dedicated useStairBuildPreview store written via getState() (no panel re-render)
and render it from a FloorplanStairBuildPreviewLayer leaf that subscribes to the
store directly — the same pattern that keeps column/elevator placement smooth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:02:48 -04:00
Wassim SAMADandGitHub d3c3c0517b Merge pull request #441 from pascalorg/feat/baked-glb-export
viewer: baked GLB export + GLB-consuming /viewer (lights, clips, perf)
2026-06-25 12:39:58 -04:00
Wassim SAMADandClaude Opus 4.8 b7386f2dcb fix(viewer): block body for zone-shape forEach (useIterableCallbackReturn)
Main's biome config flags a callback returning a value; the ternary in the
zone-shape forEach implicitly returned moveTo/lineTo. Use a block body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:37:03 -04:00
Wassim SAMAD 708fa5278e Merge remote-tracking branch 'origin/main' into feat/baked-glb-export
# Conflicts:
#	packages/editor/src/components/editor/export-manager.tsx
2026-06-25 12:34:24 -04:00
Wassim SAMADandClaude Opus 4.8 bbf9291c2d feat(editor): roof/stair/elevator snapping migration + no-angle footprint draft
Migrate roof/stair/elevator draft tools + the 2D floorplan move overlay off the
legacy Shift/Alt=bypass model onto mode-driven snapping (isGridSnapActive /
isMagneticSnapActive); Alt dropped (no validity gate). stair/elevator now use the
live grid step.

These three are placed as footprints, not directional draws, so the angle-lock
mode was meaningless: add NodeDefinition.snapDraftDirectional (default true; false
for roof/stair/elevator) so their draft resolves to the no-angle 'polygon' context
(grid / lines / off). snapContextOf takes an injected draftDirectionalOf, like
profileOf. Add toolHints to stair/elevator so they route through the contextual
HUD and show the snapping chip. Fix one stale Alt-bypass comment in the item
placement coordinator (#9: already force-only). +snapping-mode test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:23:00 -04:00
Wassim SAMADandClaude Opus 4.8 8e9a45a9d5 feat(editor): MEP move-tools — mode-driven snapping (drop Shift=bypass)
The 5 bespoke MEP movers (duct/pipe-segment, liquid-line, lineset, duct-fitting)
now read the active snapping mode (isGridSnapActive / isMagneticSnapActive)
instead of shiftKey=bypass. The moving scope already carries the node
(setMovingNode → begin('moving')), so the per-kind context resolves with no
extra wiring. Grid and alignment are now independent reads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:06:31 -04:00
Wassim SAMADandClaude Opus 4.8 2fdbcf03c3 feat(editor): MEP placement migration — Shift=cycle / mode-driven snapping
Migrate all 9 MEP kinds' placement tools onto the unified snapping model:
declare snapProfile ('item' for point-placed hvac-equipment / duct-terminal /
duct-fitting / pipe-fitting / pipe-trap; 'structural' for directional runs
duct-segment / pipe-segment / liquid-line / lineset), and replace the legacy
shiftKey-bypass reads with mode-driven isGridSnapActive / isMagneticSnapActive /
isAngleSnapActive. For runs the 45° lock becomes the cyclable 'angles' mode;
Alt stays the vertical-riser modifier (run drafting has no validity gate to
force). Port mating gated on "mode != off". Dropped stale "⇧ smooth/free" hints.

The bespoke MEP move-tool/selection (endpoint) tools stay on the legacy model —
they use setMovingNode(null) so no moving-scope context resolves yet; migrating
them needs scope-wiring first (follow-up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:56:55 -04:00
Wassim SAMADandClaude Opus 4.8 76096ffe72 fix(editor): door/window move — fix 2D+3D FPS collapse + finish modifier migration
The 3D MoveDoor/MoveWindow tools wrote useScene every frame during a move
(freeFollowAt + applyPreview alternating): the wall:move (R3F) / grid:move
(DOM) de-dup compared event.timeStamp across two event systems with different
clocks, so it never matched and the floor free-follow ran during on-wall
slides too, ping-ponging the host and churning the nodes ref → framerate
collapse in both 2D and 3D. Replace it with a single-clock wall-ownership
window (performance.now, ~4 frames): the floor follow stands down while a
wall/roof hit is fresh. On-wall slides now write no scene per frame (mesh +
useLiveTransforms only). Lower the live wall-cutout throttle 120→60ms now that
the per-frame churn is gone.

Also completes the door/window modifier-model migration (#10): Shift=cycle /
Alt=force-place, fully mode-driven snap, snapProfile:'item'; exclude
ground-line candidates from along-wall opening alignment; emit the move SFX
once per snapped step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 10:21:37 -04:00
Wassim SAMADandClaude Opus 4.8 636f8ed4f7 style(viewer): wrap three import in glb-interactive (formatter)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:01:13 -04:00
Wassim SAMADandClaude Opus 4.8 5938cb6805 feat(viewer): strip scans/guides from the bake, re-add from scene data
Scans (LiDAR meshes) and guides (floorplan images) are heavy reference assets
stored elsewhere, not part of the compiled building — and baking them into a
public static GLB would also bypass the per-project show_*_public flags. Strip
both from the export entirely (previously they leaked in as empty identity
nodes, timing-dependent).

The GLB viewer re-adds them at runtime from the scene graph, like lights:
GlbReferenceNodes resolves each scan/guide's registry renderer (no static nodes
import — same nodeRegistry path the viewer already uses) and portals it into its
parent level's baked node, so the node's level-local transform resolves to the
right world pose and rides level stacking. Uses the same GuideRenderer/
ScanRenderer + asset resolver as the parametric viewer, so http-backed assets
show for everyone and local asset:// ones for the owner — exact parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 08:36:02 -04:00
Wassim SAMADandClaude Opus 4.8 df901b1d4e perf(viewer): keep GLB light pool at a fixed visible count (no WebGPU recompiles)
Toggling pointLight.visible changes the active-light count, which makes the
WebGPU renderer rebuild every material's lighting node + pipeline — a multi-
hundred-ms stall. The pool reassigns on every camera move, so zooming churned
visibility and tanked FPS. Keep all 12 pool lights permanently visible and
animate only intensity (an idle light lerps to 0); the light-set never changes,
so no recompiles. Also make reassignment O(n) (score lookup map, not find).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:46:17 -04:00
Wassim SAMADandClaude Opus 4.8 a5c08b5950 feat(viewer): pool GLB item lights + stop overlay click propagation
Match the parametric ItemLightSystem instead of mounting a light per item:
a fixed pool of point lights is assigned to the nearest/most-visible lit
items each tick (camera-proximity scored, hysteresis, level factor), snapped
to each item's world position + offset, and faded on reassignment — so a large
house doesn't blow the renderer's light budget. The controls overlay already
mounts its <Html> only while the item sits in the focused zone (not hide/show);
add stopPropagation on the overlay so toggling a control no longer bubbles to
the canvas and deselects the zone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:34:41 -04:00
Wassim SAMADandClaude Opus 4.8 0514ec1858 feat(bake): bake catalog item clips (fan spin) into the GLB + play in viewer
A catalog GLB ships its own animation clips (a ceiling fan's spin), but those
clips aren't in the editor scene graph, so the bake couldn't see them. Add an
`itemClipRegistry` (core, type-only three) that the item renderer fills with the
resolved clip per node while the scene is live; the GLB export reads it and
re-emits each item's clip onto the baked subtree, rebinding tracks to the cloned
spinning node's uuid. Catalog node names repeat across instances and the glTF
roundtrip rebinds by name, so the targeted node is uniquified per item
(`<id>__lamp_018`) — every fan animates independently.

The baked viewer plays these as looping ambient motion: `<id>: loop` clips are
set to LoopRepeat (not the door/window LoopOnce) and GlbItemAnimation drives
them off each item's toggle (lit/spinning by default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 18:13:48 -04:00
Wassim SAMADandClaude Opus 4.8 d80ffd67d5 feat(viewer): re-light + re-control baked GLBs from the scene graph
Add a GLB interactive layer (`GlbInteractive`) that re-creates the item
interactivity the parametric viewer has — point lights and the controls
overlay — on top of a baked artifact. Effects + controls come from the
DB scene graph (joined to baked nodes by `pascalId`, no sidecar); world
transforms come from the baked Object3Ds. Lights are portaled into their
item node so they ride level stacking, and intensity tracks the shared
`useInteractive` store so overlay dimming works. Baked scenes load "lit"
(toggles default on) for a showcase viewer feel.

Extract `ControlWidget` into its own module so the parametric
`InteractiveSystem` and the GLB overlay render identical controls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:47:07 -04:00
Wassim SAMADandClaude Opus 4.8 0e55d86416 fix(viewer): keep ceiling-hosted items visible in dollhouse
Items mounted on a ceiling (lamps, fans, recessed lights) are child nodes of
the ceiling, so hiding the whole occluder node hid them too. Hide only the
ceiling/roof's own meshes (stop descending at nested identity nodes) so hosted
items stay visible when a floor is opened up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:51:00 -04:00
Wassim SAMADandClaude Opus 4.8 8a57105eec feat(editor): mode-driven shelf/column/spawn placement + cross-kind floor collision
Migrate the remaining floor-placed kinds onto the unified snapping/modifier
model and generalize floor collision so any solid floor kind blocks any other.

- shelf/column/spawn declare `snapProfile: 'item'` → contextual snapping chip,
  Shift=cycle, Ctrl=grid step during placement; their tools read the active
  mode (grid/lines/off) instead of legacy Shift/Alt bypass; spawn fresh
  placement now respects alignment ("lines") like its move.
- Resize/radial handles claim the handle-drag scope (new RESIZE_HANDLE_DRAG_LABEL)
  so the HUD shows no select-mode shortcuts mid-resize.
- Column move migrated to the generic MoveRegistryNodeTool (declare `movable`,
  drop the bespoke move-tool) — gains mode-driven snapping, alignment, R/T,
  slab lift, grid SFX, and the collision box for free. 2D move still routes
  through `floorplanMoveTarget`.
- Cross-kind floor collision: new declarative `FloorPlacedConfig.collides`
  (item/shelf/column opt in; spawn/MEP/stair stay off). `canPlaceOnFloor` now
  treats every colliding floor kind as an obstacle (was item-only), reading the
  declarative footprint; the generic move tool's red/green placement box gates
  on `collides`. Column footprint uses the visible `columnFootprintHalf` extent
  so the box/slab-lift/collision track the real (round/square) column size.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:22:04 -04:00
Wassim SAMADandClaude Opus 4.8 5aedc366a6 feat(viewer): GLB walkthrough in viewer, monochrome, spawn/export polish
Move the first-person walkthrough into @pascal-app/viewer (BVHEcctrl +
GlbWalkthroughController) and round out the GLB-consuming viewer:

- Walkthrough: fallback ground only on level 0 (upper floors rely on baked
  slabs), hidden spawn marker, auto pointer-lock on enter, single-Esc exit via
  pointerlockchange, force perspective on enter / restore on exit.
- GlbScene: monochrome strips baked textures and recolours meshes by surface
  role using the active theme's clay tints; spawn node hidden from render.
- glb-export: camera/label/spawn identity extras for the viewer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:50:28 -04:00
Wassim SAMADandClaude Opus 4.8 04f1b0d59e feat(editor): node-declared per-context snapping + contextual HUD + painter scope
Generify the snapping/modifier HUD off the FSM scope and node declarations
instead of wall-creation-shaped, leaking pills.

- Per-context snapping (`snappingModeByContext`, persisted): wall / item /
  polygon mode-sets with exclusive modes (grid | lines | angles | off), each
  doing exactly what its chip says. Context is node-declared via the new
  `NodeDefinition.snapProfile` ('item' | 'structural'); the resolver maps
  (profile × action) → context with no per-kind switch.
- Scope-driven HUD: helper-manager reads the interaction scope; reshaping
  (endpoint/curve/boundary) and item move get their own chip, no select-hint
  leak. Rotate R/T rounds to 45°; Alt = force-place only (hidden for
  structural kinds); Shift = cycle everywhere.
- Slab/ceiling drafting: Shift=cycle, mode-aware grid/angle, Enter finishes
  (minDraftVertices); polygon boundary vertex/edge drag begins a reshaping
  scope. Fix grid/angle being ignored on boundary edit + slab creation:
  make resolveSurfacePlanPointSnap exclusive (alignment gated on magnetic) so
  grid/angles keep the snapped fallback instead of the raw cursor.
- Painter application scope: node-derived (single/object/matching/room) from
  the hovered node, cyclable via Shift, single-source HUD chip.
- Remove the redundant GridSnapControl from view-toggles (grid step lives in
  the contextual HUD now).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:23:15 -04:00
Wassim SAMADandClaude Opus 4.8 b8b3d35f26 refactor(editor): delete the movingNode legacy flag — node lives in the scope
The 7th and last of the legacy interaction flags. The node being placed/moved
now lives inside the interaction scope's `placing`/`moving` variant (carried
inline, since fresh-placement/duplicate drafts aren't in the scene yet), read via
`useMovingNode()` / `getMovingNode()` / `movingNodeOf(scope)`.

- scope.ts: `placing`/`moving` carry `node: AnyNode`; add `movingNodeOf`.
- use-interaction-scope.ts: `useMovingNode` (hook) + `getMovingNode` (imperative);
  no useRef snapshot needed — the node is set once at `begin`, stable for the gesture.
- use-editor.tsx: drop the `movingNode` field + the `set({ movingNode })` writes.
  `setMovingNode` still drives the scope and still sets `movingNodeOrigin` /
  `placementDragMode`, so cross-store subscribers keep firing. Param + ~90 call
  sites unchanged.
- migrate ~17 reader sites to `useMovingNode()` / `getMovingNode()`; drop
  `movingNode` from lib/scene.ts; export `movingNodeOf`.

Every interaction flag is now derived from the single authoritative scope; only
`movingNodeOrigin` + `placementDragMode` intentionally remain as useEditor flags
(they outlive the scope / gate companion behavior).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:24:02 -04:00
Wassim SAMADandClaude Opus 4.8 6cfc6ae01b fix(export): stamp openable only when an open clip bakes
A door/window only carries extras.openable + extras.clips when an open
animation actually bakes. Cased openings (no leaf) and fixed windows (no
operable sash) are no longer mislabelled openable, so the GLB never claims
a part opens when nothing moves. /viewer is unaffected (it already required
openable && clips). Adds a regression test for the no-clip case.

(Export barrels reordered by the formatter.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:44:36 -04:00
Wassim SAMADandClaude Opus 4.8 353b429a01 perf(floorplan): override-driven 3D wall move + granular sibling invalidation
Two fixes for the split-view FPS cliff when moving a wall or opening:

- wall/move-tool: publish the live preview to useLiveNodeOverrides instead of
  writing useScene.updateNodes every frame. The store's `nodes` ref no longer
  churns per frame (which had re-rendered every useScene(s => s.nodes) subscriber
  app-wide). Matches the existing 2D wall drag + 3D wall-system override pattern;
  the final plan still commits atomically as one undoable change.

- floorplan-registry-layer: replace the single global siblingEpoch with a
  per-node epoch bumped only for the nodes affected by the live drag (dragged
  wall -> walls at its old + new junctions + child openings; door/window -> host
  wall; gutter -> roof-peer gutters), unioned with the previous frame's live set
  so a cancelled drag reverts. Dragging one wall/opening now rebuilds a handful
  of geometries instead of all the level's walls + openings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:24:06 -04:00
Wassim SAMADandClaude Opus 4.8 0629c7d3e3 fix(export): snap levels to stacked positions before GLB export
exportSceneToGlb now calls snapLevelsToTruePositions() (the same clean stacked
presentation thumbnail capture uses) before snapshotting the scene, so the GLB
always reflects the stacked building regardless of the live levelMode
(exploded/solo) or an unsettled level lerp. Fixes baked GLBs where a level was
captured at a stray offset (e.g. ~ -100k Y), which inflated the bounding box and
made the model unframable / invisible in third-party glTF viewers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:06:20 -04:00
Wassim SAMADandClaude Opus 4.8 c8bd29e909 revert(viewer): drop distance-based LOD switching from GLB scene
Reverts the LOD switching (0469b256). Rendering lod1/lod2 on zoom-out produced
WebGPU validation errors (invalid bindGroup_object) on real-GPU browsers — not
caught earlier because headless Chromium falls back to the WebGL2 backend, so
the WebGPU path was never exercised. The viewer returns to loading the baked
KTX2 lod0 (still the 22MB->12MB win). LOD switching to revisit with a real-GPU
test loop and on-demand loading instead of preload-all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:38:40 -04:00
Wassim SAMADandClaude Opus 4.8 0469b25639 feat(viewer): distance-based LOD switching in GLB scene
GlbScene accepts an optional lodUrls list and swaps rendered detail by camera
distance to the building's world-space center: lod0 (interactive — identity,
selection, zones, clips all bind here) for normal + building-view distances,
simpler visual-only levels only past ~2.5R / ~8R. Single-URL callers are
unchanged. useGLTFKTX2 now accepts a URL array.

Manual visibility toggling rather than THREE.LOD: THREE.LOD measures distance
to its own origin (0,0,0), which mis-selected levels (blanking the scene) when
the baked geometry is offset from the origin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 13:10:01 -04:00
Wassim SAMADandClaude Opus 4.8 cfc1fb1672 refactor(editor): delete curving + endpoint reshaping flags
Migrate `curvingWall`, `curvingFence`, `movingWallEndpoint`, `movingFenceEndpoint`
(and their setters) off `useEditor` onto the authoritative interaction scope.
The `reshaping` scope variant gains an `endpoint` discriminator; existence
checks read `useIsCurveReshape()` / `useEndpointReshape()`, and the few sites
that need the node (affordance-tool mounts, wall-vs-fence type checks) read it
from `useReshapingNode()` — a frozen drag-start snapshot, mirroring the old
flags so the tools' own per-frame writes don't feed back. `MovingWallEndpoint`
/ `MovingFenceEndpoint` move to the kind-owned tools that consume them.

`editor-api` is simpler: endpoint engagement is kind-agnostic, and the
`engageMove` reshape clears are gone (the scope is single-owner).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:18:50 -04:00
Wassim SAMADandClaude Opus 4.8 2f2c3ef8f9 refactor(editor): delete editingHole + activeHandleDrag legacy flags
Migrate the two pure-mirror interaction flags off `useEditor` onto the
authoritative `useInteractionScope`. Both carried payloads byte-identical to
the scope union, so this is a zero-behaviour-change refactor: readers use the
reference-stable `useEditingHole()` / `useActiveHandleDrag()` hooks (or the
`getEditingHole()` imperative read), and producers drive the scope directly
with guarded `endIf` so clearing one interaction never stomps an unrelated
scope. Adds the `holeEditScope` builder + a no-leaked-flag invariant test.

Closes the first slice of the legacy-flag deletion; the rich-payload flags
(movingNode, curving*, *Endpoint, placementDragMode) remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:30:16 -04:00
Wassim SAMADandClaude Opus 4.8 064be98d7f feat(export): reusable exportSceneToGlb + BakeExporter for headless bake
Extract the GLB build (prepare + GLTFExporter + WebGPUTextureUtils) out of
ExportManager into exportSceneToGlb so it can run outside the editor download
flow. Add a BakeExporter R3F component that fires the export once a host viewer
signals scene-ready and hands back the ArrayBuffer. Both exported for the
headless bake route (baked-glb-export phase 3, headless de-risk).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:52:14 -04:00
Wassim SAMADandClaude Opus 4.8 0a7cbd2081 feat(viewer): GLB-consuming /viewer path (baked-glb-export phase 2)
Add `GlbScene` — the viewer that renders a baked GLB artifact with no
parametric scene graph, and finish the phase-1 export contract it consumes.

Viewer (packages/viewer):
- GlbScene: loads the artifact, drives a building → level → zone → node drill
  hierarchy on useViewer.selection. Room/zone picking resolves from the floor
  plane (ray ∩ floor + point-in-polygon), node picking uses a footprint test,
  so walls/ceilings/zone-helpers never skew or block selection. Level modes
  (stacked/exploded/solo), dollhouse (hide a focused floor's ceilings + roof so
  rooms are visible and pickable), reconstructed zone floor fills + gradient
  edge borders + room labels (faded, hover-brightened), baked door/window
  open clips, click-outside / empty-space deselect, and the shared outline
  post-FX. Exported as GlbScene/GlbLevel/GlbIdentity/GlbHover.
- post-processing: composite the zone-pass tint into the base scene so rooms
  show whether or not SSGI is enabled (previously only added in the SSGI branch).

Export contract (packages/editor/src/lib/glb-export.ts):
- Convert WebGPU NodeMaterials to classic glTF-standard materials; decompress
  KTX2 maps via WebGPUTextureUtils so GLTFExporter can embed them.
- Stamp name + extras identity (pascalId/kind/label/openable/clips), bake
  door/window open clips (loop:false), strip editor overlays (off-layer +
  invisible-material hitboxes) so cutouts aren't plugged.
- Fix opaque-but-flagged-transparent materials (no spurious alphaMode=BLEND)
  and BackSide → FrontSide + winding flip (glTF has no back-face-only).
- Force levels + zones visible and stamp level display names + zone polygons so
  every floor bakes and /viewer can reconstruct rooms and label the breadcrumb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:14:39 -04:00
Wassim SAMADandClaude Opus 4.8 f773e6b8c5 feat(editor): placement & interaction overhaul — FSM spine, bug tracks, perf
Implements plans/editor-placement-interaction-overhaul.md: an authoritative
interaction-scope state machine plus the catalogued placement/interaction
fixes, and split-view floor-plan performance.

- Interaction-scope spine (lib/interaction/* + store/use-interaction-scope),
  driven from central useEditor setters; overlay scoping (zone labels,
  context badges, floating action menu) reads resolveOverlayPolicy.
- Bug tracks A/B/D/E/F/G/H: handle/cutout raycast, footprint validity,
  auto-slab loop, ceiling hosting, B-key tool desync, 2D drop offset,
  per-frame jank.
- Snapping modes (grid/lines/angles/off) + contextual HUD chips; modifier
  model (Shift=cycle, Alt=free place, Ctrl=grid step).
- Item move now tracks the cursor 1:1 (was a laggy per-frame lerp); handle
  rig hides during a whole-node move; rotate gizmo advertises Shift=free
  rotation in the HUD and hides the move cross while rotating.
- Floor-plan perf: pause live reactivity while in 3D-only view; per-node
  geometry cache so only changed nodes rebuild on a drag; hoist wall miters
  to a once-per-pass ctx.levelData (O(N^2) -> O(N) on wall/opening drags).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:04:58 -04:00
Wassim SAMADandClaude Opus 4.8 06e4cd7748 feat(export): baked GLB export with identity, clips, cutout fix (phases 0-1)
Promote the client GLB export into the baked-artifact format from
plans/editor-baked-glb-export.md (phases 0 and 1).

- NodeMaterial -> classic MeshStandardMaterial conversion at export. The
  viewer's MeshStandard/LambertNodeMaterial set isNodeMaterial, not
  isMeshStandardMaterial, so GLTFExporter would otherwise drop every
  surface to a blank default. KTX2 (compressed) maps are decompressed via
  WebGPUTextureUtils so the exporter can embed them (PNG for now; KTX2
  re-encode is deferred to the phase-3 bake worker).
- Identity stamping from sceneRegistry: node.name = pascalId and
  extras = { pascalId, kind, label?, openable?, clips? }; all other
  userData stripped so editor/runtime ephemera never reach glTF extras.
- Door/window open clips baked from the build-once + pose-at-t primitives
  (door pascalSwingLeaf marker, window poseWindowMovingParts). Clips named
  by label ("Door 1: open"), carry extras.loop = false (consumers play
  once and hold; dumb glTF players still loop).
- Cutout fix: door/window selection hitboxes hide via material.visible,
  which onlyVisible misses, so the hitbox box plugged the wall opening.
  Non-renderable container meshes now keep their node but lose geometry;
  childless ones are removed.
- Editor-overlay stripping mirrors the thumbnail capture: emit
  thumbnail:before/after-capture so scene-layer affordances (handles,
  ceiling/site brackets) self-hide, and drop anything off SCENE_LAYER
  (gizmos, grid, zone fills).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:55:27 -04:00
Wassim SAMADandGitHub ae90da6554 Merge pull request #437 from pascalorg/feat/resize-sfx
editor: add resize SFX while dragging resize handles
2026-06-22 11:16:06 -04:00
Wassim SAMADandClaude Opus 4.8 9bfbcc9f01 editor: add resize SFX triggered while dragging resize handles
Register a `resize` sound effect (three round-robin variations with
pitch/pan jitter and an 80ms gap) and emit it from the linear/radial
resize drag handler whenever the snapped+clamped value steps, so width /
length / depth / radius / height drags tick as the size changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 11:09:02 -04:00
Wassim SAMADandGitHub b2f1a8432e Merge pull request #436 from pascalorg/fix/wall-selection-keeps-material
fix(selection): selected objects keep their texture (light glow, no wash)
2026-06-22 08:34:47 -04:00
Wassim SAMADandClaude Opus 4.8 8633886eef fix(selection): selected objects keep their texture (light glow, no wash)
Selecting a textured wall/slab/item replaced its surface with a flat
purple wash. Root cause: the selection highlight clones the material and
tints it, but `NodeMaterial.clone()` on the WebGPU backend drops the
texture-map node assignments, so the clone rendered flat — and a strong
albedo blend + emissive washed whatever was left.

Fix: re-attach the maps from the source material after cloning (shared by
reference) and drop the albedo tint, keeping only a gentle indigo emissive
so the real material/texture stays readable with a soft "selected" glow.
Applied to both highlight paths:
- generic editor highlight (slabs/items) in selection-manager
- wall path (walls are excluded from the generic one), built lazily +
  cached/self-healing so it survives the wall finish's async texture load

Removes the now-dead eager wall `highlightedVisible`/`selection` profile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:17:41 -04:00
Wassim SAMADandGitHub f2352616d9 Merge pull request #433 from pascalorg/chore/editor-polish
Editor polish: paint SFX, duplicate keeps slots, review-skill convention
2026-06-19 13:11:24 -04:00
Wassim SAMADandClaude Opus 4.8 08df21a96d docs(review-skill): require slots + world-scale UV convention for new nodes
Adds a Section B rule to the review-architecture skill: a new node kind or
geometry change that exposes paintable parts must carry overrides in a
schema `slots` record resolved via def.capabilities.paint (and thread it
through clone/duplicate/preset paths), and texturable def.geometry must
emit UVs in metres (1 unit = 1 m) so catalog finishes tile. Surfaced in the
skill description too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:04:42 -04:00
Wassim SAMADandGitHub affe09d3b1 Merge pull request #432 from pascalorg/feat/fence-creation
feat(fence): horizontal-board fence style with capped posts
2026-06-19 13:03:23 -04:00
Wassim SAMADandClaude Opus 4.8 346b5d8cbe feat(fence): add horizontal-board fence style with capped posts
Adds a `horizontal` fence style that builds composite-cladding panels —
horizontal boards stacked between square intermediate posts — instead of
the vertical pickets that slat/rail/privacy all produce today.

- New `style: 'horizontal'` (core schema + inspector Style control).
- Posts march along the whole span at `postSpacing`, each with a cap.
- New `postCap` control (none/flat/pyramid, default pyramid); pyramid
  caps render as a 4-sided cone fence part.
- New `slatGap` control for the board reveal — 0 collapses to one flush
  panel so the stacked-board seams don't read as lines.
- `postCap` + `slatGap` are horizontal-only (visibleIf); postCap uses a
  labelled dropdown so its options aren't a context-free switch.
- Lower the `postSpacing` inspector minimum from 0.2 to 0.05 m.
- 2D floor plan reuses the solid-panel marker for horizontal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:57:24 -04:00
Wassim SAMADandClaude Opus 4.8 115a142d83 fix(item): duplicated items keep their painted slot materials
Duplicating an item drops to the catalog placement flow, which rebuilds the
draft from the asset + transform and never carried node.slots — so the copy
lost every painted slot override. Thread slots through the draft create path:
useDraftNode.create seeds it onto the draft and commit() forwards it to the
final node, the placement coordinator passes it to its lazy wall/ceiling
draft creates, and the item move tool supplies node.slots for both the floor
(direct create) and wall/ceiling (coordinator) duplicate paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:17:30 -04:00
Wassim SAMADandClaude Opus 4.8 bf9246cd36 feat(sfx): play paint_apply sound when a material is painted
Registers a paintApply SFX (with rate/volume jitter + a 60ms gap so rapid
multi-face painting doesn't machine-gun), wires the sfx:paint-apply bus
event, and emits it from the material-paint click chokepoint in the
selection manager (fires on apply, not hover/preview). Adds the audio
asset to the editor app's public resources.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:50:48 -04:00
Wassim SAMADandGitHub ee4d6de206 Merge pull request #431 from pascalorg/docs/item-authoring-guide
docs: item-authoring guide for paintable item GLBs
2026-06-19 09:08:09 -04:00
Wassim SAMADandClaude Opus 4.8 c726721ec6 docs(architecture): add item-authoring guide for paintable GLBs
Documents the content-author contract for catalog item GLBs: slot_
material naming + deriveSlotId rules, authored defaults and
pascal_material extras, the cutout reserved mesh, the ~1 UV unit/m world
scale, vertex colours for fixed detail, and the validated Blender + glTF
export recipe (Custom Properties export gotcha, gltfpack -ke extras
preservation). Indexes the page in the architecture README and links it
from materials-and-themes.md (previously a dangling "phase-6 Blender
recipe" reference).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:04:42 -04:00
Wassim SAMADandGitHub 36ad0f9933 Merge pull request #430 from pascalorg/chore/pull-authored-item-glbs
chore(items): pull authored item GLBs from prod
2026-06-18 16:40:18 -04:00
Wassim SAMADandClaude Opus 4.8 bfcb039757 chore(items): pull authored item GLBs from prod
Replace 89 bundled item model.glb files with the authored/optimized
versions currently served from prod storage (items/system/<slug>/model.glb),
so the committed source of truth matches what live projects load and a
catalog re-seed no longer clobbers authored geometry. Items with no prod
object (404) are left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:36:54 -04:00
Wassim SAMADandGitHub 628bd7f5c6 Merge pull request #429 from pascalorg/feat/paint-panel-polish
Paint panel polish: sticky controls, selection outlines, category auto-select
2026-06-18 12:53:28 -04:00
Wassim SAMADandClaude Opus 4.8 1dcaa73717 refactor(paint-slots): paint panel — three fixed bands, scroll only the grid
Layout is now: fixed eraser/reset + category tabs (no longer sticky-inside-
scroll), a single scrolling catalog grid, and an always-visible scene-material
footer. The custom-material "+" moved out of the colors-only grid cell onto the
scene-material section header, so a custom material can be added from any
category (creates a blank scene material, selects it as the brush, opens its
inline editor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:50:47 -04:00
Wassim SAMADandClaude Opus 4.8 8badd415b9 feat(paint-slots): paint panel polish — sticky controls, selection outlines, auto-select
- MaterialPaintPanel owns its scroll: the eraser/reset row stays pinned and
  the category tabs stick to the top, so only the material list scrolls.
- Selected catalog swatch + active scene-material card use the same
  `ring-1 ring-primary ring-inset` outline as item/preset tiles.
- Choosing a material category auto-selects its first material.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:45:45 -04:00