Skip to content
Merged
Binary file added apps/editor/public/icons/fence.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/core/src/events/bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
BuildingNode,
CeilingNode,
DoorNode,
FenceNode,
ItemNode,
LevelNode,
RoofNode,
Expand Down Expand Up @@ -41,6 +42,7 @@ export interface NodeEvent<T extends AnyNode = AnyNode> {
}

export type WallEvent = NodeEvent<WallNode>
export type FenceEvent = NodeEvent<FenceNode>
export type ItemEvent = NodeEvent<ItemNode>
export type SiteEvent = NodeEvent<SiteNode>
export type BuildingEvent = NodeEvent<BuildingNode>
Expand Down Expand Up @@ -111,6 +113,7 @@ type ThumbnailEvents = {

type EditorEvents = GridEvents &
NodeEvents<'wall', WallEvent> &
NodeEvents<'fence', FenceEvent> &
NodeEvents<'item', ItemEvent> &
NodeEvents<'site', SiteEvent> &
NodeEvents<'building', BuildingEvent> &
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/hooks/scene-registry/scene-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const sceneRegistry = {
ceiling: new Set<string>(),
level: new Set<string>(),
wall: new Set<string>(),
fence: new Set<string>(),
item: new Set<string>(),
slab: new Set<string>(),
zone: new Set<string>(),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type {
CeilingEvent,
DoorEvent,
EventSuffix,
FenceEvent,
GridEvent,
ItemEvent,
LevelEvent,
Expand Down Expand Up @@ -44,6 +45,7 @@ export {
useInteractive,
} from './store/use-interactive'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { FenceSystem } from './systems/fence/fence-system'
export { clearSceneHistory, default as useScene } from './store/use-scene'
export { CeilingSystem } from './systems/ceiling/ceiling-system'
export { DoorSystem } from './systems/door/door-system'
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
export { BuildingNode } from './nodes/building'
export { CeilingNode } from './nodes/ceiling'
export { DoorNode, DoorSegment } from './nodes/door'
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
export { GuideNode } from './nodes/guide'
export type {
AnimationEffect,
Expand All @@ -36,7 +37,7 @@ export { ScanNode } from './nodes/scan'
// Nodes
export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab'
export { StairNode } from './nodes/stair'
export { StairNode, StairRailingMode, StairTopLandingMode, StairType } from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { WallNode } from './nodes/wall'
export { WindowNode } from './nodes/window'
Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/schema/nodes/fence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'

export const FenceStyle = z.enum(['slat', 'rail', 'privacy'])
export const FenceBaseStyle = z.enum(['floating', 'grounded'])

export const FenceNode = BaseNode.extend({
id: objectId('fence'),
type: nodeType('fence'),
start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]),
height: z.number().default(1.8),
thickness: z.number().default(0.08),
baseHeight: z.number().default(0.22),
postSpacing: z.number().default(2),
postSize: z.number().default(0.1),
topRailHeight: z.number().default(0.04),
groundClearance: z.number().default(0),
edgeInset: z.number().default(0.015),
baseStyle: FenceBaseStyle.default('grounded'),
color: z.string().default('#ffffff'),
style: FenceStyle.default('slat'),
}).describe(
dedent`
Fence node - used to represent a fence segment in the building/site level coordinate system
- start/end: fence endpoints in level coordinate system
- height/thickness: overall fence dimensions in meters
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
- color/style: visual appearance options
`,
)

export type FenceNode = z.infer<typeof FenceNode>
2 changes: 2 additions & 0 deletions packages/core/src/schema/nodes/level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { CeilingNode } from './ceiling'
import { FenceNode } from './fence'
import { GuideNode } from './guide'
import { RoofNode } from './roof'
import { ScanNode } from './scan'
Expand All @@ -17,6 +18,7 @@ export const LevelNode = BaseNode.extend({
.array(
z.union([
WallNode.shape.id,
FenceNode.shape.id,
ZoneNode.shape.id,
SlabNode.shape.id,
CeilingNode.shape.id,
Expand Down
42 changes: 39 additions & 3 deletions packages/core/src/schema/nodes/stair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,59 @@ import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
import { StairSegmentNode } from './stair-segment'

export const StairRailingMode = z.enum(['none', 'left', 'right', 'both'])
export const StairType = z.enum(['straight', 'curved', 'spiral'])
export const StairTopLandingMode = z.enum(['none', 'integrated'])

export type StairRailingMode = z.infer<typeof StairRailingMode>
export type StairType = z.infer<typeof StairType>
export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>

export const StairNode = BaseNode.extend({
id: objectId('stair'),
type: nodeType('stair'),
material: MaterialSchema.optional(),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
stairType: StairType.default('straight'),
width: z.number().default(1.0),
totalRise: z.number().default(2.5),
stepCount: z.number().default(10),
thickness: z.number().default(0.25),
fillToFloor: z.boolean().default(true),
innerRadius: z.number().default(0.9),
sweepAngle: z.number().default(Math.PI / 2),
topLandingMode: StairTopLandingMode.default('none'),
topLandingDepth: z.number().default(0.9),
showCenterColumn: z.boolean().default(true),
showStepSupports: z.boolean().default(true),
railingMode: StairRailingMode.default('none'),
railingHeight: z.number().default(0.92),
// Child stair segment IDs
children: z.array(StairSegmentNode.shape.id).default([]),
}).describe(
dedent`
Stair node - a container for stair segments.
Acts as a group that holds one or more StairSegmentNodes (flights and landings).
Segments chain together based on their attachmentSide to form complex staircase shapes.
Acts as a group that either holds one or more StairSegmentNodes (straight stairs)
or stores stair-level geometry properties for curved stairs.
- position: center position of the stair group
- rotation: rotation around Y axis
- children: array of StairSegmentNode IDs
- stairType: straight (segment-based), curved (arc-based), or spiral
- width: stair width
- totalRise: total stair height
- stepCount: number of visible steps
- thickness: stair slab / tread thickness
- fillToFloor: whether the stair mass fills down to the floor or uses tread thickness only
- innerRadius: inner curve radius for curved stairs
- sweepAngle: total curved stair sweep in radians
- topLandingMode: optional integrated top landing for spiral stairs
- topLandingDepth: depth used to size the integrated spiral top landing
- showCenterColumn: whether spiral stairs render a center column
- showStepSupports: whether spiral stairs render step support brackets
- railingMode: whether to render railings and on which side(s)
- railingHeight: top height of the railing above the stair surface
- children: array of StairSegmentNode IDs for straight stairs
`,
)

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/schema/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import z from 'zod'
import { BuildingNode } from './nodes/building'
import { CeilingNode } from './nodes/ceiling'
import { DoorNode } from './nodes/door'
import { FenceNode } from './nodes/fence'
import { GuideNode } from './nodes/guide'
import { ItemNode } from './nodes/item'
import { LevelNode } from './nodes/level'
Expand All @@ -21,6 +22,7 @@ export const AnyNode = z.discriminatedUnion('type', [
BuildingNode,
LevelNode,
WallNode,
FenceNode,
ItemNode,
ZoneNode,
SlabNode,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/store/use-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,8 +378,8 @@ useScene.temporal.subscribe((state) => {
// Mark sibling nodes dirty so they can update their geometry
// (e.g. adjacent walls need to recalculate miter/junction geometry)
const parent = currentNodes[parentId]
if (parent && 'children' in parent) {
for (const childId of (parent as AnyNode & { children: string[] }).children) {
if (parent && 'children' in parent && Array.isArray(parent.children)) {
for (const childId of parent.children) {
markDirty(childId as AnyNodeId)
}
}
Expand Down
145 changes: 145 additions & 0 deletions packages/core/src/systems/fence/fence-system.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import type { AnyNodeId, FenceNode } from '../../schema'
import useScene from '../../store/use-scene'

type FencePart = {
position: [number, number, number]
scale: [number, number, number]
}

function getStyleDefaults(style: FenceNode['style']) {
if (style === 'privacy') {
return { spacingFactor: 0.42, postFactor: 1.35, baseFactor: 1.2, topFactor: 1.2 }
}

if (style === 'rail') {
return { spacingFactor: 0.68, postFactor: 0.8, baseFactor: 0.85, topFactor: 0.85 }
}

return { spacingFactor: 0.3, postFactor: 0.55, baseFactor: 1, topFactor: 0.75 }
}

function createFenceParts(fence: FenceNode): FencePart[] {
const parts: FencePart[] = []
const length = Math.max(
Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]),
0.01,
)
const panelDepth = Math.max(fence.thickness, 0.03)
const clearance = Math.max(fence.groundClearance, 0)
const styleDefaults = getStyleDefaults(fence.style)
const baseHeight = Math.max(fence.baseHeight * styleDefaults.baseFactor, 0.04)
const topRailHeight = Math.max(fence.topRailHeight * styleDefaults.topFactor, 0.01)
const verticalHeight = Math.max(fence.height - baseHeight - topRailHeight, 0.08)
const postWidth = Math.max(fence.postSize * styleDefaults.postFactor, 0.01)
const spacing = Math.max(fence.postSpacing * styleDefaults.spacingFactor, postWidth * 1.2)
const edgeInset = Math.max(fence.edgeInset ?? 0.015, 0.005)
const isFloating = fence.baseStyle === 'floating'
const baseY = isFloating ? clearance : 0
const effectiveBaseHeight = baseHeight

if (!isFloating) {
parts.push({
position: [0, baseY + effectiveBaseHeight / 2, 0],
scale: [length, effectiveBaseHeight, panelDepth * 1.05],
})
parts.push({
position: [0, baseY + effectiveBaseHeight + verticalHeight * 0.15, 0],
scale: [length, topRailHeight * 0.8, panelDepth * 0.35],
})
}

const count = Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1)
const step = count > 1 ? (length - edgeInset * 2) / (count - 1) : 0
const startX = -length / 2 + edgeInset
const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2

for (let index = 0; index < count; index += 1) {
const x = count === 1 ? 0 : startX + step * index
let posX = x
const isEdgePost = index === 0 || index === count - 1
if (count > 1) {
if (index === 0) posX = -length / 2 + edgeInset + postWidth / 2
else if (index === count - 1) posX = length / 2 - edgeInset - postWidth / 2
}
const postHeight =
isFloating && isEdgePost
? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
: verticalHeight
const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY

parts.push({
position: [posX, postY, 0],
scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)],
})
}

parts.push({
position: [0, baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2, 0],
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)],
})

if (isFloating) {
parts.push({
position: [0, baseY + effectiveBaseHeight + topRailHeight / 2, 0],
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)],
})
}

return parts
}

function generateFenceGeometry(fence: FenceNode) {
const parts = createFenceParts(fence)
const geometries = parts.map((part) => {
const geometry = new THREE.BoxGeometry(1, 1, 1)
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
geometry.translate(part.position[0], part.position[1], part.position[2])
return geometry
})

const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
geometries.forEach((geometry) => geometry.dispose())
merged.computeVertexNormals()
return merged
}

function updateFenceGeometry(fenceId: FenceNode['id']) {
const node = useScene.getState().nodes[fenceId]
if (!node || node.type !== 'fence') return

const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Mesh | undefined
if (!mesh) return

const newGeometry = generateFenceGeometry(node)
mesh.geometry.dispose()
mesh.geometry = newGeometry

const centerX = (node.start[0] + node.end[0]) / 2
const centerZ = (node.start[1] + node.end[1]) / 2
const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
mesh.position.set(centerX, 0, centerZ)
mesh.rotation.set(0, -angle, 0)
}

export const FenceSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty)

useFrame(() => {
if (dirtyNodes.size === 0) return

const nodes = useScene.getState().nodes
dirtyNodes.forEach((id) => {
const node = nodes[id]
if (!node || node.type !== 'fence') return
updateFenceGeometry(id as FenceNode['id'])
clearDirty(id as AnyNodeId)
})
}, 4)

return null
}
Loading