feat(paint-slots): prepared-drywall finish + fence 4 slots matching build options

- Add 'concrete-drywall' (Prepared Drywall) catalog finish + 512 KTX2/webp
  runtime assets; default walls (WALL_SLOT_DEFAULT interior+exterior) and the
  roof wall/trim band to it so roofs read continuous with walls.
- fence: replace the 2-slot panel/rail split with 4 slots that match the panel's
  build options — posts / infill (showInfill) / base (grounded only) / rail —
  each its own mesh with userData.slotId; conditional slots track the build
  state. Defaults: posts/infill/base charcoal, rail wood-finewood27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-17 20:51:49 -04:00
co-authored by Claude Opus 4.8
parent e92ee05702
commit bba0cc5f68
14 changed files with 115 additions and 52 deletions
+1 -1
View File
@@ -165,7 +165,7 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
surfaces: { sides: { faces: 'all' } },
duplicable: true,
deletable: true,
slots: () => fenceSlots(),
slots: (node) => fenceSlots(node as FenceNodeType),
paint: fencePaint,
// Placed by drawing the span with the two-click tool; a saved preset
// seeds its build parameters via `toolDefaults.fence` (see `tool.tsx`
+10 -13
View File
@@ -12,18 +12,18 @@ import {
} from '@pascal-app/viewer'
import { FrontSide, Group, type Material, Mesh, type Texture } from 'three'
import type { FenceNode } from './schema'
import { FENCE_PANEL_SLOT_DEFAULT, FENCE_RAIL_SLOT_DEFAULT, type FenceSlotId } from './slots'
import { FENCE_SLOT_DEFAULTS, type FenceSlotId } from './slots'
/**
* Stage B builder for fence. Splits the geometry into two paintable slots —
* `panel` (posts / base / infill) and `rail` (the cap rail) — each its own Mesh
* with a `userData.slotId` so the unified slot paint resolves and previews per
* part.
* Stage B builder for fence. Splits the geometry into four paintable slots —
* `posts`, `infill`, `base`, `rail` (matching the build options in the panel) —
* each its own Mesh with a `userData.slotId` so the unified slot paint resolves
* and previews per part. Empty groups (no infill / floating base) are skipped.
*
* Per slot the material resolves: `node.slots[slotId]` (a shared scene material
* or `library:` finish) → the legacy inline `node.material` / `materialPreset`
* (pre-slot-model scenes, applied to both parts) → the declared slot default.
* Textures-off collapses both parts to the themed joinery role.
* (pre-slot-model scenes, applied to every part) → the declared slot default.
* Textures-off collapses every part to the themed joinery role.
*
* Phase 6 cleanup moves the geometry math out of the legacy
* `viewer/src/systems/fence/fence-system.tsx` into this folder once the legacy
@@ -37,10 +37,7 @@ type FenceMaterial = Material & {
transparent: boolean
}
const SLOT_DEFAULTS: Record<FenceSlotId, string> = {
panel: FENCE_PANEL_SLOT_DEFAULT,
rail: FENCE_RAIL_SLOT_DEFAULT,
}
const FENCE_SLOT_ORDER: FenceSlotId[] = ['posts', 'infill', 'base', 'rail']
const fenceMaterialCache = new Map<string, Material>()
@@ -67,7 +64,7 @@ function getFenceSlotMaterial(
return getLegacyFenceMaterial(node, shading)
}
return resolveSlotDefaultMaterial(SLOT_DEFAULTS[slotId], shading, 0.8)
return resolveSlotDefaultMaterial(FENCE_SLOT_DEFAULTS[slotId], shading, 0.8)
}
function getLegacyFenceMaterial(node: FenceNode, shading: RenderShading): Material {
@@ -113,7 +110,7 @@ export function buildFenceGeometry(
const group = new Group()
const geometries = generateFenceSlotGeometries(node)
for (const slotId of ['panel', 'rail'] as const) {
for (const slotId of FENCE_SLOT_ORDER) {
const geometry = geometries[slotId]
if (geometry.getAttribute('position') === undefined) continue
const material = getFenceSlotMaterial(
+3 -1
View File
@@ -1,9 +1,11 @@
import type { AnyNode, FenceNode, PaintResolveArgs } from '@pascal-app/core'
import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint'
const FENCE_SLOT_IDS = new Set<string>(['posts', 'infill', 'base', 'rail'])
function resolveFenceRole(args: PaintResolveArgs): string | null {
const slotId = (args.hitObject?.userData as { slotId?: unknown } | undefined)?.slotId
return slotId === 'panel' || slotId === 'rail' ? slotId : null
return typeof slotId === 'string' && FENCE_SLOT_IDS.has(slotId) ? slotId : null
}
export const fencePaint = createSlotPaintCapability({
+25 -8
View File
@@ -1,15 +1,32 @@
import type { SlotDeclaration } from '@pascal-app/core'
import type { FenceNode } from './schema'
export type FenceSlotId = 'panel' | 'rail'
// Slots map 1:1 to the fence panel's build options: the end posts, the infill
// slats (the showInfill toggle), the base kickboard, and the top rail.
export type FenceSlotId = 'posts' | 'infill' | 'base' | 'rail'
// Body (posts / base / infill) reads as a dark composite by default; the rail
// cap as wood. Both are repaintable per slot.
export const FENCE_PANEL_SLOT_DEFAULT = 'library:preset-charcoal'
export const FENCE_POSTS_SLOT_DEFAULT = 'library:preset-charcoal'
export const FENCE_INFILL_SLOT_DEFAULT = 'library:preset-charcoal'
export const FENCE_BASE_SLOT_DEFAULT = 'library:preset-charcoal'
export const FENCE_RAIL_SLOT_DEFAULT = 'library:wood-finewood27'
export function fenceSlots(): SlotDeclaration[] {
return [
{ slotId: 'panel', label: 'Panel', default: FENCE_PANEL_SLOT_DEFAULT },
{ slotId: 'rail', label: 'Rail', default: FENCE_RAIL_SLOT_DEFAULT },
export const FENCE_SLOT_DEFAULTS: Record<FenceSlotId, string> = {
posts: FENCE_POSTS_SLOT_DEFAULT,
infill: FENCE_INFILL_SLOT_DEFAULT,
base: FENCE_BASE_SLOT_DEFAULT,
rail: FENCE_RAIL_SLOT_DEFAULT,
}
export function fenceSlots(node: FenceNode): SlotDeclaration[] {
const slots: SlotDeclaration[] = [
{ slotId: 'posts', label: 'Posts', default: FENCE_POSTS_SLOT_DEFAULT },
]
if (node.showInfill !== false) {
slots.push({ slotId: 'infill', label: 'Infill', default: FENCE_INFILL_SLOT_DEFAULT })
}
if (node.baseStyle !== 'floating') {
slots.push({ slotId: 'base', label: 'Base', default: FENCE_BASE_SLOT_DEFAULT })
}
slots.push({ slotId: 'rail', label: 'Rail', default: FENCE_RAIL_SLOT_DEFAULT })
return slots
}
+2 -2
View File
@@ -22,9 +22,9 @@ export function getRoofMaterials(
const materials = textures
? [
// Mirrors getRoofMaterialArray's catalog defaults (wall/trim concrete,
// Mirrors getRoofMaterialArray's catalog defaults (wall/trim drywall,
// soft-white deck + soffit, terracotta shingle) for the no-parent path.
resolveSlotDefaultMaterial('library:concrete-plate', shading), // 0: Wall/Trim
resolveSlotDefaultMaterial('library:concrete-drywall', shading), // 0: Wall/Trim
resolveSlotDefaultMaterial('library:preset-softwhite', shading), // 1: Deck
resolveSlotDefaultMaterial('library:preset-softwhite', shading), // 2: Interior
resolveSlotDefaultMaterial('library:roof-terracottatiles', shading), // 3: Shingle