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:
co-authored by
Claude Opus 4.8
parent
e92ee05702
commit
bba0cc5f68
@@ -3938,6 +3938,44 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'concrete-drywall',
|
||||
label: 'Prepared Drywall',
|
||||
category: 'concrete',
|
||||
surfaces: ['wall', 'ceiling'],
|
||||
description: 'Smooth prepared drywall ready for paint',
|
||||
previewThumbnailUrl: '/material/concrete/prepared_drywall/prepared_drywall_thumb.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
albedoMap: '/material/concrete/prepared_drywall/prepared_drywall_basecolor_512.ktx2',
|
||||
normalMap: '/material/concrete/prepared_drywall/prepared_drywall_normal_512.ktx2',
|
||||
roughnessMap: '/material/concrete/prepared_drywall/prepared_drywall_roughness_512.ktx2',
|
||||
aoMap: '/material/concrete/prepared_drywall/prepared_drywall_ao_512.ktx2',
|
||||
},
|
||||
mapProperties: {
|
||||
color: '#ffffff',
|
||||
roughness: 0.9,
|
||||
metalness: 0,
|
||||
repeatX: 0.5,
|
||||
repeatY: 0.5,
|
||||
rotation: 0,
|
||||
wrapS: 'Repeat',
|
||||
wrapT: 'Repeat',
|
||||
normalScaleX: 1,
|
||||
normalScaleY: 1,
|
||||
emissiveIntensity: 1,
|
||||
displacementScale: 0,
|
||||
transparent: false,
|
||||
flipY: false,
|
||||
bumpScale: 1,
|
||||
emissiveColor: '#000000',
|
||||
aoMapIntensity: 1,
|
||||
side: 0,
|
||||
opacity: 1,
|
||||
lightMapIntensity: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'metal-copper',
|
||||
label: 'Copper',
|
||||
|
||||
@@ -52,8 +52,8 @@ export type WallSurfaceSide = 'interior' | 'exterior'
|
||||
// May be a `#rrggbb` colour or a `library:<id>` ref. Textures-off still
|
||||
// collapses to the themed wall role (the escape hatch).
|
||||
export const WALL_SLOT_DEFAULT: Record<WallSurfaceSide, string> = {
|
||||
interior: 'library:concrete-plate',
|
||||
exterior: 'library:concrete-plate',
|
||||
interior: 'library:concrete-drywall',
|
||||
exterior: 'library:concrete-drywall',
|
||||
}
|
||||
|
||||
export type WallSurfaceMaterialSpec = {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -150,15 +150,17 @@ function getStyleDefaults(style: FenceNode['style']) {
|
||||
return { spacingFactor: 0.3, postFactor: 0.55, baseFactor: 1, topFactor: 0.75 }
|
||||
}
|
||||
|
||||
export type FenceSlotParts = {
|
||||
/** Posts, base/kickboard, and vertical infill — the fence body. */
|
||||
panel: FencePart[]
|
||||
/** Top rail (and the floating style's matching bottom rail). */
|
||||
rail: FencePart[]
|
||||
}
|
||||
// Paint slots map 1:1 to the fence panel's build options (Structure + the
|
||||
// showInfill toggle): the end posts, the infill slats between them, the base
|
||||
// kickboard, and the top rail.
|
||||
export type FenceSlotId = 'posts' | 'infill' | 'base' | 'rail'
|
||||
|
||||
export type FenceSlotParts = Record<FenceSlotId, FencePart[]>
|
||||
|
||||
function createFenceParts(fence: FenceNode): FenceSlotParts {
|
||||
const panel: FencePart[] = []
|
||||
const posts: FencePart[] = []
|
||||
const infill: FencePart[] = []
|
||||
const base: FencePart[] = []
|
||||
const rail: FencePart[] = []
|
||||
const length = Math.max(getWallCurveLength(fence), 0.01)
|
||||
const panelDepth = Math.max(fence.thickness, 0.03)
|
||||
@@ -178,7 +180,7 @@ function createFenceParts(fence: FenceNode): FenceSlotParts {
|
||||
const endInsetT = Math.max(0.501, 1 - edgeInset / length)
|
||||
|
||||
if (!isFloating) {
|
||||
panel.push(
|
||||
base.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
@@ -188,7 +190,7 @@ function createFenceParts(fence: FenceNode): FenceSlotParts {
|
||||
panelDepth * 1.05,
|
||||
),
|
||||
)
|
||||
panel.push(
|
||||
base.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
@@ -213,7 +215,9 @@ function createFenceParts(fence: FenceNode): FenceSlotParts {
|
||||
: verticalHeight
|
||||
const postY = fullHeightPost ? postHeight / 2 : verticalY
|
||||
|
||||
panel.push({
|
||||
// End posts are the structural `posts` slot; the intermediate verticals are
|
||||
// the `infill` slats (only present when showInfill adds them).
|
||||
;(isEdgePost ? posts : infill).push({
|
||||
position: [frame.point.x, postY, frame.point.y],
|
||||
rotationY: -frame.tangentAngle,
|
||||
scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)],
|
||||
@@ -244,7 +248,7 @@ function createFenceParts(fence: FenceNode): FenceSlotParts {
|
||||
)
|
||||
}
|
||||
|
||||
return { panel, rail }
|
||||
return { posts, infill, base, rail }
|
||||
}
|
||||
|
||||
function mergeFenceParts(parts: FencePart[]): THREE.BufferGeometry {
|
||||
@@ -262,21 +266,26 @@ function mergeFenceParts(parts: FencePart[]): THREE.BufferGeometry {
|
||||
}
|
||||
|
||||
/**
|
||||
* Geometry split by paint slot: the body (posts / base / infill) and the rail
|
||||
* cap, each a separate merged BufferGeometry so the fence renderer can give
|
||||
* each its own material + `userData.slotId`.
|
||||
* Geometry split by paint slot — posts, infill, base, rail — each a separate
|
||||
* merged BufferGeometry (empty ones included) so the fence renderer can give
|
||||
* each its own material + `userData.slotId`. Slots match the panel's build
|
||||
* options 1:1.
|
||||
*/
|
||||
export function generateFenceSlotGeometries(fence: FenceNode): {
|
||||
panel: THREE.BufferGeometry
|
||||
rail: THREE.BufferGeometry
|
||||
} {
|
||||
const { panel, rail } = createFenceParts(fence)
|
||||
return { panel: mergeFenceParts(panel), rail: mergeFenceParts(rail) }
|
||||
export function generateFenceSlotGeometries(
|
||||
fence: FenceNode,
|
||||
): Record<FenceSlotId, THREE.BufferGeometry> {
|
||||
const parts = createFenceParts(fence)
|
||||
return {
|
||||
posts: mergeFenceParts(parts.posts),
|
||||
infill: mergeFenceParts(parts.infill),
|
||||
base: mergeFenceParts(parts.base),
|
||||
rail: mergeFenceParts(parts.rail),
|
||||
}
|
||||
}
|
||||
|
||||
export function generateFenceGeometry(fence: FenceNode) {
|
||||
const { panel, rail } = createFenceParts(fence)
|
||||
return mergeFenceParts([...panel, ...rail])
|
||||
const { posts, infill, base, rail } = createFenceParts(fence)
|
||||
return mergeFenceParts([...posts, ...infill, ...base, ...rail])
|
||||
}
|
||||
|
||||
function updateFenceGeometry(fenceId: FenceNode['id']) {
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
|
||||
// Declared catalog defaults for an unpainted roof, per the 4-slot layout
|
||||
// (0 wall/trim · 1 deck · 2 interior soffit · 3 shingle top). The wall/trim
|
||||
// band mirrors the wall kind's default (WALL_SLOT_DEFAULT = concrete-plate) so
|
||||
// a roof reads as continuous with the walls below it.
|
||||
// band mirrors the wall kind's default (WALL_SLOT_DEFAULT = concrete-drywall)
|
||||
// so a roof reads as continuous with the walls below it.
|
||||
const ROOF_DEFAULT_REFS: [string, string, string, string] = [
|
||||
'library:concrete-plate',
|
||||
'library:concrete-drywall',
|
||||
'library:preset-softwhite',
|
||||
'library:preset-softwhite',
|
||||
'library:roof-terracottatiles',
|
||||
|
||||
Reference in New Issue
Block a user