Feat/stairs fence update (#226)
* feat: railing on the straight stairs and new fence * feat: added spiral and curved stairs with bug fix for fence * feat:fence are linked to each other ... so moving one move the other sharing the same coordinate * fix: update stair railing logic to include front-side attachments for terminal landings * Integrate fence rendering into the fence system * fix: pass nodeId instead of undefined node to WallTreeNode and FenceTreeNode TreeNode was passing `node` (undefined variable) instead of `nodeId` to WallTreeNode and FenceTreeNode, causing a runtime ReferenceError. Updated FenceTreeNode to accept nodeId and look up the node from the scene store internally, consistent with all other tree node components. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: update fence icon with new isometric design Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Aymeric Rabot <aymeric@pascal.app> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
Aymeric Rabot
parent
682e2a1a12
commit
a205e4f778
@@ -5,6 +5,7 @@ import {
|
||||
type AnyNodeId,
|
||||
type CeilingNode,
|
||||
DoorNode,
|
||||
FenceNode,
|
||||
ItemNode,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
@@ -33,6 +34,7 @@ const ALLOWED_TYPES = [
|
||||
'stair',
|
||||
'stair-segment',
|
||||
'wall',
|
||||
'fence',
|
||||
'slab',
|
||||
'ceiling',
|
||||
]
|
||||
@@ -82,6 +84,7 @@ export function FloatingActionMenu() {
|
||||
node.type === 'item' ||
|
||||
node.type === 'window' ||
|
||||
node.type === 'door' ||
|
||||
node.type === 'fence' ||
|
||||
node.type === 'roof' ||
|
||||
node.type === 'roof-segment' ||
|
||||
node.type === 'stair' ||
|
||||
@@ -113,11 +116,18 @@ export function FloatingActionMenu() {
|
||||
duplicate = WindowNode.parse(duplicateInfo)
|
||||
} else if (node.type === 'item') {
|
||||
duplicate = ItemNode.parse(duplicateInfo)
|
||||
} else if (node.type === 'fence') {
|
||||
duplicate = FenceNode.parse(duplicateInfo)
|
||||
duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1]
|
||||
duplicate.end = [duplicate.end[0] + 1, duplicate.end[1] + 1]
|
||||
} else if (node.type === 'roof') {
|
||||
duplicate = RoofNode.parse(duplicateInfo)
|
||||
} else if (node.type === 'roof-segment') {
|
||||
duplicate = RoofSegmentNode.parse(duplicateInfo)
|
||||
} else if (node.type === 'stair') {
|
||||
duplicateInfo.children = []
|
||||
duplicateInfo.metadata = { ...duplicateInfo.metadata }
|
||||
delete duplicateInfo.metadata?.isNew
|
||||
duplicate = StairNode.parse(duplicateInfo)
|
||||
} else if (node.type === 'stair-segment') {
|
||||
duplicate = StairSegmentNode.parse(duplicateInfo)
|
||||
@@ -130,6 +140,8 @@ export function FloatingActionMenu() {
|
||||
if (duplicate) {
|
||||
if (duplicate.type === 'door' || duplicate.type === 'window') {
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
} else if (duplicate.type === 'fence') {
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
} else if (
|
||||
duplicate.type === 'roof' ||
|
||||
duplicate.type === 'roof-segment' ||
|
||||
@@ -144,7 +156,35 @@ export function FloatingActionMenu() {
|
||||
duplicate.position[2] + 1,
|
||||
]
|
||||
}
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
if (node.type === 'stair' && duplicate.type === 'stair') {
|
||||
const nodesState = useScene.getState().nodes
|
||||
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
|
||||
{ node: duplicate, parentId: duplicate.parentId as AnyNodeId },
|
||||
]
|
||||
|
||||
for (const childId of node.children ?? []) {
|
||||
const childNode = nodesState[childId]
|
||||
if (childNode?.type !== 'stair-segment') {
|
||||
continue
|
||||
}
|
||||
|
||||
let childDuplicateInfo = structuredClone(childNode) as any
|
||||
delete childDuplicateInfo.id
|
||||
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
|
||||
delete childDuplicateInfo.metadata?.isNew
|
||||
|
||||
try {
|
||||
const childDuplicate = StairSegmentNode.parse(childDuplicateInfo)
|
||||
createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate stair segment', e)
|
||||
}
|
||||
}
|
||||
|
||||
useScene.getState().createNodes(createOps)
|
||||
} else {
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
}
|
||||
|
||||
// Duplicate children for roof nodes
|
||||
if (node.type === 'roof' && node.children) {
|
||||
@@ -166,36 +206,23 @@ export function FloatingActionMenu() {
|
||||
}
|
||||
|
||||
// Duplicate children for stair nodes
|
||||
if (node.type === 'stair' && node.children) {
|
||||
const nodesState = useScene.getState().nodes
|
||||
for (const childId of node.children) {
|
||||
const childNode = nodesState[childId]
|
||||
if (childNode && childNode.type === 'stair-segment') {
|
||||
let childDuplicateInfo = structuredClone(childNode) as any
|
||||
delete childDuplicateInfo.id
|
||||
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
|
||||
try {
|
||||
const childDuplicate = StairSegmentNode.parse(childDuplicateInfo)
|
||||
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate stair segment', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
duplicate.type === 'item' ||
|
||||
duplicate.type === 'fence' ||
|
||||
duplicate.type === 'window' ||
|
||||
duplicate.type === 'door' ||
|
||||
duplicate.type === 'roof' ||
|
||||
duplicate.type === 'roof-segment' ||
|
||||
duplicate.type === 'stair' ||
|
||||
duplicate.type === 'stair-segment'
|
||||
) {
|
||||
setMovingNode(duplicate as any)
|
||||
} else if (duplicate.type === 'stair') {
|
||||
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
|
||||
}
|
||||
if (duplicate.type !== 'stair') {
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
},
|
||||
[node, setMovingNode, setSelection],
|
||||
|
||||
@@ -8063,6 +8063,7 @@ export function FloorplanPanel() {
|
||||
...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
|
||||
isNew: true,
|
||||
}
|
||||
cloned.children = []
|
||||
|
||||
try {
|
||||
const duplicate = ItemNodeSchema.parse(cloned)
|
||||
@@ -8191,8 +8192,8 @@ export function FloorplanPanel() {
|
||||
delete cloned.id
|
||||
cloned.metadata = {
|
||||
...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
|
||||
isNew: true,
|
||||
}
|
||||
delete (cloned.metadata as Record<string, unknown>).isNew
|
||||
|
||||
const nextPosition =
|
||||
Array.isArray(cloned.position) && cloned.position.length >= 3
|
||||
@@ -8207,9 +8208,11 @@ export function FloorplanPanel() {
|
||||
|
||||
try {
|
||||
const duplicate = StairNodeSchema.parse(cloned)
|
||||
useScene.getState().createNode(duplicate, stair.parentId as AnyNodeId)
|
||||
|
||||
const nodesState = useScene.getState().nodes
|
||||
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
|
||||
{ node: duplicate, parentId: stair.parentId as AnyNodeId },
|
||||
]
|
||||
|
||||
for (const childId of stair.children ?? []) {
|
||||
const childNode = nodesState[childId]
|
||||
if (childNode?.type !== 'stair-segment') {
|
||||
@@ -8222,19 +8225,20 @@ export function FloorplanPanel() {
|
||||
...(typeof childClone.metadata === 'object' && childClone.metadata !== null
|
||||
? childClone.metadata
|
||||
: {}),
|
||||
isNew: true,
|
||||
}
|
||||
delete (childClone.metadata as Record<string, unknown>).isNew
|
||||
|
||||
const childDuplicate = StairSegmentNodeSchema.parse(childClone)
|
||||
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
|
||||
createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
|
||||
}
|
||||
|
||||
setMovingNode(duplicate)
|
||||
setSelection({ selectedIds: [] })
|
||||
useScene.getState().createNodes(createOps)
|
||||
|
||||
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
|
||||
} catch (error) {
|
||||
console.error('Failed to duplicate stair', error)
|
||||
}
|
||||
}, [selectedStairEntry, setMovingNode, setSelection])
|
||||
}, [selectedStairEntry, setSelection])
|
||||
const handleSelectedStairDuplicate = useCallback(
|
||||
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -26,6 +26,7 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
||||
|
||||
type SelectableNodeType =
|
||||
| 'wall'
|
||||
| 'fence'
|
||||
| 'item'
|
||||
| 'building'
|
||||
| 'zone'
|
||||
@@ -186,6 +187,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
structure: {
|
||||
types: [
|
||||
'wall',
|
||||
'fence',
|
||||
'item',
|
||||
'zone',
|
||||
'slab',
|
||||
@@ -238,6 +240,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
}
|
||||
if (
|
||||
node.type === 'wall' ||
|
||||
node.type === 'fence' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof' ||
|
||||
@@ -299,6 +302,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
||||
|
||||
if (
|
||||
node.type === 'wall' ||
|
||||
node.type === 'fence' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof' ||
|
||||
@@ -443,6 +447,7 @@ export const SelectionManager = () => {
|
||||
|
||||
const allTypes = [
|
||||
'wall',
|
||||
'fence',
|
||||
'item',
|
||||
'building',
|
||||
'zone',
|
||||
@@ -537,6 +542,7 @@ export const SelectionManager = () => {
|
||||
}
|
||||
} else if (
|
||||
node.type === 'wall' ||
|
||||
node.type === 'fence' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'roof' ||
|
||||
@@ -586,6 +592,7 @@ export const SelectionManager = () => {
|
||||
|
||||
const allTypes = [
|
||||
'wall',
|
||||
'fence',
|
||||
'item',
|
||||
'building',
|
||||
'slab',
|
||||
@@ -657,6 +664,7 @@ export const SelectionManager = () => {
|
||||
|
||||
const allTypes = [
|
||||
'wall',
|
||||
'fence',
|
||||
'item',
|
||||
'slab',
|
||||
'ceiling',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type AnyNodeId, type StairNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Imperatively toggles the Three.js visibility of stair objects based on the
|
||||
@@ -17,6 +17,27 @@ import { useEffect, useRef } from 'react'
|
||||
*/
|
||||
export const StairEditSystem = () => {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedStairSignature = useScene(
|
||||
useCallback(
|
||||
(state) =>
|
||||
selectedIds
|
||||
.map((id) => {
|
||||
const node = state.nodes[id as AnyNodeId]
|
||||
if (!node) return null
|
||||
if (node.type === 'stair') {
|
||||
return `${node.id}:${node.stairType}`
|
||||
}
|
||||
if (node.type === 'stair-segment' && node.parentId) {
|
||||
const parent = state.nodes[node.parentId as AnyNodeId] as StairNode | undefined
|
||||
return parent?.type === 'stair' ? `${parent.id}:${parent.stairType}` : null
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('|'),
|
||||
[selectedIds],
|
||||
),
|
||||
)
|
||||
const prevActiveStairIds = useRef(new Set<string>())
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,14 +62,15 @@ export const StairEditSystem = () => {
|
||||
const group = sceneRegistry.nodes.get(stairId)
|
||||
if (!group) continue
|
||||
|
||||
const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined
|
||||
const isCurved = stairNode?.stairType === 'curved' || stairNode?.stairType === 'spiral'
|
||||
const mergedMesh = group.getObjectByName('merged-stair')
|
||||
const segmentsWrapper = group.getObjectByName('segments-wrapper')
|
||||
const isActive = activeStairIds.has(stairId)
|
||||
|
||||
if (mergedMesh) mergedMesh.visible = !isActive
|
||||
if (segmentsWrapper) segmentsWrapper.visible = isActive
|
||||
if (mergedMesh) mergedMesh.visible = !isActive && !isCurved
|
||||
if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved
|
||||
|
||||
const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined
|
||||
if (stairNode?.children?.length) {
|
||||
const wasActive = prevActiveStairIds.current.has(stairId)
|
||||
if (isActive !== wasActive) {
|
||||
@@ -63,7 +85,7 @@ export const StairEditSystem = () => {
|
||||
}
|
||||
|
||||
prevActiveStairIds.current = activeStairIds
|
||||
}, [selectedIds])
|
||||
}, [selectedIds, selectedStairSignature])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function hasWallChildOverlap(
|
||||
const newLeft = clampedX - halfW
|
||||
const newRight = clampedX + halfW
|
||||
|
||||
for (const childId of wallNode.children) {
|
||||
for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
|
||||
if (childId === ignoreId) continue
|
||||
const child = nodes[childId as AnyNodeId]
|
||||
if (!child) continue
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { FenceNode, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import {
|
||||
type WallPlanPoint,
|
||||
findWallSnapTarget,
|
||||
isWallLongEnough,
|
||||
snapPointTo45Degrees,
|
||||
snapPointToGrid,
|
||||
} from '../wall/wall-drafting'
|
||||
|
||||
export type FencePlanPoint = WallPlanPoint
|
||||
|
||||
type SegmentNode = {
|
||||
start: FencePlanPoint
|
||||
end: FencePlanPoint
|
||||
}
|
||||
|
||||
function distanceSquared(a: FencePlanPoint, b: FencePlanPoint): number {
|
||||
const dx = a[0] - b[0]
|
||||
const dz = a[1] - b[1]
|
||||
return dx * dx + dz * dz
|
||||
}
|
||||
|
||||
function projectPointOntoSegment(
|
||||
point: FencePlanPoint,
|
||||
segment: SegmentNode,
|
||||
): FencePlanPoint | null {
|
||||
const [x1, z1] = segment.start
|
||||
const [x2, z2] = segment.end
|
||||
const dx = x2 - x1
|
||||
const dz = z2 - z1
|
||||
const lengthSquared = dx * dx + dz * dz
|
||||
if (lengthSquared < 1e-9) {
|
||||
return null
|
||||
}
|
||||
|
||||
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
|
||||
if (t <= 0 || t >= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [x1 + dx * t, z1 + dz * t]
|
||||
}
|
||||
|
||||
function findFenceSnapTarget(
|
||||
point: FencePlanPoint,
|
||||
fences: FenceNode[],
|
||||
ignoreFenceIds: string[] = [],
|
||||
): FencePlanPoint | null {
|
||||
const radiusSquared = 0.35 ** 2
|
||||
const ignoredFenceIds = new Set(ignoreFenceIds)
|
||||
let bestTarget: FencePlanPoint | null = null
|
||||
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const fence of fences) {
|
||||
if (ignoredFenceIds.has(fence.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const candidates: Array<FencePlanPoint | null> = [
|
||||
fence.start,
|
||||
fence.end,
|
||||
projectPointOntoSegment(point, fence),
|
||||
]
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
|
||||
const candidateDistanceSquared = distanceSquared(point, candidate)
|
||||
if (
|
||||
candidateDistanceSquared > radiusSquared ||
|
||||
candidateDistanceSquared >= bestDistanceSquared
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
bestTarget = candidate
|
||||
bestDistanceSquared = candidateDistanceSquared
|
||||
}
|
||||
}
|
||||
|
||||
return bestTarget
|
||||
}
|
||||
|
||||
export function snapFenceDraftPoint(args: {
|
||||
point: FencePlanPoint
|
||||
walls: WallNode[]
|
||||
fences: FenceNode[]
|
||||
start?: FencePlanPoint
|
||||
angleSnap?: boolean
|
||||
ignoreFenceIds?: string[]
|
||||
}): FencePlanPoint {
|
||||
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
|
||||
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
|
||||
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
|
||||
|
||||
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
|
||||
}
|
||||
|
||||
export function createFenceOnCurrentLevel(
|
||||
start: FencePlanPoint,
|
||||
end: FencePlanPoint,
|
||||
): FenceNode | null {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
|
||||
if (!(currentLevelId && isWallLongEnough(start, end))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length
|
||||
const fence = FenceNode.parse({
|
||||
name: `Fence ${fenceCount + 1}`,
|
||||
start,
|
||||
end,
|
||||
})
|
||||
|
||||
createNode(fence, currentLevelId)
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
|
||||
return fence
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import {
|
||||
createFenceOnCurrentLevel,
|
||||
snapFenceDraftPoint,
|
||||
type FencePlanPoint,
|
||||
} from './fence-drafting'
|
||||
|
||||
const FENCE_PREVIEW_HEIGHT = 1.8
|
||||
|
||||
const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
|
||||
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
|
||||
const length = direction.length()
|
||||
|
||||
if (length < 0.01) {
|
||||
mesh.visible = false
|
||||
return
|
||||
}
|
||||
|
||||
mesh.visible = true
|
||||
direction.normalize()
|
||||
|
||||
const shape = new Shape()
|
||||
shape.moveTo(0, 0)
|
||||
shape.lineTo(length, 0)
|
||||
shape.lineTo(length, FENCE_PREVIEW_HEIGHT)
|
||||
shape.lineTo(0, FENCE_PREVIEW_HEIGHT)
|
||||
shape.closePath()
|
||||
|
||||
const geometry = new ShapeGeometry(shape)
|
||||
const angle = -Math.atan2(direction.z, direction.x)
|
||||
|
||||
mesh.position.set(start.x, start.y, start.z)
|
||||
mesh.rotation.y = angle
|
||||
|
||||
if (mesh.geometry) {
|
||||
mesh.geometry.dispose()
|
||||
}
|
||||
mesh.geometry = geometry
|
||||
}
|
||||
|
||||
const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
const { nodes } = useScene.getState()
|
||||
|
||||
if (!currentLevelId) return { walls: [], fences: [] }
|
||||
|
||||
const levelNode = nodes[currentLevelId]
|
||||
if (!levelNode || levelNode.type !== 'level') return { walls: [], fences: [] }
|
||||
|
||||
const children = (levelNode as LevelNode).children.map((childId) => nodes[childId])
|
||||
|
||||
return {
|
||||
walls: children.filter((node): node is WallNode => node?.type === 'wall'),
|
||||
fences: children.filter((node): node is FenceNode => node?.type === 'fence'),
|
||||
}
|
||||
}
|
||||
|
||||
export const FenceTool: React.FC = () => {
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const previewRef = useRef<Mesh>(null!)
|
||||
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||
const endingPoint = useRef(new Vector3(0, 0, 0))
|
||||
const buildingState = useRef(0)
|
||||
const shiftPressed = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
let previousFenceEnd: [number, number] | null = null
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!(cursorRef.current && previewRef.current)) return
|
||||
|
||||
const { walls, fences } = getCurrentLevelElements()
|
||||
const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
|
||||
if (buildingState.current === 1) {
|
||||
const snappedLocal = snapFenceDraftPoint({
|
||||
point: localPoint,
|
||||
walls,
|
||||
fences,
|
||||
start: [startingPoint.current.x, startingPoint.current.z],
|
||||
angleSnap: !shiftPressed.current,
|
||||
})
|
||||
endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1])
|
||||
cursorRef.current.position.copy(endingPoint.current)
|
||||
|
||||
const currentFenceEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
|
||||
if (
|
||||
previousFenceEnd &&
|
||||
(currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
previousFenceEnd = currentFenceEnd
|
||||
|
||||
updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current)
|
||||
} else {
|
||||
const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences })
|
||||
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
||||
}
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const { walls, fences } = getCurrentLevelElements()
|
||||
const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
|
||||
if (buildingState.current === 0) {
|
||||
const snappedStart = snapFenceDraftPoint({ point: localClick, walls, fences })
|
||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
buildingState.current = 1
|
||||
previewRef.current.visible = true
|
||||
} else {
|
||||
const snappedEnd = snapFenceDraftPoint({
|
||||
point: localClick,
|
||||
walls,
|
||||
fences,
|
||||
start: [startingPoint.current.x, startingPoint.current.z],
|
||||
angleSnap: !shiftPressed.current,
|
||||
})
|
||||
const dx = snappedEnd[0] - startingPoint.current.x
|
||||
const dz = snappedEnd[1] - startingPoint.current.z
|
||||
if (dx * dx + dz * dz < 0.01 * 0.01) return
|
||||
createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
|
||||
previewRef.current.visible = false
|
||||
buildingState.current = 0
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = true
|
||||
}
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = false
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
if (buildingState.current === 1) {
|
||||
markToolCancelConsumed()
|
||||
buildingState.current = 0
|
||||
previewRef.current.visible = 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 () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere ref={cursorRef} height={FENCE_PREVIEW_HEIGHT} />
|
||||
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
|
||||
<shapeGeometry />
|
||||
<meshBasicMaterial
|
||||
color="#ffffff"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.45}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, type FenceNode, emitter, type GridEvent, 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'
|
||||
|
||||
function snap(value: number) {
|
||||
return Math.round(value * 2) / 2
|
||||
}
|
||||
|
||||
function samePoint(a: [number, number], b: [number, number]) {
|
||||
return a[0] === b[0] && a[1] === b[1]
|
||||
}
|
||||
|
||||
type LinkedFenceSnapshot = {
|
||||
id: FenceNode['id']
|
||||
start: [number, number]
|
||||
end: [number, number]
|
||||
}
|
||||
|
||||
function getLinkedFenceSnapshots(args: {
|
||||
fenceId: FenceNode['id']
|
||||
originalStart: [number, number]
|
||||
originalEnd: [number, number]
|
||||
}) {
|
||||
const { fenceId, originalStart, originalEnd } = args
|
||||
const { nodes } = useScene.getState()
|
||||
const snapshots: LinkedFenceSnapshot[] = []
|
||||
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (!(node?.type === 'fence' && node.id !== fenceId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
!samePoint(node.start, originalStart) &&
|
||||
!samePoint(node.start, originalEnd) &&
|
||||
!samePoint(node.end, originalStart) &&
|
||||
!samePoint(node.end, originalEnd)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
snapshots.push({
|
||||
id: node.id,
|
||||
start: [...node.start] as [number, number],
|
||||
end: [...node.end] as [number, number],
|
||||
})
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
|
||||
function getLinkedFenceUpdates(
|
||||
linkedFences: LinkedFenceSnapshot[],
|
||||
originalStart: [number, number],
|
||||
originalEnd: [number, number],
|
||||
nextStart: [number, number],
|
||||
nextEnd: [number, number],
|
||||
) {
|
||||
return linkedFences.map((fence) => ({
|
||||
id: fence.id,
|
||||
start: samePoint(fence.start, originalStart)
|
||||
? nextStart
|
||||
: samePoint(fence.start, originalEnd)
|
||||
? nextEnd
|
||||
: fence.start,
|
||||
end: samePoint(fence.end, originalStart)
|
||||
? nextStart
|
||||
: samePoint(fence.end, originalEnd)
|
||||
? nextEnd
|
||||
: fence.end,
|
||||
}))
|
||||
}
|
||||
|
||||
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
|
||||
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
|
||||
const linkedOriginalsRef = useRef(
|
||||
getLinkedFenceSnapshots({
|
||||
fenceId: node.id,
|
||||
originalStart: node.start,
|
||||
originalEnd: node.end,
|
||||
}),
|
||||
)
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
const nodeIdRef = useRef(node.id)
|
||||
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
|
||||
|
||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
||||
const centerX = (node.start[0] + node.end[0]) / 2
|
||||
const centerZ = (node.start[1] + node.end[1]) / 2
|
||||
return [centerX, 0, centerZ]
|
||||
})
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const nodeId = nodeIdRef.current
|
||||
const originalStart = originalStartRef.current
|
||||
const originalEnd = originalEndRef.current
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
let wasCommitted = false
|
||||
|
||||
const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => {
|
||||
useScene.getState().updateNodes(
|
||||
updates.map((entry) => ({
|
||||
id: entry.id as AnyNodeId,
|
||||
data: { start: entry.start, end: entry.end },
|
||||
})),
|
||||
)
|
||||
for (const entry of updates) {
|
||||
useScene.getState().markDirty(entry.id as AnyNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => {
|
||||
previewRef.current = { start: nextStart, end: nextEnd }
|
||||
const centerX = (nextStart[0] + nextEnd[0]) / 2
|
||||
const centerZ = (nextStart[1] + nextEnd[1]) / 2
|
||||
setCursorLocalPos([centerX, 0, centerZ])
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: nextStart, end: nextEnd },
|
||||
...getLinkedFenceUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const localX = snap(event.localPosition[0])
|
||||
const localZ = snap(event.localPosition[2])
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
(localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
previousGridPosRef.current = [localX, localZ]
|
||||
|
||||
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
||||
dragAnchorRef.current = anchor
|
||||
|
||||
const deltaX = localX - anchor[0]
|
||||
const deltaZ = localZ - anchor[1]
|
||||
|
||||
const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ]
|
||||
const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ]
|
||||
|
||||
applyPreview(nextStart, nextEnd)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||
|
||||
wasCommitted = true
|
||||
useScene.temporal.getState().resume()
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: preview.start, end: preview.end },
|
||||
...getLinkedFenceUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
preview.start,
|
||||
preview.end,
|
||||
),
|
||||
])
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [exitMoveMode])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
BuildingNode,
|
||||
DoorNode,
|
||||
FenceNode,
|
||||
ItemNode,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
@@ -13,6 +14,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { MoveBuildingContent } from '../building/move-building-tool'
|
||||
import { MoveDoorTool } from '../door/move-door-tool'
|
||||
import { MoveFenceTool } from '../fence/move-fence-tool'
|
||||
import { MoveRoofTool } from '../roof/move-roof-tool'
|
||||
import { MoveWindowTool } from '../window/move-window-tool'
|
||||
import type { PlacementState } from './placement-types'
|
||||
@@ -86,6 +88,7 @@ export const MoveTool: React.FC = () => {
|
||||
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
||||
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
||||
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
||||
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
||||
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
||||
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
||||
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
||||
|
||||
@@ -197,7 +197,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
||||
const node = nodes[childId as AnyNodeId]
|
||||
if (!node) continue
|
||||
|
||||
if (node.type === 'wall') {
|
||||
if (node.type === 'wall' || node.type === 'fence') {
|
||||
const wall = node as WallNode
|
||||
if (
|
||||
segmentIntersectsBounds(wall.start[0], wall.start[1], wall.end[0], wall.end[1], bounds)
|
||||
@@ -205,7 +205,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
||||
result.push(wall.id)
|
||||
}
|
||||
// Check wall children (doors/windows)
|
||||
for (const itemId of wall.children) {
|
||||
for (const itemId of Array.isArray(wall.children) ? wall.children : []) {
|
||||
const child = nodes[itemId as AnyNodeId]
|
||||
if (!child) continue
|
||||
if (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export const DEFAULT_STAIR_TYPE = 'straight' as const
|
||||
export const DEFAULT_STAIR_WIDTH = 1.0
|
||||
export const DEFAULT_STAIR_LENGTH = 3.0
|
||||
export const DEFAULT_STAIR_HEIGHT = 2.5
|
||||
@@ -5,3 +6,12 @@ export const DEFAULT_STAIR_STEP_COUNT = 10
|
||||
export const DEFAULT_STAIR_ATTACHMENT_SIDE = 'front' as const
|
||||
export const DEFAULT_STAIR_FILL_TO_FLOOR = true
|
||||
export const DEFAULT_STAIR_THICKNESS = 0.25
|
||||
export const DEFAULT_CURVED_STAIR_INNER_RADIUS = 0.9
|
||||
export const DEFAULT_CURVED_STAIR_SWEEP_ANGLE = Math.PI / 2
|
||||
export const DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE = (400 * Math.PI) / 180
|
||||
export const DEFAULT_SPIRAL_TOP_LANDING_MODE = 'none' as const
|
||||
export const DEFAULT_SPIRAL_TOP_LANDING_DEPTH = 0.9
|
||||
export const DEFAULT_SPIRAL_SHOW_CENTER_COLUMN = true
|
||||
export const DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS = true
|
||||
export const DEFAULT_STAIR_RAILING_MODE = 'right' as const
|
||||
export const DEFAULT_STAIR_RAILING_HEIGHT = 0.92
|
||||
|
||||
@@ -13,12 +13,21 @@ import * as THREE from 'three'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import {
|
||||
DEFAULT_CURVED_STAIR_INNER_RADIUS,
|
||||
DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
|
||||
DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
|
||||
DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
|
||||
DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
|
||||
DEFAULT_SPIRAL_TOP_LANDING_MODE,
|
||||
DEFAULT_STAIR_ATTACHMENT_SIDE,
|
||||
DEFAULT_STAIR_FILL_TO_FLOOR,
|
||||
DEFAULT_STAIR_HEIGHT,
|
||||
DEFAULT_STAIR_LENGTH,
|
||||
DEFAULT_STAIR_RAILING_HEIGHT,
|
||||
DEFAULT_STAIR_RAILING_MODE,
|
||||
DEFAULT_STAIR_STEP_COUNT,
|
||||
DEFAULT_STAIR_THICKNESS,
|
||||
DEFAULT_STAIR_TYPE,
|
||||
DEFAULT_STAIR_WIDTH,
|
||||
} from './stair-defaults'
|
||||
|
||||
@@ -88,6 +97,20 @@ function commitStairPlacement(
|
||||
name,
|
||||
position,
|
||||
rotation,
|
||||
stairType: DEFAULT_STAIR_TYPE,
|
||||
width: DEFAULT_STAIR_WIDTH,
|
||||
totalRise: DEFAULT_STAIR_HEIGHT,
|
||||
stepCount: DEFAULT_STAIR_STEP_COUNT,
|
||||
thickness: DEFAULT_STAIR_THICKNESS,
|
||||
fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
|
||||
innerRadius: DEFAULT_CURVED_STAIR_INNER_RADIUS,
|
||||
sweepAngle: DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
|
||||
topLandingMode: DEFAULT_SPIRAL_TOP_LANDING_MODE,
|
||||
topLandingDepth: DEFAULT_SPIRAL_TOP_LANDING_DEPTH,
|
||||
showCenterColumn: DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
|
||||
showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
|
||||
railingHeight: DEFAULT_STAIR_RAILING_HEIGHT,
|
||||
railingMode: DEFAULT_STAIR_RAILING_MODE,
|
||||
children: [segment.id],
|
||||
})
|
||||
|
||||
|
||||
@@ -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 { FenceTool } from './fence/fence-tool'
|
||||
import { ItemTool } from './item/item-tool'
|
||||
import { MoveTool } from './item/move-tool'
|
||||
import { RoofTool } from './roof/roof-tool'
|
||||
@@ -30,6 +31,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
||||
},
|
||||
structure: {
|
||||
wall: WallTool,
|
||||
fence: FenceTool,
|
||||
slab: SlabTool,
|
||||
ceiling: CeilingTool,
|
||||
roof: RoofTool,
|
||||
|
||||
@@ -77,7 +77,7 @@ export function hasWallChildOverlap(
|
||||
const newLeft = clampedX - halfW
|
||||
const newRight = clampedX + halfW
|
||||
|
||||
for (const childId of wallNode.children) {
|
||||
for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
|
||||
if (childId === ignoreId) continue
|
||||
const child = nodes[childId as AnyNodeId]
|
||||
if (!child) continue
|
||||
|
||||
@@ -28,6 +28,7 @@ export const tools: ToolConfig[] = [
|
||||
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
|
||||
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
||||
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
|
||||
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
|
||||
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, type FenceBaseStyle, type FenceNode, type FenceStyle, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyle }[] = [
|
||||
{ label: 'Slat', value: 'slat' },
|
||||
{ label: 'Rail', value: 'rail' },
|
||||
{ label: 'Privacy', value: 'privacy' },
|
||||
]
|
||||
|
||||
const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyle }[] = [
|
||||
{ label: 'Grounded', value: 'grounded' },
|
||||
{ label: 'Floating', value: 'floating' },
|
||||
]
|
||||
|
||||
export function FencePanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId ? (nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<FenceNode>) => {
|
||||
if (!selectedId) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
|
||||
},
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleUpdateLength = useCallback(
|
||||
(newLength: number) => {
|
||||
if (!node || newLength <= 0) return
|
||||
|
||||
const dx = node.end[0] - node.start[0]
|
||||
const dz = node.end[1] - node.start[1]
|
||||
const currentLength = Math.sqrt(dx * dx + dz * dz)
|
||||
if (currentLength === 0) return
|
||||
|
||||
const dirX = dx / currentLength
|
||||
const dirZ = dz / currentLength
|
||||
const newEnd: [number, number] = [
|
||||
node.start[0] + dirX * newLength,
|
||||
node.start[1] + dirZ * newLength,
|
||||
]
|
||||
|
||||
handleUpdate({ end: newEnd })
|
||||
},
|
||||
[node, handleUpdate],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
if (!node || node.type !== 'fence' || selectedIds.length !== 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)
|
||||
|
||||
return (
|
||||
<PanelWrapper icon="/icons/build.png" onClose={handleClose} title={node.name || 'Fence'} width={300}>
|
||||
<PanelSection title="Style">
|
||||
<SegmentedControl
|
||||
onChange={(value) => handleUpdate({ style: value })}
|
||||
options={FENCE_STYLE_OPTIONS}
|
||||
value={node.style}
|
||||
/>
|
||||
<SegmentedControl
|
||||
className="mt-2"
|
||||
onChange={(value) => handleUpdate({ baseStyle: value })}
|
||||
options={FENCE_BASE_STYLE_OPTIONS}
|
||||
value={node.baseStyle}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Length"
|
||||
max={50}
|
||||
min={0.1}
|
||||
onChange={handleUpdateLength}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={length}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={4}
|
||||
min={0.4}
|
||||
onChange={(value) => handleUpdate({ height: Math.max(0.4, value) })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={node.height}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
max={0.5}
|
||||
min={0.03}
|
||||
onChange={(value) => handleUpdate({ thickness: Math.max(0.03, value) })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.thickness}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Structure">
|
||||
<SliderControl
|
||||
label="Base Height"
|
||||
max={1}
|
||||
min={0.04}
|
||||
onChange={(value) => handleUpdate({ baseHeight: Math.max(0.04, value) })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={node.baseHeight}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Top Rail"
|
||||
max={0.25}
|
||||
min={0.01}
|
||||
onChange={(value) => handleUpdate({ topRailHeight: Math.max(0.01, value) })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.topRailHeight}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Post Spacing"
|
||||
max={5}
|
||||
min={0.2}
|
||||
onChange={(value) => handleUpdate({ postSpacing: Math.max(0.2, value) })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={node.postSpacing}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Post Size"
|
||||
max={0.4}
|
||||
min={0.01}
|
||||
onChange={(value) => handleUpdate({ postSize: Math.max(0.01, value) })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.postSize}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Ground Clear"
|
||||
max={0.6}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ groundClearance: Math.max(0, value) })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.groundClearance}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Edge Inset"
|
||||
max={0.25}
|
||||
min={0.005}
|
||||
onChange={(value) => handleUpdate({ edgeInset: Math.max(0.005, value) })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.edgeInset}
|
||||
/>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CeilingPanel } from './ceiling-panel'
|
||||
import { DoorPanel } from './door-panel'
|
||||
import { FencePanel } from './fence-panel'
|
||||
import { ItemPanel } from './item-panel'
|
||||
import { ReferencePanel } from './reference-panel'
|
||||
import { RoofPanel } from './roof-panel'
|
||||
@@ -47,6 +48,8 @@ export function PanelManager() {
|
||||
return <CeilingPanel />
|
||||
case 'wall':
|
||||
return <WallPanel />
|
||||
case 'fence':
|
||||
return <FencePanel />
|
||||
case 'door':
|
||||
return <DoorPanel />
|
||||
case 'window':
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
type AnyNodeId,
|
||||
type MaterialSchema,
|
||||
type StairNode,
|
||||
type StairRailingMode,
|
||||
type StairTopLandingMode,
|
||||
type StairType,
|
||||
StairNode as StairNodeSchema,
|
||||
type StairSegmentNode,
|
||||
StairSegmentNode as StairSegmentNodeSchema,
|
||||
@@ -15,19 +18,41 @@ import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { ToggleControl } from '../controls/toggle-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
{ label: 'Both', value: 'both' },
|
||||
]
|
||||
|
||||
const STAIR_TYPE_OPTIONS: { label: string; value: StairType }[] = [
|
||||
{ label: 'Straight', value: 'straight' },
|
||||
{ label: 'Curved', value: 'curved' },
|
||||
{ label: 'Spiral', value: 'spiral' },
|
||||
]
|
||||
|
||||
const TOP_LANDING_MODE_OPTIONS: { label: string; value: StairTopLandingMode }[] = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Integrated', value: 'integrated' },
|
||||
]
|
||||
|
||||
export function StairPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const createNodes = useScene((s) => s.createNodes)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
@@ -114,7 +139,8 @@ export function StairPanel() {
|
||||
|
||||
let duplicateInfo = structuredClone(node) as any
|
||||
delete duplicateInfo.id
|
||||
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
||||
duplicateInfo.metadata = { ...duplicateInfo.metadata }
|
||||
duplicateInfo.children = []
|
||||
duplicateInfo.position = [
|
||||
duplicateInfo.position[0] + 1,
|
||||
duplicateInfo.position[1],
|
||||
@@ -123,29 +149,31 @@ export function StairPanel() {
|
||||
|
||||
try {
|
||||
const duplicate = StairNodeSchema.parse(duplicateInfo)
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
|
||||
// Also duplicate all child segments
|
||||
const nodesState = useScene.getState().nodes
|
||||
const children = node.children || []
|
||||
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
|
||||
{ node: duplicate, parentId: duplicate.parentId as AnyNodeId },
|
||||
]
|
||||
|
||||
for (const childId of children) {
|
||||
const childNode = nodesState[childId]
|
||||
if (childNode && childNode.type === 'stair-segment') {
|
||||
let childDuplicateInfo = structuredClone(childNode) as any
|
||||
delete childDuplicateInfo.id
|
||||
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
|
||||
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
|
||||
const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
|
||||
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
|
||||
createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
|
||||
}
|
||||
}
|
||||
|
||||
setSelection({ selectedIds: [] })
|
||||
setMovingNode(duplicate)
|
||||
createNodes(createOps)
|
||||
|
||||
setSelection({ selectedIds: [duplicate.id as AnyNode['id']] })
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate stair', e)
|
||||
}
|
||||
}, [node, setSelection, setMovingNode])
|
||||
}, [createNodes, node, setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (node) {
|
||||
@@ -179,34 +207,159 @@ export function StairPanel() {
|
||||
title={node.name || 'Staircase'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Segments">
|
||||
<div className="flex flex-col gap-1">
|
||||
{segments.map((seg, i) => (
|
||||
<button
|
||||
className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]"
|
||||
key={seg.id}
|
||||
onClick={() => handleSelectSegment(seg.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{seg.name || `Segment ${i + 1}`}</span>
|
||||
<span className="text-muted-foreground text-xs capitalize">{seg.segmentType}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<ActionButton
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add flight"
|
||||
onClick={handleAddFlight}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add landing"
|
||||
onClick={handleAddLanding}
|
||||
/>
|
||||
</div>
|
||||
<PanelSection title="Type">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate(
|
||||
value === 'spiral' && node.stairType !== 'spiral'
|
||||
? {
|
||||
stairType: value,
|
||||
sweepAngle: DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE,
|
||||
position: [node.position[0], 0, node.position[2]],
|
||||
}
|
||||
: { stairType: value },
|
||||
)
|
||||
}
|
||||
options={STAIR_TYPE_OPTIONS}
|
||||
value={node.stairType ?? 'straight'}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{node.stairType === 'straight' && (
|
||||
<PanelSection title="Segments">
|
||||
<div className="flex flex-col gap-1">
|
||||
{segments.map((seg, i) => (
|
||||
<button
|
||||
className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]"
|
||||
key={seg.id}
|
||||
onClick={() => handleSelectSegment(seg.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{seg.name || `Segment ${i + 1}`}</span>
|
||||
<span className="text-muted-foreground text-xs capitalize">{seg.segmentType}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<ActionButton
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add flight"
|
||||
onClick={handleAddFlight}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add landing"
|
||||
onClick={handleAddLanding}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{(node.stairType === 'curved' || node.stairType === 'spiral') && (
|
||||
<PanelSection title="Geometry">
|
||||
<MetricControl
|
||||
label="Width"
|
||||
max={10}
|
||||
min={0.4}
|
||||
onChange={(value) => handleUpdate({ width: value })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.width ?? 1) * 100) / 100}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Rise"
|
||||
max={10}
|
||||
min={0.2}
|
||||
onChange={(value) => handleUpdate({ totalRise: value })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Steps"
|
||||
max={32}
|
||||
min={2}
|
||||
onChange={(value) => handleUpdate({ stepCount: Math.max(2, Math.round(value)) })}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit=""
|
||||
value={Math.max(2, Math.round(node.stepCount ?? 10))}
|
||||
/>
|
||||
{node.stairType !== 'spiral' && (
|
||||
<ToggleControl
|
||||
checked={node.fillToFloor ?? true}
|
||||
label="Fit To Floor"
|
||||
onChange={(checked) => handleUpdate({ fillToFloor: checked })}
|
||||
/>
|
||||
)}
|
||||
{(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
|
||||
<MetricControl
|
||||
label="Thickness"
|
||||
max={1}
|
||||
min={0.02}
|
||||
onChange={(value) => handleUpdate({ thickness: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
|
||||
/>
|
||||
)}
|
||||
<MetricControl
|
||||
label="Inner Radius"
|
||||
max={10}
|
||||
min={node.stairType === 'spiral' ? 0.05 : 0.2}
|
||||
onChange={(value) => handleUpdate({ innerRadius: value })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.innerRadius ?? 0.9) * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Sweep"
|
||||
max={node.stairType === 'spiral' ? 720 : 270}
|
||||
min={node.stairType === 'spiral' ? -720 : -270}
|
||||
onChange={(degrees) => handleUpdate({ sweepAngle: (degrees * Math.PI) / 180 })}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round(((node.sweepAngle ?? Math.PI / 2) * 180) / Math.PI)}
|
||||
/>
|
||||
{node.stairType === 'spiral' && (
|
||||
<>
|
||||
<SegmentedControl
|
||||
onChange={(value) => handleUpdate({ topLandingMode: value })}
|
||||
options={TOP_LANDING_MODE_OPTIONS}
|
||||
value={node.topLandingMode ?? 'none'}
|
||||
/>
|
||||
{(node.topLandingMode ?? 'none') === 'integrated' && (
|
||||
<MetricControl
|
||||
label="Top Landing"
|
||||
max={5}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ topLandingDepth: value })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100}
|
||||
/>
|
||||
)}
|
||||
<ToggleControl
|
||||
checked={node.showCenterColumn ?? true}
|
||||
label="Center Column"
|
||||
onChange={(checked) => handleUpdate({ showCenterColumn: checked })}
|
||||
/>
|
||||
<ToggleControl
|
||||
checked={node.showStepSupports ?? true}
|
||||
label="Step Supports"
|
||||
onChange={(checked) => handleUpdate({ showStepSupports: checked })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Position">
|
||||
<MetricControl
|
||||
label="X"
|
||||
@@ -280,6 +433,26 @@ export function StairPanel() {
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Railing">
|
||||
<SegmentedControl
|
||||
onChange={(value) => handleUpdate({ railingMode: value })}
|
||||
options={RAILING_MODE_OPTIONS}
|
||||
value={node.railingMode ?? 'none'}
|
||||
/>
|
||||
{(node.railingMode ?? 'none') !== 'none' && (
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={1.4}
|
||||
min={0.7}
|
||||
onChange={(value) => handleUpdate({ railingHeight: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={Math.round((node.railingHeight ?? 0.92) * 100) / 100}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { type AnyNodeId, type FenceNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import useEditor from '../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
|
||||
interface FenceTreeNodeProps {
|
||||
nodeId: AnyNodeId
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function FenceTreeNode({ nodeId, depth, isLast }: FenceTreeNodeProps) {
|
||||
const node = useScene((state) => state.nodes[nodeId]) as FenceNode | undefined
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(nodeId)
|
||||
const isHovered = useViewer((state) => state.hoveredId === nodeId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
|
||||
if (!node) return null
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const handled = handleTreeSelection(e, nodeId, selectedIds, setSelection)
|
||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/fence.png" width={14} />
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName="Fence"
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
}
|
||||
nodeId={nodeId}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={() => focusTreeNode(nodeId)}
|
||||
onMouseEnter={() => setHoveredId(nodeId)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import { cn } from '../../../../../lib/utils'
|
||||
import { BuildingTreeNode } from './building-tree-node'
|
||||
import { CeilingTreeNode } from './ceiling-tree-node'
|
||||
import { DoorTreeNode } from './door-tree-node'
|
||||
import { FenceTreeNode } from './fence-tree-node'
|
||||
import { ItemTreeNode } from './item-tree-node'
|
||||
import { LevelTreeNode } from './level-tree-node'
|
||||
import { RoofTreeNode } from './roof-tree-node'
|
||||
@@ -88,6 +89,8 @@ export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
||||
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'wall':
|
||||
return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'fence':
|
||||
return <FenceTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'roof':
|
||||
return <RoofTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'stair':
|
||||
|
||||
@@ -62,6 +62,7 @@ const wallModeConfig = {
|
||||
const getNodeName = (node: AnyNode): string => {
|
||||
if ('name' in node && node.name) return node.name
|
||||
if (node.type === 'wall') return 'Wall'
|
||||
if (node.type === 'fence') return 'Fence'
|
||||
if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item'
|
||||
if (node.type === 'slab') return 'Slab'
|
||||
if (node.type === 'ceiling') return 'Ceiling'
|
||||
|
||||
@@ -16,7 +16,15 @@ export function useContextualTools() {
|
||||
}
|
||||
|
||||
// Default tools when nothing is selected
|
||||
const defaultTools: StructureTool[] = ['wall', 'slab', 'ceiling', 'roof', 'door', 'window']
|
||||
const defaultTools: StructureTool[] = [
|
||||
'wall',
|
||||
'fence',
|
||||
'slab',
|
||||
'ceiling',
|
||||
'roof',
|
||||
'door',
|
||||
'window',
|
||||
]
|
||||
|
||||
if (selection.selectedIds.length === 0) {
|
||||
return defaultTools
|
||||
@@ -29,7 +37,7 @@ export function useContextualTools() {
|
||||
|
||||
// If a wall is selected, prioritize wall-hosted elements
|
||||
if (selectedTypes.has('wall')) {
|
||||
return ['window', 'door', 'wall'] as StructureTool[]
|
||||
return ['window', 'door', 'wall', 'fence'] as StructureTool[]
|
||||
}
|
||||
|
||||
// If a slab is selected, prioritize slab editing
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AssetInput } from '@pascal-app/core'
|
||||
import {
|
||||
type BuildingNode,
|
||||
type DoorNode,
|
||||
type FenceNode,
|
||||
type ItemNode,
|
||||
type LevelNode,
|
||||
type RoofNode,
|
||||
@@ -33,6 +34,7 @@ export type Mode = 'select' | 'edit' | 'delete' | 'build'
|
||||
// Structure mode tools (building elements)
|
||||
export type StructureTool =
|
||||
| 'wall'
|
||||
| 'fence'
|
||||
| 'room'
|
||||
| 'custom-room'
|
||||
| 'slab'
|
||||
@@ -85,6 +87,7 @@ type EditorState = {
|
||||
| ItemNode
|
||||
| WindowNode
|
||||
| DoorNode
|
||||
| FenceNode
|
||||
| RoofNode
|
||||
| RoofSegmentNode
|
||||
| StairNode
|
||||
@@ -96,6 +99,7 @@ type EditorState = {
|
||||
| ItemNode
|
||||
| WindowNode
|
||||
| DoorNode
|
||||
| FenceNode
|
||||
| RoofNode
|
||||
| RoofSegmentNode
|
||||
| StairNode
|
||||
|
||||
Reference in New Issue
Block a user