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
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -48,7 +48,7 @@
|
|||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@pascal-app/core",
|
"name": "@pascal-app/core",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dedent": "^1.7.1",
|
"dedent": "^1.7.1",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
},
|
},
|
||||||
"packages/viewer": {
|
"packages/viewer": {
|
||||||
"name": "@pascal-app/viewer",
|
"name": "@pascal-app/viewer",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"polygon-clipping": "^0.15.7",
|
"polygon-clipping": "^0.15.7",
|
||||||
"zustand": "^5",
|
"zustand": "^5",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
BuildingNode,
|
BuildingNode,
|
||||||
CeilingNode,
|
CeilingNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
|
FenceNode,
|
||||||
ItemNode,
|
ItemNode,
|
||||||
LevelNode,
|
LevelNode,
|
||||||
RoofNode,
|
RoofNode,
|
||||||
@@ -41,6 +42,7 @@ export interface NodeEvent<T extends AnyNode = AnyNode> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type WallEvent = NodeEvent<WallNode>
|
export type WallEvent = NodeEvent<WallNode>
|
||||||
|
export type FenceEvent = NodeEvent<FenceNode>
|
||||||
export type ItemEvent = NodeEvent<ItemNode>
|
export type ItemEvent = NodeEvent<ItemNode>
|
||||||
export type SiteEvent = NodeEvent<SiteNode>
|
export type SiteEvent = NodeEvent<SiteNode>
|
||||||
export type BuildingEvent = NodeEvent<BuildingNode>
|
export type BuildingEvent = NodeEvent<BuildingNode>
|
||||||
@@ -111,6 +113,7 @@ type ThumbnailEvents = {
|
|||||||
|
|
||||||
type EditorEvents = GridEvents &
|
type EditorEvents = GridEvents &
|
||||||
NodeEvents<'wall', WallEvent> &
|
NodeEvents<'wall', WallEvent> &
|
||||||
|
NodeEvents<'fence', FenceEvent> &
|
||||||
NodeEvents<'item', ItemEvent> &
|
NodeEvents<'item', ItemEvent> &
|
||||||
NodeEvents<'site', SiteEvent> &
|
NodeEvents<'site', SiteEvent> &
|
||||||
NodeEvents<'building', BuildingEvent> &
|
NodeEvents<'building', BuildingEvent> &
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const sceneRegistry = {
|
|||||||
ceiling: new Set<string>(),
|
ceiling: new Set<string>(),
|
||||||
level: new Set<string>(),
|
level: new Set<string>(),
|
||||||
wall: new Set<string>(),
|
wall: new Set<string>(),
|
||||||
|
fence: new Set<string>(),
|
||||||
item: new Set<string>(),
|
item: new Set<string>(),
|
||||||
slab: new Set<string>(),
|
slab: new Set<string>(),
|
||||||
zone: new Set<string>(),
|
zone: new Set<string>(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type {
|
|||||||
CeilingEvent,
|
CeilingEvent,
|
||||||
DoorEvent,
|
DoorEvent,
|
||||||
EventSuffix,
|
EventSuffix,
|
||||||
|
FenceEvent,
|
||||||
GridEvent,
|
GridEvent,
|
||||||
ItemEvent,
|
ItemEvent,
|
||||||
LevelEvent,
|
LevelEvent,
|
||||||
@@ -44,6 +45,7 @@ export {
|
|||||||
useInteractive,
|
useInteractive,
|
||||||
} from './store/use-interactive'
|
} from './store/use-interactive'
|
||||||
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||||
|
export { FenceSystem } from './systems/fence/fence-system'
|
||||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||||
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
||||||
export { DoorSystem } from './systems/door/door-system'
|
export { DoorSystem } from './systems/door/door-system'
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export {
|
|||||||
export { BuildingNode } from './nodes/building'
|
export { BuildingNode } from './nodes/building'
|
||||||
export { CeilingNode } from './nodes/ceiling'
|
export { CeilingNode } from './nodes/ceiling'
|
||||||
export { DoorNode, DoorSegment } from './nodes/door'
|
export { DoorNode, DoorSegment } from './nodes/door'
|
||||||
|
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
|
||||||
export { GuideNode } from './nodes/guide'
|
export { GuideNode } from './nodes/guide'
|
||||||
export type {
|
export type {
|
||||||
AnimationEffect,
|
AnimationEffect,
|
||||||
@@ -36,7 +37,7 @@ export { ScanNode } from './nodes/scan'
|
|||||||
// Nodes
|
// Nodes
|
||||||
export { SiteNode } from './nodes/site'
|
export { SiteNode } from './nodes/site'
|
||||||
export { SlabNode } from './nodes/slab'
|
export { SlabNode } from './nodes/slab'
|
||||||
export { StairNode } from './nodes/stair'
|
export { StairNode, StairRailingMode, StairTopLandingMode, StairType } from './nodes/stair'
|
||||||
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
|
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
|
||||||
export { WallNode } from './nodes/wall'
|
export { WallNode } from './nodes/wall'
|
||||||
export { WindowNode } from './nodes/window'
|
export { WindowNode } from './nodes/window'
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
|
||||||
|
export const FenceStyle = z.enum(['slat', 'rail', 'privacy'])
|
||||||
|
export const FenceBaseStyle = z.enum(['floating', 'grounded'])
|
||||||
|
|
||||||
|
export const FenceNode = BaseNode.extend({
|
||||||
|
id: objectId('fence'),
|
||||||
|
type: nodeType('fence'),
|
||||||
|
start: z.tuple([z.number(), z.number()]),
|
||||||
|
end: z.tuple([z.number(), z.number()]),
|
||||||
|
height: z.number().default(1.8),
|
||||||
|
thickness: z.number().default(0.08),
|
||||||
|
baseHeight: z.number().default(0.22),
|
||||||
|
postSpacing: z.number().default(2),
|
||||||
|
postSize: z.number().default(0.1),
|
||||||
|
topRailHeight: z.number().default(0.04),
|
||||||
|
groundClearance: z.number().default(0),
|
||||||
|
edgeInset: z.number().default(0.015),
|
||||||
|
baseStyle: FenceBaseStyle.default('grounded'),
|
||||||
|
color: z.string().default('#ffffff'),
|
||||||
|
style: FenceStyle.default('slat'),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
Fence node - used to represent a fence segment in the building/site level coordinate system
|
||||||
|
- start/end: fence endpoints in level coordinate system
|
||||||
|
- height/thickness: overall fence dimensions in meters
|
||||||
|
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
|
||||||
|
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
|
||||||
|
- color/style: visual appearance options
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
export type FenceNode = z.infer<typeof FenceNode>
|
||||||
@@ -2,6 +2,7 @@ import dedent from 'dedent'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { BaseNode, nodeType, objectId } from '../base'
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
import { CeilingNode } from './ceiling'
|
import { CeilingNode } from './ceiling'
|
||||||
|
import { FenceNode } from './fence'
|
||||||
import { GuideNode } from './guide'
|
import { GuideNode } from './guide'
|
||||||
import { RoofNode } from './roof'
|
import { RoofNode } from './roof'
|
||||||
import { ScanNode } from './scan'
|
import { ScanNode } from './scan'
|
||||||
@@ -17,6 +18,7 @@ export const LevelNode = BaseNode.extend({
|
|||||||
.array(
|
.array(
|
||||||
z.union([
|
z.union([
|
||||||
WallNode.shape.id,
|
WallNode.shape.id,
|
||||||
|
FenceNode.shape.id,
|
||||||
ZoneNode.shape.id,
|
ZoneNode.shape.id,
|
||||||
SlabNode.shape.id,
|
SlabNode.shape.id,
|
||||||
CeilingNode.shape.id,
|
CeilingNode.shape.id,
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { BaseNode, nodeType, objectId } from '../base'
|
|||||||
import { MaterialSchema } from '../material'
|
import { MaterialSchema } from '../material'
|
||||||
import { StairSegmentNode } from './stair-segment'
|
import { StairSegmentNode } from './stair-segment'
|
||||||
|
|
||||||
|
export const StairRailingMode = z.enum(['none', 'left', 'right', 'both'])
|
||||||
|
export const StairType = z.enum(['straight', 'curved', 'spiral'])
|
||||||
|
export const StairTopLandingMode = z.enum(['none', 'integrated'])
|
||||||
|
|
||||||
|
export type StairRailingMode = z.infer<typeof StairRailingMode>
|
||||||
|
export type StairType = z.infer<typeof StairType>
|
||||||
|
export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
|
||||||
|
|
||||||
export const StairNode = BaseNode.extend({
|
export const StairNode = BaseNode.extend({
|
||||||
id: objectId('stair'),
|
id: objectId('stair'),
|
||||||
type: nodeType('stair'),
|
type: nodeType('stair'),
|
||||||
@@ -11,16 +19,44 @@ export const StairNode = BaseNode.extend({
|
|||||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
// Rotation around Y axis in radians
|
// Rotation around Y axis in radians
|
||||||
rotation: z.number().default(0),
|
rotation: z.number().default(0),
|
||||||
|
stairType: StairType.default('straight'),
|
||||||
|
width: z.number().default(1.0),
|
||||||
|
totalRise: z.number().default(2.5),
|
||||||
|
stepCount: z.number().default(10),
|
||||||
|
thickness: z.number().default(0.25),
|
||||||
|
fillToFloor: z.boolean().default(true),
|
||||||
|
innerRadius: z.number().default(0.9),
|
||||||
|
sweepAngle: z.number().default(Math.PI / 2),
|
||||||
|
topLandingMode: StairTopLandingMode.default('none'),
|
||||||
|
topLandingDepth: z.number().default(0.9),
|
||||||
|
showCenterColumn: z.boolean().default(true),
|
||||||
|
showStepSupports: z.boolean().default(true),
|
||||||
|
railingMode: StairRailingMode.default('none'),
|
||||||
|
railingHeight: z.number().default(0.92),
|
||||||
// Child stair segment IDs
|
// Child stair segment IDs
|
||||||
children: z.array(StairSegmentNode.shape.id).default([]),
|
children: z.array(StairSegmentNode.shape.id).default([]),
|
||||||
}).describe(
|
}).describe(
|
||||||
dedent`
|
dedent`
|
||||||
Stair node - a container for stair segments.
|
Stair node - a container for stair segments.
|
||||||
Acts as a group that holds one or more StairSegmentNodes (flights and landings).
|
Acts as a group that either holds one or more StairSegmentNodes (straight stairs)
|
||||||
Segments chain together based on their attachmentSide to form complex staircase shapes.
|
or stores stair-level geometry properties for curved stairs.
|
||||||
- position: center position of the stair group
|
- position: center position of the stair group
|
||||||
- rotation: rotation around Y axis
|
- rotation: rotation around Y axis
|
||||||
- children: array of StairSegmentNode IDs
|
- stairType: straight (segment-based), curved (arc-based), or spiral
|
||||||
|
- width: stair width
|
||||||
|
- totalRise: total stair height
|
||||||
|
- stepCount: number of visible steps
|
||||||
|
- thickness: stair slab / tread thickness
|
||||||
|
- fillToFloor: whether the stair mass fills down to the floor or uses tread thickness only
|
||||||
|
- innerRadius: inner curve radius for curved stairs
|
||||||
|
- sweepAngle: total curved stair sweep in radians
|
||||||
|
- topLandingMode: optional integrated top landing for spiral stairs
|
||||||
|
- topLandingDepth: depth used to size the integrated spiral top landing
|
||||||
|
- showCenterColumn: whether spiral stairs render a center column
|
||||||
|
- showStepSupports: whether spiral stairs render step support brackets
|
||||||
|
- railingMode: whether to render railings and on which side(s)
|
||||||
|
- railingHeight: top height of the railing above the stair surface
|
||||||
|
- children: array of StairSegmentNode IDs for straight stairs
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import z from 'zod'
|
|||||||
import { BuildingNode } from './nodes/building'
|
import { BuildingNode } from './nodes/building'
|
||||||
import { CeilingNode } from './nodes/ceiling'
|
import { CeilingNode } from './nodes/ceiling'
|
||||||
import { DoorNode } from './nodes/door'
|
import { DoorNode } from './nodes/door'
|
||||||
|
import { FenceNode } from './nodes/fence'
|
||||||
import { GuideNode } from './nodes/guide'
|
import { GuideNode } from './nodes/guide'
|
||||||
import { ItemNode } from './nodes/item'
|
import { ItemNode } from './nodes/item'
|
||||||
import { LevelNode } from './nodes/level'
|
import { LevelNode } from './nodes/level'
|
||||||
@@ -21,6 +22,7 @@ export const AnyNode = z.discriminatedUnion('type', [
|
|||||||
BuildingNode,
|
BuildingNode,
|
||||||
LevelNode,
|
LevelNode,
|
||||||
WallNode,
|
WallNode,
|
||||||
|
FenceNode,
|
||||||
ItemNode,
|
ItemNode,
|
||||||
ZoneNode,
|
ZoneNode,
|
||||||
SlabNode,
|
SlabNode,
|
||||||
|
|||||||
@@ -378,8 +378,8 @@ useScene.temporal.subscribe((state) => {
|
|||||||
// Mark sibling nodes dirty so they can update their geometry
|
// Mark sibling nodes dirty so they can update their geometry
|
||||||
// (e.g. adjacent walls need to recalculate miter/junction geometry)
|
// (e.g. adjacent walls need to recalculate miter/junction geometry)
|
||||||
const parent = currentNodes[parentId]
|
const parent = currentNodes[parentId]
|
||||||
if (parent && 'children' in parent) {
|
if (parent && 'children' in parent && Array.isArray(parent.children)) {
|
||||||
for (const childId of (parent as AnyNode & { children: string[] }).children) {
|
for (const childId of parent.children) {
|
||||||
markDirty(childId as AnyNodeId)
|
markDirty(childId as AnyNodeId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useFrame } from '@react-three/fiber'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||||
|
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||||
|
import type { AnyNodeId, FenceNode } from '../../schema'
|
||||||
|
import useScene from '../../store/use-scene'
|
||||||
|
|
||||||
|
type FencePart = {
|
||||||
|
position: [number, number, number]
|
||||||
|
scale: [number, number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStyleDefaults(style: FenceNode['style']) {
|
||||||
|
if (style === 'privacy') {
|
||||||
|
return { spacingFactor: 0.42, postFactor: 1.35, baseFactor: 1.2, topFactor: 1.2 }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style === 'rail') {
|
||||||
|
return { spacingFactor: 0.68, postFactor: 0.8, baseFactor: 0.85, topFactor: 0.85 }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { spacingFactor: 0.3, postFactor: 0.55, baseFactor: 1, topFactor: 0.75 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFenceParts(fence: FenceNode): FencePart[] {
|
||||||
|
const parts: FencePart[] = []
|
||||||
|
const length = Math.max(
|
||||||
|
Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]),
|
||||||
|
0.01,
|
||||||
|
)
|
||||||
|
const panelDepth = Math.max(fence.thickness, 0.03)
|
||||||
|
const clearance = Math.max(fence.groundClearance, 0)
|
||||||
|
const styleDefaults = getStyleDefaults(fence.style)
|
||||||
|
const baseHeight = Math.max(fence.baseHeight * styleDefaults.baseFactor, 0.04)
|
||||||
|
const topRailHeight = Math.max(fence.topRailHeight * styleDefaults.topFactor, 0.01)
|
||||||
|
const verticalHeight = Math.max(fence.height - baseHeight - topRailHeight, 0.08)
|
||||||
|
const postWidth = Math.max(fence.postSize * styleDefaults.postFactor, 0.01)
|
||||||
|
const spacing = Math.max(fence.postSpacing * styleDefaults.spacingFactor, postWidth * 1.2)
|
||||||
|
const edgeInset = Math.max(fence.edgeInset ?? 0.015, 0.005)
|
||||||
|
const isFloating = fence.baseStyle === 'floating'
|
||||||
|
const baseY = isFloating ? clearance : 0
|
||||||
|
const effectiveBaseHeight = baseHeight
|
||||||
|
|
||||||
|
if (!isFloating) {
|
||||||
|
parts.push({
|
||||||
|
position: [0, baseY + effectiveBaseHeight / 2, 0],
|
||||||
|
scale: [length, effectiveBaseHeight, panelDepth * 1.05],
|
||||||
|
})
|
||||||
|
parts.push({
|
||||||
|
position: [0, baseY + effectiveBaseHeight + verticalHeight * 0.15, 0],
|
||||||
|
scale: [length, topRailHeight * 0.8, panelDepth * 0.35],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1)
|
||||||
|
const step = count > 1 ? (length - edgeInset * 2) / (count - 1) : 0
|
||||||
|
const startX = -length / 2 + edgeInset
|
||||||
|
const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2
|
||||||
|
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const x = count === 1 ? 0 : startX + step * index
|
||||||
|
let posX = x
|
||||||
|
const isEdgePost = index === 0 || index === count - 1
|
||||||
|
if (count > 1) {
|
||||||
|
if (index === 0) posX = -length / 2 + edgeInset + postWidth / 2
|
||||||
|
else if (index === count - 1) posX = length / 2 - edgeInset - postWidth / 2
|
||||||
|
}
|
||||||
|
const postHeight =
|
||||||
|
isFloating && isEdgePost
|
||||||
|
? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
|
||||||
|
: verticalHeight
|
||||||
|
const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY
|
||||||
|
|
||||||
|
parts.push({
|
||||||
|
position: [posX, postY, 0],
|
||||||
|
scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push({
|
||||||
|
position: [0, baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2, 0],
|
||||||
|
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isFloating) {
|
||||||
|
parts.push({
|
||||||
|
position: [0, baseY + effectiveBaseHeight + topRailHeight / 2, 0],
|
||||||
|
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateFenceGeometry(fence: FenceNode) {
|
||||||
|
const parts = createFenceParts(fence)
|
||||||
|
const geometries = parts.map((part) => {
|
||||||
|
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
||||||
|
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
|
||||||
|
geometry.translate(part.position[0], part.position[1], part.position[2])
|
||||||
|
return geometry
|
||||||
|
})
|
||||||
|
|
||||||
|
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
|
||||||
|
geometries.forEach((geometry) => geometry.dispose())
|
||||||
|
merged.computeVertexNormals()
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFenceGeometry(fenceId: FenceNode['id']) {
|
||||||
|
const node = useScene.getState().nodes[fenceId]
|
||||||
|
if (!node || node.type !== 'fence') return
|
||||||
|
|
||||||
|
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Mesh | undefined
|
||||||
|
if (!mesh) return
|
||||||
|
|
||||||
|
const newGeometry = generateFenceGeometry(node)
|
||||||
|
mesh.geometry.dispose()
|
||||||
|
mesh.geometry = newGeometry
|
||||||
|
|
||||||
|
const centerX = (node.start[0] + node.end[0]) / 2
|
||||||
|
const centerZ = (node.start[1] + node.end[1]) / 2
|
||||||
|
const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
|
||||||
|
mesh.position.set(centerX, 0, centerZ)
|
||||||
|
mesh.rotation.set(0, -angle, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FenceSystem = () => {
|
||||||
|
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||||
|
const clearDirty = useScene((state) => state.clearDirty)
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (dirtyNodes.size === 0) return
|
||||||
|
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
dirtyNodes.forEach((id) => {
|
||||||
|
const node = nodes[id]
|
||||||
|
if (!node || node.type !== 'fence') return
|
||||||
|
updateFenceGeometry(id as FenceNode['id'])
|
||||||
|
clearDirty(id as AnyNodeId)
|
||||||
|
})
|
||||||
|
}, 4)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -304,15 +304,18 @@ function updateMergedStairGeometry(
|
|||||||
const mergedMesh = group.getObjectByName('merged-stair') as THREE.Mesh | undefined
|
const mergedMesh = group.getObjectByName('merged-stair') as THREE.Mesh | undefined
|
||||||
if (!mergedMesh) return
|
if (!mergedMesh) return
|
||||||
|
|
||||||
|
if (stairNode.stairType === 'curved' || stairNode.stairType === 'spiral') {
|
||||||
|
replaceMeshGeometry(mergedMesh, createEmptyGeometry())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const children = stairNode.children ?? []
|
const children = stairNode.children ?? []
|
||||||
const segments = children
|
const segments = children
|
||||||
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||||
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
|
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
|
||||||
|
|
||||||
if (segments.length === 0) {
|
if (segments.length === 0) {
|
||||||
mergedMesh.geometry.dispose()
|
replaceMeshGeometry(mergedMesh, createEmptyGeometry())
|
||||||
mergedMesh.geometry = new THREE.BufferGeometry()
|
|
||||||
mergedMesh.geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,11 +340,8 @@ function updateMergedStairGeometry(
|
|||||||
geometries.push(geo)
|
geometries.push(geo)
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged = mergeGeometries(geometries, false)
|
const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry()
|
||||||
if (merged) {
|
replaceMeshGeometry(mergedMesh, merged)
|
||||||
mergedMesh.geometry.dispose()
|
|
||||||
mergedMesh.geometry = merged
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispose individual geometries
|
// Dispose individual geometries
|
||||||
for (const geo of geometries) {
|
for (const geo of geometries) {
|
||||||
@@ -416,6 +416,548 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] {
|
|||||||
return [x * cos + z * sin, -x * sin + z * cos]
|
return [x * cos + z * sin, -x * sin + z * cos]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createEmptyGeometry(): THREE.BufferGeometry {
|
||||||
|
const geometry = new THREE.BufferGeometry()
|
||||||
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||||
|
return geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceMeshGeometry(mesh: THREE.Mesh, geometry: THREE.BufferGeometry) {
|
||||||
|
mesh.geometry.dispose()
|
||||||
|
mesh.geometry = geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
type StairRailSide = 'left' | 'right'
|
||||||
|
type StairRailPathSide = StairRailSide | 'front'
|
||||||
|
type StairRailSidePath = {
|
||||||
|
side: StairRailPathSide
|
||||||
|
points: THREE.Vector3[]
|
||||||
|
}
|
||||||
|
type StairSegmentRailPath = {
|
||||||
|
segment: StairSegmentNode
|
||||||
|
sidePaths: StairRailSidePath[]
|
||||||
|
connectFromPrevious: boolean
|
||||||
|
}
|
||||||
|
type StairRailLayout = {
|
||||||
|
center: [number, number]
|
||||||
|
elevation: number
|
||||||
|
rotation: number
|
||||||
|
segment: StairSegmentNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateStairRailingGeometry(
|
||||||
|
stairNode: StairNode,
|
||||||
|
segments: StairSegmentNode[],
|
||||||
|
transforms: SegmentTransform[],
|
||||||
|
): THREE.BufferGeometry {
|
||||||
|
const railingMode = stairNode.railingMode ?? 'none'
|
||||||
|
if (railingMode === 'none') {
|
||||||
|
return createEmptyGeometry()
|
||||||
|
}
|
||||||
|
|
||||||
|
const railHeight = Math.max(0.5, stairNode.railingHeight ?? 0.92)
|
||||||
|
const midRailHeight = Math.max(railHeight * 0.45, 0.35)
|
||||||
|
const railRadius = 0.022
|
||||||
|
const postRadius = 0.018
|
||||||
|
const inset = 0.06
|
||||||
|
const landingInset = 0.08
|
||||||
|
const geometries: THREE.BufferGeometry[] = []
|
||||||
|
|
||||||
|
const segmentRailPaths = buildStairRailPaths(segments, transforms, railingMode, inset, landingInset)
|
||||||
|
|
||||||
|
for (const segmentRailPath of segmentRailPaths) {
|
||||||
|
for (const sidePath of segmentRailPath.sidePaths) {
|
||||||
|
const points = sidePath.points
|
||||||
|
if (points.length === 0) continue
|
||||||
|
|
||||||
|
geometries.push(...buildBalusterGeometries(points, railHeight, postRadius))
|
||||||
|
geometries.push(...buildOffsetRailSegmentGeometries(points, railHeight, railRadius))
|
||||||
|
geometries.push(
|
||||||
|
...buildOffsetRailSegmentGeometries(points, midRailHeight, railRadius * 0.8),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 1; index < segmentRailPaths.length; index++) {
|
||||||
|
const previousPath = segmentRailPaths[index - 1]
|
||||||
|
const currentPath = segmentRailPaths[index]
|
||||||
|
if (!(previousPath && currentPath && currentPath.connectFromPrevious)) continue
|
||||||
|
if (previousPath.segment.segmentType === 'landing') continue
|
||||||
|
|
||||||
|
for (const sidePath of currentPath.sidePaths) {
|
||||||
|
if (currentPath.segment.segmentType === 'landing') continue
|
||||||
|
const currentPoint = sidePath.points[0]
|
||||||
|
if (!currentPoint) continue
|
||||||
|
|
||||||
|
const previousSidePath = [...previousPath.sidePaths]
|
||||||
|
.map((entry) => ({
|
||||||
|
entry,
|
||||||
|
distance: entry.points.length
|
||||||
|
? entry.points[entry.points.length - 1]!.distanceTo(currentPoint)
|
||||||
|
: Number.POSITIVE_INFINITY,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.distance - right.distance)[0]?.entry
|
||||||
|
|
||||||
|
const previousPoint =
|
||||||
|
previousSidePath && previousSidePath.points.length > 0
|
||||||
|
? previousSidePath.points[previousSidePath.points.length - 1]
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!(previousPoint && currentPoint)) continue
|
||||||
|
|
||||||
|
const connectorPoints = [previousPoint, currentPoint]
|
||||||
|
geometries.push(...buildOffsetRailSegmentGeometries(connectorPoints, railHeight, railRadius))
|
||||||
|
geometries.push(
|
||||||
|
...buildOffsetRailSegmentGeometries(connectorPoints, midRailHeight, railRadius * 0.8),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry()
|
||||||
|
for (const geometry of geometries) {
|
||||||
|
geometry.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStairRailPaths(
|
||||||
|
segments: StairSegmentNode[],
|
||||||
|
transforms: SegmentTransform[],
|
||||||
|
railingMode: 'left' | 'right' | 'both',
|
||||||
|
inset: number,
|
||||||
|
landingInset: number,
|
||||||
|
): StairSegmentRailPath[] {
|
||||||
|
const layouts = computeStairRailLayouts(segments, transforms)
|
||||||
|
|
||||||
|
if (railingMode === 'both') {
|
||||||
|
const isStraightLineDoubleLandingLayout =
|
||||||
|
segments.length === 4 &&
|
||||||
|
segments[0]?.segmentType === 'stair' &&
|
||||||
|
segments[1]?.segmentType === 'landing' &&
|
||||||
|
segments[2]?.segmentType === 'stair' &&
|
||||||
|
segments[2]?.attachmentSide === 'front' &&
|
||||||
|
segments[3]?.segmentType === 'landing' &&
|
||||||
|
segments[3]?.attachmentSide === 'front'
|
||||||
|
|
||||||
|
return layouts.map((layout, index) => {
|
||||||
|
const segment = layout.segment
|
||||||
|
const previousSegment = index > 0 ? segments[index - 1] : undefined
|
||||||
|
const nextSegment = index < segments.length - 1 ? segments[index + 1] : undefined
|
||||||
|
const hideLandingRailing =
|
||||||
|
segment.segmentType === 'landing' &&
|
||||||
|
previousSegment?.segmentType === 'stair' &&
|
||||||
|
nextSegment?.segmentType === 'stair'
|
||||||
|
const visualTurnSide = nextSegment?.attachmentSide
|
||||||
|
const sideCandidates =
|
||||||
|
hideLandingRailing
|
||||||
|
? visualTurnSide === 'left'
|
||||||
|
? (['front', 'right'] as const)
|
||||||
|
: visualTurnSide === 'right'
|
||||||
|
? (['front', 'left'] as const)
|
||||||
|
: (['left', 'right'] as const)
|
||||||
|
: segment.segmentType === 'landing'
|
||||||
|
? nextSegment?.segmentType === 'landing' && visualTurnSide === 'left'
|
||||||
|
? (['front', 'right'] as const)
|
||||||
|
: nextSegment?.segmentType === 'landing' && visualTurnSide === 'right'
|
||||||
|
? (['front', 'left'] as const)
|
||||||
|
: visualTurnSide === 'left'
|
||||||
|
? (['right'] as const)
|
||||||
|
: visualTurnSide === 'right'
|
||||||
|
? (['left'] as const)
|
||||||
|
: (['left', 'right'] as const)
|
||||||
|
: (['left', 'right'] as const)
|
||||||
|
const sidePaths = sideCandidates
|
||||||
|
.map((side) =>
|
||||||
|
buildSegmentRailPath(
|
||||||
|
layout,
|
||||||
|
side,
|
||||||
|
previousSegment,
|
||||||
|
nextSegment,
|
||||||
|
inset,
|
||||||
|
landingInset,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter((entry): entry is StairRailSidePath => entry !== null)
|
||||||
|
|
||||||
|
return {
|
||||||
|
segment,
|
||||||
|
sidePaths:
|
||||||
|
isStraightLineDoubleLandingLayout && index === 1
|
||||||
|
? ((['left', 'right'] as const)
|
||||||
|
.map((side) =>
|
||||||
|
buildSegmentRailPath(
|
||||||
|
layout,
|
||||||
|
side,
|
||||||
|
previousSegment,
|
||||||
|
nextSegment,
|
||||||
|
inset,
|
||||||
|
landingInset,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter((entry): entry is StairRailSidePath => entry !== null))
|
||||||
|
: sidePaths,
|
||||||
|
connectFromPrevious:
|
||||||
|
index > 0 &&
|
||||||
|
!(previousSegment?.segmentType === 'landing' && segment.segmentType === 'landing'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStraightLineDoubleLandingLayout =
|
||||||
|
segments.length === 4 &&
|
||||||
|
segments[0]?.segmentType === 'stair' &&
|
||||||
|
segments[1]?.segmentType === 'landing' &&
|
||||||
|
segments[2]?.segmentType === 'stair' &&
|
||||||
|
segments[2]?.attachmentSide === 'front' &&
|
||||||
|
segments[3]?.segmentType === 'landing' &&
|
||||||
|
segments[3]?.attachmentSide === 'front'
|
||||||
|
|
||||||
|
const resolved: StairSegmentRailPath[] = []
|
||||||
|
layouts.forEach((layout, index) => {
|
||||||
|
const segment = layout.segment
|
||||||
|
const previousSegment = index > 0 ? segments[index - 1] : undefined
|
||||||
|
const nextSegment = index < segments.length - 1 ? segments[index + 1] : undefined
|
||||||
|
const nextAttachmentSide = nextSegment?.attachmentSide
|
||||||
|
const isMiddleLandingBetweenFlights =
|
||||||
|
segment.segmentType === 'landing' &&
|
||||||
|
previousSegment?.segmentType === 'stair' &&
|
||||||
|
nextSegment?.segmentType === 'stair'
|
||||||
|
const suppressLandingRailing =
|
||||||
|
segment.segmentType === 'landing' &&
|
||||||
|
nextSegment?.segmentType === 'landing' &&
|
||||||
|
nextAttachmentSide === railingMode
|
||||||
|
const landingContinuesOnPreferredSide =
|
||||||
|
segment.segmentType === 'landing'
|
||||||
|
? nextAttachmentSide == null ||
|
||||||
|
nextAttachmentSide === 'front' ||
|
||||||
|
nextAttachmentSide === railingMode
|
||||||
|
: true
|
||||||
|
|
||||||
|
const sidePaths =
|
||||||
|
suppressLandingRailing
|
||||||
|
? []
|
||||||
|
: segment.segmentType !== 'landing'
|
||||||
|
? [
|
||||||
|
buildSegmentRailPath(
|
||||||
|
layout,
|
||||||
|
railingMode,
|
||||||
|
previousSegment,
|
||||||
|
nextSegment,
|
||||||
|
inset,
|
||||||
|
landingInset,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: isStraightLineDoubleLandingLayout
|
||||||
|
? [
|
||||||
|
buildSegmentRailPath(
|
||||||
|
layout,
|
||||||
|
railingMode,
|
||||||
|
previousSegment,
|
||||||
|
nextSegment,
|
||||||
|
inset,
|
||||||
|
landingInset,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: isMiddleLandingBetweenFlights && railingMode === 'left'
|
||||||
|
? nextAttachmentSide === 'right'
|
||||||
|
? [
|
||||||
|
buildSegmentRailPath(
|
||||||
|
layout,
|
||||||
|
'front',
|
||||||
|
previousSegment,
|
||||||
|
nextSegment,
|
||||||
|
inset,
|
||||||
|
landingInset,
|
||||||
|
),
|
||||||
|
buildSegmentRailPath(
|
||||||
|
layout,
|
||||||
|
'left',
|
||||||
|
previousSegment,
|
||||||
|
nextSegment,
|
||||||
|
inset,
|
||||||
|
landingInset,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
: isMiddleLandingBetweenFlights && railingMode === 'right'
|
||||||
|
? nextAttachmentSide === 'left'
|
||||||
|
? [
|
||||||
|
buildSegmentRailPath(
|
||||||
|
layout,
|
||||||
|
'front',
|
||||||
|
previousSegment,
|
||||||
|
nextSegment,
|
||||||
|
inset,
|
||||||
|
landingInset,
|
||||||
|
),
|
||||||
|
buildSegmentRailPath(
|
||||||
|
layout,
|
||||||
|
'right',
|
||||||
|
previousSegment,
|
||||||
|
nextSegment,
|
||||||
|
inset,
|
||||||
|
landingInset,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
: nextSegment?.segmentType === 'landing' &&
|
||||||
|
nextAttachmentSide != null &&
|
||||||
|
nextAttachmentSide !== 'front' &&
|
||||||
|
nextAttachmentSide !== railingMode
|
||||||
|
? [
|
||||||
|
buildSegmentRailPath(
|
||||||
|
layout,
|
||||||
|
'front',
|
||||||
|
previousSegment,
|
||||||
|
nextSegment,
|
||||||
|
inset,
|
||||||
|
landingInset,
|
||||||
|
),
|
||||||
|
buildSegmentRailPath(
|
||||||
|
layout,
|
||||||
|
railingMode,
|
||||||
|
previousSegment,
|
||||||
|
nextSegment,
|
||||||
|
inset,
|
||||||
|
landingInset,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
buildSegmentRailPath(
|
||||||
|
layout,
|
||||||
|
railingMode,
|
||||||
|
previousSegment,
|
||||||
|
nextSegment,
|
||||||
|
inset,
|
||||||
|
landingInset,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
resolved.push({
|
||||||
|
segment,
|
||||||
|
sidePaths: sidePaths.filter((entry): entry is StairRailSidePath => entry !== null),
|
||||||
|
connectFromPrevious:
|
||||||
|
index > 0 &&
|
||||||
|
!suppressLandingRailing &&
|
||||||
|
sidePaths.length > 0 &&
|
||||||
|
(segment.segmentType === 'landing' ? landingContinuesOnPreferredSide : true),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeStairRailLayouts(
|
||||||
|
segments: StairSegmentNode[],
|
||||||
|
transforms: SegmentTransform[],
|
||||||
|
): StairRailLayout[] {
|
||||||
|
return segments.map((segment, index) => {
|
||||||
|
const transform = transforms[index]!
|
||||||
|
const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation)
|
||||||
|
|
||||||
|
return {
|
||||||
|
center: [transform.position[0] + centerOffsetX, transform.position[2] + centerOffsetZ],
|
||||||
|
elevation: transform.position[1],
|
||||||
|
rotation: transform.rotation,
|
||||||
|
segment,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSegmentRailPath(
|
||||||
|
layout: StairRailLayout,
|
||||||
|
side: StairRailPathSide,
|
||||||
|
previousSegment: StairSegmentNode | undefined,
|
||||||
|
nextSegment: StairSegmentNode | undefined,
|
||||||
|
inset: number,
|
||||||
|
landingInset: number,
|
||||||
|
): StairRailSidePath | null {
|
||||||
|
const segment = layout.segment
|
||||||
|
const segmentSteps = Math.max(1, segment.segmentType === 'landing' ? 1 : segment.stepCount)
|
||||||
|
const segmentStepDepth = segment.length / segmentSteps
|
||||||
|
const segmentStepHeight = segment.segmentType === 'landing' ? 0 : segment.height / segmentSteps
|
||||||
|
const segmentTopThickness = getSegmentTopThickness(segment)
|
||||||
|
const flightSideOffset =
|
||||||
|
side === 'left' ? segment.width / 2 - 0.045 : -segment.width / 2 + 0.045
|
||||||
|
const flightStartX =
|
||||||
|
previousSegment?.segmentType === 'landing' ? -segment.length / 2 + landingInset : -segment.length / 2
|
||||||
|
const flightEndX =
|
||||||
|
nextSegment?.segmentType === 'landing' ? segment.length / 2 - landingInset : segment.length / 2
|
||||||
|
|
||||||
|
if (segment.segmentType === 'landing') {
|
||||||
|
return buildLandingRailPathFromScratch(
|
||||||
|
layout,
|
||||||
|
side,
|
||||||
|
previousSegment,
|
||||||
|
nextSegment,
|
||||||
|
segmentTopThickness,
|
||||||
|
landingInset,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
side,
|
||||||
|
points: [
|
||||||
|
...(previousSegment?.segmentType === 'landing'
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
toRailLayoutWorldPoint(layout, flightStartX, segmentTopThickness, flightSideOffset),
|
||||||
|
]),
|
||||||
|
...Array.from({ length: segmentSteps }).map((_, index) =>
|
||||||
|
toRailLayoutWorldPoint(
|
||||||
|
layout,
|
||||||
|
-segment.length / 2 + segmentStepDepth * index + segmentStepDepth / 2,
|
||||||
|
segmentStepHeight * (index + 1),
|
||||||
|
flightSideOffset,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...(nextSegment?.segmentType === 'landing'
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
toRailLayoutWorldPoint(layout, flightEndX, segment.height, flightSideOffset),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLandingRailPathFromScratch(
|
||||||
|
layout: StairRailLayout,
|
||||||
|
side: StairRailPathSide,
|
||||||
|
previousSegment: StairSegmentNode | undefined,
|
||||||
|
nextSegment: StairSegmentNode | undefined,
|
||||||
|
topY: number,
|
||||||
|
inset: number,
|
||||||
|
): StairRailSidePath | null {
|
||||||
|
const segment = layout.segment
|
||||||
|
const backX = -segment.length / 2 + inset
|
||||||
|
const frontX = segment.length / 2 - inset
|
||||||
|
const leftZ = segment.width / 2 - inset
|
||||||
|
const rightZ = -segment.width / 2 + inset
|
||||||
|
|
||||||
|
const edgePoints =
|
||||||
|
side === 'left'
|
||||||
|
? ([
|
||||||
|
toRailLayoutWorldPoint(layout, backX, topY, leftZ),
|
||||||
|
toRailLayoutWorldPoint(layout, frontX, topY, leftZ),
|
||||||
|
] as THREE.Vector3[])
|
||||||
|
: side === 'right'
|
||||||
|
? ([
|
||||||
|
toRailLayoutWorldPoint(layout, backX, topY, rightZ),
|
||||||
|
toRailLayoutWorldPoint(layout, frontX, topY, rightZ),
|
||||||
|
] as THREE.Vector3[])
|
||||||
|
: ([
|
||||||
|
// When the next flight turns, rail the visible leading edge nearest the turn opening.
|
||||||
|
toRailLayoutWorldPoint(
|
||||||
|
layout,
|
||||||
|
previousSegment?.segmentType === 'stair' &&
|
||||||
|
nextSegment?.attachmentSide &&
|
||||||
|
nextSegment.attachmentSide !== 'front'
|
||||||
|
? backX
|
||||||
|
: frontX,
|
||||||
|
topY,
|
||||||
|
leftZ,
|
||||||
|
),
|
||||||
|
toRailLayoutWorldPoint(
|
||||||
|
layout,
|
||||||
|
previousSegment?.segmentType === 'stair' &&
|
||||||
|
nextSegment?.attachmentSide &&
|
||||||
|
nextSegment.attachmentSide !== 'front'
|
||||||
|
? backX
|
||||||
|
: frontX,
|
||||||
|
topY,
|
||||||
|
rightZ,
|
||||||
|
),
|
||||||
|
] as THREE.Vector3[])
|
||||||
|
|
||||||
|
return {
|
||||||
|
side,
|
||||||
|
points: edgePoints,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRailLayoutWorldPoint(
|
||||||
|
layout: StairRailLayout,
|
||||||
|
localX: number,
|
||||||
|
localY: number,
|
||||||
|
localZ: number,
|
||||||
|
): THREE.Vector3 {
|
||||||
|
const [offsetX, offsetZ] = rotateXZ(localZ, localX, layout.rotation)
|
||||||
|
return new THREE.Vector3(
|
||||||
|
layout.center[0] + offsetX,
|
||||||
|
layout.elevation + localY,
|
||||||
|
layout.center[1] + offsetZ,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOffsetRailSegmentGeometries(
|
||||||
|
points: THREE.Vector3[],
|
||||||
|
heightOffset: number,
|
||||||
|
radius: number,
|
||||||
|
): THREE.BufferGeometry[] {
|
||||||
|
const geometries: THREE.BufferGeometry[] = []
|
||||||
|
|
||||||
|
for (let index = 0; index < points.length - 1; index++) {
|
||||||
|
const start = points[index]
|
||||||
|
const end = points[index + 1]
|
||||||
|
if (!(start && end)) continue
|
||||||
|
|
||||||
|
const segmentGeometry = createCylinderBetweenPoints(
|
||||||
|
start.clone().add(new THREE.Vector3(0, heightOffset, 0)),
|
||||||
|
end.clone().add(new THREE.Vector3(0, heightOffset, 0)),
|
||||||
|
radius,
|
||||||
|
8,
|
||||||
|
)
|
||||||
|
if (segmentGeometry) {
|
||||||
|
geometries.push(segmentGeometry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return geometries
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBalusterGeometries(
|
||||||
|
points: THREE.Vector3[],
|
||||||
|
height: number,
|
||||||
|
radius: number,
|
||||||
|
): THREE.BufferGeometry[] {
|
||||||
|
const geometries: THREE.BufferGeometry[] = []
|
||||||
|
|
||||||
|
for (const point of points) {
|
||||||
|
const geometry = new THREE.CylinderGeometry(radius, radius, Math.max(height, 0.05), 8)
|
||||||
|
geometry.translate(point.x, point.y + height / 2, point.z)
|
||||||
|
geometries.push(geometry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return geometries
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSegmentTopThickness(segment: StairSegmentNode): number {
|
||||||
|
return Math.max(segment.thickness ?? 0.25, 0.02)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCylinderBetweenPoints(
|
||||||
|
start: THREE.Vector3,
|
||||||
|
end: THREE.Vector3,
|
||||||
|
radius: number,
|
||||||
|
radialSegments: number,
|
||||||
|
): THREE.BufferGeometry | null {
|
||||||
|
const direction = new THREE.Vector3().subVectors(end, start)
|
||||||
|
const length = direction.length()
|
||||||
|
if (length <= 1e-5) return null
|
||||||
|
|
||||||
|
const midpoint = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5)
|
||||||
|
const quaternion = new THREE.Quaternion().setFromUnitVectors(
|
||||||
|
new THREE.Vector3(0, 1, 0),
|
||||||
|
direction.clone().normalize(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const geometry = new THREE.CylinderGeometry(radius, radius, length, radialSegments)
|
||||||
|
geometry.applyQuaternion(quaternion)
|
||||||
|
geometry.translate(midpoint.x, midpoint.y, midpoint.z)
|
||||||
|
return geometry
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes the absolute Y height of a segment by traversing the stair's segment chain.
|
* Computes the absolute Y height of a segment by traversing the stair's segment chain.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
|
FenceNode,
|
||||||
ItemNode,
|
ItemNode,
|
||||||
RoofNode,
|
RoofNode,
|
||||||
RoofSegmentNode,
|
RoofSegmentNode,
|
||||||
@@ -33,6 +34,7 @@ const ALLOWED_TYPES = [
|
|||||||
'stair',
|
'stair',
|
||||||
'stair-segment',
|
'stair-segment',
|
||||||
'wall',
|
'wall',
|
||||||
|
'fence',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
]
|
]
|
||||||
@@ -82,6 +84,7 @@ export function FloatingActionMenu() {
|
|||||||
node.type === 'item' ||
|
node.type === 'item' ||
|
||||||
node.type === 'window' ||
|
node.type === 'window' ||
|
||||||
node.type === 'door' ||
|
node.type === 'door' ||
|
||||||
|
node.type === 'fence' ||
|
||||||
node.type === 'roof' ||
|
node.type === 'roof' ||
|
||||||
node.type === 'roof-segment' ||
|
node.type === 'roof-segment' ||
|
||||||
node.type === 'stair' ||
|
node.type === 'stair' ||
|
||||||
@@ -113,11 +116,18 @@ export function FloatingActionMenu() {
|
|||||||
duplicate = WindowNode.parse(duplicateInfo)
|
duplicate = WindowNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'item') {
|
} else if (node.type === 'item') {
|
||||||
duplicate = ItemNode.parse(duplicateInfo)
|
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') {
|
} else if (node.type === 'roof') {
|
||||||
duplicate = RoofNode.parse(duplicateInfo)
|
duplicate = RoofNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'roof-segment') {
|
} else if (node.type === 'roof-segment') {
|
||||||
duplicate = RoofSegmentNode.parse(duplicateInfo)
|
duplicate = RoofSegmentNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'stair') {
|
} else if (node.type === 'stair') {
|
||||||
|
duplicateInfo.children = []
|
||||||
|
duplicateInfo.metadata = { ...duplicateInfo.metadata }
|
||||||
|
delete duplicateInfo.metadata?.isNew
|
||||||
duplicate = StairNode.parse(duplicateInfo)
|
duplicate = StairNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'stair-segment') {
|
} else if (node.type === 'stair-segment') {
|
||||||
duplicate = StairSegmentNode.parse(duplicateInfo)
|
duplicate = StairSegmentNode.parse(duplicateInfo)
|
||||||
@@ -130,6 +140,8 @@ export function FloatingActionMenu() {
|
|||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
if (duplicate.type === 'door' || duplicate.type === 'window') {
|
if (duplicate.type === 'door' || duplicate.type === 'window') {
|
||||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||||
|
} else if (duplicate.type === 'fence') {
|
||||||
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||||
} else if (
|
} else if (
|
||||||
duplicate.type === 'roof' ||
|
duplicate.type === 'roof' ||
|
||||||
duplicate.type === 'roof-segment' ||
|
duplicate.type === 'roof-segment' ||
|
||||||
@@ -144,7 +156,35 @@ export function FloatingActionMenu() {
|
|||||||
duplicate.position[2] + 1,
|
duplicate.position[2] + 1,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
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)
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||||
|
}
|
||||||
|
|
||||||
// Duplicate children for roof nodes
|
// Duplicate children for roof nodes
|
||||||
if (node.type === 'roof' && node.children) {
|
if (node.type === 'roof' && node.children) {
|
||||||
@@ -166,37 +206,24 @@ export function FloatingActionMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Duplicate children for stair nodes
|
// 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 (
|
if (
|
||||||
duplicate.type === 'item' ||
|
duplicate.type === 'item' ||
|
||||||
|
duplicate.type === 'fence' ||
|
||||||
duplicate.type === 'window' ||
|
duplicate.type === 'window' ||
|
||||||
duplicate.type === 'door' ||
|
duplicate.type === 'door' ||
|
||||||
duplicate.type === 'roof' ||
|
duplicate.type === 'roof' ||
|
||||||
duplicate.type === 'roof-segment' ||
|
duplicate.type === 'roof-segment' ||
|
||||||
duplicate.type === 'stair' ||
|
|
||||||
duplicate.type === 'stair-segment'
|
duplicate.type === 'stair-segment'
|
||||||
) {
|
) {
|
||||||
setMovingNode(duplicate as any)
|
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],
|
[node, setMovingNode, setSelection],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8063,6 +8063,7 @@ export function FloorplanPanel() {
|
|||||||
...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
|
...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
|
||||||
isNew: true,
|
isNew: true,
|
||||||
}
|
}
|
||||||
|
cloned.children = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const duplicate = ItemNodeSchema.parse(cloned)
|
const duplicate = ItemNodeSchema.parse(cloned)
|
||||||
@@ -8191,8 +8192,8 @@ export function FloorplanPanel() {
|
|||||||
delete cloned.id
|
delete cloned.id
|
||||||
cloned.metadata = {
|
cloned.metadata = {
|
||||||
...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
|
...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
|
||||||
isNew: true,
|
|
||||||
}
|
}
|
||||||
|
delete (cloned.metadata as Record<string, unknown>).isNew
|
||||||
|
|
||||||
const nextPosition =
|
const nextPosition =
|
||||||
Array.isArray(cloned.position) && cloned.position.length >= 3
|
Array.isArray(cloned.position) && cloned.position.length >= 3
|
||||||
@@ -8207,9 +8208,11 @@ export function FloorplanPanel() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const duplicate = StairNodeSchema.parse(cloned)
|
const duplicate = StairNodeSchema.parse(cloned)
|
||||||
useScene.getState().createNode(duplicate, stair.parentId as AnyNodeId)
|
|
||||||
|
|
||||||
const nodesState = useScene.getState().nodes
|
const nodesState = useScene.getState().nodes
|
||||||
|
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
|
||||||
|
{ node: duplicate, parentId: stair.parentId as AnyNodeId },
|
||||||
|
]
|
||||||
|
|
||||||
for (const childId of stair.children ?? []) {
|
for (const childId of stair.children ?? []) {
|
||||||
const childNode = nodesState[childId]
|
const childNode = nodesState[childId]
|
||||||
if (childNode?.type !== 'stair-segment') {
|
if (childNode?.type !== 'stair-segment') {
|
||||||
@@ -8222,19 +8225,20 @@ export function FloorplanPanel() {
|
|||||||
...(typeof childClone.metadata === 'object' && childClone.metadata !== null
|
...(typeof childClone.metadata === 'object' && childClone.metadata !== null
|
||||||
? childClone.metadata
|
? childClone.metadata
|
||||||
: {}),
|
: {}),
|
||||||
isNew: true,
|
|
||||||
}
|
}
|
||||||
|
delete (childClone.metadata as Record<string, unknown>).isNew
|
||||||
|
|
||||||
const childDuplicate = StairSegmentNodeSchema.parse(childClone)
|
const childDuplicate = StairSegmentNodeSchema.parse(childClone)
|
||||||
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
|
createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
|
||||||
}
|
}
|
||||||
|
|
||||||
setMovingNode(duplicate)
|
useScene.getState().createNodes(createOps)
|
||||||
setSelection({ selectedIds: [] })
|
|
||||||
|
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to duplicate stair', error)
|
console.error('Failed to duplicate stair', error)
|
||||||
}
|
}
|
||||||
}, [selectedStairEntry, setMovingNode, setSelection])
|
}, [selectedStairEntry, setSelection])
|
||||||
const handleSelectedStairDuplicate = useCallback(
|
const handleSelectedStairDuplicate = useCallback(
|
||||||
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
|||||||
|
|
||||||
type SelectableNodeType =
|
type SelectableNodeType =
|
||||||
| 'wall'
|
| 'wall'
|
||||||
|
| 'fence'
|
||||||
| 'item'
|
| 'item'
|
||||||
| 'building'
|
| 'building'
|
||||||
| 'zone'
|
| 'zone'
|
||||||
@@ -186,6 +187,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
structure: {
|
structure: {
|
||||||
types: [
|
types: [
|
||||||
'wall',
|
'wall',
|
||||||
|
'fence',
|
||||||
'item',
|
'item',
|
||||||
'zone',
|
'zone',
|
||||||
'slab',
|
'slab',
|
||||||
@@ -238,6 +240,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
node.type === 'wall' ||
|
node.type === 'wall' ||
|
||||||
|
node.type === 'fence' ||
|
||||||
node.type === 'slab' ||
|
node.type === 'slab' ||
|
||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
node.type === 'roof' ||
|
node.type === 'roof' ||
|
||||||
@@ -299,6 +302,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
node.type === 'wall' ||
|
node.type === 'wall' ||
|
||||||
|
node.type === 'fence' ||
|
||||||
node.type === 'slab' ||
|
node.type === 'slab' ||
|
||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
node.type === 'roof' ||
|
node.type === 'roof' ||
|
||||||
@@ -443,6 +447,7 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
const allTypes = [
|
const allTypes = [
|
||||||
'wall',
|
'wall',
|
||||||
|
'fence',
|
||||||
'item',
|
'item',
|
||||||
'building',
|
'building',
|
||||||
'zone',
|
'zone',
|
||||||
@@ -537,6 +542,7 @@ export const SelectionManager = () => {
|
|||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
node.type === 'wall' ||
|
node.type === 'wall' ||
|
||||||
|
node.type === 'fence' ||
|
||||||
node.type === 'slab' ||
|
node.type === 'slab' ||
|
||||||
node.type === 'ceiling' ||
|
node.type === 'ceiling' ||
|
||||||
node.type === 'roof' ||
|
node.type === 'roof' ||
|
||||||
@@ -586,6 +592,7 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
const allTypes = [
|
const allTypes = [
|
||||||
'wall',
|
'wall',
|
||||||
|
'fence',
|
||||||
'item',
|
'item',
|
||||||
'building',
|
'building',
|
||||||
'slab',
|
'slab',
|
||||||
@@ -657,6 +664,7 @@ export const SelectionManager = () => {
|
|||||||
|
|
||||||
const allTypes = [
|
const allTypes = [
|
||||||
'wall',
|
'wall',
|
||||||
|
'fence',
|
||||||
'item',
|
'item',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type AnyNodeId, type StairNode, sceneRegistry, useScene } from '@pascal-app/core'
|
import { type AnyNodeId, type StairNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
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
|
* Imperatively toggles the Three.js visibility of stair objects based on the
|
||||||
@@ -17,6 +17,27 @@ import { useEffect, useRef } from 'react'
|
|||||||
*/
|
*/
|
||||||
export const StairEditSystem = () => {
|
export const StairEditSystem = () => {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
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>())
|
const prevActiveStairIds = useRef(new Set<string>())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,14 +62,15 @@ export const StairEditSystem = () => {
|
|||||||
const group = sceneRegistry.nodes.get(stairId)
|
const group = sceneRegistry.nodes.get(stairId)
|
||||||
if (!group) continue
|
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 mergedMesh = group.getObjectByName('merged-stair')
|
||||||
const segmentsWrapper = group.getObjectByName('segments-wrapper')
|
const segmentsWrapper = group.getObjectByName('segments-wrapper')
|
||||||
const isActive = activeStairIds.has(stairId)
|
const isActive = activeStairIds.has(stairId)
|
||||||
|
|
||||||
if (mergedMesh) mergedMesh.visible = !isActive
|
if (mergedMesh) mergedMesh.visible = !isActive && !isCurved
|
||||||
if (segmentsWrapper) segmentsWrapper.visible = isActive
|
if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved
|
||||||
|
|
||||||
const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined
|
|
||||||
if (stairNode?.children?.length) {
|
if (stairNode?.children?.length) {
|
||||||
const wasActive = prevActiveStairIds.current.has(stairId)
|
const wasActive = prevActiveStairIds.current.has(stairId)
|
||||||
if (isActive !== wasActive) {
|
if (isActive !== wasActive) {
|
||||||
@@ -63,7 +85,7 @@ export const StairEditSystem = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prevActiveStairIds.current = activeStairIds
|
prevActiveStairIds.current = activeStairIds
|
||||||
}, [selectedIds])
|
}, [selectedIds, selectedStairSignature])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function hasWallChildOverlap(
|
|||||||
const newLeft = clampedX - halfW
|
const newLeft = clampedX - halfW
|
||||||
const newRight = 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
|
if (childId === ignoreId) continue
|
||||||
const child = nodes[childId as AnyNodeId]
|
const child = nodes[childId as AnyNodeId]
|
||||||
if (!child) continue
|
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 {
|
import type {
|
||||||
BuildingNode,
|
BuildingNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
|
FenceNode,
|
||||||
ItemNode,
|
ItemNode,
|
||||||
RoofNode,
|
RoofNode,
|
||||||
RoofSegmentNode,
|
RoofSegmentNode,
|
||||||
@@ -13,6 +14,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
|||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { MoveBuildingContent } from '../building/move-building-tool'
|
import { MoveBuildingContent } from '../building/move-building-tool'
|
||||||
import { MoveDoorTool } from '../door/move-door-tool'
|
import { MoveDoorTool } from '../door/move-door-tool'
|
||||||
|
import { MoveFenceTool } from '../fence/move-fence-tool'
|
||||||
import { MoveRoofTool } from '../roof/move-roof-tool'
|
import { MoveRoofTool } from '../roof/move-roof-tool'
|
||||||
import { MoveWindowTool } from '../window/move-window-tool'
|
import { MoveWindowTool } from '../window/move-window-tool'
|
||||||
import type { PlacementState } from './placement-types'
|
import type { PlacementState } from './placement-types'
|
||||||
@@ -86,6 +88,7 @@ export const MoveTool: React.FC = () => {
|
|||||||
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
||||||
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
||||||
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
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')
|
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
||||||
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
||||||
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
|||||||
const node = nodes[childId as AnyNodeId]
|
const node = nodes[childId as AnyNodeId]
|
||||||
if (!node) continue
|
if (!node) continue
|
||||||
|
|
||||||
if (node.type === 'wall') {
|
if (node.type === 'wall' || node.type === 'fence') {
|
||||||
const wall = node as WallNode
|
const wall = node as WallNode
|
||||||
if (
|
if (
|
||||||
segmentIntersectsBounds(wall.start[0], wall.start[1], wall.end[0], wall.end[1], bounds)
|
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)
|
result.push(wall.id)
|
||||||
}
|
}
|
||||||
// Check wall children (doors/windows)
|
// 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]
|
const child = nodes[itemId as AnyNodeId]
|
||||||
if (!child) continue
|
if (!child) continue
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export const DEFAULT_STAIR_TYPE = 'straight' as const
|
||||||
export const DEFAULT_STAIR_WIDTH = 1.0
|
export const DEFAULT_STAIR_WIDTH = 1.0
|
||||||
export const DEFAULT_STAIR_LENGTH = 3.0
|
export const DEFAULT_STAIR_LENGTH = 3.0
|
||||||
export const DEFAULT_STAIR_HEIGHT = 2.5
|
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_ATTACHMENT_SIDE = 'front' as const
|
||||||
export const DEFAULT_STAIR_FILL_TO_FLOOR = true
|
export const DEFAULT_STAIR_FILL_TO_FLOOR = true
|
||||||
export const DEFAULT_STAIR_THICKNESS = 0.25
|
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 { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
import {
|
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_ATTACHMENT_SIDE,
|
||||||
DEFAULT_STAIR_FILL_TO_FLOOR,
|
DEFAULT_STAIR_FILL_TO_FLOOR,
|
||||||
DEFAULT_STAIR_HEIGHT,
|
DEFAULT_STAIR_HEIGHT,
|
||||||
DEFAULT_STAIR_LENGTH,
|
DEFAULT_STAIR_LENGTH,
|
||||||
|
DEFAULT_STAIR_RAILING_HEIGHT,
|
||||||
|
DEFAULT_STAIR_RAILING_MODE,
|
||||||
DEFAULT_STAIR_STEP_COUNT,
|
DEFAULT_STAIR_STEP_COUNT,
|
||||||
DEFAULT_STAIR_THICKNESS,
|
DEFAULT_STAIR_THICKNESS,
|
||||||
|
DEFAULT_STAIR_TYPE,
|
||||||
DEFAULT_STAIR_WIDTH,
|
DEFAULT_STAIR_WIDTH,
|
||||||
} from './stair-defaults'
|
} from './stair-defaults'
|
||||||
|
|
||||||
@@ -88,6 +97,20 @@ function commitStairPlacement(
|
|||||||
name,
|
name,
|
||||||
position,
|
position,
|
||||||
rotation,
|
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],
|
children: [segment.id],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 { FenceTool } from './fence/fence-tool'
|
||||||
import { ItemTool } from './item/item-tool'
|
import { ItemTool } from './item/item-tool'
|
||||||
import { MoveTool } from './item/move-tool'
|
import { MoveTool } from './item/move-tool'
|
||||||
import { RoofTool } from './roof/roof-tool'
|
import { RoofTool } from './roof/roof-tool'
|
||||||
@@ -30,6 +31,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
|||||||
},
|
},
|
||||||
structure: {
|
structure: {
|
||||||
wall: WallTool,
|
wall: WallTool,
|
||||||
|
fence: FenceTool,
|
||||||
slab: SlabTool,
|
slab: SlabTool,
|
||||||
ceiling: CeilingTool,
|
ceiling: CeilingTool,
|
||||||
roof: RoofTool,
|
roof: RoofTool,
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function hasWallChildOverlap(
|
|||||||
const newLeft = clampedX - halfW
|
const newLeft = clampedX - halfW
|
||||||
const newRight = 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
|
if (childId === ignoreId) continue
|
||||||
const child = nodes[childId as AnyNodeId]
|
const child = nodes[childId as AnyNodeId]
|
||||||
if (!child) continue
|
if (!child) continue
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const tools: ToolConfig[] = [
|
|||||||
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
|
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
|
||||||
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
||||||
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
|
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
|
||||||
|
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
|
||||||
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
{ 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 useEditor from '../../../store/use-editor'
|
||||||
import { CeilingPanel } from './ceiling-panel'
|
import { CeilingPanel } from './ceiling-panel'
|
||||||
import { DoorPanel } from './door-panel'
|
import { DoorPanel } from './door-panel'
|
||||||
|
import { FencePanel } from './fence-panel'
|
||||||
import { ItemPanel } from './item-panel'
|
import { ItemPanel } from './item-panel'
|
||||||
import { ReferencePanel } from './reference-panel'
|
import { ReferencePanel } from './reference-panel'
|
||||||
import { RoofPanel } from './roof-panel'
|
import { RoofPanel } from './roof-panel'
|
||||||
@@ -47,6 +48,8 @@ export function PanelManager() {
|
|||||||
return <CeilingPanel />
|
return <CeilingPanel />
|
||||||
case 'wall':
|
case 'wall':
|
||||||
return <WallPanel />
|
return <WallPanel />
|
||||||
|
case 'fence':
|
||||||
|
return <FencePanel />
|
||||||
case 'door':
|
case 'door':
|
||||||
return <DoorPanel />
|
return <DoorPanel />
|
||||||
case 'window':
|
case 'window':
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type MaterialSchema,
|
type MaterialSchema,
|
||||||
type StairNode,
|
type StairNode,
|
||||||
|
type StairRailingMode,
|
||||||
|
type StairTopLandingMode,
|
||||||
|
type StairType,
|
||||||
StairNode as StairNodeSchema,
|
StairNode as StairNodeSchema,
|
||||||
type StairSegmentNode,
|
type StairSegmentNode,
|
||||||
StairSegmentNode as StairSegmentNodeSchema,
|
StairSegmentNode as StairSegmentNodeSchema,
|
||||||
@@ -15,19 +18,41 @@ import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
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 { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
import { MaterialPicker } from '../controls/material-picker'
|
||||||
import { MetricControl } from '../controls/metric-control'
|
import { MetricControl } from '../controls/metric-control'
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
|
import { SegmentedControl } from '../controls/segmented-control'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
|
import { ToggleControl } from '../controls/toggle-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
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() {
|
export function StairPanel() {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const nodes = useScene((s) => s.nodes)
|
const nodes = useScene((s) => s.nodes)
|
||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
const createNode = useScene((s) => s.createNode)
|
const createNode = useScene((s) => s.createNode)
|
||||||
|
const createNodes = useScene((s) => s.createNodes)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
|
||||||
const selectedId = selectedIds[0]
|
const selectedId = selectedIds[0]
|
||||||
@@ -114,7 +139,8 @@ export function StairPanel() {
|
|||||||
|
|
||||||
let duplicateInfo = structuredClone(node) as any
|
let duplicateInfo = structuredClone(node) as any
|
||||||
delete duplicateInfo.id
|
delete duplicateInfo.id
|
||||||
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
duplicateInfo.metadata = { ...duplicateInfo.metadata }
|
||||||
|
duplicateInfo.children = []
|
||||||
duplicateInfo.position = [
|
duplicateInfo.position = [
|
||||||
duplicateInfo.position[0] + 1,
|
duplicateInfo.position[0] + 1,
|
||||||
duplicateInfo.position[1],
|
duplicateInfo.position[1],
|
||||||
@@ -123,29 +149,31 @@ export function StairPanel() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const duplicate = StairNodeSchema.parse(duplicateInfo)
|
const duplicate = StairNodeSchema.parse(duplicateInfo)
|
||||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
|
||||||
|
|
||||||
// Also duplicate all child segments
|
|
||||||
const nodesState = useScene.getState().nodes
|
const nodesState = useScene.getState().nodes
|
||||||
const children = node.children || []
|
const children = node.children || []
|
||||||
|
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
|
||||||
|
{ node: duplicate, parentId: duplicate.parentId as AnyNodeId },
|
||||||
|
]
|
||||||
|
|
||||||
for (const childId of children) {
|
for (const childId of children) {
|
||||||
const childNode = nodesState[childId]
|
const childNode = nodesState[childId]
|
||||||
if (childNode && childNode.type === 'stair-segment') {
|
if (childNode && childNode.type === 'stair-segment') {
|
||||||
let childDuplicateInfo = structuredClone(childNode) as any
|
let childDuplicateInfo = structuredClone(childNode) as any
|
||||||
delete childDuplicateInfo.id
|
delete childDuplicateInfo.id
|
||||||
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
|
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
|
||||||
const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
|
const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
|
||||||
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
|
createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelection({ selectedIds: [] })
|
createNodes(createOps)
|
||||||
setMovingNode(duplicate)
|
|
||||||
|
setSelection({ selectedIds: [duplicate.id as AnyNode['id']] })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to duplicate stair', e)
|
console.error('Failed to duplicate stair', e)
|
||||||
}
|
}
|
||||||
}, [node, setSelection, setMovingNode])
|
}, [createNodes, node, setSelection])
|
||||||
|
|
||||||
const handleMove = useCallback(() => {
|
const handleMove = useCallback(() => {
|
||||||
if (node) {
|
if (node) {
|
||||||
@@ -179,6 +207,25 @@ export function StairPanel() {
|
|||||||
title={node.name || 'Staircase'}
|
title={node.name || 'Staircase'}
|
||||||
width={300}
|
width={300}
|
||||||
>
|
>
|
||||||
|
<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">
|
<PanelSection title="Segments">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{segments.map((seg, i) => (
|
{segments.map((seg, i) => (
|
||||||
@@ -206,6 +253,112 @@ export function StairPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PanelSection>
|
</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">
|
<PanelSection title="Position">
|
||||||
<MetricControl
|
<MetricControl
|
||||||
@@ -280,6 +433,26 @@ export function StairPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</PanelSection>
|
</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">
|
<PanelSection title="Actions">
|
||||||
<ActionGroup>
|
<ActionGroup>
|
||||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
<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 { BuildingTreeNode } from './building-tree-node'
|
||||||
import { CeilingTreeNode } from './ceiling-tree-node'
|
import { CeilingTreeNode } from './ceiling-tree-node'
|
||||||
import { DoorTreeNode } from './door-tree-node'
|
import { DoorTreeNode } from './door-tree-node'
|
||||||
|
import { FenceTreeNode } from './fence-tree-node'
|
||||||
import { ItemTreeNode } from './item-tree-node'
|
import { ItemTreeNode } from './item-tree-node'
|
||||||
import { LevelTreeNode } from './level-tree-node'
|
import { LevelTreeNode } from './level-tree-node'
|
||||||
import { RoofTreeNode } from './roof-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} />
|
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||||
case 'wall':
|
case 'wall':
|
||||||
return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||||
|
case 'fence':
|
||||||
|
return <FenceTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||||
case 'roof':
|
case 'roof':
|
||||||
return <RoofTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
return <RoofTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||||
case 'stair':
|
case 'stair':
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const wallModeConfig = {
|
|||||||
const getNodeName = (node: AnyNode): string => {
|
const getNodeName = (node: AnyNode): string => {
|
||||||
if ('name' in node && node.name) return node.name
|
if ('name' in node && node.name) return node.name
|
||||||
if (node.type === 'wall') return 'Wall'
|
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 === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item'
|
||||||
if (node.type === 'slab') return 'Slab'
|
if (node.type === 'slab') return 'Slab'
|
||||||
if (node.type === 'ceiling') return 'Ceiling'
|
if (node.type === 'ceiling') return 'Ceiling'
|
||||||
|
|||||||
@@ -16,7 +16,15 @@ export function useContextualTools() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default tools when nothing is selected
|
// 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) {
|
if (selection.selectedIds.length === 0) {
|
||||||
return defaultTools
|
return defaultTools
|
||||||
@@ -29,7 +37,7 @@ export function useContextualTools() {
|
|||||||
|
|
||||||
// If a wall is selected, prioritize wall-hosted elements
|
// If a wall is selected, prioritize wall-hosted elements
|
||||||
if (selectedTypes.has('wall')) {
|
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
|
// If a slab is selected, prioritize slab editing
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { AssetInput } from '@pascal-app/core'
|
|||||||
import {
|
import {
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
|
type FenceNode,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
@@ -33,6 +34,7 @@ export type Mode = 'select' | 'edit' | 'delete' | 'build'
|
|||||||
// Structure mode tools (building elements)
|
// Structure mode tools (building elements)
|
||||||
export type StructureTool =
|
export type StructureTool =
|
||||||
| 'wall'
|
| 'wall'
|
||||||
|
| 'fence'
|
||||||
| 'room'
|
| 'room'
|
||||||
| 'custom-room'
|
| 'custom-room'
|
||||||
| 'slab'
|
| 'slab'
|
||||||
@@ -85,6 +87,7 @@ type EditorState = {
|
|||||||
| ItemNode
|
| ItemNode
|
||||||
| WindowNode
|
| WindowNode
|
||||||
| DoorNode
|
| DoorNode
|
||||||
|
| FenceNode
|
||||||
| RoofNode
|
| RoofNode
|
||||||
| RoofSegmentNode
|
| RoofSegmentNode
|
||||||
| StairNode
|
| StairNode
|
||||||
@@ -96,6 +99,7 @@ type EditorState = {
|
|||||||
| ItemNode
|
| ItemNode
|
||||||
| WindowNode
|
| WindowNode
|
||||||
| DoorNode
|
| DoorNode
|
||||||
|
| FenceNode
|
||||||
| RoofNode
|
| RoofNode
|
||||||
| RoofSegmentNode
|
| RoofSegmentNode
|
||||||
| StairNode
|
| StairNode
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { type FenceNode, useRegistry, useScene } from '@pascal-app/core'
|
||||||
|
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
|
import type { Mesh } from 'three'
|
||||||
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
|
import { DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
||||||
|
|
||||||
|
export const FenceRenderer = ({ node }: { node: FenceNode }) => {
|
||||||
|
const ref = useRef<Mesh>(null!)
|
||||||
|
const handlers = useNodeEvents(node, 'fence')
|
||||||
|
const material = useMemo(() => DEFAULT_STAIR_MATERIAL, [])
|
||||||
|
|
||||||
|
useRegistry(node.id, 'fence', ref)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
useScene.getState().markDirty(node.id)
|
||||||
|
}, [node.id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible} {...handlers}>
|
||||||
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { type AnyNode, useScene } from '@pascal-app/core'
|
|||||||
import { BuildingRenderer } from './building/building-renderer'
|
import { BuildingRenderer } from './building/building-renderer'
|
||||||
import { CeilingRenderer } from './ceiling/ceiling-renderer'
|
import { CeilingRenderer } from './ceiling/ceiling-renderer'
|
||||||
import { DoorRenderer } from './door/door-renderer'
|
import { DoorRenderer } from './door/door-renderer'
|
||||||
|
import { FenceRenderer } from './fence/fence-renderer'
|
||||||
import { GuideRenderer } from './guide/guide-renderer'
|
import { GuideRenderer } from './guide/guide-renderer'
|
||||||
import { ItemRenderer } from './item/item-renderer'
|
import { ItemRenderer } from './item/item-renderer'
|
||||||
import { LevelRenderer } from './level/level-renderer'
|
import { LevelRenderer } from './level/level-renderer'
|
||||||
@@ -32,6 +33,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
|
|||||||
{node.type === 'item' && <ItemRenderer node={node} />}
|
{node.type === 'item' && <ItemRenderer node={node} />}
|
||||||
{node.type === 'slab' && <SlabRenderer node={node} />}
|
{node.type === 'slab' && <SlabRenderer node={node} />}
|
||||||
{node.type === 'wall' && <WallRenderer node={node} />}
|
{node.type === 'wall' && <WallRenderer node={node} />}
|
||||||
|
{node.type === 'fence' && <FenceRenderer node={node} />}
|
||||||
{node.type === 'door' && <DoorRenderer node={node} />}
|
{node.type === 'door' && <DoorRenderer node={node} />}
|
||||||
{node.type === 'window' && <WindowRenderer node={node} />}
|
{node.type === 'window' && <WindowRenderer node={node} />}
|
||||||
{node.type === 'zone' && <ZoneRenderer node={node} />}
|
{node.type === 'zone' && <ZoneRenderer node={node} />}
|
||||||
|
|||||||
@@ -1,10 +1,40 @@
|
|||||||
import { type StairNode, useRegistry, useScene } from '@pascal-app/core'
|
import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
|
||||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
import type * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
|
type SegmentTransform = {
|
||||||
|
position: [number, number, number]
|
||||||
|
rotation: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type StairRailPathSide = 'left' | 'right' | 'front'
|
||||||
|
|
||||||
|
type StairRailSidePath = {
|
||||||
|
side: StairRailPathSide
|
||||||
|
points: [number, number, number][]
|
||||||
|
}
|
||||||
|
|
||||||
|
type StairSegmentRailPath = {
|
||||||
|
layout: StairRailLayout
|
||||||
|
sidePaths: StairRailSidePath[]
|
||||||
|
connectFromPrevious: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type StairRailLayout = {
|
||||||
|
center: [number, number]
|
||||||
|
elevation: number
|
||||||
|
rotation: number
|
||||||
|
segment: StairSegmentNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type LandingChainNextStair = {
|
||||||
|
nextStairLayout?: StairRailLayout
|
||||||
|
isTerminalLandingBeforeStair: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export const StairRenderer = ({ node }: { node: StairNode }) => {
|
export const StairRenderer = ({ node }: { node: StairNode }) => {
|
||||||
const ref = useRef<THREE.Group>(null!)
|
const ref = useRef<THREE.Group>(null!)
|
||||||
|
|
||||||
@@ -34,6 +64,10 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
|||||||
<mesh castShadow material={material} name="merged-stair" receiveShadow>
|
<mesh castShadow material={material} name="merged-stair" receiveShadow>
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
{node.stairType === 'curved' || node.stairType === 'spiral' ? (
|
||||||
|
<CurvedStairBody material={material} stair={node} />
|
||||||
|
) : null}
|
||||||
|
<StairRailings material={material} stair={node} />
|
||||||
<group name="segments-wrapper" visible={false}>
|
<group name="segments-wrapper" visible={false}>
|
||||||
{(node.children ?? []).map((childId) => (
|
{(node.children ?? []).map((childId) => (
|
||||||
<NodeRenderer key={childId} nodeId={childId} />
|
<NodeRenderer key={childId} nodeId={childId} />
|
||||||
@@ -42,3 +76,764 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
|||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StairRailings({ stair, material }: { stair: StairNode; material: THREE.Material }) {
|
||||||
|
const nodes = useScene((state) => state.nodes)
|
||||||
|
|
||||||
|
const segments = useMemo(
|
||||||
|
() =>
|
||||||
|
(stair.children ?? [])
|
||||||
|
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||||
|
.filter((node): node is StairSegmentNode => node?.type === 'stair-segment' && node.visible !== false),
|
||||||
|
[nodes, stair.children],
|
||||||
|
)
|
||||||
|
|
||||||
|
const railPaths = useMemo(() => buildStairRailPaths(segments, stair.railingMode ?? 'none'), [segments, stair.railingMode])
|
||||||
|
|
||||||
|
const railHeight = stair.railingHeight ?? 0.92
|
||||||
|
const midRailHeight = Math.max(railHeight * 0.45, 0.35)
|
||||||
|
const railRadius = 0.022
|
||||||
|
const balusterRadius = 0.018
|
||||||
|
|
||||||
|
if ((stair.railingMode ?? 'none') === 'none') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stair.stairType === 'curved' || stair.stairType === 'spiral') {
|
||||||
|
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
|
||||||
|
const sweepAngle = stair.sweepAngle ?? (stair.stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
|
||||||
|
const stepSweep = sweepAngle / stepCount
|
||||||
|
const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount
|
||||||
|
const innerRadius = Math.max(stair.stairType === 'spiral' ? 0.05 : 0.2, stair.innerRadius ?? 0.9)
|
||||||
|
const outerRadius = innerRadius + Math.max(stair.width ?? 1, 0.4)
|
||||||
|
const leftRadius = sweepAngle >= 0 ? innerRadius + 0.04 : outerRadius - 0.04
|
||||||
|
const rightRadius = sweepAngle >= 0 ? outerRadius - 0.04 : innerRadius + 0.04
|
||||||
|
const radii =
|
||||||
|
stair.railingMode === 'both'
|
||||||
|
? [leftRadius, rightRadius]
|
||||||
|
: stair.railingMode === 'left'
|
||||||
|
? [leftRadius]
|
||||||
|
: stair.railingMode === 'right'
|
||||||
|
? [rightRadius]
|
||||||
|
: []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group name="stair-railing">
|
||||||
|
{radii.map((radius, sideIndex) => {
|
||||||
|
const sidePoints = Array.from({ length: stepCount }).map((_, index) => {
|
||||||
|
const angle = -sweepAngle / 2 + stepSweep * index + stepSweep / 2
|
||||||
|
return [
|
||||||
|
Math.cos(angle) * radius,
|
||||||
|
stair.stairType === 'spiral'
|
||||||
|
? stepHeight * index + Math.max(stair.thickness ?? 0.25, 0.02)
|
||||||
|
: stepHeight * (index + 1),
|
||||||
|
Math.sin(angle) * radius,
|
||||||
|
] as [number, number, number]
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group key={`${stair.id}-curved-railing-${sideIndex}`}>
|
||||||
|
{sidePoints.map((point, pointIndex) => (
|
||||||
|
<mesh
|
||||||
|
castShadow
|
||||||
|
geometry={BALUSTER_GEOMETRY}
|
||||||
|
key={`${stair.id}-curved-baluster-${sideIndex}-${pointIndex}`}
|
||||||
|
material={material}
|
||||||
|
position={[point[0], point[1] + railHeight / 2, point[2]]}
|
||||||
|
receiveShadow
|
||||||
|
scale={[balusterRadius, railHeight, balusterRadius]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{sidePoints.slice(0, -1).map((point, pointIndex) => {
|
||||||
|
const nextPoint = sidePoints[pointIndex + 1]
|
||||||
|
if (!nextPoint) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group key={`${stair.id}-curved-rail-${sideIndex}-${pointIndex}`}>
|
||||||
|
<RailSegment
|
||||||
|
end={[nextPoint[0], nextPoint[1] + railHeight, nextPoint[2]]}
|
||||||
|
material={material}
|
||||||
|
radius={railRadius}
|
||||||
|
start={[point[0], point[1] + railHeight, point[2]]}
|
||||||
|
/>
|
||||||
|
<RailSegment
|
||||||
|
end={[nextPoint[0], nextPoint[1] + midRailHeight, nextPoint[2]]}
|
||||||
|
material={material}
|
||||||
|
radius={railRadius * 0.8}
|
||||||
|
start={[point[0], point[1] + midRailHeight, point[2]]}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (railPaths.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group name="stair-railing">
|
||||||
|
{railPaths.map((segmentPath, index) => (
|
||||||
|
<group
|
||||||
|
key={`${segmentPath.layout.segment.id}-railing`}
|
||||||
|
position={[segmentPath.layout.center[0], segmentPath.layout.elevation, segmentPath.layout.center[1]]}
|
||||||
|
rotation-y={segmentPath.layout.rotation}
|
||||||
|
>
|
||||||
|
{segmentPath.sidePaths.map((sidePath, sideIndex) => (
|
||||||
|
<group key={`${segmentPath.layout.segment.id}-${sidePath.side}-${sideIndex}`}>
|
||||||
|
{sidePath.points.map((point, pointIndex) => (
|
||||||
|
<mesh
|
||||||
|
castShadow
|
||||||
|
geometry={BALUSTER_GEOMETRY}
|
||||||
|
key={`${segmentPath.layout.segment.id}-${sidePath.side}-baluster-${pointIndex}`}
|
||||||
|
material={material}
|
||||||
|
position={[point[2], point[1] + railHeight / 2, point[0]]}
|
||||||
|
receiveShadow
|
||||||
|
scale={[balusterRadius, railHeight, balusterRadius]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{sidePath.points.slice(0, -1).map((point, pointIndex) => {
|
||||||
|
const nextPoint = sidePath.points[pointIndex + 1]
|
||||||
|
if (!nextPoint) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group key={`${segmentPath.layout.segment.id}-${sidePath.side}-rail-${pointIndex}`}>
|
||||||
|
<RailSegment
|
||||||
|
end={[nextPoint[2], nextPoint[1] + railHeight, nextPoint[0]]}
|
||||||
|
material={material}
|
||||||
|
radius={railRadius}
|
||||||
|
start={[point[2], point[1] + railHeight, point[0]]}
|
||||||
|
/>
|
||||||
|
<RailSegment
|
||||||
|
end={[nextPoint[2], nextPoint[1] + midRailHeight, nextPoint[0]]}
|
||||||
|
material={material}
|
||||||
|
radius={railRadius * 0.8}
|
||||||
|
start={[point[2], point[1] + midRailHeight, point[0]]}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
{railPaths.slice(1).map((segmentPath, index) => {
|
||||||
|
const previousPath = railPaths[index]
|
||||||
|
if (!previousPath || !segmentPath.connectFromPrevious) return null
|
||||||
|
if (previousPath.layout.segment.segmentType === 'landing') return null
|
||||||
|
if (segmentPath.layout.segment.segmentType === 'landing') return null
|
||||||
|
|
||||||
|
return segmentPath.sidePaths.map((sidePath, sideIndex) => {
|
||||||
|
const currentPoint = sidePath.points[0]
|
||||||
|
if (!currentPoint) return null
|
||||||
|
|
||||||
|
const currentWorldPoint = toWorldRailPoint(segmentPath.layout, currentPoint)
|
||||||
|
const previousSidePath = [...previousPath.sidePaths]
|
||||||
|
.map((entry) => {
|
||||||
|
const lastPoint = entry.points[entry.points.length - 1]
|
||||||
|
return {
|
||||||
|
entry,
|
||||||
|
distance: lastPoint ? distance3(toWorldRailPoint(previousPath.layout, lastPoint), currentWorldPoint) : Number.POSITIVE_INFINITY,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.distance - right.distance)[0]?.entry
|
||||||
|
const previousPoint =
|
||||||
|
previousSidePath && previousSidePath.points.length
|
||||||
|
? previousSidePath.points[previousSidePath.points.length - 1]
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (!(previousPoint && currentPoint)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousWorldPoint = toWorldRailPoint(previousPath.layout, previousPoint)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group key={`${previousPath.layout.segment.id}-${segmentPath.layout.segment.id}-${sideIndex}`}>
|
||||||
|
<RailSegment
|
||||||
|
end={[currentWorldPoint[0], currentWorldPoint[1] + railHeight, currentWorldPoint[2]]}
|
||||||
|
material={material}
|
||||||
|
radius={railRadius}
|
||||||
|
start={[previousWorldPoint[0], previousWorldPoint[1] + railHeight, previousWorldPoint[2]]}
|
||||||
|
/>
|
||||||
|
<RailSegment
|
||||||
|
end={[currentWorldPoint[0], currentWorldPoint[1] + midRailHeight, currentWorldPoint[2]]}
|
||||||
|
material={material}
|
||||||
|
radius={railRadius * 0.8}
|
||||||
|
start={[previousWorldPoint[0], previousWorldPoint[1] + midRailHeight, previousWorldPoint[2]]}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const BALUSTER_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8)
|
||||||
|
const RAIL_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8)
|
||||||
|
|
||||||
|
function RailSegment({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
radius,
|
||||||
|
material,
|
||||||
|
}: {
|
||||||
|
start: [number, number, number]
|
||||||
|
end: [number, number, number]
|
||||||
|
radius: number
|
||||||
|
material: THREE.Material
|
||||||
|
}) {
|
||||||
|
const startVector = useMemo(() => new THREE.Vector3(...start), [start])
|
||||||
|
const endVector = useMemo(() => new THREE.Vector3(...end), [end])
|
||||||
|
const direction = useMemo(() => endVector.clone().sub(startVector), [endVector, startVector])
|
||||||
|
const length = Math.max(direction.length(), 0.01)
|
||||||
|
const quaternion = useMemo(
|
||||||
|
() => new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.clone().normalize()),
|
||||||
|
[direction],
|
||||||
|
)
|
||||||
|
const midpoint = useMemo(() => startVector.clone().add(endVector).multiplyScalar(0.5), [endVector, startVector])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
castShadow
|
||||||
|
geometry={RAIL_GEOMETRY}
|
||||||
|
material={material}
|
||||||
|
position={[midpoint.x, midpoint.y, midpoint.z]}
|
||||||
|
quaternion={quaternion}
|
||||||
|
receiveShadow
|
||||||
|
scale={[Math.max(radius, 0.01), length, Math.max(radius, 0.01)]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CurvedStairBody({ stair, material }: { stair: StairNode; material: THREE.Material }) {
|
||||||
|
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
|
||||||
|
const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1)
|
||||||
|
const stepHeight = totalRise / stepCount
|
||||||
|
const isSpiral = stair.stairType === 'spiral'
|
||||||
|
const innerRadius = Math.max(isSpiral ? 0.05 : 0.2, stair.innerRadius ?? 0.9)
|
||||||
|
const outerRadius = innerRadius + Math.max(stair.width ?? 1, 0.4)
|
||||||
|
const sweepAngle = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2)
|
||||||
|
const stepSweep = sweepAngle / stepCount
|
||||||
|
const thickness = Math.max(stair.thickness ?? 0.25, 0.02)
|
||||||
|
const fillToFloor = stair.fillToFloor ?? true
|
||||||
|
const spiralColumnRadius = Math.max(0.05, Math.min(innerRadius * 0.72, innerRadius - 0.03))
|
||||||
|
const spiralColumnHeight = totalRise + thickness
|
||||||
|
const spiralLandingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max((stair.width ?? 1) * 0.9, 0.8))
|
||||||
|
const spiralLandingSweep =
|
||||||
|
isSpiral && (stair.topLandingMode ?? 'none') === 'integrated'
|
||||||
|
? Math.min(Math.PI * 0.75, spiralLandingDepth / Math.max(innerRadius + (stair.width ?? 1) / 2, 0.1)) *
|
||||||
|
Math.sign(sweepAngle || 1)
|
||||||
|
: 0
|
||||||
|
const spiralLastStepTop = stepHeight * Math.max(stepCount - 1, 0) + thickness
|
||||||
|
const spiralLandingThickness =
|
||||||
|
isSpiral && (stair.topLandingMode ?? 'none') === 'integrated'
|
||||||
|
? Math.max(0.02, totalRise - spiralLastStepTop)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group name={isSpiral ? 'spiral-stair' : 'curved-stair'}>
|
||||||
|
{isSpiral && (stair.showCenterColumn ?? true) ? (
|
||||||
|
<mesh castShadow receiveShadow material={material} position={[0, spiralColumnHeight / 2, 0]}>
|
||||||
|
<cylinderGeometry args={[spiralColumnRadius, spiralColumnRadius, spiralColumnHeight, 10]} />
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
{Array.from({ length: stepCount }).map((_, index) => {
|
||||||
|
const currentHeight = stepHeight * (index + 1)
|
||||||
|
const actualStepHeight = isSpiral ? thickness : fillToFloor ? Math.max(currentHeight, thickness) : thickness
|
||||||
|
const startAngle = -sweepAngle / 2 + stepSweep * index
|
||||||
|
const endAngle = startAngle + stepSweep
|
||||||
|
const stepY = isSpiral ? stepHeight * index : fillToFloor ? 0 : Math.max(currentHeight - thickness, 0)
|
||||||
|
const midAngle = startAngle + stepSweep / 2
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group key={`${stair.id}-${isSpiral ? 'spiral' : 'curved'}-step-${index}`} position-y={stepY}>
|
||||||
|
{isSpiral && (stair.showStepSupports ?? true) ? (
|
||||||
|
<mesh
|
||||||
|
castShadow
|
||||||
|
material={material}
|
||||||
|
position={[
|
||||||
|
Math.cos(midAngle) * (spiralColumnRadius + Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 - 0.02),
|
||||||
|
Math.max(thickness * 0.55, 0.025) / 2,
|
||||||
|
Math.sin(midAngle) * (spiralColumnRadius + Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 - 0.02),
|
||||||
|
]}
|
||||||
|
receiveShadow
|
||||||
|
rotation-y={-midAngle}
|
||||||
|
>
|
||||||
|
<boxGeometry
|
||||||
|
args={[
|
||||||
|
Math.max(0.04, innerRadius - spiralColumnRadius + 0.04),
|
||||||
|
Math.max(thickness * 0.55, 0.025),
|
||||||
|
Math.max(0.04, Math.min(0.12, Math.max(thickness * 0.55, 0.025) * 1.5)),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
) : null}
|
||||||
|
<CurvedStepMesh
|
||||||
|
endAngle={endAngle}
|
||||||
|
innerRadius={innerRadius}
|
||||||
|
material={material}
|
||||||
|
outerRadius={outerRadius}
|
||||||
|
positionY={0}
|
||||||
|
startAngle={startAngle}
|
||||||
|
stepHeight={actualStepHeight}
|
||||||
|
thickness={thickness}
|
||||||
|
/>
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{isSpiral && (stair.topLandingMode ?? 'none') === 'integrated' ? (
|
||||||
|
<CurvedStepMesh
|
||||||
|
endAngle={sweepAngle / 2 + spiralLandingSweep}
|
||||||
|
innerRadius={innerRadius}
|
||||||
|
material={material}
|
||||||
|
outerRadius={outerRadius}
|
||||||
|
positionY={spiralLastStepTop}
|
||||||
|
startAngle={sweepAngle / 2}
|
||||||
|
stepHeight={spiralLandingThickness}
|
||||||
|
thickness={spiralLandingThickness}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CurvedStepMesh({
|
||||||
|
innerRadius,
|
||||||
|
outerRadius,
|
||||||
|
startAngle,
|
||||||
|
endAngle,
|
||||||
|
stepHeight,
|
||||||
|
thickness,
|
||||||
|
positionY,
|
||||||
|
material,
|
||||||
|
}: {
|
||||||
|
innerRadius: number
|
||||||
|
outerRadius: number
|
||||||
|
startAngle: number
|
||||||
|
endAngle: number
|
||||||
|
stepHeight: number
|
||||||
|
thickness: number
|
||||||
|
positionY: number
|
||||||
|
material: THREE.Material
|
||||||
|
}) {
|
||||||
|
const geometry = useMemo(
|
||||||
|
() => buildCurvedStepGeometry(innerRadius, outerRadius, startAngle, endAngle, Math.max(stepHeight, thickness)),
|
||||||
|
[endAngle, innerRadius, outerRadius, startAngle, stepHeight, thickness],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <mesh castShadow geometry={geometry} material={material} position-y={positionY} receiveShadow />
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCurvedStepGeometry(
|
||||||
|
innerRadius: number,
|
||||||
|
outerRadius: number,
|
||||||
|
startAngle: number,
|
||||||
|
endAngle: number,
|
||||||
|
height: number,
|
||||||
|
) {
|
||||||
|
const clampedHeight = Math.max(height, 0.02)
|
||||||
|
const y0 = 0
|
||||||
|
const y1 = clampedHeight
|
||||||
|
const sweepAngle = endAngle - startAngle
|
||||||
|
const sweepDirection = Math.sign(sweepAngle) || 1
|
||||||
|
const segmentCount = Math.max(4, Math.min(24, Math.ceil(Math.abs(sweepAngle) / (Math.PI / 18) + Math.max(0, (outerRadius - innerRadius) * 3))))
|
||||||
|
|
||||||
|
const positions: number[] = []
|
||||||
|
const normals: number[] = []
|
||||||
|
|
||||||
|
const pointOnArc = (radius: number, angle: number, y: number) =>
|
||||||
|
new THREE.Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius)
|
||||||
|
|
||||||
|
const pushTriangle = (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3, normal: THREE.Vector3) => {
|
||||||
|
const edgeAB = b.clone().sub(a)
|
||||||
|
const edgeAC = c.clone().sub(a)
|
||||||
|
const faceNormal = edgeAB.cross(edgeAC)
|
||||||
|
const ordered = faceNormal.dot(normal) >= 0 ? [a, b, c] : [a, c, b]
|
||||||
|
for (const point of ordered) {
|
||||||
|
positions.push(point.x, point.y, point.z)
|
||||||
|
normals.push(normal.x, normal.y, normal.z)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushQuad = (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3, d: THREE.Vector3, normal: THREE.Vector3) => {
|
||||||
|
pushTriangle(a, b, c, normal)
|
||||||
|
pushTriangle(a, c, d, normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
const upNormal = new THREE.Vector3(0, 1, 0)
|
||||||
|
const downNormal = new THREE.Vector3(0, -1, 0)
|
||||||
|
|
||||||
|
for (let index = 0; index < segmentCount; index++) {
|
||||||
|
const t0 = index / segmentCount
|
||||||
|
const t1 = (index + 1) / segmentCount
|
||||||
|
const segStart = startAngle + sweepAngle * t0
|
||||||
|
const segEnd = startAngle + sweepAngle * t1
|
||||||
|
const midAngle = (segStart + segEnd) / 2
|
||||||
|
|
||||||
|
const innerStartBottom = pointOnArc(innerRadius, segStart, y0)
|
||||||
|
const innerEndBottom = pointOnArc(innerRadius, segEnd, y0)
|
||||||
|
const outerStartBottom = pointOnArc(outerRadius, segStart, y0)
|
||||||
|
const outerEndBottom = pointOnArc(outerRadius, segEnd, y0)
|
||||||
|
const innerStartTop = pointOnArc(innerRadius, segStart, y1)
|
||||||
|
const innerEndTop = pointOnArc(innerRadius, segEnd, y1)
|
||||||
|
const outerStartTop = pointOnArc(outerRadius, segStart, y1)
|
||||||
|
const outerEndTop = pointOnArc(outerRadius, segEnd, y1)
|
||||||
|
|
||||||
|
const outerNormal = new THREE.Vector3(Math.cos(midAngle), 0, Math.sin(midAngle)).normalize()
|
||||||
|
const innerNormal = new THREE.Vector3(-Math.cos(midAngle), 0, -Math.sin(midAngle)).normalize()
|
||||||
|
|
||||||
|
pushQuad(innerStartTop, outerStartTop, outerEndTop, innerEndTop, upNormal)
|
||||||
|
pushQuad(innerStartBottom, innerEndBottom, outerEndBottom, outerStartBottom, downNormal)
|
||||||
|
pushQuad(innerStartBottom, innerStartTop, innerEndTop, innerEndBottom, innerNormal)
|
||||||
|
pushQuad(outerStartBottom, outerEndBottom, outerEndTop, outerStartTop, outerNormal)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startInnerBottom = pointOnArc(innerRadius, startAngle, y0)
|
||||||
|
const startOuterBottom = pointOnArc(outerRadius, startAngle, y0)
|
||||||
|
const startInnerTop = pointOnArc(innerRadius, startAngle, y1)
|
||||||
|
const startOuterTop = pointOnArc(outerRadius, startAngle, y1)
|
||||||
|
const endInnerBottom = pointOnArc(innerRadius, endAngle, y0)
|
||||||
|
const endOuterBottom = pointOnArc(outerRadius, endAngle, y0)
|
||||||
|
const endInnerTop = pointOnArc(innerRadius, endAngle, y1)
|
||||||
|
const endOuterTop = pointOnArc(outerRadius, endAngle, y1)
|
||||||
|
const startNormal = new THREE.Vector3(
|
||||||
|
sweepDirection * Math.sin(startAngle),
|
||||||
|
0,
|
||||||
|
-sweepDirection * Math.cos(startAngle),
|
||||||
|
).normalize()
|
||||||
|
const endNormal = new THREE.Vector3(
|
||||||
|
-sweepDirection * Math.sin(endAngle),
|
||||||
|
0,
|
||||||
|
sweepDirection * Math.cos(endAngle),
|
||||||
|
).normalize()
|
||||||
|
|
||||||
|
pushQuad(startInnerBottom, startOuterBottom, startOuterTop, startInnerTop, startNormal)
|
||||||
|
pushQuad(endInnerBottom, endInnerTop, endOuterTop, endOuterBottom, endNormal)
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry()
|
||||||
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||||
|
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
||||||
|
geometry.computeVertexNormals()
|
||||||
|
return geometry
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStairRailPaths(
|
||||||
|
segments: StairSegmentNode[],
|
||||||
|
railingMode: StairNode['railingMode'],
|
||||||
|
): StairSegmentRailPath[] {
|
||||||
|
if (!segments.length || railingMode === 'none') return []
|
||||||
|
|
||||||
|
const layouts = computeStairRailLayouts(segments)
|
||||||
|
const landingInset = 0.08
|
||||||
|
|
||||||
|
if (railingMode === 'both') {
|
||||||
|
const isStraightLineDoubleLandingLayout =
|
||||||
|
layouts.length === 4 &&
|
||||||
|
layouts[0]?.segment.segmentType === 'stair' &&
|
||||||
|
layouts[1]?.segment.segmentType === 'landing' &&
|
||||||
|
layouts[2]?.segment.segmentType === 'stair' &&
|
||||||
|
layouts[2]?.segment.attachmentSide === 'front' &&
|
||||||
|
layouts[3]?.segment.segmentType === 'landing' &&
|
||||||
|
layouts[3]?.segment.attachmentSide === 'front'
|
||||||
|
|
||||||
|
return layouts.map((layout, index) => {
|
||||||
|
const previousLayout = index > 0 ? layouts[index - 1] : undefined
|
||||||
|
const nextLayout = layouts[index + 1]
|
||||||
|
const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(layouts, index)
|
||||||
|
const hideLandingRailing =
|
||||||
|
layout.segment.segmentType === 'landing' &&
|
||||||
|
previousLayout?.segment.segmentType === 'stair' &&
|
||||||
|
nextLayout?.segment.segmentType === 'stair'
|
||||||
|
const visualTurnSide =
|
||||||
|
isTerminalLandingBeforeStair && nextStairLayout?.segment.attachmentSide
|
||||||
|
? nextStairLayout.segment.attachmentSide
|
||||||
|
: nextLayout?.segment.attachmentSide
|
||||||
|
const sideCandidates =
|
||||||
|
isTerminalLandingBeforeStair && layout.segment.segmentType === 'landing'
|
||||||
|
? visualTurnSide === 'left'
|
||||||
|
? (['front', 'right'] as const)
|
||||||
|
: visualTurnSide === 'right'
|
||||||
|
? (['front', 'left'] as const)
|
||||||
|
: (['left', 'right'] as const)
|
||||||
|
: hideLandingRailing
|
||||||
|
? visualTurnSide === 'left'
|
||||||
|
? (['front', 'right'] as const)
|
||||||
|
: visualTurnSide === 'right'
|
||||||
|
? (['front', 'left'] as const)
|
||||||
|
: (['left', 'right'] as const)
|
||||||
|
: layout.segment.segmentType === 'landing'
|
||||||
|
? nextLayout?.segment.segmentType === 'landing' && visualTurnSide === 'left'
|
||||||
|
? (['front', 'right'] as const)
|
||||||
|
: nextLayout?.segment.segmentType === 'landing' && visualTurnSide === 'right'
|
||||||
|
? (['front', 'left'] as const)
|
||||||
|
: visualTurnSide === 'left'
|
||||||
|
? (['right'] as const)
|
||||||
|
: visualTurnSide === 'right'
|
||||||
|
? (['left'] as const)
|
||||||
|
: (['left', 'right'] as const)
|
||||||
|
: (['left', 'right'] as const)
|
||||||
|
|
||||||
|
return {
|
||||||
|
layout,
|
||||||
|
sidePaths:
|
||||||
|
isStraightLineDoubleLandingLayout && index === 1
|
||||||
|
? (['left', 'right'] as const).map((side) => buildSegmentRailPath(layouts, index, side, landingInset))
|
||||||
|
: sideCandidates.map((side) => buildSegmentRailPath(layouts, index, side, landingInset)),
|
||||||
|
connectFromPrevious:
|
||||||
|
index > 0 &&
|
||||||
|
!(previousLayout?.segment.segmentType === 'landing' && layout.segment.segmentType === 'landing'),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStraightLineDoubleLandingLayout =
|
||||||
|
layouts.length === 4 &&
|
||||||
|
layouts[0]?.segment.segmentType === 'stair' &&
|
||||||
|
layouts[1]?.segment.segmentType === 'landing' &&
|
||||||
|
layouts[2]?.segment.segmentType === 'stair' &&
|
||||||
|
layouts[2]?.segment.attachmentSide === 'front' &&
|
||||||
|
layouts[3]?.segment.segmentType === 'landing' &&
|
||||||
|
layouts[3]?.segment.attachmentSide === 'front'
|
||||||
|
|
||||||
|
return layouts.map((layout, index) => {
|
||||||
|
const previousLayout = index > 0 ? layouts[index - 1] : undefined
|
||||||
|
const nextLayout = layouts[index + 1]
|
||||||
|
const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(layouts, index)
|
||||||
|
const isMiddleLandingBetweenFlights =
|
||||||
|
layout.segment.segmentType === 'landing' &&
|
||||||
|
previousLayout?.segment.segmentType === 'stair' &&
|
||||||
|
nextLayout?.segment.segmentType === 'stair'
|
||||||
|
const nextAttachmentSide = nextLayout?.segment.attachmentSide
|
||||||
|
const terminalNextAttachmentSide = nextStairLayout?.segment.attachmentSide
|
||||||
|
const suppressMiddleLandingOnPreferredTurnSide =
|
||||||
|
isMiddleLandingBetweenFlights &&
|
||||||
|
nextAttachmentSide != null &&
|
||||||
|
nextAttachmentSide !== 'front' &&
|
||||||
|
nextAttachmentSide === railingMode
|
||||||
|
const suppressLandingRailing =
|
||||||
|
(layout.segment.segmentType === 'landing' &&
|
||||||
|
nextLayout?.segment.segmentType === 'landing' &&
|
||||||
|
nextAttachmentSide === railingMode) ||
|
||||||
|
suppressMiddleLandingOnPreferredTurnSide
|
||||||
|
const landingContinuesOnPreferredSide =
|
||||||
|
layout.segment.segmentType === 'landing'
|
||||||
|
? nextAttachmentSide == null || nextAttachmentSide === 'front' || nextAttachmentSide === railingMode
|
||||||
|
: true
|
||||||
|
|
||||||
|
const sideCandidates =
|
||||||
|
suppressLandingRailing
|
||||||
|
? ([] as StairRailPathSide[])
|
||||||
|
: layout.segment.segmentType !== 'landing'
|
||||||
|
? [railingMode]
|
||||||
|
: isTerminalLandingBeforeStair
|
||||||
|
? railingMode === 'left'
|
||||||
|
? terminalNextAttachmentSide === 'right'
|
||||||
|
? (['front', 'left'] as const)
|
||||||
|
: terminalNextAttachmentSide === 'front' || terminalNextAttachmentSide == null
|
||||||
|
? (['left'] as const)
|
||||||
|
: ([] as StairRailPathSide[])
|
||||||
|
: railingMode === 'right'
|
||||||
|
? terminalNextAttachmentSide === 'left'
|
||||||
|
? (['front', 'right'] as const)
|
||||||
|
: terminalNextAttachmentSide === 'front' || terminalNextAttachmentSide == null
|
||||||
|
? (['right'] as const)
|
||||||
|
: ([] as StairRailPathSide[])
|
||||||
|
: [railingMode]
|
||||||
|
: isStraightLineDoubleLandingLayout
|
||||||
|
? [railingMode]
|
||||||
|
: isMiddleLandingBetweenFlights && railingMode === 'left'
|
||||||
|
? nextAttachmentSide === 'right'
|
||||||
|
? (['front', 'left'] as const)
|
||||||
|
: (['left'] as const)
|
||||||
|
: isMiddleLandingBetweenFlights && railingMode === 'right'
|
||||||
|
? nextAttachmentSide === 'left'
|
||||||
|
? (['front', 'right'] as const)
|
||||||
|
: (['right'] as const)
|
||||||
|
: nextLayout?.segment.segmentType === 'landing' &&
|
||||||
|
nextAttachmentSide != null &&
|
||||||
|
nextAttachmentSide !== 'front' &&
|
||||||
|
nextAttachmentSide !== railingMode
|
||||||
|
? (['front', railingMode] as StairRailPathSide[])
|
||||||
|
: [railingMode]
|
||||||
|
|
||||||
|
return {
|
||||||
|
layout,
|
||||||
|
sidePaths: sideCandidates.map((side) => buildSegmentRailPath(layouts, index, side, landingInset)),
|
||||||
|
connectFromPrevious:
|
||||||
|
index > 0 &&
|
||||||
|
!suppressLandingRailing &&
|
||||||
|
sideCandidates.length > 0 &&
|
||||||
|
(layout.segment.segmentType === 'landing' ? landingContinuesOnPreferredSide : true),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLandingChainNextStair(layouts: StairRailLayout[], index: number): LandingChainNextStair {
|
||||||
|
const layout = layouts[index]
|
||||||
|
if (!layout || layout.segment.segmentType !== 'landing') {
|
||||||
|
return { isTerminalLandingBeforeStair: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = index
|
||||||
|
while (cursor + 1 < layouts.length && layouts[cursor + 1]?.segment.segmentType === 'landing') {
|
||||||
|
cursor += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStairLayout =
|
||||||
|
cursor + 1 < layouts.length && layouts[cursor + 1]?.segment.segmentType === 'stair'
|
||||||
|
? layouts[cursor + 1]
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextStairLayout,
|
||||||
|
isTerminalLandingBeforeStair: Boolean(nextStairLayout) && cursor === index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeStairRailLayouts(segments: StairSegmentNode[]): StairRailLayout[] {
|
||||||
|
const transforms = computeSegmentTransforms(segments)
|
||||||
|
return segments.map((segment, index) => {
|
||||||
|
const transform = transforms[index]!
|
||||||
|
const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation)
|
||||||
|
return {
|
||||||
|
center: [transform.position[0] + centerOffsetX, transform.position[2] + centerOffsetZ],
|
||||||
|
elevation: transform.position[1],
|
||||||
|
rotation: transform.rotation,
|
||||||
|
segment,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSegmentRailPath(
|
||||||
|
layouts: StairRailLayout[],
|
||||||
|
layoutIndex: number,
|
||||||
|
side: StairRailPathSide,
|
||||||
|
landingInset: number,
|
||||||
|
): StairRailSidePath {
|
||||||
|
const layout = layouts[layoutIndex]!
|
||||||
|
const segment = layout.segment
|
||||||
|
const previousLayout = layoutIndex > 0 ? layouts[layoutIndex - 1] : undefined
|
||||||
|
const nextLayout = layoutIndex >= 0 ? layouts[layoutIndex + 1] : undefined
|
||||||
|
const steps = Math.max(1, segment.segmentType === 'landing' ? 1 : segment.stepCount)
|
||||||
|
const stepDepth = segment.length / steps
|
||||||
|
const stepHeight = segment.segmentType === 'landing' ? 0 : segment.height / steps
|
||||||
|
const flightSideOffset = side === 'left' ? segment.width / 2 - 0.045 : -segment.width / 2 + 0.045
|
||||||
|
const flightStartX =
|
||||||
|
previousLayout?.segment.segmentType === 'landing' ? -segment.length / 2 + landingInset : -segment.length / 2
|
||||||
|
const flightEndX =
|
||||||
|
nextLayout?.segment.segmentType === 'landing' ? segment.length / 2 - landingInset : segment.length / 2
|
||||||
|
const landingFrontX =
|
||||||
|
previousLayout?.segment.segmentType === 'stair' &&
|
||||||
|
segment.attachmentSide &&
|
||||||
|
segment.attachmentSide !== 'front'
|
||||||
|
? -segment.length / 2 + landingInset
|
||||||
|
: segment.length / 2 - landingInset
|
||||||
|
|
||||||
|
if (segment.segmentType === 'landing') {
|
||||||
|
const backX = -segment.length / 2 + landingInset
|
||||||
|
const frontX = segment.length / 2 - landingInset
|
||||||
|
const leftZ = segment.width / 2 - landingInset
|
||||||
|
const rightZ = -segment.width / 2 + landingInset
|
||||||
|
|
||||||
|
return {
|
||||||
|
side,
|
||||||
|
points:
|
||||||
|
side === 'left'
|
||||||
|
? [
|
||||||
|
[backX, 0, leftZ],
|
||||||
|
[frontX, 0, leftZ],
|
||||||
|
]
|
||||||
|
: side === 'right'
|
||||||
|
? [
|
||||||
|
[backX, 0, rightZ],
|
||||||
|
[frontX, 0, rightZ],
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
[landingFrontX, 0, leftZ],
|
||||||
|
[landingFrontX, 0, rightZ],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
side,
|
||||||
|
points: [
|
||||||
|
...(previousLayout?.segment.segmentType === 'landing' ? [] : ([[flightStartX, stepHeight > 0 ? stepHeight : 0, flightSideOffset]] as [number, number, number][])),
|
||||||
|
...Array.from({ length: steps }).map(
|
||||||
|
(_, index) =>
|
||||||
|
[
|
||||||
|
-segment.length / 2 + stepDepth * index + stepDepth / 2,
|
||||||
|
stepHeight * (index + 1),
|
||||||
|
flightSideOffset,
|
||||||
|
] as [number, number, number],
|
||||||
|
),
|
||||||
|
...(nextLayout?.segment.segmentType === 'landing'
|
||||||
|
? []
|
||||||
|
: ([[flightEndX, segment.height, flightSideOffset]] as [number, number, number][])),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWorldRailPoint(layout: StairRailLayout, point: [number, number, number]): [number, number, number] {
|
||||||
|
const [localX, localY, localZ] = point
|
||||||
|
const [offsetX, offsetZ] = rotateXZ(localZ, localX, layout.rotation)
|
||||||
|
return [layout.center[0] + offsetX, layout.elevation + localY, layout.center[1] + offsetZ]
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransform[] {
|
||||||
|
const transforms: SegmentTransform[] = []
|
||||||
|
let currentPos = new THREE.Vector3(0, 0, 0)
|
||||||
|
let currentRot = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const segment = segments[i]!
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
transforms.push({ position: [currentPos.x, currentPos.y, currentPos.z], rotation: currentRot })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = segments[i - 1]!
|
||||||
|
const localAttachPos = new THREE.Vector3()
|
||||||
|
let rotChange = 0
|
||||||
|
|
||||||
|
switch (segment.attachmentSide) {
|
||||||
|
case 'front':
|
||||||
|
localAttachPos.set(0, prev.height, prev.length)
|
||||||
|
break
|
||||||
|
case 'left':
|
||||||
|
localAttachPos.set(prev.width / 2, prev.height, prev.length / 2)
|
||||||
|
rotChange = Math.PI / 2
|
||||||
|
break
|
||||||
|
case 'right':
|
||||||
|
localAttachPos.set(-prev.width / 2, prev.height, prev.length / 2)
|
||||||
|
rotChange = -Math.PI / 2
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
localAttachPos.applyAxisAngle(new THREE.Vector3(0, 1, 0), currentRot)
|
||||||
|
currentPos = currentPos.clone().add(localAttachPos)
|
||||||
|
currentRot += rotChange
|
||||||
|
|
||||||
|
transforms.push({ position: [currentPos.x, currentPos.y, currentPos.z], rotation: currentRot })
|
||||||
|
}
|
||||||
|
|
||||||
|
return transforms
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateXZ(x: number, z: number, angle: number): [number, number] {
|
||||||
|
const cos = Math.cos(angle)
|
||||||
|
const sin = Math.sin(angle)
|
||||||
|
return [x * cos + z * sin, -x * sin + z * cos]
|
||||||
|
}
|
||||||
|
|
||||||
|
function distance3(a: [number, number, number], b: [number, number, number]) {
|
||||||
|
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2])
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
CeilingSystem,
|
CeilingSystem,
|
||||||
DoorSystem,
|
DoorSystem,
|
||||||
|
FenceSystem,
|
||||||
ItemSystem,
|
ItemSystem,
|
||||||
RoofSystem,
|
RoofSystem,
|
||||||
SlabSystem,
|
SlabSystem,
|
||||||
@@ -138,6 +139,7 @@ const Viewer: React.FC<ViewerProps> = ({
|
|||||||
{/* Core systems */}
|
{/* Core systems */}
|
||||||
<CeilingSystem />
|
<CeilingSystem />
|
||||||
<DoorSystem />
|
<DoorSystem />
|
||||||
|
<FenceSystem />
|
||||||
<ItemSystem />
|
<ItemSystem />
|
||||||
<RoofSystem />
|
<RoofSystem />
|
||||||
<SlabSystem />
|
<SlabSystem />
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type SelectableNodeType =
|
|||||||
| 'level'
|
| 'level'
|
||||||
| 'zone'
|
| 'zone'
|
||||||
| 'wall'
|
| 'wall'
|
||||||
|
| 'fence'
|
||||||
| 'window'
|
| 'window'
|
||||||
| 'door'
|
| 'door'
|
||||||
| 'item'
|
| 'item'
|
||||||
@@ -138,6 +139,13 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean =
|
|||||||
return startIn || endIn
|
return startIn || endIn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.type === 'fence') {
|
||||||
|
const fence = node as { start: [number, number]; end: [number, number] }
|
||||||
|
const startIn = pointInPolygonWithTolerance(fence.start[0], fence.start[1], zone.polygon)
|
||||||
|
const endIn = pointInPolygonWithTolerance(fence.end[0], fence.end[1], zone.polygon)
|
||||||
|
return startIn || endIn
|
||||||
|
}
|
||||||
|
|
||||||
if (node.type === 'slab' || node.type === 'ceiling') {
|
if (node.type === 'slab' || node.type === 'ceiling') {
|
||||||
const poly = (node as { polygon: [number, number][] }).polygon
|
const poly = (node as { polygon: [number, number][] }).polygon
|
||||||
if (!poly?.length) return false
|
if (!poly?.length) return false
|
||||||
@@ -221,7 +229,7 @@ const getStrategy = (): SelectionStrategy | null => {
|
|||||||
|
|
||||||
// Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors)
|
// Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors)
|
||||||
return {
|
return {
|
||||||
types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'],
|
types: ['wall', 'fence', 'item', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'],
|
||||||
handleClick: (node, nativeEvent) => {
|
handleClick: (node, nativeEvent) => {
|
||||||
let nodeToSelect = node
|
let nodeToSelect = node
|
||||||
if (node.type === 'roof-segment' && node.parentId) {
|
if (node.type === 'roof-segment' && node.parentId) {
|
||||||
@@ -248,6 +256,7 @@ const getStrategy = (): SelectionStrategy | null => {
|
|||||||
isValid: (node) => {
|
isValid: (node) => {
|
||||||
const validTypes = [
|
const validTypes = [
|
||||||
'wall',
|
'wall',
|
||||||
|
'fence',
|
||||||
'item',
|
'item',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
@@ -303,6 +312,7 @@ export const SelectionManager = () => {
|
|||||||
'level',
|
'level',
|
||||||
'zone',
|
'zone',
|
||||||
'wall',
|
'wall',
|
||||||
|
'fence',
|
||||||
'item',
|
'item',
|
||||||
'slab',
|
'slab',
|
||||||
'ceiling',
|
'ceiling',
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
type DoorNode,
|
type DoorNode,
|
||||||
type EventSuffix,
|
type EventSuffix,
|
||||||
emitter,
|
emitter,
|
||||||
|
type FenceEvent,
|
||||||
|
type FenceNode,
|
||||||
type ItemEvent,
|
type ItemEvent,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type LevelEvent,
|
type LevelEvent,
|
||||||
@@ -37,6 +39,7 @@ type NodeConfig = {
|
|||||||
site: { node: SiteNode; event: SiteEvent }
|
site: { node: SiteNode; event: SiteEvent }
|
||||||
item: { node: ItemNode; event: ItemEvent }
|
item: { node: ItemNode; event: ItemEvent }
|
||||||
wall: { node: WallNode; event: WallEvent }
|
wall: { node: WallNode; event: WallEvent }
|
||||||
|
fence: { node: FenceNode; event: FenceEvent }
|
||||||
building: { node: BuildingNode; event: BuildingEvent }
|
building: { node: BuildingNode; event: BuildingEvent }
|
||||||
level: { node: LevelNode; event: LevelEvent }
|
level: { node: LevelNode; event: LevelEvent }
|
||||||
zone: { node: ZoneNode; event: ZoneEvent }
|
zone: { node: ZoneNode; event: ZoneEvent }
|
||||||
|
|||||||
Reference in New Issue
Block a user