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',
|
id: 'metal-copper',
|
||||||
label: '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
|
// May be a `#rrggbb` colour or a `library:<id>` ref. Textures-off still
|
||||||
// collapses to the themed wall role (the escape hatch).
|
// collapses to the themed wall role (the escape hatch).
|
||||||
export const WALL_SLOT_DEFAULT: Record<WallSurfaceSide, string> = {
|
export const WALL_SLOT_DEFAULT: Record<WallSurfaceSide, string> = {
|
||||||
interior: 'library:concrete-plate',
|
interior: 'library:concrete-drywall',
|
||||||
exterior: 'library:concrete-plate',
|
exterior: 'library:concrete-drywall',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WallSurfaceMaterialSpec = {
|
export type WallSurfaceMaterialSpec = {
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
|
|||||||
surfaces: { sides: { faces: 'all' } },
|
surfaces: { sides: { faces: 'all' } },
|
||||||
duplicable: true,
|
duplicable: true,
|
||||||
deletable: true,
|
deletable: true,
|
||||||
slots: () => fenceSlots(),
|
slots: (node) => fenceSlots(node as FenceNodeType),
|
||||||
paint: fencePaint,
|
paint: fencePaint,
|
||||||
// Placed by drawing the span with the two-click tool; a saved preset
|
// Placed by drawing the span with the two-click tool; a saved preset
|
||||||
// seeds its build parameters via `toolDefaults.fence` (see `tool.tsx`
|
// seeds its build parameters via `toolDefaults.fence` (see `tool.tsx`
|
||||||
|
|||||||
@@ -12,18 +12,18 @@ import {
|
|||||||
} from '@pascal-app/viewer'
|
} from '@pascal-app/viewer'
|
||||||
import { FrontSide, Group, type Material, Mesh, type Texture } from 'three'
|
import { FrontSide, Group, type Material, Mesh, type Texture } from 'three'
|
||||||
import type { FenceNode } from './schema'
|
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 —
|
* Stage B builder for fence. Splits the geometry into four paintable slots —
|
||||||
* `panel` (posts / base / infill) and `rail` (the cap rail) — each its own Mesh
|
* `posts`, `infill`, `base`, `rail` (matching the build options in the panel) —
|
||||||
* with a `userData.slotId` so the unified slot paint resolves and previews per
|
* each its own Mesh with a `userData.slotId` so the unified slot paint resolves
|
||||||
* part.
|
* and previews per part. Empty groups (no infill / floating base) are skipped.
|
||||||
*
|
*
|
||||||
* Per slot the material resolves: `node.slots[slotId]` (a shared scene material
|
* Per slot the material resolves: `node.slots[slotId]` (a shared scene material
|
||||||
* or `library:` finish) → the legacy inline `node.material` / `materialPreset`
|
* or `library:` finish) → the legacy inline `node.material` / `materialPreset`
|
||||||
* (pre-slot-model scenes, applied to both parts) → the declared slot default.
|
* (pre-slot-model scenes, applied to every part) → the declared slot default.
|
||||||
* Textures-off collapses both parts to the themed joinery role.
|
* Textures-off collapses every part to the themed joinery role.
|
||||||
*
|
*
|
||||||
* Phase 6 cleanup moves the geometry math out of the legacy
|
* Phase 6 cleanup moves the geometry math out of the legacy
|
||||||
* `viewer/src/systems/fence/fence-system.tsx` into this folder once the legacy
|
* `viewer/src/systems/fence/fence-system.tsx` into this folder once the legacy
|
||||||
@@ -37,10 +37,7 @@ type FenceMaterial = Material & {
|
|||||||
transparent: boolean
|
transparent: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SLOT_DEFAULTS: Record<FenceSlotId, string> = {
|
const FENCE_SLOT_ORDER: FenceSlotId[] = ['posts', 'infill', 'base', 'rail']
|
||||||
panel: FENCE_PANEL_SLOT_DEFAULT,
|
|
||||||
rail: FENCE_RAIL_SLOT_DEFAULT,
|
|
||||||
}
|
|
||||||
|
|
||||||
const fenceMaterialCache = new Map<string, Material>()
|
const fenceMaterialCache = new Map<string, Material>()
|
||||||
|
|
||||||
@@ -67,7 +64,7 @@ function getFenceSlotMaterial(
|
|||||||
return getLegacyFenceMaterial(node, shading)
|
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 {
|
function getLegacyFenceMaterial(node: FenceNode, shading: RenderShading): Material {
|
||||||
@@ -113,7 +110,7 @@ export function buildFenceGeometry(
|
|||||||
const group = new Group()
|
const group = new Group()
|
||||||
const geometries = generateFenceSlotGeometries(node)
|
const geometries = generateFenceSlotGeometries(node)
|
||||||
|
|
||||||
for (const slotId of ['panel', 'rail'] as const) {
|
for (const slotId of FENCE_SLOT_ORDER) {
|
||||||
const geometry = geometries[slotId]
|
const geometry = geometries[slotId]
|
||||||
if (geometry.getAttribute('position') === undefined) continue
|
if (geometry.getAttribute('position') === undefined) continue
|
||||||
const material = getFenceSlotMaterial(
|
const material = getFenceSlotMaterial(
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { AnyNode, FenceNode, PaintResolveArgs } from '@pascal-app/core'
|
import type { AnyNode, FenceNode, PaintResolveArgs } from '@pascal-app/core'
|
||||||
import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint'
|
import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint'
|
||||||
|
|
||||||
|
const FENCE_SLOT_IDS = new Set<string>(['posts', 'infill', 'base', 'rail'])
|
||||||
|
|
||||||
function resolveFenceRole(args: PaintResolveArgs): string | null {
|
function resolveFenceRole(args: PaintResolveArgs): string | null {
|
||||||
const slotId = (args.hitObject?.userData as { slotId?: unknown } | undefined)?.slotId
|
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({
|
export const fencePaint = createSlotPaintCapability({
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
import type { SlotDeclaration } from '@pascal-app/core'
|
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
|
export const FENCE_POSTS_SLOT_DEFAULT = 'library:preset-charcoal'
|
||||||
// cap as wood. Both are repaintable per slot.
|
export const FENCE_INFILL_SLOT_DEFAULT = 'library:preset-charcoal'
|
||||||
export const FENCE_PANEL_SLOT_DEFAULT = 'library:preset-charcoal'
|
export const FENCE_BASE_SLOT_DEFAULT = 'library:preset-charcoal'
|
||||||
export const FENCE_RAIL_SLOT_DEFAULT = 'library:wood-finewood27'
|
export const FENCE_RAIL_SLOT_DEFAULT = 'library:wood-finewood27'
|
||||||
|
|
||||||
export function fenceSlots(): SlotDeclaration[] {
|
export const FENCE_SLOT_DEFAULTS: Record<FenceSlotId, string> = {
|
||||||
return [
|
posts: FENCE_POSTS_SLOT_DEFAULT,
|
||||||
{ slotId: 'panel', label: 'Panel', default: FENCE_PANEL_SLOT_DEFAULT },
|
infill: FENCE_INFILL_SLOT_DEFAULT,
|
||||||
{ slotId: 'rail', label: 'Rail', default: FENCE_RAIL_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
|
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.
|
// 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), // 1: Deck
|
||||||
resolveSlotDefaultMaterial('library:preset-softwhite', shading), // 2: Interior
|
resolveSlotDefaultMaterial('library:preset-softwhite', shading), // 2: Interior
|
||||||
resolveSlotDefaultMaterial('library:roof-terracottatiles', shading), // 3: Shingle
|
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 }
|
return { spacingFactor: 0.3, postFactor: 0.55, baseFactor: 1, topFactor: 0.75 }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FenceSlotParts = {
|
// Paint slots map 1:1 to the fence panel's build options (Structure + the
|
||||||
/** Posts, base/kickboard, and vertical infill — the fence body. */
|
// showInfill toggle): the end posts, the infill slats between them, the base
|
||||||
panel: FencePart[]
|
// kickboard, and the top rail.
|
||||||
/** Top rail (and the floating style's matching bottom rail). */
|
export type FenceSlotId = 'posts' | 'infill' | 'base' | 'rail'
|
||||||
rail: FencePart[]
|
|
||||||
}
|
export type FenceSlotParts = Record<FenceSlotId, FencePart[]>
|
||||||
|
|
||||||
function createFenceParts(fence: FenceNode): FenceSlotParts {
|
function createFenceParts(fence: FenceNode): FenceSlotParts {
|
||||||
const panel: FencePart[] = []
|
const posts: FencePart[] = []
|
||||||
|
const infill: FencePart[] = []
|
||||||
|
const base: FencePart[] = []
|
||||||
const rail: FencePart[] = []
|
const rail: FencePart[] = []
|
||||||
const length = Math.max(getWallCurveLength(fence), 0.01)
|
const length = Math.max(getWallCurveLength(fence), 0.01)
|
||||||
const panelDepth = Math.max(fence.thickness, 0.03)
|
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)
|
const endInsetT = Math.max(0.501, 1 - edgeInset / length)
|
||||||
|
|
||||||
if (!isFloating) {
|
if (!isFloating) {
|
||||||
panel.push(
|
base.push(
|
||||||
...createFenceCurveSpanParts(
|
...createFenceCurveSpanParts(
|
||||||
fence,
|
fence,
|
||||||
0,
|
0,
|
||||||
@@ -188,7 +190,7 @@ function createFenceParts(fence: FenceNode): FenceSlotParts {
|
|||||||
panelDepth * 1.05,
|
panelDepth * 1.05,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
panel.push(
|
base.push(
|
||||||
...createFenceCurveSpanParts(
|
...createFenceCurveSpanParts(
|
||||||
fence,
|
fence,
|
||||||
0,
|
0,
|
||||||
@@ -213,7 +215,9 @@ function createFenceParts(fence: FenceNode): FenceSlotParts {
|
|||||||
: verticalHeight
|
: verticalHeight
|
||||||
const postY = fullHeightPost ? postHeight / 2 : verticalY
|
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],
|
position: [frame.point.x, postY, frame.point.y],
|
||||||
rotationY: -frame.tangentAngle,
|
rotationY: -frame.tangentAngle,
|
||||||
scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)],
|
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 {
|
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
|
* Geometry split by paint slot — posts, infill, base, rail — each a separate
|
||||||
* cap, each a separate merged BufferGeometry so the fence renderer can give
|
* merged BufferGeometry (empty ones included) so the fence renderer can give
|
||||||
* each its own material + `userData.slotId`.
|
* each its own material + `userData.slotId`. Slots match the panel's build
|
||||||
|
* options 1:1.
|
||||||
*/
|
*/
|
||||||
export function generateFenceSlotGeometries(fence: FenceNode): {
|
export function generateFenceSlotGeometries(
|
||||||
panel: THREE.BufferGeometry
|
fence: FenceNode,
|
||||||
rail: THREE.BufferGeometry
|
): Record<FenceSlotId, THREE.BufferGeometry> {
|
||||||
} {
|
const parts = createFenceParts(fence)
|
||||||
const { panel, rail } = createFenceParts(fence)
|
return {
|
||||||
return { panel: mergeFenceParts(panel), rail: mergeFenceParts(rail) }
|
posts: mergeFenceParts(parts.posts),
|
||||||
|
infill: mergeFenceParts(parts.infill),
|
||||||
|
base: mergeFenceParts(parts.base),
|
||||||
|
rail: mergeFenceParts(parts.rail),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateFenceGeometry(fence: FenceNode) {
|
export function generateFenceGeometry(fence: FenceNode) {
|
||||||
const { panel, rail } = createFenceParts(fence)
|
const { posts, infill, base, rail } = createFenceParts(fence)
|
||||||
return mergeFenceParts([...panel, ...rail])
|
return mergeFenceParts([...posts, ...infill, ...base, ...rail])
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFenceGeometry(fenceId: FenceNode['id']) {
|
function updateFenceGeometry(fenceId: FenceNode['id']) {
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ import {
|
|||||||
|
|
||||||
// Declared catalog defaults for an unpainted roof, per the 4-slot layout
|
// 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
|
// (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
|
// band mirrors the wall kind's default (WALL_SLOT_DEFAULT = concrete-drywall)
|
||||||
// a roof reads as continuous with the walls below it.
|
// so a roof reads as continuous with the walls below it.
|
||||||
const ROOF_DEFAULT_REFS: [string, string, string, string] = [
|
const ROOF_DEFAULT_REFS: [string, string, string, string] = [
|
||||||
'library:concrete-plate',
|
'library:concrete-drywall',
|
||||||
'library:preset-softwhite',
|
'library:preset-softwhite',
|
||||||
'library:preset-softwhite',
|
'library:preset-softwhite',
|
||||||
'library:roof-terracottatiles',
|
'library:roof-terracottatiles',
|
||||||
|
|||||||
Reference in New Issue
Block a user