diff --git a/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_ao_512.ktx2 b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_ao_512.ktx2 new file mode 100644 index 00000000..1f9fa9fc Binary files /dev/null and b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_ao_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_basecolor_512.ktx2 b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_basecolor_512.ktx2 new file mode 100644 index 00000000..3e265ea1 Binary files /dev/null and b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_basecolor_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_normal_512.ktx2 b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_normal_512.ktx2 new file mode 100644 index 00000000..0c700cd6 Binary files /dev/null and b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_normal_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_roughness_512.ktx2 b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_roughness_512.ktx2 new file mode 100644 index 00000000..e0804cba Binary files /dev/null and b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_roughness_512.ktx2 differ diff --git a/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_thumb.webp b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_thumb.webp new file mode 100644 index 00000000..f4c69c45 Binary files /dev/null and b/apps/editor/public/material/concrete/prepared_drywall/prepared_drywall_thumb.webp differ diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index eedf63e4..161b25b6 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -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', diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index c1d65e4b..af1b2b1f 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -52,8 +52,8 @@ export type WallSurfaceSide = 'interior' | 'exterior' // May be a `#rrggbb` colour or a `library:` ref. Textures-off still // collapses to the themed wall role (the escape hatch). export const WALL_SLOT_DEFAULT: Record = { - interior: 'library:concrete-plate', - exterior: 'library:concrete-plate', + interior: 'library:concrete-drywall', + exterior: 'library:concrete-drywall', } export type WallSurfaceMaterialSpec = { diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index 80df0eb1..be18d3c9 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -165,7 +165,7 @@ export const fenceDefinition: NodeDefinition = { 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` diff --git a/packages/nodes/src/fence/geometry.ts b/packages/nodes/src/fence/geometry.ts index dc1f52aa..e48f6355 100644 --- a/packages/nodes/src/fence/geometry.ts +++ b/packages/nodes/src/fence/geometry.ts @@ -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 = { - panel: FENCE_PANEL_SLOT_DEFAULT, - rail: FENCE_RAIL_SLOT_DEFAULT, -} +const FENCE_SLOT_ORDER: FenceSlotId[] = ['posts', 'infill', 'base', 'rail'] const fenceMaterialCache = new Map() @@ -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( diff --git a/packages/nodes/src/fence/paint.ts b/packages/nodes/src/fence/paint.ts index b1cde84a..00456382 100644 --- a/packages/nodes/src/fence/paint.ts +++ b/packages/nodes/src/fence/paint.ts @@ -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(['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({ diff --git a/packages/nodes/src/fence/slots.ts b/packages/nodes/src/fence/slots.ts index aa9b3bd7..6874ef67 100644 --- a/packages/nodes/src/fence/slots.ts +++ b/packages/nodes/src/fence/slots.ts @@ -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 = { + 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 } diff --git a/packages/nodes/src/roof/roof-materials.ts b/packages/nodes/src/roof/roof-materials.ts index daf414f3..940ae3bf 100644 --- a/packages/nodes/src/roof/roof-materials.ts +++ b/packages/nodes/src/roof/roof-materials.ts @@ -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 diff --git a/packages/viewer/src/systems/fence/fence-system.tsx b/packages/viewer/src/systems/fence/fence-system.tsx index 7bfc3bd3..9e49d91d 100644 --- a/packages/viewer/src/systems/fence/fence-system.tsx +++ b/packages/viewer/src/systems/fence/fence-system.tsx @@ -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 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 { + 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']) { diff --git a/packages/viewer/src/systems/roof/roof-materials.ts b/packages/viewer/src/systems/roof/roof-materials.ts index 85f2a4db..8b68890d 100644 --- a/packages/viewer/src/systems/roof/roof-materials.ts +++ b/packages/viewer/src/systems/roof/roof-materials.ts @@ -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',