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
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 778 B |
@@ -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