Commit Graph
100 Commits
Author SHA1 Message Date
Wassim SAMADandClaude Opus 4.8 bc9e075b2d fix(editor): 2D slab/zone/ceiling drafting honors the active snapping mode
The polygon-draft snap path used the legacy model: bypassSnap = shiftPressed and
angleSnap = pointCount > 0 && !bypassSnap, so the 15deg angle lock engaged after
the first vertex regardless of mode — hijacking grid/lines/off into angle-snap
even though the HUD chip showed the right mode.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:02:48 -04:00
Wassim SAMADandClaude Opus 4.8 bbf9291c2d feat(editor): roof/stair/elevator snapping migration + no-angle footprint draft
Migrate roof/stair/elevator draft tools + the 2D floorplan move overlay off the
legacy Shift/Alt=bypass model onto mode-driven snapping (isGridSnapActive /
isMagneticSnapActive); Alt dropped (no validity gate). stair/elevator now use the
live grid step.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:45:45 -04:00
Wassim SAMADandGitHub ff2a24795c Merge pull request #428 from pascalorg/feat/paint-slots
Paintable slots: unified material painting across items, walls & procedural geometry
2026-06-18 12:24:56 -04:00
Wassim SAMAD c4b6e74d65 Merge remote-tracking branch 'origin/main' into feat/paint-slots
# Conflicts:
#	packages/core/src/store/use-scene.ts
#	packages/editor/src/components/editor/index.tsx
2026-06-18 12:22:26 -04:00
Wassim SAMADandClaude Opus 4.8 d924931cee feat(paint-slots): split slab into top + side slots
Slabs now expose two paintable faces instead of one: `surface` (top, keeps
the wood floor default and the prior slot id so painted slabs are
unaffected) and `side` (vertical walls + underside, default light grey).
The merged slab buffer is split by per-triangle face normal into a top mesh
and a side mesh, each tagged with its slot id for paint resolve/preview;
legacy whole-slab material maps onto the top only. Textures-off still
collapses to the floor role.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:05:51 -04:00
Wassim SAMADandClaude Opus 4.8 6b67ab6949 feat(paint-slots): create-in-place scene materials + paint-panel polish
Custom-create now pre-creates a scene material and opens its inline editor
in the build pane (no separate right-side PaintPanel, which is removed);
the brush + "Paint with" use a scene: ref so painting stores the ref and
edits propagate everywhere. Slot preview (shared + item) resolves scene
refs so hover shows the real material.

Material properties editor uses the shared SliderControl for roughness/
metalness/opacity; row action buttons use Tooltip instead of title; the
color input renders as a clean filled swatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 11:46:45 -04:00
Wassim SAMADandClaude Opus 4.8 c6423e7c73 feat(paint-slots): migrate wall + procedural kinds onto node.slots
Retire the inline material fields on every slot-model kind, moving them
onto the unified node.slots model on load so painting, edit-propagation,
and the picker behave uniformly.

Walls: slots {interior,exterior}; slot-first viewer resolution threading
sceneMaterials (content folded into the wall material hash); WallRenderer
subscribes to the scene-material palette so a scene-material edit
re-renders live; wallPaint rebuilt on createSlotPaintCapability.

Load migration generalizes legacy -> slots across slab/ceiling (surface),
fence (posts/infill/base/rail), column (shaft/base/capital/frame), shelf
(shelves/frame/back), and stair (per-role tread/side/railing). Library/
scene refs pass through; inline customs mint a deduped scene material;
legacy fields cleared. No visual change (renderers already fell back to
the legacy fields). Roof/chimney/dormer/vents intentionally stay on their
role system.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 10:12:53 -04:00
Wassim SAMADandGitHub 9e7beb8910 Merge pull request #427 from pascalorg/fix/compass-north
fix(editor): point compass needle at true north, not camera
2026-06-18 09:16:19 -04:00
Wassim SAMADandClaude Opus 4.8 222713b26f fix(editor): point compass needle at true north, not camera
The compass needle rotation was `-floorplanUserRotationDeg`, but the
2D-floorplan SVG and 3D camera projections both place true north at
`+floorplanUserRotationDeg` (= cameraAzimuth − 90°). The negated sign
made the needle rotate opposite to north as the camera orbits, so it
appeared to track the camera instead of pointing north.

