Add curved fence support
This commit is contained in:
@@ -13,6 +13,7 @@ export const FenceNode = BaseNode.extend({
|
||||
materialPreset: z.string().optional(),
|
||||
start: z.tuple([z.number(), z.number()]),
|
||||
end: z.tuple([z.number(), z.number()]),
|
||||
curveOffset: z.number().optional(),
|
||||
height: z.number().default(1.8),
|
||||
thickness: z.number().default(0.08),
|
||||
baseHeight: z.number().default(0.22),
|
||||
@@ -28,6 +29,7 @@ export const FenceNode = BaseNode.extend({
|
||||
dedent`
|
||||
Fence node - used to represent a fence segment in the building/site level coordinate system
|
||||
- start/end: fence endpoints in level coordinate system
|
||||
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
|
||||
- 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
|
||||
|
||||
@@ -4,12 +4,90 @@ 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'
|
||||
import { getWallCurveFrameAt, getWallCurveLength } from '../wall/wall-curve'
|
||||
|
||||
type FencePart = {
|
||||
position: [number, number, number]
|
||||
rotationY?: number
|
||||
scale: [number, number, number]
|
||||
}
|
||||
|
||||
const MIN_CURVE_SEGMENT_LENGTH = 0.18
|
||||
|
||||
function createFencePartGeometry(part: FencePart) {
|
||||
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
||||
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
|
||||
if (part.rotationY) {
|
||||
geometry.rotateY(part.rotationY)
|
||||
}
|
||||
geometry.translate(part.position[0], part.position[1], part.position[2])
|
||||
applyFenceUVs(geometry)
|
||||
return geometry
|
||||
}
|
||||
|
||||
function getFencePointAt(fence: FenceNode, t: number) {
|
||||
const frame = getWallCurveFrameAt(fence, t)
|
||||
return {
|
||||
point: frame.point,
|
||||
tangentAngle: Math.atan2(frame.tangent.y, frame.tangent.x),
|
||||
}
|
||||
}
|
||||
|
||||
function createStraightFenceSpanPart(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
centerY: number,
|
||||
height: number,
|
||||
depth: number,
|
||||
): FencePart | null {
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[1] - start[1]
|
||||
const length = Math.hypot(dx, dz)
|
||||
if (length <= 1e-4) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
position: [(start[0] + end[0]) / 2, centerY, (start[1] + end[1]) / 2],
|
||||
rotationY: -Math.atan2(dz, dx),
|
||||
scale: [length, height, depth],
|
||||
}
|
||||
}
|
||||
|
||||
function createFenceCurveSpanParts(
|
||||
fence: FenceNode,
|
||||
startT: number,
|
||||
endT: number,
|
||||
centerY: number,
|
||||
height: number,
|
||||
depth: number,
|
||||
): FencePart[] {
|
||||
const parts: FencePart[] = []
|
||||
const frameCount = Math.max(
|
||||
1,
|
||||
Math.ceil((getWallCurveLength(fence) * Math.max(1e-4, endT - startT)) / MIN_CURVE_SEGMENT_LENGTH),
|
||||
)
|
||||
|
||||
let previous = getFencePointAt(fence, startT)
|
||||
for (let index = 1; index <= frameCount; index += 1) {
|
||||
const t = startT + (endT - startT) * (index / frameCount)
|
||||
const current = getFencePointAt(fence, t)
|
||||
const segment = createStraightFenceSpanPart(
|
||||
[previous.point.x, previous.point.y],
|
||||
[current.point.x, current.point.y],
|
||||
centerY,
|
||||
height,
|
||||
depth,
|
||||
)
|
||||
if (segment) {
|
||||
parts.push(segment)
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
function applyFenceUVs(geometry: THREE.BufferGeometry) {
|
||||
const position = geometry.getAttribute('position')
|
||||
const normal = geometry.getAttribute('normal')
|
||||
@@ -71,10 +149,7 @@ function getStyleDefaults(style: FenceNode['style']) {
|
||||
|
||||
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 length = Math.max(getWallCurveLength(fence), 0.01)
|
||||
const panelDepth = Math.max(fence.thickness, 0.03)
|
||||
const clearance = Math.max(fence.groundClearance, 0)
|
||||
const styleDefaults = getStyleDefaults(fence.style)
|
||||
@@ -87,31 +162,39 @@ function createFenceParts(fence: FenceNode): FencePart[] {
|
||||
const isFloating = fence.baseStyle === 'floating'
|
||||
const baseY = isFloating ? clearance : 0
|
||||
const effectiveBaseHeight = baseHeight
|
||||
const startInsetT = Math.min(0.499, edgeInset / length)
|
||||
const endInsetT = Math.max(0.501, 1 - edgeInset / length)
|
||||
|
||||
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],
|
||||
})
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight / 2,
|
||||
effectiveBaseHeight,
|
||||
panelDepth * 1.05,
|
||||
),
|
||||
)
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight + verticalHeight * 0.15,
|
||||
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 t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1))
|
||||
const frame = getFencePointAt(fence, t)
|
||||
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
|
||||
@@ -119,21 +202,34 @@ function createFenceParts(fence: FenceNode): FencePart[] {
|
||||
const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY
|
||||
|
||||
parts.push({
|
||||
position: [posX, postY, 0],
|
||||
position: [frame.point.x, postY, frame.point.y],
|
||||
rotationY: -frame.tangentAngle,
|
||||
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)],
|
||||
})
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2,
|
||||
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)],
|
||||
})
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight + topRailHeight / 2,
|
||||
topRailHeight,
|
||||
Math.max(panelDepth * 0.55, 0.018),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return parts
|
||||
@@ -141,13 +237,7 @@ function createFenceParts(fence: FenceNode): FencePart[] {
|
||||
|
||||
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])
|
||||
applyFenceUVs(geometry)
|
||||
geometry.translate(part.position[0], part.position[1], part.position[2])
|
||||
return geometry
|
||||
})
|
||||
const geometries = parts.map(createFencePartGeometry)
|
||||
|
||||
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
|
||||
geometries.forEach((geometry) => geometry.dispose())
|
||||
@@ -169,12 +259,8 @@ function updateFenceGeometry(fenceId: FenceNode['id']) {
|
||||
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)
|
||||
mesh.position.set(0, 0, 0)
|
||||
mesh.rotation.set(0, 0, 0)
|
||||
}
|
||||
|
||||
export const FenceSystem = () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Point2D } from './wall-mitering'
|
||||
import type { WallNode } from '../../schema'
|
||||
import type { FenceNode, WallNode } from '../../schema'
|
||||
|
||||
const CURVE_EPSILON = 1e-6
|
||||
const DEFAULT_SAMPLE_SEGMENTS = 24
|
||||
|
||||
type WallCurveLike = Pick<WallNode, 'start' | 'end' | 'curveOffset'>
|
||||
type WallCurveLike = Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset'>
|
||||
|
||||
type CurveFrame = {
|
||||
point: Point2D
|
||||
@@ -198,7 +198,7 @@ export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPL
|
||||
}
|
||||
|
||||
export function getWallSurfacePolygon(
|
||||
wall: Pick<WallNode, 'start' | 'end' | 'curveOffset' | 'thickness'>,
|
||||
wall: Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset' | 'thickness'>,
|
||||
segments = DEFAULT_SAMPLE_SEGMENTS,
|
||||
miterOverrides?: WallSurfaceMiterOverrides,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user