Commit Graph
370 Commits
Author SHA1 Message Date
Wassim SAMADandClaude Opus 4.8 65a7fcb74b feat(paint-slots): world-space planar UVs for walls (seamless tiling)
Walls used THREE's ExtrudeGeometry UVs, which restart at each wall's local
start/end — so finishes seamed at wall joins, showed a mid-face axis-switch
stripe, and didn't line up with the roof gable. Re-project the render mesh's
UVs in WORLD space (1 unit = 1 m), matching roof-system's pushRoofUv exactly:
vertical faces U = ±worldX/Z, V = 1 - worldY. De-indexes so each triangle uses
its own face normal (no edge seams). Applied only to the render mesh; collision
and floorplan geometry keep the original UVs. Now walls tile continuously
across segments and meet the gable seamlessly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:11:57 -04:00
Wassim SAMADandClaude Opus 4.8 c7d0cd3388 feat(paint-slots): world-reference roof gable wall UVs to align with walls
The gable wall band tiled its V in segment-local space, so its texture phase
didn't line up with the wall below (whose ExtrudeGeometry UVs map V = 1 -
height-from-base). Make vertical roof faces tile V in world space
(V = 1 - worldY) via the segment's resolved world Y (parent roof + segment
position), so the band matches a ground-floor wall's vertical tiling at the
eave seam. U is unchanged (stays in the face's local run).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:37:43 -04:00
Wassim SAMADandClaude Opus 4.8 0e8df1b946 fix(paint-slots): nudge fence vertical members 0.001m thinner than the rails
The posts/infill and the accent rail shared the same panelDepth*0.35 depth, so
their faces were coplanar where they cross -> z-fighting. Make the verticals
0.001m shy so the rail wins the depth test cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:12:53 -04:00
Wassim SAMADandClaude Opus 4.8 f7e774485c fix(paint-slots): guard empty fence slot group before mergeGeometries
mergeGeometries throws on an empty array, so an absent slot group (infill with
showInfill off, base on a floating fence) crashed the geometry build. Return an
empty BufferGeometry for an empty group; the renderer already skips meshes with
no position attribute.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:57:46 -04:00
Wassim SAMADandClaude Opus 4.8 bba0cc5f68 feat(paint-slots): prepared-drywall finish + fence 4 slots matching build options
- Add 'concrete-drywall' (Prepared Drywall) catalog finish + 512 KTX2/webp
  runtime assets; default walls (WALL_SLOT_DEFAULT interior+exterior) and the
  roof wall/trim band to it so roofs read continuous with walls.
- fence: replace the 2-slot panel/rail split with 4 slots that match the panel's
  build options — posts / infill (showInfill) / base (grounded only) / rail —
  each its own mesh with userData.slotId; conditional slots track the build
  state. Defaults: posts/infill/base charcoal, rail wood-finewood27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 20:51:49 -04:00
Wassim SAMADandClaude Opus 4.8 e92ee05702 feat(paint-slots): round-2 feedback — fence 2 slots + UVs, roof defaults, stair body
- 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>
2026-06-17 17:21:58 -04:00
Wassim SAMADandClaude Opus 4.8 4818807360 refactor(paint-slots): share scene IBL as viewer SceneEnvironment
Replace the editor-only EditorEnvironment wrapper with a SceneEnvironment
component exported from @pascal-app/viewer, mounted as an opt-in <Viewer>
child (still not baked into the Viewer component). One source of truth the
editor and the community public viewer both inject; embed/thumbnail
surfaces simply don't mount it. Sunset preset at environmentIntensity 0.6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:49:35 -04:00
Wassim SAMADandClaude Opus 4.8 8aa179e9cb feat(paint-slots): per-part paint for windows + doors, chrome/brass, world-scale UVs
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>
2026-06-17 14:01:37 -04:00
3cb37c98cd feat: embed surface — export in-canvas affordances + transparent/faint-ink viewer support
Lets a host mount the editor's real editing experience on a bare <Viewer>
without the full <Editor> shell.

editor: export Grid, NodeArrowHandles, MoveTool, ToolManager — the selection
handles, the kind-owned mover, the build-tool host, and the drafting grid that
feeds tools their grid:* pointer events. See the doc comment in index.tsx for
how they cooperate with host camera controls and selection.

viewer: two opt-in, non-persisted presentation flags (both default off, so the
editor and every other consumer are unchanged):
- transparentBackground / <Viewer transparent>: emit premultiplied RGBA masked
  by geometry + outline alpha (outputColorTransform off on that path) so the
  scene can float on any page background. ACES tone-mapping makes a true-white
  opaque background impossible, hence transparency.
- inkOpacity: override the per-mode ink-edge opacity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 11:07:43 -04:00
Wassim SAMADandClaude Opus 4.8 c3bd065f2f fix(paint-slots): disable the CSG cutout's raycast so window/door paint hits the slots
The real interceptor was the 'cutout' mesh, not the hitbox: it's a 1m-deep CSG
helper whose front face sits 0.5m proud of the glass, and current three.js
raycasts invisible meshes — so every paint/hover ray resolved to 'cutout'
(no slotId → role null). Disable its raycast; combined with the hitbox noop,
paint/hover rays now land on the tagged frame/glass (panel/glass) children.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:42:48 -04:00
Wassim SAMADandClaude Opus 4.8 531dbb6e68 perf(post-processing): don't rebuild the pipeline on hover + temp paint debug logs
- post-processing: hoverHighlightMode was a dependency of the pipeline-build
  effect, so every hover rebuilt the entire pipeline. The hover style is already
  pushed to uniforms in a separate effect, so the rebuild was pure waste —
  removed it from the deps (and the build log).
- selection-manager: temporary [paint-debug] logs for window/door hover to trace
  why their paint dispatch drops (to be removed once diagnosed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:36:58 -04:00
Wassim SAMADandClaude Opus 4.8 cebb29460d fix(paint-slots): make window/door paint hits land on the tagged children
The window/door root is an invisible hitbox the system gives full-depth
BoxGeometry; its front face intercepted every paint/hover ray, so the hit
resolved to the hitbox (no slotId) → role null → paint silently disabled.

Disable the hitbox's own raycast in the visual path so R3F's recursive
intersect returns the tagged frame/glass (panel/glass) children instead;
selection still works because those child hits bubble to the root's event
handlers. Restored to the default raycast each build, and kept for 'opening'
windows/doors (no visuals to paint, still need a selectable hitbox). The
'cutout' child is visible=false so the raycaster already skips it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 09:10:43 -04:00
Wassim SAMADandClaude Opus 4.8 9f1627e923 feat(paint-slots): paintable slots for windows + doors (frame/glass, panel/glass)
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>
2026-06-17 08:54:18 -04:00
Wassim SAMADandClaude Opus 4.8 737c4e9d1d feat(paint-slots): concrete-plate finish + try defaults (wall=concrete, slab=wood plank 48, ceiling=soft white)
- material-library: add 'concrete-plate' KTX2 finish (512, fabric/leather-style
  pipeline) + editor-app texture mirror.
- viewer: shared resolveSlotDefaultMaterial(colour|library ref) so a kind's slot
  default can be a catalog finish, not just a flat colour.
- wall default -> library:concrete-plate (interior + exterior).
- slab default -> library:wood-woodplank48 (slab geometry resolves it via the
  shared helper).
- ceiling default -> soft white #f2eee6 (ceiling renders flat-tinted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:25:48 -04:00
Wassim SAMADandClaude Opus 4.8 967a905e3b feat(paint-slots): unified slot defaults + paint for slab, ceiling, wall (phase 5)
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>
2026-06-17 07:51:37 -04:00
Open Pascal 8eab9979ab fix: address 2nd-round adversarial review of PR #402 fixes
- core: decouple drag-follow from distributionRole — add
  portConnectivityFollow flag to NodeDefinition; pipe-trap opts out
  (portConnectivityFollow: false) so dragging a connected pipe endpoint
  stretches the trap arm instead of yanking the anchored trap fixture

- core: remove the module-level getLevelHeight cache entirely — it was
  keyed only by nodes-object identity, which could return stale heights
  for in-place mutations by pure/headless callers. The function is now
  fully pure and deterministic; viewer hot path recomputes per frame as
  before (the cache only ever skipped the resolver-free branch)

- test: harden port-connectivity-pipe.test.ts — real DuctSegmentNode
  cross-family isolation case (was waste-vs-vent), new pipe-trap anchor
  case (run drag doesn't move trap; trap drag still stretches run), and
  beforeEach/afterEach registry reset instead of leaky beforeAll

- nit: biome format/import-order on all touched files
2026-06-16 19:56:31 +00:00
Open Pascal 54a24e4c5c fix: address review findings from PR #402 MEP systems
- core: fix layer violation in level-height.ts — extract sceneRegistry
  import, replace with optional WallBaseYResolver callback so core stays
  pure (no Three.js mesh state); viewer callers pass resolver, headless
  callers (MCP/tests) get deterministic node-data-only result

- core: generalise port-connectivity service from duct-only to all
  distribution families — match partners by distributionRole ('run' →
  endpoint stretch, 'fitting' → rigid follow) instead of hard-coded
  duct-segment/duct-fitting type names; add system-compat guard so
  cross-system ports (e.g. supply duct vs waste pipe) don't fuse

- editor: fix port-snap rotation bug in move tool — pass preview node
  at live rotation into resolvePortSnap so own-port positions reflect
  any mid-drag R/T rotation before computing the snap delta

- editor: wire pipe-trap into UI — add to StructureTool union,
  MepToolKind, MEP_ITEMS Build-tab tile, and structure-tools action menu

- test: add port-connectivity-pipe.test.ts — 2 tests covering
  pipe-fitting → pipe-segment endpoint drag and cross-system isolation

- test: fix stale pipe-auto-fitting.test.ts wye expectation — author
  deliberately chose square sanitary-tee for DWV side-taps (documented
  in PR description and PipeFittingNode schema); update the one test
  that still expected wye to match the implemented behaviour

- nit: fix optional-chain biome warning in validate-dwv.ts
2026-06-16 19:30:51 +00:00
Sudhir YadavandGitHub 5551500d98 feat: HVAC ductwork + DWV plumbing systems (#402)
Adds two new MEP node families (HVAC ductwork, DWV plumbing) built on a shared port-connectivity model. Co-authored by @sudhir9297.
2026-06-16 15:30:39 -04:00
Wassim SAMADandClaude Opus 4.8 042f855586 feat(paint-slots): KTX2 finish library — fabric/leather/concrete/metal (phase 4)
Wire KTX2 into the catalog finish path and add 15 textured finishes across four
new families, all as GPU-compressed KTX2 (512px) + a webp picker thumbnail.

- Shared `ktx2-loader.ts`: one KTX2Loader (Basis transcoder) used by both the
  GLB loader and catalog textures; `ensureKtx2Support(renderer)` runs once at
  viewer init (GPUDeviceWatcher) so `.ktx2` finishes load even with no GLB in
  the scene. `use-gltf-ktx2` now reuses the shared instance.
- `materials.ts`: texture loaders pick the KTX2 loader for `.ktx2` urls, the
  image loader otherwise (all three load paths).
- `material-library.ts`: 15 entries (fabric ×6, leather ×2, concrete ×4,
  metal ×3). Neutral albedo for tinting, `flipY: false` (compressed textures
  can't flip), `repeat` per real-world tile size, metals `metalness: 1`.
  Normals encoded UASTC, data maps ETC1S/linear.

Assets generated from raw sources via the new community
`scripts/build-material-textures.ts`. 1024/256 tiers + raws kept out of git.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:37:23 -04:00
Wassim SAMADandClaude Opus 4.8 101341d98d feat(paint-slots): paintable slots on the procedural shelf (phase 5)
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>
2026-06-16 10:59:59 -04:00
Wassim SAMADandClaude Opus 4.8 937cf02aff feat(paint-slots): world-scale pool slab floor UVs + document the metre UV contract
Pool slabs (generatePoolGeometry) were the one procedural surface emitting
normalized [0..1] floor UVs, so a textured finish stretched to fit the pool
instead of tiling at real-world scale like every other surface. Switch the
floor to shape-space metres (x, -z) — the same mapping generatePositiveSlab-
Geometry already uses for its caps. Pool walls were already in metres.

Also document the contract in wiki/architecture/materials-and-themes.md: every
procedural surface generates UVs in metres (1 UV unit = 1 m), GLB slots follow
the same ~1 unit/m authoring convention, and a catalog material's `repeat` is
therefore a per-material world-scale setting (tiles per metre), identical for
every surface that uses it — never per-item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:51:54 -04:00
Aymeric RabotandClaude Opus 4.8 c9121d322c Merge origin/main (#407 placement restructure) into opening-proximity-guides
#407 ("Always-visible placement ghosts + true-nearest 2D opening snap")
restructured the door/window placement tools: it split the old
create-in-resolve into a pure resolveWallPlacement() + side-effecting
applyWallTarget(), added an off-host floating ghost (fallbackPose / showGhostAt),
unified wall hover into onWallHover, and extracted commit{Door,Window}AtWall.

Conflict resolution (door/tool.tsx, window/tool.tsx):
- Re-homed the single publishOpeningGuidesForWallEvent() call into applyWallTarget
  (after the draft update + updateCursor), using that scope (wall,
  getSlabElevationForWall(wall)); door includeVertical:false, window true.
- Routed clearOpeningGuides3D() through showGhostAt so every off-host fallback
  path clears; kept clears in hideCursor, commit helpers, onRoofHover, teardown.
- Made the window sill snap (resolvePlacementY) event-free and call it from the
  pure resolveWallPlacement, so hover + click both get sill/centre/top snapping;
  Shift bypasses, the moving draft is excluded via ignoreId.
- Dropped the branch's inline onWallClick in favour of #407's onWallClick +
  commitWindowAtWall (no behavior lost).
- Reconstructed both files' import blocks, which the auto-merge had truncated to
  stubs (only tsc caught it).

All other conflicts auto-merged (registry types, floorplan-registry-layer,
both move-tools). Verified: typecheck 9/9, biome clean, nodes 169 + core 594
tests pass, editor `bun run build` 7/7. Merge resolution reviewed by Codex
(adversarial): no semantic regressions; all #407 behavior preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:58:00 -04:00
1628b728fa Always-visible placement ghosts + true-nearest 2D opening snap (#407)
* feat(editor): always-visible translucent placement ghost for openings + roof accessories

When a host-surface placement tool is armed, the node's real geometry now
follows the cursor everywhere as a translucent ghost: tinted invalid (red)
and unconfirmable off-host, snapping onto its host surface (wall/roof) with
the existing valid/invalid affordances when near. This replaces the old red
wireframe box (door/window) and red DragBoundingBox (roof accessories), so
the armed tool is visible before the cursor reaches a placeable surface.

- New shared `applyGhost` helper (nodes/src/shared/ghost-materials.ts):
  clones materials, disables raycast (avoids cursor-ray starvation),
  tints invalid; cleanup disposes only the clones.
- New door/window preview components built from the real geometry via new
  `buildDoorPreviewMesh`/`buildWindowPreviewMesh` viewer exports; tools float
  the ghost via a `fallbackPose` that is mutually exclusive with the on-host
  draft + wireframe outline.
- `RoofAttachmentFallbackPreview` gains a `ghost` prop; all 11 roof-accessory
  tools pass their real preview (invalid-tinted) instead of a box `size`.

Snapping behavior is unchanged (no proximity snap yet).

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

* feat(editor): magnetic proximity wall-snap for door/window placement

The door/window ghost now follows the cursor over the floor like a moving
item and magnetically snaps onto the nearest wall within range (1.5 m), then
releases back to free-follow when the cursor moves away — instead of only
attaching on a direct wall-mesh ray hit. A grid-snap sound plays each time it
snaps onto a new spot, so it reads as moving a physical object that can only
land on walls.

- Plan-space proximity via the existing `findClosestWallInPlan` (the same
  helper the 2D floor-plan move uses): level-scoped, skips curved walls,
  returns wall + along-wall localX + side + wall-local rotation.
- `grid:move` drives the snap and `grid:click` commits when proximity-snapped;
  a direct wall-mesh hover (wall:enter/move) still owns the precise face side.
  Both paths share `applyWallTarget` (create the draft once, reparent only on
  an actual wall change) and a shared commit that refreshes alignment anchors.
- Disambiguation without a stuck flag: a per-pointermove `timeStamp` gate
  (R3F + the grid raycast share the source DOM event) plus a `cameraDragging`
  guard and stale-`hostKind` reset, so a missed wall:leave during a camera
  orbit can't strand the draft.
- Window keeps its sill height on the floor path (the floor cursor carries no
  wall-face Y) — defaults to a ~0.9 m sill, mirroring the 2D move.
- Shift bypasses the along-wall grid/alignment snap but still attaches to the
  nearest wall, matching the 3D-hover and 2D-move conventions.

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

* fix(editor): door/window preset placement follows the cursor over open floor

The community preset/catalog flow places doors and windows through the
isNew move path (MoveDoorTool / MoveWindowTool), which had no free-follow:
the fresh clone was parented to the level at the origin and only became
visible once the cursor reached a wall, so over empty floor nothing tracked
the cursor. Now the move tools mirror the def.tool placement behaviour:

- Off-wall, the real node rides the cursor like an item (reparented to the
  level, positioned at the building-local cursor) so it's obvious what's
  being placed before it attaches.
- Within range of a wall it magnetically snaps on via findClosestWallInPlan
  (the same plan-space helper the 2D move uses), releasing back to free-follow
  when the cursor moves away, and plays the grid-snap sound on each new snap.
- grid:click commits only when snapped (open floor is a no-op — a door/window
  needs a wall); the wall/roof mesh-hover paths are unchanged and still own
  their own click. A per-pointermove timeStamp gate + cameraDragging guard
  keep the floor handler from fighting a wall/roof hover.
- Windows default to a ~0.9m sill while off-wall (fresh preset clones carry
  position [0,0,0], which buried half the window below the floor).

The wall/roof commit body is extracted into a shared commitToWall so the
mesh-click and proximity-click paths stay identical. Existing-node moves are
fully restored on cancel/unmount (the node stays isTransient through
free-follow). Standalone-editor def.tool placement already had this in a
prior commit; this brings the community move path to parity.

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

* feat(editor): 2D floorplan ghost follows the cursor for door/window placement

Mirrors the 3D free-follow in the top-down floor plan: while placing a door
or window, a loose footprint rectangle now follows the cursor over open floor
so it's obvious what's being placed before it snaps to a wall. The instant the
cursor nears a wall, the existing synthesized wall:enter/move path takes over
and the real on-wall door/window symbol (swing arc, etc.) replaces the ghost.

- The opening-placement pointer-move handler in floorplan-panel sets a new
  `openingGhostPoint` on the off-wall (findClosestWallPoint miss) branch and
  clears it on a wall hit; a loose width × 0.1m rectangle renders at that point
  inside the floor-plan scene group (same world→SVG transform as every glyph).
- Width comes from the moving node or the kind default (door 0.9 / window 1.5).
- The ghost clears when opening placement ends (tool/mode change, cancel,
  commit) and on level change, so no stale rectangle lingers.

Deliberately a plain rectangle, not the full swing-arc symbol: off-wall there's
no host to orient the swing to. The shared door/window def.floorplan builders
are untouched — overloading them with a wall-less fallback would make
roof-hosted doors (parent is a roof segment, builder returns null today) draw
stray rectangles in plan. Keeping the preview in the editor layer avoids that.

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

* feat(editor): faithful 2D door/window ghost, 2D wall-slide, R-flip during placement; 3D snap only on hover

Three placement fixes plus a snapping revision, for both the standalone
def.tool path and the community isNew move path (door + window):

- 2D faithful ghost: the off-wall placement ghost now renders the real
  blueprint symbol (door swing arc / window panes) following the cursor, not a
  bare rectangle. Done by publishing a transient opening on a synthetic wall to
  usePlacementPreview (extended with a `parentNode` fed as the builder's
  ctx.parent) so the real def.floorplan builder draws it. Cleared on wall-hit,
  on commit, on placement-inactive, and on level change.
- 2D slide-along-wall: the floor-plan registry layer ignored useLiveTransforms
  for door/window (only floor-placed + slab/ceiling/zone), so a same-wall slide
  updated the 3D mesh but left the 2D symbol frozen. It now merges the
  wall-local live position/rotation onto the node (keeping parentId) so the 2D
  symbol slides with the cursor.
- R-flip during placement: pressing R now flips a door/window's facing
  (front ↔ back, rotation += π) before commit — the placement tools own R while
  placing (the global selection-based R/T handler stands down via
  isPlacingOpening so it can't double-fire). No-op on roof faces (front-only).
- 3D snapping zero-padding: removed the 1.5 m proximity magnet; in 3D the
  opening free-follows the cursor over open floor and snaps only when the cursor
  ray actually hovers a wall/roof mesh (big raycast targets). 2D keeps its
  0.5 m findClosestWallPoint padding since plan walls are thin.

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

* fix(editor): make FloorplanRegistryMoveOverlay the sole 2D owner of door/window placement

Community door/window placement (movingNode + metadata.isNew) had TWO 2D paths
running at once: the floorplan-panel synthesized wall:*/grid:* events (driving
the 3D MoveDoorTool) AND FloorplanRegistryMoveOverlay via def.floorplanMoveTarget.
They fought — R didn't flip the 2D symbol and clicks didn't commit in 2D, while
3D worked. The overlay + floorplanMoveTarget is the purpose-built 2D owner
(faithful def.floorplan symbol, plan-space CTM coords, Figma snap, single-undo
commit), so it now owns 2D opening placement when movingNode is set:

- floorplan-panel: the opening pointer-move branch + the registry grid catch-all
  + the background-click catch-all all now exclude the door/window MOVE case
  (`!isOpeningMoveActive`), so the synthesized events no longer fire for it (they
  still drive pure raw-build placement, which has no movingNode). Without the
  catch-all exclusions the move case fell through to grid:move/grid:click, which
  re-drove the 3D tool's free-follow and consumed the commit click.
- R-flip in 2D: `FloorplanMoveTargetSession` gains optional `flipSide()`;
  door/window floorplan-move implement it (XOR the wall-derived side + π rotation,
  re-running the last apply). The overlay's keydown calls `session.flipSide()` on
  R — gated on `hasMovedSinceStart` so it only fires when the 2D pane is the
  active mover (the 3D MoveDoorTool owns R in 3D/split; this prevents a double
  flip / double cue on one R press).
- Commit in 2D now flows solely through the overlay's pointerup (no competing
  synthesized wall:click), so click-to-place commits.

The global use-keyboard R/T already stands down during opening placement
(isPlacingOpening), so a selected node can't also flip.

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

* fix(editor): 2D door/window move free-follows the cursor off-wall and commits only on a wall

Moving an existing door/window in the 2D floor plan: the move target's `apply`
early-returned off-wall (`if (!hit) return`), so the opening stayed frozen on
its old wall instead of following the cursor between walls (3D free-follows),
and an off-wall confirm click committed the stale last-wall position — looking
like the placement failed.

Now `doorFloorplanMoveTarget`/`windowFloorplanMoveTarget` mirror the 3D move:
- Off-wall, `apply` free-follows the cursor — hides the real node and floats the
  faithful door/window symbol at the cursor via a synthetic wall published to
  `usePlacementPreview` (the same preview layer fresh placement uses). The real
  node is `visible:false` so the registry layer skips it (no double symbol).
- Back on a wall, it clears the ghost, reveals the real node, and snaps as before.
- `canCommit` returns false while off-wall, so an open-floor click reverts to the
  pre-move snapshot (door returns to its wall) instead of committing in mid-air —
  matching the 3D move, where clicking open floor commits nothing. On a wall the
  commit lands normally.

The overlay's snapshot revert (cancel / invalid commit) and the on-wall `apply`'s
`visible:true` restore both guarantee the node is never left hidden after a move.

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

* fix(editor): 2D opening snap picks the true nearest wall, with a tighter radius

The 2D door/window snap felt too aggressive and could grab a wall further away
than the one the cursor was actually nearest. Root cause in
`findClosestWallInPlan`: it compared a candidate's true segment distance against
the previous best's `perpDistance` (signed offset to the wall's infinite line,
not the clamped segment distance). Near a wall end those diverge, so a closer
wall could be rejected / a farther one kept.

- Track the best segment distance and keep the strict minimum — the wall chosen
  is now always the single closest segment to the cursor (true nearest), which
  resolves correctly when many walls sit close together.
- Tighten the snap radius from 1.5 m to 0.4 m: plan walls are thin, so the old
  radius snapped from far away. The opening now free-follows the cursor until
  it's genuinely near a wall.

Only the 2D move/placement targets use this helper (3D snaps on raycast hover);
wall-attached items share the same improved nearest-wall behaviour.

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

* feat(editor): true-nearest 2D opening snap with dev hit-area overlay

Round 7 of the placement-ghosts work. Make the 2D door/window wall snap
always pick the wall nearest the cursor, fix fresh-placement snapping/slide
in 2D, and add a dev-only overlay that visualises each wall's snap region.

- Extract the plan-space nearest-wall-segment math to core
  (`lib/wall-distance.ts`: collectLevelWallSegments / closestOnSegment /
  nearestWallSegment / WALL_SNAP_DISTANCE_M). `findClosestWallInPlan`
  delegates to it, so the snap and the debug overlay share one source of
  truth. WallHit contract unchanged.
- door/window 2D move now resolves the host level via the shared
  `getOpeningHostLevelId` (wall-hosted, roof-hosted, AND fresh-placement
  parented straight to the level — the last case previously resolved to the
  building, so a new opening never snapped in 2D).
- Cursor resolver switched to absolute mode: query the snap with the true
  cursor, not the original-wall position + grab delta, so it picks the
  cursor-nearest wall (matching the 3D move) instead of a far wall across a
  thin gap.
- 2D move clears any stale `useLiveTransforms` entry for the node each apply:
  the registry layer renders door/window from the live transform in
  preference to the scene node, so a leftover entry from the 3D tool froze
  the 2D slide for fresh / re-armed openings.
- Fresh window defaults to a 0.9 m sill in 2D (was sitting half-below floor
  at y=0), matching the 3D MoveWindowTool.
- New dev-only FloorplanVoronoiLayer + `show2dVoronoi` editor flag: draws
  each wall's snap hit area as an analytic capsule (no grid sampling), gated
  on a developer-menu toggle.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 12:24:27 -04:00
Wassim SAMADandClaude Fable 5 7afb286e47 feat(paint-slots): authored item materials + unified slot painting
Phase 1 + paint unification of the paint-slots plan.

- core: scene-material data layer (materials map mirroring collections,
  undo/partialize/setScene full-graph support), SceneMaterial schema,
  scene:/library: MaterialRef helpers + parseMaterialRef, slot id helpers
  (deriveSlotId/slotLabelFromId), slots map on ItemNode, hitObject on
  PaintResolveArgs, optional PaintCapability.commit.
- viewer: resolveMaterialRef (library:/scene: -> three material, null on
  dangling).
- nodes(item): renderer keeps authored GLB materials for slot-authored
  assets and applies per-slot overrides per-instance (never mutates the
  shared cached GLB); textures-off still collapses to furnishing role;
  non-authored items unchanged. Item paint capability + registration.
- editor: item joins the unified (nodeId, slotId) paint dispatch; item
  paint target + slot reset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 10:01:08 -04:00
Aymeric RabotandClaude Opus 4.8 1f829d52ed feat(editor): draggable move handle for wall-hosted doors & windows
Doors and windows could only be moved via the floating action menu — their
3D handle rig declared width/height resize arrows but no move grip, and
Ctrl/Meta-drag was a no-op for them. Add a press-drag move cross and make
direct-drag work for every bespoke-mover kind.

- door/window: add a `tap-action` `move-cross` handle (plane node-normal,
  portal grandparent, `engageMoveDrag`) mirroring the item wall grip. It
  routes through the existing per-kind move tool (3D `affordanceTools.move`,
  2D `floorplanMoveTarget`) — wall-bound slide + re-host onto another wall —
  so the grip, the floating Move button, and the 2D plan's move dot share one
  pipeline. Grab-drag-release commits without a second click.

- canDirectMoveNode: gate Ctrl/Meta-drag on `movable || affordanceTools.move`
  (the 3D-mountable move paths) instead of `movable` only, so doors/windows/
  walls/slabs/stairs/… are draggable in 3D as they already are in 2D.
  Floorplan-only movers (zone) stay excluded — no 3D tool mounts. The
  floating helper auto-syncs (it reads canDirectMoveNode).

- TapActionArrow: honor `plane: 'node-normal'` by tilting the move cross
  [π/2,0,0] into the wall face — previously ignored, so the item wall grip
  rendered flat too. Now door/window/wall-item crosses lie in the wall.

- use-node-events: split the drag-suppression gate. `inputDragging` still
  suppresses SELECTION events (the synthesized release-click would re-select),
  but no longer suppresses SPATIAL events (enter/move/leave) — a
  surface-following move tool runs with `inputDragging` set and needs
  wall:move to track the cursor. General consumers that must ignore drags
  (viewer hover, box-select) already self-gate on `inputDragging`; the
  editor's select-hover and paint-preview enter handlers now gate on it too.

- handle-arrow: make handle hit areas inert while `placementDragMode` is set,
  so a move grip riding the dragged node can't intercept the ray and starve
  the move tool's surface raycast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:51:37 -04:00
cf24b62c44 fix(viewer): UI flicker on camera move from interactive overlays + dirty-mark leaks (#401)
* 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>
2026-06-12 10:05:06 -04:00
Aymeric RabotandGitHub aab48e053f fix(editor): harden editor interactions and WebGPU rendering
Fix editor bug sweep regressions, WebGPU CSG/material crashes, Shift snap bypass behavior, arrow handle drag projection, and the wall preview null guard covered by the Sentry follow-up PRs.
2026-06-11 13:03:34 -04:00
Aymeric RabotandClaude Fable 5 aa3b0ef758 refactor: release-review cleanup for roof wall openings
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>
2026-06-10 14:33:13 -04:00
Aymeric Rabot b8dfa90762 Merge remote-tracking branch 'origin/main' into feat/editor-ux-rendering-placement 2026-06-10 14:21:35 -04:00
Aymeric RabotandClaude Fable 5 7879b83df3 feat: face-frame hosting for roof wall children + items on roof walls
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>
2026-06-10 14:21:14 -04:00
Aymeric RabotandClaude Fable 5 bdfee058bd feat: doors and windows on roof-segment wall faces
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>
2026-06-10 00:39:13 -04:00
github-actions[bot] ab3c256804 release: @pascal-app/core@0.9.1 @pascal-app/viewer@0.9.1 @pascal-app/editor@0.9.1 @pascal-app/mcp@0.3.1 @pascal-app/ifc-converter@0.1.1 2026-06-10 03:16:48 +00:00
Aymeric RabotandClaude Fable 5 9bfd75c2c7 chore(release): align core/viewer/editor to 0.9.0 ahead of v0.9.1
The v0.9.0 GitHub release declared the trio at 0.9.0 but the npm publish
never ran (npm still serves 0.8.0). Aligning manifests so the release
workflow's bump=patch lands everything at the announced-next 0.9.1
(mcp 0.3.0 -> 0.3.1, ifc-converter 0.1.0 -> 0.1.1 keep their own lines,
matching the per-package version table style of previous releases).
Inter-package peer/dev refs move to ^0.9.0 so the workspace stays
self-linked during the release run; the workflow re-syncs them to the
final versions after bumping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:54:42 -04:00
Aymeric Rabot eb00f9f477 fix: harden editor scene loading 2026-06-09 19:54:42 -04:00
Aymeric Rabot 8d8052297c Merge remote-tracking branch 'origin/main' into chore/deps-2026-06 2026-06-09 18:52:42 -04:00
Wassim SAMADandGitHub 50304d7d10 Support template scans and room preset workflows (#390)
* 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
2026-06-09 16:36:31 -04:00
Aymeric Rabot 265acdb2be fix: improve ceiling handle feedback and WebGPU placeholders 2026-06-09 14:49:42 -04:00
Aymeric RabotandClaude Fable 5 9d24e260c3 chore(deps): June 2026 dependency refresh
Range edits:
- next 16.2.1 -> 16.2.7 in apps/editor + apps/ifc-converter. 16.2.5/16.2.6
  fixed six high-severity advisories (middleware bypass, DoS, SSRF, XSS,
  RSC cache poisoning); 16.2.7 is bugfix backports. No API/config changes.
- typescript 6.0.2 -> 6.0.3 everywhere, including aligning the 5.9.3
  stragglers (@pascal-app/mcp, @repo/ui, @repo/eslint-config) that date
  from the original scaffold and were never intentionally held back.
- @types/node in @pascal-app/mcp: ^25.5.0 -> ^22.19.20 — mcp was the only
  workspace typed against non-LTS node 25; 22 matches the release
  workflow's node and every other workspace.
- @number-flow/react ^0.5.14 -> ^0.6.0 (packages/editor, apps/editor);
  the only breaking change (removed --number-flow-char-height CSS var)
  is unused here.
- agentation ^2.3.2 -> ^3.0.2 (apps/editor devtool; v3 is a drop-in for
  the props-less <Agentation /> usage, additions are opt-in).
- Root overrides: @types/react 19.2.14 -> 19.2.17,
  @types/three 0.184.0 -> 0.184.1 (types-only fixes).

Lockfile refresh within existing ranges picks up react 19.2.7 (pairs with
next 16.2.7 — 19.2.6 had a server-action FormData regression), motion
12.40.0, three-mesh-bvh 0.9.10, @react-three/uikit-lucide 1.0.73, the
June radix wave, lucide-react 1.17.0, zustand 5.0.14, tailwind-merge
3.6.0, geist 1.7.2 (fixes Geist Mono ligature regression), react-grab
0.1.44, biome 2.4.16 + ultracite 7.8.2, turbo 2.9.17.

Held: three stays 0.184.0 (npm latest; single-instance constraint),
tailwindcss 4.3.0 + lightningcss 1.32.0 already match the pinned
optionalDependencies native binaries, eslint 10 major not taken
(@repo/ui is unconsumed legacy scaffold).

Verified: turbo build, check-types, biome check, and 931 package tests
green; bun.lock stays lockfileVersion 1 (CI bun 1.3.0 compatible);
single next/three resolution confirmed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 14:48:40 -04:00
Wassim SAMADandGitHub 812b7306e8 Fix editor interaction, loading, and IFC cleanup (#385)
* fix: update site labels after camera swaps

* fix: gate scene display until viewer is ready

* fix: render scene loader on first paint

* fix: keep loader during pending scene graph

* feat: add wasd camera panning

* feat: add x delete mode shortcut

* feat: add floorplan north compass

* fix: move floorplan compass to lower corner

* fix: progressively rebuild heavy scene geometry

* fix: simplify noisy ifc wall output
2026-06-08 15:04:28 -04:00
ce6f999310 arch: enforce layer boundaries — ceiling dispatch, store relocation, shared helper (#382)
* 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>
2026-06-08 13:14:39 -04:00
Aymeric Rabot 03f57b1f4f Fix stair opening previews and slab cutouts 2026-06-07 00:46:51 -04:00
8f2e5678e8 ci: add lint and typecheck workflow (closes #147) (#369)
* fix: resolve lint and type errors to enable CI

- Remove stale biome-ignore suppressions in bootstrap.ts, r3f.d.ts,
  and parametric-node-renderer.tsx (rules no longer fire).
- Replace forEach callbacks that return values with for...of loops
  in node-actions.ts and build-collider-world.ts
  (lint/suspicious/useIterableCallbackReturn).
- Rewrite assign-in-expression guards in ceiling/tool.tsx to explicit
  if-statements (lint/suspicious/noAssignInExpressions).
- Hoist useMemo/useCallback above early return in chimney/panel.tsx
  (lint/correctness/useHookAtTopLevel).
- Add `^build` to turbo check-types dependsOn so packages/core dist is
  up-to-date before type checking — this resolves all 32 type errors
  which were caused by stale dist, not missing symbols.

* ci: add lint and typecheck workflow on push/PR

Adds a quality gate that runs `bun run check` (Biome) and
`bun run check-types` (TypeScript) on every push and PR to main.
Uses concurrency groups to cancel stale runs. Closes #147.

---------

Co-authored-by: Pascal <open@pascal.app>
2026-06-06 09:05:16 -04:00
f01c443f2a fix(editor): render slab side walls solid regardless of polygon winding (#377)
Slab and pool side walls assumed a CCW contour (the unflipped quad's right-hand normal is outward only for CCW), but outsetPolygon and the slab tool preserve the drawn winding. A CW-drawn slab therefore got inward-facing side-wall normals that FrontSide culling dropped, so the slab read as see-through from the camera-facing side (~half the time, depending on draw direction). Normalize the contour to CCW (reverse when signed area < 0) before building both the positive-slab and pool geometry.

Caps are unaffected (Earcut normalizes the outer ring); holes are already double-emitted + winding-normalized; ceilings use flat ShapeGeometry (no extruded walls); autoFromWalls slabs are already CCW so this is a no-op for them.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:37:23 -04:00
Aymeric RabotandGitHub 0b338cf647 feat(editor): live floor-stacking, unified handle system, slab-hole editing + interaction polish (#375)
- Live slab-stacking Y previews for all floor-placed kinds (item/shelf/spawn/column/stair) during placement + both move pathways, via a shared core resolver; canonical positions unchanged.
- Unified 3D handle system (one drag pipeline + one visual primitive) with forgiving invisible hit-areas on every handle, kept on EDITOR_LAYER so they don't poison the MRT scene pass.
- Hover + click-to-edit slab holes in 3D (manual hole -> hole editor; stair/elevator hole -> select owner); generic cross-arrow polygon-move grip; normalized handle interaction colors.
- NaN-safe node mutations + non-finite shadow-light bounds guard.
- Built on #373 (level-scoped alignment / registry slab tool); #373 owns X/Z alignment, this owns Y floor-stacking.
2026-06-05 16:24:48 -04:00
46f94b97b3 editor: alignment guides + floor-plan move/placement parity (#372)
* 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

* feat(editor): 3D alignment guides for item/wall/fence move + placement

Bring Figma-style alignment guides into the 3D editor, reusing the shared
pure resolver (`resolveAlignment`) and ephemeral guide store
(`useAlignmentGuides`) that previously only drove the 2D floor plan.

Core:
- `alignment-anchors.ts`: node→anchor adapters (footprint AABBs, corner
  anchors, wall/fence segment anchors) + `refineGuidesToGap` so a guide's
  line and distance read to the candidate's nearest edge, not the far side.
- `bboxCornerAnchors` + corner-only footprint anchors so alignment locks to
  item edges, never centrelines.
- `resolvePointSnap` (point-coincidence variant; kept for future use).

Editor:
- `Alignment3DGuideLayer`: dashed ribbon + flat floor dots + distance pill,
  in the project's indigo accent, mounted inside ToolManager's building-local
  group so guides render in the cursor's frame.
- Producers wired in the item move tool, item placement coordinator, and the
  wall + fence endpoint tools; walls and fences cross-align.

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

* fix(editor): align guides snap to nearest real anchor, drop bbox re-span

The 3D alignment guide could place its end dot in empty space: a diagonal
wall (or any rotated / non-rectangular object) has bounding-box corners that
don't lie on the object, and `refineGuidesToGap` re-spanned the guide to
exactly those AABB edges — so the dot floated "along the coordinate" rather
than on the item.

- `resolveAlignment` now tie-breaks to the candidate anchor NEAREST on the
  perpendicular axis (after the tightest axis match). Anchors are real points
  (corners / endpoints / midpoints), so the guide always connects to the
  closest actual point — which also yields the facing-edge gap distance.
- All four producers (item move, item placement, wall + fence endpoints) now
  publish the raw resolver guides; the AABB nearest-edge re-span is gone.
- Removed the now-dead `refineGuidesToGap` and `resolvePointSnap` helpers
  (and their tests / exports).

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

* feat(editor): group move handle + shared group-transform core, move-tool polish

Add a group-move gizmo alongside the existing group-rotate handle, both
driven by a new shared `group-transform-shared` module (participant
classification, group-box + corner math, connected wall/fence component
expansion so attached structure transforms rigidly as one piece).

- core: refactor alignment-anchors collection + tests, extend handle registry
- editor: group-move-handle, group-transform-shared; rotate handle reuses them;
  node-arrow-handles gains click-swallow guard; box-select + placement tweaks
- nodes: move-tool updates across ceiling/column/slab/roof/registry; door math
  and panel adjustments; item definition cleanup
- nodes(fence): play `sfx:grid-snap` ticker on endpoint move, matching the
  wall endpoint tool (fixes missing audio feedback on fence side drag)

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

* fix(editor): keep autosave alive across page unload

The autosave debounces writes by 1s and relies on a `beforeunload` flush
for anything still pending. That flush fired a plain `fetch` PUT, which
the browser cancels the instant the page unloads — so refreshing right
after an edit (e.g. painting a roof material) silently dropped the change
and the reload showed the last persisted scene.

Thread a `{ keepalive }` option through the save callback and set it on
the unload flush so the request survives the unload. Also listen for
`pagehide` (fires where `beforeunload` does not, e.g. mobile Safari /
bfcache) and clear the dirty flag up front so the two listeners don't
double-send. Normal debounced saves omit `keepalive` (its 64KB body cap
only constrains the best-effort unload flush, not regular saves).

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

* feat(editor): paint eraser + reset-all, drop roof cross-role bleed

Material paint gains an eraser (clear a surface back to its default) and a
"Reset all" action that defaults every painted surface on a node — for a
roof that includes each child segment — via a generic
`buildResetSurfaceMaterialUpdates` that nulls catch-all and role-specific
material fields without per-kind knowledge.

Also stop a single painted roof surface from bleeding onto the others:
`getEffectiveRoofSurfaceMaterial`, `getRoofMaterialArray`, and the segment
renderer no longer cross-fall-back between top/edge/wall. An unset role
resolves only to the legacy catch-all (back-compat) or the theme default,
so painting just the shingle, trim, or soffit stays on that surface.

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

* feat(editor): alignment guides + drag bounding box across tools

Extend the Figma-style 3D alignment guides to the placement and move
tools for columns, elevators, roofs, stairs, ceilings, slabs, fences,
walls, doors, and windows: each collects alignment anchors from the
scene, resolves a snap within the shared threshold, and drives the
`useAlignmentGuides` overlay. Wall openings (doors/windows) only snap
along their host wall via the new `wall-opening-alignment` helper.

Add a shared `DragBoundingBox` overlay (exported from the editor barrel)
that renders the dragged object's bounds during a move, wired into the
column move tool alongside the alignment snap.

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

* fix(editor): axis-stable resize-arrow drag plane + slimmer gizmo handles

Build the linear resize-arrow's drag plane so it always contains the
handle's axis (view direction minus its along-axis component) instead of
a plane that merely faces the camera. The old camera-facing normal
collapsed when the axis pointed toward the viewer — screen motion barely
changed the axis component, so the resize crawled or stopped tracking the
cursor. Also slim the extruded arrow/handle geometry (shared by the node
arrows, wall side handles, and polygon editor) for a lighter gizmo.

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

* fix(editor): fill-block wall opening highlight + selected frameless openings

Draw the selected-wall opening highlight as a translucent block filling
the cutout volume (front-side culled) instead of a single vertical pane,
so it reads as an occupied slot from any angle — including a top-down
floorplan view where an edge-on pane was invisible. Also highlight a
directly-selected frameless opening (a `door` with openingKind
`'opening'`), which otherwise renders no geometry of its own, and reflect
live drag overrides via `useLiveNodeOverrides`.

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

* fix(viewer): double-side slab hole side-walls

Build the slab in 3D rather than via ExtrudeGeometry so each hole-wall
quad is emitted twice with opposite winding. The slab material is forced
to FrontSide (DoubleSide poisons the MRT scene pass), under which
ExtrudeGeometry's single-sided hole walls get back-face culled and you
see straight through the cut. The doubled quads keep the cut's inner
thickness visible from any angle without z-fighting.

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

* fix(editor): box-select building-scoped nodes like elevators

Building-scoped selectable nodes (e.g. elevators) are children of the
building, not the active level, so the level walk never reached them.
Also walk the level's building children and box-test any registry-
selectable kind by its rendered bounds, matching the column/stair/shelf
path.

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

* feat(editor): shelf placement alignment + column placement ghost

Shelf placement now snaps to Figma-style alignment guides by its
footprint edges (layered on grid snap, Alt bypasses), matching the
existing 3D move tool. Guides refresh after each drop and clear on
teardown.

Column placement migrates to the registry `def.tool` path so it can
render a translucent column ghost at the cursor (like the shelf build
tool) instead of a bare cursor sphere — the editor package can't import
the column geometry, so the tool now lives in packages/nodes:
- extract `ColumnBody` from the renderer and add a `ColumnPreview`
  (cloned translucent material, raycast disabled, origin-positioned)
- new `column/tool.tsx` registry placement tool with the same
  footprint-edge alignment as shelf / column move
- wire `def.tool` + tool hints; drop the now-unreachable legacy
  editor-side `ColumnTool` and its dead file

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

* feat(editor): floor-plan alignment, pivot moves & placement ghosts

Bring the 3D editor's Figma-style alignment experience to the 2D floor
plan across every node kind, fix pivot semantics on move, and add 2D
placement ghosts.

Alignment
- Wall anchors now include ±thickness/2 face corners so columns/items/etc.
  snap flush to wall faces (fixes pillar↔wall); shared by 2D and 3D.
- Shared apply-alignment helper (applyFloorplanAlignment /
  alignFloorplanDraftPoint, with excludeIds) used by move sessions,
  structural drafting (wall/fence/slab/zone/ceiling/roof), and wall/fence
  endpoint drags.
- Door/window/wall-item moves get along-wall edge-to-edge snapping.
- Generic free-translate move path aligns by edges (corner anchors).

Pivot moves (2D)
- Polygon kinds (slab/ceiling/zone) move by centroid→cursor via a shared
  polygon-centroid mover; stair moves by origin→cursor; matching 3D.
- Shelf/column move targets write position directly (single source of
  truth) so the 3D group no longer sticks on commit.

Placement ghosts (2D)
- usePlacementPreview store + FloorplanPlacementPreviewLayer render a
  kind's def.floorplan footprint following the cursor; wired for column
  and elevator.

Fixes
- Elevator placement no longer deselects the active floor plan
  (preserve levelId through setSelection's hierarchy guard).
- Guides clear on every commit/cancel/unmount path.

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

* refactor(editor): address architecture review for floor-plan work

- Move usePlacementPreview store from core to editor: placement ghosts
  are an editor/tool concern the read-only viewer never needs. Rewire the
  column tool (via the @pascal-app/editor public surface) and the
  editor-internal elevator tool + preview layer (relative imports).
- FloorplanPlacementPreviewLayer: read scene lazily in ctx.resolve instead
  of bulk-reading the nodes map during render.
- wiki/architecture/tools.md: refresh the stale useLiveTransforms-per-kind
  note to reflect item/shelf/column (world-plan) + slab/ceiling/zone (delta).

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

* fix(nodes): recreate window draft on wall:move when null after placement

After a successful click-to-place, the click handler deletes the
transient draft and relies on the wall-rebuild → R3F pointer-enter
cascade to create a fresh draft for the next placement. If that cascade
doesn't fire synchronously (e.g. async geometry rebuild) the next
wall:move receives a null draftRef and bails — requiring leave/re-enter
to place again.

Fix: in onWallMove, when draftRef.current is null but we're hovering a
valid wall, recreate the draft immediately (same WindowNode.parse +
createNode path as onWallEnter). This is idempotent: if wall:enter does
fire first, destroyDraft() in onWallEnter cleans up cleanly.

Preserves parity with door multi-place behaviour, matching #367's intent.

* fix(nodes): recreate door draft on wall:move when null after placement

Mirror of the window fix one commit back: after click-to-place the
DoorTool deletes its transient draft and relies on the wall-rebuild \u2192
R3F pointer-enter cascade to spawn a fresh draft for the next placement.
When that cascade doesn't fire synchronously, the next wall:move sees a
null draftRef and bails \u2014 forcing a leave/re-enter to place again.

Recreate the draft in onWallMove when null and over a valid wall on the
current level. Idempotent with onWallEnter (destroyDraft cleans up if
both fire).

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Pascal <open@pascal.app>
2026-06-04 14:03:00 -04:00
ee98c55b65 Polish: window preset preview + smooth shelf item placement + Shelf build tool (#367)
* fix(nodes,viewer): window preset live wall preview + shaped-cutout move tracking

MoveWindowTool marked the moving window `isTransient` unconditionally, but
WindowSystem only rebuilds the host wall's cutout for non-transient windows —
so a window *preset* (isNew) showed no live hole on the wall and couldn't be
placed consecutively without leaving/re-entering. Guard the transient mark on
`!isNew`, matching MoveDoorTool.

Separately, shaped openings (arch / rounded / `opening`) rebuild their cutout
brush from `node.position`, which a same-wall move doesn't write (it mutates the
mesh directly and publishes to `useLiveTransforms`). The wall-system's
`getEffectiveNode` only merges `useLiveNodeOverrides` (resize arrows), so shaped
cutouts lagged the move while rectangular ones (rebuilt from the live mesh
matrixWorld) tracked. Fold `useLiveTransforms` into door/window children before
collecting cutouts so shaped holes follow the live move too.

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

* fix(editor,viewer,nodes): smooth item placement on shelves

Three issues made hosting an item on a shelf janky (item-on-item was already
smooth because plain items have no `def.geometry` to rebuild):

- Jitter: the draft mesh intercepted the cursor ray, so the shelf-row hit was
  re-derived from the moving item each frame. Disable raycasting on the draft
  during placement (incl. async GLB children, reconciled per frame) so the ray
  passes through to the surface beneath — mirrors MoveRegistryNodeTool.

- Reparent/vanish at the edges: `onShelfLeave` flipped state to floor without
  reparenting the draft off the shelf, so the floor strategy's level-local
  position rendered compounded with the shelf transform. The grid handler now
  owns the shelf→floor transition.

- In/out oscillation: reparenting the draft onto the shelf dirtied the shelf,
  and GeometrySystem disposed+rebuilt its boards, making r3f fire a spurious
  shelf:leave→enter that thrashed placement between the row and the floor. Add
  an opt-in `def.geometryKey` so GeometrySystem skips the rebuild when geometry
  inputs are unchanged (shelf boards don't depend on hosted children). Keep the
  item sticky by testing the cursor ray against the shelf's bounding box: a ray
  that slips through a gap and lands on the floor behind the shelf still counts
  as "on the shelf"; only a ray that misses the shelf detaches to the floor.

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

* feat(editor-app): add Shelf to the standalone editor Build tab

The shelf kind is fully wired (def.tool, presentation icon, StructureTool id)
but was absent from the standalone editor's Build palette. Add it between
Column and Spawn Point. Community has its own build-tab and is left untouched.

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

* chore(mcp): biome-format coordinate-conventions-demo.json

The committed example violated Biome formatting (expanded polygon arrays),
which broke the `mcp-ci` Biome check on main (#356) and every branch since.
Format it so CI is green. Pure formatting — no content change.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:10:22 -04:00
AntonandGitHub d926dede78 fix(viewer): normalize legacy door nodes against the schema before render (#303)
Legacy/unparsed door nodes can miss schema-defaulted fields (segments, columnRatios, dividerThickness, panelInset/panelDepth …) and crash the door geometry build (EDITOR-AM). `updateDoorMesh` re-applies the Zod defaults once at entry via `DoorNodeSchema.safeParse` (with a drop-bad-segments retry and a full-defaults fallback), so every downstream read sees populated data — including the shaped-top divider path #334 added. Also normalizes schema-valid legacy doors at load in `migrateNodes`, mirroring the existing stair normalizer, so the door panel and window system see defaulted data too.

Reimplemented against main: the original used `segments ?? []` (wrong — legacy doors rendered an empty frame) and missed the #334 `dividerThickness` sites. Verified: `bun run check-types` clean, biome clean on touched files.
2026-06-03 16:07:32 -04:00
eb1f2d12c2 feat(nodes): roof accessories — gutters, downspouts & vents + gizmos (#355)
* 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

* fix(editor): ceiling-attached item placement from 2D floor plan

The 3D viewer drives ceiling-item placement via ceiling:enter/move/click
raycast events on the ceiling mesh. The floor plan has no such mesh, so
ceiling-attached items (lights, fans) never transitioned out of
surface: 'floor' — the draft sat at floor height while the 2D cursor
moved freely, reading as a 2D/3D sync bug.

Synthesise the same ceiling events from 2D plan points by hit-testing
ceiling polygons on the active level, and publish the building-local
cursor (not world-space) to useLiveTransforms so the floorplan registry
override renders the draft under the cursor regardless of building
position / rotation.

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

* fix(editor): chevron arrows render on SCENE_LAYER so ink-edge shader outlines them

Node arrow handles and polygon-editor edge arrows were tagged for
EDITOR_LAYER, which hides them from the post-processing scenePass — the
ink-edge shader reads the depth/normal MRT from that pass, so the
chevrons rendered flat with no outlined edges. Drop the EDITOR_LAYER
tagging on both, matching the wall-height arrow which already stays on
SCENE_LAYER for the same reason.

Pair with depthWrite: true on the chevron materials so their silhouettes
enter the depth buffer; depthTest stays off to keep the chevron drawn
on top of underlying geometry. Without depthWrite, only the
normal-discontinuity branch of the ink shader can detect the chevron,
and the lines drop out when faces align with whatever sits behind them
in screen space.

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

* fix(editor): ceiling grid overlay no longer blocks selecting items under it

Two issues kept the ceiling grid overlay covering the room after the
user moved on from a ceiling-related action:

1. CeilingSystem treated any selected descendant of a ceiling as
   "reveal the grid" — so after placing a ceiling light and the new
   item became selected, the grid stayed on and its mesh intercepted
   every subsequent 3D click, re-selecting the ceiling instead of the
   items below. Restrict the reveal to directly-selected ceilings.

2. The ceiling top material used the opaque surface-role material, so
   a top-down camera lost view of everything under the ceiling the
   moment the overlay turned on. Swap the top material for the
   transparent grid-pattern material (bottom stays opaque so the
   in-room view still reads as a solid surface).

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

* fix(core): curve- and thickness-aware wall/slab overlap detection

`wallOverlapsPolygon` and `getSlabElevationForWall` previously treated a
wall as an infinitely thin chord from start to end. Two failure modes
fell out of that:

1. Curved walls whose chord lies outside the slab but whose centerline
   bows into the slab interior were missed entirely. The wall stayed at
   Y=0 while the slab elevation moved, and `markNodesOverlappingSlab`
   never re-dirtied it when the slab Y changed.

2. Perimeter walls of a room — whose centerline sits exactly on (or just
   outside) the slab's polygon edge — also missed detection, because
   pointInPolygon on the boundary is unreliable. Half the wall's body is
   inside the slab; it should follow the slab elevation.

Switch `wallOverlapsPolygon` to a wall-shaped input (start/end + optional
curveOffset + thickness), sample the centerline for curved walls, and
add a ±halfThickness perpendicular test for straight walls. Threaded
through `getSlabElevationForWall`, the wall system, and the
`markNodesOverlappingSlab` pass. Legacy chord-only call shape preserved
for callers that don't yet have a wall in hand.

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

* fix(editor): show Move button for legacy-movable kinds in floating action menu

`isRegistryMovable` only sees kinds wired through `capabilities.movable`,
`floorplanMoveTarget`, or `affordanceTools.move`. The legacy tail of
`MoveTool` (tools/item/move-tool.tsx) still handles roof, roof-segment,
stair, stair-segment, building, and elevator, but the floating action
menu was hiding the Move button for them because the registry check
returned false. The mover worked once invoked — the entry point was
missing.

Add a `LEGACY_MOVABLE_KINDS` set alongside the registry check so those
kinds get the Move button until they migrate onto kind-owned
affordances; drop a kind from the set once it does.

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

* feat(nodes): in-world registry handles for roof accessories + axis-stable surface basis

Adds the registry-driven chevron / tracker / rotate-gizmo handle set to
skylight, solar-panel, chimney, and roof-segment so every roof-mounted
kind has consistent in-world manipulation, and fixes the underlying
renderer + surface-basis bugs that made those handles land on the wrong
spot or behave inconsistently across mirrored slopes.

Handles
- `LinearResizeHandle` gains `shape: 'arrow' | 'tracker'`. `'tracker'`
  renders a dashed vertical leader from the surface up to a draggable
  cube, reusing the linear-resize drag pipeline. Roof-segment's
  wall-height handle adopts it.
- Roof-segment: width chevrons split into two asymmetric handles
  (each grows its own edge, opposite stays world-fixed via `apply`
  recomputing `position`).
- Skylight: width × 2 (asymmetric), height × 2 (asymmetric), curb-height
  tracker, rotate gizmo (corner, lifted off surface), and a diagonal
  frame-thickness chevron at the -X+Z corner.
- Solar panel: same six handles operating on total array dimensions
  (back-solving `panelWidth` / `panelHeight` from `columns` / `rows`),
  plus a frame-depth chevron above the array.
- Chimney: registry handle set following the same idioms.

Skylight / solar-panel renderer
- Collapse the previously nested `position → surfaceQuat → rotation-y
  → rotation-x` groups into a single registered transform group whose
  local `position` + composed `quaternion` carry the full pose in
  segment frame. The registry handles read this Object3D's local
  matrix (via `portal: 'grandparent'`), and a split tree exposed only
  the bottom group's local pose so handles landed at the segment
  origin on the roof floor.

Surface basis (solar-panel/geometry.ts)
- `surfaceQuatFromNormal` builds `right` by projecting world +X onto
  the surface plane instead of `up × normal`. The cross-product version
  flipped sign when the normal's Z component flipped (e.g. the two
  slopes of a gable roof), so hosted children's local +X pointed in
  opposite world directions across the ridge and asymmetric chevrons
  anchored the wrong edge. Projecting +X keeps the basis stable across
  mirror-image slopes.

Skylight move-tool ghost
- Switch from the raycast normal (`event.normal × normalMatrix`) to the
  analytical normal (`getAnalyticalNormal`) on every pointer move, and
  mirror the placement tool's transform stack: `position → yaw (roof +
  segment) → surfaceQuat → skylight rotation → preview`. Re-engaging
  Move from the floating action menu now shows the same correctly
  oriented ghost the first-placement tool does.

* feat(nodes): split roof-segment depth chevron into asymmetric front/back arrows

Brings the depth handle in line with the width handles: one chevron on
each Z edge, each anchored to the opposite edge so dragging only moves
its own side. `apply` recomputes `position` along the segment's local
+Z arm (yaw-aware) so the anchored edge stays world-fixed.

Depth also feeds the slope-frame math via `getActiveRoofHeight`, so a
naïve depth change would also raise/lower the peak (constant pitch
across a larger run). We hold the peak fixed by back-solving a new
`pitch` for the new depth via `getPitchFromActiveRoofHeight`, clamped
to the schema's pitch range — the segment grows along the deck plane
without ramping up.

* feat(nodes): in-world handles for dormer + window-bottom clipping check

Dormer joins chimney / roof-segment with chevron handles on the
selected node. Five body handles (width L/R, depth, wall-height
tracker, rotate) plus four window-opening handles (width L/R,
height top/bottom) — the window handles re-emit windowOffsetX /
windowOffsetY in `apply` so the anchored edge stays put as the
dragged edge follows the pointer.

Handle visibility on roof accessories needed an editor-side assist:
the host segment's mesh registers inside RoofRenderer's
`<group segments-wrapper visible={false}>`, which hides anything
portaled into it. Chimney's `portal: 'grandparent'` escape trips a
WebGPU "Color target has no corresponding fragment stage output"
pipeline error on dormer (likely an MRT interaction with the
window-assembly's transparent glazing), so RoofEditSystem now flips
the wrapper visible whenever ANY accessory hosted on a segment of
this roof is selected — and resets each segment mesh to an empty
4-group placeholder on the transition so stale per-segment CSG from
a prior edit doesn't double-render on top of the merged shell.

Window clipping was using wall-top-above-slope as the exposure
threshold, but the window sits in the skirt well below the eave —
so a dormer whose eave barely cleared the host roof rendered a
fully-buried window. `getDormerExposedFaces` now gates on
window-bottom-above-slope; both the CSG cut decision and the
window-assembly render path feed off the same number. The
in-world window chevrons resolve the host segment via sceneApi and
flip to whichever face is currently exposed, so dragging the
dormer across the ridge moves the chevrons to the visible gable.

Also fixes the BoxGeometry vs ExtrudeGeometry mismatch in
`buildDormerFallbackGeometry` — body was indexed, roof was not, so
`mergeGeometries` rejected the pair and spammed the console on every
height-drag frame. Body is now `.toNonIndexed()` before the merge.

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

* fix(viewer): glazing role uses FrontSide to avoid MRT back-face pipeline error

DoubleSide on a NodeMaterial inside the MRT scene pass makes WebGPU
compile a back-face shader variant that doesn't declare outputs for
every MRT target — the validator rejects it and poisons the render
context with "Color target has no corresponding fragment stage output".
The warning was already documented on `glassMaterial` (materials.ts:77),
but `createSurfaceRoleMaterial` was still forcing DoubleSide for the
glazing role.

Manifested on scene open as soon as a dormer was present: the dormer's
window-assembly mounts the glazing material on both gable faces on the
first frame, so the back-face pipeline gets compiled immediately.

Glazing now resolves to FrontSide; the dormer's back gable group flips
180° so its FrontSide normals point outward (the sill no longer needs
its per-face Z mirror since the group rotation handles it).

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

* feat(nodes): in-world handles for box-vent + ridge-vent; freeze non-active arrows during drag

Adds 5 chevron handles to box-vent (width L/R, depth, height, rotate)
and ridge-vent (length L/R, width, height, rotate). Box-vent's renderer
now composes slope tilt + yaw onto the registered ref's quaternion
(mirrors solar-panel) so handle placements use vent-mesh-local coords
directly; ridge-vent's registered ref was already at the vent frame.

Ridge-vent renderer now merges `useLiveNodeOverrides` so the mesh
updates in-flight during a handle drag instead of freezing until commit.
Box-vent / dormer / chimney already did this; ridge-vent was the only
roof accessory not subscribed.

`NodeArrowHandles` now tracks the active drag descriptor + a pre-drag
store snapshot. Non-active arrows render against the snapshot with a
node-local freeze offset that cancels the mesh's `position` drift —
asymmetric resize (width / length L+R) recomputes position to anchor
the opposite edge, and without the freeze every other chevron would
slide along with the moving mesh center. The active arrow's freeze
offset is null, so it tracks the cursor as before. Rotation drags
collapse the offset to zero (position doesn't change), so non-active
chevrons naturally rotate with the mesh.

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

* feat(nodes): gutter accessory — eave-mounted rain channel with three profiles

New `gutter` node kind hosted on a roof-segment. Placement tool snaps
to the eave line of the segment under the cursor (segment-local
`Z = +depth/2`, `Y = wallHeight`); the back wall of the gutter then
sits flush against the fascia and the trough hangs outward (+Z).

Three cross-section profiles share the same outer-outline-minus-cavity
extrude recipe and only differ in the outline curve:

  - `k-style`:    ogee fascia (S-curve) — default residential look
  - `half-round`: semicircular trough — colonial / classical feel
  - `box`:        rectangular u-channel — commercial / industrial

Three in-world chevron handles via the registry:

  - length L + R (asymmetric — drag one end, the other stays world-fixed)
  - size (anchor='max', drops the trough downward as the cursor pulls)

Wiring touches every node-kind ledger: schema (core/schema/nodes/
gutter.ts), AnyNode union, schema barrel, material targets, roof-segment
hosted-accessory comment, event bus (`gutter:*`), nodes barrel +
registry, plus the per-kind file set under `packages/nodes/src/gutter/`
(geometry, schema re-export, parametrics, renderer, preview, tool,
definition, index).

V1 ships gutters only — downspouts deferred so the eave-snap +
cross-section pipeline can be eyeballed before stacking the
downspout-corner placement logic on top.

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

* feat(gutter): roof-panel entry, eave-edge snap, open-top U-channel, move tool

UI surface area:
- Roof inspector "Elements" section now lists existing gutters + an
  "Add Gutter" button. Adds `'gutter'` to the StructureTool union so
  setTool('gutter') typechecks.
- Sidebar tree-node map gets a GutterTreeNode entry; selecting a
  gutter in the outline focuses it just like other roof accessories.
- Floating action menu now shows a Move button on a selected gutter
  via the new `affordanceTools.move`. MoveGutterTool ghost-follows
  the cursor, eave-snaps on each frame, and commits to the new
  segment + side on click. Mirrors the ridge-vent move flow.

Geometry fix:
- Three cross-sections were authored as a closed outline + inset
  hole, which extrudes as a sealed box with a tunnel through it
  (top sealed). Real gutters need an OPEN top. Each profile now
  traces a single U-shape polygon around the channel material:
  outer wall down -> bottom -> outer wall up -> front rim ->
  inner wall down -> inner bottom -> inner wall up -> back rim.
  The interior of the U is empty space, not a hole inside a
  closed shape.

Placement fix:
- Snap now lands on the OUTER drip edge of the roof, not the wall
  line. Segment-local Z = sign * (depth/2 + overhang - 4 cm tuck),
  Y = wallHeight - overhang * tan(pitch) + 4 cm tuck. Sign of the
  cursor's localZ picks the near eave; back eave uses rotation = pi
  so the trough hangs outward in both directions. The 4 cm tuck
  offsets keep the gutter visually attached to the fascia rather
  than floating at the very tip of the overhang.

Hook order fix (regression from the previous commit):
- `NodeArrowHandlesForNode` had its new useState/useMemo hooks
  AFTER the `if (!portalObject ...) return null` guard. The
  registry-resolve useEffect flips portalObject from null to object
  one frame later, so the guard passed on render N+1 and three new
  hooks suddenly appeared in the hook list. Moved them above the
  early return.

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

* fix(viewer/nodes): drop DoubleSide NodeMaterial MRT landmines across slab, vents, gutter, window

DoubleSide on any NodeMaterial inside the MRT scenePass makes WebGPU
compile a back-face shader variant whose fragment outputs don't cover
every MRT target — the validator rejects the pipeline and poisons the
render context with "Color target has no corresponding fragment stage
output but writeMask is not zero", manifesting as
renderPipeline_NNN invalid on scene open. The pattern was already
documented at materials.ts:77 and fixed for glazing in 9400f1c5, but
several roof / floor renderers still requested DoubleSide on
`createSurfaceRoleMaterial` (which returns a `MeshLambertNodeMaterial`)
and on user-supplied materials (which may also be NodeMaterials):

- slab/geometry.ts — fired on every untextured floor; the live culprit
  on scene reload after the gutter renderer was switched to FrontSide.
- gutter/renderer.tsx — the U-channel cross-section is traced as a
  single closed polygon around the material, so ExtrudeGeometry already
  produces outward-facing normals on every visible face; DoubleSide was
  speculative.
- box-vent / ridge-vent renderers — DoubleSide was deliberate (to keep
  back faces of thin extrudes visible from below); now a known visual
  tradeoff. Build the geometry as a closed solid in `geometry.ts` if
  the underside-view becomes noticeable; do not bring DoubleSide back.
- viewer/lib/materials.ts `DEFAULT_WINDOW_MATERIAL` — same fix on the
  fallback window material.

Local `defaultMaterial` constants in box-vent / ridge-vent / gutter
also lose their `side: DoubleSide` for consistency (those are
`MeshStandardMaterial`, hit only when a preset ref fails to resolve,
but the same landmine pattern).

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

* feat(editor): Shift-snap rotate gizmos to 15° increments

Holding Shift while dragging a whole-node rotation arrow now snaps the
delta to π/12 (15°) steps. Scoped to `descriptor.shape === 'rotate'`
so curved-stair sweep handles keep their continuous feel.

* feat(nodes): stair railings track live segment-drag overrides

`StairRailings` was reading each child segment from zustand only, so
width/length/height drag handles (which publish to
`useLiveNodeOverrides` and only flush on release) left the railing
frozen at the pre-drag values until release. Subscribe to the override
map and merge each child's override onto its zustand snapshot so the
railing rebuilds every frame during the drag.

* chore(ifc-converter): next-env routes path moves under .next/dev/types

Auto-generated next-env.d.ts update from the local Next.js dev server —
the routes type now lives under `.next/dev/types/routes.d.ts` rather
than `.next/types/routes.d.ts`.

* fix(gutter): roofType-aware eave snap — 4-way on hip/flat, low side on shed

Gutter place + move tools were hard-coded to snap to ±Z eaves, working
for gable / gambrel / mansard / dutch but missing:

- Hip / flat: 4 eaves, not 2. Clicks on the side slopes (±X eaves)
  collapsed back onto ±Z, so users couldn't place a gutter on a hip's
  side eave at all.
- Shed: only one real eave (the low side at +Z). Clicking on the high
  wall side used to snap to -Z, which is the rake / high end with no
  fascia to hang from.

New shared `eave-snap.ts` module:

- `resolveEaveSnap(segment, localX, localZ)` returns
  `{ eaveX, eaveY, eaveZ, rotation, side }`.
- Hip / flat picker uses `max(|lx|/halfW, |lz|/halfD)` — same
  discriminator `analyticalSurfaceY` uses for hip — to pick which of
  the four slopes the cursor is on, then signs +/-.
- ±X eaves rotate the gutter ±π/2 so its outward axis points away
  from the building.
- Shed always returns +Z (the low side).
- Gable / gambrel / mansard / dutch unchanged (±Z).

Both tools collapsed their duplicated tuck constants + inlined
resolver — the "keep these in sync" comment became a landmine once
the resolver grew non-trivial.

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

* feat(gutter): end caps + corner mitre + ghost-placed parity

- Ghost/move previews mirror the GutterRenderer transform chain
  (roof → segment → snap) and use FrontSide. Removes drift between
  the placement ghost and the gutter that lands on click.

- New endCapLeft / endCapRight booleans (default true) slice a
  solid-outer plug into the extrusion at each enabled end. Inspector
  exposes both toggles; caps subtract from node.length so the
  user-set span is preserved.

- corner-mitre.ts detects sibling gutters meeting within 5 cm on the
  same segment and returns per-end mitre angles. The end-face skew
  holds back walls at the inner corner while front rims extend to
  the outer eave intersection; cap on a mitred end is force-
  suppressed so the L-junction stays open. Renderer pulls siblings
  via useShallow.

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

* feat(gutter): drag-snap corners, live eave Y, hangers

- Length L/R handles now snap to sibling gutter endpoints within 10 cm
  and pull BOTH gutters' lengths to the axis intersection — the
  geometric eave corner — so the 5 cm corner-mitre window fires
  reliably. Sibling adjustment writes through sceneApi.update; the
  drag pipeline's history pause batches it with the main commit into
  one undo step.

- Renderer derives eave Y live from segment.wallHeight, overhang, and
  pitch via the new shared computeEaveY() — instead of trusting
  node.position[1] from placement time. Subscribes to the segment's
  useLiveNodeOverrides entry too, so a wall-height drag on the
  segment moves the gutter on every frame (not just at commit).

- Hangers: new hangerStyle (strap / none) + hangerSpacing fields.
  buildHangers() lays thin 25mm × 3mm × rim-width box straps across
  the rim at the configured spacing, inset by 5 cm from each end and
  skipping any cap slabs. BoxGeometry is converted to non-indexed
  before merge — ExtrudeGeometry isn't indexed, and mergeGeometries
  rejects mixed-index sets. Inspector exposes both fields in a new
  "Hangers" group.

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

* feat(gutter): downspout outlet with real CSG-drilled hole

New schema fields: outletSide ('none' | 'left' | 'right'), outletInset,
outletDiameter. Default 'none' so existing gutters don't sprout
outlets on schema upgrade.

Geometry adds a solid cylindrical stub at bore + 3 mm wall radius
descending 6 cm from the trough floor, profile-aware Z midpoint
(k-style 0.4·size, half-round size, box size/2), X clamped between
the caps. After merging into the channel + caps + hangers, a
three-bvh-csg SUBTRACTION drills a bore-wide cylinder vertically
through the floor and stub — so the result is a real hole in the
trough floor with a hollow tube hanging through it. Drill overshoots
floor + stub by 1 cm each side to keep cut planes from coinciding
with mesh faces (csg-evaluator produces degenerate output on
coplanar cuts).

CSG only runs when outletSide ≠ 'none' — the existing merge path is
the fast path for capped-only gutters.

Inspector exposes the three fields under a new 'Outlet' group.

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

* feat(downspout): new node + gutter inspector list section

DownspoutNode lives next to the other roof accessories. Scene-graph
parent is the same roof-segment the host gutter sits on; logical
attach is via `gutterId`. Schema: length (default 2.5 m), diameter
(default 0.07 m / 3″ — matches gutter outlet default), material.

Renderer mounts a CylinderGeometry under the same transform chain
the gutter uses (segment → gutter-mesh-local → outlet); pulls
`computeEaveY` from the live + drag-override segment so wallHeight /
overhang / pitch changes track on the same frame as the gutter.
`resolveGutterOutletPlacement` (gutter/outlet-lookup.ts) is the
shared helper both the downspout renderer and the inline Add path
use to compute (x, y, z, bore) in gutter-mesh-local space.

Two arrow handles on a selected downspout:
- length: tracker shape (dashed leader + draggable cube). Anchored
  to the outlet, cube at the pipe bottom — readable even when the
  cube ends up below ground.
- diameter: symmetric `z`-axis chevron sitting at a fixed −20 cm Y
  below the outlet with 25 cm of outward clearance past the worst-
  case k-style rim, so it stays inside the gutter's camera frame
  instead of floating mid-pipe.

Inspector UX: new optional `trailingSection` slot on
ParametricDescriptor — a lazy-loaded React subsection rendered
between groups and the Actions section. Gutter's slot loads a
`downspouts-panel` that lists every attached downspout (button per
item, click → select), and an "Add Downspout" button below that
immediately creates a new one parented to the gutter's segment —
matches the roof inspector's gutter list pattern. Disabled with a
helper line when `outletSide === 'none'`. Multiple downspouts per
gutter are allowed.

`StructureTool` union picks up 'downspout' so the placement tool
remains addressable (used by the legacy roof-panel button before the
inspector list took over).

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

* chore: pending floorplan alignment-guide work-in-progress

Snapshot of in-flight alignment-guide files that have been sitting in
the working tree (not authored in this session). New service +
zustand store wire up the data; the floorplan layer + overlay are
the visible consumers. Committed as-is to clear the tree.

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

* feat(core): gutter multi-outlet model + downspout routing options

Replace the gutter's single outletSide/outletInset/outletDiameter triple
with an `outlets` array of `{ id, offset, diameter }` so one run can host
several downspouts on independent drops instead of stacking on one. Each
downspout links to an outlet by `outletId`.

Add downspout routing/styling fields: `standoff` (gap proud of the wall),
`shape` (auto/round/rect), `strapStyle`/`strapSpacing`, and `terminal`
(splash/kickout/straight).

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

* feat(editor): in-world rotate & move gizmos with live-drag dimension pill

Add two new handle-descriptor capabilities to the core registry — a free
`translate` handle (ground-plane or wall-normal move cross) and
`rotationPlane` for arc-resize (yaw vs spin-flat-against-wall) — plus
`measureLabel`, which routes a resize handle's readout to a floating
dimension pill instead of its inline chip, and `overrideTarget`, a
cross-node redirect for handles that edit a sibling's value.

node-arrow-handles implements all four, merging the in-flight drag into
`useLiveNodeOverrides` so the mesh moves in real time and commits only on
release. Item gains in-world rotate + move gizmos (floor items: world-Y
rotate + floor-plane move; wall items: wall-normal spin + wall-face move).

Add the shared MeasurementPill (H · L · T) and formatMeasurement, wired
into the floating action menu (live wall/fence height drag) and the wall /
fence endpoint move tools. Side handles merge live overrides so every
affordance tracks the height mid-drag. Item duplicate now drag-to-places
(no auto-insert) and the placement coordinator rotates in 45° steps to
match the R-key step.

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

* feat(gutter): multi-downspout outlets with CSG drops, routing & profiles

Build out the multi-outlet gutter against the new schema. Each outlet is
drilled through the trough floor via CSG (profile-geometry shares the
trough cross-section), and a downspout links to one outlet by id so
several no longer stack on a single drop. The downspouts panel manages the
outlet list; outlet-lookup resolves a downspout's mount from its gutter +
outlet.

Downspout gains real routing (routing.ts): offset elbows step the run back
from the eave overhang to the wall (standoff escape hatch), auto/round/rect
cross-section following the gutter profile, wall straps, and splash /
kickout / straight terminals, with inspector-editors for the new fields.

Length-snap now snaps only the dragged gutter to the geometric corner —
never moving its corner-mate — so dragging one gutter can't reset another
the user placed deliberately. Adds gutter floorplan (eave-line silhouette).

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

* feat(floorplan): 2D footprints for roof nodes + rotate-handle angle wedge

Add floorplan builders for box-vent, chimney, dormer, ridge-vent, roof,
skylight, and solar-panel, and wire each into its definition, so roof-layer
nodes finally draw a 2D footprint. Roof draws the merged silhouette of its
child segments, and roof-segment now renders proper architectural roof
linework (ridge / hip / break + shed downslope arrow) per shape instead of
a bare rectangle.

Add a `pivot` to the floorplan rotate affordance so the layer can sweep a
live angle wedge + degree readout during a rotate drag — the 2D twin of the
3D rotate gizmo. Column / elevator / shelf / stair pass their pivot through.

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

* fix(editor): select ceiling only via its corner handles, not the grid body

Add `viaHandle` to NodeEvent and set it on ceiling corner-bracket clicks.
The selection manager now ignores non-handle ceiling clicks without
stopping propagation, so a top-down click on the revealed ceiling grid
falls through to the item hosted beneath it instead of re-selecting the
ceiling and swallowing the click. The corner brackets draw with depthTest
off at a high render order so they stay visible and clickable through
occluding geometry.

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

* feat(editor): highlight wall openings on a selected wall

Add WallOpeningHighlights — an indigo accent frame + translucent pane
drawn around each door / window opening of the selected wall, so editable
children (including frameless openings with no visible geometry) are easy
to locate. The accent is deliberately distinct from the white selection
outline, and draws with depthTest off so it reads on top of the wall.

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

* fix(solar-panel): derive surface frame live so panels re-seat on roof changes

Compute the panel's Y and tilt from the parent roof-segment's finished
(deck + shingle) surface every render via getRoofOuterSurfaceFrameAtPoint
— the same helper skylights use — instead of reading the stored
position[1]/surfaceNormal snapshot. Merging the segment's live overrides
means the panel re-seats and re-tilts continuously during a wall-height /
pitch drag rather than floating or burying until the value commits.

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

* fix(first-person): coerce imported item geometry attrs to Float32 for collider merge

mergeGeometries requires every merged geometry to share the same typed-array
constructor per attribute. Imported item GLBs using KHR_mesh_quantization or
interleaved buffers broke the merge against Float32 wall/slab geometry, so
decode each attribute into a plain non-normalized Float32 BufferAttribute
before cloning into the collider world.

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

* fix(editor): lower camera minimum zoom distance to 6m

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

* chore(mcp): bump @pascal-app/mcp to 0.3.0 in lockfile

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

* refactor(nodes): registry-own move dispatch + hoist shared roof helpers

Addresses the architecture-review findings on the gutter/downspout branch:

- Move dispatch: port the bespoke roof/roof-segment/stair/stair-segment
  movers (MoveRoofTool) and the building mover (MoveBuildingContent) into
  @pascal-app/nodes (shared/move-roof-tool.tsx, building/move-tool.tsx) and
  declare them via `affordanceTools.move`. Delete both hardcoded dispatch
  lists — `LEGACY_MOVABLE_KINDS` in floating-action-menu and the roof/stair/
  building arms of MoveTool. `onMove` is now purely `isRegistryMovable`.
  Editor internals the movers need are exported from @pascal-app/editor
  (adds clearRoofDuplicateMetadata); sfxEmitter.emit -> triggerSFX. elevator
  keeps its existing capabilities.movable path (its legacy arm is the lone
  remaining one, now documented).

- Cross-kind imports: hoist resolveRoofSegmentHit (roof/segment-hit.ts) and
  the roof-surface normal math (getSurfaceY/getAnalyticalNormal/
  surfaceQuatFromNormal, formerly in solar-panel/geometry.ts) into
  packages/nodes/src/shared/, so the 8 roof accessories + skylight/box-vent
  stop reaching into sibling kind folders. roof/index and solar-panel/index
  re-export from shared so public surfaces are unchanged.

- Inspector: make ParametricInspector action `enabledIf` reactive by
  subscribing to its boolean result (ParamActionButton), matching the
  existing FieldRenderer/visibleIf pattern.

Type-checked (tsc) and linted (biome) across editor + nodes. Move behaviour
is preserved by construction but not yet runtime-verified in the editor.

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

* feat(nodes): add cupola + turbine-vent roof accessories; group-rotate gizmo; in-ring rotation readout

New roof-accessory node types and editor gizmo work:

- feat(nodes): cupola (roof lantern) and turbine-vent (whirlybird) node
  types — schema, geometry, parametrics, renderer, panel, move/placement
  tools, floorplan, and tests; registered in the node + core schema indexes
  with site-panel tree nodes.
- feat(editor): group-rotate handle — a single rotation gizmo for 2+ movable
  nodes that spins the selection rigidly about its shared bbox center.
- fix(editor): the live rotation readout (degree wedge + chip) now renders as
  a child of the node frame, concentric and coplanar with the guide ring, so
  it sits centered in the ring on pitched roofs instead of floating off to the
  side. Flat-ground gizmos and the group-rotate readout are unchanged.
- refactor(nodes): rework box-vent / ridge-vent geometry + definitions and
  share roof helpers; tidy roof/box-vent panels.

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

* feat(nodes): eyebrow-vent kind; ridge-vent tracks roof height; vent paint + tree-node go registry-driven

New eyebrow-vent roof accessory plus two follow-on cleanups:

- feat(nodes): eyebrow-vent — a louvered roof vent in three styles (`scoop`
  swept eyebrow, `half-round` D-vent, `slant-box` hooded box). Solid louver
  slabs set into a framed/contained front, double-sided geometry, and
  adjustable style / louver-count / dimensions / slant (`backRatio`). Full
  registry wiring: core schema, node dir (definition/geometry/renderer/tool/
  move-tool/panel/floorplan/preview/tests), event type, palette button.
- fix(nodes): ridge vent derives its Y from the segment's current surface
  (`getSurfaceY`) instead of a stored value, so lowering a roof drops the cap
  onto the new ridge automatically.
- refactor(nodes,editor): migrate the vent family (box / ridge / turbine /
  cupola / eyebrow) to `capabilities.paint` via a shared single-surface
  capability, removing the hardcoded `node.type` paint arms from the editor's
  selection-manager + material-paint (matches chimney/dormer).
- refactor(editor): replace the five identical vent tree-node components with
  one `def.presentation`-driven `RegistryTreeNode`.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-02 10:57:37 -04:00
f7ff60561e feat(editor): preset placement polish — params, move tool, shadows, isolation (#357)
* feat(editor): preset placement polish — params, move tool, shadows

- wall/slab/ceiling/roof create paths consume `toolDefaults` so template
  presets build with their saved params; cleared on tool unmount
- wall draw preview reflects the preset's height/thickness (+ HUD labels)
- box-select picks up registry-selectable kinds (shelf) via bbox
- registry + column move tools: snap to the active grid step, R/T rotation,
  and ignore the stray trailing click that armed the move (no double-place)
- shelf geometry casts + receives shadows like fence/slab

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

* feat(viewer): expose isIsolationActive() for isolation-aware consumers

Tracks whether an isolation filter is currently applied and exposes it so
hosts can avoid acting on the partial view — e.g. skipping project-thumbnail
autosave while a single subtree is isolated (preset capture).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 10:17:15 -04:00
8abfc94b99 feat(editor): preset-system polish — paint panel, slim action bar, icon rail (#354)
* feat(editor): preset-system polish — paint panel, slim action bar, icon rail

- Export `MaterialPaintPanel` so embedders host the paint material picker in
  their own panel (community docks it in the Build sidebar) instead of the
  bottom action bar.
- ActionMenu: drop the build / material-paint / furnish modes and the
  structure-tools palette row + paint tray (the host's Build sidebar owns
  building now). Reduce `structure-tools` to the shared `tools` lookup still
  used by cursor/floorplan indicators; remove the orphaned `useContextualTools`.
- MaterialPicker: swatches wrap into a fluid `auto-fill` grid that fills width.
- IconRail (tab-bar): bigger icons, grayscale-when-idle, Radix tooltip, w-14
  rail; sync `RAIL_WIDTH` to 56.
- Inspector footer: hand the host `footer` to kind-owned custom panels via
  `InspectorFooterContext` so the save button renders without per-kind wiring.

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

* chore: biome format pass

Whole-repo formatter normalization (line wrap/unwrap only, no logic changes)
surfaced by the format-on-edit hook against prior drift. Kept separate from the
feature commit so the preset-system diff stays reviewable.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 13:13:23 -04:00