The formula was written for the 2D floorplan and reused verbatim when
the compass was portaled into the 3D viewer; both instances had the
flipped sign. "Align to north" is unaffected (userRotation 0 → up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:13:27 -04:00
Wassim SAMADandClaude Opus 4.8 faf73d66f2 feat(paint-slots): full world-space UVs for roof gable walls (match walls)
The earlier gable fix world-referenced only V, leaving U in segment-local
space — so gable and wall lined up vertically but slid horizontally. Replace the
V-only scalar offset with the segment's full world matrix (roof group composed
with segment transform) and project vertical faces exactly like the wall kind:
U = ±worldX/Z, V = 1 - worldY. Gable now tiles continuously into the walls in
both axes. Sloped shingle UVs are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:16:57 -04:00
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 e46a63d172 feat(paint-slots): fence base + rail default to greige
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:18:42 -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 SAMAD ad227a04a2 Merge branch 'feat/paint-slots-elevator' into feat/paint-slots 2026-06-17 15:44:14 -04:00
Wassim SAMAD adf5077d0d Merge branch 'feat/paint-slots-column' into feat/paint-slots 2026-06-17 15:44:14 -04:00
Wassim SAMAD 9060b92e24 Merge branch 'feat/paint-slots-stair' into feat/paint-slots 2026-06-17 15:44:14 -04:00
Wassim SAMAD 33886141fa Merge branch 'feat/paint-slots-fence' into feat/paint-slots 2026-06-17 15:44:14 -04:00
Wassim SAMADandClaude Opus 4.8 1199c04fac feat(paint-slots): migrate elevator onto the unified slot model
Elevator exposes four finish slots — cab / doors / shaft / glass (glass only
when shaft or door style is glass) — on node.slots. The finish materials now
resolve through a per-node material set (context provider) keyed on node.slots
+ scene materials: textures-off joinery role -> node.slots ref -> declared
default (cab preset-softwhite, doors metal-steel, shaft preset-lightgrey,
glass preset-glass), with glass transparency flags re-applied. Cab/door/shaft/
glass meshes tag userData.slotId; buttons, indicators, control panels, and
queue strips stay untagged (functional UI, not paintable). Interaction system
untouched. Monochrome unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:35:41 -04:00
Wassim SAMADandClaude Opus 4.8 0cdaf8b1b9 feat(paint-slots): migrate column onto the unified slot model
Column exposes shaft / base / capital / frame slots on node.slots (base when
baseStyle!=none, capital when capitalStyle!=none, frame only for non-vertical
support styles). The single material context is widened to a per-slot map plus
a ColumnSlotContext; each part subtree is wrapped in <ColumnSlot> so every
mesh primitive resolves its material AND stamps userData.slotId from the same
context. Decorative shaft parts inherit shaft, base/capital carvings inherit
their parent slot. Resolution: textures-off wall role -> node.slots ref ->
legacy material/preset -> declared default (shaft/base/capital concrete-plaster,
frame metal-steel). Monochrome unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:34:39 -04:00
Wassim SAMADandClaude Opus 4.8 16c117142f feat(paint-slots): migrate stair onto the unified slot model
Stair exposes three paintable slots — treads / body / railing (railing only
when railingMode != none) — on node.slots. The 2-material-index body mesh
maps materialIndex 0->treads, 1->body via userData.slotIds; railing meshes
tag userData.slotId='railing'. Per-slot resolution layers over the viewer's
base body/railing materials only in textures-on mode: node.slots ref ->
legacy per-part field (preserved) -> declared default (treads wood-woodplank48,
body wood-woodfine2, railing metal-steel). A custom preview swaps the targeted
body material-array index and whole-mesh railing materials. Monochrome
unchanged. Stops using DEFAULT_STAIR_MATERIAL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:33:42 -04:00
Wassim SAMADandClaude Opus 4.8 5555ad1fe0 feat(paint-slots): migrate fence onto the unified slot model
Fence joins the node.slots paint model with one paintable 'surface' slot
(merged post/base/rail geometry is a single mesh). Resolution order matches
slab: textures-off joinery role -> node.slots.surface ref -> legacy inline
material/preset -> declared default (library:wood-finewood27). Tags the mesh
userData.slotId='surface' and wires capabilities.slots + paint. Stops
borrowing DEFAULT_STAIR_MATERIAL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:29:20 -04:00
Wassim SAMADandClaude Opus 4.8 751392c640 feat(paint-slots): default roof shingle surface to catalog finish
Replace the flat #e5e5e5 placeholder on the roof shingle surface (material
index 3) with the catalog roof-weatheredshingles finish via
resolveSlotDefaultMaterial. Wall/trim, deck, and interior surfaces and the
textures-off role array are unchanged. Roof keeps its existing per-segment
role material system (no node.slots migration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:27:40 -04:00
Wassim SAMADandClaude Opus 4.8 a8f32dd47a feat(paint-slots): add node.slots field to fence/stair/column/elevator schemas
Pre-adds the unified slot-ref field (Record<slotId, MaterialRef>) to the
four kinds being migrated onto the paint-slot model, so per-kind work can
proceed in parallel worktrees without cross-package core edits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:20:21 -04:00
Wassim SAMADandClaude Opus 4.8 4c27558a4f chore(paint-slots): drop 50 unreferenced editor material maps
Remove height/displacement/mask/mortar/joint texture files the material
schema never samples (it reads only albedo/normal/roughness/ao/metalness).
Computed as present-on-disk minus catalog-referenced in material-library.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:57:47 -04:00
Wassim SAMADandGitHub 071319e102 Merge pull request #417 from pascalorg/feat/paint-slots-node-defaults
feat(paint-slots): unified slot defaults + paint for slab, ceiling, wall (phase 5)
2026-06-17 14:49:51 -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 37c56788f3 feat(paint-slots): add drei sunset environment as an editor viewer child
Inject an EditorEnvironment wrapper (drei prefiltered sunset HDRI at
environmentIntensity 0.6) as a child of the editor Viewer, not baked into
the Viewer component, so read-only/embed viewers stay lightweight. This
gives PBR metals their reflections and lifts lighting on vertical walls
that flat directional + hemisphere lights cannot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:30:23 -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
Wassim SAMADandGitHub 2e5a4ff9d0 Merge pull request #419 from pascalorg/feat/embed-surface-on-main
Land embed-surface on main (in-canvas affordance exports + transparent/faint-ink viewer)
2026-06-17 11:15:22 -04:00
Wassim SAMADandGitHub 8775734aa8 Merge pull request #418 from pascalorg/feat/snapshot-camera-only-and-icon-webp
Snapshot capture: camera-only mode + UI icons → WebP
2026-06-17 10:45:43 -04:00
Wassim SAMADandClaude Opus 4.8 267dac7b1a perf(editor): convert UI icons from PNG to WebP
The editor's /icons assets were ~10MB of oversized PNGs (a single
toolbar icon up to 1.4MB). Convert every non-PWA icon to WebP (quality
92) and repoint all /icons/*.png references to .webp across
packages/editor, packages/nodes, and apps/editor. PWA/platform icons
(apple-touch-icon, icon-192, icon-512) stay PNG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:41:50 -04:00
Wassim SAMADandClaude Opus 4.8 5efcc834a8 feat(editor): make snapshot capture camera-only
Snapshot/preset capture is for framing a thumbnail, not editing, so it
should only move the camera:

- gate the selection manager, transform handles, floating menus, and the
  tool manager (which renders the site boundary flags) behind
  isCaptureMode, so clicking the scene no longer selects items or shows
  editing controls
- hide zone meshes and the HTML zone tags while capturing
- force 3D on entry (the 2D/split floorplan panes render nothing useful
  for a thumbnail) and restore the prior view mode on exit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:41:50 -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
Wassim SAMADandGitHub b0f4e1b8ee Merge pull request #415 from pascalorg/feat/paint-slots-phase-4-finish-library
feat(paint-slots): KTX2 finish library — fabric/leather/concrete/metal (phase 4)
2026-06-16 16:14:28 -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 SAMADandGitHub 0e8a8c3d14 Merge pull request #414 from pascalorg/feat/paint-slots-material-catalog
feat(paint-slots): material catalog families + 45-color palette + preset-style picker
2026-06-16 13:29:32 -04:00
Wassim SAMADandClaude Opus 4.8 dbefcf763c feat(paint-slots): recurate material catalog into families + expand colors
- Replace location-based categories (wood/flooring/roof/other) with material
  families: colors, wood, stone, brick, tile, concrete, metal, fabric, leather,
  roofing, ground, glass
- Add MaterialSurface tags (floor/wall/ceiling/roof/furniture/outdoor); retag all
  65 existing finishes; ids unchanged so library refs keep resolving
- Expand curated colors 15 -> 45, ordered by hue; unify on catalog library items
  and retire CURATED_COLORS (picker reads the colors family)
- Paint picker: wrapping rounded category chips (was horizontal scroll), empty
  families auto-hide, preset-style swatch cards (name label, selection-only ring,
  hover bg + SFX)

Phase 4 (finish-library content) of editor-paint-slots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:28:31 -04:00
Wassim SAMADandGitHub 8a0f6cfe27 Merge pull request #412 from pascalorg/feat/paint-slots-phase-5-shelf-slots
feat(paint-slots): paintable slots on the procedural shelf (phase 5)
2026-06-16 13:08:12 -04:00
Wassim SAMADandClaude Opus 4.8 e079615196 fix(shelf): floor-anchor cubby no-bottom divider + embed bookshelf divider depth
- Cubby without a bottom board: the lowest cell opens onto the floor, but its
  column divider still started at the (missing) bottom board's top, leaving it
  floating ~thickness above the floor. Anchor it at y=0 when there's no bottom.

- Bookshelf full-height divider: it crosses the continuous shelves, so it can't
  share their depth plane (proud at the front, coplanar with the back panel —
  z-fighting down the centre back). Recess its depth to sit fully INSIDE the
  boards' depth: embedded at each shelf crossing (board occludes it) and tucked
  inside the back panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:05:47 -04:00
Wassim SAMADandClaude Opus 4.8 c68dfa2095 fix(shelf): match cubby divider depth to the boards (no front overflow, no back seam)
The cubby column divider was full-depth while the boards are depth-recessed,
so it (a) sat proud of the shelf fronts — the earlier Y-tuck then made it poke
past the top/bottom boards — and (b) shared the back panel's rear plane,
z-fighting down the centre of the back.

Give the divider the same depth recess as the boards (via boardGeometry) and
drop the Y-tuck: it now sits flush with the shelf fronts and its back tucks
inside the back panel. Flush top/bottom is fine — those board faces are
back-to-back, not co-facing, so they don't fight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:57:00 -04:00
Wassim SAMADandClaude Opus 4.8 c717c0c13d fix(shelf): tuck cubby cell dividers into the boards they meet
Cubby per-cell dividers landed exactly flush on the board below and under the
board above — coplanar faces that shimmer and read as merged into the shelf.
Extend each divider 1mm into both boards (centre unchanged) so it tucks under
the top board and onto the bottom board with a solid, seam-free join.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:43:13 -04:00
Wassim SAMADandClaude Opus 4.8 b602e04b70 fix(shelf): stop top-face z-fighting + avoid plate/side gaps
Two follow-ups to the plate-recess fix:

- Top surface: full-height frame members that pass UNDER the top board
  (back panel, bookshelf column dividers, corner posts) had their top face at
  unitHeight — coplanar with the top board's top — so they z-fought through it
  (the seam on the top). Drop their top 1mm (cappedFrameY); bottoms stay on the
  floor so nothing floats.

- Plate width: the previous pass recessed every board's width, which opened a
  1mm gap where boards ABUT the side panels (cubby / bookshelf-with-sides).
  Width is now recessed only where boards span OVER posts (open-rack /
  no-sides bookshelf); abutting boards keep full width and meet the sides
  flush. Depth stays recessed everywhere (gap-free, fixes the back-panel fight).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:19:17 -04:00
Wassim SAMADandClaude Opus 4.8 e17c298284 fix(shelf): recess plates 1mm to stop z-fighting with the frame
Open-rack / no-sides shelves span their boards across the full footprint, so a
board's end / front / back faces land exactly on the corner posts' outer faces
(and, with a back panel, on its rear face). Coplanar surfaces the depth buffer
can't order flicker as z-fighting (visible as seams where plates meet posts).

Recess every board 1mm on width + depth via a `boardGeometry` helper so each
plate sits just inside the frame — the meshes still overlap (no visible gap),
but no two faces are coplanar. Frame parts (posts/sides/back/dividers/brackets)
keep their full size as the silhouette.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:10:34 -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 SAMADandGitHub 89a7232a67 Merge pull request #410 from pascalorg/feat/paint-slots-phase-4-uv-worldscale
feat(paint-slots): world-scale pool slab floor UVs + metre UV contract
2026-06-16 09:57:30 -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
Wassim SAMADandClaude Opus 4.8 138543ea8c chore(render-modes): use US spelling "colors" in Materials toggle detail
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:42:26 -04:00
Wassim SAMADandGitHub 0b25e4f020 Merge pull request #409 from pascalorg/feat/colored-monochrome-items
Colored/Monochrome: items show real materials + Render-menu toggle
2026-06-15 17:44:55 -04:00
Wassim SAMADandClaude Fable 5 4411b8ab5f feat(render-modes): add Colored / Monochrome toggle to the Render menu
The textures axis had no UI control. Add Colored (textures on — show item
materials, textures, vertex colours) / Monochrome (textures off — flat clay
by surface role) options to the Render dropdown, wired to useViewer.textures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 17:41:38 -04:00
Wassim SAMADandClaude Fable 5 3d5a36c73d feat(render-modes): items show real materials in colored mode (not flat clay)
In colored mode (textures on), every item now renders its authored material
— textures, vertex colours, and default colours — instead of being stripped
to the uniform off-white baseMaterial. AI-generated and vertex-coloured items
show their real appearance; slot resolution (override -> curated -> authored)
is unchanged. Monochrome (textures off) still collapses to the furnishing
clay role colour (the escape hatch). Drops the now-unused isAuthored marker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 17:17:44 -04:00
Wassim SAMADandGitHub f740f5993b Merge pull request #406 from pascalorg/feat/paint-slots-phase-2a
Paint slots 2a: render pascal_material curated defaults
2026-06-15 11:36:14 -04:00
Wassim SAMADandGitHub aafb238545 Merge pull request #405 from pascalorg/fix/snapping-and-wall-build
fix(walls): bound wall miters, heal corrupt scenes, follow grid snap
2026-06-15 11:33:05 -04:00
Wassim SAMADandClaude Fable 5 d52cbe6757 feat(paint-slots): read pascal_material glTF extras as curated slot default
Insert the plan's middle resolution step for item slots: a curated
default baked into the GLB as a per-material 'pascal_material' extra
(three's GLTFLoader copies material extras to userData). Resolution per
slot is now: node.slots[slotId] override -> pascal_material curated
default -> authored GLB material. Bare ids resolve as library:<id>;
unresolved refs fall through. Runtime reads only the GLB (no DB).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 11:32:05 -04:00
Wassim SAMADandClaude Opus 4.8 6d5f041b48 fix(walls): bound wall miters, heal corrupt scenes, follow grid snap
Three related editor fixes surfaced while debugging a captured house
project that rendered an infinite wall and failed to load.

Infinite wall (core/systems/wall/wall-mitering.ts):
  Junction miters are line-line intersections, so the joint point sits
  ~halfThickness/sin(theta) from the junction. The only guard was an
  exact-parallel check (det < 1e-9), so two walls meeting at a shallow
  angle (a room-preset preview dragged onto an existing wall, or a wall
  drawn nearly collinear to its neighbour) produced a joint point far
  away — an infinite spike. Add a miter limit: reject joints farther
  than 10x half-thickness from the junction and fall back to a square
  joint, exactly like the parallel case.

Scene load failure (core/utils/heal-scene-graph.ts + validate-build-json
+ use-scene migrateNodes):
  Capture wall-merge could leave a `children: [null]` entry (see the
  matching merge-walls.ts fix in private-editor) and zero-length walls.
  `null` children fail wall schema validation, so the whole scene fails
  to load. Add a shared heal step — strip non-string child refs, drop
  childless zero-length walls — run on every load path: import
  validation now repairs instead of hard-failing (with a warning), and
  setScene heals on the prod project-load path too.

Grid snap (nodes slab/ceiling/spawn tools):
  These tools hardcoded a 0.5 m snap (Math.round(x*2)/2) and ignored the
  editor's grid-snap setting, so the cursor jumped by 0.5 while later
  vertices already followed the configured step. Route them through
  snapPointToGrid / snapScalar with gridSnapStep.

Adds unit tests for the miter limit and the heal step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:30:39 -04:00
Wassim SAMADandGitHub e0179c480a Merge pull request #404 from pascalorg/feat/paint-slots-phase-1
Paint slots: phase 1 + paint unification
2026-06-15 10:59:49 -04:00
Wassim SAMADandClaude Fable 5 2ac9f6762d feat(paint-slots): persist scene materials + collections via autosave
The editor autosave/load is the single source of truth for what travels
with a scene. Include document-level state (collections, materials) so it
survives reload in every embedder (community cloud save + standalone
localStorage):

- SceneGraph carries optional collections + materials.
- useAutoSave triggers on collections/materials reference changes (not just
  nodes) and writes them into the saved graph + the unload flush.
- applySceneGraphToEditor restores them via setScene's extras arg on load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 10:43:09 -04:00
Wassim SAMADandClaude Fable 5 b3d840a39b feat(paint-slots): scene-material library panel + Colors swatches
Phase 3 paint UI.

- Extract reusable MaterialPropertiesEditor (shared by the custom active-
  paint editor and the scene-material editor).
- Curated Colors swatch row in the picker; catalog presets fork-on-tweak
  by seeding a custom material from the entry's previewColor.
- Scene materials section in MaterialPaintPanel (shown once any exist):
  list with swatch, inline rename, 'used by N parts', paint-with,
  edit (live-propagates to every referencing part via the renderer's
  sceneMaterials dep), duplicate, delete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 10:13:05 -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
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
d36e8fcc78 fix: zone grid snapping, shelf position panel, deselect on item placement (#394)
* fix: zone grid snapping, shelf position panel, deselect on item placement

- Zone tool reads the editor's gridSnapStep (0.5/0.25/0.1/0.05) instead
  of a hardcoded 0.5 for both cursor move and click snapping.
- Shelf inspector gains a Position group (vec3 X/Y/Z sliders), matching
  the item panel.
- Item catalog clears the viewer selection before arming placement so
  shortcuts (rotate & co) don't hit both the ghost and the selected node.

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

* refactor: move level display naming into @pascal-app/core

getDefaultLevelName / getLevelDisplayName ("Ground Floor" / "Floor N" /
"Basement N") lived in packages/editor's internal lib, so viewer-only
surfaces couldn't reach them and fell back to hand-rolled "Level N"
labels. The helpers are pure domain logic, so they move to core and
export from its barrel; the editor package's seven call sites now import
them from there.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:56:24 -04:00
7539387ff5 editor: fix preset double-commit ghosts, show compass in all view modes (#393)
* fix: guard preset placement against double commit from duplicate click events

A single physical click reaches commitAtCursor twice: node clicks are
synthesized on pointerup (use-node-events) while grid:click rides the
native click event from a canvas DOM listener (use-grid-events) that
deliberately ignores stopPropagation. The second pass found the fresh
draft already deleted and took the orphan re-create path, minting a
hidden ghost copy, replaying the placement SFX, and abandoning the
re-armed clone as a second ghost.

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

* feat: show the compass in all view modes via the viewer-area container

The floorplan compass now portals onto the always-visible viewer area
instead of living inside the (display:none in 3D) floorplan pane. The
2D/3D navigation poses already sync through navigationSyncPose, so the
needle and align-to-north stay correct in 2d, 3d, and split alike.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:05:00 -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
Wassim SAMADandGitHub b620c9d502 Fix camera floorplan navigation sync (#387) 2026-06-08 20:34:09 -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