diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index 1818d0075..641fd3d00 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -3,8 +3,9 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' -export const FenceStyle = z.enum(['slat', 'rail', 'privacy']) +export const FenceStyle = z.enum(['slat', 'rail', 'privacy', 'horizontal']) export const FenceBaseStyle = z.enum(['floating', 'grounded']) +export const FencePostCap = z.enum(['none', 'flat', 'pyramid']) export const FenceNode = BaseNode.extend({ id: objectId('fence'), @@ -25,6 +26,10 @@ export const FenceNode = BaseNode.extend({ topRailHeight: z.number().default(0.04), groundClearance: z.number().default(0), edgeInset: z.number().default(0.015), + // Reveal between the boards of a `horizontal` fence (0 = flush cladding). + slatGap: z.number().default(0.01), + // Topper drawn on each `horizontal`-fence post. + postCap: FencePostCap.default('pyramid'), baseStyle: FenceBaseStyle.default('grounded'), showInfill: z.boolean().default(true), color: z.string().default('#ffffff'), diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts index f184cf354..64f4fe339 100644 --- a/packages/nodes/src/fence/definition.ts +++ b/packages/nodes/src/fence/definition.ts @@ -154,6 +154,8 @@ export const fenceDefinition: NodeDefinition = { topRailHeight: 0.04, groundClearance: 0, edgeInset: 0.015, + slatGap: 0.01, + postCap: 'pyramid', baseStyle: 'grounded', showInfill: true, color: '#ffffff', diff --git a/packages/nodes/src/fence/floorplan.ts b/packages/nodes/src/fence/floorplan.ts index 11e35c0c0..17297f8fe 100644 --- a/packages/nodes/src/fence/floorplan.ts +++ b/packages/nodes/src/fence/floorplan.ts @@ -20,7 +20,7 @@ import type { FenceNode } from './schema' * b. White underlay — the visual "fence body" base layer. * c. Dark accent — the actual fence outline. * 2. Style-aware markers at computed positions along the centerline: - * - `privacy`: rotated rectangle (vertical slat). + * - `privacy` / `horizontal`: rotated rectangle (solid panel). * - `rail`: concentric circle stack (post + ring + tiny center). * - default `slat`: white X mark with a coloured X on top. * 3. Markers thinned when `showInfill === false` — only first + last @@ -89,7 +89,7 @@ function buildMarker( ): FloorplanGeometry { const markerStrokeWidth = isActive ? 1.65 : 1.35 - if (fence.style === 'privacy') { + if (fence.style === 'privacy' || fence.style === 'horizontal') { const w = clamp(fence.postSize * 0.58, 0.038, 0.068) const h = clamp(Math.max(fence.baseHeight * 0.5, fence.postSize * 1.4), 0.1, 0.17) // Surface plate underneath + accent rectangle on top — gives a clean diff --git a/packages/nodes/src/fence/parametrics.ts b/packages/nodes/src/fence/parametrics.ts index 830cad560..59f3bcd2b 100644 --- a/packages/nodes/src/fence/parametrics.ts +++ b/packages/nodes/src/fence/parametrics.ts @@ -9,7 +9,7 @@ import type { FenceNode } from './schema' * - **Dimensions**: Length (derived from start/end), Curve (sagitta * with dynamic bounds), Height, Thickness. * - **Structure**: Base Height, Top Rail, Post Spacing, Post Size, - * Ground Clear, Edge Inset. + * Post Cap + Slat Gap (horizontal-only), Ground Clear, Edge Inset. * * Length + Curve use the `custom` field kind because they don't map * to single number fields with static bounds — see `inspector-editors.tsx`. @@ -22,7 +22,7 @@ export const fenceParametrics: ParametricDescriptor = { { key: 'style', kind: 'enum', - options: ['slat', 'rail', 'privacy'], + options: ['slat', 'rail', 'privacy', 'horizontal'], display: 'segmented', }, { @@ -48,8 +48,26 @@ export const fenceParametrics: ParametricDescriptor = { fields: [ { key: 'baseHeight', kind: 'number', unit: 'm', min: 0.04, max: 1, step: 0.01 }, { key: 'topRailHeight', kind: 'number', unit: 'm', min: 0.01, max: 0.25, step: 0.005 }, - { key: 'postSpacing', kind: 'number', unit: 'm', min: 0.2, max: 5, step: 0.05 }, + { key: 'postSpacing', kind: 'number', unit: 'm', min: 0.05, max: 5, step: 0.01 }, { key: 'postSize', kind: 'number', unit: 'm', min: 0.01, max: 0.4, step: 0.005 }, + { + // Dropdown (not segmented) so the inspector renders its "Post Cap" + // label — a bare segmented `None / Flat / Pyramid` switch reads + // contextless. + key: 'postCap', + kind: 'enum', + options: ['none', 'flat', 'pyramid'], + visibleIf: (n) => n.style === 'horizontal', + }, + { + key: 'slatGap', + kind: 'number', + unit: 'm', + min: 0, + max: 0.1, + step: 0.002, + visibleIf: (n) => n.style === 'horizontal', + }, { key: 'groundClearance', kind: 'number', unit: 'm', min: 0, max: 0.6, step: 0.005 }, { key: 'edgeInset', kind: 'number', unit: 'm', min: 0.005, max: 0.25, step: 0.005 }, ], diff --git a/packages/viewer/src/systems/fence/fence-system.tsx b/packages/viewer/src/systems/fence/fence-system.tsx index b82b1fdd0..f60ca60e1 100644 --- a/packages/viewer/src/systems/fence/fence-system.tsx +++ b/packages/viewer/src/systems/fence/fence-system.tsx @@ -14,12 +14,18 @@ type FencePart = { position: [number, number, number] rotationY?: number scale: [number, number, number] + // A `pyramid` part is a 4-sided cone (square base aligned to the part axes), + // used for peaked post caps. Defaults to a box. + shape?: 'box' | 'pyramid' } const MIN_CURVE_SEGMENT_LENGTH = 0.18 function createFencePartGeometry(part: FencePart) { - const geometry = new THREE.BoxGeometry(1, 1, 1) + const geometry = + part.shape === 'pyramid' + ? new THREE.ConeGeometry(0.5, 1, 4, 1, false, Math.PI / 4) + : new THREE.BoxGeometry(1, 1, 1) geometry.scale(part.scale[0], part.scale[1], part.scale[2]) if (part.rotationY) { geometry.rotateY(part.rotationY) @@ -157,7 +163,126 @@ export type FenceSlotId = 'posts' | 'infill' | 'base' | 'rail' export type FenceSlotParts = Record +/** + * Horizontal-board fence — composite cladding boards stacked between square + * intermediate posts (each capped), instead of the vertical pickets the other + * styles draw. Posts march along the whole span at `postSpacing` (not just the + * two ends), the boards run full-length so they curve with the fence, and a + * thin reveal between boards leaves the groove shadow that reads as cladding. + */ +function createHorizontalFenceParts(fence: FenceNode): FenceSlotParts { + 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) + const clearance = Math.max(fence.groundClearance, 0) + const isFloating = fence.baseStyle === 'floating' + const showInfill = fence.showInfill ?? true + + const baseHeight = Math.max(fence.baseHeight, 0.04) + const topRailHeight = Math.max(fence.topRailHeight, 0.01) + const verticalHeight = Math.max(fence.height - baseHeight - topRailHeight, 0.08) + const baseY = isFloating ? clearance : 0 + + // Square posts stand proud of the recessed boards on both faces. + const postWidth = Math.max(fence.postSize * 1.4, 0.04) + const postDepth = postWidth + const boardDepth = Math.min(panelDepth, postDepth - 0.012) + + // Grounded fences get a kickboard along the bottom; floating ones don't. + if (!isFloating) { + base.push( + ...createFenceCurveSpanParts( + fence, + 0, + 1, + baseY + baseHeight / 2, + baseHeight, + postDepth * 0.92, + ), + ) + } + + // Stack full-length boards between the kickboard and the top rail. The board + // height is derived to evenly fill the panel around a ~0.145 m target, with a + // constant reveal between each so the count adapts to any fence height. + if (showInfill) { + const reveal = Math.max(fence.slatGap ?? 0.01, 0) + const infillBottom = baseY + baseHeight + if (reveal < 0.002) { + // No reveal → one flush panel, so the stacked-board edge seams don't + // read as faint lines where the user asked for a smooth surface. + infill.push( + ...createFenceCurveSpanParts( + fence, + 0, + 1, + infillBottom + verticalHeight / 2, + verticalHeight, + boardDepth, + ), + ) + } else { + const boardCount = Math.max(1, Math.round(verticalHeight / (0.145 + reveal))) + const slabHeight = Math.max((verticalHeight - reveal * (boardCount - 1)) / boardCount, 0.02) + for (let index = 0; index < boardCount; index += 1) { + const centerY = infillBottom + slabHeight / 2 + index * (slabHeight + reveal) + infill.push(...createFenceCurveSpanParts(fence, 0, 1, centerY, slabHeight, boardDepth)) + } + } + } + + // Top rail caps the boards. + rail.push( + ...createFenceCurveSpanParts( + fence, + 0, + 1, + baseY + baseHeight + verticalHeight + topRailHeight / 2, + topRailHeight, + Math.max(postDepth * 0.78, 0.02), + ), + ) + + // Posts at every `postSpacing`, anchored at both ends, each with a flat cap. + const spacing = Math.max(fence.postSpacing, postWidth * 1.4) + const postCount = Math.max(2, Math.floor(length / spacing) + 1) + const postHeight = baseHeight + verticalHeight + topRailHeight + clearance + const capHeight = Math.max(postWidth * 0.32, 0.03) + const cap = fence.postCap ?? 'pyramid' + for (let index = 0; index < postCount; index += 1) { + const t = postCount === 1 ? 0.5 : index / (postCount - 1) + const frame = getFencePointAt(fence, t) + posts.push({ + position: [frame.point.x, postHeight / 2, frame.point.y], + rotationY: -frame.tangentAngle, + scale: [postWidth, postHeight, postDepth], + }) + if (cap === 'flat') { + posts.push({ + position: [frame.point.x, postHeight + capHeight / 2, frame.point.y], + rotationY: -frame.tangentAngle, + scale: [postWidth * 1.22, capHeight, postDepth * 1.22], + }) + } else if (cap === 'pyramid') { + posts.push({ + position: [frame.point.x, postHeight + capHeight * 0.9, frame.point.y], + rotationY: -frame.tangentAngle, + scale: [postWidth * 1.18, capHeight * 1.8, postDepth * 1.18], + shape: 'pyramid', + }) + } + } + + return { posts, infill, base, rail } +} + function createFenceParts(fence: FenceNode): FenceSlotParts { + if (fence.style === 'horizontal') return createHorizontalFenceParts(fence) + const posts: FencePart[] = [] const infill: FencePart[] = [] const base: FencePart[] = []