nodes: add modular cabinets and wall trim controls (#461)

* 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

* Add modular cabinet node

* Add modular cabinet run and tall cabinet workflows

* Add cabinet paint slots and material groups

* Improve modular cabinet presets and wall snapping

* Improve cabinet wall snapping

* Fix cabinet wall snapping and countertop joins

* Improve cabinet appliance compartments

* Improve fridge visuals and smooth cabinet animations

Fridge exterior is now brushed silver with brass accents and the
interior is all white. Door/drawer animation no longer rebuilds the
full cabinet geometry per frame — animated parts carry pose metadata
that a new per-frame cabinet system applies directly to transforms.

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

* Improve gas hob flames and cooktop knob controls

Port the curved-flame look from reference gas-burner photos: each burner
gets a ring of vertex-coloured tube flames (blue body, orange-yellow tips)
whose spines breathe and flicker per frame at ~30fps, plus a flat ignition
glow and a faded heat halo. Fix the knob pointer notch to rotate with the
knob instead of drifting sideways. Also includes cooktop compartment
presets/panels and cabinet selection/move affordance work.

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

* Fix cabinet UVs and registry actions

* Fix registry move snap typecheck

* Fix cabinet knob animation concurrency

* Fix cabinet material disposal, undo flood, and 2D move parity

- Flag shared cabinet appliance materials (and the viewer's cached material
  factories) as cached so geometry rebuilds no longer dispose materials
  still referenced by other nodes, forcing scene-wide shader recompiles.
- Route the door/drawer open animation through useLiveNodeOverrides with a
  single final commit, so one play is one undo step instead of ~20.
- Add a cabinet-module floorplanMoveTarget: 2D drags now convert through
  planToLocal + magneticSnap like the 3D path, instead of writing plan
  coords into the run-local position (teleporting modules on rotated runs).
- Re-key sibling runs (cabinetAdjacencyRevision) when a run's
  neighbor-affecting inputs change, so countertop overhang joins re-trim
  when a neighbor moves/resizes/deletes.
- Narrow the cabinet panel's scene subscriptions (useShallow module
  selector) so it stops re-rendering on every scene mutation.

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

* Restructure cabinet schema and centralize run math/mutations

Prepares the cabinet node for the queued specialty units (corner L-shape,
sink base, appliance gap, open shelving):

- Add a `moduleKind` discriminator (default 'standard') on CabinetModuleNode
  so new unit types extend an enum instead of overloading the stack, and
  split CabinetCompartment into a z.discriminatedUnion so invalid field
  combos (drawer with rack style, fridge with burner state) are
  unrepresentable. Shared box fields now come from one `cabinetBoxFields`
  object so run/module schemas can't drift.
- Extract the straight-line run assumption (sort-by-x, edges, adjacency,
  spans, side-insert, reflow, frame transforms) into run-layout.ts —
  previously spread across definition/geometry/quick-actions/stack/
  move-frame, so corner support would have meant five parallel edits.
- Consolidate the run mutations (add module, wall cabinet/hood above,
  base↔tall switch, layout-revision bump) into run-ops.ts on SceneApi.
  Panel and quick-actions had drifted copies: the panel's add-module
  skipped gap checks, revision-bump scope differed per surface.
- Remove dead code: node-level doorStyle (with a migrateNodes entry —
  geometry only ever read per-compartment doorType), the unreachable
  slot-handle mesh block, the no-op handlePosition 'edge' enum value,
  wallLocalX, and duplicate totalCabinetHeight definitions.

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

* Add cabinet snap tests and split the cabinet panel

- New tests: cabinetModuleParentFrame.magneticSnap (flush snap, threshold,
  Z alignment, nearest-edge) and wall-snap's resolveCabinetWallFaceOffset /
  collectCabinetWallSnapNeighbors (straight face, miter taper, ray-miss
  fallback, yaw/face/parent filtering) — 87 cabinet tests, up from 71.
- De-brittle geometry test lookups: coordinate-encoded mesh names are now
  matched by pattern, so dimension-default changes don't break them.
- Split panel.tsx (1,541 → 679 lines): CompartmentCard + option constants
  into compartment-card.tsx, CabinetRunPanel + reflow wiring into
  run-panel.tsx, and the compartment type-transition tables into a pure
  resolveCompartmentTransition in stack-transitions.ts.

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

