- fence: split into two paint slots — panel (posts/base/infill, default charcoal)
and rail (cap, default wood-finewood27) — as separate meshes with userData.slotId.
Fix applyFenceUVs to continuous world-space 1 UV unit = 1 m (drop the per-part
min origin that broke tiling across parts). New generateFenceSlotGeometries.
- roof: real catalog defaults for the segment surfaces via getRoofMaterialArray
(the actual default path): wall/trim concrete-plate (matches walls), deck +
soffit soft-white, shingle terracotta; textures-off role escape hatch kept.
Align nodes getRoofMaterials no-parent fallback to match.
- stair: body slot default -> preset-lightgrey.
- (biome formatting normalization of the round-1 merged renderer files rides along.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Elevator exposes four finish slots — cab / doors / shaft / glass (glass only
when shaft or door style is glass) — on node.slots. The finish materials now
resolve through a per-node material set (context provider) keyed on node.slots
+ scene materials: textures-off joinery role -> node.slots ref -> declared
default (cab preset-softwhite, doors metal-steel, shaft preset-lightgrey,
glass preset-glass), with glass transparency flags re-applied. Cab/door/shaft/
glass meshes tag userData.slotId; buttons, indicators, control panels, and
queue strips stay untagged (functional UI, not paintable). Interaction system
untouched. Monochrome unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Column exposes shaft / base / capital / frame slots on node.slots (base when
baseStyle!=none, capital when capitalStyle!=none, frame only for non-vertical
support styles). The single material context is widened to a per-slot map plus
a ColumnSlotContext; each part subtree is wrapped in <ColumnSlot> so every
mesh primitive resolves its material AND stamps userData.slotId from the same
context. Decorative shaft parts inherit shaft, base/capital carvings inherit
their parent slot. Resolution: textures-off wall role -> node.slots ref ->
legacy material/preset -> declared default (shaft/base/capital concrete-plaster,
frame metal-steel). Monochrome unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stair exposes three paintable slots — treads / body / railing (railing only
when railingMode != none) — on node.slots. The 2-material-index body mesh
maps materialIndex 0->treads, 1->body via userData.slotIds; railing meshes
tag userData.slotId='railing'. Per-slot resolution layers over the viewer's
base body/railing materials only in textures-on mode: node.slots ref ->
legacy per-part field (preserved) -> declared default (treads wood-woodplank48,
body wood-woodfine2, railing metal-steel). A custom preview swaps the targeted
body material-array index and whole-mesh railing materials. Monochrome
unchanged. Stops using DEFAULT_STAIR_MATERIAL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fence joins the node.slots paint model with one paintable 'surface' slot
(merged post/base/rail geometry is a single mesh). Resolution order matches
slab: textures-off joinery role -> node.slots.surface ref -> legacy inline
material/preset -> declared default (library:wood-finewood27). Tags the mesh
userData.slotId='surface' and wires capabilities.slots + paint. Stops
borrowing DEFAULT_STAIR_MATERIAL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the flat #e5e5e5 placeholder on the roof shingle surface (material
index 3) with the catalog roof-weatheredshingles finish via
resolveSlotDefaultMaterial. Wall/trim, deck, and interior surfaces and the
textures-off role array are unchanged. Roof keeps its existing per-segment
role material system (no node.slots migration).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds on the explicit per-mesh slot tagging (currentDoorSlot/currentWindowSlot):
- Per-part painting: door = panel/frame/glass/hardware, window = frame/glass,
each independently paintable. The recessed door/window body sits behind the
wall, so the proud invisible cutout wins the scene raycast over the wall and
the shared resolveSlotByReRaycast() re-raycasts the kind's own subtree to pick
the exact part under the cursor (panel↔frame↔glass↔hardware). Hover tracks the
cursor via a re-eval (idempotent, no flicker).
- Door frame is its own slot (separate frameMaterial); hardware = new flat
'metal-chrome'.
- Library defaults (generic): panel/frame -> library:preset-softwhite, glass ->
library:preset-glass (flipped preset-glass to FrontSide — DoubleSide poisons
the WebGPU MRT pass; it's the only glass we use).
- Catalog: add flat (non-PBR) 'metal-chrome' + 'metal-brass'; drop metal
metalness 1 -> 0.6 so metals are lit by existing lights (no env needed).
- World-scale UVs (1 unit = 1m) on door/window box meshes via shared box-uv.ts,
so finishes tile at real-world scale instead of stretching.
- PaintResolveArgs gains an optional for subtree re-raycasting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Windows and doors build all visuals in their viewer systems from module-global
materials, so this threads per-node slot materials + userData.slotId tags
through those builders without restructuring them:
- window: 'frame' + 'glass' slots. door: 'panel' (body = casing + leaf) +
'glass'; the opening reveal keeps its own material.
- Each system captures per-frame viewer state, then updateWindow/DoorMesh points
the builder-facing base/glass materials at the node's resolved slot override
(recomputed per node, so the next node resets without a restore). Meshes are
auto-tagged in the shared addBox/addShape helpers by which material they got.
- Textures-off still collapses to the role material (escape hatch); a slot
override only applies in colored mode.
- Editing a referenced scene material re-dirties the window/door (these systems
aren't covered by GeometrySystem's scene-material re-dirty).
- New paint capabilities (resolve role from userData.slotId, preview by
userData.slotId) + capabilities.slots; window/door dropped from the paint
disabled list. Shared previewSlotByUserData helper.
Defaults unchanged: unpainted windows/doors render exactly as before (the slot
fallback is the existing frame/glass material), so no visual regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings slab, ceiling, and wall onto the unified slot contract the shelf
established, so each declares its paintable slots with a declarative default and
(slab/ceiling) is painted through the registry capabilities.paint dispatch.
- Shared helper packages/nodes/src/shared/slot-paint.ts: a node.slots-based
PaintCapability factory (commit/resolve/effective generic; preview injected).
Distinct from surface-paint.ts, which writes the legacy inline node.material.
- slab: schema slots; def.geometry resolves node.slots.surface -> legacy
material -> declared default, tags the mesh userData.slotId; slabPaint +
capabilities.slots. Retires DEFAULT_SLAB_MATERIAL in the slab path.
- ceiling: schema slots; material builders extracted to ceiling/materials.ts
(shared by renderer + paint preview, built BackSide so the hover preview is
visible from below); renderer resolves the slot; ceilingPaint + slots.
- wall: WALL_SLOT_DEFAULT in core; the viewer's getMaterialsForWall renders an
unpainted face with its declared default instead of the themed wall role;
capabilities.slots (interior/exterior). wallPaint's inline interior/exterior
fields are unchanged (node.slots migration is a later step).
- selection-manager + material-paint: drop slab/ceiling from the legacy
single-surface arms (now registry-driven).
Behavior change (intended, matches the shelf precedent + the phase-5 plan):
colored-mode UNPAINTED slab/ceiling/wall surfaces now render their fixed slot
default (#e5e5e5 / #f5f5dc / #ffffff) instead of the theme role colour. The
textures-off (monochrome) role collapse is unchanged — the escape hatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Cubby without a bottom board: the lowest cell opens onto the floor, but its
column divider still started at the (missing) bottom board's top, leaving it
floating ~thickness above the floor. Anchor it at y=0 when there's no bottom.
- Bookshelf full-height divider: it crosses the continuous shelves, so it can't
share their depth plane (proud at the front, coplanar with the back panel —
z-fighting down the centre back). Recess its depth to sit fully INSIDE the
boards' depth: embedded at each shelf crossing (board occludes it) and tucked
inside the back panel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cubby column divider was full-depth while the boards are depth-recessed,
so it (a) sat proud of the shelf fronts — the earlier Y-tuck then made it poke
past the top/bottom boards — and (b) shared the back panel's rear plane,
z-fighting down the centre of the back.
Give the divider the same depth recess as the boards (via boardGeometry) and
drop the Y-tuck: it now sits flush with the shelf fronts and its back tucks
inside the back panel. Flush top/bottom is fine — those board faces are
back-to-back, not co-facing, so they don't fight.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cubby per-cell dividers landed exactly flush on the board below and under the
board above — coplanar faces that shimmer and read as merged into the shelf.
Extend each divider 1mm into both boards (centre unchanged) so it tucks under
the top board and onto the bottom board with a solid, seam-free join.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two follow-ups to the plate-recess fix:
- Top surface: full-height frame members that pass UNDER the top board
(back panel, bookshelf column dividers, corner posts) had their top face at
unitHeight — coplanar with the top board's top — so they z-fought through it
(the seam on the top). Drop their top 1mm (cappedFrameY); bottoms stay on the
floor so nothing floats.
- Plate width: the previous pass recessed every board's width, which opened a
1mm gap where boards ABUT the side panels (cubby / bookshelf-with-sides).
Width is now recessed only where boards span OVER posts (open-rack /
no-sides bookshelf); abutting boards keep full width and meet the sides
flush. Depth stays recessed everywhere (gap-free, fixes the back-panel fight).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Open-rack / no-sides shelves span their boards across the full footprint, so a
board's end / front / back faces land exactly on the corner posts' outer faces
(and, with a back panel, on its rear face). Coplanar surfaces the depth buffer
can't order flicker as z-fighting (visible as seams where plates meet posts).
Recess every board 1mm on width + depth via a `boardGeometry` helper so each
plate sits just inside the frame — the meshes still overlap (no visible gap),
but no two faces are coplanar. Frame parts (posts/sides/back/dividers/brackets)
keep their full size as the silhouette.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Proves the unified (nodeId, slotId) slot contract on a procedural generator,
beyond items and walls. A shelf now exposes three paintable slots — shelves /
frame / back — painted through the same PaintCapability dispatch and the same
node.slots: Record<slotId, MaterialRef> shape items use.
Foundation (shared, reusable by future procedural kinds):
- core: SlotDeclaration type + capabilities.slots(node) registry declaration;
GeometryContext gains `materials` so a pure builder can resolve scene:<id>
slot refs without importing useScene.
- viewer GeometrySystem: threads the scene material library into every builder
ctx, and re-dirties (bypassing the geometryKey skip) any geometry node that
references a scene material when that material changes — so editing a custom
colour propagates to every shelf using it, matching items.
Shelf:
- schema: slots: Record<string, MaterialRef> (mirrors ItemNode).
- geometry: per-slot material resolution (slot override -> legacy whole-shelf
-> declared default colour); every mesh stamped with userData.slotId;
DEFAULT_SHELF_MATERIAL retired (declared default gives identical off-white).
- paint.ts: PaintCapability (resolveRole from userData.slotId, scene-material
commit for one-off colours, preview restricted to __fromGeometry meshes so
hosted items aren't ghosted, getEffectiveMaterial incl. legacy fallback).
- definition: paint + slots capabilities; slots folded into geometryKey.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In colored mode (textures on), every item now renders its authored material
— textures, vertex colours, and default colours — instead of being stripped
to the uniform off-white baseMaterial. AI-generated and vertex-coloured items
show their real appearance; slot resolution (override -> curated -> authored)
is unchanged. Monochrome (textures off) still collapses to the furnishing
clay role colour (the escape hatch). Drops the now-unused isAuthored marker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Insert the plan's middle resolution step for item slots: a curated
default baked into the GLB as a per-material 'pascal_material' extra
(three's GLTFLoader copies material extras to userData). Resolution per
slot is now: node.slots[slotId] override -> pascal_material curated
default -> authored GLB material. Bare ids resolve as library:<id>;
unresolved refs fall through. Runtime reads only the GLB (no DB).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: sync bun.lock with 0.9.1 workspace versions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(viewer): stop interactive overlays from starving frames and flickering the UI
Every interactive item mounted a drei <Html occlude> overlay
unconditionally — invisible (opacity 0) when no zone was selected, but
still alive. With `occlude` as a bare boolean, drei raycasts the entire
scene per overlay on every camera-move frame, and rewrites each
element's z-index while toggling display when the occlusion flips. On
scenes with hundreds of interactive items (recessed lights, ceiling
fans) this starved the frame budget and made the whole DOM UI blink
during camera moves while the WebGPU canvas stayed healthy.
Overlays now mount only while a zone is selected and the item sits
inside its polygon, fade in/out over 300ms (the child components stay
rendered so the exit transition can play before the <Html> unmounts),
and drop `occlude` entirely. eps=-1 works around a drei mount bug: its
mount path writes the element transform without the distanceFactor
scale, and with a static camera the eps guard never re-applies it, so
freshly mounted overlays stayed mis-scaled until the camera moved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(viewer): break down dirty nodes by kind in the perf overlay
DIRTY now reads e.g. "29 (12 wall, 9 ceiling, 8 item)" — sorted by
count, only non-zero kinds, with a "missing" bucket for dirty ids whose
node no longer exists. Makes dirty-mark leaks attributable at a glance.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(core): skip dirty marks for kinds with no dirty consumer
dirtyNodes is consumed by GeometrySystem (def.geometry),
FloorElevationSystem (capabilities.floorPlaced), and the legacy
per-kind viewer systems. Site, building, level, zone, and guide match
none of those, so their marks were never cleared: they accumulated for
the whole session (every child create/delete dirties its parent),
permanently defeated every consumer's empty-set early exit each frame,
and polluted the perf overlay's DIRTY readout.
NodeDefinition gains an explicit dirtyTracking?: boolean opt-out
(default tracked — no derivable predicate exists since wall's dirty
consumption lives in the viewer while zone/guide/level declare
def.system for unrelated per-frame work). markDirty consults the
registry; the five structural kinds opt out.
Also fixes a second leak: deleteNodesAction never removed deleted ids
from the dirty set, and every consumer skips missing nodes without
clearing them, so marks on deleted nodes lived forever.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* style: replace concise-arrow forEach with for...of in deleteNodesAction
biome's useIterableCallbackReturn rejects forEach callbacks that
implicitly return a value (Set.add / clearDirty).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Dual review pass (Claude multi-angle + Codex release-quality). One
correctness fix and the agreed do-now cleanups:
- fix: clone-scene-graph remaps roofSegmentId like wallId in both
clone paths — duplicated scenes/levels kept pointing roof-hosted
children at the original segments.
- extract the settled, stateless roof target/cursor math shared by the
four door/window tools into shared/roof-wall-opening-placement.ts
(resolveRoofWallOpeningTarget + getRoofWallOpeningCursorPose +
worldToSelectedBuildingLocal); tools keep the stateful lifecycle
(drafts, undo/temporal, commit field lists). −199 net lines.
- rename host-generic state: currentWallId→currentHostId,
markWallDirty→markHostDirty (they hold segment ids too); capability
cascadesViaHostSegment→dirtyHandledByOwnSystem (behavior-facing,
before the public API hardens).
- drop getRoofAccessoryKinds from core's public API — its only caller
was the standalone Build tab, which now enumerates the registry
inline with its app-specific filter.
- window move-tool uses the shared stripPlacementMetadataFlags; stale
"segment-local" comment fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wall-mounted items join doors/windows on roof-segment wall faces, and
the storage model moves to FACE-LOCAL coordinates so hosted children
track segment edits live.
- Children store roofFace + position [u, v, z-from-mid-plane] with
rotation 0 in-frame — the exact wall-child conventions (the wall
volume's mid-plane lands on the nominal footprint). A shared
<RoofFaceHostFrame> derives segment pose + face frame from the
live-override-merged segment: children follow resize handle drags in
real time and never jump on commit. No re-anchor cascade needed —
position is authoritative, the frame is derived. migrateNodes
converts branch-era segment-local data.
- Items: roofWallStrategy + roof:* handlers in the placement
coordinator (surface 'roof-wall'), Shift free-place normalized with
walls, ItemSystem wall-side push extended to segment hosts, correct
2D plan glyphs via face→segment→roof pose composition. The roof
hit resolver + overlap guard moved to @pascal-app/editor (the
coordinator lives there; nodes already depends on editor).
- Cuts: subtractAccessoryCuts extracted and applied in BOTH the
merged-shell and per-segment CSG paths (full edit mode / painted
segments used to lose every hole), built from the CURRENT host
geometry and live-effective children so holes follow segment and
opening drags.
- Handle rig: the grandparent portal now maps the node's world pose
into the portal frame instead of composing parent+node registry
poses — correct for any nesting (the face-frame group broke the
old assumption), identical for walls.
- Host-field hygiene: useDraftNode.commit/adopt and the window panel
duplicate forward roofSegmentId/roofFace/wallId; every roof↔wall
re-anchor clears and every revert restores them.
Codex-reviewed (design consultation, adversarial rounds on the
replaced cascade and on this refactor); frame conventions locked by
unit tests. Record: private-editor plans/editor-roof-wall-openings.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: zone grid snapping, shelf position panel, deselect on item placement
- Zone tool reads the editor's gridSnapStep (0.5/0.25/0.1/0.05) instead
of a hardcoded 0.5 for both cursor move and click snapping.
- Shelf inspector gains a Position group (vec3 X/Y/Z sliders), matching
the item panel.
- Item catalog clears the viewer selection before arming placement so
shortcuts (rotate & co) don't hit both the ghost and the selected node.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor: move level display naming into @pascal-app/core
getDefaultLevelName / getLevelDisplayName ("Ground Floor" / "Floor N" /
"Basement N") lived in packages/editor's internal lib, so viewer-only
surfaces couldn't reach them and fell back to hand-rolled "Level N"
labels. The helpers are pure domain logic, so they move to core and
export from its barrel; the editor package's seven call sites now import
them from there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Openings now host on the walls a roof segment generates — the base
walls under the roof and the coplanar gable/shed/gambrel end faces, so
a window can sit in a gable pediment.
- core: roof-segment-walls.ts models the four vertical faces as 2D
frames (u along face, v height) with convex profile polygons that
mirror the wall volume getRoofSegmentBrushes builds; rect-in-profile
clamping and anchored resize limits via half-plane algebra.
- schemas: optional roofSegmentId on door/window; position is the
segment-local wall mid-plane center, rotation[1] the face yaw.
- cut: reuses capabilities.roofAccessory.buildCut; new cutScope: 'wall'
subtracts from the wall brush only. cascadesViaHostSegment keeps the
roof-merge loop from consuming door/window dirty marks (their own
systems cascade via parentId).
- tools: roof:* handlers in door/window tool + move-tool (the Build-tab
preset path), with roofSegmentId cleared/restored across every
roof<->wall re-anchor and revert; shared hit resolver normalizes
normals through world space (merged mesh vs painted segment frames).
- fix: RoofSystem no longer rebuilds per-segment CSG in accessory-reveal
mode — the uncut rebuild used to draw over the merged shell's fresh
opening until deselect.
- fix: the walkthrough collider world now prunes by renderer-effective
visibility; stale uncut segment CSG inside the hidden segments-wrapper
blocked the player at openings the merged shell had cut through.
Known gap: painted segments render per-segment CSG without accessory
cuts (pre-existing, also affects skylight/dormer). Twice Codex-reviewed;
details in private-editor plans/editor-roof-wall-openings.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Allow template forks to preserve scans
* Add room preset save trigger
* Show room preset action for selected zones
* Use standard inspector for selected zones
* Support room preset editor workflows
* Preview room clear-underneath items
* Add roof surface placement support for items
Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.
- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fixed conflict
* Snapshot: ridge vent locks to segment ridge during placement, free sliders after
Placement tool clamps the cursor to the segment's ridge line for all roof
types (gable, hip, shed, gambrel, dutch, mansard; flat rejected). Renderer
re-derives Y from the live surface plus a small lift, and treats stored
position[1] / position[2] as user offsets so the inspector sliders move
the vent after placement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(roof): contribute outer silhouette to alignment-guide candidates
Roof has no centred-box footprint (it's the union of its `roof-segment`
children) so the capability bridge needs the resolved AABB directly. We
build it from the children's corners in roof-local space, then transform
to world coords. Roofs only contribute as static candidates — the
move-roof tool drives them by origin, so the relocatable-box path never
applies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(floorplan): upright text geometry that stays horizontal under scene rotation
Floor-plan text labels were rotating with the 90° default scene rotation,
making zone names read sideways. Add an `upright` flag to text geometry:
when set, the registry layer counter-rotates the label by sceneRotationDeg
around its anchor so it reads horizontally on screen. Zone labels opt in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): redesign material-paint cursor badge and gate floor-plan paint mode
Paint cursor swaps the inline icon+label chip for a stacked badge — a
glowing accent stem pointing down to the hit point with the paint icon
above — so the cue reads as "pointer" rather than "tooltip". Switches
the accent to indigo. The 2D floor-plan now also shows the paint-icon
overlay and routes pointer interactions to the painter when in
material-paint mode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): 90° rotate action on the floating building menu
Adds a Rotate button to the building action menu (and a generic `onRotate`
slot to NodeActionMenu). Each click rotates the building 90° CW around
its world-bbox center, mirroring the offset compensation
`MoveBuildingContent` uses during R/T-rotate drags — so the building
spins in place instead of orbiting its (often off-centre) origin.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(column,shelf): show cursor sphere during placement preview
Matches the placement affordance other floor-placed tools already have
so users see the snap point as a discrete dot alongside the ghost mesh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(ifc-converter): update next-env types path to .next/dev/types
Next.js moved generated route types under `.next/dev/types/` — regenerate
the reference so the type-check resolves them again.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ridge-vent): track parent segment's live overrides for real-time follow
While dragging a roof-segment resize handle (width / depth / pitch /
wallHeight / rotation), the new dimensions stream through
useLiveNodeOverrides and only flush to the store on release — so the
ridge vent was snapping to the new ridge only after the drag ended.
Merge segment overrides into the renderer's segment view so the vent
rides the resize in real time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): step building rotation in 15° increments
Cuts the floating building menu's rotate-step from 90° to 15° so each
click nudges the building rather than slamming it to the next cardinal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): world-frame alignment guides and grid snap under building rotation
Brings the 2D floor-plan alignment + grid behaviour in line with 3D so
guides and snap follow the world XZ grid regardless of how the active
building is rotated.
Core: adds resolveAlignmentInBuildingWorld + snapWorldXZToBuildingLocal,
plus a shared BuildingPose type, so callers can resolve in world space
while staying in building-local coords downstream.
Editor: introduces packages/editor/src/lib/world-grid-snap.ts as the
single entry point for tools that need world-frame alignment / grid
snap (resolveAlignmentForActiveBuilding, getActiveBuildingPose,
snapWorldXZForActiveBuilding, snapBuildingLocalToWorldGrid). Honours
useLiveTransforms and resolves the active building via level.parentId
first so the helper stays in sync with the floor-plan panel.
Alignment store unification: applyFloorplanAlignment and the registry
move overlay now publish guides in world XZ — the same frame the 3D
tools already use — so the shared useAlignmentGuides store carries one
consistent coordinate system. The 2D layer renders world XZ via the
fixed world → SVG transform (a 90° rotation around the building's
world position; independent of building rotation), so guides come out
parallel to the world-axis-aligned floor-plan grid in every case.
Drafting + move sessions across walls, fences, slabs, ceilings,
columns, items, shelves, roofs, stairs, zones and elevators are
re-routed through the new helpers so cursor placement, grid snap,
draft endpoints, and move commits all land on the world XZ grid
even when the building has been rotated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* revert(editor): drop building drag-rotate and grid building-XZ chase
Two behaviours added recently were causing the world grid to slide
around during building edits. Reverts both, keeping the rest of the
world-frame alignment work intact.
- floating-building-action-menu / node-action-menu: roll the rotate
button back to a single click that increments rotation by 15°
around the bbox center (state at 3eb02fde). The drag-rotate from
b50c0d5a streamed live position updates per frame, which the
floor plan and grid both followed.
- grid: stop chasing the active building's world XZ each frame
(pre-c00a2469 behaviour). The grid stays anchored at world (0, 0)
so it doesn't visibly sweep when the building origin moves —
during a drag, an R/T rotation in the move tool, or a click
rotate that re-anchors the origin to the bbox-pivot. Y still
lerps to the active level's floor height.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): consistent building rotation pivot + 2D position sync
Several connected fixes to building rotate/move so the 3D mesh, the
2D floor plan, and alignment guides all stay visually consistent
across rotations and drags.
Rotation-invariant pivot
- New `getBuildingLocalBboxCenter` helper walks descendant mesh
geometry bounds in the building's LOCAL frame, returning a
centroid that doesn't depend on the building's current world
rotation. The previous approach derived the pivot from the world
AABB at the current rotation, which made the local pivot subtly
drift each time the building was rotated.
- Floating menu click-rotate and move-tool R/T rotation now seed
their pivot from this helper, so successive rotations stay
pinned at the same world point.
Move-tool stability
- R/T rotation always pivots around the current world bbox center,
even before the cursor has moved onto the grid (previously it
fell back to rotating around the origin in that case).
- Commit (`onGridClick`) now writes the mesh's actual on-screen
pose instead of recomputing it from the click event's grid
coords, removing a one-cell snap on release.
Alignment resolver
- Tie-break is now perp-first, primary-second. The previous order
was reliable when wall faces shared an exact axis-aligned coord
(pre-rotation), but post-rotation float deltas meant primary
never tied and the resolver could lock onto the far corner of a
candidate. Perp-first picks the visually closest point.
2D ↔ 3D sync
- The floor-plan SVG scene group now applies a live `translate(...)`
derived from `(committedBuildingPosition → live buildingPosition)`,
in addition to the existing live rotation. Building-local content
slides in lockstep with the 3D mesh during a drag.
- The alignment-guide layer projects from world XZ using the
COMMITTED building position, so guides stay locked to their world
coordinates while the building slides past them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Merge remote-tracking branch 'origin/main' into fix/fir-5-june
* feat(wall): alt-click commits a single wall instead of chaining
Holding Alt on the click that completes a segment now stops drafting
instead of starting a new segment from the just-placed endpoint. Same
behaviour wired in both the 3D wall tool and the 2D floor-plan
placement hook (which already passed `singleWall: event.altKey` —
the 2D handler was ignoring the flag).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* revert(editor): drop 15° rotate from floating building action menu
Removes the rotate button on the floating building menu and the
`getBuildingLocalBboxCenter` pivot helper that only existed to support
it. The selected-group rotate handle remains the way to rotate a
building.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(ifc-converter): point next-env at .next/types instead of .next/dev/types
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): unify 3D move grip with floating-menu Move via tap-to-engage
The translate gizmo on the 3D bounding box now hands the node to the
registry move tool (same path as the floating action menu's Move
button) instead of running its own plane-drag. One move flow — green
bounding box, cursor follow, alignment guides, R/T rotation,
click-to-commit — drives both entry points.
Adds a `dragBounds` capability on the node registry so kinds whose
rendered mesh contains extras (per-level landing assemblies on an
elevator, etc.) can declare the box the user thinks of as "the thing
being dragged." Elevator and stair opt in; everything else still
auto-measures. `MoveRoofTool` now shows the green box for whole-stair
and whole-roof moves, and aligns roofs by their footprint corners
(previously only stairs did). Slab floor-plan moves clear
`autoFromWalls` on commit so space-detection doesn't snap the slab
back, via a new `extraCommitFields` hook on the polygon-centroid
move target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(editor): 2D alignment guides in building-local frame + world-grid wall snap
The 2D floor-plan alignment layer now mounts inside the rotated scene
<g> and reads from an editor-local `useAlignmentGuides` store —
guides are stored in building-local meters and follow the SVG
transform that already carries the rest of the floor-plan geometry.
Pill labels are counter-rotated by the scene rotation so they stay
upright under building rotation. The 3D pipeline keeps its own
world-frame store; the two surfaces no longer share frames.
Wall drafting picks up an optional `gridSnap` override so the 2D
floor-plan can snap drafts onto the world-XZ grid even when the
active building is rotated — without it, a rotated building's
drafted wall would chase the local grid and miss the visible lines.
`snapPointTo45Degrees` and `snapWallDraftPointDetailed` route through
the override when supplied; otherwise the prior local-axis grid snap
is unchanged. The floor-plan panel wires `snapBuildingLocalToWorldGrid`
through the placement hook and exports `WALL_GRID_STEP` to support it.
Also fixes the minor/major grid stroke palette swap so minor lines
render with the minor palette and majors with the major palette.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(editor): drop dead onRotate slot, memoize dragBounds snapshot
Removes the unreachable `onRotate` prop, `RotateCw` import, and
rotate button from `NodeActionMenu` — its only caller was the
floating building menu's 15° rotate, which was removed in d65fd5fe.
Memoizes `MoveRegistryNodeTool`'s `dragBounds` lookup so it captures
the scene snapshot once per node instead of re-reading
`useScene.getState().nodes` on every render. Bounds depend only on
`node` (locked for the tool's lifetime) and start-time sibling state
(elevator shaft height from the level set), so a one-shot snapshot is
the right semantics — and it avoids implying a live subscription that
isn't there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: biome check --write (import order, formatting, unused import)
Auto-fixes from `bun check:fix`:
- import-order normalization in editor/nodes/core
- unused `snapToGrid` / `useLayoutEffect` imports dropped
- minor whitespace/wrapping tweaks
No behavioural change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add roof surface placement support for items
Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.
- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fixed conflict
* editor: per-door-type floor-plan symbols
Render a distinct, static plan symbol for each door type in the
registry floor-plan builder (`packages/nodes/src/door/floorplan.ts`),
independent of the door's live open/close animation:
- single / hinged: fixed 90° swing with a dashed quarter-circle arc
- double / french: two mirrored half-width leaves + dashed arcs
- folding / bifold: static zigzag accordion (~80% span) on the wall face
- sliding: bypass — two overlapping panels on parallel tracks + arrow
- pocket: thin white leaf, ~60% closed, sliding into the solid wall
- barn: surface-mounted panel parked over the wall, dashed closed-ghost
+ slide arrow
The swing arc is dashed in screen-pixel units (the renderer uses
non-scaling-stroke). Symbols are oriented by hingesSide / swingDirection
/ slideDirection as appropriate.
Also includes pre-existing working-tree changes unrelated to the door
symbols: group move/rotate transform and box-select tweaks, and a
regenerated ifc-converter next-env.d.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix recessed ceiling fixtures and draw safety
* feat(editor): magnetic wall-snap with per-kind beacon (2D + 3D)
Snap the wall draft / endpoint-move point onto existing wall geometry —
corners, midpoints, wall–wall intersections, and along-wall edges — and
show a beacon at the snap point whose glyph encodes what it caught
(square = corner, triangle = midpoint, ✕ = intersection, circle = edge).
- Pure snap geometry extracted to wall-snap-geometry.ts (unit-tested).
- Ephemeral useWallSnapIndicator store drives a 3D pillar+glyph beacon
and a 2D SVG glyph beacon, both indigo to match the alignment guides.
- Gated by a new persisted "Magnetic snap" toggle in the Display menu
(useEditor); honored by draw + commit + endpoint-move in both views.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* editor: garage and open-doorway floor-plan symbols
Extend the per-door-type plan symbols in the registry floor-plan
builder (packages/nodes/src/door/floorplan.ts):
- open doorway (openingKind === 'opening'): bare gap, no leaf/arc/panel
(mirrors the 3D system, which renders only the cutout for openings)
- garage sectional: closed leaf + side tracks into the garage + dashed
parked ghost at the inner end
- garage roll-up: closed leaf + coil barrel (capsule) with a coil hint
- garage tilt-up: closed leaf + dashed parked panel + dashed curved
up-and-over swing path
- gate the swing arc to actual swing doors (hinged/double/french) so
other types fall back to the plain footprint
Garage mechanisms sit on the interior (door-local -z) side to match the
3D garage builders, independent of swingDirection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* arch: enforce layer boundaries — registry dispatch, store relocation, shared helper
Three architectural fixes to bring the branch into full compliance:
1. **ceiling-system kind check → CeilingCutCapability**
Replace `child.type === 'item'` branch in `ceiling-system` with registry
dispatch. Add `CeilingCutCapability` type to `packages/core` registry types,
implement `buildCeilingHole` on `itemDefinition`, and rewrite
`collectRecessedItemHoles` → `collectCeilingHoles` to dispatch through
`nodeRegistry` — viewer never again inspects a node's kind directly.
2. **useAlignmentGuides + useWallSnapIndicator → packages/editor**
These stores are editor-only UI (snap beacons, alignment guides). Move them
from `packages/core/src/store/` to `packages/editor/src/store/`, re-export
from `packages/editor`, and update all 34 consumer files across
`packages/editor` and `packages/nodes` to import from `@pascal-app/editor`.
3. **findLevelAncestorId extracted to core**
`item-light-system` had a private `resolveNodeLevelId` that duplicated
level-ancestor traversal logic. Extract it as `findLevelAncestorId` in
`packages/core` (spatial-grid-sync), export it, and replace the local copy.
All four packages typecheck cleanly (zero errors).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: fix lint, untrack .claude/launch.json
Run bun check --write to clear 8 Biome errors (formatting + import order
+ one unused import). Untrack .claude/launch.json and add it plus
.claude/settings.local.json to .gitignore so local IDE/agent configs
stop landing in commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>