* feat(plugins): trees tracer-bullet plugin + minimal host panel surface
Ship one real first-party plugin and only the host surface it forces
into existence, proving the plugin contribution paths every future
plugin (mint.gg generator, Home Assistant, environments, room volume)
will reuse.
Host surface (core + editor):
- core: Plugin.panels + observable panelRegistry; loadPlugin routes
panels (namespaced by plugin id, dup-throws like nodes).
- editor: AppSidebar merges panelRegistry into the icon rail via
useSyncExternalStore; each plugin panel lazy-loaded behind an error
boundary; host extraPanels keep precedence.
- editor: widen Tool to `KnownTool | (string & {})` so plugin tool ids
(e.g. 'trees:tree') typecheck; dispatch already registry-first.
Trees plugin (packages/plugin-trees), structurally a third-party pack
(peer-deps on @pascal-app/*):
- trees:tree node — procedural low-poly geometry (oak/pine/birch/palm),
free parametric inspector (preset/height/seed + Randomize), placement
tool + ghost preview built from public primitives only.
- presets rail panel — panel -> plugin store -> def.tool -> SceneApi ->
scene -> reactive read-back ("N planted").
Loading + docs:
- apps/editor: setPluginDiscovery([treesPlugin]) in bootstrap;
transpilePackages + dep.
- wiki/architecture/plugin-authoring.md: panels field, error-boundary
contract, styling, tool-from-panel note.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugins): merge plugin panels into the v2 sidebar layout too
The community shell renders <Editor layoutVersion="v2" sidebarTabs={...}>,
a separate rail from AppSidebar. Merge registry panels into the v2
tabMap + tab bar (and thus mobile) so plugin panels show up there as
well, not only in the v1 AppSidebar.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugin-trees): ez-tree geometry + instanced rendering + snapping
Replace the placeholder low-poly geometry with dgreenheck/ez-tree
(self-contained — bark/leaf textures inlined as base64, no assets to
host) and render the forest with true GPU instancing.
Rendering:
- def.system (system.tsx): one collective renderer that groups every
trees:tree node by (preset, seed) variant and draws each variant as
one InstancedMesh per ez-tree sub-mesh, composing the parent level's
world matrix into per-instance matrices. ~1 draw call per variant.
- def.renderer (proxy-renderer.tsx): an invisible, raycastable per-node
proxy so the host's existing selection / outline / zone machinery
keeps working — no instanceId bookkeeping. (Outline highlights the
proxy bbox; documented.)
- geometry per variant generated once by ez-tree and cached; seeds drawn
from a bounded pool so trees actually share variants.
Presets remapped to ez-tree built-ins (oak/pine/aspen/ash/bush).
Placement now respects the active snap mode — isGridSnapActive() +
gridSnapStep + snapPointToGrid, like the built-in item/shelf tools.
transpilePackages += @dgreenheck/ez-tree in the editor app.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugin-trees): true-silhouette hover/select + richer presets panel
Selection/hover now outlines the real tree shape, not the bbox. The
proxy splits into an outer group (stable invisible box collider +
pointer handlers, the raycast target) and an inner registered group that
mounts the real ez-tree geometry (invisible, non-raycasting) only while
the node is hovered or selected. The host outline pass reads the
registered inner group, so it traces the true silhouette; picking stays
on the steady box. Geometry for the highlight reuses the cached variant.
Panel: redesigned cards with gradient swatches + selected state, a
"planted" count chip, and a height slider that seeds the next tree's
height (a per-instance scale — never touches placed trees or instancing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugin-trees): curated tree params + procedural flowers (sibling kind)
Tree params: expose foliageDensity, trunkThickness, and a leafless toggle
in the inspector and the panel brush. Each is folded into the instancing
variant key and mapped onto ez-tree options (radius scale, leaf count),
so editing one tree only re-buckets that tree — instancing degrades
gracefully, never worse than per-node.
Flowers: add a `trees:flower` sibling kind — simple procedural geometry
(stem + petals + center, merged per material), presets daisy / tulip /
lavender, instanced + selectable exactly like trees.
Refactor: extract the instanced renderer + selection proxy into a generic
`instanced.tsx` (InstancedKindSystem + KindProxy) and the snap/level/grid
placement wiring into `placement.tsx` (usePlacement). Trees and flowers
are now thin bindings — the template for future plant kinds.
Panel: a Trees / Flowers toggle, gradient preset cards, per-kind planted
count, and brush sliders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugin-trees): native inspector controls in panel + preset thumbnails + ez-tree credit
- swap the panel's custom slider/checkbox/toggle for the host's exported
SliderControl/ToggleControl/SegmentedControl so the brush matches the
right-hand inspector pixel-for-pixel
- replaceable preset thumbnails (inline SVG data URIs, no asset hosting) render
as <img> cards instead of gradient swatches
- credit footer linking @dgreenheck/ez-tree (Daniel Greenheck, MIT)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): all ez-tree presets + type + leaf/branch colours, flower petal colour, grass kind
- tree presets now cover all of ez-tree's built-ins via species × size
(Small/Medium/Large + Bush 1/2/3 + Trellis), plus a Deciduous/Evergreen type
- edit-only leaf & branch colour tints (leaves.tint / bark.tint), all folded
into the instancing variant key
- flowers gain a per-flower petal colour (baked from preset at placement)
- new trees:grass instanced kind — procedural blade tufts (meadow/fescue/reed)
with a per-tuft blade colour, reusing the generic instanced + placement core
- panel: Trees/Flowers/Grass segmented switch, size + type controls, native
host controls throughout; credit links Daniel Greenheck's X
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): tolerate nodes persisted before new fields existed
treeSpecOf/flowerPetalColor now default every geometry field, and hexToInt
guards undefined — trees/flowers placed before size/type/colour fields were
added load with neutral defaults instead of crashing the instanced renderer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): shared always-on wind + pin credits to panel bottom
- wind.ts injects a sway into each material's begin_vertex driven by one shared
uTime uniform (advanced per frame in the instanced system); USE_INSTANCING
guards keep the same material valid for the non-instanced placement ghost.
Applied to tree, flower, and grass materials — a whole scene sways like ez-tree.
- credits footer is now sticky to the bottom of the panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): rename rail panel Trees -> Nature (covers trees/flowers/grass)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): rename panel header Plant -> Nature to match the rail
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: bake-exportable instanced plants + renderer-agnostic wind
Bake (GLB export): add a transient useViewer.isExporting flag. BakeExporter
flips it, waits for the commit, then exports. KindProxy watches it and, during
export, emits each plant's REAL visible geometry under scene-renderer (which the
exporter clones) instead of the invisible collider — so instanced kinds
(def.system) that live outside that subtree are captured. The collider box is
dropped during export so it doesn't bake as a phantom solid.
Wind: the previous onBeforeCompile wind was a no-op under the editor's WebGPU
renderer (WebGL-only hook). Replace it with a renderer-agnostic per-instance
base-pivot tilt animated in useFrame (which FrameLimiter drives continuously).
Reads as wind under any renderer; removes the dead wind.ts + applyWind calls.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): TSL vertex-bend wind (WebGPU), replacing CPU tilt
wind-node.ts adds a TSL positionNode that bends each plant proportional to
height above its base, phased per-instance (instanceIndex) and per-vertex, so
tips sway and roots stay planted — animated on the GPU via the renderer's time
node. ez-tree is untouched: its materials are copied into MeshStandardNodeMaterial
(toWindMaterial); flowers/grass build node materials directly (windStandardMaterial).
Instance matrices go back to static.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): tree materials render black under wind (Phong not Standard)
ez-tree's bark/leaves are MeshPhongMaterial; copying them into a
MeshStandardNodeMaterial swapped the shading model (specular/shininess ->
roughness/metalness) and rendered black. Convert into the matching node
material type (Phong->Phong, Lambert->Lambert, else Standard) so map/color/
alphaTest/side are preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): trees still black — build node material from map/color explicitly
Material.copy() doesn't transfer a classic material's map/color onto a node
material, so textured trees rendered black (flowers/grass were fine — they carry
no map). Re-create the ez-tree materials as MeshStandardNodeMaterial with map,
alphaMap, color, side, alphaTest passed explicitly in the constructor — the same
proven path the flower/grass materials use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): tree wind — per-leaf flutter (ez-tree style), static trunk
The whole-tree height-based bend was a rigid rotation about the base ('rotating
in place'). Replicate ez-tree's actual approach: scale sway by the leaf card's
uv.y and apply it ONLY to the 'leaves' material (multi-frequency wave, phased per
instance + per leaf) so leaves flutter from their attachment while bark/branches
stay static. Flowers/grass keep the gentle whole-plant stem bend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugins): Plugin.onSceneLoad hook + viewer application; trees wind via it
Adds a broad, plugin-declared post-load scene hook so effects survive GLB export:
- core: Plugin.onSceneLoad (lazy, three-free) + observable sceneHookRegistry + loadPlugin routing.
- viewer: applyPluginSceneHooks util; PluginSceneHooksSystem re-runs it on the live
scene as nodes change; GlbScene runs it on each loaded baked GLB.
- plugin-trees: declare onSceneLoad; wind is no longer welded into the materials —
the hook re-attaches LEAF_FLUTTER/STEM_BEND by material name (leaves / flower-* /
grass-*), so it now animates in the baked /viewer too, not just live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): scale-invariant leaf wind (÷ modelScale) so baked GLB matches editor
The bake's quantiser renormalises each plant mesh to [-1,1] and moves real size
into a large node scale (~3 on trees). LEAF_FLUTTER added a constant local offset,
so in the baked GLB it was multiplied by that scale → ~3x over-swing. Divide the
sway by modelScale.x to keep a constant world-space amplitude; editor (scale 1)
is unchanged, baked now matches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* revert: onSceneLoad wind approach → back to wind-in-materials
Reverts the Plugin.onSceneLoad hook + viewer application + modelScale compensation
(commits 893ca839, 6c9ee003). Superseded by the bake-policy 'replace' model
(plans/editor-plugin-trees-example.md Part D): trees render live in the viewer via
their own path, so wind is a plain positionNode on the material again — no hook,
no baked-geometry decoration, no coordinate-space fights.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(bake): per-kind bake policy (static/strip/replace)
Adds NodeDefinition.bake ('static' default | 'strip' | 'replace') + registry
helpers bakePolicyOf/kindsWithBakePolicy in core. Generalises the previously
hardcoded scan/guide handling:
- glb-export strips kinds with bake==='strip' from the artifact (was a
scan/guide type check); 'replace' kinds stay baked (portable static snapshot).
- glb-reference-nodes selects rebuild candidates by policy instead of type.
- scan/guide declare bake:'strip'; trees/flower/grass declare bake:'replace'.
Viewer-side 'replace' swap (strip baked meshes + live-rebuild) is the next slice.
Part of plans/editor-plugin-trees-example.md Part D.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(bake): viewer 'replace' swap — hide baked meshes, re-render live
Adds NodeDefinition.bakeReplaceRenderer (falls back to renderer). The baked
/viewer now, for kinds with bake:'replace' and a loaded plugin:
- hides the static baked meshes (bakePolicyOf(kind)==='replace') in GlbScene's
identity pass — capability-gated, so with no plugin the meshes stay;
- re-renders each node live via buildGlbReplaceNodes + the shared portal-into-
baked-level path (GlbReferenceNode now prefers bakeReplaceRenderer), so trees
ride level stacking automatically and wind runs in real coordinate space.
plugin-trees ships a hookless KindStatic (real geometry + wind materials, no
collider/selection) and per-kind static-renderer binds; tree/flower/grass point
bakeReplaceRenderer at them. Completes plans/editor-plugin-trees-example.md Part D.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(viewer): memoize GlbReferenceNode(s) — skip forest re-render on camera move
GlbScene re-renders per frame during camera movement (hover raycast / walkthrough
HUD). The rebuilt nodes reconciled every frame — fine for 1-2 scans/guides, but a
bake:'replace' forest puts dozens of nodes here (profiler: KindStatic x56 6ms,
GlbReferenceNode x57 4ms per frame). memo at the node boundary skips the whole
subtree (incl. the plugin renderer) when (node, anchor) are unchanged; both props
are stable refs, so it short-circuits cleanly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(plugin-trees): NO_RAYCAST on KindStatic meshes — cheap hover over a forest
The baked scene's <primitive> has pointer handlers, so R3F recursively raycasts
every descendant on each pointer move (incl. orbit drags). The live replace trees
had fully-raycastable dense ez-tree geometry, so hover cost ~400ms/frame over a
forest (profiler: 'JavaScript' 413ms during camera move). They carry no pascalId
(a hit resolves to the level, not the tree), so they're scenery, not pick targets
— mirror KindProxy, which already NO_RAYCASTs its real geometry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(bake): viewer 'replace' renders instanced per level (was per-node)
Replaces the per-node KindStatic viewer path with a collective instanced one,
mirroring the editor's system instead of diverging from it:
- extract InstancedNodes from InstancedKindSystem (shared by both) with a
localSpace flag: editor folds parent world matrix (root instances); viewer uses
level-local matrices + NO_RAYCAST, portaled into the baked level.
- bakeReplaceRenderer is now a collective renderer (BakeReplaceRenderer<N>,
receives {nodes}); GlbReplaceInstances groups replace nodes by (level, kind) and
portals each kind's instanced renderer into that baked level. GlbReferenceNode
reverts to per-node renderer (strip kinds only).
Fixes: forest hover cost (100+ tree meshes → a few instanced draws, R3F walks far
fewer objects) and per-tree wind phase (instanceIndex varies again; per-node
KindStatic gave every tree phase 0 → unison sway). Trees still ride level stacking
(portaled) and stay static in a plain glTF viewer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* perf(viewer): BVH the baked scene — accelerate hover/pick raycasts
The baked /viewer runs with useBvh={false}, so GlbScene's hover/pick raycasts
against the baked building were brute-force triangle intersection (profiler:
_computeIntersections + intersectTriangle ~30%). The parametric viewer wraps its
scene in <SceneBvh>; the baked one never did. SceneBvh's effect is one-shot on
mount and can't catch the async-loaded GLB, so compute a per-mesh BVH inside
GlbScene keyed on gltf.scene instead. 'replace' instances are NO_RAYCAST, so the
raycast===Mesh.prototype.raycast guard skips them (mirrors SceneBvh).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): inherit ez-tree preset by default (seed/type/tints)
Our node defaults (seed:1, treeType:deciduous, colors:#ffffff) were applied as
*overrides* on top of loadPreset, discarding each preset's tuned seed, growth
model, and tints — so our trees looked nothing like eztree.dev (pine grew
deciduous, foliage washed white, every silhouette off).
Mirror the flower petalColor pattern: seed/treeType/leafColor/branchColor are now
optional; generateTree only overrides an ez-tree option when the node set it,
otherwise loadPreset's value stands. Placement stores none of them (fresh tree =
pure preset; all same-preset trees share one instancing variant); the deciduous/
evergreen brush toggle is gone (growth model comes from the preset). The inspector
keeps them as per-tree overrides, and Randomize still varies the seed.
Verified against ez-tree: fresh Oak Medium → seed 35729 + preset tints; Pine
Medium → evergreen + seed 13977; overrides apply when set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): bundle real preset artwork (webp, in-package)
Replaces the placeholder SVG thumbnails + lucide panel icon with the real nature
art. 13 webp (~126KB total, 256px cards / 128px icon) live in src/assets and are
imported via art.ts — both consumers are Next, so transpilePackages runs them
through the image pipeline (hashed, cached /_next/static/media URLs). No CDN, no
per-app public/ mirroring; the assets travel with the package.
- art.ts: central webp imports → TREE_ART / FLOWER_ART / GRASS_ART / NATURE_ICON
- presets/flower-presets/grass-presets: thumbnail ← bundled art (was *Thumbnail())
- index.ts: Nature panel icon ← NATURE_ICON (was lucide:leaf)
- assets.d.ts: ambient *.webp decl (no next type dep)
- remove thumbnails.ts (placeholder generators, now dead)
Verified on the running dev server: webp emitted + served (HTTP 200, image/webp).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(plugin-trees): square preset cards on light-gray flattened thumbnails
Regenerate the 12 thumbnails with the transparency flattened onto gray-100
(#f3f4f6) and render the card square (aspect-square, was h-16 crop). The
nature-icon keeps its transparency for the icon rail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(plugin-trees): correct move-drag + outline behavior for instanced plants
Two selection defects, one root cause each:
1. Outline moved mirror-wise during a move drag. The host move tool drives the
registered Object3D imperatively with absolute level-local positions (and
mirrors them via useLiveTransforms) — the contract ParametricNodeRenderer
satisfies by putting position + registration on the same group. KindProxy
registered a child nested inside the transform-carrying group, so drag
positions landed in the node's rotated frame (placement gives every plant a
random Y rotation → deltas rotated up to 180°). Restructure: the registered
group now carries position/rotation (live-transform aware); the box collider
is a positioned sibling, staying out of the outline mask.
2. Outline froze while the mesh swayed. The outline mask pass renders with a
shared override material, so it can never follow the material positionNode
wind. Instead, while hovered/selected the collective system skips the node
and the proxy mounts the real geometry with static twins of the wind
materials (toStaticMaterial — explicit property transfer; node-material
clone() drops map/color). Outlined mesh == visible mesh, both still.
Bonus from the same restructure: a move drag now animates the actual plant in
realtime (the proxy is what the tool drives), not just the drag box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(plugin-trees): 2D plan symbols + trunk-sized footprint
- def.floorplan for the three kinds — the registry floor-plan layer renders any
kind that provides one, so this is all plugin nodes need to appear in 2D.
Tree: dashed canopy ring (dashed = overhead element) in the preset swatch +
solid trunk dot; ring is pointer-events:stroke so the large disc doesn't
steal clicks from what's under the canopy. Flower/grass: small colour dots.
Selected → palette stroke + move-handle; hovered → hover stroke.
- Tree floorPlaced.footprint is trunk-sized (treeTrunkRadius) instead of
canopy-sized, so the move/placement drag box hugs where the tree actually
plants instead of spanning the crown. The invisible hover collider keeps its
larger radius — only the displayed box shrinks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugin-trees): trunk-sized drag box, 45°-aligned rotation, rotate gizmo
- The move drag box ignored floorPlaced.footprint: with collides:false the tool
auto-measures the rendered mesh — which, since the selection swap-out, is the
whole canopy. Declare capabilities.dragBounds (trunk-sized, node-local) so the
box hugs the trunk and rotates with the node instead of wrapping the crown as
a world AABB.
- Placement wrote a fully random Y rotation, so the box/gizmo never sat on a 45°
step. Snap the random rotation to 45° increments (variety preserved, alignment
restored) and widen rotatable.snapAngles to 8×45° on all three kinds.
- Add the standard rotate gizmo (def.handles, shelf-style arc handle): ring
around the trunk near the ground — not the canopy, which would put the handle
meters out on a large oak.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugin-trees): live rotate preview + ground-level rotate ring
- KindProxy now folds useLiveNodeOverrides into its transform (mirrors
ParametricNodeRenderer) — the rotate/resize gizmos publish mid-drag patches
there, so the plant turns in realtime instead of snapping on commit. The
snap-on-commit (with the arc delta wrapping to ±180°) is also what made long
drags land 'the wrong way'; with live feedback the direction reads correctly
and matches the item gizmo pipeline exactly.
- Rotate ring drops from mid-trunk to 0.25m — a floor affordance like the item
gizmo, not a waist-height hoop.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(registry): panelForKind — 'find in catalog' opens a plugin kind's panel
loadPlugin now associates every node kind with its plugin's first (namespaced)
panel id; panelRegistry.panelForKind(kind) exposes it. A host's find-node
handler can open the right panel for any plugin kind with zero per-plugin
knowledge.
plugin-trees uses it end-to-end: a module-level selection:find-node listener
(find-sync.ts, imported by the manifest so it's live from plugin load) points
the panel store at the found node's section + preset — panel section state
moved from panel-local useState into the store to make it addressable.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(registry): plugin panels declare their workspaces (default edit-only)
Plugin panels rode into the studio rail: the host swaps its own sidebar tabs by
workspaceMode, but usePluginPanels appended registry panels unconditionally.
PluginPanel gains workspaces?: readonly ('edit'|'studio')[] — manifest metadata,
default ['edit'] (an authoring panel has no business in the clean render
workspace; a plugin shipping studio tooling opts in explicitly). usePluginPanels
filters by the current workspaceMode, covering both the v1 AppSidebar and v2 tab
bar paths. Nature declares nothing and disappears from studio via the default;
the v2 layout's existing active-tab fallback handles a panel vanishing mid-use.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: link plugin-authoring guide from README + CONTRIBUTING, fix stale wiki path
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugin-trees): keep ez-tree off the eager SSR path
ez-tree loads its inlined textures at module scope (needs document), so any
eager import chain reaching geometry.ts crashed Next prerender
(ReferenceError: document is not defined on /_not-found). Move the pure
helpers (mulberry32, naturalHeight) to variant-utils.ts so the
flower/grass builders and floorplan no longer pull ez-tree, and drop the
generateTree re-export from the package barrel (no external consumers).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: resolve lint and type errors to enable CI
- Remove stale biome-ignore suppressions in bootstrap.ts, r3f.d.ts,
and parametric-node-renderer.tsx (rules no longer fire).
- Replace forEach callbacks that return values with for...of loops
in node-actions.ts and build-collider-world.ts
(lint/suspicious/useIterableCallbackReturn).
- Rewrite assign-in-expression guards in ceiling/tool.tsx to explicit
if-statements (lint/suspicious/noAssignInExpressions).
- Hoist useMemo/useCallback above early return in chimney/panel.tsx
(lint/correctness/useHookAtTopLevel).
- Add `^build` to turbo check-types dependsOn so packages/core dist is
up-to-date before type checking — this resolves all 32 type errors
which were caused by stale dist, not missing symbols.
* ci: add lint and typecheck workflow on push/PR
Adds a quality gate that runs `bun run check` (Biome) and
`bun run check-types` (TypeScript) on every push and PR to main.
Uses concurrency groups to cancel stale runs. Closes#147.
---------
Co-authored-by: Pascal <open@pascal.app>
* 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>
The standalone editor app loaded `lib/bootstrap.ts` as a side-effect
import only from `components/scene-loader.tsx`. That worked for the
`/edit/[sceneId]` route but left every other page (homepage, settings,
viewer-only routes) hitting `<Viewer>` with an empty client-side
registry — node materials resolved to `null` and React surfaced a
`<html>`-level hydration mismatch on first paint.
Fix mirrors the community-app side that landed in pascalorg/private-
editor#27:
- New `app/client-bootstrap.tsx` — thin client wrapper that imports
`../lib/bootstrap` and renders children.
- `app/layout.tsx` mounts `<ClientBootstrap>` around `{children}` so
every page in the standalone editor gets the registry populated
before its first `<Viewer>` / `<Editor>` mounts.
- `lib/bootstrap.ts` switched to **synchronous** built-in registration
via `registerNode(def)` per kind instead of `await loadPlugin(...)`.
The previous async kick-off only resolved in a microtask, letting
the first SSR / hydration pass see an empty registry. External
plugin discovery (`discoverPlugins()`) stays async and runs via its
own `loadExternalPlugins()` path, gated by `externalsKickedOff` so
HMR doesn't re-fetch.
- `components/scene-loader.tsx` drops the per-page side-effect import
— the root provider handles it now.
`bun.lock` syncs `@pascal-app/editor` into `@pascal-app/nodes`'s
peerDependencies + devDependencies (already declared in
`packages/nodes/package.json`; only the lockfile lagged).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Verification anchor for "which path is running this kind?":
[pascal:registry] loaded pascal:core v1 (1 kinds: shelf)
prints in the browser dev console on app boot. Empty array means every
kind is on the legacy dispatch path. A kind in the array means the
registry-first NodeRenderer / ToolManager shims own it (legacy path is
short-circuited).
Also exposes `globalThis.__pascalNodeRegistry` in dev so you can run
ad-hoc inspections like `__pascalNodeRegistry.has('spawn')` from the
console. Production builds skip both.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds apps/editor/lib/bootstrap.ts that calls loadPlugin(builtinPlugin)
as a module-side-effect on first import. Imported from scene-loader.tsx
so it runs on the client side where the editor mounts.
Idempotency guard handles HMR re-execution (would otherwise throw on
duplicate registerNode). For the empty plugin this commit, the entire
call is a no-op — included now so future commits that add real node
kinds only need to push them onto builtinPlugin.nodes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-push audit agent A2 flagged two HIGH-severity security issues that
would have shipped in the PR had we not checked:
1. PUT /api/scenes/[id] URL-validation bypass. Phase 8 P4 fixed
the POST /api/scenes route by replacing a loose z.unknown() graph
schema with AnyNode superRefine. The fix never made it to the PUT
handler - an attacker could resubmit the same javascript:/file:///
payloads via PUT. Fixed by extracting the tight validator into
apps/editor/lib/graph-schema.ts and sharing it across both routes.
2. SSRF in photo_to_scene / analyze_floorplan_image /
analyze_room_photo. All three tools called raw fetch(image) on
user-supplied URLs with no validation - a direct
http://169.254.169.254/latest/meta-data/ exfil primitive on any
cloud host. Added packages/mcp/src/lib/safe-fetch.ts that:
- Blocks loopback (127.0.0.0/8, ::1)
- Blocks link-local incl. cloud metadata (169.254.0.0/16)
- Blocks private ranges (10/8, 172.16/12, 192.168/16, fc00::/7)
- Blocks .local/.internal/.corp hostnames + localhost variants
- Blocks v4-mapped IPv6 loopback (::ffff:127.0.0.1)
- Manual redirects (max 3), revalidating the allowlist per hop
- 20 MB response-size cap (streamed, enforced per-chunk)
- 10s timeout
- Optional PASCAL_ALLOWED_ASSET_ORIGINS env allowlist
Tests: 8 new SSRF guard tests, all vision tests still pass, full
suite 302/302.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upload files directly to Supabase Storage via signed URLs, bypassing
the Next.js 100MB body size limit. Supports scans up to 200MB with
real-time progress tracking that persists across sidebar panel switches.
- Add Zustand upload store for persistent upload state
- Add createAssetUploadUrl/confirmAssetUpload server actions
- Add XHR-based upload orchestrator with progress events
- Scene node creation works even if sidebar panel unmounts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Removed 9 stale branch-specific env vars on Vercel that were locked
to the deleted feat/refactor-supabase-setup branch
- Added runtime warning when a preview deployment detects it's using
the production Supabase instance (branching skipped/misconfigured)
The root issue: Supabase branching only creates preview branches for
PRs that include migration changes. PRs without migrations get SKIPPED
and fall through to the generic env vars, which include 'preview' in
their targets and point to production Supabase.
To fully fix: configure Supabase branching to always create preview
branches, or remove 'preview' from generic Supabase env var targets
on Vercel (requires branching to be working first).
Co-authored-by: Anton Pascal <anton-pascal@users.noreply.github.com>
Validates all required env vars at runtime with zod schemas.
Skips validation during build via SKIP_ENV_VALIDATION flag.
Replaces scattered process.env access with typed env imports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Turbo was stripping env vars during build on Vercel. Added all required
vars to turbo.json build task. Also hardened Supabase and BetterAuth
init with fallback placeholders for build-time safety.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Defer database client creation until first use via Proxy so Next.js
builds succeed without POSTGRES_URL and SUPABASE_URL env vars present.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add Google OAuth sign-in alongside magic link authentication
- Username onboarding: require username selection after first sign-in
- Public profile pages at /u/[username] with social links (GitHub, X)
- Settings page for managing username and social links
- Viewer header: unified floating card with project name, @username, breadcrumb
- Guest CTA on viewer page prompting sign-in
- Show owner avatar + username on community project cards
- Redesigned project cards (larger preview, avatar + stats row below)
- Consistent navbar across hub and profile pages with GitHub link
- Footer with links to GitHub repo and npm packages
- Borderless design with natural shadows throughout
- Apple corner smoothing (squircle) progressive enhancement
- cursor:pointer globally on buttons and links
- DB migrations: username, github_url, x_url columns on auth_users
- Root-level db:generate/migrate/push/studio scripts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>