* Split cabinet geometry.ts into per-appliance modules

Pure code motion: geometry.ts (4,728 lines) becomes a 336-line orchestrator
dispatching into geometry/ — shared helpers + cached appliance materials
(shared.ts), CSG fronts/handles/doors/drawers (fronts.ts), run spans +
countertop (run.ts), and self-contained hood/fridge/cooktop/dishwasher/
oven-microwave/pantry builders. No mesh names, userData stamps, materials,
or math changed; the './geometry' import path is unchanged.

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

* Add undermount sink compartment with CSG-cut countertops

New 'sink' compartment type (single / double / 60-40 bowl layouts)
following the cooktop pattern: zero stack height, countertop-plane
geometry, panel card with a bowl-layout selector, and a Sink Base
preset. Bowl openings are subtracted from both module- and run-owned
countertop slabs via three-bvh-csg; the basin shells, drain plumbing,
and a spec-sheet-proportioned gooseneck faucet (Grohe Minta-style pin
lever) render beneath the cut. Sink modules skip the carcass top panel
and the deck under the sink row so the basin hangs through.

Also scope the viewer mock in roof-surface-placement-guides.test.ts to
only stub useViewer — the bare module stub leaked into later suites and
broke real CSG imports.

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

* Update ifc-converter next-env reference to build-mode route types

Regenerated by `next typegen` during check-types; points at
.next/types instead of the dev-mode .next/dev/types path.

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

* Draw cabinet floor plans with kitchen drafting symbols

Replace the per-module box soup with NKBA/architectural-convention
symbols: the run draws one heavier countertop outline per span
(extended by the overhang), base modules draw their front edge plus
compartment symbols — rounded bowl rects + faucet dot for sinks,
burner/zone rings for cooktops (reusing the 3D layout tables so 2D and
3D always agree) — and appliance modules carry standard upright labels
(DW / REF / OV / MW / PAN). Nested wall cabinets and hood-only modules
draw as dashed open outlines per the above-cut-plane convention.

* Play cabinet open/close animation on the E interaction key

Add an `e` slot to registry KeyboardActions and dispatch it in the
keyboard hook ahead of the legacy door/window arms, so kinds opt into
the E interaction on their NodeDefinition. Cabinets register it: E on a
module eases its doors/drawers open or closed, E on a run swings every
child module together, and hood-only modules fall through. The panel's
rAF animator moves into cabinet/interaction.ts (live-override frames,
single undo commit) so the Play button and E share one animation, and
the shortcuts dialog now documents E.

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

* Add kitchen island and bar counter support to cabinet runs

Islands: run-level countertopBackOverhang (seating side) and
withFinishedBack (decorative back panel), plus an I-key island placement
mode in the cabinet tool that skips wall snap. Bar counters: optional
barLedge {edge, height, depth} drawing a knee wall and raised slab along
the back or either run end, superseding the seating overhang on its
edge. withWaterfall drops slab-material panels to the floor on exposed
run ends. 2D floorplan outlines, selection bounds, geometry keys, and
run panel controls updated in parity; schemaVersion 3 -> 6.

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

* Merge quick-action arrow and plus into one directional add glyph

The add-left/add-right buttons showed two unrelated icons; a single
plus-on-tail arrow reads as "add on this side".

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

* Extend plugin discovery instead of replacing the host source

Also track the ifc-converter next-env routes reference update.

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

* Add L-corner runs to modular cabinets with run-scoped grouping

An "L Left/Right" quick action spawns a perpendicular base leg, wall leg,
and wall bridge as metadata-linked runs parented to the SOURCE RUN, so the
run stays the single group: selecting a base module selects only it, and
the clicked module never becomes a container. Corner runs re-anchor when
the source module resizes or moves (new MovableParentFrame.onCommit hook,
mirrored in the 2D floorplan commit), run bounds fold in child leg runs,
and deleting one corner member removes only that node while unlink patches
keep the survivors' link metadata consistent.

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

* Delete emptied cabinet run groups in the same delete gesture

Removing a run's last child (module or derived corner leg) left an
empty group node in the scene graph and persisted data. onDeleteCascade
now receives the gesture's pending delete ids so multi-select deletes
count siblings as gone, and both cabinet kinds cascade the orphaned
parent run away in one undo step.

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

* Remove filler front gap treatment

* Apply architecture review fixes across cabinet, plugin-trees, and framework

