Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/core/src/schema/nodes/fence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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'),
Expand Down
2 changes: 2 additions & 0 deletions packages/nodes/src/fence/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
topRailHeight: 0.04,
groundClearance: 0,
edgeInset: 0.015,
slatGap: 0.01,
postCap: 'pyramid',
baseStyle: 'grounded',
showInfill: true,
color: '#ffffff',
Expand Down
4 changes: 2 additions & 2 deletions packages/nodes/src/fence/floorplan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
24 changes: 21 additions & 3 deletions packages/nodes/src/fence/parametrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -22,7 +22,7 @@ export const fenceParametrics: ParametricDescriptor<FenceNode> = {
{
key: 'style',
kind: 'enum',
options: ['slat', 'rail', 'privacy'],
options: ['slat', 'rail', 'privacy', 'horizontal'],
display: 'segmented',
},
{
Expand All @@ -48,8 +48,26 @@ export const fenceParametrics: ParametricDescriptor<FenceNode> = {
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 },
],
Expand Down
127 changes: 126 additions & 1 deletion packages/viewer/src/systems/fence/fence-system.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -157,7 +163,126 @@ export type FenceSlotId = 'posts' | 'infill' | 'base' | 'rail'

export type FenceSlotParts = Record<FenceSlotId, FencePart[]>

/**
* 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[] = []
Expand Down
Loading