diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts index d1592081..234da407 100644 --- a/packages/core/src/schema/nodes/fence.ts +++ b/packages/core/src/schema/nodes/fence.ts @@ -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 diff --git a/packages/core/src/systems/fence/fence-system.tsx b/packages/core/src/systems/fence/fence-system.tsx index 8a6d38c8..4fcc1a86 100644 --- a/packages/core/src/systems/fence/fence-system.tsx +++ b/packages/core/src/systems/fence/fence-system.tsx @@ -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 = () => { diff --git a/packages/core/src/systems/wall/wall-curve.ts b/packages/core/src/systems/wall/wall-curve.ts index 5b6c281c..902050ba 100644 --- a/packages/core/src/systems/wall/wall-curve.ts +++ b/packages/core/src/systems/wall/wall-curve.ts @@ -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 +type WallCurveLike = Pick type CurveFrame = { point: Point2D @@ -198,7 +198,7 @@ export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPL } export function getWallSurfacePolygon( - wall: Pick, + wall: Pick, segments = DEFAULT_SAMPLE_SEGMENTS, miterOverrides?: WallSurfaceMiterOverrides, ) { diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index d06771b5..a3ab3da8 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -50,10 +50,12 @@ export function FloatingActionMenu() { const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered) const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint) const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint) + const curvingFence = useEditor((s) => s.curvingFence) const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint) const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint) const setCurvingWall = useEditor((s) => s.setCurvingWall) + const setCurvingFence = useEditor((s) => s.setCurvingFence) const setSelection = useViewer((s) => s.setSelection) const setEditingHole = useEditor((s) => s.setEditingHole) @@ -132,15 +134,24 @@ export function FloatingActionMenu() { if (node?.type === 'wall' || node?.type === 'fence') { 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 startLocalX = node.type === 'wall' ? 0 : -segmentLength / 2 - const endLocalX = node.type === 'wall' ? segmentLength : segmentLength / 2 - const startWorld = obj.localToWorld(new THREE.Vector3(startLocalX, 0, 0)) - const endWorld = obj.localToWorld(new THREE.Vector3(endLocalX, 0, 0)) + const startWorld = + node.type === 'wall' + ? obj.localToWorld(new THREE.Vector3(0, 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) { startEndpointGroupRef.current.position.set( @@ -187,12 +198,19 @@ export function FloatingActionMenu() { const handleCurve = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - if (!canCurveSelectedWall || !node || node.type !== 'wall') return + if (!node) return 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: [] }) }, - [canCurveSelectedWall, node, setCurvingWall, setSelection], + [canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection], ) const handleEndpointMove = useCallback( (endpoint: 'start' | 'end', e: React.MouseEvent) => { @@ -410,7 +428,8 @@ export function FloatingActionMenu() { if ( !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || movingWallEndpoint || - movingFenceEndpoint + movingFenceEndpoint || + curvingFence ) return null @@ -427,7 +446,11 @@ export function FloatingActionMenu() { > state.isFloorplanHovered) const movingNode = useEditor((state) => state.movingNode) const curvingWall = useEditor((state) => state.curvingWall) + const curvingFence = useEditor((state) => state.curvingFence) - if (!isFloorplanHovered || movingNode || curvingWall) { + if (!isFloorplanHovered || movingNode || curvingWall || curvingFence) { return null } @@ -4982,6 +4983,7 @@ export function FloorplanPanel() { const setMode = useEditor((state) => state.setMode) const movingNode = useEditor((state) => state.movingNode) const curvingWall = useEditor((state) => state.curvingWall) + const curvingFence = useEditor((state) => state.curvingFence) const phase = useEditor((state) => state.phase) const mode = useEditor((state) => state.mode) const setPhase = useEditor((state) => state.setPhase) @@ -5656,6 +5658,7 @@ export function FloorplanPanel() { const isCeilingMoveActive = movingNode?.type === 'ceiling' const isWallMoveActive = movingNode?.type === 'wall' const isWallCurveActive = curvingWall?.type === 'wall' + const isFenceCurveActive = curvingFence?.type === 'fence' const isItemPlacementPreviewActive = (mode === 'build' && tool === 'item') || movingNode?.type === 'item' const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo @@ -5667,6 +5670,7 @@ export function FloorplanPanel() { isCeilingMoveActive || isWallMoveActive || isWallCurveActive || + isFenceCurveActive || isFloorItemBuildActive || isFloorItemMoveActive const floorplanPreviewStairSegment = useMemo( diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 7122ff1a..fe871832 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -472,6 +472,7 @@ export const SelectionManager = () => { const movingNode = useEditor((s) => s.movingNode) const curvingWall = useEditor((s) => s.curvingWall) + const curvingFence = useEditor((s) => s.curvingFence) useEffect(() => { setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default') @@ -510,7 +511,7 @@ export const SelectionManager = () => { useEffect(() => { if (mode !== 'select') return - if (movingNode || curvingWall) return + if (movingNode || curvingWall || curvingFence) return const onClick = (event: NodeEvent) => { // Skip if box-select just completed (drag ended over a node) @@ -648,12 +649,12 @@ export const SelectionManager = () => { }) emitter.off('grid:click', onGridClick) } - }, [curvingWall, mode, movingNode]) + }, [curvingFence, curvingWall, mode, movingNode]) // Global double-click handler for auto-switching phases and cross-phase hover useEffect(() => { if (mode !== 'select') return - if (movingNode || curvingWall) return + if (movingNode || curvingWall || curvingFence) return const onEnter = (event: NodeEvent) => { const node = event.node @@ -782,7 +783,7 @@ export const SelectionManager = () => { emitter.off(`${type}:double-click` as any, onDoubleClick as any) }) } - }, [curvingWall, mode, movingNode]) + }, [curvingFence, curvingWall, mode, movingNode]) // Delete mode: click-to-delete (sledgehammer tool) useEffect(() => { diff --git a/packages/editor/src/components/tools/fence/curve-fence-tool.tsx b/packages/editor/src/components/tools/fence/curve-fence-tool.tsx new file mode 100644 index 00000000..4599cec3 --- /dev/null +++ b/packages/editor/src/components/tools/fence/curve-fence-tool.tsx @@ -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(Date.now()) + const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node)) + const previousCurveOffsetRef = useRef(null) + const shiftPressedRef = useRef(false) + const previewOffsetRef = useRef(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 ( + + + + ) +} diff --git a/packages/editor/src/components/tools/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts index 05ccaeae..99ad4c06 100644 --- a/packages/editor/src/components/tools/fence/fence-drafting.ts +++ b/packages/editor/src/components/tools/fence/fence-drafting.ts @@ -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 { sfxEmitter } from '../../../lib/sfx-bus' import { @@ -60,11 +60,16 @@ function findFenceSnapTarget( continue } - const candidates: Array = [ - fence.start, - fence.end, - projectPointOntoSegment(point, fence), - ] + const candidates: Array = [fence.start, fence.end] + if (isCurvedWall(fence)) { + const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3)) + 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) { if (!candidate) { diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index b60f59d2..1a4e13e0 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -11,6 +11,7 @@ import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor' import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor' import { CeilingTool } from './ceiling/ceiling-tool' import { DoorTool } from './door/door-tool' +import { CurveFenceTool } from './fence/curve-fence-tool' import { FenceTool } from './fence/fence-tool' import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool' import { ItemTool } from './item/item-tool' @@ -57,6 +58,7 @@ export const ToolManager: React.FC = () => { const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint) const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint) const curvingWall = useEditor((state) => state.curvingWall) + const curvingFence = useEditor((state) => state.curvingFence) const editingHole = useEditor((state) => state.editingHole) const selectedZoneId = useViewer((state) => state.selection.zoneId) const buildingId = useViewer((state) => state.selection.buildingId) @@ -149,6 +151,7 @@ export const ToolManager: React.FC = () => { {movingWallEndpoint && } {movingFenceEndpoint && } {curvingWall && } + {curvingFence && } {movingNode && movingNode.type !== 'building' && } {!movingNode && BuildToolComponent && } diff --git a/packages/editor/src/components/ui/panels/fence-panel.tsx b/packages/editor/src/components/ui/panels/fence-panel.tsx index 4e2785da..0fab17fd 100644 --- a/packages/editor/src/components/ui/panels/fence-panel.tsx +++ b/packages/editor/src/components/ui/panels/fence-panel.tsx @@ -1,8 +1,22 @@ '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 { Move, Spline } from 'lucide-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 { PanelSection } from '../controls/panel-section' import { SegmentedControl } from '../controls/segmented-control' @@ -28,6 +42,8 @@ export function FencePanel() { const selectedCount = useViewer((s) => s.selection.selectedIds.length) const setSelection = useViewer((s) => s.setSelection) const updateNode = useScene((s) => s.updateNode) + const setMovingNode = useEditor((s) => s.setMovingNode) + const setCurvingFence = useEditor((s) => s.setCurvingFence) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined, @@ -67,6 +83,20 @@ export function FencePanel() { setSelection({ selectedIds: [] }) }, [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( (materialPreset: string) => { handleUpdate({ materialPreset, material: undefined }) @@ -83,9 +113,9 @@ export function FencePanel() { if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null - const dx = node.end[0] - node.start[0] - const dz = node.end[1] - node.start[1] - const length = Math.sqrt(dx * dx + dz * dz) + const length = getWallCurveLength(node) + const curveOffset = getClampedWallCurveOffset(node) + const maxCurveOffset = getMaxWallCurveOffset(node) return ( + handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })} + precision={2} + step={0.1} + unit="m" + value={Math.round(curveOffset * 100) / 100} + /> + + + + } label="Move" onClick={handleMove} /> + } + label="Curve" + onClick={handleCurve} + /> + + ) } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 9b73cd41..58f7a485 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -145,6 +145,8 @@ type EditorState = { setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void curvingWall: WallNode | null setCurvingWall: (wall: WallNode | null) => void + curvingFence: FenceNode | null + setCurvingFence: (fence: FenceNode | null) => void selectedMaterialTarget: SelectedMaterialTarget | null setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void selectedReferenceId: string | null @@ -524,6 +526,8 @@ const useEditor = create()( setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }), curvingWall: null, setCurvingWall: (wall) => set({ curvingWall: wall }), + curvingFence: null, + setCurvingFence: (fence) => set({ curvingFence: fence }), selectedMaterialTarget: null, setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }), selectedReferenceId: null,