Move cabinet semantics out of packages/editor behind registry capabilities
(def.tree, selectionProxy.bypassDirectPick, IconRef quick-action icons,
catalog tool field), route cabinet selection through a core selection-proxy
helper, and make PanelWorkspace an opaque host tag. Fix the cabinet
placement undo flood, dirtyNodes.add bypasses, normalized UVs on paintable
slots, plugin-trees ghost layers/stale level transforms/untyped find-sync
event, and cross-file store-mock pollution plus orphaned dist tests that
broke combined test runs.

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

* Propagate front styling to L-corner runs even when re-layout bails

Group style changes rode entirely on syncDerivedCornerRun, which bails
silently when a later-drawn wall blocks the corner layout or a leg run
gained modules outside the derived-run spec — leaving the L legs styled
stale. Apply the style patch directly to every linked corner run before
attempting the geometric re-layout.

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

* Improve editor viewer integration

* Refactor plugin panels and floorplan invalidation

* UV-unwrap and material-assign roof-segment trim planes

* Fix cabinet placement snap feedback

* Add cabinet wall snap bypass

* Fix cabinet continuous placement flow

* Implement wall band material slots

* Add corner addition preview functionality and related tests

* Fix cabinet and wall band behavior

* Fix architecture review blockers

* Fix post-merge quality check

* fix cursor review feedback

* Fix cabinet placement type and quick action history

* Improve cabinet placement feedback

* Make wall bands configurable

* Add wall trim profiles and default materials

* Fix architecture review issues

* Fix cabinet rotation and wall band visibility

* Fix sink appliance paint persistence

* Bake cabinet animations and preserve wall band material

* Fix cabinet group duplication and module snapping

* Fix cabinet duplication architecture

* Fix cabinet floorplan test and make placement-type hint a live chip

- floorplan.test.ts: pass the required liveTransforms map to
  cabinetFloorplanSiblingOverrides (the runtime callers always supply it)
- ToolHint gains an optional generic `chip` contract (subscribe/value/
  cycle/labels) so a kind can render a tool hint as a live mode chip —
  like the snapping/continuation chips — without the editor knowing the
  kind's store
