From bf9246cd3619a79d1c5585e148df9ff7a2c761ca Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 19 Jun 2026 10:50:48 -0400 Subject: [PATCH 1/3] feat(sfx): play paint_apply sound when a material is painted Registers a paintApply SFX (with rate/volume jitter + a 60ms gap so rapid multi-face painting doesn't machine-gun), wires the sfx:paint-apply bus event, and emits it from the material-paint click chokepoint in the selection manager (fires on apply, not hover/preview). Adds the audio asset to the editor app's public resources. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/editor/public/audios/sfx/paint_apply.mp3 | Bin 0 -> 68992 bytes .../src/components/editor/selection-manager.tsx | 1 + packages/editor/src/lib/sfx-bus.ts | 2 ++ packages/editor/src/lib/sfx-player.ts | 9 +++++++++ 4 files changed, 12 insertions(+) create mode 100644 apps/editor/public/audios/sfx/paint_apply.mp3 diff --git a/apps/editor/public/audios/sfx/paint_apply.mp3 b/apps/editor/public/audios/sfx/paint_apply.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..6fabae1f02d60104b959dc1eecf08bbf2cdea320 GIT binary patch literal 68992 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 { } interaction.apply() + sfxEmitter.emit('sfx:paint-apply') if (activePreview?.key === interaction.key) { activePreview = null } else { diff --git a/packages/editor/src/lib/sfx-bus.ts b/packages/editor/src/lib/sfx-bus.ts index ff555612..262525d5 100644 --- a/packages/editor/src/lib/sfx-bus.ts +++ b/packages/editor/src/lib/sfx-bus.ts @@ -16,6 +16,7 @@ type SFXEvents = { 'sfx:snapshot-capture': undefined 'sfx:menu-hover': undefined 'sfx:menu-click': undefined + 'sfx:paint-apply': undefined } /** @@ -45,6 +46,7 @@ export function initSFXBus() { sfxEmitter.on('sfx:snapshot-capture', () => playSFX('snapshotCapture')) sfxEmitter.on('sfx:menu-hover', () => playSFX('menuHover')) sfxEmitter.on('sfx:menu-click', () => playSFX('menuClick')) + sfxEmitter.on('sfx:paint-apply', () => playSFX('paintApply')) } /** diff --git a/packages/editor/src/lib/sfx-player.ts b/packages/editor/src/lib/sfx-player.ts index 5106c7c7..f4d12e65 100644 --- a/packages/editor/src/lib/sfx-player.ts +++ b/packages/editor/src/lib/sfx-player.ts @@ -100,6 +100,15 @@ export const SFX: Record = { volumeRange: [0.5, 0.6], panJitter: 0.1, }, + // Fired when a material is applied to a surface in paint mode. Painting can + // fire in quick succession across faces, so keep variation + a small gap. + paintApply: { + src: '/audios/sfx/paint_apply.mp3', + rateRange: [0.95, 1.05], + volumeRange: [0.85, 1.0], + panJitter: 0.12, + minIntervalMs: 60, + }, } as const export type SFXName = keyof typeof SFX From 115a142d839f6075c3f98a9a474b4a99f2391b0f Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 19 Jun 2026 11:17:30 -0400 Subject: [PATCH 2/3] fix(item): duplicated items keep their painted slot materials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplicating an item drops to the catalog placement flow, which rebuilds the draft from the asset + transform and never carried node.slots — so the copy lost every painted slot override. Thread slots through the draft create path: useDraftNode.create seeds it onto the draft and commit() forwards it to the final node, the placement coordinator passes it to its lazy wall/ceiling draft creates, and the item move tool supplies node.slots for both the floor (direct create) and wall/ceiling (coordinator) duplicate paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/tools/item/use-draft-node.ts | 8 +++++++- .../tools/item/use-placement-coordinator.tsx | 19 +++++++++++++++++-- packages/nodes/src/item/move-tool.tsx | 5 ++++- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/components/tools/item/use-draft-node.ts b/packages/editor/src/components/tools/item/use-draft-node.ts index dfd752fb..a53a47b9 100644 --- a/packages/editor/src/components/tools/item/use-draft-node.ts +++ b/packages/editor/src/components/tools/item/use-draft-node.ts @@ -27,12 +27,14 @@ export interface DraftNodeHandle { readonly current: ItemNode | null /** Whether the current draft was adopted (move mode) vs created (create mode) */ readonly isAdopted: boolean - /** Create a new draft item at the given position. Returns the created node or null. */ + /** Create a new draft item at the given position. Returns the created node or null. + * `slots` seeds painted slot overrides so duplicates keep their materials. */ create: ( gridPosition: Vector3, asset: AssetInput, rotation?: [number, number, number], scale?: [number, number, number], + slots?: ItemNode['slots'], ) => ItemNode | null /** Take ownership of an existing scene node as the draft (for move mode). */ adopt: (node: ItemNode) => void @@ -61,6 +63,7 @@ export function useDraftNode(): DraftNodeHandle { asset: AssetInput, rotation?: [number, number, number], scale?: [number, number, number], + slots?: ItemNode['slots'], ): ItemNode | null => { const currentLevelId = useViewer.getState().selection.levelId if (!currentLevelId) return null @@ -73,6 +76,7 @@ export function useDraftNode(): DraftNodeHandle { asset, parentId: currentLevelId, metadata: { isTransient: true }, + ...(slots ? { slots } : {}), }) useScene.getState().createNode(node, currentLevelId) @@ -180,6 +184,8 @@ export function useDraftNode(): DraftNodeHandle { rotation: updateProps.rotation ?? draft.rotation, scale: updateProps.scale ?? draft.scale, side: updateProps.side ?? draft.side, + // Carry painted slot overrides so a duplicated item keeps its materials. + ...(draft.slots ? { slots: draft.slots } : {}), // Roof host — see the move-mode commit above for why this must be // forwarded explicitly. roofSegmentId: updateProps.roofSegmentId, diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index dbe35414..4384da2a 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -192,6 +192,9 @@ export interface PlacementCoordinatorConfig { initialState?: PlacementState /** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */ defaultScale?: [number, number, number] + /** Painted slot overrides to seed onto a lazily-created draft (wall/ceiling + * duplicates) so the duplicate keeps its materials. */ + slots?: ItemNode['slots'] /** Move-mode sessions keep the grabbed item offset from the first surface hit * (floor / wall / ceiling / item-surface / shelf) instead of snapping the * item's origin under the cursor. */ @@ -512,7 +515,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea 0, ] - draftNode.create(gridPosition.current, asset, initRotation, configRef.current.defaultScale) + draftNode.create( + gridPosition.current, + asset, + initRotation, + configRef.current.defaultScale, + configRef.current.slots, + ) const draft = draftNode.current if (draft) { @@ -857,7 +866,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftNode.commit(result.nodeUpdate) if (configRef.current.onCommitted()) { - draftNode.create(gridPosition.current, asset, currentRotation) + draftNode.create( + gridPosition.current, + asset, + currentRotation, + configRef.current.defaultScale, + configRef.current.slots, + ) const previewBounds = expandBoundsToGrid( getFallbackPreviewBounds(draftNode.current, asset, asset.attachTo), asset.attachTo, diff --git a/packages/nodes/src/item/move-tool.tsx b/packages/nodes/src/item/move-tool.tsx index f32c7ce4..852cd6c4 100644 --- a/packages/nodes/src/item/move-tool.tsx +++ b/packages/nodes/src/item/move-tool.tsx @@ -115,6 +115,9 @@ export function MoveItemTool({ node }: { node: ItemNode }) { const cursor = usePlacementCoordinator({ asset: node.asset, draftNode, + // Carry painted slot overrides onto the duplicate's draft (wall/ceiling + // items create their draft lazily inside the coordinator). + slots: node.slots, // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft. initialState: isNew ? { @@ -135,7 +138,7 @@ export function MoveItemTool({ node }: { node: ItemNode }) { // items are created lazily on surface entry. gridPosition.copy(new Vector3(...node.position)) if (!node.asset.attachTo) { - draftNode.create(gridPosition, node.asset, node.rotation, node.scale) + draftNode.create(gridPosition, node.asset, node.rotation, node.scale, node.slots) } } else { draftNode.adopt(node) From 08df21a96de5be0e8022c99c86e34986d896a7df Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Fri, 19 Jun 2026 13:04:42 -0400 Subject: [PATCH 3/3] docs(review-skill): require slots + world-scale UV convention for new nodes Adds a Section B rule to the review-architecture skill: a new node kind or geometry change that exposes paintable parts must carry overrides in a schema `slots` record resolved via def.capabilities.paint (and thread it through clone/duplicate/preset paths), and texturable def.geometry must emit UVs in metres (1 unit = 1 m) so catalog finishes tile. Surfaced in the skill description too. Co-Authored-By: Claude Opus 4.8 (1M context) --- .agents/skills/review-architecture/SKILL.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.agents/skills/review-architecture/SKILL.md b/.agents/skills/review-architecture/SKILL.md index d740bd5c..88e952d3 100644 --- a/.agents/skills/review-architecture/SKILL.md +++ b/.agents/skills/review-architecture/SKILL.md @@ -1,6 +1,6 @@ --- name: review-architecture -description: Review a PR against the Pascal architectural rules — package boundaries (core/viewer/editor/nodes), the registry-driven composition model (def.geometry / def.renderer / def.system), legacy-dispatch regressions, hook hygiene (useEditor/useScene/useViewer), and selector performance. Use when the user asks to review a PR, audit a branch, or check that changes respect the codebase's architecture. +description: Review a PR against the Pascal architectural rules — package boundaries (core/viewer/editor/nodes), the registry-driven composition model (def.geometry / def.renderer / def.system), legacy-dispatch regressions, the slots + world-scale-UV convention for new nodes/geometry, hook hygiene (useEditor/useScene/useViewer), and selector performance. Use when the user asks to review a PR, audit a branch, or check that changes respect the codebase's architecture. allowed-tools: Bash(git *) Bash(gh *) Read Grep Glob --- @@ -101,6 +101,9 @@ If the PR adds or modifies a node kind, check against `wiki/architecture/node-de - **Host kinds need `children` on the schema.** If `def.relations.hosts` is set, the schema must declare `children: z.array(z.string()).default([])` (and `migrateNodes` must patch existing scenes). Otherwise `useScene.createNode(child, parentId)` writes a `parent.children` entry into nothing and the host never sees the new child. - **Movable opt-in.** `MoveTool` dispatches to `MoveRegistryNodeTool` only when `def.capabilities.movable` is set. Kinds with bespoke move semantics (wall endpoint drag with linked-wall cascade, slab vertex edit, etc.) deliberately omit `movable` and supply `def.affordanceTools.move` instead. Force-routing a bespoke-move kind through generic dispatch (`nodeRegistry.has(kind)` instead of `def.capabilities.movable`) is a regression — call it out. The bug history is documented in `plans/editor-node-registry.md` ("Capability-driven move dispatch"). - **Paint dispatch lives on `def.capabilities.paint`.** A paintable kind declares `resolveRole` / `buildPatch` / `applyPreview` (+ optional `getEffectiveMaterial`) on `PaintCapability`; the editor's selection-manager routes hover / click / preview through the generic dispatcher. A PR that adds an `if (node.type === '')` arm to paint-mode handling, paint-preview application, or material picker resolution is a regression — the behaviour belongs on the kind's `paint` capability. See `packages/core/src/registry/types.ts` (`PaintCapability`). +- **Slots + world-scale UVs for paintable surfaces.** A new kind (or geometry change) that exposes paintable parts must follow the unified slot convention, not reinvent it: + - **Paintable parts are slots, carried on the node.** Overrides live in a `slots` record (`slotId → MaterialRef`, `scene:`/`library:`) on the schema, resolved via `def.capabilities.paint` — not ad-hoc per-surface `material` / `materialPreset` fields, and not a parallel store. A new paintable kind whose schema lacks `slots` (or whose duplicate / preset / clone path drops it) is a blocker: it silently loses painted materials. (Slots are plain data — generic clone/parse preserves them; bespoke draft-rebuild placement paths must thread them through explicitly. Reference bug: item duplicate rebuilt the draft from `asset` and dropped `slots`.) + - **Texturable geometry emits UVs in metres (1 UV unit = 1 m).** Any `def.geometry` producing a surface a finish can tile onto must generate UVs at the same world scale walls / slabs / roofs use, because catalog finishes set `repeat` as tiles-per-metre. Unitless, bounding-box-normalised, or hardcoded UVs that don't scale with the surface are a blocker — finishes won't tile consistently. Flat-colour-only surfaces need no UVs. GLB item authoring follows the same contract via `slot_`-prefixed materials (case-insensitive, `slot_` → slot id). See `wiki/architecture/materials-and-themes.md` § "Texture world scale" and `wiki/architecture/item-authoring.md`. - **Floor elevation lives on `def.capabilities.floorPlaced`.** Kinds that rest on a level and lift over overlapping slabs declare a `footprint` (and optional `applies` predicate) on `FloorPlacedConfig`; the generic `` writes `slabElevation + node.position[1]` onto the registered mesh on each dirty mark. A new per-kind `useEffect` / per-kind system that recomputes Y from slab overlap is a regression — the per-kind block was lifted out of `ItemSystem` in Phase 6.1. See `packages/core/src/registry/types.ts` (`FloorPlacedConfig`). - **Render-mode behaviour goes through `def.surfaceRole`.** Solid / Rendered / Clay viewer modes look up the kind's `surfaceRole` on `NodeDefinition` to pick the right material strategy (clay overrides, edge passes, theme overlays). New `if (node.type === '')` arms inside the render-mode pipeline or theme application are a regression — the behaviour belongs on `def.surfaceRole`. See `packages/core/src/registry/types.ts:529`. - **Capability names must describe verbs, not host kinds.** `movable` / `paint` / `floorPlaced` / `cuttable` / `selectable` describe *what the node does*. A new capability named after a host kind (`slabAccessory`, `wallAccessory`, `siteAccessory`, anything `Xaccessory` / `Xhosted` shaped) couples the registry's type surface to one specific host and reads as precedent for the next reviewer. Blocker. Push back: generalise into a paired *host-side* capability ("I merge subtractive accessories from my children") + *accessory-side* capability ("I provide a cut geometry, cascade my dirty mark to my host's parent"). The single existing case — `capabilities.roofAccessory` (`packages/core/src/registry/types.ts:791`, consumed by `packages/viewer/src/systems/roof/roof-system.tsx`) — is documented tech debt; do not extend the pattern.