6b0fe5b99c1d3eb32887dbbefdd39594b01bdb82
284
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
814d94336b |
core(space-detection): add pause/resume refcount primitive (#336)
initSpaceDetectionSync subscribes to every scene mutation and auto-derives slabs/ceilings from wall topology. When a host app drives explicit slab/ceiling creation (e.g. the community editor's AI create_room flow), the auto-sync races those nodes and the polygon-signature de-dupe is fragile enough that duplicates leak through. Expose pauseSpaceDetection / resumeSpaceDetection / isSpaceDetectionPaused mirroring the existing pauseSceneHistory refcount in store/history-control. While paused, the subscriber rolls previousSnapshots forward so resume does NOT trigger a backfill that would re-introduce the very duplicates the host paused to avoid. No behavior change for callers that don't touch the new functions. |
||
|
|
bb5ce68254 |
Viewer render modes: Solid/Rendered + textures + surface-role clay + scene themes + edges (#332)
* viewer: add Phase 1 render-modes foundation (shading/textures/colorPreset state, defaultRender prop, SSGI gating) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: Phase 2 render-modes material-class switch (Lambert in solid, Standard in rendered) Shading-aware material factories (cached per class), reactive selection in renderers via the useViewer(shading) pattern, and dirty-rebuild on toggle for geometry/door systems. Rendered mode output unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: Phase 3a render-modes surface roles + clay palette foundation Adds surfaceRole token to core NodeDefinition, per-kind default roles, ColorPreset palettes + resolveSurfaceColor/createSurfaceRoleMaterial (glazing stays translucent), and the textures-off recolor path for def.geometry kinds (slab/fence/shelf via GeometrySystem.applyDefaultSurfaceRole) + wall. Renderer- based kinds (roof/window/stair/item/column/door/ceiling/elevator) wired in 3b. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: Phase 3b render-modes textures-off recoloring for renderer/system kinds Wires clay role coloring (textures=off) for roof/roof-segment, window, stair/ stair-segment, door, item, column, ceiling, elevator via createSurfaceRoleMaterial, reactive on textures/colorPreset. Per-surface roles: roof top+edge=roof / underside=ceiling; window frame=joinery / glass=glazing; stair+door+elevator= joinery; ceiling=ceiling; column=wall; item=furnishing. textures=on unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: Phase 4a render-modes UI — Solid/Rendered toggle + per-context persistence Per-context shading via renderContext discriminator + shadingByContext (persisted); <Viewer> renderContext prop seeds per-context on mount. Solid/Rendered toggle in the editor action bar + standalone toolbar + command palette. Editor mounts default to renderContext=editor / shading=solid. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: fix window-system glassMaterial type to allow clay glazing reassignment The let was inferred as MeshLambertNodeMaterial from the imported glass constant, so reassigning createSurfaceRoleMaterial('glazing') (returns THREE.Material) failed under tsc --build. Widen the annotation to THREE.Material. Surfaced by the build (check-types had replayed a stale turbo cache). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: Phase 5 lighting — add theme-driven hemisphere light, trim fill directionals 3->2 Adds a sky/ground hemisphere fill (theme-lerped) and drops the second fill directional; the hemisphere covers the shadow-side fill it provided, at one fewer per-fragment directional term (shared by Solid + Rendered). Ambient lowered since the hemisphere now carries soft fill. Intensities are a starting point — tune visually on the gpu-perf overlay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: tune Solid lighting for more form — stronger hemisphere sky/ground contrast, lower ambient Darker hemisphere ground (#d8d6cf -> #aaa49a) + higher hemisphere intensity and lower ambient so directional shading reads as form and undersides ground without AO. Keeps Solid free of any post-processing pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: biome format render-mode files (lefthook pre-commit) Formatting-only — import wrapping, dep-array wrapping, single-line ternaries — across Phase 2-4a files that weren't biome-clean. No logic changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: scene-theme system — named environment themes (studio/paper/sunset/night/...) New SceneTheme registry (lib/scene-themes.ts) drives lights, background, and tone mapping; lights.tsx refactored data-driven (N directionals + hemisphere + ambient). sceneTheme state (persisted) + cycle-button picker in editor bar + standalone toolbar, importing the registry from the viewer barrel (single source). Default 'studio' reproduces the prior look exactly; app light/dark 'theme' untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: Phase 7 — popover-dropdown pickers for render mode + scene theme (editor bar) Replace the shading + scene-theme cycle buttons in viewer-overlay.tsx with DropdownMenu pickers: render mode shows 2 rows (Solid/Rendered) with one-line detail; scene theme lists all themes with a derived color-swatch strip + active check. Imports the registry from the viewer barrel (single source). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: Phase 7 — dropdown pickers in standalone toolbar + export DropdownMenu from barrel Replicate the render-mode + scene-theme dropdown pickers (with swatch strip + active check) to apps/editor's compact toolbar, matching viewer-overlay.tsx. Export DropdownMenu* from the @pascal-app/editor barrel for the standalone app. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer+editor: Phase 8 — crisp geometry edge overlay (off/soft/strong/sketchy) EdgeOverlaySystem draws EdgesGeometry LineSegments over node-backed building meshes (scoped via sceneRegistry, skips zone-layer/hitbox/overlay meshes), rebuilt on geometry-uuid or mode change, line color follows scene-theme background luminance; sketchy = static TSL vertex jitter. New 'edges' state (persisted, default off) + Edges dropdown in editor bar + standalone toolbar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer+editor: edge overlay — thick lines via Line2, drop sketchy mode Switch EdgeOverlaySystem from LineBasicNodeMaterial (1px hardware cap) to LineSegments2 + Line2NodeMaterial so edges have real screen-space width (soft 1.5px / strong 3px); resolution tracks viewport. EdgeMode is now off/soft/strong (sketchy removed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: fix edge overlay crash — webgpu Line2NodeMaterial has no settable resolution material.resolution is undefined under WebGPU (the node material reads the viewport internally); optional-chain the .set() call so it no-ops there instead of throwing. Thickness still applies via linewidth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: edge overlay — weld vertices to fix CSG spiderweb edges + lighter strong Position-only mergeVertices before EdgesGeometry so coplanar triangles from CSG-cut walls (doors/windows) share vertices and their interior edges are suppressed — only opening outlines + silhouettes remain. Strong linewidth 3 -> 2px (was too heavy). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: edge overlay — crease-only extractor to fix CSG spiderweb Replace EdgesGeometry (which always draws unpaired boundary/T-junction edges) with buildCreaseEdges: weld positions, keep only edges shared by exactly two faces whose dihedral exceeds the threshold, drop everything unpaired. CSG-cut walls/slabs are watertight so real corners + opening outlines survive while the interior triangulation fans (coplanar or T-junction) are removed. Open meshes (bare ground plane, billboard leaves) shed their boundary clutter too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: crease edges — coarser weld (0.1mm->1mm) to recover CSG/extrude seam edges The cap<->side-wall top edge of ExtrudeGeometry walls drifts past 0.1mm after CSG, so it stayed unpaired and was dropped. Weld at ~1mm to pair it into a real crease while staying far below feature size (wall thickness, openings). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: replace geometry edge overlay with screen-space ink (SketchUp look) Port the prototype's screen-space ink into the post-processing pipeline: depth + normal Sobel reading the scene-pass MRT. Crease term (normalized normals, center-vs-neighbour) + distance-independent depth-step term (raw Laplacian / (1-d)² with a noise gate so flat ground stays clean). Topology-agnostic, so it finally handles CSG-cut walls/openings without the spiderweb or missing-edge problems of EdgesGeometry. Driven by the existing edges off/soft/strong mode; MRT now builds when SSGI OR ink is on; ink colour tracks scene-theme luminance. Removes EdgeOverlaySystem + crease-edges (geometry approach). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: soft/strong ink modes + keep editor overlays out of the ink Two adjustments to the screen-space ink pass: 1. Soft vs strong now visibly differ. The edge masks saturate, so the old `intensity` gain did nothing once a line was detected. Replace it with a sample radius (line thickness) + opacity: soft = 1px / 50%, strong = 2px / 100%. `inkedEdges` takes `radius` + `opacity` instead of `intensity`. 2. Editor overlays (gizmos, move handles, tool previews, grid) no longer get inked. The scene pass that feeds the depth/normal MRT now renders only SCENE_LAYER; overlays render in a dedicated pass on OVERLAY_LAYER and are composited on top after the ink + outlines, so they read as crisp UI and never get inked or AO'd. New OVERLAY_LAYER constant in viewer; editor's EDITOR_LAYER re-exports it so the two stay in lockstep. Also moves WallMoveSideHandles (the wall/fence move arrows) onto EDITOR_LAYER — it was the one overlay still on SCENE_LAYER. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: render the grid in the scene pass so geometry occludes it The depth-gate fix couldn't help the grid: its material is depthWrite:false, so it never wrote overlay-pass depth and the "didn't write depth -> keep on top" term forced it on top — hence the floor grid bleeding through walls and objects. A full-floor plane can only be occluded correctly by living in the same depth context as the scene, so move the grid onto its own GRID_LAYER which the scene pass renders (alongside SCENE_LAYER). It's flat and depth-non-writing, so the screen-space ink still ignores it; gizmos/handles stay on OVERLAY_LAYER. The grid camera layer is enabled in custom-camera-controls and disabled on the thumbnail camera so thumbnails stay grid-free, matching EDITOR_LAYER. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: strong ink — match soft's 1px thickness, differ by darkness only Strong at radius 2 read too thick. Soft's 1px line is the nice one, so use it for both modes and let strong distinguish itself purely by being fully solid (opacity 1) vs soft's lighter 50%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: shadow frustum follows the view + a shadows on/off setting The directional light's ortho shadow camera only covers ±50 around the light target, which was pinned at the origin — so zones far from origin received no shadows no matter where the camera moved. Recentre each shadow-casting light (position + target together, preserving direction) on the view focus every frame: the orbit-controls target when available, else the camera's ground projection. The shadow area now tracks wherever the user looks. Also add a persisted `shadows` toggle (default on) to the viewer store and a "Shadows" switch in the editor settings panel — the dedicated shadows control the render-modes plan deferred. Lights gate castShadow on it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: shadows toggle in the standalone toolbar + a touch more shadow strength The shadows switch I added only lived in the cloud settings panel's Visibility section, which is hidden in the local/standalone editor (no projectId). Add a ShadowsToggle button next to the grid toggle in the standalone toolbar so it's reachable there, matching how Show Grid is exposed in both places. Also push shadow strength partway toward the aesthetic prototype (which runs near-black, no blur): bump the bright-key shadow-intensity cap 0.4 -> 0.55 and tighten shadow-radius 2 -> 1.5. Still softer than aesthetic by design. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * nodes: site ground receives shadows (lit material instead of unlit Basic) The site ground fill used MeshBasicMaterial — unlit, so it could never show the directional shadow, and shadows visibly truncated at the slab edge. Swap it for a lit MeshLambertNodeMaterial with receiveShadow on the mesh; the geometry is the site polygon (slab footprints punched out), so shadows now extend across the whole site and stop at its boundary, which is the desired bound. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * themes: per-theme clay palettes + 2x2 swatch in the theme pickers Each scene theme now carries a clayTints map (wall/floor/ceiling/roof/glazing) giving it a per-surface-role palette — e.g. Mediterranean's blue roof + warm walls. The theme pickers (standalone toolbar + community overlay) now render the aesthetic-style 2x2 swatch of those role tints over the theme background instead of the old 3-colour strip. Data + UI only; wiring the tints into the textures-off surface materials is a separate change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: slimmer theme switcher + cloud-sun icon Shrink the scene-theme toolbar button (w-[8.5rem] -> w-28) so it stops reserving space for "Mediterranean"; the label truncates when it overflows. Swap the palette icon for cloud-sun (atmosphere/lighting, distinct from the app light/dark Sun-Moon toggle) in both the standalone toolbar and the community overlay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * editor: theme switcher icon -> swatch-book Swap the scene-theme icon from cloud-sun to swatch-book in both the standalone toolbar and the community overlay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * themes: colour untextured building surfaces by the active scene theme Untextured walls/roof/slab/ceiling now take the active theme's per-role colour (theme.clayTints[role], falling back to the colour preset) in BOTH textures modes. The textures toggle only governs surfaces that actually have an explicit material/preset — those still show their texture when textures are on. This is what makes e.g. Mediterranean read as a blue roof + warm walls instead of the old hardcoded white/grey defaults. - materials.ts: resolveSurfaceColor / createSurfaceRoleMaterial take an optional sceneThemeId (theme tint ?? preset palette); theme folded into the cache key. - wall-materials, roof-materials, slab/geometry, ceiling/renderer: the untextured fallback now resolves to the themed role colour instead of white/grey, in both modes; theme threaded into each builder + material cache key. - wall-cutout: now reads textures/colorPreset/sceneTheme and re-applies wall materials when any change (previously it ignored textures/colorPreset entirely). - geometry-system: threads sceneTheme into the generic surface-role path + rebuild effect. Renderers/preview call sites thread sceneTheme through. Doors/windows/stairs/columns/items still use their existing defaults — a follow-up pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(slab): recessed (negative-elevation) slabs extrude downward again The registry geometry builder created the slab mesh at Y=0 without applying the negative-elevation offset, so recessed slabs rendered above the floor plane (pool geometry is built locally with its floor cap at Y=0 and walls rising to Y=|elevation|, so the mesh must be shifted down by `elevation` to recess). The runtime slab-system already did this; the static builder path didn't. Mirror it: shift mesh.position.y by elevation when negative. Positive elevation unchanged. Unrelated to render modes — bundled into this branch's PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof): legacy roofs render again — migration wrote invalid wallHeight 0 Legacy roof nodes (old format, no `children`) were migrated with a hardcoded `wallHeight: 0`. With zero wall height the eave height (`wallHeight - autoDrop`) went negative in getRoofSegmentBrushes, producing geometrically invalid brushes; three-bvh-csg then spammed "TriangleClipper: Coplanar clip not handled" every frame and emitted NaN positions, so the merged roof geometry failed computeBoundingSphere and never rendered. - core/use-scene migration: wallHeight 0 -> 0.5 (the RoofSegmentNode schema default), so migrated segments have a valid wall height. - roof-system: clamp eave height to >= 0.01 so an intentional wallHeight 0 can never yield a negative eave, and guard updateMergedRoofGeometry so a CSG result with NaN positions is discarded (keep the last good mesh, warn once per roof) instead of poisoning the buffer + spamming the console. Unrelated to render modes — bundled into this branch's PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(column): columns cast and receive shadows Column meshes (box, beam, cylinder, sphere, torus) rendered without castShadow/ receiveShadow, so columns neither dropped a shadow nor caught one — unlike walls, slabs and roofs. Set both on all column shape meshes. Unrelated to render modes — bundled into this branch's PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * viewer: default edges to soft Editor defaults are now solid shading / studio theme / soft edges / shadows on. Shading (solid, via EDITOR_DEFAULT_RENDER), theme (studio) and shadows (on) were already the defaults; edges was 'off' — make 'soft' the default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * themes: fold light/dark into the scene theme (remove the separate toggle) The viewer had two overlapping appearance controls: a light/dark `theme` toggle AND scene themes (which already drive the 3D background + lights). They conflicted — e.g. Night/Twilight are dark themes, but the light/dark toggle was an independent axis still tinting the 2D scene chrome. Unify on the scene theme: add an explicit `appearance: 'light' | 'dark'` to each SceneTheme (twilight/night = dark, the rest = light) and drive everything the old toggle drove off it — canvas backdrop, grid line colours, measurement-label/ cursor/site-edge contrast, the site ground fill, the ground occluder, and the mobile viewer bg. The editor UI chrome is unaffected (always dark via a fixed body class). Removes the `theme`/`setTheme` store state (+ persistence) and every light/dark toggle UI: the standalone toolbar Sun/Moon button, the community overlay theme switch, the command-palette command, and the ifc-converter preview toolbar button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * themes: per-theme ground colour + add the "Verdant" nature theme - Add a `ground` colour to every SceneTheme and drive the site ground fill + the infinite ground-occluder off it (instead of the binary isDark ? #1f2433 : #fafafa). Dark themes now get a lit mid-tone ground (twilight #4a4566, night #2b3247) so the ground reads as ground rather than going near-black. - Add a new green/nature scene theme "Verdant": soft green sky + lit, with a green roof clay tint and mossy ground. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wiki: document the surface-colour / theme system The colour-per-node/renderer/system model from the render-modes work was undocumented. Add wiki/architecture/materials-and-themes.md covering surface roles, colour presets, the textures axis, scene themes (appearance / ground / clay tints), and the "untextured surfaces are theme-coloured in both modes" invariant + where each kind wires it. Also fix two pages that the same work made stale: - node-definitions: geometry builders receive (shading, textures, colorPreset, sceneTheme); document the `surfaceRole` token + applyDefaultSurfaceRole. - layers: OVERLAY_LAYER (1, viewer) with EDITOR_LAYER now its alias, the new GRID_LAYER (3, rendered in the scene pass for depth occlusion), and the overlay pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * nodes: wire #330 roof-accessory kinds into the surface-role/theme colours PR #330's new kinds (chimney, dormer, skylight, solar-panel, ridge-vent, box-vent) use custom renderers, so the generic textures-off recolour path never reached them — they fell back to hardcoded colours. Wire each renderer into the render-modes system: read shading/textures/colorPreset/sceneTheme and resolve untextured surfaces via createSurfaceRoleMaterial (and force the role colour when textures are off), matching column/ceiling. Roles: chimney body→wall / cap→roof; dormer wall→wall, roof→roof, glass→glazing, frame→joinery; skylight glass→glazing / frame→joinery; ridge-vent + box-vent→roof; solar-panel frame→roof (the dark product-specific cell face is left as-is). Each definition also gets its dominant `surfaceRole` token. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof): legacy-roof migration must use a non-zero wallHeight The render-modes/#330 merge left the legacy roof→roof-segment migration writing `wallHeight: 0`. With #330's pitch model that builds a flat, zero-volume wall CSG brush, which three-bvh-csg can't clip ("Coplanar clip not handled") and yields NaN positions — so the migrated old roof never renders. Use the schema default 0.5 (what new roofs use), giving a valid wall. The eave clamp + merged- geometry NaN guard added earlier stay as defense-in-depth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof): guard slope frame against missing/NaN pitch (no more NaN geometry) getSegmentSlopeFrame used `pitch <= 0` to detect flat/zero-pitch, but an undefined or NaN pitch (a segment from an older migration that set `roofHeight` instead of `pitch`, or stale persisted data) slips past that check and computes Math.tan(NaN) → NaN tanTheta/activeRh → NaN segment geometry → the merged-roof CSG spews "Coplanar clip not handled" and NaN positions, so the roof never renders. Use `!(pitch > 0)` so any non-positive/non-finite pitch resolves to the flat frame. Self-heals bad data regardless of how the segment was produced. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof): migration guarantees a positive pitch for every roof-segment Segments saved with neither a valid pitch nor a roofHeight (older/partial saves, e.g. landing home-graph) fell through the legacy roofHeight->pitch branch, leaving pitch undefined. The slope-frame guard then resolved them to a flat frame, so the roof rendered as a slab instead of pitched. Branch 2b now normalises any segment lacking a valid pitch: derive from roofHeight when present, else fall back to the schema default (40deg). The migration result is cast (not zod-parsed), so this is the only place the default lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
87384cfbab |
feat(roof-system): six roof-accessory kinds (chimney, dormer, skylight, solar-panel, ridge-vent, box-vent) on the registry model (#330)
* 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(box-vent): port to packages/nodes registry shape Move box-vent from the legacy scattered layout (core schema + viewer/systems/renderers + editor/tools/panels/sidebar) into a single `packages/nodes/src/box-vent/` folder following the Phase 5 Stage E pattern. The kind now self-registers via the built-in plugin. - schema lives in `core/schema/nodes/box-vent.ts` (referenced by the hand-maintained AnyNode union) and re-exports from the kind folder. - `def.renderer` reads the parent roof-segment from useScene, applies the slope tilt + segment yaw + node rotation stack, and follows the segment's useLiveTransforms override during a parent drag. - geometry builder is pure and shared by renderer / preview / tool / unit tests. `computeBoxVentSlopeTilt` is lifted as a helper for future reuse by other roof-mounted kinds (skylight / solar-panel). - placement tool listens to `roof:*` events, snaps to the segment under the cursor, creates a new BoxVentNode parented to that segment. - BoxVentEvent + `NodeEvents<'box-vent', BoxVentEvent>` added to the event bus so `useNodeEvents(node, 'box-vent')` type-checks. Verified: workspace `bun run build` + `bun run check-types` pass; 13 new unit tests in `__tests__/{schema,geometry}.test.ts` pass. Worked example for porting the remaining roof-system kinds (ridge-vent, chimney, solar-panel, skylight, dormer) — see `.claude/PORT-CHEATSHEET.md`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ridge-vent): port to packages/nodes registry shape Same pattern as box-vent (`752ace83`): one folder under `packages/nodes/src/ridge-vent/`, schema in core, registration via the built-in plugin. No outside-the-folder edits beyond core schema/types, the event bus, and the plugin index. - pure geometry builder shared by renderer / preview / tool / tests, covering all three styles (curved cap / shingled / metal) and the optional end caps. - custom `def.renderer` reads the parent roof-segment, follows useLiveTransforms during a parent drag. No slope tilt — the ridge IS the high line of the segment so the transform stack is one level shallower than box-vent. - placement tool snaps the cursor to the ridge (segment-local Z=0) wherever the cursor lands on a segment, then commits on click with Z=0 baked into the new node's position. - RidgeVentEvent + NodeEvents<'ridge-vent', ...> added to the event bus. Verified: workspace build green, 9 new tests pass alongside the 13 box-vent tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(chimney): port to packages/nodes registry shape (Option C) Same shape as box-vent (`752ace83`) and ridge-vent (`10d489d6`). **Scope — Option C.** Chimney lands in the registry with solid geometry; the CSG-driven decoration (cap flue holes, body cavity, panels, bands, and the roof-trim that hides the chimney bottom inside the deck) is preserved in the schema but NOT rendered yet. These re-light when roof-segment migrates to Stage B and introduces a `roofCutout` capability the parent segment can read. Visual consequence: a placed chimney intersects the roof at the deck line instead of having a clean CSG-cut hole around it. Placement, move (via the legacy floating-vent-actions until the affordance tool is ported), paint, inspector edits, undo, and delete all work correctly. - pure builder returns `{ body, cap, flues, cricket }` so each piece carries its own material (body/top split matches the schema's `material` vs `topMaterial`). Body height derived from the parent segment's `wallHeight + (flat ? 0 : roofHeight) + heightAboveRidge`. - custom `def.renderer` reads the parent segment via `useScene`, follows `useLiveTransforms` during a parent drag. - placement tool listens to `roof:*` events, creates a new ChimneyNode parented to the targeted segment with segment-local coordinates. - ChimneyEvent + NodeEvents<'chimney', ChimneyEvent> added to the event bus. - ChimneyMaterialRole helper re-exported from core (used by the paint-mode picker — keeps the legacy multi-surface signature). Verified: workspace build green, 11 new tests pass (36 total across box-vent / ridge-vent / chimney). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(solar-panel): port to packages/nodes registry shape (Option C) Fourth roof-mounted kind, same shape as box-vent (`752ace83`), ridge-vent (`10d489d6`), and chimney (`45038713`). - pure builder generates the rows × columns cell grid as a single merged BufferGeometry with two render groups (frame + glass) so one mesh can take a `[frameMaterial, panelMaterial]` array. - analytical roof-surface helpers (`getSurfaceY`, `getAnalyticalNormal`, `surfaceQuatFromNormal`) live alongside the geometry builder and drive both the renderer (when `surfaceNormal` is absent from the node) and the placement preview/commit. - placement tool stores the analytical surfaceNormal on the new node so the runtime renderer and the placement preview produce the same orientation. - `solar-panel-presets.ts` moved into core (it was already imported from the schema there) and re-exported through `@pascal-app/core`. - inspector parametrics cover preset, grid, panel dims, mounting (flush/tilted with `tiltAngle` shown only when tilted), standoff, and frame. - SolarPanelEvent + NodeEvents<'solar-panel', ...> on the bus. **Option C still applies**: panels visually sit on the roof surface but the roof is NOT cut beneath them; the legacy renderer's useFrame-driven quaternion smoothing is replaced by a static quaternion computed once per render. Surface tracking under live parent rotation comes back when roof-segment migrates to Stage B. Verified: workspace build green, 16 new tests (52 total across the four ported kinds). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skylight): port to packages/nodes registry shape (stub) Fifth roof-mounted kind. Schema is complete, but the geometry and animation surfaces are intentionally stubbed — this commit lands the registration so the kind is present in palette / inspector / sidebar / undo, and follow-up commits flesh out the type-specific geometry and the animation system. **Scope.** - Schema: every field from the archive ports verbatim (25 fields, five `skylightType` variants, opening/sliding state, lantern proportions, curb). - Geometry: frame + glass rendered as plain boxes regardless of `skylightType`. Lantern slope, opening swing tilt, and sliding panel offset from the archive are not yet rebuilt. - Animation: `operationState` and `slideFraction` round-trip via the inspector but don't drive geometry yet and don't interpolate over time. The legacy animation lived in `useInteractive.skylight Animations`, which doesn't exist on main — re-introducing that surface is a focused follow-up. - Inherits Option C from chimney: no CSG cutout into the roof; no frame CSG (4 box rails instead). **Why ship the stub now**: the framework wiring (schema in core, event bus entry, plugin registration, inspector descriptor, custom renderer with parent-segment lookup, placement tool) is the part that's reusable across all five `skylightType` variants. Wiring + box geometry takes the kind from "doesn't exist" to "place / move / paint / delete / undo all work" without committing to the harder type-specific geometry decisions. Follow-up commits: - type-specific geometry (lantern slope, opening tilt, sliding offset) - animation system + `useInteractive.skylightAnimations` extension Verified: workspace build green, 7 new tests (59 total across the five ported kinds). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dormer): port to packages/nodes registry shape (stub) Sixth and final roof-mounted kind. Schema complete; geometry stubbed as a house silhouette (box body + triangular gable). Same Option C inheritance as chimney, solar-panel, and skylight. **Key call: window is inlined, not a hosted child.** The archive's dormer carries its window opening as parametric fields on its own schema (`windowWidth`, `windowColumns`, `windowSill`, etc.) — not a hosted `WindowNode` child. So `relations.hosts` stays unset and the kind doesn't need a `children` field. The 17 window-* fields stay in the schema; geometry beyond the silhouette stub picks them up later. - per-surface material resolution (`getEffectiveDormerSurfaceMaterial`) ports verbatim into core with the same cross-fallback semantics (top → material, side ↔ wall, then legacy `material`). - placement tool follows the established pattern (`roof:*` events, segment-local commit, analytical surfaceNormal stored). - `RoofType` import resolved from the existing `roof-segment` schema on main (the archive's `./roof-type` file is consolidated there). - DormerEvent + NodeEvents<'dormer', DormerEvent> on the bus. **Stub scope.** Geometry renders gable-only regardless of `roofType`; no window opening cutout, no window frame, no sill, no roof trim where the dormer meets the host segment. The archive's geometry relies on `getDormerExposedFaces` + `generateDormerGeometry` from the legacy roof-system, neither of which exists in `packages/nodes`. Follow-up commits add per-roofType dormer roofs, the window opening+frame+sill, and the trim/CSG against the parent segment. Verified: workspace build green, 12 new tests pass (71 total across all six ported kinds; pre-existing spawn parity failures unrelated). All six roof-system kinds now live in the registry shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: porting cheat-sheet for the roof-system kind migrations Reference doc kept alongside the six kind ports (box-vent, ridge-vent, chimney, solar-panel, skylight, dormer) so future kind authors can follow the same shape. Captures: - the per-kind folder layout (13 files, what each one owns) - the three-checkbox composition model (`geometry` / `renderer` / `system`) - every `NodeDefinition` field with usage notes - the wiring touch-points outside the kind folder (`packages/nodes/src/index.ts`, `packages/core/src/events/bus.ts`, the AnyNode union, the core schema exports) - per-kind decisions for the six roof-system kinds (which checkboxes each one ticks, what gets stubbed, what's deferred) - pitfalls hit while porting (material-cache leaks, group-transform mutation, host-kind children fields, Path 1 vs Path 2 floorplan move) - a pre-PR checklist Kept under `.claude/` (not `wiki/`) since it's a working note for the in-flight migration, not authoritative project documentation. Move into `wiki/architecture/` later if it earns its keep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: wire paint targets + skylight animation surface for new kinds Two small additions cherry-picked from roof-system-archive that the six kind ports depend on. Both are mechanical and unblock follow-up work without changing existing behavior. **Paint targets.** Add `chimney`, `skylight`, `dormer` to `MaterialTarget` enum so the paint picker surfaces these kinds. Wire `chimney` and `dormer` into the relevant material-library target arrays (WALL_TARGETS, SLAB_TARGETS, WALL_AND_SLAB_TARGETS, ROOF_TARGETS) so wall / slab / roof material catalog entries are offered when painting a chimney or dormer. Without this the new kinds' `material` / `materialPreset` fields can be set programmatically but the user-facing paint flow has nothing to target. **Skylight animation surface.** Port `SkylightInteractiveState` + `SkylightAnimationState` types, `skylights` / `skylightAnimations` store fields, and four actions (`setSkylightOpenState`, `removeSkylightOpenState`, `startSkylightAnimation`, `cancelSkylightAnimation`) onto `useInteractive`. Mirrors the existing door / window animation surfaces one-for-one. This is the prerequisite the skylight stub commit (`6dcee1ee`) called out — the follow-up commit that adds the skylight animation system component + wires `operationState` into the renderer's geometry now has something to consume. Neither change touches the six kind folders or their definitions — the kinds will pick up the new paint targets automatically and the skylight animation surface is dormant until a consumer ports forward. Verified: workspace build green, 71/71 kind tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(palette): surface the six roof accessories in StructureTools Closes the UX gap from the kind ports: box-vent / ridge-vent / chimney / solar-panel / skylight / dormer are registered in the registry with `def.tool` and `presentation`, but the top palette (`StructureTools`) is currently driven by a hand-coded `tools` array, not by the registry. So the new kinds existed in the codebase but had no entry point in the running editor — the user had no way to add them. - Extend `StructureTool` union in `use-editor.tsx` with the six new kind IDs so `setTool('chimney')` typechecks. - Add six entries to the `tools` array in `structure-tools.tsx`. All use the existing `/icons/roof.png` (a kind-specific icon set is a follow-up). The ToolManager already dispatches `nodeRegistry.get(tool)?.tool` (`tool-manager.tsx:28`), so clicking a new palette button activates the kind's registered `def.tool` automatically — no further wiring needed. Follow-up: a `parametrics.customPanel` on `roofDefinition` that surfaces inline "Add Chimney / Skylight / Dormer / ..." buttons in the roof inspector (matching the legacy `roof-panel.tsx` UX). For now, top palette is the entry point. Verified: workspace build green, 71/71 kind tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(roof): add inline "Add Element" section to the roof inspector When a roof is selected, the inspector now shows six quick-add buttons (Chimney, Dormer, Skylight, Solar Panel, Box Vent, Ridge Vent) in an "Add element" section between Position and Actions. Closes the discoverability gap from the kind ports — the user no longer has to hunt for the kind in the top palette. - Lives in `packages/nodes/src/roof/panel.tsx` (the roof's existing customPanel — it already escapes the auto-derived inspector to render Segments + Position + Actions). - Each button calls `useEditor.getState().setTool(kind)` to activate the kind's registered `def.tool`. The ToolManager dispatches via `nodeRegistry.get(tool)?.tool` (`tool-manager.tsx:28`), so this reuses the same code path as clicking the kind in the top palette. - Tools listen for `roof:*` events — after clicking "Add Chimney" the user clicks anywhere on a roof segment to commit the new node parented to that segment. Mirrors the legacy `roof-panel.tsx` UX (which had inline Add buttons that created hidden nodes + entered move mode); the registry-shaped equivalent activates the placement tool instead so the user sees a preview that follows the cursor. Verified: workspace build green, 71/71 kind tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ux(palette): remove roof accessories from the top palette Six kinds (box-vent / ridge-vent / chimney / solar-panel / skylight / dormer) only make sense in context of a selected roof segment — putting them in the top palette clutters it for users not actively editing a roof. They're entered through the roof inspector's "Add element" section instead (added in 275af8f4), which routes to the same registry-driven placement tools. - Remove the six entries from the `tools` array in `structure-tools.tsx`. - Keep `StructureTool` union additions in `use-editor.tsx` since `setTool('chimney')` etc. still need to typecheck from the roof panel's `activateTool` callback. Verified: workspace build green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(placement): always resolve a roof segment on click so add commits Likely root cause of "Add Element clicks not adding anything." The six roof-mounted placement tools each had a private `resolveSegmentFromWorldPoint` that returned null when the click's segment-local (x, z) fell outside `width/2 × depth/2` — but the visible merged-roof mesh extends past those bounds by the segment's overhang. Clicks landing anywhere in the eave band, or beyond every segment's nominal footprint, silently no-op'd: `onClick` early-returned on `if (!hit) return` and no node was created. - Extract a shared `resolveRoofSegmentHit` into `packages/nodes/src/roof/segment-hit.ts`. - Bounds check now includes `seg.overhang` on each side, matching the visible roof mesh. - If no segment passes the exact check, fall back to the FIRST segment with the click point projected into its local frame. Same policy the legacy `roof-panel.tsx` used (it parented all add operations to `segments[0]` and let the user move afterward). - Rewire box-vent, ridge-vent, chimney, solar-panel, skylight, and dormer placement tools to use the shared helper. Drop the per-tool copies (and the now-unused `RoofSegmentNode` import in 5 of them). After this, clicking "Add Chimney" / etc. in the roof inspector followed by a click anywhere on the visible roof commits the new node every time. Verified: workspace build green, 71/71 kind tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof): mount accessory children of roof-segments so Add actually adds Closes "Add Element click adds nothing to the scene." After the click, the new chimney/skylight/dormer/box-vent/ridge-vent/solar-panel node was being created in `useScene.nodes` with `parentId: <segmentId>` — but nothing mounted it visually. Two missing pieces: 1. RoofSegmentNode had no `children` array. `createNodesAction` appends `newNode.id` to `parent.children` only when the parent declares the field (`node-actions.ts:355`). Without it the parent-side write was a no-op, so the accessory existed in the store but nothing ever fired its `<NodeRenderer>` mount. 2. Even with the schema field, `roof-segment/renderer.tsx` was a leaf `<mesh>` — no recursive `<NodeRenderer>` mount of `node.children`. Fix: - `core/src/schema/nodes/roof-segment.ts`: add `children: z.array(z.string()).default([])`. - `nodes/src/roof-segment/renderer.tsx`: emit a `<group>` alongside the placeholder mesh that iterates `node.children` and mounts each via `<NodeRenderer>`. The group carries the same transform as the mesh so accessories inherit the segment's local frame — matching the segment-local coordinates each accessory renderer assumes. - `nodes/src/roof/renderer.tsx`: drop the `visible={false}` segments wrapper. `RoofSystem` only fills the parent roof's `merged-roof` mesh (`viewer/systems/roof/roof-system.tsx:172` via `getObjectByName('merged-roof')`), so segment placeholder meshes stay empty and don't z-fight with the visible roof. Mounting segments inside a visible wrapper is what lets accessory grand- children render at all. Also unblocks the user's `roof/panel.tsx` accessory-list selectors (which loop `seg.children` for chimneys/dormers/skylights/etc.) by giving the schema the field they expect. Verified: workspace build green, 71/71 kind tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roof-accessories): drop double-applied segment transform from renderers After the previous fix (segments host accessories via recursive NodeRenderer), each accessory was being positioned at *twice* the segment offset — the renderer's outer group still applied `segment.position` and `segment.rotation`, and the React parent (the segment's group) was already at that transform too. Result: chimneys, skylights, dormers, etc. landed in the scene graph but rendered far off-screen — invisible from any normal camera view. Fix the six accessory renderers (box-vent, ridge-vent, chimney, solar-panel, skylight, dormer) to assume the segment's transform is inherited from the React tree: - Drop the outer `<group position={segmentPosition} rotation-y={...}>` wrapper. - Apply `node.position` (segment-local) directly to the ref'd outer group, with the kind-specific tilt / quaternion / yaw on inner groups. - Drop `useLiveTransforms` lookup for the segment — React tree re-renders propagate parent transform changes automatically. - Keep the `useScene` segment lookup; it's still needed for kind- specific math (slope tilt, analytical surface normal, base Y from wallHeight) that reads segment fields beyond just the transform. Chimney's outer group sits at `[0, 0, 0]` because `applyNodeTransform` in `geometry.ts` already bakes `node.position` and `node.rotation` into the chimney's vertex positions (which also bake `baseY` from `segment.wallHeight`). No double application there either. After this, Add Element → click → place puts the accessory at the clicked spot on the roof, visible and selectable. Verified: workspace build green, 71/71 kind tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(scene): migrate existing roof-segments to add `children: []` Closes "Add Element click adds to side panel but not to scene graph." The previous fix added `children: z.array(z.string()).default([])` to RoofSegmentNode, but that default only applies when zod parses the segment fresh. Every roof-segment already in a loaded scene (saved before the schema change) carries no `children` field at runtime. `createNodesAction` (`node-actions.ts:355`) appends the new child id to `parent.children` only when `'children' in parent && Array.isArray(...)` is true. For un-migrated segments that check fails — the chimney / skylight / dormer / etc. is added to `useScene.nodes` (so it shows up in the sidebar tree) but the parent-side write is a no-op, so the segment's children array remains undefined, the segment renderer's recursive `<NodeRenderer>` finds nothing to mount, and nothing appears in the 3D scene. Mirror the existing shelf migration (`use-scene.ts:351`): when the scene loads, patch every roof-segment whose `children` isn't an array to `children: []`. Existing scenes get the field on next load; new segments get it from the schema default. After this the "Add Element" click commits visibly. Verified: workspace build green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(chimney): port archive's bespoke tabbed panel as customPanel The auto-derived inspector worked but couldn't reproduce the archive's tabbed UI (Cap / Flues / Shoulder / Bands / Cricket / Panels) where each sub-section gets its own dedicated controls laid out as a grid of selectable cards. Users selecting a chimney got a flat parametric form instead of the dense bespoke editor. - Drop the legacy `packages/editor/src/components/ui/panels/chimney-panel.tsx` into `packages/nodes/src/chimney/panel.tsx` (the kind's customPanel slot). Rewrites: * Helper imports collapsed to one barrel from `@pascal-app/editor` (`ActionButton`, `ActionGroup`, `PanelSection`, `PanelWrapper`, `SegmentedControl`, `SliderControl`, `triggerSFX`). * `sfxEmitter.emit(...)` → `triggerSFX(...)` (same SFX, registry- safe export from `@pascal-app/editor`). * Inline a 3-line `cn` helper since editor doesn't re-export the legacy `lib/utils` one. * `ChimneyPanel` becomes `default export` so customPanel's lazy loader can pick it up. - Wire `chimneyParametrics.customPanel = () => import('./panel')` so the registry's parametric inspector defers to the bespoke component. - Keep `groups` in `chimneyParametrics` for MCP / fallback consumers (the parametric data is still authoritative). User-visible: clicking a chimney now opens the tabbed inspector with the exact category layout from the archive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(chimney): CSG-trim the body against the roof so it carves cleanly Closes "chimney is not getting trimmed just like in roof-system." Previously the chimney mesh was rendered as solid geometry that intersected the roof shell visually at the deck line — Option C debt called out in `45038713`. Now the body is CSG-cut against the parent segment so only the portion above the shingles is visible, matching the archive's UX. - `packages/viewer/src/lib/csg-utils.ts`: port `csgEvaluator`, `csgGeometry`, `csgMaterials`, `computeGeometryBoundsTree`, `prepareBrushForCSG`, and the `Brush` / `SUBTRACTION` re-exports from `roof-system-archive`. Lives in viewer because `three-bvh-csg` + `three-mesh-bvh` are viewer-only deps. - `packages/viewer/src/index.ts`: expose the CSG primitives + the existing `getRoofSegmentBrushes` (which was already defined on main but not in the package surface). Adding `getRoofSegmentBrushes` to the export — internal already; this just opens it for kinds living in `@pascal-app/nodes`. - `packages/nodes/src/chimney/roof-trim.ts`: new helper `trimChimneyBodyAgainstRoof(body, segment, node)`. Wraps the body in a `Brush`, runs a two-pass `SUBTRACTION` (chimney - wallBrush - shinSlab), returns the trimmed `BufferGeometry`. Returns the input unchanged on any CSG failure so the chimney still renders. - `packages/nodes/src/chimney/renderer.tsx`: memoize a `trimmedBody` alongside the existing geo memo (keyed on the segment shape fields that drive the roof brushes) and pass it to the body mesh instead of `geo.body`. Disposal updated to release whichever buffer is actually live. Deferred (Option C still): bands and panels CSG. They were the same flow but operate on additional pieces; they re-light in a follow-up once the chimney's bands / panels geometry comes back online. Verified: workspace build green, chimney unit tests pass (14/14). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skylight): port full v1 implementation (panel, 5-type 3D, CSG cut, animations) Brings the skylight node from a box-only stub up to feature parity with the roof-system branch. Mirrors the chimney port pattern established in 9fd42e33 and 65eec685. UI - packages/nodes/src/skylight/panel.tsx — bespoke tabbed inspector (type card picker + per-variant controls: lantern height/scale, opening angle/side/motor, sliding direction/track width, curb, frame, position, rotation). Wired via parametrics.customPanel. 3D - packages/nodes/src/skylight/{renderer,geometry,frame-csg}.tsx — full 5-variant geometry (flat / walk-on / lantern / opening / sliding) with frame ring CSG and type-specific glass (lantern pyramid + cylindrical frame bars; opening hinged glass with optional motor housing; sliding two-pane on tracks). - packages/nodes/src/skylight/preview.tsx — uses the real frame-csg builder so placement ghost matches the committed mesh. Placement / move - packages/nodes/src/skylight/tool.tsx — commits hit.localY so the skylight lands on the outer shingle surface, not the bare-rafter analytical Y (was sinking into the deck). - packages/nodes/src/skylight/move-tool.tsx — kind-owned drag wired via def.affordanceTools.move. Uses SkylightPreview as the ghost so drag and duplicate both show the real frame following the roof raycast. Reparents across segments and dirties old+new for CSG re-cut. CSG cutout - packages/viewer/src/systems/roof/roof-system.tsx — buildSkylightCutBrush added; the per-child loop in updateMergedRoofGeometry subtracts every skylight from shin/deck/wall in segment-local before the segment transform stacks on (matches v1). - Ported v1's getRoofOuterSurfaceFrameAtPoint helper (raycast against the actual outer-shingle module mesh) and made both the cut and the renderer read surface point + normal from it — keeps frame and cut aligned on every roof type incl. hip 4-faces, gambrel, mansard, dutch. - mergeVertices on the cut box before computeBoundsTree — without it three-bvh-csg silently no-ops on the BoxGeometry after applyQuaternion tilts the cut ~90° about the surface normal (hip short faces). - Renderer wraps content in an outer <group position={segment.position} rotation-y={segment.rotation}> so the frame inherits the same segment transform that applyTransform bakes into the cut brush (skylight is rendered under <group name="roof-elements"> at the roof level, not under the segment, so the renderer has to apply it explicitly). - Skylight dirty propagation in RoofSystem: edits/moves dirty the host segment so the parent roof rebuilds. - packages/viewer/src/index.ts — exposes getRoofOuterSurfaceFrameAtPoint, SurfaceFrame, getRoofSegmentBrushes, csg primitives so @pascal-app/nodes can compose roof-aware cuts without a layer violation. Animations - packages/editor/src/lib/skylight-interaction.ts — verbatim port of v1 (toggleSkylightOpenState, closeSkylightOpenState, isOperableSkylightType, SKYLIGHT_TOGGLE_ANIMATION_MS = 520). - packages/editor/src/hooks/use-keyboard.ts — R toggles, T closes operable skylights, mirroring door/window. - packages/nodes/src/skylight/system.tsx — SkylightAnimationSystem ported as def.system; advances skylightAnimations and writes operationState back to useInteractive.skylights. - Dropped the per-tick markSkylightDirty in the animation system. The renderer subscribes to useInteractive directly, so the glass swings/ slides via Zustand re-renders without dirtying the scene — the cut geometry doesn't depend on operationState, so re-CSG'ing the merged roof on every animation frame was pure waste (caused visible lag). - packages/core/src/index.ts — exports SkylightInteractiveState and SkylightAnimationState (interaction lib uses them). Drag / duplicate ghost - Floating action menu's setMovingNode → MoveTool → registry affordance now resolves to the kind-owned move tool. Duplicate already worked through structuredClone + def.schema.parse + setMovingNode; the new move-tool provides the ghost both flows use. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Persist roof segment materials across refresh * feat(solar-panel): full port from archive + placement/orientation fixes Solar panel — ported from roof-system archive to registry shape: **Inspector & presets** - Custom panel (panel.tsx) with preset card grid (Residential / Residential Large / Compact / Frameless); picking a preset now writes all four dim fields (panelWidth / panelHeight / frameThickness / frameDepth) so the inspector immediately reflects the selection. - Auto-fit to roof, Flip orientation, Custom label when no preset matches. - Live preview: renderer subscribes to useLiveNodeOverrides so slider drags update the mesh before the value is committed to the Zustand store. - Registered via parametrics.customPanel (same pattern as chimney). **Texture / materials** - Procedural cell texture (createSolarPanelTexture): chamfered cell shape, dark blue gradient, finger-line and busbar detail drawn on a 256×256 canvas, tiled per cell via stretched UVs in buildSolarPanelGeometry. - getDefaultPanelMaterial singleton uses MeshStandardNodeMaterial (WebGPU- native) so the material integrates with the MRT pass without triggering "Color target has no corresponding fragment stage output / writeMask not zero" GPU validation errors on segment reparent. - defaultFrameMaterial and move-tool previewMaterial also switched to WebGPU-safe types (MeshStandardNodeMaterial / MeshBasicMaterial). **Default grid size** - Schema defaults changed from 4 rows × 5 columns → 2 rows × 3 columns. - Placement ghost and move-tool ghost use a compact 2×3 footprint; committed panels also default to 2×3. **Placement tool** - Commit position uses raycast hit Y (hit.localY from segObj.worldToLocal) instead of analytical getSurfaceY so the panel lands exactly where the ghost was shown rather than sinking into the deck/shingle layers. - Ghost orientation uses the same analytical-normal + explicit-yaw pattern as the placement tool for correctness on rotated segments. **Move tool** - Rewrote ghost to use resolveRoofSegmentHit + getAnalyticalNormal (segment-local) + explicit rotation-y group, matching the placement tool's ghost layout exactly. Dropped unreliable event.normal / world- space quat path that gave wrong tilt on any segment with rotation ≠ 0. - Committed surfaceNormal is now segment-local (not world-space) so the renderer's surfaceQuat + outer segment.rotation group compose correctly without double-rotating the panel. - Uses shared resolveRoofSegmentHit (with surface-Y disambiguation) instead of the private copy, so segment hopping respects the correct face. - Reparents children arrays on segment hop. **Renderer** - Applies segment.position + segment.rotation explicitly (roof accessories are mounted under roof-elements group which has no transform, not under segment subtree). - Merges useLiveNodeOverrides so slider drags update the 3D mesh in real time (same pattern as elevator/skylight renderers). **Scene graph** - SolarPanelTreeNode added; registered in tree-node.tsx type map so panels appear under their parent roof-segment in the sidebar. **Segment-hit disambiguation** - resolveRoofSegmentHit now scores all bbox-passing candidates by |localY − analyticalSurfaceY(localX, localZ)| and picks the smallest, fixing the long-standing bug where hip/gable segments at the same roof origin all pass the axis-aligned bbox test and the first-match (always segments[0]) was returned regardless of which slope was clicked. Benefits all roof-accessory placement tools (chimney, box-vent, skylight, dormer, solar-panel). **Hip-roof normal fix** - getAnalyticalNormal for hip now uses slopeReach = min(w,d)/2 for the Y component on all four faces. The old code used depth/2 for front/back and width/2 for sides, which was only correct for square (w==d) hips; for any other aspect ratio the long-axis faces tilted the panel at the wrong angle. **Dormer, dormer move-tool, window-frame, ridge-vent, box-vent, skylight** - Assorted in-progress work: dormer window-frame geometry, move-tool port, panel refinements, ridge-vent / box-vent panel additions, skylight CSG frame refinements, roof-system geometry improvements, material-paint support, post-processing cleanup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * perf(chimney): fix hook order + share materials + memoise segment brushes - renderer.tsx: hoist `surfaceArray` useMemo above the `!segment || !geo` early return so hook call order stays stable across renders (the previous order would have crashed React the first time segment or geo flipped to null mid-session). - renderer.tsx: replace the module-scoped `bodyMaterial` / `topMaterial` singletons with per-instance fallback materials so a paint-mode or debug mutation on one chimney can't bleed into every other unpainted chimney on the scene; dispose them on unmount. - renderer.tsx: collapse the 36-field hand-maintained dep array on the `geo` useMemo (and the 10-field one on `trimmedBody`) down to the memoised `node` / `segment` references — adding a new schema field no longer risks stale geometry from a forgotten dep, and the `eslint-disable react-hooks/exhaustive-deps` lines are gone. - renderer.tsx + roof-trim.ts: memoise `getRoofSegmentBrushes(segment)` per-segment-shape in the renderer instead of rebuilding the four CSG-ready brushes inside `trimChimneyBodyAgainstRoof` on every call. A chimney slider drag changes `node.*` but not the segment, so the brushes now survive the entire drag instead of being rebuilt and disposed every frame. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(scene): pin reparent behavior for roof-mounted kinds under repeated A→B→A→B Investigating a reported crash where moving a vent across roof segments three times in a row crashes the scene. The hypothesis was duplicate IDs in the host segments' `children` arrays. These tests prove that's NOT the cause: at the store level, the auto-reparent inside `updateNodesAction` leaves children lists clean under repeated hopping for every roof-mounted kind (box-vent, chimney, skylight, dormer, solar-panel, ridge-vent), and even the redundant manual-then- auto pattern the vent move-tools use converges to the same correct state. Crash root cause still under investigation, but these pins prevent the obvious-and-tempting regression where someone "fixes" reparent by hand and accidentally lets duplicates through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(chimney): give each sub-mesh its own name Body, cap, flues, cricket, and bands were all named `chimney-surface`, so hover/selection couldn't distinguish them and panel breadcrumbs couldn't say "Chimney cap" vs "Chimney body". Rename to `chimney-{body,cap,flues,cricket,bands}`. No code looked up the old literal, so this is a pure naming improvement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(chimney): smooth round shading, radial cap UVs, cap reveal, flue pots Four visual upgrades to the chimney builder, all in `geometry.ts`: - **Round chimneys now render smooth, not faceted.** The previous `pushCylinderFaces` emitted unindexed triangles, so `computeVertexNormals()` baked per-triangle face normals into every vertex — the 24 polygon segments of a round body / cap / band were visible as flats. Round paths now build per-tier `THREE.CylinderGeometry` (indexed, side vertices shared across radial segments) and merge via `mergeGeometries`. Crisp rim edges are preserved because CylinderGeometry uses separate cap vertices. - **Radial cap UVs.** Old `pushCylinderFaces` pushed `(0,0)` for every vertex on the top/bottom fan, so any texture on a round chimney smeared to a point at the caps. CylinderGeometry gives proper radial UVs (0.5 ± 0.5·cos/sin) for free. - **Cap reveal.** The cap used to sit flush on the body, reading as glued on. New `CAP_REVEAL = 0.003` (3 mm) air gap above the body catches a shadow line and sells the cap as a separate stone / metal piece. `capTopY` (used for flue placement) updates so flues still sit on the actual cap top. - **Flue pots, not drainpipes.** Each flue was a single straight cylinder / box — visually a "drainpipe", not a chimney. New two-tier silhouette: a tall straight shaft topped by a short overhanging rim (12 % of height, capped at 4 cm; rim radius flares 12 %). Reads as a terracotta pot. Total height still equals `flueHeight`, so the bore cutter in `holes.ts` covers the whole envelope unchanged. Removed the now-unused `pushCylinderFaces` helper. Slab path unchanged — square chimneys keep their crisp 90° corners. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(chimney): cornerBevel for square bodies / caps / bands Square chimneys read as plastic boxes at any distance because every vertical edge is a perfect 90° corner that catches no light. New `cornerBevel` field (default 0 → existing scenes unchanged) replaces each corner with a 45° chamfer face. Real masonry chimneys often ship the same detail — a small bevel (~1-2 cm) breaks up the silhouette and reads as stone or chamfered brick. - Schema: add `cornerBevel: z.number().default(0)` to ChimneyNode. - Geometry: extend `pushSlabFaces` with an optional `bevel` param. When > 0, dispatch to a new `pushOctagonalSlabFaces` that emits an 8-vertex ring per y-level (axis-aligned faces + 45° chamfer faces) plus fan-triangulated octagonal caps. UVs follow the same physical-meter convention as the unchamfered path so a brick texture tiles at a consistent rate with and without bevel. - Thread `node.cornerBevel` through `buildBodyGeometry`, `buildCapGeometry`, and `buildBandsGeometry` (square paths only — round bodies have no corners to bevel). - Parametrics: expose under the Body group with `visibleIf` gating on square body for the MCP / fallback inspector. - Panel: add a "Corner Bevel" SliderControl in the Footprint section, same conditional visibility, clamped at `min(width, depth) / 2`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(chimney): style presets — Brick / Stone / Modern / Round The chimney panel exposes 30+ sliders; landing on a coherent silhouette (corbeled stone with a sloped cap and a cricket vs. a straight brick with a double band) takes a dozen edits even when you know what you want. New "Style" segmented control at the top of the panel applies a curated bundle of fields in one click. Presets only touch shape / silhouette / accessory fields: `bodyShape`, `shoulderStyle*`, `cap*`, `band*`, `cricket*`, `cornerBevel`, `panel*`, `flue*`. Dimensions (`width`, `depth`, `heightAboveRidge`), placement (`position`, `rotation`, `roofSegmentId`), and paint (`material*`, `topMaterial*`) are deliberately left alone — applying a preset to an already-sized, already-painted chimney resizes nothing and doesn't overwrite the user's material choices. - `presets.ts`: four preset bundles + `detectActiveChimneyPreset` helper for highlighting the matching preset in the segmented control. - `panel.tsx`: new "Style" PanelSection above Footprint, segmented control wired to `commitProp(chimneyPresets[key])`. Renders with no segment highlighted ("custom") when the current node doesn't match any preset exactly. - `__tests__/presets.test.ts`: round-trip each preset, confirm fresh-default chimneys are NOT detected as any preset, and confirm non-preset fields (dims / materials / placement) don't knock a chimney out of a preset. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(chimney): drop the Stone preset Leaves Brick / Modern / Round. The parameterised round-trip test auto-adjusts via `CHIMNEY_PRESET_KEYS`; no test code change needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dormer): bugs, perf, UX, refactor + bundled pitch WIP Dormer review-backlog batch across five passes. Plus the previously unstaged pitch/roofHeight migration on RoofSegmentNode bundled in per session continuity. Dormer — bugs / dead code: - implement the windowSill (toggle was UI-only before) - ghost preview reads wallSkirtHeight and branches on roofType=flat - live-override slider drag swaps the heavy CSG for the fallback - consolidate arch/rounded shape builders between viewer CSG and frame - drop unused surfaceNormal field - collapse getEffectiveDormerSurfaceMaterial fall-through - confine the panel's updateWorldMatrix into a single useMemo - preserve position Y on panel commits (was being zeroed) Dormer — schema hygiene: - DORMER_DEFAULTS named constants replace inline magic numbers - collapse windowCornerRadius + windowRadiusMode + windowCornerRadii into the tuple alone; "All vs Individual" is derived UI state - drop the `as never` id casts; rely on objectId default factory Dormer — tactile UX: - R / Shift+R rotates the placement ghost by ±15° - auto-number new dormer names ("Dormer N", smallest free integer) - DORMER_PLACEMENT_SNAP_M + ROTATION_STEP constants extracted Dormer — code shape: - new use-dormer-placement hook dedupes tool + move-tool (~90% shared) - new <DormerWindowAssembly> isolates the frame/glass/sill JSX - panel.tsx 788 -> 295 lines; Position / Window / Actions sections extracted into per-file components Bundled pitch WIP (pre-existing, unrelated to dormer): - RoofSegmentNode.roofHeight removed; pitch (degrees) added - new helpers in roof-segment: getActiveRoofHeight, getPitchFromActiveRoofHeight, getSegmentSlopeFrame, ROOF_SHAPE_DEFAULTS - migration in use-scene.ts converts legacy roofHeight to pitch - consumers updated: chimney, box-vent, ridge-vent, solar-panel, roof-segment, roof, segment-hit, roof-tool, mcp construction-tools Verification: 12/12 dormer tests pass; targeted tsc on dormer files clean. Workspace bun build of nodes is also affected by the pitch WIP, which is included here per request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(roof): per-segment top/edge/wall materials + restore legacy UV flow - RoofSegmentNode gains optional topMaterial / edgeMaterial / wallMaterial fields mirroring the parent roof. getEffectiveSegmentSurfaceMaterial resolves through segment-role → segment-legacy → parent fallback. - Segment renderer builds the 4-slot array per role with the parent's array as a fallback so paint at any level reaches the right surface. - Painting a segment directly (segment edit mode hover) writes to the segment's role fields via buildRoofSegmentSurfaceMaterialPatch — the parent roof and other segments are untouched. - Segments with any material override render as their own per-segment mesh inside a new always-visible 'painted-segments' group; the merged- roof CSG skips them (hasSegmentMaterialOverride) so we don't double- paint with the roof's default array. - Paint preview now dispatches to a segment-aware path (applyRoofSegmentPaintPreview) so hover effects land on the visible per-segment mesh instead of the hidden merged-roof. - Drop the createRoofUvGeometry post-CSG re-projection. UVs now flow through CSG (csgEvaluator.attributes includes 'uv') exactly as in the legacy roof-system branch. - Drop the stray [skylight-cut] console.log left in the hot path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(roof-system): registry capabilities for roof accessories, paint, and keyboard Replace kind-name branches in framework code with registry-driven dispatch. Three new NodeDefinition slots back the migration: - capabilities.roofAccessory — host roof cascade + optional CSG cut. Lets viewer's RoofSystem iterate dirty children and call buildCut on any kind that declares it, instead of switching on node.type. Dormer + skylight cut builders moved into packages/nodes/<kind>/. - capabilities.paint — resolveRole / buildPatch / applyPreview / getEffectiveMaterial. Chimney, dormer, and wall now route through it; per-kind arms deleted from selection-manager + material-paint. - keyboardActions — R / T handlers contributed by the kind. Skylight's open/close logic moved from editor/lib to nodes/skylight/interaction. Dormer + skylight kind code (geometry, fallback shape, exposed-face math, window-dim resolver, CSG cut builders) now lives under packages/nodes/ src/<kind>/ instead of packages/viewer/src/systems/roof/roof-system.tsx. The viewer keeps only roof-generic primitives (roof-segment brushes, surface-frame query, CSG dummy mats, material-slot remap). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: remove PORT-CHEATSHEET, document roofAccessory/paint/keyboardActions, fix double registry lookup - Remove .claude/PORT-CHEATSHEET.md (AI authoring aid, not for public repo) - Document three new NodeDefinition capabilities in wiki/architecture/node-definitions.md: roofAccessory, paint, keyboardActions - Fix double nodeRegistry.get() lookup in use-keyboard.ts: replace !.keyboardActions!.r!.run() with ?.keyboardActions?.r?.run() for both R and T arms * fix(item-placement): memoize preview/dimension callbacks to stop placement loop updatePreviewGeometry and updateDimensionGuides were declared as plain functions in the component body, so they got a fresh identity every render. Both sit in the placement setup effect's dependency array, which made React tear the effect down and re-run it on every commit — its teardown deletes the draft node while the setup re-creates it, producing an infinite delete/recreate loop ("Maximum update depth exceeded") when opening furnish mode. Wrap both in useCallback with empty deps (they only close over stable refs, module-level helpers, and the setDimensionBounds setter). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(item-placement): correct preview box dimensions and floor rotation Two issues surfaced after the placement-loop memoization removed the accidental every-render recompute that was masking them: 1. Preview box used stale (asset-default) dimensions at draft creation because nothing recomputed it once the imperative draft was made. Recompute the box from the freshly-created draft in `ensureDraft` and the chained next-draft path in `onGridClick`. 2. The green/red box (and the live transform the 2D floorplan mirrors) ignored the floor item's rotation: - `floorStrategy.move` returned a hardcoded `cursorRotationY: 0`; now returns the draft's rotation (`rotY`). - `onGridMove` never applied `result.cursorRotationY` to the cursor group; now it does, so box + floorplan track the draft on every move. - the init seed used the mesh world quaternion, which double-counts building rotation for floor items; floor now seeds from the node's local Y rotation (wall/ceiling keep the world-quaternion path). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: open-pascal <open@pascal.app> Co-authored-by: Wassim SAMAD <wass08@gmail.com> |
||
|
|
ffc497c67a |
editor: gate Load Build behind a verification dialog
Loading JSON previously called setScene blindly and crashed when the file held schema-invalid nodes (e.g. items missing `asset`). The new dialog parses the file, runs validateBuildJson in core, and surfaces structure counts (site/building/levels/walls/doors/ windows/items/slabs/ceilings/zones/scans), floor area, and a per-node Schema Details list grouped by type. Import is blocked when any hard error exists. Two schema bugs the validator surfaced are fixed here too: - SiteNode.children is now an id array like every other node (was a discriminatedUnion of full objects; three readers carried a string-or-object ternary that's now dropped). migrateNodes flattens legacy nested-object children on load. Default-scene seed and photo-to-scene MCP builder updated to pass `building.id`. - LevelNode.children now includes shelf — the editor allowed it but the schema didn't. The schema-vs-registry-as-source-of-truth discussion is captured in plans/editor-node-registry.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
3adae8acea | style: apply biome lint fixes after portforward | ||
|
|
fe68dbeebd | Constrain wall moves along wall normals | ||
|
|
c31c08fd90 | feat: implement StairOpeningSystem to handle automated stair opening updates and adjust camera distance constraints | ||
|
|
8b73d7986b | feat: expand material library with new wood and flooring textures while cleaning up legacy assets | ||
|
|
a9d26239b7 | Refactor material catalog for roofing and flooring | ||
|
|
a3378a666d | Preserve elevator stop order and handle viewport resize | ||
|
|
cdde88f9c0 | Clamp spiral stair openings at full sweep | ||
|
|
3800bd7fc8 | fix: increase stair opening buffer constraints and set default offset to 0.15 | ||
|
|
9a481f2511 |
registry: generic FloorElevationSystem driven by a floorPlaced capability
Symptom: a shelf placed on a level with a raised slab underneath visually clipped through it — `ItemSystem` lifted items onto slabs, but shelves (and other floor-placed registry kinds) had no equivalent path. Fix: lift the slab-elevation logic out of `ItemSystem` into a generic `<FloorElevationSystem>` keyed off a new `capabilities.floorPlaced` config. Any kind that opts in declares a `footprint(node)` (dimensions + rotation used to query overlapping slabs) and an optional `applies` predicate (skips items whose `asset.attachTo` is wall / ceiling). The new system runs at frame priority 1 so its `mesh.position.y` override lands before `ItemSystem` / `GeometrySystem` (priority 2) clear the dirty mark. The spatial-grid sync's `markNodesOverlappingSlab` also dropped its hardcoded `item` branch in favour of an iteration over every registered kind that declares `floorPlaced` — so any new floor-placed kind picks up slab-driven re-elevation automatically. Tagged kinds: - `item` — `footprint = getScaledDimensions`, `applies = !asset.attachTo` - `shelf` — `footprint = (w, h, d)` - `column` — `footprint = (w, h, d)` - `spawn` — `footprint = (0.6, 1.8, 0.6)` (marker) `ItemSystem` retains only the wall-side z-offset block (`mesh.position.z = wallThickness / 2`). The elevation block + its `getScaledDimensions` / `resolveLevelId` / `spatialGridManager` imports moved to the generic system. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d747d2f0ea |
Phase 5 Stage E: full kind migration into packages/nodes
Wholesale move of every remaining kind into its own subdirectory under
`packages/nodes/src/`, finishing the registry-driven migration. Each
kind now ships its definition, schema (re-exported from core), and any
of `geometry` / `renderer` / `system` / `floorplan` / `tool` /
`move-tool` / `panel` / `floorplan-move` / `floorplan-affordances` /
`parametrics` / `preview` it needs — no per-kind code remains under
`packages/editor/src/components/tools/` or
`packages/viewer/src/components/renderers/`.
Deleted (replaced by registry-driven equivalents):
- `tools/{ceiling,column,door,fence,item,slab,spawn,wall,window}/...`
(boundary editors, hole editors, placement tools, move tools,
endpoint movers, curve tools, helpers, math libs)
- `ui/helpers/{ceiling,slab,wall}-helper.tsx`
- `ui/panels/{column,door,elevator,item,roof,roof-segment,spawn,
stair,stair-segment,wall,window}-panel.tsx`
- `viewer/src/components/renderers/{building,ceiling,column,door,
elevator,fence,guide,item,level,roof,roof-segment,scan,site,slab,
spawn,stair,stair-segment,wall,window,zone}-renderer.tsx`
- `viewer/src/components/viewer/legacy-system.tsx`
Added under `packages/nodes/src/`:
- `building/`, `column/`, `elevator/`, `guide/`, `level/`, `roof/`,
`roof-segment/`, `scan/`, `shared/`, `site/`, `stair/`,
`stair-segment/` packages with definition + schema + renderer / system
/ floorplan / panel as appropriate.
- New `floorplan-move.ts` for every kind that supports 2D moves
(ceiling, door, item, shelf, slab, window) — single registry-driven
dispatch path via `def.floorplanMoveTarget`.
- New `floorplan-affordances.ts` for kinds with polygon / endpoint
drags (ceiling, fence, slab, wall) — using the shared
`polygon-vertex-affordance` factories.
- New per-kind `panel.tsx` for kinds with custom inspector content
(door, item, shelf, spawn, wall, window).
- New per-kind `tool.tsx` for placement (door, item, shelf, window).
- New per-kind `move-tool.tsx` for kinds with custom 3D move flows
(door, item, slab, window).
Coordinator + manager updates in `packages/editor/`:
- `tool-manager.tsx` resolves tools from the registry only — no
hardcoded type→component map.
- `panel-manager.tsx` resolves inspector panels the same way.
- `placement-{coordinator,strategies,types}.ts` extended with
shelf-surface placement.
- `selection-manager.tsx` adds the registry-selectable fallback.
- `floorplan-panel.tsx`, `floorplan-background-placement.ts`,
`floorplan-render-context.tsx` updated for the registry layer's new
contract (props, affordance dispatch, render context).
Viewer updates:
- `viewer/index.tsx` drops legacy renderer mounts.
- `node-renderer.tsx` resolves by registry only.
- `scene-bvh.tsx`, `use-node-events.ts`, `level-system.tsx`,
`wall-cutout.tsx`, `zone-system.tsx`, `materials.ts` adjusted for
the registry-only world.
Sidebar tree nodes for ceiling / fence / slab / shelf / tree-node
updated to read from the registered nodes instead of the deleted
legacy renderer trees.
Wiki: new `plugin-authoring.md` page, README index updated.
Tests in `packages/nodes/src/index.test.ts` validate every registered
kind has the required shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
7a92641baa |
floorplan/registry-layer: z-order buckets + overlay pass + click guard
Three layered improvements on the 2D registry layer:
- Z-order buckets so the SVG document order reflects intent: zones (0)
paint first, slabs/ceilings (1) next, every other kind (walls / items /
shelves / columns / stairs / …) on top. Stable sort preserves DFS order
within a bucket.
- Base / overlay split: each entry's `FloorplanGeometry` tree is walked
through `splitFloorplanOverlay`, partitioning into a base group
(polygons, paths, fills, hatches) and an overlay group (interactive
handles + labels — `text` / `endpoint-handle` / `midpoint-handle` /
`edge-handle` / `move-handle` / `dimension` / `dimension-label`). Base
renders rank-sorted; overlays paint after every base entry so polygon-
editor chrome on a selected slab and zone name labels stay legible
above the structural fills sitting on top of them.
- Click guard on the outer layer `<g>`. The base/overlay split means
pointer-down lands on base and pointer-up lands on overlay (selection
mounts the overlay on top mid-gesture). The browser then dispatches
`click` to the lowest common ancestor, ABOVE the entry's
`onClick={handleClickStop}`. Without a higher-level stop, the click
reached the SVG's `handleBackgroundClick` → `clear-elements` and the
selection set on pointer-down vanished a frame later. Scoped to
`onClick` only so pointer / hover / drag still propagate inside the
registry tree.
Also extends the `text` FloorplanGeometry with stroke / strokeWidth /
paintOrder / fontFamily so kinds can match the legacy "white fill +
colored outline" label look (used by the new zone name label).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
924567293a |
shelf: v2 — cubby default, withBottom, item hosting, paintable surface
Schema v2 adds style/rows/columns/withBack/withSides/withBottom/bracketStyle and a `children: ItemNode[]` field for item hosting. Schema-level defaults preserve the v1 wall-shelf visual so existing scenes load unchanged; the placement tool spreads `shelfDefinition.defaults()` for fresh shelves (cubby 3x2 at 1m × 0.5m × 1.8m, thickness 0.05m, back/sides/bottom on). Four style geometries (wall-shelf / bookshelf / open-rack / cubby) share the dimensional schema. `shelfRowSurfaceYs` exposes one host surface per row, plus the bottom-board top when `withBottom` is on for cubby / bookshelf. Material is a single paintable surface (same shape walls / slabs / stairs use); `DEFAULT_SHELF_MATERIAL` aligned with `DEFAULT_WALL_MATERIAL` so unpainted shelves read as the canonical off-white. Preview clones each cached material before mutating `transparent / opacity` on the ghost — without the clone the mutation leaked into the cached `getShelfMaterial` instance every committed shelf was using, rendering them all see-through after the first placement preview rendered. Store hardening: `migrateNodes` patches missing `children: []` on v1 shelves, and `updateNodesAction` reparenting tolerates a missing children array on the new parent. `MaterialTarget` enum adds `'shelf'` so paint mode picks up the kind. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
6a1d853dd8 |
IconRef: add url kind, point registered kinds at palette assets
User pointed at the palette's PNG icons in the bottom toolbar — they
expected the inspector title to use the same artwork, not the iconify
lucide glyphs. Different visual style.
- IconRef gains `{ kind: 'url'; src: string }`. Plain `<img>` render
in ParametricInspector (no next/image — the inspector is
`'use client'`).
- All currently-registered kinds switched to URL refs matching their
palette `iconSrc`:
fence → /icons/fence.png
slab → /icons/floor.png
ceiling → /icons/ceiling.png
wall → /icons/wall.png
spawn → /icons/site.png
shelf → /icons/column.png (placeholder, same as palette)
- Kind-owned panels (slab/ceiling) already pass URL strings to their
own PanelWrapper; unchanged.
Door/window/item will get URL refs when they register at Stage A.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
282cb22585 |
parametrics: add custom field kind + restore fence Length / Curve
User noticed the auto-derived fence inspector was missing the legacy
panel's Length + Curve sliders, and the section labelling was wrong
(Posts → Structure). Both Length and Curve are awkward for the
parametrics field model:
- **Length** doesn't map to a single node key — it's derived from
`start`/`end`, and editing it moves `end` along the existing
direction.
- **Curve** maps to `curveOffset` but the slider's min/max are
bounded per-node by the chord length, plus updates need
`normalizeWallCurveOffset`.
Adds a `kind: 'custom'` field with a kind-supplied
`component: ComponentType<{ node, onUpdate }>`. The inspector mounts
it and lets the kind own rendering + update logic. `key` becomes a
free-form React key/label since it no longer needs to map to a node
property.
Fence parametrics now mirrors the legacy layout 1:1:
- Style (segmented controls + showInfill toggle).
- Dimensions (Length, Curve, Height, Thickness).
- Structure (Base Height, Top Rail, Post Spacing, Post Size, Ground
Clear, Edge Inset).
Length + Curve live in fence/inspector-editors.tsx.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
80199dc890 |
parametrics: add display: 'segmented' enum hint + drop fence color
User pointed out two regressions in the auto-derived fence inspector: - A `color` field rendered for fence — but the legacy `FencePanel` hid it (fence's color is a leftover schema field that isn't part of the inspector UX). Dropped from `fenceParametrics`. - Style + base-style enums rendered as a dropdown, but the legacy used the inline segmented switcher (Slat/Rail/Privacy + Grounded/Floating). Added a `display?: 'select' | 'segmented'` hint to the enum field kind. ParametricInspector renders SegmentedControl when set; defaults to dropdown otherwise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
3c37ff009b |
Phase 5 Stage E: fence / slab / ceiling drop their legacy panels
Three flavors of Stage E in one PR: - **Fence** — fully auto-derived. Adds `kind: 'boolean'` to ParamField (rendered as ToggleControl), wires `showInfill` through `def.parametrics`. Legacy `FencePanel` deleted; the auto-derived `<ParametricInspector>` now drives fence editing entirely. - **Slab / Ceiling** — kind-owned via `parametrics.customPanel`. The legacy panels have shape-specific bits (elevation/height presets, area display, holes list with auto-vs-manual provenance) that don't fit the auto-derived field model yet. `<ParametricInspector>` learns to lazy-load and mount `parametrics.customPanel` when present; legacy `SlabPanel` + `CeilingPanel` files relocate to `nodes/src/<kind>/panel.tsx` and the legacy copies delete. When `list` / `computed` / `action` field kinds eventually graduate to auto-derived support, these custom panels collapse back into `parametrics.groups`. The plan calls this out under "Custom-behavior escape hatch" and Foot-gun 5 of the recipe. Public-surface additions in `@pascal-app/editor`: - `ActionButton`, `ActionGroup`, `PanelSection`, `SegmentedControl`, `ToggleControl`, `PanelWrapper` — needed by the kind-owned panels. Per-kind progress table: fence/slab/ceiling all flip to E ✅. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d1231b4901 |
Phase 5 Stage D moves: live-drag for slab/ceiling + cursor follows pointer
User-reported regressions after the previous Stage D move ports: 1. Slab/ceiling moves were sluggish because the actions wrote scene.update(polygon) every grid:move tick → React re-render + CSG-with-holes geometry rebuild per frame. Apply the live-drag exception (same recipe fence move already used): visual translation via `sceneRegistry.nodes.get(id).position` + `useLiveTransforms`; scene.polygon is only written on commit. Polygon center precomputed in ctx, mesh-offset clears on commit/cancel. 2. Cursor sphere sat at the polygon center (offset from the user's actual cursor by `originalCenter - first_cursor`). Move wrappers now subscribe to `grid:move` and set `cursorRef.current.position` directly — no React state, no per-tick reconcile. Cursor lands on the user's pointer. 3. Fence move had the same React-reconcile-per-tick cost via its `useLiveTransforms` subscription. Switched to the same direct ref-mutation pattern. Adds a "REAL bend" test pinning that one Ctrl-Z after a real curve drag undoes only the bend, not the create. The previously reported "first undo does nothing" outcome reproduces only for no-op bends (drags within `normalizeWallCurveOffset`'s straight-snap threshold, ≈1.5cm on a 3m fence). For visible bends the dance pushes a real pastState entry and one undo step rolls back the bend. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d43cd569d6 |
drag-session: tests pinning single-undo-dance behavior
Includes a regression test for the "no-op bend" case that reproduces the user-reported 'fence bend cancels fence creation' bug: when action.commit returns false (draft === original), no pastState push happens during the drag's commit path. The very next Ctrl-Z falls through to whatever was on the stack before activation — typically the fence creation itself. The actual dance with real changes (delta !=0) works correctly — the test exercising the full createDragSession flow with action.commit returning true pins one undo step covering only the drag. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
36c48b7fc9 |
chore: biome auto-format pass (resolve persistent dirty-tree noise)
These files had been showing as modified in every dev session — biome's canonical formatting (line-length collapses, import sort, type-modifier placement) didn't match the committed state. No semantic changes. Committing now so the working tree stays clean across sessions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
ee309ece6f |
drag-session: dispose() is now silent — does not fire onCancel
The previous dispose() was documented as "equivalent to cancel()" and fired onCancel. That breaks React StrictMode's mount → cleanup → mount cycle for useDragAction consumers: the first cleanup's onCancel resets the parent state machine (e.g. setCurvingFence(null)), which unmounts the component before the second mount runs. Net result: the tool blinks in and out instantly. dispose() now restores scene state + resumes history but skips onCancel. Explicit cancel() still fires onCancel (Esc / external aborts). New test locks this in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
8ca9686b27 |
Phase 5 Stage D: fence curve affordance → registry DragAction
First Stage D port — `CurveFenceTool` (178 LoC legacy) split into a pure `DragAction` primitive (`packages/nodes/src/fence/actions/curve.ts`) plus a thin React wrapper (`packages/nodes/src/fence/curve-tool.tsx`) that feeds it through `useDragAction`. The kind declares the affordance via `def.affordanceTools.curve`; ToolManager lazy-loads it at runtime when `useEditor.curvingFence` activates. Falls back to the legacy `CurveFenceTool` for any kind that hasn't been ported. The lazy-load dispatch dodges the editor→nodes circular dep (nodes already depends on editor for `useDragAction` + `CursorSphere`). Establishes the pattern for the remaining fence affordances (`MoveFenceEndpoint`, `MoveFence`, placement) and for slab/ceiling/wall D ports. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d10d7f8deb |
Phase 4 follow-on: floor-plan interaction + def.toolHints + spawn floorplan builder
Three additions on top of the floor-plan registry contract:
1. def.toolHints + RegisteredToolHelper (registry contract for the
shortcut hint panel)
- New ToolHint type in core: { key, label } static array.
- Added `def.toolHints?: ToolHint[]` to NodeDefinition.
- New <RegisteredToolHelper hints={...}> in editor — same visual
styling as WallHelper / ItemHelper but data-driven.
- HelperManager: registry-first check before falling through to the
hand-written per-tool switch. Per-tool helper files get deleted
as their kind migrates `toolHints` in.
- Shelf + spawn definitions ship toolHints today; wall ports in
Phase 3 Milestone C alongside its tool/affordance port.
2. Floor-plan interaction layer (selection + drag-to-move)
- <FloorplanRegistryLayer> now wraps each entry in an interactive
<g>:
* Click → useViewer.setSelection({ selectedIds: [id] }).
Selection visual is a thicker accent-colored stroke applied
via withSelectionStyle() recursion through the FloorplanGeometry
tree — kinds don't author selection decoration.
* Drag → imperative SVG transform during the gesture, single
updateNode commit on pointerup. Same "smooth move" pattern as
MoveRegistryNodeTool for 3D drag: no per-tick store update,
no React re-render storm, no zundo bloat. Coordinate
conversion via svg.getScreenCTM().inverse().
* useScene.temporal.pause/resume brackets the gesture so one
drag = one undo step.
- Global pointermove / pointerup listeners so the gesture survives
the cursor leaving the entry's bounding box (matches the legacy
elevator-resize-drag and item-drag patterns in floorplan-panel).
3. Spawn floor-plan builder (deferred wiring)
- buildSpawnFloorplan written but NOT wired on the definition —
spawn already renders in the legacy floorplan-panel.tsx via
`floorplanSpawnEntries`, and wiring def.floorplan now would
double-render. The pure builder lives in nodes/src/spawn/
floorplan.ts ready to wire when the legacy inline branch is
removed (Phase 5 spawn-floorplan migration PR — same shape as
wall's feature flag, but per kind inside the legacy panel).
Plan updated: floor-plan interaction section locks the click/drag
contract in, wall-floor-plan-as-legacy note flags everything advanced
the user sees today as legacy that ports alongside Milestone C.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ac36297e7e |
Phase 4 follow-on: registry-driven floor-plan rendering + shelf port
Adds the floor-plan side of the three-checkbox composition model documented in wiki/architecture/node-definitions.md. Mirrors the 3D side (def.geometry → <GeometrySystem> → <ParametricNodeRenderer>) but emits SVG primitives instead of three.js Object3Ds, and runs inside the floor-plan panel. Type-side (packages/core/src/registry/types.ts): - New FloorplanGeometry tagged union covering path / polygon / polyline / rect / circle / line / group. FloorplanStyle props map straight to SVG attributes. Coordinates are level-local meters; rotations are radians (three.js convention). - New `def.floorplan?: (node, ctx) => FloorplanGeometry | null` field on NodeDefinition, independent of `geometry` and `renderer`. Re- exported via packages/core/src/registry/index.ts. Runtime (packages/editor): - <FloorplanGeometryRenderer> walks the FloorplanGeometry tree and emits the matching React-SVG elements. Pure data → DOM; no per-kind logic. - <FloorplanRegistryLayer> reads the active levelId, walks the level subtree, looks up each node's def.floorplan, builds a GeometryContext, calls the builder, and renders the output via FloorplanGeometryRenderer. - Mounted in floorplan-panel.tsx just before <FloorplanMarqueeLayer> so registry-driven kinds layer above legacy inline content. Shelf migration (proof port): - New nodes/src/shelf/floorplan.ts — buildShelfFloorplan(node) emits a group with the rotation/translation transform and a width × depth rectangle in the shelf's color. Brackets omitted (hidden under top board from above). - Wired to shelfDefinition.floorplan. Shelf now appears in the floor plan view for the first time (was missing from the legacy panel's inline switch). Pattern proven; every future kind migrating in Phase 5 follows the same shape: a pure (node, ctx) => FloorplanGeometry function. As kinds register their floor-plan builders, the corresponding inline branches in floorplan-panel.tsx become dead code and can be deleted in the same PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
3f3818f3b0 |
Phase 4: generic GeometrySystem + ParametricNodeRenderer; shelf ports off renderer/system files
Lands the three-checkbox composition runtime documented in wiki/architecture/node-definitions.md. A kind with only a pure geometry function now needs zero per-kind React or system code. Type-side additions (packages/core/src/registry/types.ts): - New `GeometryContext` (resolve / children / siblings / parent) — read- only scene access for builders that reference other nodes by ID (wall miters, door cutouts). Most kinds ignore it. - New `geometry?: (node, ctx) => Object3D` field on NodeDefinition, independent of renderer/system. Three orthogonal opt-ins replace the v0 RendererSource union. - Re-exported via packages/core/src/registry/index.ts (consumed by nodes packages through `export * from './registry'`). Runtime (packages/viewer): - New <GeometrySystem> (systems/geometry/geometry-system.tsx) walks dirtyNodes, builds a GeometryContext per dirty node, calls def.geometry, disposes old children, attaches new ones, clearDirty. Frame priority 2 (matches the priority shelf's per-kind system had). Mounted in viewer/index.tsx alongside <RegisteredSystems>. - New <ParametricNodeRenderer> (components/renderers/parametric-node- renderer.tsx) — empty <group> + useRegistry + useNodeEvents + markDirty-on-mount + useLiveTransforms. Mounts hosted children via <NodeRenderer> recursively. The default renderer for any registered kind without a custom def.renderer. - <NodeRenderer> dispatch updated: custom renderer wins, else geometry-only kinds fall through to ParametricNodeRenderer, else null (legacy switch fallback). Documented inline. Shelf migration (proof of the boilerplate collapse): - Deleted nodes/src/shelf/renderer.tsx (was 45 lines of registry + handler boilerplate). - Deleted nodes/src/shelf/system.tsx (was 60 lines of dirty-loop + dispose plumbing). - shelfDefinition now: `geometry: buildShelfGeometry`. One line. buildShelfGeometry is the pure function from geometry.ts that already existed. End-to-end effect: registry-driven shelf now mounts via the framework's generic renderer + system. Parametric edits flow through the same dirty-driven rebuild path, but the kind ships ~100 fewer lines of boilerplate. Every future kind that fits the same shape (item, fence segment, column, etc. as they migrate in Phase 5) follows the same "one line, one pure function" pattern. Wall stays on its dedicated def.renderer + def.system — its mitering needs level-batch context (`ctx.levelData?.miters`, future extension) that the generic system doesn't yet provide. Decided at Phase 3+, not blocking Phase 4 acceptance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
0a723fa1f2 |
Wall Phase 3 milestone A: registry skeleton (metadata only)
Lays down the wall folder under @pascal-app/nodes with everything needed to register the kind, but intentionally without runtime wiring: - schema.ts re-exports WallNode from core (door/window/item still type their parentId against WallNode.shape.id, so the schema stays canonical there for now). - parametrics.ts declares thickness / height / curveOffset for the Phase 4 inspector. Endpoints and host children are edited via affordances, not number inputs, so they're not in parametrics. - definition.ts encodes capabilities (surfaces, selectable, duplicable, deletable — no movable since wall's move is bespoke endpoint-drag), relations (hosts doors/windows/items, affectsSpatial slabs/ceilings/ zones, linkedBy endpoint-match, cascadeDelete descendants), and the presentation metadata for the palette. Renderer / system / tool fields are deliberately absent — the existing wall-renderer.tsx and wall-system.tsx keep serving wall until milestone B. - feature-flag.ts gates the eventual registration via NEXT_PUBLIC_USE_REGISTRY_FOR_WALL (same pattern Phase 2 used for spawn). - wallDefinition is NOT yet appended to builtinPlugin.nodes — registration is what flips the Phase 0 dispatch shims, and we don't want that until the runtime port lands. Until then this file is metadata-only. Two type-side changes pulled forward from Phase 4 to make a metadata-only definition compile: - NodeDefinition.renderer becomes optional (the three-checkbox model documented in wiki/architecture/node-definitions.md already promises this). RegistryRenderer in node-renderer.tsx gains a null-guard so an undefined renderer cleanly falls through to the legacy switch. No runtime behavior change. Walls render and behave exactly as before. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e84b1ec8bc |
Add preview slot to NodeDefinition; show translucent shape during move
User feedback: the move tool's CursorSphere is just a vertical line,
hard to tell what you're moving. Placement gets a translucent shape
that follows the cursor; move should too.
NodeDefinition.preview?: () => Promise<{ default: ComponentType<{ node }> }>
opt-in lazy component that renders a translucent ghost of the node.
Used by:
- The placement tool (ShelfTool) — renders the preview at the cursor
position so the user sees the shape they're placing.
- The move tool (MoveRegistryNodeTool) — renders the preview at the
drag target alongside the CursorSphere. Plus the original node is
also dragged via live transforms, so the user sees both: the actual
node moving + a translucent ghost at the same spot.
Implementation:
- New nodes/shelf/preview.tsx: ShelfPreview component. Renders the
same shape as ShelfRenderer but `transparent: true, opacity: 0.5`.
- shelfDefinition.preview = () => import('./preview').
- ShelfTool's placement preview now uses <ShelfPreview node={defaults} />
instead of an inline copy of the box geometry.
- MoveRegistryNodeTool lazy-loads `def.preview` (cached by loader,
Suspense-wrapped). If a kind doesn't define `preview`, only the
CursorSphere shows — matches today's behavior.
Phase 4 may merge `preview` with `renderer` behind an `opacity` prop
so kinds don't duplicate JSX between the solid and translucent
versions; until then defining `preview` is opt-in and one extra file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
6d97a87547 |
Selection: registry-driven, drop spawn flag, restore green color
Two concerns from the spike:
1) Selection / floating-action-menu had hardcoded kind lists scattered
across 4 files. Adding 'shelf' to each one per migration was the
wrong abstraction — the user's question "did you make it generic
from the noderegistry?" was the right one. Done now.
Added to @pascal-app/core/registry:
- getSelectableKinds(): string[] — returns all registered kinds
whose definition declares `capabilities.selectable`.
- isRegistrySelectable(kind): boolean — predicate for OR-chains.
Refactored hardcoded sites to merge registry kinds at runtime,
keeping legacy hardcoded lists intact so existing kinds keep
working unchanged:
- editor SelectionManager: 4 subscription loops (enter/leave/click)
+ structure.isValid + getSelectionTarget — all augment with
registry kinds. Phase 6 deletes the hardcoded lists.
- viewer SelectionManager: subscription loop + SelectableNodeType
broadened with `(string & {})` to accept registry kinds.
- floating-action-menu: ALLOWED_TYPES OR'd with isRegistrySelectable.
- Removed the manually-added 'shelf' entries from previous commit
857ddd4; they were redundant once the registry-driven path landed.
Future built-in nodes that declare `capabilities.selectable` get
click-selection + hover + the floating action menu (move/delete
icons) for free, no editing of these 4 files.
2) Spawn parity is signed off. Drop the
NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN flag entirely; spawn registers
unconditionally in builtinPlugin.nodes. Restored SPAWN_COLOR to
the original #22c55e green (was #ef4444 red as a Phase 2
verification marker).
Pre-existing typecheck errors in editor (ceiling/fence/slab tree-node,
scene.ts buildingId) are unchanged.
630 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
76794ceb7d |
Wire shelf + spawn placement polish: SFX, cursor, sidebar, selection
User-visible follow-ups after first running the Phase 2 spike.
Spawn tool now matches legacy UX:
- CursorSphere from @pascal-app/editor for the placement indicator
(ring + line + tool-icon tooltip) — was a plain sphere mesh.
- Emits sfx:structure-build on commit + setTool(null) + setMode
('select') to exit build mode, matching legacy spawn-tool.
Shelf tool placement:
- Emits sfx:structure-build on commit.
- Cursor preview now shows top board + brackets (was just the top),
matching what gets placed.
Shelf selectable from the 3D canvas:
- ShelfEvent type added to @pascal-app/core/events/bus.
- 'shelf' added to NodeConfig in useNodeEvents.
- ShelfRenderer wires `useNodeEvents(node, 'shelf')` handlers onto
every mesh. Clicks/hovers now bubble through the editor's selection
manager and update useViewer.selection.
Shelf appears in the sidebar:
- ShelfTreeNode component (mirrors spawn-tree-node's shape +
selection/hover/rename wiring; lucide Layers icon).
- TreeNode dispatcher adds a `case 'shelf':` arm.
Framework changes:
- @pascal-app/editor exports CursorSphere alongside triggerSFX.
- @pascal-app/nodes now declares @pascal-app/editor as peer/dev dep.
Pre-existing typecheck errors in @pascal-app/editor (ceiling-tree-node,
fence-tree-node, slab-tree-node, scene.ts) are unchanged — present on
main and not introduced by this commit.
630 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
b6d77206b4 |
Phase 2 spike: spawn migration (flagged) + new shelf node
The first time registry-driven nodes actually run in the editor. Spawn migration (under NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN flag): - New packages/nodes/src/spawn/ folder with renderer, tool, schema (re-exported from core), parametrics, definition, index. - Spawn definition appended to builtinPlugin.nodes only when the flag is set. With the flag off, the Phase 0 shims fall through and the legacy SpawnRenderer / SpawnTool keep ownership. - New no-props SpawnTool reads activeLevelId from useViewer directly, matches legacy placement behavior (half-meter snap, singleton-per- level, replace-on-reclick). - Structural parity test (9 cases) validates definition shape + schema identity. Pixel-diff defers to Phase 4 alongside more nodes. New shelf node (no legacy — registered unconditionally): - ShelfNode schema in core/schema/nodes/shelf.ts (hand-maintained AnyNode union for now; Phase 6 derives the union from the registry and moves the schema fully into nodes/shelf/). - packages/nodes/src/shelf/ folder: pure geometry builder (buildShelfGeometry returns a Three.js Group of top board + brackets), R3F renderer that mounts the built group, no-props placement tool, parametrics descriptor (width/depth/thickness/ height/bracketStyle/color), definition with surfaces.top stackable surface for future stacking, and presentation metadata for the palette. - 13 unit tests across schema bounds and geometry behavior. - Palette wiring: 'shelf' added to StructureTool union + an entry in the structure-tools array (placeholder icon, replaced in Phase 4 when palette is registry-driven). Framework changes: - @pascal-app/viewer now exports useNodeEvents from its public barrel so node bundles in @pascal-app/nodes can subscribe to node-specific pointer events. (Used by spawn renderer; shelf renderer skips it for now since useNodeEvents has a hardcoded kind list — Phase 4 generalizes it via the registry.) - @pascal-app/nodes gains @pascal-app/viewer as a peer + dev dep so node bundles can import from it. 630 tests pass across 76 files (22 new this phase). Editor app continues to ship green with both legacy spawn and the new shelf node co-existing through the Phase 0 dispatch shims. To validate end-to-end in dev: - bun dev:community → open editor → click 'Shelf' in structure toolbar → click to place. Confirms full registry path (NodeRenderer dispatch + ToolManager dispatch + sceneRegistry byType Proxy). - Set NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN=1, restart dev, place spawn → visually identical to legacy. Confirms parity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e8cf85313b |
Add cascade resolver bench harness, p95 gate < 2ms (Phase 1, 6/6)
Builds a 15200-node fixture (50×100 wall grid + 8000 hosted doors + 200 sparsely-indexed slabs) and runs `cascadeDirty` 1000 times with warm-up. Reports p50/p95/p99/mean/max in ms. Runs via `bun run bench:registry` from the core package. Today: p95 measured at ~0.002ms — three orders of magnitude under the Phase 1 gate of 2ms. Headroom is substantial; we'll only revisit this if Phase 3 wall introduces `linkedBy: 'endpoint-match'` and pushes the inner cascade past the gate. Not wired into CI for v1 — regressions reviewed manually before phase gates. Output is JSON so a future CI step can diff against a baseline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e799ff70da |
Add DragSession + useDragAction hook (Phase 1, 5/6)
Pure orchestrator in core, thin React wrapper in editor: - `core/services/drag-session.ts` — `createDragSession(action, scene, options)` returns an imperative session with `start / move / commit / cancel / dispose / isActive / getDraft`. Pauses history on start, resumes on terminate. Per-move runs preview → snap → apply, then cascades dirty marks via the relations resolver (deduped across ticks). Re-entry guard, idempotent dispose, fires onCommit/onCancel callbacks. All tested in bun:test — no React needed. - `editor/src/hooks/use-drag-action.ts` — wraps the session with the editor's grid-event emitter and an Esc-to-cancel keyboard listener. Builds a `SceneApi` once via `createSceneApi(useScene)` at module init. The hook itself is small enough to read top-to-bottom; all behavior lives in the session. Tests (13 cases) cover the hard parts: history pause/resume bracket, explicit cancel restoring all touched nodes, dispose mid-drag, commit returning false short-circuiting to cancel, snap callback wired in, re-entry rejected, deduped dirty-mark across multiple move ticks, hosts cascade from the registry firing in apply. No callers yet — Phase 2 column and shelf tools are the first consumers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
386df62b43 |
Add MovementService (axis-lock + grid-snap) to core (Phase 1, 4/6)
Pure constraint math built on the registry's `MovableConfig`: - `resolveMovable(node)` — reads `def.capabilities.movable`, runs the optional `override(ctx)` callback (returning null falls back to the base config). Returns null when the kind isn't movable. - `applyAxisLock(current, target, axes)` — projects 3D motion onto the allowed axes; locked components fall back to current. - `moveToward(node, current, target, options?)` — top-level helper combining axis lock + (optional) grid snap. Returns null when the node is not movable. - `movePlanToward(node, currentY, current, target, options?)` — X/Z-plane convenience for floor/plan-view placement. - `isMovable(node)` — predicate for tools/UI gating. Tests cover override callback, null-override fallback, axis lock permutations, grid-snap on/off, and the 2D plan convenience. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
bc0c73d449 |
Add SnapServices (grid + angle) to core (Phase 1, 3/6)
Pure math, no React, no scene access. Three primitives plus a facade: - `snapScalar(value, step)` / `snapPointToGrid(point, step)` / `snapVec3ToGrid(point, step)` — regular grid snapping. Default step 0.25m matches the editor's wall tool. - `snapPointToAngle(from, cursor, angleStep, gridStep?)` — locks a cursor to the nearest angle multiple from a fixed point, preserves distance, optionally re-grids the projected point. Default angle step π/12 (15°). - `snapAngleToList(angle, list, tolerance)` — snaps a free angle to the nearest entry in a fixed list (e.g. 0/45/90/135) within a tolerance; returns the original angle otherwise. Handles wrap. - `snapServices` facade — `grid.*` + `angle.*` namespaces. Stable contract that `DragAction.snap` callbacks receive. Phase 3 ports the existing `snapWallDraftPoint` family from `editor/.../wall-drafting.ts` under a `wall.*` namespace. 17 unit tests cover the math + the facade pass-through. No existing callers re-wired yet — Phase 2 column/shelf tools are the first consumers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cf93c32183 |
Add HostingService with cycle/depth/kind validation (Phase 1, 2/6)
First service in `core/src/services/`: pure, no React or R3F, takes
SceneApi + node data, returns results.
Exports:
- `canAttach(childId, hostId, scene)` — validates host attachment.
Rejects self-host, cycles (host's ancestor chain contains child),
chains past MAX_HOST_DEPTH (6), and host kinds outside the child
def's `capabilities.hostable.parents` allowlist. Returns a typed
AttachError discriminated union so callers can render specific
messages.
- `getSurface(host)` / `getTopSurfaceHeight(host)` — reads
`def.capabilities.surfaces` from the registry; resolves
function-valued heights with the node.
- `clampYToHostTop(host, y)` — convenience for placement code.
- `pickHost({ point, candidates, placedKind, hitTest? })` — given
spatially pre-filtered candidates, returns the first hostable.
The runtime is responsible for spatial filtering; this function
stays pure.
MAX_HOST_DEPTH = 6: the explore earlier found today's editor has no
cap on item-on-item nesting. Cap is bounded by hostable depth, not
total tree depth (sites/buildings/levels don't count).
17 tests cover all rejection paths + happy paths + function-valued
surface heights.
Re-exported from `@pascal-app/core` via a new `services/` barrel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
90702e74e2 |
Add relations cascade resolver (Phase 1, 1/6)
Pure traversal that walks a node's declared `relations` and returns the
full set of IDs that should be marked dirty alongside it. Two
implementations:
- `cascadeDirty(id, ctx)` — follows `hosts` (matching children) and
`affectsSpatial` (via injected spatialQuery). Phase 3 will add
`linkedBy: 'endpoint-match'`.
- `collectDescendants(id, ctx)` — pure subtree traversal for
`cascadeDelete: 'descendants'` and subtree deletion tools.
Both bounded by maxDepth (default 16) and visited-set so cycles in
bad data can't loop forever.
Context-based design: spatialQuery and childQuery are injected, so the
resolver itself stays pure — the DragAction runtime can plug in
spatialGridManager-backed queries; tests pass stubs.
Today, registry has no kinds → cascadeDirty(id) always returns just
{id}. No behavior change. Phase 3 wall is the first real consumer.
11 unit tests cover empty/no-relations baseline, hosts cascade, depth
limit, spatial query, missing spatialQuery branch, cycle protection,
childQuery override, descendant collection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
3de098fe4f |
Add Presentation + IconRef to NodeDefinition v1 surface
Adds optional `presentation` field for tool palette metadata: sentence- case label, optional description, icon (iconify reference, inline SVG, or lazy React component), palette section override, sort order, and a `hidden` flag for container kinds that exist but should not appear in the palette. Consumer arrives in Phase 4 (auto-derived palette buttons) — defining the type now means Phase 2's `column` and `shelf` definitions ship with the field already populated, no later round-trip. Iconify is the encouraged form for built-ins and AI-authored nodes: matches the @iconify-react setup the editor app already uses, and AI emits a name string from a curated list (no asset upload step needed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c683c04c39 |
Add test script to @pascal-app/core so turbo picks up its unit tests
Without a "test" script the package was invisible to `turbo test` — private-editor's CI runs `bun run test` (= `turbo test`), which only walks workspace packages that declare a test runner. Mirrors mcp and nodes which already do this. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4c3feea052 |
Proxy-back sceneRegistry.byType for registry/plugin kinds (shim 1/4)
byType was a hardcoded object keyed by the built-in node kinds. With
the registry, kinds can come from @pascal-app/nodes (or future
plugins) — so byType now wraps a Map via a Proxy that auto-creates an
empty Set the first time any kind is touched.
Built-in kinds are still pre-seeded at module init so the fast path
(no Proxy trap) is preserved. clear() iterates the backing Map.
useRegistry's `type` parameter widens from `keyof typeof byType` to
`KnownNodeKind | (string & {})` — preserves autocomplete for
built-ins while accepting plugin-supplied kinds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
a41321e193 |
Lock bun.lock for @pascal-app/nodes workspace + sort registry barrel
Companion changes for the new nodes package: bun.lock entry from `bun install`, and a Biome-auto-sort of the registry barrel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
fb91713374 |
Add node registry primitives (PR 0.1 of node-registry plan)
Introduces the @pascal-app/core/registry surface that future node-bundle packages (and external plugins) will use to register node kinds with the host. No runtime behavior changes — registry is empty until subsequent PRs populate it. - types.ts: NodeDefinition, Capabilities, Relations, DragAction, Plugin, ParametricDescriptor, Affordance, SceneApi, NodeRegistry. Capability configs accept an override escape hatch; additive-only after v1. - registry.ts: nodeRegistry singleton, registerNode, async loadPlugin. Validates kind, schemaVersion, apiVersion; rejects duplicate kinds. - scene-api.ts: createSceneApi factory wrapping the scene store with copy-on-write snapshot semantics for pauseHistory/restore/resumeHistory. - index.ts: barrel re-exporting the public surface. - core/index.ts + package.json: export * from registry and add the ./registry subpath so consumers can import either way. Tests (27 cases, all bun:test): registry registration / validation / plugin loading; SceneApi read/write/dirty/history; lazy snapshot capture with update/upsert/delete reversal via restore and restoreAll. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c5ee1c64d0 |
Mark elevator-opening-system as client
The file imports useEffect/useRef from React, which Next.js RSC builds flag as client-only. Other core systems (e.g. elevator-runtime-system) use useFrame from @react-three/fiber and slip through, but this one needs the directive explicitly. Fixes Turbopack build failure in private-editor community app: "You're importing a module that depends on useEffect into a React Server Component module." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
ce69433a1a | Refine elevator sync and viewer rebuild handling | ||
|
|
9838d2cd4f | Refactor elevator runtime into core | ||
|
|
b782ce9cf2 |
Merge branch 'main' into feat/elevator-system
# Conflicts: # packages/editor/src/components/tools/item/move-tool.tsx # packages/editor/src/components/tools/tool-manager.tsx # packages/editor/src/components/ui/panels/panel-manager.tsx # packages/editor/src/store/use-editor.tsx # packages/viewer/src/components/renderers/site/site-renderer.tsx # packages/viewer/src/components/viewer/ground-occluder.tsx # packages/viewer/src/components/viewer/index.tsx # packages/viewer/src/components/viewer/post-processing.tsx |
||
|
|
70c85d7760 | Refine wall move previews and expose auto-slab planning | ||
|
|
564bfa229e |
Merge remote-tracking branch 'origin/main' into fix/fri-8-may
# Conflicts: # packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx # packages/editor/src/components/tools/select/box-select-tool.tsx # packages/editor/src/components/tools/wall/move-wall-tool.tsx # packages/editor/src/components/ui/panels/column-panel.tsx # packages/editor/src/components/ui/panels/door-panel.tsx # packages/editor/src/components/ui/panels/window-panel.tsx # packages/viewer/src/components/renderers/column/column-renderer.tsx # packages/viewer/src/components/viewer/index.tsx # packages/viewer/src/systems/slab/slab-system.tsx |