- cabinet: the I hint now shows the current value (Type: Cabinet /
  Type: Island) and is clickable to toggle

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
This commit is contained in:
Sudhir Yadav
2026-07-12 18:33:48 -04:00
committed by GitHub
co-authored by Claude Fable 5 Wassim SAMAD
parent 7d6879c416
commit c45808840c
169 changed files with 30272 additions and 706 deletions
+15 -8
View File
@@ -1,8 +1,9 @@
# @pascal-app/plugin-trees
The first-party **example plugin** for the Pascal editor. It contributes a
procedural _trees_ node and a left-rail _presets_ panel, and exists to prove —
and document — the minimal host surface every future plugin reuses.
The first-party **example plugin** for the Pascal editor. It contributes
procedural plant nodes plus a separately exported host-side Nature panel, and
exists to prove — and document — the minimal node-plugin surface every future
plugin reuses.
It is structurally identical to a third-party plugin: it peer-depends on
`@pascal-app/{core,viewer,editor}` (plus `react`/`three`/`@react-three/fiber`/
@@ -11,10 +12,11 @@ nothing private. Copy this folder as the starting point for a new plugin.
## What it demonstrates
The contribution paths a plugin has:
The contribution paths this package demonstrates:
1. **Left-rail panel**`panels` in the manifest. `presets-panel.tsx` is a
plain React component the host mounts behind an error boundary.
1. **Host-side panel extension** — the standalone editor registers
`treesHostPanel` separately from the core plugin manifest. `presets-panel.tsx`
is a plain React component the host mounts behind an error boundary.
2. **Right inspector for free**`def.parametrics` (`parametrics.ts`). The host
renders the preset/height/seed controls + the Randomize action with zero
tree-specific code.
@@ -70,11 +72,16 @@ setPluginDiscovery(async () => [treesPlugin])
```
`treesPlugin` exports three node kinds (`trees:tree`, `trees:flower`,
`trees:grass`) and one panel (`Trees`), loaded through the same `loadPlugin`
path as the built-ins.
`trees:grass`) for the core `loadPlugin` path. The editor app separately imports
`treesHostPanel` to surface the Nature rail entry; panels are not part of the v1
core plugin manifest.
## Notes / known gaps
- `package.json` points `main`/`exports` at raw TypeScript (`./src/index.ts`),
which works here only because the host app's bundler transpiles workspace
packages. A real third-party plugin must ship built JS (with `.d.ts` types) or
otherwise ensure the consuming host transpiles the package.
- `createNode` and the `floorPlaced.footprint` callback are typed against the
host's hand-maintained `AnyNode` union, so the node is cast (`as AnyNode` /
`as TreeNode`). The registry derives `AnyNode` post-migration.
+17 -20
View File
@@ -6,8 +6,8 @@ import { type TreesPanelMode, useTreesStore } from './store'
/**
* "Find in catalog" sync. The editor's node action menu emits
* `selection:find-node`; the host opens the panel that owns the kind (via
* `panelRegistry.panelForKind`) — but which *section* of the Nature panel to
* `selection:find-node`; the host opens the panel that owns the kind — but
* which *section* of the Nature panel to
* show is plugin knowledge, so the plugin listens too and points its own store
* at the found node's section + preset. Module-level (imported by the plugin
* manifest) so the listener is live from plugin load, even while the panel has
@@ -20,21 +20,18 @@ const MODE_BY_KIND: Record<string, TreesPanelMode> = {
'trees:grass': 'grass',
}
emitter.on(
'selection:find-node' as never,
((node: AnyNode) => {
const mode = MODE_BY_KIND[node.type as string]
if (!mode) return
const store = useTreesStore.getState()
store.setMode(mode)
if (mode === 'trees') {
const tree = node as unknown as TreeNode
store.setPreset(tree.preset ?? 'oak')
store.setSize(tree.size ?? 'medium')
} else if (mode === 'flowers') {
store.setFlowerPreset((node as unknown as FlowerNode).preset ?? 'daisy')
} else {
store.setGrassPreset((node as unknown as GrassNode).preset ?? 'meadow')
}
}) as never,
)
emitter.on('selection:find-node', (node: AnyNode) => {
const mode = MODE_BY_KIND[node.type as string]
if (!mode) return
const store = useTreesStore.getState()
store.setMode(mode)
if (mode === 'trees') {
const tree = node as unknown as TreeNode
store.setPreset(tree.preset ?? 'oak')
store.setSize(tree.size ?? 'medium')
} else if (mode === 'flowers') {
store.setFlowerPreset((node as unknown as FlowerNode).preset ?? 'daisy')
} else {
store.setGrassPreset((node as unknown as GrassNode).preset ?? 'meadow')
}
})
+8 -8
View File
@@ -1,4 +1,5 @@
import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
import type { EditorHostPanel } from '@pascal-app/editor'
// Side-effect: subscribes the panel store to `selection:find-node` so the
// host's "find in catalog" lands on the right Nature section (see find-sync.ts).
import './find-sync'
@@ -22,14 +23,13 @@ export const treesPlugin: Plugin = {
flowerDefinition as unknown as AnyNodeDefinition,
grassDefinition as unknown as AnyNodeDefinition,
],
panels: [
{
id: 'trees',
label: 'Nature',
icon: { kind: 'url', src: NATURE_ICON },
component: () => import('./presets-panel'),
},
],
}
export const treesHostPanel: EditorHostPanel = {
id: 'pascal:trees:trees',
label: 'Nature',
icon: { kind: 'url', src: NATURE_ICON },
component: () => import('./presets-panel'),
}
// NOTE: no re-export from './geometry' — it imports ez-tree, which touches
+36 -3
View File
@@ -10,7 +10,7 @@ import {
} from '@pascal-app/core'
import { useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useLayoutEffect, useMemo, useRef } from 'react'
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import { type BufferGeometry, type InstancedMesh, type Material, Matrix4, Object3D } from 'three'
import { toStaticMaterial } from './wind-node'
@@ -192,9 +192,15 @@ function InstancedSubMesh<N extends Placeable>({
// (cached) geometry/material alive across any recreation.
const capacity = Math.max(16, Math.ceil(nodes.length / 32) * 32)
useLayoutEffect(() => {
// Snapshot of each referenced parent level's matrixWorld at the last matrix
// write — the per-frame staleness check below compares against it so a level
// move (explode, elevation edit) refreshes instances without a node change.
const parentWorlds = useRef(new Map<string, number[]>())
const writeMatrices = useCallback(() => {
const mesh = ref.current
if (!mesh) return
parentWorlds.current.clear()
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i]
if (!node) continue
@@ -208,8 +214,11 @@ function InstancedSubMesh<N extends Placeable>({
// scene root, so fold in the parent level's world matrix.
const parent =
!localSpace && node.parentId ? sceneRegistry.nodes.get(node.parentId) : undefined
if (parent) {
if (parent && node.parentId) {
parent.updateWorldMatrix(true, false)
if (!parentWorlds.current.has(node.parentId)) {
parentWorlds.current.set(node.parentId, parent.matrixWorld.toArray())
}
INSTANCE_MATRIX.multiplyMatrices(parent.matrixWorld, DUMMY.matrix)
mesh.setMatrixAt(i, INSTANCE_MATRIX)
} else {
@@ -221,6 +230,30 @@ function InstancedSubMesh<N extends Placeable>({
mesh.computeBoundingSphere()
}, [nodes, naturalHeight, localSpace])
useLayoutEffect(() => {
writeMatrices()
}, [writeMatrices])
// A parent level can move without any node of this kind changing (level
// explode, elevation edits), which would leave the baked-in world transform
// stale. Compare each referenced level's matrixWorld against the snapshot —
// a handful of levels × 16 floats per frame — and rewrite only on change.
useFrame(() => {
if (localSpace || !ref.current) return
for (const [id, cached] of parentWorlds.current) {
const parent = sceneRegistry.nodes.get(id)
if (!parent) continue
parent.updateWorldMatrix(true, false)
const elements = parent.matrixWorld.elements
for (let i = 0; i < 16; i += 1) {
if (elements[i] !== cached[i]) {
writeMatrices()
return
}
}
}
})
return (
<instancedMesh
args={[subMesh.geometry as BufferGeometry, subMesh.material as Material, capacity]}
+4
View File
@@ -1,5 +1,6 @@
'use client'
import { EDITOR_LAYER } from '@pascal-app/editor'
import { useEffect, useMemo } from 'react'
import type { Material } from 'three'
import { generateTree, treeSpecOf } from './geometry'
@@ -16,6 +17,9 @@ export default function TreePreview({ node }: { node: TreeNode }) {
const built = useMemo(() => {
const tree = generateTree(treeSpecOf(node))
tree.scale.setScalar(node.height / naturalHeight(tree))
// Overlay layer keeps the ghost out of export/snapshot passes. Layers
// don't inherit, so every object in the built tree needs it.
tree.traverse((obj) => obj.layers.set(EDITOR_LAYER))
return tree
}, [node])
+15 -17
View File
@@ -1,7 +1,7 @@
'use client'
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
import { triggerSFX } from '@pascal-app/editor'
import { EDITOR_LAYER, triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useMemo } from 'react'
import { usePlacement } from './placement'
@@ -17,29 +17,27 @@ import { useTreesStore } from './store'
*/
export default function TreeTool() {
const activeLevelId = useViewer((s) => s.selection.levelId)
const brush = useTreesStore()
const preset = useTreesStore((s) => s.preset)
const size = useTreesStore((s) => s.size)
const height = useTreesStore((s) => s.height)
const foliageDensity = useTreesStore((s) => s.foliageDensity)
const trunkThickness = useTreesStore((s) => s.trunkThickness)
const leafless = useTreesStore((s) => s.leafless)
const previewNode = useMemo(
() =>
TreeNode.parse({
preset: brush.preset,
size: brush.size,
height: brush.height,
foliageDensity: brush.foliageDensity,
trunkThickness: brush.trunkThickness,
leafless: brush.leafless,
preset,
size,
height,
foliageDensity,
trunkThickness,
leafless,
// seed/treeType left unset → the ghost shows the pure preset, as placed.
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
[
brush.preset,
brush.size,
brush.height,
brush.foliageDensity,
brush.trunkThickness,
brush.leafless,
],
[preset, size, height, foliageDensity, trunkThickness, leafless],
)
const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => {
@@ -67,7 +65,7 @@ export default function TreeTool() {
if (!activeLevelId) return null
return (
<group ref={cursorRef} visible={cursorVisible}>
<group layers={EDITOR_LAYER} ref={cursorRef} visible={cursorVisible}>
<TreePreview node={previewNode} />
</group>
)