diff --git a/apps/editor/public/icons/fence.png b/apps/editor/public/icons/fence.png new file mode 100644 index 00000000..47f677f5 Binary files /dev/null and b/apps/editor/public/icons/fence.png differ diff --git a/bun.lock b/bun.lock index 8778a38c..35f9e541 100644 --- a/bun.lock +++ b/bun.lock @@ -48,7 +48,7 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", @@ -166,7 +166,7 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "polygon-clipping": "^0.15.7", "zustand": "^5", diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 5592be3d..18bf9aed 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -4,6 +4,7 @@ import type { BuildingNode, CeilingNode, DoorNode, + FenceNode, ItemNode, LevelNode, RoofNode, @@ -41,6 +42,7 @@ export interface NodeEvent { } export type WallEvent = NodeEvent +export type FenceEvent = NodeEvent export type ItemEvent = NodeEvent export type SiteEvent = NodeEvent export type BuildingEvent = NodeEvent @@ -111,6 +113,7 @@ type ThumbnailEvents = { type EditorEvents = GridEvents & NodeEvents<'wall', WallEvent> & + NodeEvents<'fence', FenceEvent> & NodeEvents<'item', ItemEvent> & NodeEvents<'site', SiteEvent> & NodeEvents<'building', BuildingEvent> & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index d6b2c0f0..c44e7909 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -15,6 +15,7 @@ export const sceneRegistry = { ceiling: new Set(), level: new Set(), wall: new Set(), + fence: new Set(), item: new Set(), slab: new Set(), zone: new Set(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 98f66d5a..1325c1ea 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ export type { CeilingEvent, DoorEvent, EventSuffix, + FenceEvent, GridEvent, ItemEvent, LevelEvent, @@ -44,6 +45,7 @@ export { useInteractive, } from './store/use-interactive' 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 { CeilingSystem } from './systems/ceiling/ceiling-system' export { DoorSystem } from './systems/door/door-system' diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 927c48a5..26da7c49 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -15,6 +15,7 @@ export { export { BuildingNode } from './nodes/building' export { CeilingNode } from './nodes/ceiling' export { DoorNode, DoorSegment } from './nodes/door' +export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode } from './nodes/guide' export type { AnimationEffect, @@ -36,7 +37,7 @@ export { ScanNode } from './nodes/scan' // Nodes export { SiteNode } from './nodes/site' 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 { WallNode } from './nodes/wall' export { WindowNode } from './nodes/window' diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts new file mode 100644 index 00000000..3a42e9d8 --- /dev/null +++ b/packages/core/src/schema/nodes/fence.ts @@ -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 diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 9a834750..4761161c 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -2,6 +2,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { CeilingNode } from './ceiling' +import { FenceNode } from './fence' import { GuideNode } from './guide' import { RoofNode } from './roof' import { ScanNode } from './scan' @@ -17,6 +18,7 @@ export const LevelNode = BaseNode.extend({ .array( z.union([ WallNode.shape.id, + FenceNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, CeilingNode.shape.id, diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index 2901771e..9e3df380 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -4,6 +4,14 @@ import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' 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 +export type StairType = z.infer +export type StairTopLandingMode = z.infer + export const StairNode = BaseNode.extend({ id: objectId('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]), // Rotation around Y axis in radians 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 children: z.array(StairSegmentNode.shape.id).default([]), }).describe( dedent` Stair node - a container for stair segments. - Acts as a group that holds one or more StairSegmentNodes (flights and landings). - Segments chain together based on their attachmentSide to form complex staircase shapes. + Acts as a group that either holds one or more StairSegmentNodes (straight stairs) + or stores stair-level geometry properties for curved stairs. - position: center position of the stair group - 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 `, ) diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index d7b3538e..1b17a6b0 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -2,6 +2,7 @@ import z from 'zod' import { BuildingNode } from './nodes/building' import { CeilingNode } from './nodes/ceiling' import { DoorNode } from './nodes/door' +import { FenceNode } from './nodes/fence' import { GuideNode } from './nodes/guide' import { ItemNode } from './nodes/item' import { LevelNode } from './nodes/level' @@ -21,6 +22,7 @@ export const AnyNode = z.discriminatedUnion('type', [ BuildingNode, LevelNode, WallNode, + FenceNode, ItemNode, ZoneNode, SlabNode, diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index d9cdc2d2..83803afa 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -378,8 +378,8 @@ useScene.temporal.subscribe((state) => { // Mark sibling nodes dirty so they can update their geometry // (e.g. adjacent walls need to recalculate miter/junction geometry) const parent = currentNodes[parentId] - if (parent && 'children' in parent) { - for (const childId of (parent as AnyNode & { children: string[] }).children) { + if (parent && 'children' in parent && Array.isArray(parent.children)) { + for (const childId of parent.children) { markDirty(childId as AnyNodeId) } } diff --git a/packages/core/src/systems/fence/fence-system.tsx b/packages/core/src/systems/fence/fence-system.tsx new file mode 100644 index 00000000..7f1de3bd --- /dev/null +++ b/packages/core/src/systems/fence/fence-system.tsx @@ -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 +} diff --git a/packages/core/src/systems/stair/stair-system.tsx b/packages/core/src/systems/stair/stair-system.tsx index d85a8c14..c799e9d1 100644 --- a/packages/core/src/systems/stair/stair-system.tsx +++ b/packages/core/src/systems/stair/stair-system.tsx @@ -304,15 +304,18 @@ function updateMergedStairGeometry( const mergedMesh = group.getObjectByName('merged-stair') as THREE.Mesh | undefined if (!mergedMesh) return + if (stairNode.stairType === 'curved' || stairNode.stairType === 'spiral') { + replaceMeshGeometry(mergedMesh, createEmptyGeometry()) + return + } + const children = stairNode.children ?? [] const segments = children .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) .filter((n): n is StairSegmentNode => n?.type === 'stair-segment') if (segments.length === 0) { - mergedMesh.geometry.dispose() - mergedMesh.geometry = new THREE.BufferGeometry() - mergedMesh.geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + replaceMeshGeometry(mergedMesh, createEmptyGeometry()) return } @@ -337,11 +340,8 @@ function updateMergedStairGeometry( geometries.push(geo) } - const merged = mergeGeometries(geometries, false) - if (merged) { - mergedMesh.geometry.dispose() - mergedMesh.geometry = merged - } + const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry() + replaceMeshGeometry(mergedMesh, merged) // Dispose individual 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] } +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. */ diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 62345a2b..ea6e46b0 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, type CeilingNode, DoorNode, + FenceNode, ItemNode, RoofNode, RoofSegmentNode, @@ -33,6 +34,7 @@ const ALLOWED_TYPES = [ 'stair', 'stair-segment', 'wall', + 'fence', 'slab', 'ceiling', ] @@ -82,6 +84,7 @@ export function FloatingActionMenu() { node.type === 'item' || node.type === 'window' || node.type === 'door' || + node.type === 'fence' || node.type === 'roof' || node.type === 'roof-segment' || node.type === 'stair' || @@ -113,11 +116,18 @@ export function FloatingActionMenu() { duplicate = WindowNode.parse(duplicateInfo) } else if (node.type === 'item') { duplicate = ItemNode.parse(duplicateInfo) + } else if (node.type === 'fence') { + duplicate = FenceNode.parse(duplicateInfo) + duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1] + duplicate.end = [duplicate.end[0] + 1, duplicate.end[1] + 1] } else if (node.type === 'roof') { duplicate = RoofNode.parse(duplicateInfo) } else if (node.type === 'roof-segment') { duplicate = RoofSegmentNode.parse(duplicateInfo) } else if (node.type === 'stair') { + duplicateInfo.children = [] + duplicateInfo.metadata = { ...duplicateInfo.metadata } + delete duplicateInfo.metadata?.isNew duplicate = StairNode.parse(duplicateInfo) } else if (node.type === 'stair-segment') { duplicate = StairSegmentNode.parse(duplicateInfo) @@ -130,6 +140,8 @@ export function FloatingActionMenu() { if (duplicate) { if (duplicate.type === 'door' || duplicate.type === 'window') { useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) + } else if (duplicate.type === 'fence') { + useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) } else if ( duplicate.type === 'roof' || duplicate.type === 'roof-segment' || @@ -144,7 +156,35 @@ export function FloatingActionMenu() { duplicate.position[2] + 1, ] } - useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) + if (node.type === 'stair' && duplicate.type === 'stair') { + const nodesState = useScene.getState().nodes + const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [ + { node: duplicate, parentId: duplicate.parentId as AnyNodeId }, + ] + + for (const childId of node.children ?? []) { + const childNode = nodesState[childId] + if (childNode?.type !== 'stair-segment') { + continue + } + + let childDuplicateInfo = structuredClone(childNode) as any + delete childDuplicateInfo.id + childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata } + delete childDuplicateInfo.metadata?.isNew + + try { + const childDuplicate = StairSegmentNode.parse(childDuplicateInfo) + createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId }) + } catch (e) { + console.error('Failed to duplicate stair segment', e) + } + } + + useScene.getState().createNodes(createOps) + } else { + useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) + } // Duplicate children for roof nodes if (node.type === 'roof' && node.children) { @@ -166,36 +206,23 @@ export function FloatingActionMenu() { } // Duplicate children for stair nodes - if (node.type === 'stair' && node.children) { - const nodesState = useScene.getState().nodes - for (const childId of node.children) { - const childNode = nodesState[childId] - if (childNode && childNode.type === 'stair-segment') { - let childDuplicateInfo = structuredClone(childNode) as any - delete childDuplicateInfo.id - childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true } - try { - const childDuplicate = StairSegmentNode.parse(childDuplicateInfo) - useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId) - } catch (e) { - console.error('Failed to duplicate stair segment', e) - } - } - } - } } if ( duplicate.type === 'item' || + duplicate.type === 'fence' || duplicate.type === 'window' || duplicate.type === 'door' || duplicate.type === 'roof' || duplicate.type === 'roof-segment' || - duplicate.type === 'stair' || duplicate.type === 'stair-segment' ) { setMovingNode(duplicate as any) + } else if (duplicate.type === 'stair') { + setSelection({ selectedIds: [duplicate.id as AnyNodeId] }) + } + if (duplicate.type !== 'stair') { + setSelection({ selectedIds: [] }) } - setSelection({ selectedIds: [] }) } }, [node, setMovingNode, setSelection], diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 37133fdd..b9701612 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -8063,6 +8063,7 @@ export function FloorplanPanel() { ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), isNew: true, } + cloned.children = [] try { const duplicate = ItemNodeSchema.parse(cloned) @@ -8191,8 +8192,8 @@ export function FloorplanPanel() { delete cloned.id cloned.metadata = { ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), - isNew: true, } + delete (cloned.metadata as Record).isNew const nextPosition = Array.isArray(cloned.position) && cloned.position.length >= 3 @@ -8207,9 +8208,11 @@ export function FloorplanPanel() { try { const duplicate = StairNodeSchema.parse(cloned) - useScene.getState().createNode(duplicate, stair.parentId as AnyNodeId) - const nodesState = useScene.getState().nodes + const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [ + { node: duplicate, parentId: stair.parentId as AnyNodeId }, + ] + for (const childId of stair.children ?? []) { const childNode = nodesState[childId] if (childNode?.type !== 'stair-segment') { @@ -8222,19 +8225,20 @@ export function FloorplanPanel() { ...(typeof childClone.metadata === 'object' && childClone.metadata !== null ? childClone.metadata : {}), - isNew: true, } + delete (childClone.metadata as Record).isNew const childDuplicate = StairSegmentNodeSchema.parse(childClone) - useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId) + createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId }) } - setMovingNode(duplicate) - setSelection({ selectedIds: [] }) + useScene.getState().createNodes(createOps) + + setSelection({ selectedIds: [duplicate.id as AnyNodeId] }) } catch (error) { console.error('Failed to duplicate stair', error) } - }, [selectedStairEntry, setMovingNode, setSelection]) + }, [selectedStairEntry, setSelection]) const handleSelectedStairDuplicate = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 04518047..3de50223 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -26,6 +26,7 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => { type SelectableNodeType = | 'wall' + | 'fence' | 'item' | 'building' | 'zone' @@ -186,6 +187,7 @@ const SELECTION_STRATEGIES: Record = { structure: { types: [ 'wall', + 'fence', 'item', 'zone', 'slab', @@ -238,6 +240,7 @@ const SELECTION_STRATEGIES: Record = { } if ( node.type === 'wall' || + node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -299,6 +302,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => { if ( node.type === 'wall' || + node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -443,6 +447,7 @@ export const SelectionManager = () => { const allTypes = [ 'wall', + 'fence', 'item', 'building', 'zone', @@ -537,6 +542,7 @@ export const SelectionManager = () => { } } else if ( node.type === 'wall' || + node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -586,6 +592,7 @@ export const SelectionManager = () => { const allTypes = [ 'wall', + 'fence', 'item', 'building', 'slab', @@ -657,6 +664,7 @@ export const SelectionManager = () => { const allTypes = [ 'wall', + 'fence', 'item', 'slab', 'ceiling', diff --git a/packages/editor/src/components/systems/stair/stair-edit-system.tsx b/packages/editor/src/components/systems/stair/stair-edit-system.tsx index 0edd1797..265d57b5 100644 --- a/packages/editor/src/components/systems/stair/stair-edit-system.tsx +++ b/packages/editor/src/components/systems/stair/stair-edit-system.tsx @@ -1,6 +1,6 @@ import { type AnyNodeId, type StairNode, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' /** * Imperatively toggles the Three.js visibility of stair objects based on the @@ -17,6 +17,27 @@ import { useEffect, useRef } from 'react' */ export const StairEditSystem = () => { const selectedIds = useViewer((s) => s.selection.selectedIds) + const selectedStairSignature = useScene( + useCallback( + (state) => + selectedIds + .map((id) => { + const node = state.nodes[id as AnyNodeId] + if (!node) return null + if (node.type === 'stair') { + return `${node.id}:${node.stairType}` + } + if (node.type === 'stair-segment' && node.parentId) { + const parent = state.nodes[node.parentId as AnyNodeId] as StairNode | undefined + return parent?.type === 'stair' ? `${parent.id}:${parent.stairType}` : null + } + return null + }) + .filter(Boolean) + .join('|'), + [selectedIds], + ), + ) const prevActiveStairIds = useRef(new Set()) useEffect(() => { @@ -41,14 +62,15 @@ export const StairEditSystem = () => { const group = sceneRegistry.nodes.get(stairId) if (!group) continue + const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined + const isCurved = stairNode?.stairType === 'curved' || stairNode?.stairType === 'spiral' const mergedMesh = group.getObjectByName('merged-stair') const segmentsWrapper = group.getObjectByName('segments-wrapper') const isActive = activeStairIds.has(stairId) - if (mergedMesh) mergedMesh.visible = !isActive - if (segmentsWrapper) segmentsWrapper.visible = isActive + if (mergedMesh) mergedMesh.visible = !isActive && !isCurved + if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved - const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined if (stairNode?.children?.length) { const wasActive = prevActiveStairIds.current.has(stairId) if (isActive !== wasActive) { @@ -63,7 +85,7 @@ export const StairEditSystem = () => { } prevActiveStairIds.current = activeStairIds - }, [selectedIds]) + }, [selectedIds, selectedStairSignature]) return null } diff --git a/packages/editor/src/components/tools/door/door-math.ts b/packages/editor/src/components/tools/door/door-math.ts index 2fa5d03c..9a2dc9df 100644 --- a/packages/editor/src/components/tools/door/door-math.ts +++ b/packages/editor/src/components/tools/door/door-math.ts @@ -70,7 +70,7 @@ export function hasWallChildOverlap( const newLeft = clampedX - halfW const newRight = clampedX + halfW - for (const childId of wallNode.children) { + for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) { if (childId === ignoreId) continue const child = nodes[childId as AnyNodeId] if (!child) continue diff --git a/packages/editor/src/components/tools/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts new file mode 100644 index 00000000..f5ec1167 --- /dev/null +++ b/packages/editor/src/components/tools/fence/fence-drafting.ts @@ -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 = [ + 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 +} diff --git a/packages/editor/src/components/tools/fence/fence-tool.tsx b/packages/editor/src/components/tools/fence/fence-tool.tsx new file mode 100644 index 00000000..d7091fd1 --- /dev/null +++ b/packages/editor/src/components/tools/fence/fence-tool.tsx @@ -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(null) + const previewRef = useRef(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 ( + + + + + + + + ) +} diff --git a/packages/editor/src/components/tools/fence/move-fence-tool.tsx b/packages/editor/src/components/tools/fence/move-fence-tool.tsx new file mode 100644 index 00000000..4fb36354 --- /dev/null +++ b/packages/editor/src/components/tools/fence/move-fence-tool.tsx @@ -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 ( + + + + ) +} diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 3ea47ddf..20cefbf5 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -1,6 +1,7 @@ import type { BuildingNode, DoorNode, + FenceNode, ItemNode, RoofNode, RoofSegmentNode, @@ -13,6 +14,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { MoveBuildingContent } from '../building/move-building-tool' import { MoveDoorTool } from '../door/move-door-tool' +import { MoveFenceTool } from '../fence/move-fence-tool' import { MoveRoofTool } from '../roof/move-roof-tool' import { MoveWindowTool } from '../window/move-window-tool' import type { PlacementState } from './placement-types' @@ -86,6 +88,7 @@ export const MoveTool: React.FC = () => { return if (movingNode.type === 'door') return if (movingNode.type === 'window') return + if (movingNode.type === 'fence') return if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') return if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') diff --git a/packages/editor/src/components/tools/select/box-select-tool.tsx b/packages/editor/src/components/tools/select/box-select-tool.tsx index 5166ca31..8dd700a6 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -197,7 +197,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] { const node = nodes[childId as AnyNodeId] if (!node) continue - if (node.type === 'wall') { + if (node.type === 'wall' || node.type === 'fence') { const wall = node as WallNode if ( segmentIntersectsBounds(wall.start[0], wall.start[1], wall.end[0], wall.end[1], bounds) @@ -205,7 +205,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] { result.push(wall.id) } // Check wall children (doors/windows) - for (const itemId of wall.children) { + for (const itemId of Array.isArray(wall.children) ? wall.children : []) { const child = nodes[itemId as AnyNodeId] if (!child) continue if ( diff --git a/packages/editor/src/components/tools/stair/stair-defaults.ts b/packages/editor/src/components/tools/stair/stair-defaults.ts index 6ccd1f5e..ace03f5e 100644 --- a/packages/editor/src/components/tools/stair/stair-defaults.ts +++ b/packages/editor/src/components/tools/stair/stair-defaults.ts @@ -1,3 +1,4 @@ +export const DEFAULT_STAIR_TYPE = 'straight' as const export const DEFAULT_STAIR_WIDTH = 1.0 export const DEFAULT_STAIR_LENGTH = 3.0 export const DEFAULT_STAIR_HEIGHT = 2.5 @@ -5,3 +6,12 @@ export const DEFAULT_STAIR_STEP_COUNT = 10 export const DEFAULT_STAIR_ATTACHMENT_SIDE = 'front' as const export const DEFAULT_STAIR_FILL_TO_FLOOR = true export const DEFAULT_STAIR_THICKNESS = 0.25 +export const DEFAULT_CURVED_STAIR_INNER_RADIUS = 0.9 +export const DEFAULT_CURVED_STAIR_SWEEP_ANGLE = Math.PI / 2 +export const DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE = (400 * Math.PI) / 180 +export const DEFAULT_SPIRAL_TOP_LANDING_MODE = 'none' as const +export const DEFAULT_SPIRAL_TOP_LANDING_DEPTH = 0.9 +export const DEFAULT_SPIRAL_SHOW_CENTER_COLUMN = true +export const DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS = true +export const DEFAULT_STAIR_RAILING_MODE = 'right' as const +export const DEFAULT_STAIR_RAILING_HEIGHT = 0.92 diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index a9093611..2ed9031a 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -13,12 +13,21 @@ import * as THREE from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' import { + DEFAULT_CURVED_STAIR_INNER_RADIUS, + DEFAULT_CURVED_STAIR_SWEEP_ANGLE, + DEFAULT_SPIRAL_SHOW_CENTER_COLUMN, + DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS, + DEFAULT_SPIRAL_TOP_LANDING_DEPTH, + DEFAULT_SPIRAL_TOP_LANDING_MODE, DEFAULT_STAIR_ATTACHMENT_SIDE, DEFAULT_STAIR_FILL_TO_FLOOR, DEFAULT_STAIR_HEIGHT, DEFAULT_STAIR_LENGTH, + DEFAULT_STAIR_RAILING_HEIGHT, + DEFAULT_STAIR_RAILING_MODE, DEFAULT_STAIR_STEP_COUNT, DEFAULT_STAIR_THICKNESS, + DEFAULT_STAIR_TYPE, DEFAULT_STAIR_WIDTH, } from './stair-defaults' @@ -88,6 +97,20 @@ function commitStairPlacement( name, position, rotation, + stairType: DEFAULT_STAIR_TYPE, + width: DEFAULT_STAIR_WIDTH, + totalRise: DEFAULT_STAIR_HEIGHT, + stepCount: DEFAULT_STAIR_STEP_COUNT, + thickness: DEFAULT_STAIR_THICKNESS, + fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR, + innerRadius: DEFAULT_CURVED_STAIR_INNER_RADIUS, + sweepAngle: DEFAULT_CURVED_STAIR_SWEEP_ANGLE, + topLandingMode: DEFAULT_SPIRAL_TOP_LANDING_MODE, + topLandingDepth: DEFAULT_SPIRAL_TOP_LANDING_DEPTH, + showCenterColumn: DEFAULT_SPIRAL_SHOW_CENTER_COLUMN, + showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS, + railingHeight: DEFAULT_STAIR_RAILING_HEIGHT, + railingMode: DEFAULT_STAIR_RAILING_MODE, children: [segment.id], }) diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index c806c502..50373250 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -11,6 +11,7 @@ import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor' import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor' import { CeilingTool } from './ceiling/ceiling-tool' import { DoorTool } from './door/door-tool' +import { FenceTool } from './fence/fence-tool' import { ItemTool } from './item/item-tool' import { MoveTool } from './item/move-tool' import { RoofTool } from './roof/roof-tool' @@ -30,6 +31,7 @@ const tools: Record>> = { }, structure: { wall: WallTool, + fence: FenceTool, slab: SlabTool, ceiling: CeilingTool, roof: RoofTool, diff --git a/packages/editor/src/components/tools/window/window-math.ts b/packages/editor/src/components/tools/window/window-math.ts index 842634a2..182611a2 100644 --- a/packages/editor/src/components/tools/window/window-math.ts +++ b/packages/editor/src/components/tools/window/window-math.ts @@ -77,7 +77,7 @@ export function hasWallChildOverlap( const newLeft = clampedX - halfW const newRight = clampedX + halfW - for (const childId of wallNode.children) { + for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) { if (childId === ignoreId) continue const child = nodes[childId as AnyNodeId] if (!child) continue diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index 6e731cae..d0cf44b5 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -28,6 +28,7 @@ export const tools: ToolConfig[] = [ { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' }, { id: 'door', iconSrc: '/icons/door.png', label: 'Door' }, { id: 'window', iconSrc: '/icons/window.png', label: 'Window' }, + { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, ] diff --git a/packages/editor/src/components/ui/panels/fence-panel.tsx b/packages/editor/src/components/ui/panels/fence-panel.tsx new file mode 100644 index 00000000..a61ff783 --- /dev/null +++ b/packages/editor/src/components/ui/panels/fence-panel.tsx @@ -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) => { + 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 ( + + + handleUpdate({ style: value })} + options={FENCE_STYLE_OPTIONS} + value={node.style} + /> + handleUpdate({ baseStyle: value })} + options={FENCE_BASE_STYLE_OPTIONS} + value={node.baseStyle} + /> + + + + + handleUpdate({ height: Math.max(0.4, value) })} + precision={2} + step={0.05} + unit="m" + value={node.height} + /> + handleUpdate({ thickness: Math.max(0.03, value) })} + precision={3} + step={0.005} + unit="m" + value={node.thickness} + /> + + + + handleUpdate({ baseHeight: Math.max(0.04, value) })} + precision={3} + step={0.01} + unit="m" + value={node.baseHeight} + /> + handleUpdate({ topRailHeight: Math.max(0.01, value) })} + precision={3} + step={0.005} + unit="m" + value={node.topRailHeight} + /> + handleUpdate({ postSpacing: Math.max(0.2, value) })} + precision={2} + step={0.05} + unit="m" + value={node.postSpacing} + /> + handleUpdate({ postSize: Math.max(0.01, value) })} + precision={3} + step={0.005} + unit="m" + value={node.postSize} + /> + handleUpdate({ groundClearance: Math.max(0, value) })} + precision={3} + step={0.005} + unit="m" + value={node.groundClearance} + /> + handleUpdate({ edgeInset: Math.max(0.005, value) })} + precision={3} + step={0.005} + unit="m" + value={node.edgeInset} + /> + + + ) +} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 4455842c..7ff860c0 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -5,6 +5,7 @@ import { useViewer } from '@pascal-app/viewer' import useEditor from '../../../store/use-editor' import { CeilingPanel } from './ceiling-panel' import { DoorPanel } from './door-panel' +import { FencePanel } from './fence-panel' import { ItemPanel } from './item-panel' import { ReferencePanel } from './reference-panel' import { RoofPanel } from './roof-panel' @@ -47,6 +48,8 @@ export function PanelManager() { return case 'wall': return + case 'fence': + return case 'door': return case 'window': diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx index a6dd60f9..c91e9684 100644 --- a/packages/editor/src/components/ui/panels/stair-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -5,6 +5,9 @@ import { type AnyNodeId, type MaterialSchema, type StairNode, + type StairRailingMode, + type StairTopLandingMode, + type StairType, StairNode as StairNodeSchema, type StairSegmentNode, StairSegmentNode as StairSegmentNodeSchema, @@ -15,19 +18,41 @@ import { Copy, Move, Plus, Trash2 } from 'lucide-react' import { useCallback } from 'react' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' +import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults' import { ActionButton, ActionGroup } from '../controls/action-button' import { MaterialPicker } from '../controls/material-picker' import { MetricControl } from '../controls/metric-control' import { PanelSection } from '../controls/panel-section' +import { SegmentedControl } from '../controls/segmented-control' import { SliderControl } from '../controls/slider-control' +import { ToggleControl } from '../controls/toggle-control' import { PanelWrapper } from './panel-wrapper' +const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [ + { label: 'None', value: 'none' }, + { label: 'Left', value: 'left' }, + { label: 'Right', value: 'right' }, + { label: 'Both', value: 'both' }, +] + +const STAIR_TYPE_OPTIONS: { label: string; value: StairType }[] = [ + { label: 'Straight', value: 'straight' }, + { label: 'Curved', value: 'curved' }, + { label: 'Spiral', value: 'spiral' }, +] + +const TOP_LANDING_MODE_OPTIONS: { label: string; value: StairTopLandingMode }[] = [ + { label: 'None', value: 'none' }, + { label: 'Integrated', value: 'integrated' }, +] + export function StairPanel() { const selectedIds = useViewer((s) => s.selection.selectedIds) const setSelection = useViewer((s) => s.setSelection) const nodes = useScene((s) => s.nodes) const updateNode = useScene((s) => s.updateNode) const createNode = useScene((s) => s.createNode) + const createNodes = useScene((s) => s.createNodes) const setMovingNode = useEditor((s) => s.setMovingNode) const selectedId = selectedIds[0] @@ -114,7 +139,8 @@ export function StairPanel() { let duplicateInfo = structuredClone(node) as any delete duplicateInfo.id - duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true } + duplicateInfo.metadata = { ...duplicateInfo.metadata } + duplicateInfo.children = [] duplicateInfo.position = [ duplicateInfo.position[0] + 1, duplicateInfo.position[1], @@ -123,29 +149,31 @@ export function StairPanel() { try { const duplicate = StairNodeSchema.parse(duplicateInfo) - useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) - // Also duplicate all child segments const nodesState = useScene.getState().nodes const children = node.children || [] + const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [ + { node: duplicate, parentId: duplicate.parentId as AnyNodeId }, + ] for (const childId of children) { const childNode = nodesState[childId] if (childNode && childNode.type === 'stair-segment') { let childDuplicateInfo = structuredClone(childNode) as any delete childDuplicateInfo.id - childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true } + childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata } const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo) - useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId) + createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId }) } } - setSelection({ selectedIds: [] }) - setMovingNode(duplicate) + createNodes(createOps) + + setSelection({ selectedIds: [duplicate.id as AnyNode['id']] }) } catch (e) { console.error('Failed to duplicate stair', e) } - }, [node, setSelection, setMovingNode]) + }, [createNodes, node, setSelection]) const handleMove = useCallback(() => { if (node) { @@ -179,33 +207,158 @@ export function StairPanel() { title={node.name || 'Staircase'} width={300} > - -
- {segments.map((seg, i) => ( - - ))} -
-
- } - label="Add flight" - onClick={handleAddFlight} + + + 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'} + /> + + + {node.stairType === 'straight' && ( + +
+ {segments.map((seg, i) => ( + + ))} +
+
+ } + label="Add flight" + onClick={handleAddFlight} + /> + } + label="Add landing" + onClick={handleAddLanding} + /> +
+
+ )} + + {(node.stairType === 'curved' || node.stairType === 'spiral') && ( + + handleUpdate({ width: value })} + precision={2} + step={0.05} + unit="m" + value={Math.round((node.width ?? 1) * 100) / 100} /> - } - label="Add landing" - onClick={handleAddLanding} + handleUpdate({ totalRise: value })} + precision={2} + step={0.05} + unit="m" + value={Math.round((node.totalRise ?? 2.5) * 100) / 100} /> -
-
+ 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' && ( + handleUpdate({ fillToFloor: checked })} + /> + )} + {(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && ( + handleUpdate({ thickness: value })} + precision={2} + step={0.01} + unit="m" + value={Math.round((node.thickness ?? 0.25) * 100) / 100} + /> + )} + handleUpdate({ innerRadius: value })} + precision={2} + step={0.05} + unit="m" + value={Math.round((node.innerRadius ?? 0.9) * 100) / 100} + /> + 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' && ( + <> + handleUpdate({ topLandingMode: value })} + options={TOP_LANDING_MODE_OPTIONS} + value={node.topLandingMode ?? 'none'} + /> + {(node.topLandingMode ?? 'none') === 'integrated' && ( + handleUpdate({ topLandingDepth: value })} + precision={2} + step={0.05} + unit="m" + value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100} + /> + )} + handleUpdate({ showCenterColumn: checked })} + /> + handleUpdate({ showStepSupports: checked })} + /> + + )} + + )} + + handleUpdate({ railingMode: value })} + options={RAILING_MODE_OPTIONS} + value={node.railingMode ?? 'none'} + /> + {(node.railingMode ?? 'none') !== 'none' && ( + handleUpdate({ railingHeight: value })} + precision={2} + step={0.02} + unit="m" + value={Math.round((node.railingHeight ?? 0.92) * 100) / 100} + /> + )} + + } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx new file mode 100644 index 00000000..0e2206fa --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx @@ -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 ( + } + depth={depth} + expanded={false} + hasChildren={false} + icon={ + + } + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={node.visible !== false} + label={ + setIsEditing(true)} + onStopEditing={() => setIsEditing(false)} + /> + } + nodeId={nodeId} + onClick={handleClick} + onDoubleClick={() => focusTreeNode(nodeId)} + onMouseEnter={() => setHoveredId(nodeId)} + onMouseLeave={() => setHoveredId(null)} + onToggle={() => {}} + /> + ) +} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index f2b069b1..eabf547e 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -57,6 +57,7 @@ import { cn } from '../../../../../lib/utils' import { BuildingTreeNode } from './building-tree-node' import { CeilingTreeNode } from './ceiling-tree-node' import { DoorTreeNode } from './door-tree-node' +import { FenceTreeNode } from './fence-tree-node' import { ItemTreeNode } from './item-tree-node' import { LevelTreeNode } from './level-tree-node' import { RoofTreeNode } from './roof-tree-node' @@ -88,6 +89,8 @@ export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) { return case 'wall': return + case 'fence': + return case 'roof': return case 'stair': diff --git a/packages/editor/src/components/viewer-overlay.tsx b/packages/editor/src/components/viewer-overlay.tsx index 06f19a13..72b89c7f 100755 --- a/packages/editor/src/components/viewer-overlay.tsx +++ b/packages/editor/src/components/viewer-overlay.tsx @@ -62,6 +62,7 @@ const wallModeConfig = { const getNodeName = (node: AnyNode): string => { if ('name' in node && node.name) return node.name if (node.type === 'wall') return 'Wall' + if (node.type === 'fence') return 'Fence' if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item' if (node.type === 'slab') return 'Slab' if (node.type === 'ceiling') return 'Ceiling' diff --git a/packages/editor/src/hooks/use-contextual-tools.ts b/packages/editor/src/hooks/use-contextual-tools.ts index 50beff88..e24ec6fe 100644 --- a/packages/editor/src/hooks/use-contextual-tools.ts +++ b/packages/editor/src/hooks/use-contextual-tools.ts @@ -16,7 +16,15 @@ export function useContextualTools() { } // Default tools when nothing is selected - const defaultTools: StructureTool[] = ['wall', 'slab', 'ceiling', 'roof', 'door', 'window'] + const defaultTools: StructureTool[] = [ + 'wall', + 'fence', + 'slab', + 'ceiling', + 'roof', + 'door', + 'window', + ] if (selection.selectedIds.length === 0) { return defaultTools @@ -29,7 +37,7 @@ export function useContextualTools() { // If a wall is selected, prioritize wall-hosted elements if (selectedTypes.has('wall')) { - return ['window', 'door', 'wall'] as StructureTool[] + return ['window', 'door', 'wall', 'fence'] as StructureTool[] } // If a slab is selected, prioritize slab editing diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index dd4dd2f0..6ace07b8 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -4,6 +4,7 @@ import type { AssetInput } from '@pascal-app/core' import { type BuildingNode, type DoorNode, + type FenceNode, type ItemNode, type LevelNode, type RoofNode, @@ -33,6 +34,7 @@ export type Mode = 'select' | 'edit' | 'delete' | 'build' // Structure mode tools (building elements) export type StructureTool = | 'wall' + | 'fence' | 'room' | 'custom-room' | 'slab' @@ -85,6 +87,7 @@ type EditorState = { | ItemNode | WindowNode | DoorNode + | FenceNode | RoofNode | RoofSegmentNode | StairNode @@ -96,6 +99,7 @@ type EditorState = { | ItemNode | WindowNode | DoorNode + | FenceNode | RoofNode | RoofSegmentNode | StairNode diff --git a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx new file mode 100644 index 00000000..008e083d --- /dev/null +++ b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx @@ -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(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 ( + + + + ) +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 1347e372..827f6893 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -4,6 +4,7 @@ import { type AnyNode, useScene } from '@pascal-app/core' import { BuildingRenderer } from './building/building-renderer' import { CeilingRenderer } from './ceiling/ceiling-renderer' import { DoorRenderer } from './door/door-renderer' +import { FenceRenderer } from './fence/fence-renderer' import { GuideRenderer } from './guide/guide-renderer' import { ItemRenderer } from './item/item-renderer' import { LevelRenderer } from './level/level-renderer' @@ -32,6 +33,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'item' && } {node.type === 'slab' && } {node.type === 'wall' && } + {node.type === 'fence' && } {node.type === 'door' && } {node.type === 'window' && } {node.type === 'zone' && } diff --git a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx index efeb24ad..d90d5a3c 100644 --- a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx +++ b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx @@ -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 type * as THREE from 'three' +import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' 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 }) => { const ref = useRef(null!) @@ -34,6 +64,10 @@ export const StairRenderer = ({ node }: { node: StairNode }) => { + {node.stairType === 'curved' || node.stairType === 'spiral' ? ( + + ) : null} + {(node.children ?? []).map((childId) => ( @@ -42,3 +76,764 @@ export const StairRenderer = ({ node }: { node: StairNode }) => { ) } + +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 ( + + {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 ( + + {sidePoints.map((point, pointIndex) => ( + + ))} + {sidePoints.slice(0, -1).map((point, pointIndex) => { + const nextPoint = sidePoints[pointIndex + 1] + if (!nextPoint) return null + + return ( + + + + + ) + })} + + ) + })} + + ) + } + + if (railPaths.length === 0) { + return null + } + + return ( + + {railPaths.map((segmentPath, index) => ( + + {segmentPath.sidePaths.map((sidePath, sideIndex) => ( + + {sidePath.points.map((point, pointIndex) => ( + + ))} + {sidePath.points.slice(0, -1).map((point, pointIndex) => { + const nextPoint = sidePath.points[pointIndex + 1] + if (!nextPoint) return null + + return ( + + + + + ) + })} + + ))} + + ))} + {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 ( + + + + + ) + }) + })} + + ) +} + +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 ( + + ) +} + +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 ( + + {isSpiral && (stair.showCenterColumn ?? true) ? ( + + + + ) : 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 ( + + {isSpiral && (stair.showStepSupports ?? true) ? ( + + + + ) : null} + + + ) + })} + {isSpiral && (stair.topLandingMode ?? 'none') === 'integrated' ? ( + + ) : null} + + ) +} + +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 +} + +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]) +} diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 1cf3b243..ec1d97bf 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -3,6 +3,7 @@ import { CeilingSystem, DoorSystem, + FenceSystem, ItemSystem, RoofSystem, SlabSystem, @@ -138,6 +139,7 @@ const Viewer: React.FC = ({ {/* Core systems */} + diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index 9174d02d..81424157 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -29,6 +29,7 @@ type SelectableNodeType = | 'level' | 'zone' | 'wall' + | 'fence' | 'window' | 'door' | 'item' @@ -138,6 +139,13 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean = 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') { const poly = (node as { polygon: [number, number][] }).polygon 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) 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) => { let nodeToSelect = node if (node.type === 'roof-segment' && node.parentId) { @@ -248,6 +256,7 @@ const getStrategy = (): SelectionStrategy | null => { isValid: (node) => { const validTypes = [ 'wall', + 'fence', 'item', 'slab', 'ceiling', @@ -303,6 +312,7 @@ export const SelectionManager = () => { 'level', 'zone', 'wall', + 'fence', 'item', 'slab', 'ceiling', diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 46cec9d3..d247ece1 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -7,6 +7,8 @@ import { type DoorNode, type EventSuffix, emitter, + type FenceEvent, + type FenceNode, type ItemEvent, type ItemNode, type LevelEvent, @@ -37,6 +39,7 @@ type NodeConfig = { site: { node: SiteNode; event: SiteEvent } item: { node: ItemNode; event: ItemEvent } wall: { node: WallNode; event: WallEvent } + fence: { node: FenceNode; event: FenceEvent } building: { node: BuildingNode; event: BuildingEvent } level: { node: LevelNode; event: LevelEvent } zone: { node: ZoneNode; event: ZoneEvent }