Add curved fence support
This commit is contained in:
@@ -13,6 +13,7 @@ export const FenceNode = BaseNode.extend({
|
|||||||
materialPreset: z.string().optional(),
|
materialPreset: z.string().optional(),
|
||||||
start: z.tuple([z.number(), z.number()]),
|
start: z.tuple([z.number(), z.number()]),
|
||||||
end: z.tuple([z.number(), z.number()]),
|
end: z.tuple([z.number(), z.number()]),
|
||||||
|
curveOffset: z.number().optional(),
|
||||||
height: z.number().default(1.8),
|
height: z.number().default(1.8),
|
||||||
thickness: z.number().default(0.08),
|
thickness: z.number().default(0.08),
|
||||||
baseHeight: z.number().default(0.22),
|
baseHeight: z.number().default(0.22),
|
||||||
@@ -28,6 +29,7 @@ export const FenceNode = BaseNode.extend({
|
|||||||
dedent`
|
dedent`
|
||||||
Fence node - used to represent a fence segment in the building/site level coordinate system
|
Fence node - used to represent a fence segment in the building/site level coordinate system
|
||||||
- start/end: fence endpoints in 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
|
- height/thickness: overall fence dimensions in meters
|
||||||
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
|
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
|
||||||
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
|
- 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 { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||||
import type { AnyNodeId, FenceNode } from '../../schema'
|
import type { AnyNodeId, FenceNode } from '../../schema'
|
||||||
import useScene from '../../store/use-scene'
|
import useScene from '../../store/use-scene'
|
||||||
|
import { getWallCurveFrameAt, getWallCurveLength } from '../wall/wall-curve'
|
||||||
|
|
||||||
type FencePart = {
|
type FencePart = {
|
||||||
position: [number, number, number]
|
position: [number, number, number]
|
||||||
|
rotationY?: number
|
||||||
scale: [number, number, 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) {
|
function applyFenceUVs(geometry: THREE.BufferGeometry) {
|
||||||
const position = geometry.getAttribute('position')
|
const position = geometry.getAttribute('position')
|
||||||
const normal = geometry.getAttribute('normal')
|
const normal = geometry.getAttribute('normal')
|
||||||
@@ -71,10 +149,7 @@ function getStyleDefaults(style: FenceNode['style']) {
|
|||||||
|
|
||||||
function createFenceParts(fence: FenceNode): FencePart[] {
|
function createFenceParts(fence: FenceNode): FencePart[] {
|
||||||
const parts: FencePart[] = []
|
const parts: FencePart[] = []
|
||||||
const length = Math.max(
|
const length = Math.max(getWallCurveLength(fence), 0.01)
|
||||||
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 panelDepth = Math.max(fence.thickness, 0.03)
|
||||||
const clearance = Math.max(fence.groundClearance, 0)
|
const clearance = Math.max(fence.groundClearance, 0)
|
||||||
const styleDefaults = getStyleDefaults(fence.style)
|
const styleDefaults = getStyleDefaults(fence.style)
|
||||||
@@ -87,31 +162,39 @@ function createFenceParts(fence: FenceNode): FencePart[] {
|
|||||||
const isFloating = fence.baseStyle === 'floating'
|
const isFloating = fence.baseStyle === 'floating'
|
||||||
const baseY = isFloating ? clearance : 0
|
const baseY = isFloating ? clearance : 0
|
||||||
const effectiveBaseHeight = baseHeight
|
const effectiveBaseHeight = baseHeight
|
||||||
|
const startInsetT = Math.min(0.499, edgeInset / length)
|
||||||
|
const endInsetT = Math.max(0.501, 1 - edgeInset / length)
|
||||||
|
|
||||||
if (!isFloating) {
|
if (!isFloating) {
|
||||||
parts.push({
|
parts.push(
|
||||||
position: [0, baseY + effectiveBaseHeight / 2, 0],
|
...createFenceCurveSpanParts(
|
||||||
scale: [length, effectiveBaseHeight, panelDepth * 1.05],
|
fence,
|
||||||
})
|
0,
|
||||||
parts.push({
|
1,
|
||||||
position: [0, baseY + effectiveBaseHeight + verticalHeight * 0.15, 0],
|
baseY + effectiveBaseHeight / 2,
|
||||||
scale: [length, topRailHeight * 0.8, panelDepth * 0.35],
|
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 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
|
const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2
|
||||||
|
|
||||||
for (let index = 0; index < count; index += 1) {
|
for (let index = 0; index < count; index += 1) {
|
||||||
const x = count === 1 ? 0 : startX + step * index
|
const t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1))
|
||||||
let posX = x
|
const frame = getFencePointAt(fence, t)
|
||||||
const isEdgePost = index === 0 || index === count - 1
|
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 =
|
const postHeight =
|
||||||
isFloating && isEdgePost
|
isFloating && isEdgePost
|
||||||
? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
|
? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
|
||||||
@@ -119,21 +202,34 @@ function createFenceParts(fence: FenceNode): FencePart[] {
|
|||||||
const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY
|
const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY
|
||||||
|
|
||||||
parts.push({
|
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)],
|
scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push({
|
parts.push(
|
||||||
position: [0, baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2, 0],
|
...createFenceCurveSpanParts(
|
||||||
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)],
|
fence,
|
||||||
})
|
0,
|
||||||
|
1,
|
||||||
|
baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2,
|
||||||
|
topRailHeight,
|
||||||
|
Math.max(panelDepth * 0.55, 0.018),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if (isFloating) {
|
if (isFloating) {
|
||||||
parts.push({
|
parts.push(
|
||||||
position: [0, baseY + effectiveBaseHeight + topRailHeight / 2, 0],
|
...createFenceCurveSpanParts(
|
||||||
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)],
|
fence,
|
||||||
})
|
0,
|
||||||
|
1,
|
||||||
|
baseY + effectiveBaseHeight + topRailHeight / 2,
|
||||||
|
topRailHeight,
|
||||||
|
Math.max(panelDepth * 0.55, 0.018),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return parts
|
return parts
|
||||||
@@ -141,13 +237,7 @@ function createFenceParts(fence: FenceNode): FencePart[] {
|
|||||||
|
|
||||||
function generateFenceGeometry(fence: FenceNode) {
|
function generateFenceGeometry(fence: FenceNode) {
|
||||||
const parts = createFenceParts(fence)
|
const parts = createFenceParts(fence)
|
||||||
const geometries = parts.map((part) => {
|
const geometries = parts.map(createFencePartGeometry)
|
||||||
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 merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
|
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
|
||||||
geometries.forEach((geometry) => geometry.dispose())
|
geometries.forEach((geometry) => geometry.dispose())
|
||||||
@@ -169,12 +259,8 @@ function updateFenceGeometry(fenceId: FenceNode['id']) {
|
|||||||
const newGeometry = generateFenceGeometry(node)
|
const newGeometry = generateFenceGeometry(node)
|
||||||
mesh.geometry.dispose()
|
mesh.geometry.dispose()
|
||||||
mesh.geometry = newGeometry
|
mesh.geometry = newGeometry
|
||||||
|
mesh.position.set(0, 0, 0)
|
||||||
const centerX = (node.start[0] + node.end[0]) / 2
|
mesh.rotation.set(0, 0, 0)
|
||||||
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 = () => {
|
export const FenceSystem = () => {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { Point2D } from './wall-mitering'
|
import type { Point2D } from './wall-mitering'
|
||||||
import type { WallNode } from '../../schema'
|
import type { FenceNode, WallNode } from '../../schema'
|
||||||
|
|
||||||
const CURVE_EPSILON = 1e-6
|
const CURVE_EPSILON = 1e-6
|
||||||
const DEFAULT_SAMPLE_SEGMENTS = 24
|
const DEFAULT_SAMPLE_SEGMENTS = 24
|
||||||
|
|
||||||
type WallCurveLike = Pick<WallNode, 'start' | 'end' | 'curveOffset'>
|
type WallCurveLike = Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset'>
|
||||||
|
|
||||||
type CurveFrame = {
|
type CurveFrame = {
|
||||||
point: Point2D
|
point: Point2D
|
||||||
@@ -198,7 +198,7 @@ export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPL
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getWallSurfacePolygon(
|
export function getWallSurfacePolygon(
|
||||||
wall: Pick<WallNode, 'start' | 'end' | 'curveOffset' | 'thickness'>,
|
wall: Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset' | 'thickness'>,
|
||||||
segments = DEFAULT_SAMPLE_SEGMENTS,
|
segments = DEFAULT_SAMPLE_SEGMENTS,
|
||||||
miterOverrides?: WallSurfaceMiterOverrides,
|
miterOverrides?: WallSurfaceMiterOverrides,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -50,10 +50,12 @@ export function FloatingActionMenu() {
|
|||||||
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||||
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
|
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
|
||||||
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
|
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
|
||||||
|
const curvingFence = useEditor((s) => s.curvingFence)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
|
const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
|
||||||
const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint)
|
const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint)
|
||||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||||
|
const setCurvingFence = useEditor((s) => s.setCurvingFence)
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const setEditingHole = useEditor((s) => s.setEditingHole)
|
const setEditingHole = useEditor((s) => s.setEditingHole)
|
||||||
|
|
||||||
@@ -132,15 +134,24 @@ export function FloatingActionMenu() {
|
|||||||
|
|
||||||
if (node?.type === 'wall' || node?.type === 'fence') {
|
if (node?.type === 'wall' || node?.type === 'fence') {
|
||||||
const segment = node as WallNode | FenceNode
|
const segment = node as WallNode | FenceNode
|
||||||
const segmentLength = Math.hypot(
|
|
||||||
segment.end[0] - segment.start[0],
|
|
||||||
segment.end[1] - segment.start[1],
|
|
||||||
)
|
|
||||||
const endpointYOffset = 0.35
|
const endpointYOffset = 0.35
|
||||||
const startLocalX = node.type === 'wall' ? 0 : -segmentLength / 2
|
const startWorld =
|
||||||
const endLocalX = node.type === 'wall' ? segmentLength : segmentLength / 2
|
node.type === 'wall'
|
||||||
const startWorld = obj.localToWorld(new THREE.Vector3(startLocalX, 0, 0))
|
? obj.localToWorld(new THREE.Vector3(0, 0, 0))
|
||||||
const endWorld = obj.localToWorld(new THREE.Vector3(endLocalX, 0, 0))
|
: obj.localToWorld(new THREE.Vector3(segment.start[0], 0, segment.start[1]))
|
||||||
|
const endWorld =
|
||||||
|
node.type === 'wall'
|
||||||
|
? obj.localToWorld(
|
||||||
|
new THREE.Vector3(
|
||||||
|
Math.hypot(
|
||||||
|
segment.end[0] - segment.start[0],
|
||||||
|
segment.end[1] - segment.start[1],
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: obj.localToWorld(new THREE.Vector3(segment.end[0], 0, segment.end[1]))
|
||||||
|
|
||||||
if (startEndpointGroupRef.current) {
|
if (startEndpointGroupRef.current) {
|
||||||
startEndpointGroupRef.current.position.set(
|
startEndpointGroupRef.current.position.set(
|
||||||
@@ -187,12 +198,19 @@ export function FloatingActionMenu() {
|
|||||||
const handleCurve = useCallback(
|
const handleCurve = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (!canCurveSelectedWall || !node || node.type !== 'wall') return
|
if (!node) return
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
setCurvingWall(node)
|
if (node.type === 'wall') {
|
||||||
|
if (!canCurveSelectedWall) return
|
||||||
|
setCurvingWall(node)
|
||||||
|
} else if (node.type === 'fence') {
|
||||||
|
setCurvingFence(node)
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
},
|
},
|
||||||
[canCurveSelectedWall, node, setCurvingWall, setSelection],
|
[canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
|
||||||
)
|
)
|
||||||
const handleEndpointMove = useCallback(
|
const handleEndpointMove = useCallback(
|
||||||
(endpoint: 'start' | 'end', e: React.MouseEvent) => {
|
(endpoint: 'start' | 'end', e: React.MouseEvent) => {
|
||||||
@@ -410,7 +428,8 @@ export function FloatingActionMenu() {
|
|||||||
if (
|
if (
|
||||||
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
|
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
|
||||||
movingWallEndpoint ||
|
movingWallEndpoint ||
|
||||||
movingFenceEndpoint
|
movingFenceEndpoint ||
|
||||||
|
curvingFence
|
||||||
)
|
)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
@@ -427,7 +446,11 @@ export function FloatingActionMenu() {
|
|||||||
>
|
>
|
||||||
<NodeActionMenu
|
<NodeActionMenu
|
||||||
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
|
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
|
||||||
onCurve={canCurveSelectedWall ? handleCurve : undefined}
|
onCurve={
|
||||||
|
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
|
||||||
|
? handleCurve
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onDuplicate={
|
onDuplicate={
|
||||||
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
|
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
|
||||||
|
|||||||
@@ -4778,8 +4778,9 @@ const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
|||||||
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
||||||
const movingNode = useEditor((state) => state.movingNode)
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
const curvingWall = useEditor((state) => state.curvingWall)
|
const curvingWall = useEditor((state) => state.curvingWall)
|
||||||
|
const curvingFence = useEditor((state) => state.curvingFence)
|
||||||
|
|
||||||
if (!isFloorplanHovered || movingNode || curvingWall) {
|
if (!isFloorplanHovered || movingNode || curvingWall || curvingFence) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4982,6 +4983,7 @@ export function FloorplanPanel() {
|
|||||||
const setMode = useEditor((state) => state.setMode)
|
const setMode = useEditor((state) => state.setMode)
|
||||||
const movingNode = useEditor((state) => state.movingNode)
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
const curvingWall = useEditor((state) => state.curvingWall)
|
const curvingWall = useEditor((state) => state.curvingWall)
|
||||||
|
const curvingFence = useEditor((state) => state.curvingFence)
|
||||||
const phase = useEditor((state) => state.phase)
|
const phase = useEditor((state) => state.phase)
|
||||||
const mode = useEditor((state) => state.mode)
|
const mode = useEditor((state) => state.mode)
|
||||||
const setPhase = useEditor((state) => state.setPhase)
|
const setPhase = useEditor((state) => state.setPhase)
|
||||||
@@ -5656,6 +5658,7 @@ export function FloorplanPanel() {
|
|||||||
const isCeilingMoveActive = movingNode?.type === 'ceiling'
|
const isCeilingMoveActive = movingNode?.type === 'ceiling'
|
||||||
const isWallMoveActive = movingNode?.type === 'wall'
|
const isWallMoveActive = movingNode?.type === 'wall'
|
||||||
const isWallCurveActive = curvingWall?.type === 'wall'
|
const isWallCurveActive = curvingWall?.type === 'wall'
|
||||||
|
const isFenceCurveActive = curvingFence?.type === 'fence'
|
||||||
const isItemPlacementPreviewActive =
|
const isItemPlacementPreviewActive =
|
||||||
(mode === 'build' && tool === 'item') || movingNode?.type === 'item'
|
(mode === 'build' && tool === 'item') || movingNode?.type === 'item'
|
||||||
const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
|
const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
|
||||||
@@ -5667,6 +5670,7 @@ export function FloorplanPanel() {
|
|||||||
isCeilingMoveActive ||
|
isCeilingMoveActive ||
|
||||||
isWallMoveActive ||
|
isWallMoveActive ||
|
||||||
isWallCurveActive ||
|
isWallCurveActive ||
|
||||||
|
isFenceCurveActive ||
|
||||||
isFloorItemBuildActive ||
|
isFloorItemBuildActive ||
|
||||||
isFloorItemMoveActive
|
isFloorItemMoveActive
|
||||||
const floorplanPreviewStairSegment = useMemo(
|
const floorplanPreviewStairSegment = useMemo(
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
const movingNode = useEditor((s) => s.movingNode)
|
const movingNode = useEditor((s) => s.movingNode)
|
||||||
const curvingWall = useEditor((s) => s.curvingWall)
|
const curvingWall = useEditor((s) => s.curvingWall)
|
||||||
|
const curvingFence = useEditor((s) => s.curvingFence)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
|
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
|
||||||
@@ -510,7 +511,7 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== 'select') return
|
if (mode !== 'select') return
|
||||||
if (movingNode || curvingWall) return
|
if (movingNode || curvingWall || curvingFence) return
|
||||||
|
|
||||||
const onClick = (event: NodeEvent) => {
|
const onClick = (event: NodeEvent) => {
|
||||||
// Skip if box-select just completed (drag ended over a node)
|
// Skip if box-select just completed (drag ended over a node)
|
||||||
@@ -648,12 +649,12 @@ export const SelectionManager = () => {
|
|||||||
})
|
})
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
}
|
}
|
||||||
}, [curvingWall, mode, movingNode])
|
}, [curvingFence, curvingWall, mode, movingNode])
|
||||||
|
|
||||||
// Global double-click handler for auto-switching phases and cross-phase hover
|
// Global double-click handler for auto-switching phases and cross-phase hover
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== 'select') return
|
if (mode !== 'select') return
|
||||||
if (movingNode || curvingWall) return
|
if (movingNode || curvingWall || curvingFence) return
|
||||||
|
|
||||||
const onEnter = (event: NodeEvent) => {
|
const onEnter = (event: NodeEvent) => {
|
||||||
const node = event.node
|
const node = event.node
|
||||||
@@ -782,7 +783,7 @@ export const SelectionManager = () => {
|
|||||||
emitter.off(`${type}:double-click` as any, onDoubleClick as any)
|
emitter.off(`${type}:double-click` as any, onDoubleClick as any)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [curvingWall, mode, movingNode])
|
}, [curvingFence, curvingWall, mode, movingNode])
|
||||||
|
|
||||||
// Delete mode: click-to-delete (sledgehammer tool)
|
// Delete mode: click-to-delete (sledgehammer tool)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNodeId,
|
||||||
|
emitter,
|
||||||
|
type FenceNode,
|
||||||
|
type GridEvent,
|
||||||
|
getClampedWallCurveOffset,
|
||||||
|
getMaxWallCurveOffset,
|
||||||
|
getWallChordFrame,
|
||||||
|
getWallMidpointHandlePoint,
|
||||||
|
normalizeWallCurveOffset,
|
||||||
|
pauseSceneHistory,
|
||||||
|
resumeSceneHistory,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||||
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
import { getWallGridStep, snapScalarToGrid } from '../wall/wall-drafting'
|
||||||
|
|
||||||
|
export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||||
|
const activatedAtRef = useRef<number>(Date.now())
|
||||||
|
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
|
||||||
|
const previousCurveOffsetRef = useRef<number | null>(null)
|
||||||
|
const shiftPressedRef = useRef(false)
|
||||||
|
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
|
||||||
|
|
||||||
|
const initialHandle = getWallMidpointHandlePoint(node)
|
||||||
|
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
|
||||||
|
initialHandle.x,
|
||||||
|
0,
|
||||||
|
initialHandle.y,
|
||||||
|
])
|
||||||
|
|
||||||
|
const exitCurveMode = useCallback(() => {
|
||||||
|
useEditor.getState().setCurvingFence(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nodeId = node.id
|
||||||
|
const originalCurveOffset = originalCurveOffsetRef.current
|
||||||
|
const chord = getWallChordFrame(node)
|
||||||
|
const maxCurveOffset = getMaxWallCurveOffset(node)
|
||||||
|
|
||||||
|
pauseSceneHistory(useScene)
|
||||||
|
let wasCommitted = false
|
||||||
|
|
||||||
|
const applyPreview = (curveOffset: number) => {
|
||||||
|
if (previewOffsetRef.current === curveOffset) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previewOffsetRef.current = curveOffset
|
||||||
|
|
||||||
|
const nextNode = {
|
||||||
|
...node,
|
||||||
|
curveOffset,
|
||||||
|
}
|
||||||
|
const handlePoint = getWallMidpointHandlePoint(nextNode)
|
||||||
|
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
|
||||||
|
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||||
|
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreOriginal = () => {
|
||||||
|
if (previewOffsetRef.current === originalCurveOffset) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previewOffsetRef.current = originalCurveOffset
|
||||||
|
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
||||||
|
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGridMove = (event: GridEvent) => {
|
||||||
|
const snapStep = getWallGridStep()
|
||||||
|
const localX = shiftPressedRef.current
|
||||||
|
? event.localPosition[0]
|
||||||
|
: snapScalarToGrid(event.localPosition[0], snapStep)
|
||||||
|
const localZ = shiftPressedRef.current
|
||||||
|
? event.localPosition[2]
|
||||||
|
: snapScalarToGrid(event.localPosition[2], snapStep)
|
||||||
|
|
||||||
|
const offsetFromMidpoint =
|
||||||
|
-(
|
||||||
|
(localX - chord.midpoint.x) * chord.normal.x +
|
||||||
|
(localZ - chord.midpoint.y) * chord.normal.y
|
||||||
|
)
|
||||||
|
const snappedOffset = shiftPressedRef.current
|
||||||
|
? offsetFromMidpoint
|
||||||
|
: snapScalarToGrid(offsetFromMidpoint, snapStep)
|
||||||
|
const nextCurveOffset = normalizeWallCurveOffset(
|
||||||
|
node,
|
||||||
|
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
previousCurveOffsetRef.current !== null &&
|
||||||
|
nextCurveOffset !== previousCurveOffsetRef.current
|
||||||
|
) {
|
||||||
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
|
}
|
||||||
|
previousCurveOffsetRef.current = nextCurveOffset
|
||||||
|
|
||||||
|
applyPreview(nextCurveOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
if (Date.now() - activatedAtRef.current < 150) {
|
||||||
|
event.nativeEvent?.stopPropagation?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const curveOffset = previewOffsetRef.current
|
||||||
|
wasCommitted = true
|
||||||
|
|
||||||
|
if (curveOffset !== originalCurveOffset) {
|
||||||
|
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
||||||
|
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||||
|
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||||
|
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||||
|
pauseSceneHistory(useScene)
|
||||||
|
}
|
||||||
|
|
||||||
|
sfxEmitter.emit('sfx:item-place')
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
|
exitCurveMode()
|
||||||
|
event.nativeEvent?.stopPropagation?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
restoreOriginal()
|
||||||
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
markToolCancelConsumed()
|
||||||
|
exitCurveMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
shiftPressedRef.current = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyUp = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Shift') {
|
||||||
|
shiftPressedRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitter.on('grid:move', onGridMove)
|
||||||
|
emitter.on('grid:click', onGridClick)
|
||||||
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('keyup', onKeyUp)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!wasCommitted) {
|
||||||
|
restoreOriginal()
|
||||||
|
}
|
||||||
|
resumeSceneHistory(useScene)
|
||||||
|
emitter.off('grid:move', onGridMove)
|
||||||
|
emitter.off('grid:click', onGridClick)
|
||||||
|
emitter.off('tool:cancel', onCancel)
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
|
}
|
||||||
|
}, [exitCurveMode, node])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FenceNode, useScene, type WallNode } from '@pascal-app/core'
|
import { FenceNode, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, useScene, type WallNode } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import {
|
import {
|
||||||
@@ -60,11 +60,16 @@ function findFenceSnapTarget(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidates: Array<FencePlanPoint | null> = [
|
const candidates: Array<FencePlanPoint | null> = [fence.start, fence.end]
|
||||||
fence.start,
|
if (isCurvedWall(fence)) {
|
||||||
fence.end,
|
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
|
||||||
projectPointOntoSegment(point, fence),
|
for (let index = 0; index <= sampleCount; index += 1) {
|
||||||
]
|
const frame = getWallCurveFrameAt(fence, index / sampleCount)
|
||||||
|
candidates.push([frame.point.x, frame.point.y])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
candidates.push(projectPointOntoSegment(point, fence))
|
||||||
|
}
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
|||||||
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
||||||
import { CeilingTool } from './ceiling/ceiling-tool'
|
import { CeilingTool } from './ceiling/ceiling-tool'
|
||||||
import { DoorTool } from './door/door-tool'
|
import { DoorTool } from './door/door-tool'
|
||||||
|
import { CurveFenceTool } from './fence/curve-fence-tool'
|
||||||
import { FenceTool } from './fence/fence-tool'
|
import { FenceTool } from './fence/fence-tool'
|
||||||
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
|
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
|
||||||
import { ItemTool } from './item/item-tool'
|
import { ItemTool } from './item/item-tool'
|
||||||
@@ -57,6 +58,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
|
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
|
||||||
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
||||||
const curvingWall = useEditor((state) => state.curvingWall)
|
const curvingWall = useEditor((state) => state.curvingWall)
|
||||||
|
const curvingFence = useEditor((state) => state.curvingFence)
|
||||||
const editingHole = useEditor((state) => state.editingHole)
|
const editingHole = useEditor((state) => state.editingHole)
|
||||||
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
||||||
const buildingId = useViewer((state) => state.selection.buildingId)
|
const buildingId = useViewer((state) => state.selection.buildingId)
|
||||||
@@ -149,6 +151,7 @@ export const ToolManager: React.FC = () => {
|
|||||||
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
|
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
|
||||||
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
|
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
|
||||||
{curvingWall && <CurveWallTool node={curvingWall} />}
|
{curvingWall && <CurveWallTool node={curvingWall} />}
|
||||||
|
{curvingFence && <CurveFenceTool node={curvingFence} />}
|
||||||
{movingNode && movingNode.type !== 'building' && <MoveTool />}
|
{movingNode && movingNode.type !== 'building' && <MoveTool />}
|
||||||
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNode, type AnyNodeId, type FenceNode, type MaterialSchema, useScene } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
type FenceNode,
|
||||||
|
getClampedWallCurveOffset,
|
||||||
|
getMaxWallCurveOffset,
|
||||||
|
getWallCurveLength,
|
||||||
|
type MaterialSchema,
|
||||||
|
normalizeWallCurveOffset,
|
||||||
|
useScene,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Move, Spline } from 'lucide-react'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
import { MaterialPicker } from '../controls/material-picker'
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SegmentedControl } from '../controls/segmented-control'
|
import { SegmentedControl } from '../controls/segmented-control'
|
||||||
@@ -28,6 +42,8 @@ export function FencePanel() {
|
|||||||
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
const setCurvingFence = useEditor((s) => s.setCurvingFence)
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
|
||||||
@@ -67,6 +83,20 @@ export function FencePanel() {
|
|||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
|
|
||||||
|
const handleMove = useCallback(() => {
|
||||||
|
if (!node) return
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
|
setMovingNode(node)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [node, setMovingNode, setSelection])
|
||||||
|
|
||||||
|
const handleCurve = useCallback(() => {
|
||||||
|
if (!node) return
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
|
setCurvingFence(node)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [node, setCurvingFence, setSelection])
|
||||||
|
|
||||||
const handleMaterialPresetChange = useCallback(
|
const handleMaterialPresetChange = useCallback(
|
||||||
(materialPreset: string) => {
|
(materialPreset: string) => {
|
||||||
handleUpdate({ materialPreset, material: undefined })
|
handleUpdate({ materialPreset, material: undefined })
|
||||||
@@ -83,9 +113,9 @@ export function FencePanel() {
|
|||||||
|
|
||||||
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
|
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
|
||||||
|
|
||||||
const dx = node.end[0] - node.start[0]
|
const length = getWallCurveLength(node)
|
||||||
const dz = node.end[1] - node.start[1]
|
const curveOffset = getClampedWallCurveOffset(node)
|
||||||
const length = Math.sqrt(dx * dx + dz * dz)
|
const maxCurveOffset = getMaxWallCurveOffset(node)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelWrapper
|
<PanelWrapper
|
||||||
@@ -119,6 +149,16 @@ export function FencePanel() {
|
|||||||
unit="m"
|
unit="m"
|
||||||
value={length}
|
value={length}
|
||||||
/>
|
/>
|
||||||
|
<SliderControl
|
||||||
|
label="Curve"
|
||||||
|
max={Math.max(0.01, maxCurveOffset)}
|
||||||
|
min={-Math.max(0.01, maxCurveOffset)}
|
||||||
|
onChange={(value) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })}
|
||||||
|
precision={2}
|
||||||
|
step={0.1}
|
||||||
|
unit="m"
|
||||||
|
value={Math.round(curveOffset * 100) / 100}
|
||||||
|
/>
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Height"
|
label="Height"
|
||||||
max={4}
|
max={4}
|
||||||
@@ -213,6 +253,17 @@ export function FencePanel() {
|
|||||||
value={node.material}
|
value={node.material}
|
||||||
/>
|
/>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
|
<PanelSection title="Actions">
|
||||||
|
<ActionGroup>
|
||||||
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
|
<ActionButton
|
||||||
|
icon={<Spline className="h-3.5 w-3.5" />}
|
||||||
|
label="Curve"
|
||||||
|
onClick={handleCurve}
|
||||||
|
/>
|
||||||
|
</ActionGroup>
|
||||||
|
</PanelSection>
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ type EditorState = {
|
|||||||
setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
|
setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
|
||||||
curvingWall: WallNode | null
|
curvingWall: WallNode | null
|
||||||
setCurvingWall: (wall: WallNode | null) => void
|
setCurvingWall: (wall: WallNode | null) => void
|
||||||
|
curvingFence: FenceNode | null
|
||||||
|
setCurvingFence: (fence: FenceNode | null) => void
|
||||||
selectedMaterialTarget: SelectedMaterialTarget | null
|
selectedMaterialTarget: SelectedMaterialTarget | null
|
||||||
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
|
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
|
||||||
selectedReferenceId: string | null
|
selectedReferenceId: string | null
|
||||||
@@ -524,6 +526,8 @@ const useEditor = create<EditorState>()(
|
|||||||
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
|
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
|
||||||
curvingWall: null,
|
curvingWall: null,
|
||||||
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
||||||
|
curvingFence: null,
|
||||||
|
setCurvingFence: (fence) => set({ curvingFence: fence }),
|
||||||
selectedMaterialTarget: null,
|
selectedMaterialTarget: null,
|
||||||
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
|
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
|
||||||
selectedReferenceId: null,
|
selectedReferenceId: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user