Phase 5 Stage E: full kind migration into packages/nodes
Wholesale move of every remaining kind into its own subdirectory under
`packages/nodes/src/`, finishing the registry-driven migration. Each
kind now ships its definition, schema (re-exported from core), and any
of `geometry` / `renderer` / `system` / `floorplan` / `tool` /
`move-tool` / `panel` / `floorplan-move` / `floorplan-affordances` /
`parametrics` / `preview` it needs — no per-kind code remains under
`packages/editor/src/components/tools/` or
`packages/viewer/src/components/renderers/`.
Deleted (replaced by registry-driven equivalents):
- `tools/{ceiling,column,door,fence,item,slab,spawn,wall,window}/...`
(boundary editors, hole editors, placement tools, move tools,
endpoint movers, curve tools, helpers, math libs)
- `ui/helpers/{ceiling,slab,wall}-helper.tsx`
- `ui/panels/{column,door,elevator,item,roof,roof-segment,spawn,
stair,stair-segment,wall,window}-panel.tsx`
- `viewer/src/components/renderers/{building,ceiling,column,door,
elevator,fence,guide,item,level,roof,roof-segment,scan,site,slab,
spawn,stair,stair-segment,wall,window,zone}-renderer.tsx`
- `viewer/src/components/viewer/legacy-system.tsx`
Added under `packages/nodes/src/`:
- `building/`, `column/`, `elevator/`, `guide/`, `level/`, `roof/`,
`roof-segment/`, `scan/`, `shared/`, `site/`, `stair/`,
`stair-segment/` packages with definition + schema + renderer / system
/ floorplan / panel as appropriate.
- New `floorplan-move.ts` for every kind that supports 2D moves
(ceiling, door, item, shelf, slab, window) — single registry-driven
dispatch path via `def.floorplanMoveTarget`.
- New `floorplan-affordances.ts` for kinds with polygon / endpoint
drags (ceiling, fence, slab, wall) — using the shared
`polygon-vertex-affordance` factories.
- New per-kind `panel.tsx` for kinds with custom inspector content
(door, item, shelf, spawn, wall, window).
- New per-kind `tool.tsx` for placement (door, item, shelf, window).
- New per-kind `move-tool.tsx` for kinds with custom 3D move flows
(door, item, slab, window).
Coordinator + manager updates in `packages/editor/`:
- `tool-manager.tsx` resolves tools from the registry only — no
hardcoded type→component map.
- `panel-manager.tsx` resolves inspector panels the same way.
- `placement-{coordinator,strategies,types}.ts` extended with
shelf-surface placement.
- `selection-manager.tsx` adds the registry-selectable fallback.
- `floorplan-panel.tsx`, `floorplan-background-placement.ts`,
`floorplan-render-context.tsx` updated for the registry layer's new
contract (props, affordance dispatch, render context).
Viewer updates:
- `viewer/index.tsx` drops legacy renderer mounts.
- `node-renderer.tsx` resolves by registry only.
- `scene-bvh.tsx`, `use-node-events.ts`, `level-system.tsx`,
`wall-cutout.tsx`, `zone-system.tsx`, `materials.ts` adjusted for
the registry-only world.
Sidebar tree nodes for ceiling / fence / slab / shelf / tree-node
updated to read from the registered nodes instead of the deleted
legacy renderer trees.
Wiki: new `plugin-authoring.md` page, README index updated.
Tests in `packages/nodes/src/index.test.ts` validate every registered
kind has the required shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
11015ea1ed
commit
d747d2f0ea
@@ -1,6 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type CeilingNode,
|
||||
nodeRegistry,
|
||||
type SlabNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
@@ -18,9 +25,13 @@ import { NodeActionMenu } from '../editor/node-action-menu'
|
||||
* an HTML overlay positioned at the top of the bounding box.
|
||||
*
|
||||
* Buttons:
|
||||
* - Move: sets `movingNode` in useEditor. The `<FloorplanRegistryMove
|
||||
* Overlay>` component picks that up and lets the user click in the
|
||||
* floor plan to commit the new position.
|
||||
* - Move: sets `movingNode` in useEditor. Enabled when the kind has
|
||||
* `capabilities.movable`, `def.floorplanMoveTarget`, OR
|
||||
* `def.affordanceTools.move` (slab / ceiling). The
|
||||
* `<FloorplanRegistryMoveOverlay>` / dispatcher picks the right path.
|
||||
* - Add hole (slab + ceiling only): inserts a small default-square
|
||||
* hole at the polygon centroid via `updateNode`. Mirrors the legacy
|
||||
* `handleAddHole` in `floating-action-menu.tsx`.
|
||||
* - Duplicate: deep-clones the node, marks it new, sets it as the
|
||||
* movingNode (placement cursor) — same UX pattern as 3D duplicate.
|
||||
* - Delete: calls `deleteNode(id)`. Cascade is handled by the registry's
|
||||
@@ -70,14 +81,66 @@ export function FloorplanRegistryActionMenu() {
|
||||
const node = useScene.getState().nodes[selectedId]
|
||||
if (!node) return null
|
||||
|
||||
const canMove = !!def.capabilities.movable
|
||||
// Move button is enabled when any of:
|
||||
// - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence)
|
||||
// - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item)
|
||||
// - `def.affordanceTools.move` (kind-owned 3D mover — slab / ceiling)
|
||||
// From the menu's perspective all three are "this kind can move from
|
||||
// the floor plan." The `MoveTool` dispatcher resolves the right path.
|
||||
const canMove =
|
||||
!!def.capabilities.movable || !!def.floorplanMoveTarget || !!def.affordanceTools?.move
|
||||
const canDuplicate = def.capabilities.duplicable !== false
|
||||
const canDelete = def.capabilities.deletable !== false
|
||||
const canAddHole = node.type === 'slab' || node.type === 'ceiling'
|
||||
|
||||
const handleMove = () => {
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node as never)
|
||||
// Selection stays — the move overlay reads movingNode, not selection.
|
||||
// Match the legacy 3D `floating-action-menu`: clear selection so
|
||||
// selection-gated affordances unmount during the drag. Specifically
|
||||
// the slab / ceiling boundary editor (`ToolManager` shows it when
|
||||
// `selectedSlabId !== undefined`) would otherwise stay visible
|
||||
// and render its vertex / edge handles on top of the moving mesh
|
||||
// in split-view 3D. The move overlay reads `movingNode`, not the
|
||||
// selection, so clearing it doesn't disturb the move itself; the
|
||||
// commit path re-selects the node when it ends.
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
}
|
||||
|
||||
const handleAddHole = () => {
|
||||
if (!canAddHole) return
|
||||
const surfaceNode = node as SlabNode | CeilingNode
|
||||
const polygon = surfaceNode.polygon
|
||||
if (!polygon || polygon.length < 3) return
|
||||
|
||||
let cx = 0
|
||||
let cz = 0
|
||||
for (const [x, z] of polygon) {
|
||||
cx += x
|
||||
cz += z
|
||||
}
|
||||
cx /= polygon.length
|
||||
cz /= polygon.length
|
||||
|
||||
const holeSize = 0.5
|
||||
const newHole: Array<[number, number]> = [
|
||||
[cx - holeSize, cz - holeSize],
|
||||
[cx + holeSize, cz - holeSize],
|
||||
[cx + holeSize, cz + holeSize],
|
||||
[cx - holeSize, cz + holeSize],
|
||||
]
|
||||
const currentHoles = surfaceNode.holes ?? []
|
||||
const currentMetadata = currentHoles.map(
|
||||
(_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const },
|
||||
)
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
useScene.getState().updateNode(
|
||||
selectedId as AnyNodeId,
|
||||
{
|
||||
holes: [...currentHoles, newHole],
|
||||
holeMetadata: [...currentMetadata, { source: 'manual' as const }],
|
||||
} as Partial<AnyNode>,
|
||||
)
|
||||
}
|
||||
|
||||
const handleDuplicate = () => {
|
||||
@@ -113,6 +176,7 @@ export function FloorplanRegistryActionMenu() {
|
||||
}}
|
||||
>
|
||||
<NodeActionMenu
|
||||
onAddHole={canAddHole ? handleAddHole : undefined}
|
||||
onDelete={canDelete ? handleDelete : undefined}
|
||||
onDuplicate={canDuplicate ? handleDuplicate : undefined}
|
||||
onMove={canMove ? handleMove : undefined}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import type { FloorplanPalette } from '@pascal-app/core'
|
||||
import { createContext, type ReactNode, useContext, useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* Per-frame render context shared between the legacy `floorplan-panel.tsx`
|
||||
* and the registry-driven `<FloorplanRegistryLayer>`.
|
||||
*
|
||||
* The legacy panel is the authoritative owner of the floor-plan SVG —
|
||||
* it computes `unitsPerPixel` from the viewBox / surface size, mounts the
|
||||
* pan/zoom `<g>`, and knows the active theme. The registry layer is mounted
|
||||
* inside the same `<g>`, so anything it draws shares the same coordinate
|
||||
* system; this context plumbs through the bits it can't recompute on its
|
||||
* own without re-implementing the legacy's resize / theme logic.
|
||||
*
|
||||
* Once `floorplan-panel.tsx` is fully migrated (Phase 6), this provider
|
||||
* moves into a kind-agnostic 2D editor shell and the context loses the
|
||||
* "legacy bridge" connotation.
|
||||
*/
|
||||
export type FloorplanRenderContextValue = {
|
||||
/** SVG units per screen pixel — used to keep handle radii consistent at any zoom. */
|
||||
unitsPerPixel: number
|
||||
/** Themed palette mirroring the legacy `FloorplanPalette` accent slots. */
|
||||
palette: FloorplanPalette
|
||||
/** SVG `<pattern>` id mounted in `<defs>` by the legacy panel for selection hatch fills. */
|
||||
hatchPatternId: string
|
||||
}
|
||||
|
||||
const FloorplanRenderContext = createContext<FloorplanRenderContextValue | null>(null)
|
||||
|
||||
export function FloorplanRenderProvider({
|
||||
children,
|
||||
unitsPerPixel,
|
||||
palette,
|
||||
hatchPatternId,
|
||||
}: FloorplanRenderContextValue & { children: ReactNode }) {
|
||||
const value = useMemo<FloorplanRenderContextValue>(
|
||||
() => ({ unitsPerPixel, palette, hatchPatternId }),
|
||||
[unitsPerPixel, palette, hatchPatternId],
|
||||
)
|
||||
return <FloorplanRenderContext.Provider value={value}>{children}</FloorplanRenderContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the active render context. Returns `null` when called outside a
|
||||
* provider — the registry layer treats this as "render statically, skip
|
||||
* theme-aware chrome and interactive handles". This makes the layer
|
||||
* usable in isolation tests + future editor shells without bringing the
|
||||
* whole legacy panel along.
|
||||
*/
|
||||
export function useFloorplanRender(): FloorplanRenderContextValue | null {
|
||||
return useContext(FloorplanRenderContext)
|
||||
}
|
||||
@@ -273,10 +273,12 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
||||
fill={curvedAccent}
|
||||
key={`${stair.id}:spiral-arrow`}
|
||||
pointerEvents="none"
|
||||
points={buildSvgArrowHeadPoints(
|
||||
arrowPoint,
|
||||
tangentAngle,
|
||||
clamp(stair.width * 0.18, 0.12, 0.18),
|
||||
points={formatSvgPolygonPoints(
|
||||
buildSvgArrowHeadPoints(
|
||||
arrowPoint,
|
||||
tangentAngle,
|
||||
clamp(stair.width * 0.18, 0.12, 0.18),
|
||||
),
|
||||
)}
|
||||
/>
|
||||
)
|
||||
@@ -361,10 +363,12 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
|
||||
fill={curvedAccent}
|
||||
key={`${stair.id}:curved-arrow`}
|
||||
pointerEvents="none"
|
||||
points={buildSvgArrowHeadPoints(
|
||||
arrowPoint,
|
||||
tangentAngle,
|
||||
clamp(stair.width * 0.16, 0.1, 0.16),
|
||||
points={formatSvgPolygonPoints(
|
||||
buildSvgArrowHeadPoints(
|
||||
arrowPoint,
|
||||
tangentAngle,
|
||||
clamp(stair.width * 0.16, 0.1, 0.16),
|
||||
),
|
||||
)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -103,7 +103,15 @@ export function formatSvgPolygonPoints(points: Point2D[]) {
|
||||
return points.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`).join(' ')
|
||||
}
|
||||
|
||||
export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) {
|
||||
/**
|
||||
* Three points defining an arrow head — tip + two trailing barbs.
|
||||
* Returned as plain `Point2D` objects so consumers can either feed them
|
||||
* straight into `formatSvgPolygonPoints` (for SVG `points=""`) or push
|
||||
* them onto a `FloorplanGeometry.polygon.points` array. Mixing both
|
||||
* downstream paths through a string-returning helper was awkward — see
|
||||
* `nodes/src/stair/floorplan.ts` which needs the points as objects.
|
||||
*/
|
||||
export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number): Point2D[] {
|
||||
const left = {
|
||||
x: point.x - size * Math.cos(angle - Math.PI / 6),
|
||||
y: point.y - size * Math.sin(angle - Math.PI / 6),
|
||||
@@ -113,7 +121,7 @@ export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: num
|
||||
y: point.y - size * Math.sin(angle + Math.PI / 6),
|
||||
}
|
||||
|
||||
return formatSvgPolygonPoints([point, left, right])
|
||||
return [point, left, right]
|
||||
}
|
||||
|
||||
export { toSvgPoint, toSvgX, toSvgY }
|
||||
|
||||
@@ -352,7 +352,7 @@ function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
|
||||
const nodes = useScene.getState().nodes
|
||||
const meshes: ElevatorColliderMesh[] = []
|
||||
|
||||
for (const elevatorId of sceneRegistry.byType.elevator) {
|
||||
for (const elevatorId of sceneRegistry.byType.elevator!) {
|
||||
const typedElevatorId = elevatorId as AnyNodeId
|
||||
const node = nodes[typedElevatorId]
|
||||
if (node?.type !== 'elevator' || node.visible === false) continue
|
||||
@@ -585,7 +585,7 @@ export const FirstPersonControls = () => {
|
||||
let closestDoorId: AnyNodeId | null = null
|
||||
let closestDistance = DOOR_INTERACTION_DISTANCE
|
||||
|
||||
for (const doorId of sceneRegistry.byType.door) {
|
||||
for (const doorId of sceneRegistry.byType.door!) {
|
||||
const node = nodes[doorId as AnyNodeId]
|
||||
if (node?.type !== 'door') continue
|
||||
if (node.openingKind === 'opening') continue
|
||||
@@ -683,7 +683,7 @@ export const FirstPersonControls = () => {
|
||||
let closestWindowId: AnyNodeId | null = null
|
||||
let closestDistance = DOOR_INTERACTION_DISTANCE
|
||||
|
||||
for (const windowId of sceneRegistry.byType.window) {
|
||||
for (const windowId of sceneRegistry.byType.window!) {
|
||||
const node = nodes[windowId as AnyNodeId]
|
||||
if (node?.type !== 'window') continue
|
||||
if (node.openingKind === 'opening') continue
|
||||
@@ -713,7 +713,7 @@ export const FirstPersonControls = () => {
|
||||
let closestTarget: FirstPersonInteractableTarget | null = null
|
||||
let closestDistance = DOOR_INTERACTION_DISTANCE
|
||||
|
||||
for (const elevatorId of sceneRegistry.byType.elevator) {
|
||||
for (const elevatorId of sceneRegistry.byType.elevator!) {
|
||||
const typedElevatorId = elevatorId as AnyNodeId
|
||||
const node = nodes[typedElevatorId]
|
||||
if (node?.type !== 'elevator') continue
|
||||
@@ -1088,11 +1088,11 @@ export const FirstPersonControls = () => {
|
||||
const elevatorIds = activeRide
|
||||
? [
|
||||
activeRide.elevatorId,
|
||||
...Array.from(sceneRegistry.byType.elevator).filter(
|
||||
...Array.from(sceneRegistry.byType.elevator!).filter(
|
||||
(elevatorId) => elevatorId !== activeRide.elevatorId,
|
||||
),
|
||||
]
|
||||
: Array.from(sceneRegistry.byType.elevator)
|
||||
: Array.from(sceneRegistry.byType.elevator!)
|
||||
|
||||
for (const elevatorId of elevatorIds) {
|
||||
const typedElevatorId = elevatorId as AnyNodeId
|
||||
|
||||
@@ -176,7 +176,7 @@ function buildRegisteredNodeTypeLookup() {
|
||||
const nodeTypes = new Map<string, ColliderNodeType>()
|
||||
|
||||
for (const type of COLLIDER_NODE_TYPES) {
|
||||
for (const nodeId of sceneRegistry.byType[type]) {
|
||||
for (const nodeId of sceneRegistry.byType[type]!) {
|
||||
nodeTypes.set(nodeId, type)
|
||||
}
|
||||
}
|
||||
@@ -238,7 +238,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
|
||||
}
|
||||
|
||||
for (const type of COLLIDER_NODE_TYPES) {
|
||||
for (const nodeId of sceneRegistry.byType[type]) {
|
||||
for (const nodeId of sceneRegistry.byType[type]!) {
|
||||
if (shouldSkipColliderNode(nodeId, type)) continue
|
||||
|
||||
const root = sceneRegistry.nodes.get(nodeId)
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type GuideNode,
|
||||
getRenderableSlabPolygon,
|
||||
getWallChordFrame,
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
getWallMidpointHandlePoint,
|
||||
getWallPlanFootprint,
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
type Point2D,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
resolveElevatorServiceLevelIds,
|
||||
type SiteNode,
|
||||
SlabNode,
|
||||
type SpawnNode,
|
||||
@@ -87,6 +85,10 @@ import {
|
||||
} from '../editor-2d/floorplan-hotkey-handlers'
|
||||
import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
|
||||
import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
|
||||
import {
|
||||
type FloorplanRenderContextValue,
|
||||
FloorplanRenderProvider,
|
||||
} from '../editor-2d/floorplan-render-context'
|
||||
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
|
||||
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
|
||||
import {
|
||||
@@ -8440,364 +8442,29 @@ export function FloorplanPanel() {
|
||||
|
||||
return hasPreviewWalls ? nextFloorplanWallById : floorplanWallById
|
||||
}, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft])
|
||||
const floorplanFenceEntries = useMemo(() => {
|
||||
// Fence migrated to def.floorplan (Phase 5 Stage C). When registered,
|
||||
// FloorplanRegistryLayer renders the fence polyline; this legacy
|
||||
// path short-circuits to avoid double-render. Removed entirely in
|
||||
// Phase 6 cleanup.
|
||||
if (nodeRegistry.has('fence')) return []
|
||||
return fences.flatMap((fence) => {
|
||||
const live = useLiveTransforms.getState().get(fence.id)
|
||||
const fenceCenterX = (fence.start[0] + fence.end[0]) / 2
|
||||
const fenceCenterZ = (fence.start[1] + fence.end[1]) / 2
|
||||
const displayFence = live
|
||||
? {
|
||||
...fence,
|
||||
start: [
|
||||
fence.start[0] + (live.position[0] - fenceCenterX),
|
||||
fence.start[1] + (live.position[2] - fenceCenterZ),
|
||||
] as typeof fence.start,
|
||||
end: [
|
||||
fence.end[0] + (live.position[0] - fenceCenterX),
|
||||
fence.end[1] + (live.position[2] - fenceCenterZ),
|
||||
] as typeof fence.end,
|
||||
}
|
||||
: fence
|
||||
const centerline = isCurvedWall(displayFence)
|
||||
? sampleWallCenterline(displayFence, 24)
|
||||
: [
|
||||
{ x: displayFence.start[0], y: displayFence.start[1] },
|
||||
{ x: displayFence.end[0], y: displayFence.end[1] },
|
||||
]
|
||||
const path = buildSvgPolylinePath(centerline)
|
||||
if (!path) {
|
||||
return []
|
||||
}
|
||||
// Fence is fully registry-driven (`def.floorplan` + `buildFenceFloorplan`).
|
||||
// The legacy entry list is permanently empty; kept as a typed stable
|
||||
// reference so downstream prop sites stay typed without each having to
|
||||
// declare its own `[]`.
|
||||
const floorplanFenceEntries = useMemo<FloorplanFenceEntry[]>(() => [], [])
|
||||
// Wall is fully registry-driven. Empty stable arrays for the legacy
|
||||
// entry lists; consumers' map / iteration paths become no-ops.
|
||||
const wallPolygons = useMemo<WallPolygonEntry[]>(() => [], [])
|
||||
const displayWallPolygons = useMemo<WallPolygonEntry[]>(() => [], [])
|
||||
|
||||
const markerFrames = getFloorplanFenceMarkerTs(displayFence).map((t) => {
|
||||
const frame = getWallCurveFrameAt(displayFence, t)
|
||||
|
||||
return {
|
||||
angleDeg: (Math.atan2(frame.tangent.y, frame.tangent.x) * 180) / Math.PI,
|
||||
point: frame.point,
|
||||
}
|
||||
})
|
||||
|
||||
return [{ fence: displayFence, centerline, markerFrames, path }]
|
||||
})
|
||||
}, [fences, movingFloorplanNodeRevision])
|
||||
const wallPolygons = useMemo(() => {
|
||||
// Wall migrated to def.floorplan (Phase 5 Stage C). When registered,
|
||||
// FloorplanRegistryLayer renders the mitered wall polygon; this
|
||||
// legacy path short-circuits. Removed entirely in Phase 6 cleanup.
|
||||
if (nodeRegistry.has('wall')) return []
|
||||
return walls.map((wall) => {
|
||||
const floorplanWall = floorplanWallById.get(wall.id) ?? getFloorplanWall(wall)
|
||||
const polygon = getWallPlanFootprint(floorplanWall, wallMiterData)
|
||||
return {
|
||||
points: formatPolygonPoints(polygon),
|
||||
wall,
|
||||
polygon,
|
||||
}
|
||||
})
|
||||
}, [floorplanWallById, wallMiterData, walls])
|
||||
const displayWallPolygons = useMemo(() => {
|
||||
if (!(wallEndpointDraft || wallCurveDraft)) {
|
||||
return wallPolygons
|
||||
}
|
||||
|
||||
const previewWalls = new Map<WallNode['id'], WallNode>()
|
||||
|
||||
if (wallEndpointDraft) {
|
||||
for (const draftUpdate of getWallEndpointDraftUpdates(wallEndpointDraft)) {
|
||||
const previewWall = displayWallById.get(draftUpdate.id)
|
||||
if (previewWall) {
|
||||
previewWalls.set(previewWall.id, previewWall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wallCurveDraft) {
|
||||
const previewWall = displayWallById.get(wallCurveDraft.wallId)
|
||||
if (previewWall) {
|
||||
previewWalls.set(previewWall.id, previewWall)
|
||||
}
|
||||
}
|
||||
|
||||
if (previewWalls.size === 0) {
|
||||
return wallPolygons
|
||||
}
|
||||
|
||||
return wallPolygons.map((entry) =>
|
||||
(() => {
|
||||
const previewWall = previewWalls.get(entry.wall.id)
|
||||
if (!previewWall) {
|
||||
return entry
|
||||
}
|
||||
|
||||
const previewPolygon = getWallPlanFootprint(
|
||||
getFloorplanWall(previewWall),
|
||||
EMPTY_WALL_MITER_DATA,
|
||||
)
|
||||
|
||||
return {
|
||||
wall: previewWall,
|
||||
polygon: previewPolygon,
|
||||
points: formatPolygonPoints(previewPolygon),
|
||||
}
|
||||
})(),
|
||||
)
|
||||
}, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons])
|
||||
|
||||
const openingsPolygons = useMemo(() => {
|
||||
// Doors + windows migrated to def.floorplan (Phase 5 Stage C). When
|
||||
// both registered, FloorplanRegistryLayer renders each opening's
|
||||
// polygon via its kind's builder; this legacy path short-circuits
|
||||
// to avoid double-render. Filter per kind so a partial migration
|
||||
// would still work.
|
||||
const doorRegistered = nodeRegistry.has('door')
|
||||
const windowRegistered = nodeRegistry.has('window')
|
||||
if (doorRegistered && windowRegistered) return []
|
||||
return openings.flatMap((opening) => {
|
||||
if (doorRegistered && opening.type === 'door') return []
|
||||
if (windowRegistered && opening.type === 'window') return []
|
||||
const wall = displayFloorplanWallById.get(opening.parentId as WallNode['id'])
|
||||
if (!wall) return []
|
||||
const live = useLiveTransforms.getState().get(opening.id)
|
||||
const displayOpening =
|
||||
live &&
|
||||
(movingNode?.type === 'door' || movingNode?.type === 'window') &&
|
||||
movingNode.id === opening.id
|
||||
? {
|
||||
...opening,
|
||||
position: [
|
||||
live.position[0],
|
||||
opening.position[1],
|
||||
live.position[2],
|
||||
] as typeof opening.position,
|
||||
rotation: [
|
||||
opening.rotation[0],
|
||||
live.rotation,
|
||||
opening.rotation[2],
|
||||
] as typeof opening.rotation,
|
||||
}
|
||||
: opening
|
||||
const polygon = getOpeningFootprint(wall, displayOpening)
|
||||
return [
|
||||
{
|
||||
opening: displayOpening,
|
||||
points: formatPolygonPoints(polygon),
|
||||
polygon,
|
||||
},
|
||||
]
|
||||
})
|
||||
}, [displayFloorplanWallById, movingFloorplanNodeRevision, movingNode, openings])
|
||||
const slabPolygons = useMemo(() => {
|
||||
// Slab migrated to def.floorplan (Phase 5 Stage C). When registered,
|
||||
// FloorplanRegistryLayer renders the slab polygon; this legacy
|
||||
// path short-circuits to avoid double-render. Removed entirely in
|
||||
// Phase 6 cleanup.
|
||||
if (nodeRegistry.has('slab')) return []
|
||||
return slabs.flatMap((slab) => {
|
||||
const polygon = toFloorplanPolygon(slab.polygon)
|
||||
if (polygon.length < 3) {
|
||||
return []
|
||||
}
|
||||
|
||||
const holes = (slab.holes ?? [])
|
||||
.map((hole) => toFloorplanPolygon(hole))
|
||||
.filter((hole) => hole.length >= 3)
|
||||
const visualPolygon = toFloorplanPolygon(getRenderableSlabPolygon(slab))
|
||||
const visualHoles = holes
|
||||
|
||||
return [
|
||||
{
|
||||
slab,
|
||||
polygon,
|
||||
holes,
|
||||
visualPolygon,
|
||||
visualHoles,
|
||||
path: formatPolygonPath(visualPolygon, visualHoles),
|
||||
},
|
||||
]
|
||||
})
|
||||
}, [slabs])
|
||||
const displaySlabPolygons = useMemo(() => {
|
||||
if (!(slabBoundaryDraft || slabHoleBoundaryDraft || slabHoleMoveDraft)) {
|
||||
return slabPolygons
|
||||
}
|
||||
|
||||
return slabPolygons.map((entry) => {
|
||||
let nextEntry = entry
|
||||
|
||||
if (slabBoundaryDraft && entry.slab.id === slabBoundaryDraft.slabId) {
|
||||
nextEntry = (() => {
|
||||
const draftVisualPolygon =
|
||||
slabBoundaryDraft.visualOffsets?.length === slabBoundaryDraft.polygon.length
|
||||
? getDraftSlabVisualPolygon(slabBoundaryDraft)
|
||||
: toFloorplanPolygon(
|
||||
getRenderableSlabPolygon({
|
||||
...entry.slab,
|
||||
polygon: slabBoundaryDraft.polygon,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
...entry,
|
||||
polygon: slabBoundaryDraft.polygon.map(toPoint2D),
|
||||
visualPolygon: draftVisualPolygon,
|
||||
path: formatPolygonPath(draftVisualPolygon, entry.visualHoles),
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const activeHoleDraft =
|
||||
slabHoleBoundaryDraft && entry.slab.id === slabHoleBoundaryDraft.slabId
|
||||
? slabHoleBoundaryDraft
|
||||
: slabHoleMoveDraft && entry.slab.id === slabHoleMoveDraft.slabId
|
||||
? slabHoleMoveDraft
|
||||
: null
|
||||
|
||||
if (activeHoleDraft) {
|
||||
const draftHole = activeHoleDraft.polygon.map(toPoint2D)
|
||||
const draftHoles = nextEntry.holes.map((hole, index) =>
|
||||
index === activeHoleDraft.holeIndex ? draftHole : hole,
|
||||
)
|
||||
const draftVisualHoles = nextEntry.visualHoles.map((hole, index) =>
|
||||
index === activeHoleDraft.holeIndex ? draftHole : hole,
|
||||
)
|
||||
|
||||
nextEntry = {
|
||||
...nextEntry,
|
||||
holes: draftHoles,
|
||||
visualHoles: draftVisualHoles,
|
||||
path: formatPolygonPath(nextEntry.visualPolygon, draftVisualHoles),
|
||||
}
|
||||
}
|
||||
|
||||
return nextEntry
|
||||
})
|
||||
}, [slabBoundaryDraft, slabHoleBoundaryDraft, slabHoleMoveDraft, slabPolygons])
|
||||
const ceilingPolygons = useMemo(() => {
|
||||
// Ceiling migrated to def.floorplan (Phase 5 Stage C). When registered,
|
||||
// FloorplanRegistryLayer renders the ceiling polygon; this legacy
|
||||
// path short-circuits. Removed entirely in Phase 6 cleanup.
|
||||
if (nodeRegistry.has('ceiling')) return []
|
||||
return ceilings.flatMap((ceiling) => {
|
||||
const polygon = toFloorplanPolygon(ceiling.polygon)
|
||||
if (polygon.length < 3) {
|
||||
return []
|
||||
}
|
||||
|
||||
const holes = (ceiling.holes ?? [])
|
||||
.map((hole) => toFloorplanPolygon(hole))
|
||||
.filter((hole) => hole.length >= 3)
|
||||
|
||||
return [
|
||||
{
|
||||
ceiling,
|
||||
polygon,
|
||||
holes,
|
||||
path: formatPolygonPath(polygon, holes),
|
||||
},
|
||||
]
|
||||
})
|
||||
}, [ceilings])
|
||||
const displayCeilingPolygons = useMemo(() => {
|
||||
if (!(ceilingBoundaryDraft || ceilingHoleBoundaryDraft || ceilingHoleMoveDraft)) {
|
||||
return ceilingPolygons
|
||||
}
|
||||
|
||||
return ceilingPolygons.map((entry) => {
|
||||
let nextEntry = entry
|
||||
|
||||
if (ceilingBoundaryDraft && entry.ceiling.id === ceilingBoundaryDraft.ceilingId) {
|
||||
const polygon = ceilingBoundaryDraft.polygon.map(toPoint2D)
|
||||
nextEntry = {
|
||||
...entry,
|
||||
polygon,
|
||||
path: formatPolygonPath(polygon, entry.holes),
|
||||
}
|
||||
}
|
||||
|
||||
const activeHoleDraft =
|
||||
ceilingHoleBoundaryDraft && entry.ceiling.id === ceilingHoleBoundaryDraft.ceilingId
|
||||
? ceilingHoleBoundaryDraft
|
||||
: ceilingHoleMoveDraft && entry.ceiling.id === ceilingHoleMoveDraft.ceilingId
|
||||
? ceilingHoleMoveDraft
|
||||
: null
|
||||
|
||||
if (activeHoleDraft) {
|
||||
const draftHole = activeHoleDraft.polygon.map(toPoint2D)
|
||||
const holes = nextEntry.holes.map((hole, index) =>
|
||||
index === activeHoleDraft.holeIndex ? draftHole : hole,
|
||||
)
|
||||
|
||||
nextEntry = {
|
||||
...nextEntry,
|
||||
holes,
|
||||
path: formatPolygonPath(nextEntry.polygon, holes),
|
||||
}
|
||||
}
|
||||
|
||||
return nextEntry
|
||||
})
|
||||
}, [ceilingBoundaryDraft, ceilingHoleBoundaryDraft, ceilingHoleMoveDraft, ceilingPolygons])
|
||||
const zonePolygons = useMemo(
|
||||
() =>
|
||||
zones.flatMap((zone) => {
|
||||
const polygon = toFloorplanPolygon(zone.polygon)
|
||||
if (polygon.length < 3) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
zone,
|
||||
polygon,
|
||||
points: formatPolygonPoints(polygon),
|
||||
},
|
||||
]
|
||||
}),
|
||||
[zones],
|
||||
)
|
||||
const displayZonePolygons = useMemo(() => {
|
||||
if (!zoneBoundaryDraft) {
|
||||
return zonePolygons
|
||||
}
|
||||
|
||||
return zonePolygons.map((entry) =>
|
||||
entry.zone.id === zoneBoundaryDraft.zoneId
|
||||
? {
|
||||
...entry,
|
||||
polygon: zoneBoundaryDraft.polygon.map(toPoint2D),
|
||||
points: formatPolygonPoints(zoneBoundaryDraft.polygon.map(toPoint2D)),
|
||||
}
|
||||
: entry,
|
||||
)
|
||||
}, [zoneBoundaryDraft, zonePolygons])
|
||||
const floorplanColumnEntries = useMemo<FloorplanColumnEntry[]>(
|
||||
() =>
|
||||
levelDescendantNodes.flatMap((node) => {
|
||||
if (!(node.type === 'column' && node.visible !== false)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const polygon = getColumnPlanFootprint(node)
|
||||
if (polygon.length < 3) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
column: node,
|
||||
points: formatPolygonPoints(polygon),
|
||||
polygon,
|
||||
},
|
||||
]
|
||||
}),
|
||||
[levelDescendantNodes],
|
||||
)
|
||||
// Doors + windows fully registry-driven via `def.floorplan`.
|
||||
const openingsPolygons = useMemo<OpeningPolygonEntry[]>(() => [], [])
|
||||
// Slab + ceiling fully registry-driven via `def.floorplan`. Same
|
||||
// empty-stable-array pattern.
|
||||
const slabPolygons = useMemo<SlabPolygonEntry[]>(() => [], [])
|
||||
const displaySlabPolygons = useMemo<SlabPolygonEntry[]>(() => [], [])
|
||||
const ceilingPolygons = useMemo<CeilingPolygonEntry[]>(() => [], [])
|
||||
const displayCeilingPolygons = useMemo<CeilingPolygonEntry[]>(() => [], [])
|
||||
// Zone fully registry-driven via `def.floorplan`.
|
||||
const zonePolygons = useMemo<ZonePolygonEntry[]>(() => [], [])
|
||||
const displayZonePolygons = useMemo<ZonePolygonEntry[]>(() => [], [])
|
||||
// Column fully registry-driven via `def.floorplan`.
|
||||
const floorplanColumnEntries = useMemo<FloorplanColumnEntry[]>(() => [], [])
|
||||
const levelDescendantNodeById = useMemo(
|
||||
() => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)),
|
||||
[levelDescendantNodes],
|
||||
@@ -8820,184 +8487,11 @@ export function FloorplanPanel() {
|
||||
),
|
||||
[levelDescendantNodes],
|
||||
)
|
||||
const floorplanSpawnEntries = useMemo<FloorplanSpawnEntry[]>(() => {
|
||||
// Spawn migrated to the registry-driven floor-plan layer (Phase 5
|
||||
// Stage C). When registered, FloorplanRegistryLayer renders the
|
||||
// spawn marker via def.floorplan; FloorplanRegistryActionMenu
|
||||
// handles select / move / delete. Returning [] here skips the
|
||||
// legacy rendering + action menu paths to avoid double-render.
|
||||
// Removed entirely in Phase 6 cleanup.
|
||||
if (nodeRegistry.has('spawn')) return []
|
||||
return spawns
|
||||
.filter((spawn) => spawn.visible !== false)
|
||||
.map((spawn) => {
|
||||
const live = useLiveTransforms.getState().get(spawn.id)
|
||||
return {
|
||||
spawn,
|
||||
position: {
|
||||
x: live?.position[0] ?? spawn.position[0],
|
||||
y: live?.position[2] ?? spawn.position[2],
|
||||
},
|
||||
rotation: live?.rotation ?? spawn.rotation,
|
||||
}
|
||||
})
|
||||
}, [movingFloorplanNodeRevision, spawns])
|
||||
const floorplanItemEntries = useMemo(() => {
|
||||
// Item migrated to def.floorplan (Phase 5 Stage C). When registered,
|
||||
// FloorplanRegistryLayer renders the item rectangle via the
|
||||
// parent-chain transform walker; this legacy path short-circuits.
|
||||
// Removed entirely in Phase 6 cleanup.
|
||||
if (nodeRegistry.has('item')) return []
|
||||
const transformCache = new Map<string, SharedFloorplanNodeTransform | null>()
|
||||
|
||||
return floorplanItems.flatMap((item) => {
|
||||
const entry = buildFloorplanItemEntry(item, levelDescendantNodeById, transformCache)
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
dimensionPolygon: entry.dimensionPolygon,
|
||||
item: entry.item,
|
||||
points: formatPolygonPoints(entry.polygon),
|
||||
polygon: entry.polygon,
|
||||
usesRealMesh: entry.usesRealMesh,
|
||||
center: entry.center,
|
||||
rotation: entry.rotation,
|
||||
width: entry.width,
|
||||
depth: entry.depth,
|
||||
},
|
||||
]
|
||||
})
|
||||
}, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision])
|
||||
const floorplanElevatorEntries = useMemo<FloorplanElevatorEntry[]>(() => {
|
||||
// These keys subscribe the memo to imperative floorplan stores read with getState().
|
||||
void elevatorLiveOverrideKey
|
||||
void elevatorRuntimeKey
|
||||
void movingFloorplanNodeRevision
|
||||
|
||||
if (!levelNode) {
|
||||
return []
|
||||
}
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
const interactiveElevators = useInteractive.getState().elevators
|
||||
|
||||
return elevators.flatMap((elevator) => {
|
||||
const liveOverrides = useLiveNodeOverrides.getState().get(elevator.id)
|
||||
const displayElevator = liveOverrides
|
||||
? ({ ...elevator, ...liveOverrides } as ElevatorNode)
|
||||
: elevator
|
||||
const serviceLevelIds = resolveElevatorServiceLevelIds(displayElevator, nodes)
|
||||
if (!serviceLevelIds.includes(levelNode.id)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const live = useLiveTransforms.getState().get(displayElevator.id)
|
||||
const position = live?.position ?? displayElevator.position
|
||||
const rotation = live?.rotation ?? displayElevator.rotation
|
||||
const center = { x: position[0], y: position[2] }
|
||||
const wallThickness = Math.max(displayElevator.shaftWallThickness ?? 0.09, 0.04)
|
||||
const cabWidth = Math.max(displayElevator.width, 0.8)
|
||||
const cabDepth = Math.max(displayElevator.depth, 0.8)
|
||||
const shaftWidth = Math.max(
|
||||
displayElevator.shaftWidth ?? displayElevator.width,
|
||||
cabWidth,
|
||||
0.8,
|
||||
)
|
||||
const shaftDepth = Math.max(
|
||||
displayElevator.shaftDepth ?? displayElevator.depth,
|
||||
cabDepth,
|
||||
0.8,
|
||||
)
|
||||
const doorWidth = Math.min(
|
||||
Math.max(displayElevator.doorWidth, 0.45),
|
||||
cabWidth - 0.18,
|
||||
shaftWidth - 0.18,
|
||||
)
|
||||
const halfWidth = Math.max(0.1, shaftWidth / 2 + wallThickness)
|
||||
const halfDepth = Math.max(0.1, shaftDepth / 2 + wallThickness)
|
||||
const footprintCorners: Array<readonly [number, number]> = [
|
||||
[-halfWidth, -halfDepth],
|
||||
[halfWidth, -halfDepth],
|
||||
[halfWidth, halfDepth],
|
||||
[-halfWidth, halfDepth],
|
||||
]
|
||||
const polygon = footprintCorners.map(([localX, localY]) => {
|
||||
const [offsetX, offsetY] = rotatePlanVector(localX, localY, rotation)
|
||||
return {
|
||||
x: center.x + offsetX,
|
||||
y: center.y + offsetY,
|
||||
}
|
||||
})
|
||||
const frontStart = polygon[0]
|
||||
const frontEnd = polygon[1]
|
||||
if (!(frontStart && frontEnd)) {
|
||||
return []
|
||||
}
|
||||
const [frontNormalX, frontNormalY] = rotatePlanVector(0, -1, rotation)
|
||||
const runtime = interactiveElevators[displayElevator.id]
|
||||
const disabledLevelIds = new Set(displayElevator.disabledLevelIds ?? [])
|
||||
const serviceOnlyLevelIds = new Set(displayElevator.serviceOnlyLevelIds ?? [])
|
||||
const servedLevels = serviceLevelIds.flatMap((levelId) => {
|
||||
const level = nodes[levelId as AnyNodeId]
|
||||
if (level?.type !== 'level') {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: level.id,
|
||||
isCurrent: runtime?.currentLevelId === level.id,
|
||||
isDisabled: disabledLevelIds.has(level.id),
|
||||
isQueued: runtime?.queue.includes(level.id) ?? false,
|
||||
isServiceOnly: serviceOnlyLevelIds.has(level.id),
|
||||
isTarget: runtime?.targetLevelId === level.id,
|
||||
label: level.name || `L${level.level}`,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
cabCenterLocalY: -shaftDepth / 2 + cabDepth / 2,
|
||||
cabDepth,
|
||||
cabWidth,
|
||||
center,
|
||||
doorStyle: displayElevator.doorStyle ?? 'center-opening',
|
||||
doorWidth,
|
||||
elevator: displayElevator,
|
||||
frontEdge: {
|
||||
start: frontStart,
|
||||
end: frontEnd,
|
||||
},
|
||||
frontNormal: {
|
||||
x: frontNormalX,
|
||||
y: frontNormalY,
|
||||
},
|
||||
isCarOnLevel: runtime?.currentLevelId === levelNode.id,
|
||||
isQueuedLevel: runtime?.queue.includes(levelNode.id) ?? false,
|
||||
isTargetLevel: runtime?.targetLevelId === levelNode.id,
|
||||
outerHalfDepth: halfDepth,
|
||||
outerHalfWidth: halfWidth,
|
||||
points: formatPolygonPoints(polygon),
|
||||
polygon,
|
||||
rotation,
|
||||
servedLevels,
|
||||
shaftDepth,
|
||||
shaftWallThickness: wallThickness,
|
||||
shaftWidth,
|
||||
},
|
||||
]
|
||||
})
|
||||
}, [
|
||||
elevatorLiveOverrideKey,
|
||||
elevatorRuntimeKey,
|
||||
elevators,
|
||||
levelNode,
|
||||
movingFloorplanNodeRevision,
|
||||
])
|
||||
// Spawn + item fully registry-driven.
|
||||
const floorplanSpawnEntries = useMemo<FloorplanSpawnEntry[]>(() => [], [])
|
||||
const floorplanItemEntries = useMemo<FloorplanItemEntry[]>(() => [], [])
|
||||
// Elevator fully registry-driven via `def.floorplan`.
|
||||
const floorplanElevatorEntries = useMemo<FloorplanElevatorEntry[]>(() => [], [])
|
||||
const referenceFloorLevel = useMemo(() => {
|
||||
if (!(showReferenceFloor && levelNode)) {
|
||||
return null
|
||||
@@ -9192,171 +8686,30 @@ export function FloorplanPanel() {
|
||||
wallPolygons,
|
||||
}
|
||||
}, [referenceFloorDescendants, referenceFloorLevel])
|
||||
const hasPendingItemMeshFootprints = floorplanItemEntries.some((entry) => !entry.usesRealMesh)
|
||||
const floorplanStairEntries = useMemo(
|
||||
() =>
|
||||
floorplanStairs.flatMap((stair) => {
|
||||
const displayStair =
|
||||
movingNode?.type === 'stair' && movingNode.id === stair.id
|
||||
? (() => {
|
||||
const live = useLiveTransforms.getState().get(stair.id)
|
||||
const liveX = cursorPoint?.[0] ?? live?.position[0] ?? stair.position[0]
|
||||
const liveZ = cursorPoint?.[1] ?? live?.position[2] ?? stair.position[2]
|
||||
const liveRotation = live?.rotation ?? stair.rotation
|
||||
|
||||
return {
|
||||
...stair,
|
||||
position: [liveX, stair.position[1], liveZ] as StairNode['position'],
|
||||
rotation: liveRotation,
|
||||
}
|
||||
})()
|
||||
: stair
|
||||
const segments = (displayStair.children ?? [])
|
||||
.map((childId) => levelDescendantNodeById.get(childId as AnyNodeId))
|
||||
.filter(
|
||||
(node): node is StairSegmentNode =>
|
||||
node?.type === 'stair-segment' && node.visible !== false,
|
||||
)
|
||||
const entry = buildSharedFloorplanStairEntry(displayStair, segments)
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
const hitPolygons =
|
||||
(displayStair.stairType ?? 'straight') === 'straight'
|
||||
? entry.segments.map((segmentEntry) => segmentEntry.polygon)
|
||||
: [getFloorplanCurvedStairHitPolygon(displayStair)]
|
||||
|
||||
return [
|
||||
{
|
||||
...entry,
|
||||
hitPolygons,
|
||||
segments: entry.segments.map((segmentEntry) => ({
|
||||
...segmentEntry,
|
||||
innerPoints: formatPolygonPoints(segmentEntry.innerPolygon),
|
||||
points: formatPolygonPoints(segmentEntry.polygon),
|
||||
treadBars: segmentEntry.treadBars.map((polygon) => ({
|
||||
points: formatPolygonPoints(polygon),
|
||||
polygon,
|
||||
})),
|
||||
})),
|
||||
},
|
||||
]
|
||||
}),
|
||||
[
|
||||
cursorPoint,
|
||||
floorplanStairs,
|
||||
levelDescendantNodeById,
|
||||
movingFloorplanNodeRevision,
|
||||
movingNode,
|
||||
],
|
||||
)
|
||||
const floorplanRoofEntries = useMemo(
|
||||
() =>
|
||||
roofs.flatMap((roof) => {
|
||||
const liveRoofTransform =
|
||||
movingNode?.type === 'roof' && movingNode.id === roof.id
|
||||
? useLiveTransforms.getState().get(roof.id)
|
||||
: null
|
||||
const liveRoofPosition = liveRoofTransform
|
||||
? worldToBuildingLocalPlanPoint(
|
||||
liveRoofTransform.position,
|
||||
buildingPosition,
|
||||
buildingRotationY,
|
||||
)
|
||||
: null
|
||||
const displayRoof = liveRoofTransform
|
||||
? {
|
||||
...roof,
|
||||
position: [
|
||||
liveRoofPosition?.x ?? roof.position[0],
|
||||
roof.position[1],
|
||||
liveRoofPosition?.y ?? roof.position[2],
|
||||
] as RoofNode['position'],
|
||||
rotation: liveRoofTransform.rotation,
|
||||
}
|
||||
: roof
|
||||
const segments = (displayRoof.children ?? [])
|
||||
.map((childId) => levelDescendantNodeById.get(childId as AnyNodeId))
|
||||
.filter(
|
||||
(node): node is RoofSegmentNode =>
|
||||
node?.type === 'roof-segment' && node.visible !== false,
|
||||
)
|
||||
.flatMap((segment) => {
|
||||
const liveSegmentTransform =
|
||||
movingNode?.type === 'roof-segment' && movingNode.id === segment.id
|
||||
? useLiveTransforms.getState().get(segment.id)
|
||||
: null
|
||||
const worldPositionOverride = liveSegmentTransform
|
||||
? worldToBuildingLocalPlanPoint(
|
||||
liveSegmentTransform.position,
|
||||
buildingPosition,
|
||||
buildingRotationY,
|
||||
)
|
||||
: undefined
|
||||
const polygon = getRoofSegmentPolygon(displayRoof, segment, {
|
||||
localRotation: liveSegmentTransform?.rotation,
|
||||
worldPositionOverride,
|
||||
})
|
||||
|
||||
if (polygon.length < 3) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
segment,
|
||||
polygon,
|
||||
points: formatPolygonPoints(polygon),
|
||||
ridgeLine: getRoofSegmentRidgeLine(displayRoof, segment, {
|
||||
localRotation: liveSegmentTransform?.rotation,
|
||||
worldPositionOverride,
|
||||
}),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
if (segments.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
roof: displayRoof,
|
||||
center: { x: displayRoof.position[0], y: displayRoof.position[2] },
|
||||
segments,
|
||||
},
|
||||
]
|
||||
}),
|
||||
[
|
||||
buildingPosition,
|
||||
buildingRotationY,
|
||||
levelDescendantNodeById,
|
||||
movingFloorplanNodeRevision,
|
||||
movingNode,
|
||||
roofs,
|
||||
],
|
||||
)
|
||||
const selectedOpeningEntry = useMemo(() => {
|
||||
if (selectedIds.length !== 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return openingsPolygons.find(({ opening }) => opening.id === selectedIds[0]) ?? null
|
||||
}, [openingsPolygons, selectedIds])
|
||||
const selectedItemEntry = useMemo(() => {
|
||||
if (selectedIds.length !== 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null
|
||||
}, [floorplanItemEntries, selectedIds])
|
||||
const selectedSpawnEntry = useMemo(() => {
|
||||
if (selectedIds.length !== 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return floorplanSpawnEntries.find(({ spawn }) => spawn.id === selectedIds[0]) ?? null
|
||||
}, [floorplanSpawnEntries, selectedIds])
|
||||
// Pending-mesh check was a flag the legacy active-level item entries
|
||||
// raised when their polygon was the dimension fallback (waiting for
|
||||
// the GLB to load to produce a tighter convex hull). Items are now
|
||||
// registry-rendered, so the active-level entry list is always empty
|
||||
// and this flag is permanently false.
|
||||
const hasPendingItemMeshFootprints = false
|
||||
// Stair fully registry-driven via `def.floorplan` (the parent walks
|
||||
// its `stair-segment` children inside `buildStairFloorplan` to handle
|
||||
// the cumulative-transform chain). `FloorplanRegistryLayer` renders
|
||||
// the result; this legacy list stays empty.
|
||||
const floorplanStairEntries = useMemo<FloorplanStairEntry[]>(() => [], [])
|
||||
// Roof / roof-segment fully registry-driven via def.floorplan.
|
||||
const floorplanRoofEntries = useMemo<FloorplanRoofEntry[]>(() => [], [])
|
||||
// Selection lookups against the legacy entry lists. The active-level
|
||||
// door / window / item / spawn paths are registry-driven now, so each
|
||||
// source array is permanently empty and the lookups always return
|
||||
// `null`. Selection chrome for those kinds comes from
|
||||
// `FloorplanRegistryLayer` reading `viewState.selected`. Wrapping the
|
||||
// `null` in `useMemo` (instead of a bare literal) preserves the
|
||||
// declared type at consumer sites — bare `null` would narrow to
|
||||
// `never` after `if (!entry) return`, breaking every `entry.field` read.
|
||||
const selectedOpeningEntry = useMemo<OpeningPolygonEntry | null>(() => null, [])
|
||||
const selectedItemEntry = useMemo<FloorplanItemEntry | null>(() => null, [])
|
||||
const selectedSpawnEntry = useMemo<FloorplanSpawnEntry | null>(() => null, [])
|
||||
const selectedElevatorEntry = useMemo(() => {
|
||||
if (selectedIds.length !== 1) {
|
||||
return null
|
||||
@@ -11149,6 +10502,29 @@ export function FloorplanPanel() {
|
||||
[theme],
|
||||
)
|
||||
const wallSelectionHatchId = useMemo(() => `floorplan-wall-selection-hatch-${theme}`, [theme])
|
||||
// Subset of the legacy palette surfaced to registry-driven kinds via
|
||||
// <FloorplanRenderProvider>. Mirrors `FloorplanPalette` in `@pascal-app/
|
||||
// core` — keep slot names + meanings in sync.
|
||||
const floorplanRegistryPalette = useMemo<FloorplanRenderContextValue['palette']>(
|
||||
() => ({
|
||||
selectedStroke: palette.selectedStroke,
|
||||
selectedFill: palette.selectedFill,
|
||||
selectedHatch: palette.selectedStroke,
|
||||
wallHoverStroke: palette.wallHoverStroke,
|
||||
endpointHandleFill: palette.endpointHandleFill,
|
||||
endpointHandleStroke: palette.endpointHandleStroke,
|
||||
endpointHandleHoverStroke: palette.endpointHandleHoverStroke,
|
||||
endpointHandleActiveFill: palette.endpointHandleActiveFill,
|
||||
endpointHandleActiveStroke: palette.endpointHandleActiveStroke,
|
||||
curveHandleFill: palette.curveHandleFill,
|
||||
curveHandleStroke: palette.curveHandleStroke,
|
||||
curveHandleHoverStroke: palette.curveHandleHoverStroke,
|
||||
measurementStroke: palette.measurementStroke,
|
||||
measurementLabelBackground: theme === 'dark' ? '#0f172a' : '#ffffff',
|
||||
measurementLabelText: theme === 'dark' ? '#e2e8f0' : '#171717',
|
||||
}),
|
||||
[palette, theme],
|
||||
)
|
||||
const slabSelectionHatchId = useMemo(() => `floorplan-slab-selection-hatch-${theme}`, [theme])
|
||||
const gridSteps = useMemo(
|
||||
() => getVisibleGridSteps(viewBox.width, surfaceSize.width),
|
||||
@@ -13613,14 +12989,11 @@ export function FloorplanPanel() {
|
||||
return
|
||||
}
|
||||
|
||||
if (isFloorplanGridInteractionActive) {
|
||||
const snappedPoint = emitFloorplanGridEvent('move', planPoint, event)
|
||||
setCursorPoint((previousPoint) =>
|
||||
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Slab / zone polygon build — local draft state + grid emit, same
|
||||
// reordering rationale as `handleBackgroundPlacementClick`: must
|
||||
// run BEFORE the `isFloorplanGridInteractionActive` catch-all so
|
||||
// the local polygon-draft state actually updates as the cursor
|
||||
// moves (the catch-all would otherwise swallow the move event).
|
||||
if (isPolygonBuildActive) {
|
||||
const snappedPoint = snapPolygonDraftPoint({
|
||||
point: planPoint,
|
||||
@@ -13628,6 +13001,10 @@ export function FloorplanPanel() {
|
||||
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
|
||||
})
|
||||
|
||||
// Emit `grid:move` so the registry-driven slab tool also tracks
|
||||
// the cursor (its 3D preview needs it).
|
||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||
|
||||
setCursorPoint((previousPoint) => {
|
||||
const hasChanged = !(previousPoint && pointsEqual(previousPoint, snappedPoint))
|
||||
if (hasChanged && activePolygonDraftPoints.length > 0) {
|
||||
@@ -13638,6 +13015,19 @@ export function FloorplanPanel() {
|
||||
return
|
||||
}
|
||||
|
||||
// Wall build also needs to run before the catch-all — see the
|
||||
// wall branch in `handleBackgroundPlacementClick` for the same
|
||||
// restructuring. The wall branch lives further below in this
|
||||
// handler (`if (!isWallBuildActive) ... setDraftEnd(...)`); the
|
||||
// grid emit is inlined there.
|
||||
if (!isWallBuildActive && isFloorplanGridInteractionActive) {
|
||||
const snappedPoint = emitFloorplanGridEvent('move', planPoint, event)
|
||||
setCursorPoint((previousPoint) =>
|
||||
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (isOpeningPlacementActive) {
|
||||
const closest = findClosestWallPoint(planPoint, walls, {
|
||||
canUseWall: (wall) => !isCurvedWall(wall),
|
||||
@@ -13694,6 +13084,10 @@ export function FloorplanPanel() {
|
||||
angleSnap: Boolean(draftStart) && !shiftPressed,
|
||||
})
|
||||
|
||||
// Emit `grid:move` so the registry-driven wall tool's 3D preview
|
||||
// tracks the cursor. The local draftEnd update below is what
|
||||
// drives the 2D draft polygon — both views update in parallel.
|
||||
emitFloorplanGridEvent('move', snappedPoint, event)
|
||||
setCursorPoint(snappedPoint)
|
||||
|
||||
if (!draftStart) {
|
||||
@@ -17720,8 +17114,20 @@ export function FloorplanPanel() {
|
||||
their SVG via <FloorplanGeometryRenderer>. Sits above the
|
||||
legacy inline content so newly-registered kinds (shelf
|
||||
today) overlay on top until their inline equivalent is
|
||||
removed in their Phase 5 migration PR. */}
|
||||
<FloorplanRegistryLayer />
|
||||
removed in their Phase 5 migration PR.
|
||||
|
||||
Wrapped in <FloorplanRenderProvider> so registry-driven
|
||||
kinds receive the same themed palette / units-per-pixel
|
||||
the legacy layers compute. The hatch pattern id is the
|
||||
legacy wall hatch — kinds that opt into selection hatch
|
||||
fills reuse this <defs> pattern via fill="url(...)". */}
|
||||
<FloorplanRenderProvider
|
||||
hatchPatternId={wallSelectionHatchId}
|
||||
palette={floorplanRegistryPalette}
|
||||
unitsPerPixel={floorplanUnitsPerPixel}
|
||||
>
|
||||
<FloorplanRegistryLayer />
|
||||
</FloorplanRenderProvider>
|
||||
{/* Cursor-driven placement ghost for movingNode when the
|
||||
active kind is registry-driven. Renders via a portal
|
||||
into the floor-plan scene <g> (the data-floorplan-scene
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type RoofSegmentEvent,
|
||||
resolveLevelId,
|
||||
resolveMaterial,
|
||||
type ShelfNode,
|
||||
type SlabNode,
|
||||
type StairEvent,
|
||||
type StairNode,
|
||||
@@ -341,7 +342,7 @@ function applyStairPaintPreview(
|
||||
}
|
||||
|
||||
function applySingleSurfacePaintPreview(
|
||||
node: FenceNode | ColumnNode | SlabNode | CeilingNode,
|
||||
node: FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
|
||||
material: ActivePaintMaterial,
|
||||
): PaintPreviewCleanup | null {
|
||||
if (node.type === 'ceiling') {
|
||||
@@ -409,6 +410,23 @@ function applySingleSurfacePaintPreview(
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'shelf') {
|
||||
// Shelf is a registered Group, not a Mesh. Traverse children and
|
||||
// preview-swap every child mesh — same approach `column` uses.
|
||||
if (!registeredObject) return null
|
||||
const restores: PaintPreviewCleanup[] = []
|
||||
registeredObject.traverse((object) => {
|
||||
if (!(object as Mesh).isMesh) return
|
||||
restores.push(previewMeshMaterial(object as Mesh, previewMaterial))
|
||||
})
|
||||
if (restores.length === 0) return null
|
||||
return () => {
|
||||
for (let index = restores.length - 1; index >= 0; index -= 1) {
|
||||
restores[index]?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mesh) return null
|
||||
|
||||
if (node.type === 'slab') {
|
||||
@@ -934,7 +952,8 @@ export const SelectionManager = () => {
|
||||
node.type === 'fence' ||
|
||||
node.type === 'column' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling'
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'shelf'
|
||||
) {
|
||||
const compatible = hasActivePaintMaterial(activePaintMaterial)
|
||||
|
||||
@@ -949,7 +968,7 @@ export const SelectionManager = () => {
|
||||
.updateNode(
|
||||
node.id as AnyNodeId,
|
||||
buildSingleSurfaceMaterialPatch<
|
||||
FenceNode | ColumnNode | SlabNode | CeilingNode
|
||||
FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode
|
||||
>(activePaintMaterial.material, activePaintMaterial.materialPreset),
|
||||
)
|
||||
}
|
||||
@@ -957,7 +976,7 @@ export const SelectionManager = () => {
|
||||
preview: compatible
|
||||
? () =>
|
||||
applySingleSurfacePaintPreview(
|
||||
node as FenceNode | ColumnNode | SlabNode | CeilingNode,
|
||||
node as FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
|
||||
activePaintMaterial,
|
||||
)
|
||||
: () => previewCursor('not-allowed'),
|
||||
@@ -1193,7 +1212,10 @@ export const SelectionManager = () => {
|
||||
}
|
||||
|
||||
if (
|
||||
(node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') &&
|
||||
(node.type === 'fence' ||
|
||||
node.type === 'slab' ||
|
||||
node.type === 'ceiling' ||
|
||||
node.type === 'shelf') &&
|
||||
nodeToSelect.type === node.type
|
||||
) {
|
||||
setSelectedMaterialTargetForNode(nodeToSelect, 'surface')
|
||||
|
||||
@@ -207,7 +207,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
const restoreNodeVisibility = (() => {
|
||||
const saved = new Map<THREE.Object3D, boolean>()
|
||||
for (const type of ['scan', 'guide'] as const) {
|
||||
const ids = sceneRegistry.byType[type]
|
||||
const ids = sceneRegistry.byType[type]!
|
||||
ids.forEach((id) => {
|
||||
const node = sceneRegistry.nodes.get(id)
|
||||
if (node) {
|
||||
|
||||
@@ -179,12 +179,11 @@ export function useFloorplanBackgroundPlacement({
|
||||
return true
|
||||
}
|
||||
|
||||
if (isFloorplanGridInteractionActive) {
|
||||
const snappedPoint = emitFloorplanGridEvent('click', planPoint, event)
|
||||
setCursorPoint(snappedPoint)
|
||||
return true
|
||||
}
|
||||
|
||||
// Slab / zone polygon build — local draft state + grid emit.
|
||||
// Must run BEFORE the `isFloorplanGridInteractionActive` catch-all
|
||||
// (since slab is registry-driven, the catch-all would otherwise
|
||||
// swallow the click and skip local draft state updates — leaving
|
||||
// the 2D draft polygon invisible while the 3D tool builds fine).
|
||||
if (isPolygonBuildActive) {
|
||||
const snappedPoint = snapPolygonDraftPoint({
|
||||
point: planPoint,
|
||||
@@ -192,6 +191,13 @@ export function useFloorplanBackgroundPlacement({
|
||||
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
|
||||
})
|
||||
|
||||
// Emit the grid event so the registry-driven slab tool also
|
||||
// sees the click (parity with ceiling / fence / roof branches
|
||||
// above). Zone has no registry tool — emit-or-not is irrelevant.
|
||||
if (!isZoneBuildActive) {
|
||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||
}
|
||||
|
||||
if (isZoneBuildActive) {
|
||||
handleZonePlacementPoint(snappedPoint)
|
||||
} else {
|
||||
@@ -200,19 +206,34 @@ export function useFloorplanBackgroundPlacement({
|
||||
return true
|
||||
}
|
||||
|
||||
if (!isWallBuildActive) {
|
||||
return false
|
||||
// Wall placement — local draft state + grid emit. Same reasoning
|
||||
// as slab above: wall is registry-driven, so without this branch
|
||||
// the catch-all would swallow the click and the local draftStart
|
||||
// / draftEnd state in the floor plan would never update, leaving
|
||||
// the dashed-line draft preview invisible.
|
||||
if (isWallBuildActive) {
|
||||
const snappedPoint = snapWallDraftPoint({
|
||||
point: planPoint,
|
||||
walls,
|
||||
start: draftStart ?? undefined,
|
||||
angleSnap: Boolean(draftStart) && !shiftPressed,
|
||||
})
|
||||
|
||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||
handleWallPlacementPoint(snappedPoint)
|
||||
return true
|
||||
}
|
||||
|
||||
const snappedPoint = snapWallDraftPoint({
|
||||
point: planPoint,
|
||||
walls,
|
||||
start: draftStart ?? undefined,
|
||||
angleSnap: Boolean(draftStart) && !shiftPressed,
|
||||
})
|
||||
// Generic catch-all — registry-driven tool whose kind has no
|
||||
// local floor-plan draft handler (column / spawn / shelf / etc.).
|
||||
// The tool's `grid:click` subscriber owns the placement.
|
||||
if (isFloorplanGridInteractionActive) {
|
||||
const snappedPoint = emitFloorplanGridEvent('click', planPoint, event)
|
||||
setCursorPoint(snappedPoint)
|
||||
return true
|
||||
}
|
||||
|
||||
handleWallPlacementPoint(snappedPoint)
|
||||
return true
|
||||
return false
|
||||
},
|
||||
[
|
||||
activePolygonDraftPoints,
|
||||
|
||||
@@ -46,7 +46,7 @@ export const CeilingSystem = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const ceilings = sceneRegistry.byType.ceiling
|
||||
const ceilings = sceneRegistry.byType.ceiling!
|
||||
ceilings.forEach((ceiling) => {
|
||||
const mesh = sceneRegistry.nodes.get(ceiling)
|
||||
if (mesh) {
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
import { PolygonEditor } from '../shared/polygon-editor'
|
||||
|
||||
interface CeilingBoundaryEditorProps {
|
||||
ceilingId: CeilingNode['id']
|
||||
}
|
||||
|
||||
/**
|
||||
* Ceiling boundary editor - allows editing ceiling polygon vertices for a specific ceiling
|
||||
* Uses the generic PolygonEditor component
|
||||
*/
|
||||
export const CeilingBoundaryEditor: React.FC<CeilingBoundaryEditorProps> = ({ ceilingId }) => {
|
||||
const ceilingNode = useScene((state) => state.nodes[ceilingId])
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
|
||||
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
|
||||
|
||||
const handlePolygonChange = useCallback(
|
||||
(newPolygon: Array<[number, number]>) => {
|
||||
updateNode(ceilingId, { polygon: newPolygon })
|
||||
// Re-assert selection so the ceiling stays selected after the edit
|
||||
setSelection({ selectedIds: [ceilingId] })
|
||||
},
|
||||
[ceilingId, updateNode, setSelection],
|
||||
)
|
||||
|
||||
if (!ceiling?.polygon || ceiling.polygon.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
allowEdgeMove
|
||||
color="#d4d4d4"
|
||||
levelId={resolveLevelId(ceiling, useScene.getState().nodes)}
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={ceiling.polygon}
|
||||
surfaceHeight={ceiling.height ?? 2.5}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { type CeilingNode, resolveLevelId, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
import { PolygonEditor } from '../shared/polygon-editor'
|
||||
|
||||
interface CeilingHoleEditorProps {
|
||||
ceilingId: CeilingNode['id']
|
||||
holeIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Ceiling hole editor - allows editing a specific hole polygon within a ceiling
|
||||
* Uses the generic PolygonEditor component
|
||||
*/
|
||||
export const CeilingHoleEditor: React.FC<CeilingHoleEditorProps> = ({ ceilingId, holeIndex }) => {
|
||||
const ceilingNode = useScene((state) => state.nodes[ceilingId])
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
|
||||
const ceiling = ceilingNode?.type === 'ceiling' ? (ceilingNode as CeilingNode) : null
|
||||
const holes = ceiling?.holes || []
|
||||
const hole = holes[holeIndex]
|
||||
|
||||
const handlePolygonChange = useCallback(
|
||||
(newPolygon: Array<[number, number]>) => {
|
||||
const updatedHoles = [...holes]
|
||||
updatedHoles[holeIndex] = newPolygon
|
||||
updateNode(ceilingId, { holes: updatedHoles })
|
||||
// Re-assert selection so the ceiling stays selected after the edit
|
||||
setSelection({ selectedIds: [ceilingId] })
|
||||
},
|
||||
[ceilingId, holeIndex, holes, updateNode, setSelection],
|
||||
)
|
||||
|
||||
if (!(ceiling && hole) || hole.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
allowEdgeMove
|
||||
allowPolygonMove
|
||||
color="#ef4444"
|
||||
levelId={resolveLevelId(ceiling, useScene.getState().nodes)} // red for holes
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={hole}
|
||||
surfaceHeight={ceiling.height ?? 2.5}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,465 +0,0 @@
|
||||
import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
||||
import { mix, positionLocal } from 'three/tsl'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const CEILING_HEIGHT = 2.52
|
||||
const GRID_OFFSET = 0.02
|
||||
|
||||
/**
|
||||
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
||||
*/
|
||||
const calculateSnapPoint = (
|
||||
lastPoint: [number, number],
|
||||
currentPoint: [number, number],
|
||||
): [number, number] => {
|
||||
const [x1, y1] = lastPoint
|
||||
const [x, y] = currentPoint
|
||||
|
||||
const dx = x - x1
|
||||
const dy = y - y1
|
||||
const absDx = Math.abs(dx)
|
||||
const absDy = Math.abs(dy)
|
||||
|
||||
// Calculate distances to horizontal, vertical, and diagonal lines
|
||||
const horizontalDist = absDy
|
||||
const verticalDist = absDx
|
||||
const diagonalDist = Math.abs(absDx - absDy)
|
||||
|
||||
// Find the minimum distance to determine which axis to snap to
|
||||
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
|
||||
|
||||
if (minDist === diagonalDist) {
|
||||
// Snap to 45° diagonal
|
||||
const diagonalLength = Math.min(absDx, absDy)
|
||||
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
||||
}
|
||||
if (minDist === horizontalDist) {
|
||||
// Snap to horizontal
|
||||
return [x, y1]
|
||||
}
|
||||
// Snap to vertical
|
||||
return [x1, y]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ceiling with the given polygon points and returns its ID
|
||||
*/
|
||||
const commitCeilingDrawing = (
|
||||
levelId: LevelNode['id'],
|
||||
points: Array<[number, number]>,
|
||||
): string => {
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
|
||||
// Count existing ceilings for naming
|
||||
const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
|
||||
const name = `Ceiling ${ceilingCount + 1}`
|
||||
|
||||
const ceiling = CeilingNode.parse({
|
||||
name,
|
||||
polygon: points,
|
||||
})
|
||||
|
||||
createNode(ceiling, levelId)
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
return ceiling.id
|
||||
}
|
||||
|
||||
export const CeilingTool: React.FC = () => {
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const gridCursorRef = useRef<Group>(null)
|
||||
const mainLineRef = useRef<Line>(null!)
|
||||
const closingLineRef = useRef<Line>(null!)
|
||||
const groundMainLineRef = useRef<Line>(null!)
|
||||
const groundClosingLineRef = useRef<Line>(null!)
|
||||
const verticalLineRef = useRef<Line>(null!)
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
|
||||
const [points, setPoints] = useState<Array<[number, number]>>([])
|
||||
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
||||
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
|
||||
const [levelY, setLevelY] = useState(0)
|
||||
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
||||
const shiftPressed = useRef(false)
|
||||
|
||||
// Static geometry: local y goes 0 (grid) → H (ceiling), mesh is positioned at gridY
|
||||
const verticalGeo = useMemo(
|
||||
() =>
|
||||
new BufferGeometry().setFromPoints([
|
||||
new Vector3(0, 0, 0),
|
||||
new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0),
|
||||
]),
|
||||
[],
|
||||
)
|
||||
|
||||
// opacityNode: positionLocal.y is 0 at grid, H at ceiling → fade from 0.6 to 0
|
||||
const gradientOpacityNode = useMemo(
|
||||
() => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()),
|
||||
[],
|
||||
)
|
||||
|
||||
// Update cursor position and lines on grid move
|
||||
useEffect(() => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!(cursorRef.current && gridCursorRef.current)) return
|
||||
|
||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
||||
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
||||
const gridPosition: [number, number] = [gridX, gridZ]
|
||||
|
||||
setCursorPosition(gridPosition)
|
||||
setLevelY(event.localPosition[1])
|
||||
|
||||
const ceilingY = event.localPosition[1] + CEILING_HEIGHT
|
||||
const gridY = event.localPosition[1] + GRID_OFFSET
|
||||
|
||||
// Calculate snapped display position (bypass snap when Shift is held)
|
||||
const lastPoint = points[points.length - 1]
|
||||
const displayPoint =
|
||||
shiftPressed.current || !lastPoint
|
||||
? gridPosition
|
||||
: calculateSnapPoint(lastPoint, gridPosition)
|
||||
setSnappedCursorPosition(displayPoint)
|
||||
|
||||
// Play snap sound when the snapped position actually changes (only when drawing)
|
||||
if (
|
||||
points.length > 0 &&
|
||||
previousSnappedPointRef.current &&
|
||||
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
|
||||
displayPoint[1] !== previousSnappedPointRef.current[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
previousSnappedPointRef.current = displayPoint
|
||||
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])
|
||||
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
|
||||
|
||||
if (verticalLineRef.current) {
|
||||
verticalLineRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
|
||||
}
|
||||
}
|
||||
|
||||
const onGridClick = (_event: GridEvent) => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Use the last displayed snapped position (respects Shift state from onGridMove)
|
||||
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
|
||||
|
||||
// Check if clicking on the first point to close the shape
|
||||
const firstPoint = points[0]
|
||||
if (
|
||||
points.length >= 3 &&
|
||||
firstPoint &&
|
||||
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
||||
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
||||
) {
|
||||
// Create the ceiling and select it
|
||||
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [ceilingId] })
|
||||
setPoints([])
|
||||
} else {
|
||||
// Add point to polygon
|
||||
setPoints([...points, clickPoint])
|
||||
}
|
||||
}
|
||||
|
||||
const onGridDoubleClick = (_event: GridEvent) => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Need at least 3 points to form a polygon
|
||||
if (points.length >= 3) {
|
||||
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [ceilingId] })
|
||||
setPoints([])
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
if (points.length > 0) markToolCancelConsumed()
|
||||
setPoints([])
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = true
|
||||
}
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = false
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('keyup', onKeyUp)
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('grid:double-click', onGridDoubleClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('keyup', onKeyUp)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('grid:double-click', onGridDoubleClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [currentLevelId, points, cursorPosition, setSelection])
|
||||
|
||||
// Update line geometries when points change
|
||||
useEffect(() => {
|
||||
if (!(mainLineRef.current && closingLineRef.current)) return
|
||||
|
||||
if (points.length === 0) {
|
||||
mainLineRef.current.visible = false
|
||||
closingLineRef.current.visible = false
|
||||
return
|
||||
}
|
||||
|
||||
const ceilingY = levelY + CEILING_HEIGHT
|
||||
const snappedCursor = snappedCursorPosition
|
||||
|
||||
// Build main line points
|
||||
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z))
|
||||
linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]))
|
||||
|
||||
const gridY = levelY + GRID_OFFSET
|
||||
const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z))
|
||||
groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1]))
|
||||
|
||||
// Update main line
|
||||
if (linePoints.length >= 2) {
|
||||
mainLineRef.current.geometry.dispose()
|
||||
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
|
||||
mainLineRef.current.visible = true
|
||||
|
||||
groundMainLineRef.current.geometry.dispose()
|
||||
groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints)
|
||||
groundMainLineRef.current.visible = true
|
||||
} else {
|
||||
mainLineRef.current.visible = false
|
||||
groundMainLineRef.current.visible = false
|
||||
}
|
||||
|
||||
// Update closing line (from cursor back to first point)
|
||||
const firstPoint = points[0]
|
||||
if (points.length >= 2 && firstPoint) {
|
||||
const closingPoints = [
|
||||
new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]),
|
||||
new Vector3(firstPoint[0], ceilingY, firstPoint[1]),
|
||||
]
|
||||
closingLineRef.current.geometry.dispose()
|
||||
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
|
||||
closingLineRef.current.visible = true
|
||||
|
||||
const groundClosingPoints = [
|
||||
new Vector3(snappedCursor[0], gridY, snappedCursor[1]),
|
||||
new Vector3(firstPoint[0], gridY, firstPoint[1]),
|
||||
]
|
||||
groundClosingLineRef.current.geometry.dispose()
|
||||
groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(
|
||||
groundClosingPoints,
|
||||
)
|
||||
groundClosingLineRef.current.visible = true
|
||||
} else {
|
||||
closingLineRef.current.visible = false
|
||||
groundClosingLineRef.current.visible = false
|
||||
}
|
||||
}, [points, snappedCursorPosition, levelY])
|
||||
|
||||
// Create preview shape when we have 3+ points
|
||||
const previewShape = useMemo(() => {
|
||||
if (points.length < 3) return null
|
||||
|
||||
const snappedCursor = snappedCursorPosition
|
||||
|
||||
const allPoints = [...points, snappedCursor]
|
||||
|
||||
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
|
||||
// - Shape X -> World X
|
||||
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
|
||||
const firstPt = allPoints[0]
|
||||
if (!firstPt) return null
|
||||
|
||||
const shape = new Shape()
|
||||
shape.moveTo(firstPt[0], -firstPt[1])
|
||||
|
||||
for (let i = 1; i < allPoints.length; i++) {
|
||||
const pt = allPoints[i]
|
||||
if (pt) {
|
||||
shape.lineTo(pt[0], -pt[1])
|
||||
}
|
||||
}
|
||||
shape.closePath()
|
||||
|
||||
return shape
|
||||
}, [points, snappedCursorPosition])
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Cursor at ceiling height */}
|
||||
<CursorSphere ref={cursorRef} />
|
||||
|
||||
{/* Grid-level cursor indicator */}
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
ref={gridCursorRef}
|
||||
renderOrder={2}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<ringGeometry args={[0.15, 0.2, 32]} />
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={true}
|
||||
opacity={0.5}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */}
|
||||
{/* @ts-ignore */}
|
||||
<line geometry={verticalGeo} layers={EDITOR_LAYER} ref={verticalLineRef} renderOrder={1}>
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacityNode={gradientOpacityNode}
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
|
||||
{/* Preview fill (Top) */}
|
||||
{previewShape && (
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
position={[0, levelY + CEILING_HEIGHT, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<shapeGeometry args={[previewShape]} />
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
opacity={0.15}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Preview fill (Ground) */}
|
||||
{previewShape && (
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
position={[0, levelY + GRID_OFFSET, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<shapeGeometry args={[previewShape]} />
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
opacity={0.1}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Main line */}
|
||||
{/* @ts-ignore */}
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
// @ts-expect-error
|
||||
ref={mainLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
|
||||
</line>
|
||||
|
||||
{/* Closing line */}
|
||||
{/* @ts-ignore */}
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
// @ts-expect-error
|
||||
ref={closingLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={2}
|
||||
opacity={0.5}
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
|
||||
{/* Ground main line */}
|
||||
{/* @ts-ignore */}
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
// @ts-expect-error
|
||||
ref={groundMainLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={3}
|
||||
opacity={0.3}
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
|
||||
{/* Ground closing line */}
|
||||
{/* @ts-ignore */}
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
// @ts-expect-error
|
||||
ref={groundClosingLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={2}
|
||||
opacity={0.15}
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
|
||||
{/* Point markers */}
|
||||
{points.map(([x, z], index) => (
|
||||
<CursorSphere
|
||||
color="#818cf8"
|
||||
key={index}
|
||||
position={[x, levelY + CEILING_HEIGHT + 0.01, z]}
|
||||
showTooltip={false}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type CeilingNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||
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 translatePolygon(
|
||||
polygon: Array<[number, number]>,
|
||||
deltaX: number,
|
||||
deltaZ: number,
|
||||
): Array<[number, number]> {
|
||||
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
|
||||
}
|
||||
|
||||
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
|
||||
if (polygon.length === 0) return [0, 0]
|
||||
let sumX = 0
|
||||
let sumZ = 0
|
||||
for (const [x, z] of polygon) {
|
||||
sumX += x
|
||||
sumZ += z
|
||||
}
|
||||
return [sumX / polygon.length, sumZ / polygon.length]
|
||||
}
|
||||
|
||||
export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
|
||||
const originalHolesRef = useRef(
|
||||
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
|
||||
)
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const previousCursorPosRef = useRef<[number, number, number] | null>(null)
|
||||
const previousDeltaRef = useRef<[number, number] | null>(null)
|
||||
const previewRef = useRef<{
|
||||
polygon: Array<[number, number]>
|
||||
holes: Array<Array<[number, number]>>
|
||||
} | null>(null)
|
||||
|
||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
||||
const center = getPolygonCenter(node.polygon)
|
||||
return [center[0], node.height ?? 2.5, center[1]]
|
||||
})
|
||||
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]>>(node.polygon)
|
||||
const [previewHoles, setPreviewHoles] = useState<Array<Array<[number, number]>>>(node.holes ?? [])
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const originalPolygon = originalPolygonRef.current
|
||||
const originalHoles = originalHolesRef.current
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
let wasCommitted = false
|
||||
|
||||
const applyPreview = (
|
||||
polygon: Array<[number, number]>,
|
||||
holes: Array<Array<[number, number]>>,
|
||||
) => {
|
||||
previewRef.current = { polygon, holes }
|
||||
setPreviewPolygon(polygon)
|
||||
setPreviewHoles(holes)
|
||||
const center = getPolygonCenter(polygon)
|
||||
const nextCursorPos: [number, number, number] = [center[0], node.height ?? 2.5, center[1]]
|
||||
if (
|
||||
!previousCursorPosRef.current ||
|
||||
previousCursorPosRef.current[0] !== nextCursorPos[0] ||
|
||||
previousCursorPosRef.current[1] !== nextCursorPos[1] ||
|
||||
previousCursorPosRef.current[2] !== nextCursorPos[2]
|
||||
) {
|
||||
previousCursorPosRef.current = nextCursorPos
|
||||
setCursorLocalPos(nextCursorPos)
|
||||
}
|
||||
useScene.getState().updateNode(node.id, { polygon, holes })
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
}
|
||||
|
||||
const restoreOriginal = () => {
|
||||
setPreviewPolygon(originalPolygon)
|
||||
setPreviewHoles(originalHoles)
|
||||
useScene.getState().updateNode(node.id, {
|
||||
holes: originalHoles,
|
||||
polygon: originalPolygon,
|
||||
})
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
if (
|
||||
previousDeltaRef.current &&
|
||||
previousDeltaRef.current[0] === deltaX &&
|
||||
previousDeltaRef.current[1] === deltaZ
|
||||
) {
|
||||
return
|
||||
}
|
||||
previousDeltaRef.current = [deltaX, deltaZ]
|
||||
|
||||
applyPreview(
|
||||
translatePolygon(originalPolygon, deltaX, deltaZ),
|
||||
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
|
||||
)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
|
||||
|
||||
wasCommitted = true
|
||||
|
||||
// Restore original baseline while paused so the next resume+update
|
||||
// registers as a single tracked change (undo reverts to original).
|
||||
useScene.getState().updateNode(node.id, {
|
||||
polygon: originalPolygon,
|
||||
holes: originalHoles,
|
||||
})
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(node.id, preview)
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
restoreOriginal()
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [exitMoveMode, node.height, node.id])
|
||||
|
||||
const previewFillGeometry = useMemo(
|
||||
() => createCeilingPreviewGeometry(previewPolygon, previewHoles),
|
||||
[previewHoles, previewPolygon],
|
||||
)
|
||||
|
||||
const previewOutlineGeometry = useMemo(
|
||||
() => createCeilingOutlineGeometry(previewPolygon),
|
||||
[previewPolygon],
|
||||
)
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh geometry={previewFillGeometry} position={[0, (node.height ?? 2.5) + 0.012, 0]}>
|
||||
<meshBasicMaterial
|
||||
color="#f5f5f4"
|
||||
depthWrite={false}
|
||||
opacity={0.3}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
{/* @ts-ignore */}
|
||||
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
|
||||
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
|
||||
</line>
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function createCeilingPreviewGeometry(
|
||||
polygon: Array<[number, number]>,
|
||||
holes: Array<Array<[number, number]>>,
|
||||
): BufferGeometry {
|
||||
if (polygon.length < 3) return new BufferGeometry()
|
||||
|
||||
const shape = new Shape()
|
||||
const [firstX, firstZ] = polygon[0]!
|
||||
shape.moveTo(firstX, -firstZ)
|
||||
|
||||
for (let i = 1; i < polygon.length; i++) {
|
||||
const [x, z] = polygon[i]!
|
||||
shape.lineTo(x, -z)
|
||||
}
|
||||
shape.closePath()
|
||||
|
||||
for (const holePolygon of holes) {
|
||||
if (holePolygon.length < 3) continue
|
||||
const hole = new Path()
|
||||
const [hx, hz] = holePolygon[0]!
|
||||
hole.moveTo(hx, -hz)
|
||||
for (let i = 1; i < holePolygon.length; i++) {
|
||||
const [x, z] = holePolygon[i]!
|
||||
hole.lineTo(x, -z)
|
||||
}
|
||||
hole.closePath()
|
||||
shape.holes.push(hole)
|
||||
}
|
||||
|
||||
const geometry = new ShapeGeometry(shape)
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.computeVertexNormals()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function createCeilingOutlineGeometry(polygon: Array<[number, number]>): BufferGeometry {
|
||||
const geometry = new BufferGeometry()
|
||||
if (polygon.length < 2) return geometry
|
||||
|
||||
const points = polygon.map(([x, z]) => new Vector3(x, 0, z))
|
||||
const [firstX, firstZ] = polygon[0]!
|
||||
points.push(new Vector3(firstX, 0, firstZ))
|
||||
geometry.setFromPoints(points)
|
||||
return geometry
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import '../../../three-types'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
ColumnNode,
|
||||
type ColumnNode as ColumnNodeType,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useCallback, useEffect, 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'
|
||||
|
||||
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
||||
|
||||
export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
|
||||
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
let committed = false
|
||||
|
||||
const applyPreview = (position: [number, number, number]) => {
|
||||
setPreviewPosition(position)
|
||||
useLiveTransforms.getState().set(node.id, {
|
||||
position,
|
||||
rotation: node.rotation,
|
||||
})
|
||||
sceneRegistry.nodes.get(node.id)?.position.set(position[0], position[1], position[2])
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
applyPreview([roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])])
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const position: [number, number, number] = [
|
||||
roundToHalf(event.localPosition[0]),
|
||||
0,
|
||||
roundToHalf(event.localPosition[2]),
|
||||
]
|
||||
const nodeId = (node as { id?: ColumnNodeType['id'] }).id
|
||||
|
||||
if (nodeId && useScene.getState().nodes[nodeId]) {
|
||||
committed = true
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(nodeId, { position })
|
||||
} else if (node.parentId) {
|
||||
const column = ColumnNode.parse({
|
||||
...node,
|
||||
id: undefined,
|
||||
metadata: {},
|
||||
position,
|
||||
})
|
||||
committed = true
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().createNode(column, node.parentId as AnyNodeId)
|
||||
}
|
||||
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
sceneRegistry.nodes
|
||||
.get(node.id)
|
||||
?.position.set(node.position[0], node.position[1], node.position[2])
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
if (!committed) {
|
||||
sceneRegistry.nodes
|
||||
.get(node.id)
|
||||
?.position.set(node.position[0], node.position[1], node.position[2])
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
return <CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
|
||||
*/
|
||||
export function wallLocalToWorld(
|
||||
wallNode: WallNode,
|
||||
localX: number,
|
||||
localY: number,
|
||||
levelYOffset = 0,
|
||||
slabElevation = 0,
|
||||
): [number, number, number] {
|
||||
const wallAngle = Math.atan2(
|
||||
wallNode.end[1] - wallNode.start[1],
|
||||
wallNode.end[0] - wallNode.start[0],
|
||||
)
|
||||
return [
|
||||
wallNode.start[0] + localX * Math.cos(wallAngle),
|
||||
slabElevation + localY + levelYOffset,
|
||||
wallNode.start[1] + localX * Math.sin(wallAngle),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps door center X so it stays fully within wall bounds.
|
||||
* Y is always height/2 — doors sit at floor level.
|
||||
*/
|
||||
export function clampToWall(
|
||||
wallNode: WallNode,
|
||||
localX: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): { clampedX: number; clampedY: number } {
|
||||
const dx = wallNode.end[0] - wallNode.start[0]
|
||||
const dz = wallNode.end[1] - wallNode.start[1]
|
||||
const wallLength = Math.sqrt(dx * dx + dz * dz)
|
||||
|
||||
const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
|
||||
const clampedY = height / 2 // Doors always sit at floor level
|
||||
return { clampedX, clampedY }
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a proposed door position overlaps any existing wall children.
|
||||
* Handles item, window, and door types.
|
||||
*/
|
||||
export function hasWallChildOverlap(
|
||||
wallId: string,
|
||||
clampedX: number,
|
||||
clampedY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
ignoreId?: string,
|
||||
): boolean {
|
||||
const nodes = useScene.getState().nodes
|
||||
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
|
||||
if (!wallNode) return true
|
||||
const halfW = width / 2
|
||||
const halfH = height / 2
|
||||
const newBottom = clampedY - halfH
|
||||
const newTop = clampedY + halfH
|
||||
const newLeft = clampedX - halfW
|
||||
const newRight = clampedX + halfW
|
||||
|
||||
for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
|
||||
if (childId === ignoreId) continue
|
||||
const child = nodes[childId as AnyNodeId]
|
||||
if (!child) continue
|
||||
|
||||
let childLeft: number, childRight: number, childBottom: number, childTop: number
|
||||
|
||||
if (child.type === 'item') {
|
||||
const item = child as ItemNode
|
||||
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
|
||||
const [w, h] = getScaledDimensions(item)
|
||||
childLeft = item.position[0] - w / 2
|
||||
childRight = item.position[0] + w / 2
|
||||
childBottom = item.position[1]
|
||||
childTop = item.position[1] + h
|
||||
} else if (child.type === 'window') {
|
||||
const win = child as WindowNode
|
||||
childLeft = win.position[0] - win.width / 2
|
||||
childRight = win.position[0] + win.width / 2
|
||||
childBottom = win.position[1] - win.height / 2
|
||||
childTop = win.position[1] + win.height / 2
|
||||
} else if (child.type === 'door') {
|
||||
const door = child as DoorNode
|
||||
childLeft = door.position[0] - door.width / 2
|
||||
childRight = door.position[0] + door.width / 2
|
||||
childBottom = door.position[1] - door.height / 2
|
||||
childTop = door.position[1] + door.height / 2
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
const xOverlap = newLeft < childRight && newRight > childLeft
|
||||
const yOverlap = newBottom < childTop && newTop > childBottom
|
||||
if (xOverlap && yOverlap) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
DoorNode,
|
||||
emitter,
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
snapToHalf,
|
||||
} from '../item/placement-math'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef_44_44,
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* Door tool — places DoorNodes on walls only.
|
||||
* Doors always sit at floor level (clampedY = height/2).
|
||||
*/
|
||||
export const DoorTool: React.FC = () => {
|
||||
const draftRef = useRef<DoorNode | null>(null)
|
||||
const cursorGroupRef = useRef<Group>(null!)
|
||||
const edgesRef = useRef<LineSegments>(null!)
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const getLevelId = () => useViewer.getState().selection.levelId
|
||||
const getLevelYOffset = () => {
|
||||
const id = getLevelId()
|
||||
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||
}
|
||||
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||
spatialGridManager.getSlabElevationForWall(
|
||||
wallEvent.node.parentId ?? '',
|
||||
wallEvent.node.start,
|
||||
wallEvent.node.end,
|
||||
)
|
||||
|
||||
const markWallDirty = (wallId: string) => {
|
||||
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
||||
}
|
||||
|
||||
const destroyDraft = () => {
|
||||
if (!draftRef.current) return
|
||||
const wallId = draftRef.current.parentId
|
||||
useScene.getState().deleteNode(draftRef.current.id)
|
||||
draftRef.current = null
|
||||
if (wallId) markWallDirty(wallId)
|
||||
}
|
||||
|
||||
const hideCursor = () => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
}
|
||||
|
||||
const updateCursor = (
|
||||
worldPosition: [number, number, number],
|
||||
cursorRotationY: number,
|
||||
valid: boolean,
|
||||
) => {
|
||||
const group = cursorGroupRef.current
|
||||
if (!group) return
|
||||
group.visible = true
|
||||
group.position.set(...worldPosition)
|
||||
group.rotation.y = cursorRotationY
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
return
|
||||
}
|
||||
const levelId = getLevelId()
|
||||
if (!levelId) return
|
||||
if (event.node.parentId !== levelId) return
|
||||
|
||||
destroyDraft()
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const width = 0.9
|
||||
const height = 2.1
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
|
||||
|
||||
const node = DoorNode.parse({
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
draftRef.current = node
|
||||
|
||||
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
return
|
||||
}
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const width = draftRef.current?.width ?? 0.9
|
||||
const height = draftRef.current?.height ?? 2.1
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, width, height)
|
||||
|
||||
if (draftRef.current) {
|
||||
if (event.node.id !== draftRef.current.parentId) {
|
||||
// Wall changed without enter/leave: must updateNode to reparent
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
} else {
|
||||
// Same wall: update Three.js mesh directly to avoid store churn
|
||||
const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId)
|
||||
if (draftMesh) {
|
||||
draftMesh.position.set(clampedX, clampedY, 0)
|
||||
draftMesh.rotation.set(0, itemRotation, 0)
|
||||
draftMesh.updateMatrixWorld(true)
|
||||
}
|
||||
markWallDirty(event.node.id)
|
||||
}
|
||||
}
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
width,
|
||||
height,
|
||||
draftRef.current?.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (!draftRef.current) return
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) return
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
)
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
draftRef.current.id,
|
||||
)
|
||||
if (!valid) return
|
||||
|
||||
const draft = draftRef.current
|
||||
draftRef.current = null
|
||||
|
||||
useScene.getState().deleteNode(draft.id)
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
const levelId = getLevelId()
|
||||
const state = useScene.getState()
|
||||
const doorCount = Object.values(state.nodes).filter((n) => {
|
||||
if (n.type !== 'door') return false
|
||||
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
||||
return wall?.parentId === levelId
|
||||
}).length
|
||||
const name = `Door ${doorCount + 1}`
|
||||
|
||||
const node = DoorNode.parse({
|
||||
name,
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
width: draft.width,
|
||||
height: draft.height,
|
||||
doorCategory: draft.doorCategory,
|
||||
doorType: draft.doorType,
|
||||
leafCount: draft.leafCount,
|
||||
operationState: draft.operationState,
|
||||
slideDirection: draft.slideDirection,
|
||||
trackStyle: draft.trackStyle,
|
||||
garagePanelCount: draft.garagePanelCount,
|
||||
frameThickness: draft.frameThickness,
|
||||
frameDepth: draft.frameDepth,
|
||||
threshold: draft.threshold,
|
||||
thresholdHeight: draft.thresholdHeight,
|
||||
hingesSide: draft.hingesSide,
|
||||
swingDirection: draft.swingDirection,
|
||||
segments: draft.segments,
|
||||
handle: draft.handle,
|
||||
handleHeight: draft.handleHeight,
|
||||
handleSide: draft.handleSide,
|
||||
doorCloser: draft.doorCloser,
|
||||
panicBar: draft.panicBar,
|
||||
panicBarHeight: draft.panicBarHeight,
|
||||
})
|
||||
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().pause()
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallLeave = () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
emitter.on('wall:leave', onWallLeave)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('wall:enter', onWallEnter)
|
||||
emitter.off('wall:move', onWallMove)
|
||||
emitter.off('wall:click', onWallClick)
|
||||
emitter.off('wall:leave', onWallLeave)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cursor geometry: door outline (default 0.9 × 2.1 × 0.07)
|
||||
const boxGeo = new BoxGeometry(0.9, 2.1, 0.07)
|
||||
const edgesGeo = new EdgesGeometry(boxGeo)
|
||||
boxGeo.dispose()
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments
|
||||
geometry={edgesGeo}
|
||||
layers={EDITOR_LAYER}
|
||||
material={edgeMaterial}
|
||||
ref={edgesRef}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
DoorNode,
|
||||
emitter,
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
snapToHalf,
|
||||
} from '../item/placement-math'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './door-math'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef_44_44,
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
|
||||
const cursorGroupRef = useRef<Group>(null!)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const meta =
|
||||
typeof movingDoorNode.metadata === 'object' && movingDoorNode.metadata !== null
|
||||
? (movingDoorNode.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
|
||||
const original = {
|
||||
position: [...movingDoorNode.position] as [number, number, number],
|
||||
rotation: [...movingDoorNode.rotation] as [number, number, number],
|
||||
side: movingDoorNode.side,
|
||||
parentId: movingDoorNode.parentId,
|
||||
wallId: movingDoorNode.wallId,
|
||||
metadata: movingDoorNode.metadata,
|
||||
}
|
||||
|
||||
if (!isNew) {
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
metadata: { ...meta, isTransient: true },
|
||||
})
|
||||
}
|
||||
|
||||
let currentWallId: string | null = movingDoorNode.parentId
|
||||
|
||||
const markWallDirty = (wallId: string | null) => {
|
||||
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
||||
}
|
||||
|
||||
const getLevelId = () => useViewer.getState().selection.levelId
|
||||
const getLevelYOffset = () => {
|
||||
const id = getLevelId()
|
||||
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||
}
|
||||
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||
spatialGridManager.getSlabElevationForWall(
|
||||
wallEvent.node.parentId ?? '',
|
||||
wallEvent.node.start,
|
||||
wallEvent.node.end,
|
||||
)
|
||||
|
||||
const hideCursor = () => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
}
|
||||
|
||||
const updateCursor = (
|
||||
worldPosition: [number, number, number],
|
||||
cursorRotationY: number,
|
||||
valid: boolean,
|
||||
) => {
|
||||
const group = cursorGroupRef.current
|
||||
if (!group) return
|
||||
group.visible = true
|
||||
group.position.set(...worldPosition)
|
||||
group.rotation.y = cursorRotationY
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const getPlacementOrientation = (event: WallEvent) => {
|
||||
const faceSide = getSideFromNormal(event.normal)
|
||||
const side = movingDoorNode.side ?? faceSide
|
||||
const rotationOffset = side !== faceSide ? Math.PI : 0
|
||||
return {
|
||||
side,
|
||||
itemRotation: calculateItemRotation(event.normal) + rotationOffset,
|
||||
cursorRotation:
|
||||
calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset,
|
||||
}
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
hideCursor()
|
||||
return
|
||||
}
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
)
|
||||
|
||||
const prevWallId = currentWallId
|
||||
currentWallId = event.node.id
|
||||
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
useLiveTransforms.getState().set(movingDoorNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: itemRotation,
|
||||
})
|
||||
|
||||
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
|
||||
markWallDirty(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
movingDoorNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
hideCursor()
|
||||
return
|
||||
}
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
)
|
||||
|
||||
if (currentWallId !== event.node.id) {
|
||||
// Wall changed mid-move: must updateNode to reparent
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
markWallDirty(currentWallId)
|
||||
currentWallId = event.node.id
|
||||
} else {
|
||||
// Same wall: update Three.js mesh directly to avoid store churn
|
||||
// collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
|
||||
const doorMesh = sceneRegistry.nodes.get(movingDoorNode.id as AnyNodeId)
|
||||
if (doorMesh) {
|
||||
doorMesh.position.set(clampedX, clampedY, 0)
|
||||
doorMesh.rotation.set(0, itemRotation, 0)
|
||||
doorMesh.updateMatrixWorld(true)
|
||||
}
|
||||
}
|
||||
useLiveTransforms.getState().set(movingDoorNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: itemRotation,
|
||||
})
|
||||
markWallDirty(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
movingDoorNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) return
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const { side, itemRotation } = getPlacementOrientation(event)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
movingDoorNode.id,
|
||||
)
|
||||
if (!valid) return
|
||||
|
||||
let placedId: string
|
||||
|
||||
if (isNew) {
|
||||
useScene.getState().deleteNode(movingDoorNode.id)
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
const cloned = structuredClone(movingDoorNode) as any
|
||||
delete cloned.id
|
||||
const node = DoorNode.parse({
|
||||
...cloned,
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
})
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
placedId = node.id
|
||||
} else {
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
if (original.parentId && original.parentId !== event.node.id) {
|
||||
markWallDirty(original.parentId)
|
||||
}
|
||||
placedId = movingDoorNode.id
|
||||
}
|
||||
|
||||
markWallDirty(event.node.id)
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
hideCursor()
|
||||
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
||||
exitMoveMode()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallLeave = () => {
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
if (isNew) return
|
||||
if (currentWallId && currentWallId !== original.parentId) {
|
||||
markWallDirty(currentWallId)
|
||||
}
|
||||
currentWallId = original.parentId
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
if (isNew) {
|
||||
useScene.getState().deleteNode(movingDoorNode.id)
|
||||
if (currentWallId) markWallDirty(currentWallId)
|
||||
} else {
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
hideCursor()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
emitter.on('wall:leave', onWallLeave)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
|
||||
| DoorNode
|
||||
| undefined
|
||||
const currentMeta = current?.metadata as Record<string, unknown> | undefined
|
||||
if (currentMeta?.isTransient) {
|
||||
if (isNew) {
|
||||
useScene.getState().deleteNode(movingDoorNode.id)
|
||||
if (currentWallId) markWallDirty(currentWallId)
|
||||
} else {
|
||||
useScene.getState().updateNode(movingDoorNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
}
|
||||
}
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('wall:enter', onWallEnter)
|
||||
emitter.off('wall:move', onWallMove)
|
||||
emitter.off('wall:click', onWallClick)
|
||||
emitter.off('wall:leave', onWallLeave)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [movingDoorNode, exitMoveMode])
|
||||
|
||||
const edgesGeo = useMemo(() => {
|
||||
const boxGeo = new BoxGeometry(
|
||||
movingDoorNode.width,
|
||||
movingDoorNode.height,
|
||||
movingDoorNode.frameDepth ?? 0.07,
|
||||
)
|
||||
const geo = new EdgesGeometry(boxGeo)
|
||||
boxGeo.dispose()
|
||||
return geo
|
||||
}, [movingDoorNode])
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
getWallChordFrame,
|
||||
getWallMidpointHandlePoint,
|
||||
normalizeWallCurveOffset,
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { getWallGridStep, snapScalarToGrid } from '../wall/wall-drafting'
|
||||
|
||||
export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
|
||||
const previousCurveOffsetRef = useRef<number | null>(null)
|
||||
const shiftPressedRef = useRef(false)
|
||||
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
|
||||
|
||||
const initialHandle = getWallMidpointHandlePoint(node)
|
||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
|
||||
initialHandle.x,
|
||||
0,
|
||||
initialHandle.y,
|
||||
])
|
||||
|
||||
const exitCurveMode = useCallback(() => {
|
||||
useEditor.getState().setCurvingFence(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const nodeId = node.id
|
||||
const originalCurveOffset = originalCurveOffsetRef.current
|
||||
const chord = getWallChordFrame(node)
|
||||
const maxCurveOffset = getMaxWallCurveOffset(node)
|
||||
|
||||
pauseSceneHistory(useScene)
|
||||
let wasCommitted = false
|
||||
|
||||
const applyPreview = (curveOffset: number) => {
|
||||
if (previewOffsetRef.current === curveOffset) {
|
||||
return
|
||||
}
|
||||
previewOffsetRef.current = curveOffset
|
||||
|
||||
const nextNode = {
|
||||
...node,
|
||||
curveOffset,
|
||||
}
|
||||
const handlePoint = getWallMidpointHandlePoint(nextNode)
|
||||
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
|
||||
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
}
|
||||
|
||||
const restoreOriginal = () => {
|
||||
if (previewOffsetRef.current === originalCurveOffset) {
|
||||
return
|
||||
}
|
||||
previewOffsetRef.current = originalCurveOffset
|
||||
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const snapStep = getWallGridStep()
|
||||
const localX = shiftPressedRef.current
|
||||
? event.localPosition[0]
|
||||
: snapScalarToGrid(event.localPosition[0], snapStep)
|
||||
const localZ = shiftPressedRef.current
|
||||
? event.localPosition[2]
|
||||
: snapScalarToGrid(event.localPosition[2], snapStep)
|
||||
|
||||
const offsetFromMidpoint = -(
|
||||
(localX - chord.midpoint.x) * chord.normal.x +
|
||||
(localZ - chord.midpoint.y) * chord.normal.y
|
||||
)
|
||||
const snappedOffset = shiftPressedRef.current
|
||||
? offsetFromMidpoint
|
||||
: snapScalarToGrid(offsetFromMidpoint, snapStep)
|
||||
const nextCurveOffset = normalizeWallCurveOffset(
|
||||
node,
|
||||
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
|
||||
)
|
||||
|
||||
if (
|
||||
previousCurveOffsetRef.current !== null &&
|
||||
nextCurveOffset !== previousCurveOffsetRef.current
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
previousCurveOffsetRef.current = nextCurveOffset
|
||||
|
||||
applyPreview(nextCurveOffset)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const curveOffset = previewOffsetRef.current
|
||||
wasCommitted = true
|
||||
|
||||
if (curveOffset !== originalCurveOffset) {
|
||||
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
|
||||
resumeSceneHistory(useScene)
|
||||
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
pauseSceneHistory(useScene)
|
||||
}
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
exitCurveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
resumeSceneHistory(useScene)
|
||||
markToolCancelConsumed()
|
||||
exitCurveMode()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = true
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
restoreOriginal()
|
||||
}
|
||||
resumeSceneHistory(useScene)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
}
|
||||
}, [exitCurveMode, node])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
import {
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useEffect, useRef, useState } 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 {
|
||||
formatAngleRadians,
|
||||
getAngleToSegmentReference,
|
||||
getSegmentAngleReferenceAtPoint,
|
||||
} from '../shared/segment-angle'
|
||||
import {
|
||||
createFenceOnCurrentLevel,
|
||||
type FencePlanPoint,
|
||||
snapFenceDraftPoint,
|
||||
} from './fence-drafting'
|
||||
|
||||
const FENCE_PREVIEW_HEIGHT = 1.8
|
||||
const DRAFT_LABEL_Y = FENCE_PREVIEW_HEIGHT + 0.22
|
||||
const DRAFT_ANGLE_LABEL_Y = 0.28
|
||||
|
||||
type DraftAngleLabel = {
|
||||
id: string
|
||||
label: string
|
||||
position: [number, number, number]
|
||||
}
|
||||
|
||||
type DraftMeasurementState = {
|
||||
lengthLabel: string
|
||||
lengthPosition: [number, number, number]
|
||||
angleLabels: DraftAngleLabel[]
|
||||
} | null
|
||||
|
||||
type SegmentLike = {
|
||||
id: string
|
||||
start: FencePlanPoint
|
||||
end: FencePlanPoint
|
||||
curveOffset?: number
|
||||
}
|
||||
|
||||
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
||||
if (unit === 'imperial') {
|
||||
const feet = value * 3.280_84
|
||||
const wholeFeet = Math.floor(feet)
|
||||
const inches = Math.round((feet - wholeFeet) * 12)
|
||||
if (inches === 12) return `${wholeFeet + 1}'0"`
|
||||
return `${wholeFeet}'${inches}"`
|
||||
}
|
||||
|
||||
return `${Number.parseFloat(value.toFixed(2))}m`
|
||||
}
|
||||
|
||||
function getDraftAngleLabels(
|
||||
start: FencePlanPoint,
|
||||
end: FencePlanPoint,
|
||||
segments: SegmentLike[],
|
||||
): DraftAngleLabel[] {
|
||||
const draftFromStart: FencePlanPoint = [end[0] - start[0], end[1] - start[1]]
|
||||
const draftFromEnd: FencePlanPoint = [start[0] - end[0], start[1] - end[1]]
|
||||
const endpoints = [
|
||||
{ id: 'start', point: start, draftVector: draftFromStart },
|
||||
{ id: 'end', point: end, draftVector: draftFromEnd },
|
||||
]
|
||||
const labels: DraftAngleLabel[] = []
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
const connectedSegment = segments.find((segment) =>
|
||||
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
|
||||
)
|
||||
if (!connectedSegment) continue
|
||||
|
||||
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
|
||||
if (!connectedReference) continue
|
||||
|
||||
const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
|
||||
if (angle === null) continue
|
||||
|
||||
labels.push({
|
||||
id: endpoint.id,
|
||||
label: formatAngleRadians(angle),
|
||||
position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
|
||||
})
|
||||
}
|
||||
|
||||
return labels
|
||||
}
|
||||
|
||||
function getDraftMeasurementState(
|
||||
start: FencePlanPoint,
|
||||
end: FencePlanPoint,
|
||||
segments: SegmentLike[],
|
||||
unit: 'metric' | 'imperial',
|
||||
): DraftMeasurementState {
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[1] - start[1]
|
||||
const length = Math.hypot(dx, dz)
|
||||
|
||||
if (length < 0.01) return null
|
||||
|
||||
return {
|
||||
lengthLabel: formatMeasurement(length, unit),
|
||||
lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
|
||||
angleLabels: getDraftAngleLabels(start, end, segments),
|
||||
}
|
||||
}
|
||||
|
||||
function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
|
||||
return [
|
||||
...walls.map((wall) => ({
|
||||
id: wall.id,
|
||||
start: wall.start,
|
||||
end: wall.end,
|
||||
curveOffset: wall.curveOffset,
|
||||
})),
|
||||
...fences.map((fence) => ({
|
||||
id: fence.id,
|
||||
start: fence.start,
|
||||
end: fence.end,
|
||||
curveOffset: fence.curveOffset,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
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 unit = useViewer((state) => state.unit)
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const previewRef = useRef<Mesh>(null!)
|
||||
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||
const endingPoint = useRef(new Vector3(0, 0, 0))
|
||||
const buildingState = useRef(0)
|
||||
const shiftPressed = useRef(false)
|
||||
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
||||
|
||||
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)
|
||||
setDraftMeasurement(
|
||||
getDraftMeasurementState(
|
||||
[startingPoint.current.x, startingPoint.current.z],
|
||||
snappedLocal,
|
||||
getReferenceSegments(walls, fences),
|
||||
unit,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences })
|
||||
cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1])
|
||||
setDraftMeasurement(null)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
setDraftMeasurement(null)
|
||||
} 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
|
||||
setDraftMeasurement(null)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
setDraftMeasurement(null)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}, [unit])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere height={FENCE_PREVIEW_HEIGHT} ref={cursorRef} />
|
||||
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
|
||||
<shapeGeometry />
|
||||
<meshBasicMaterial
|
||||
color="#ffffff"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.45}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{draftMeasurement && (
|
||||
<>
|
||||
<DraftMeasurementLabel
|
||||
label={draftMeasurement.lengthLabel}
|
||||
position={draftMeasurement.lengthPosition}
|
||||
/>
|
||||
{draftMeasurement.angleLabels.map((angleLabel) => (
|
||||
<DraftMeasurementLabel
|
||||
key={angleLabel.id}
|
||||
label={angleLabel.label}
|
||||
position={angleLabel.position}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftMeasurementLabel({
|
||||
label,
|
||||
position,
|
||||
}: {
|
||||
label: string
|
||||
position: [number, number, number]
|
||||
}) {
|
||||
return (
|
||||
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
||||
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
|
||||
{label}
|
||||
</div>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import {
|
||||
formatAngleRadians,
|
||||
getAngleToSegmentReference,
|
||||
getSegmentAngleReferenceAtPoint,
|
||||
} from '../shared/segment-angle'
|
||||
import { isWallLongEnough } from '../wall/wall-drafting'
|
||||
import { type FencePlanPoint, snapFenceDraftPoint } from './fence-drafting'
|
||||
|
||||
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
|
||||
|
||||
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
|
||||
return (
|
||||
Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON &&
|
||||
Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON
|
||||
)
|
||||
}
|
||||
|
||||
type SegmentLike = {
|
||||
id: string
|
||||
start: FencePlanPoint
|
||||
end: FencePlanPoint
|
||||
curveOffset?: number
|
||||
}
|
||||
|
||||
type AngleLabelState = {
|
||||
label: string
|
||||
position: [number, number, number]
|
||||
} | null
|
||||
|
||||
function getEndpointAngleLabel(args: {
|
||||
preview: { start: FencePlanPoint; end: FencePlanPoint; curveOffset?: number }
|
||||
segments: SegmentLike[]
|
||||
nodeId: FenceNode['id']
|
||||
}): AngleLabelState {
|
||||
const { preview, segments, nodeId } = args
|
||||
const endpoints = [
|
||||
{
|
||||
point: preview.start,
|
||||
},
|
||||
{
|
||||
point: preview.end,
|
||||
},
|
||||
]
|
||||
const targetSegment: SegmentLike = {
|
||||
id: nodeId,
|
||||
start: preview.start,
|
||||
end: preview.end,
|
||||
curveOffset: preview.curveOffset,
|
||||
}
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
|
||||
if (!targetReference) continue
|
||||
|
||||
const connectedSegment = segments.find(
|
||||
(segment) =>
|
||||
segment.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, segment)),
|
||||
)
|
||||
if (!connectedSegment) continue
|
||||
|
||||
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedSegment)
|
||||
if (!connectedReference) continue
|
||||
|
||||
const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
|
||||
if (angle === null) continue
|
||||
|
||||
return {
|
||||
label: formatAngleRadians(angle),
|
||||
position: [endpoint.point[0], 0.34, endpoint.point[1]],
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getReferenceSegments(walls: WallNode[], fences: FenceNode[]): SegmentLike[] {
|
||||
return [
|
||||
...walls.map((wall) => ({
|
||||
id: wall.id,
|
||||
start: wall.start,
|
||||
end: wall.end,
|
||||
curveOffset: wall.curveOffset,
|
||||
})),
|
||||
...fences.map((fence) => ({
|
||||
id: fence.id,
|
||||
start: fence.start,
|
||||
end: fence.end,
|
||||
curveOffset: fence.curveOffset,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
type LinkedFenceSnapshot = {
|
||||
id: FenceNode['id']
|
||||
start: FencePlanPoint
|
||||
end: FencePlanPoint
|
||||
curveOffset?: number
|
||||
}
|
||||
|
||||
function getLinkedFenceSnapshots(args: {
|
||||
fenceId: FenceNode['id']
|
||||
fenceParentId: string | null
|
||||
linkedPoint: FencePlanPoint
|
||||
}) {
|
||||
const { fenceId, fenceParentId, linkedPoint } = args
|
||||
const { nodes } = useScene.getState()
|
||||
const snapshots: LinkedFenceSnapshot[] = []
|
||||
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (!(node?.type === 'fence' && node.id !== fenceId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ((node.parentId ?? null) !== fenceParentId) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!samePoint(node.start, linkedPoint) && !samePoint(node.end, linkedPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
snapshots.push({
|
||||
id: node.id,
|
||||
start: [...node.start] as FencePlanPoint,
|
||||
end: [...node.end] as FencePlanPoint,
|
||||
curveOffset: node.curveOffset,
|
||||
})
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
|
||||
function getLinkedFenceUpdates(
|
||||
linkedFences: LinkedFenceSnapshot[],
|
||||
linkedPoint: FencePlanPoint,
|
||||
nextLinkedPoint: FencePlanPoint,
|
||||
) {
|
||||
return linkedFences.map((fence) => ({
|
||||
id: fence.id,
|
||||
curveOffset: fence.curveOffset,
|
||||
start: samePoint(fence.start, linkedPoint) ? nextLinkedPoint : fence.start,
|
||||
end: samePoint(fence.end, linkedPoint) ? nextLinkedPoint : fence.end,
|
||||
}))
|
||||
}
|
||||
|
||||
export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ({ target }) => {
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const previousGridPosRef = useRef<FencePlanPoint | null>(null)
|
||||
const shiftPressedRef = useRef(false)
|
||||
const altPressedRef = useRef(false)
|
||||
const nodeIdRef = useRef(target.fence.id)
|
||||
const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint)
|
||||
const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint)
|
||||
const originalMovingPointRef = useRef<FencePlanPoint>(
|
||||
target.endpoint === 'start'
|
||||
? ([...target.fence.start] as FencePlanPoint)
|
||||
: ([...target.fence.end] as FencePlanPoint),
|
||||
)
|
||||
const fixedPointRef = useRef<FencePlanPoint>(
|
||||
target.endpoint === 'start'
|
||||
? ([...target.fence.end] as FencePlanPoint)
|
||||
: ([...target.fence.start] as FencePlanPoint),
|
||||
)
|
||||
const linkedOriginalsRef = useRef(
|
||||
getLinkedFenceSnapshots({
|
||||
fenceId: target.fence.id,
|
||||
fenceParentId: target.fence.parentId ?? null,
|
||||
linkedPoint: target.endpoint === 'start' ? target.fence.start : target.fence.end,
|
||||
}),
|
||||
)
|
||||
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
|
||||
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
|
||||
|
||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
||||
const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
|
||||
return [point[0], 0, point[1]]
|
||||
})
|
||||
const [altPressed, setAltPressed] = useState(false)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingFenceEndpoint(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const nodeId = nodeIdRef.current
|
||||
const originalStart = originalStartRef.current
|
||||
const originalEnd = originalEndRef.current
|
||||
const originalMovingPoint = originalMovingPointRef.current
|
||||
const fixedPoint = fixedPointRef.current
|
||||
const siblings = Object.values(useScene.getState().nodes)
|
||||
const levelWalls = siblings.filter(
|
||||
(node): node is WallNode =>
|
||||
node?.type === 'wall' && (node.parentId ?? null) === (target.fence.parentId ?? null),
|
||||
)
|
||||
const levelFences = siblings.filter(
|
||||
(node): node is FenceNode =>
|
||||
node?.type === 'fence' && (node.parentId ?? null) === (target.fence.parentId ?? null),
|
||||
)
|
||||
|
||||
pauseSceneHistory(useScene)
|
||||
let wasCommitted = false
|
||||
|
||||
const applyNodePreview = (
|
||||
updates: Array<{ id: FenceNode['id']; start: FencePlanPoint; end: FencePlanPoint }>,
|
||||
) => {
|
||||
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 = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
|
||||
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
||||
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
|
||||
const linkedUpdates = detachLinkedFences
|
||||
? []
|
||||
: getLinkedFenceUpdates(linkedOriginalsRef.current, originalMovingPoint, movingPoint)
|
||||
previewRef.current = { start: nextStart, end: nextEnd }
|
||||
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
||||
setAngleLabel(
|
||||
getEndpointAngleLabel({
|
||||
preview: { start: nextStart, end: nextEnd, curveOffset: target.fence.curveOffset },
|
||||
segments: [...getReferenceSegments(levelWalls, levelFences), ...linkedUpdates],
|
||||
nodeId,
|
||||
}),
|
||||
)
|
||||
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
|
||||
}
|
||||
|
||||
const restoreOriginal = (clearAngleLabel = true) => {
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
if (clearAngleLabel) {
|
||||
setAngleLabel(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const planPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
const snappedPoint = snapFenceDraftPoint({
|
||||
point: planPoint,
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
start: fixedPoint,
|
||||
angleSnap: !shiftPressedRef.current,
|
||||
ignoreFenceIds: [nodeId],
|
||||
})
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
(snappedPoint[0] !== previousGridPosRef.current[0] ||
|
||||
snappedPoint[1] !== previousGridPosRef.current[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
previousGridPosRef.current = snappedPoint
|
||||
|
||||
applyPreview(snappedPoint, event.nativeEvent.altKey)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||
const hasChanged = !(
|
||||
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
|
||||
)
|
||||
|
||||
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
|
||||
wasCommitted = true
|
||||
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
|
||||
resumeSceneHistory(useScene)
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: preview.start, end: preview.end },
|
||||
...(altPressedRef.current
|
||||
? []
|
||||
: getLinkedFenceUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalMovingPoint,
|
||||
target.endpoint === 'start' ? preview.start : preview.end,
|
||||
)),
|
||||
])
|
||||
pauseSceneHistory(useScene)
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
}
|
||||
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
setAngleLabel(null)
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
resumeSceneHistory(useScene)
|
||||
setAngleLabel(null)
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = true
|
||||
}
|
||||
if (event.key === 'Alt') {
|
||||
altPressedRef.current = true
|
||||
setAltPressed(true)
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = false
|
||||
}
|
||||
if (event.key === 'Alt') {
|
||||
altPressedRef.current = false
|
||||
setAltPressed(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onWindowBlur = () => {
|
||||
shiftPressedRef.current = false
|
||||
altPressedRef.current = false
|
||||
setAltPressed(false)
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', onWindowBlur)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
restoreOriginal(false)
|
||||
}
|
||||
resumeSceneHistory(useScene)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', onWindowBlur)
|
||||
}
|
||||
}, [exitMoveMode, target])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
<Html
|
||||
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
|
||||
style={{ pointerEvents: 'none', touchAction: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<div className="translate-y-10">
|
||||
<div
|
||||
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
|
||||
altPressed
|
||||
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
|
||||
: 'border-border/70 bg-background/90 text-foreground/80'
|
||||
}`}
|
||||
>
|
||||
{altPressed ? 'Detach endpoint' : 'Drag endpoint'}
|
||||
</div>
|
||||
</div>
|
||||
</Html>
|
||||
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function EndpointAngleLabel({
|
||||
label,
|
||||
position,
|
||||
}: {
|
||||
label: string
|
||||
position: [number, number, number]
|
||||
}) {
|
||||
return (
|
||||
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
||||
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
|
||||
{label}
|
||||
</div>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type * as THREE from 'three'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { snapFenceDraftPoint } from './fence-drafting'
|
||||
|
||||
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']
|
||||
fenceParentId: string | null
|
||||
originalStart: [number, number]
|
||||
originalEnd: [number, number]
|
||||
}) {
|
||||
const { fenceId, fenceParentId, 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 ((node.parentId ?? null) !== fenceParentId) {
|
||||
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 activatedAtRef = useRef<number>(Date.now())
|
||||
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,
|
||||
fenceParentId: node.parentId ?? null,
|
||||
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
|
||||
const levelNode =
|
||||
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
|
||||
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
|
||||
: null
|
||||
const levelChildren = levelNode?.children ?? []
|
||||
const levelWalls = levelChildren
|
||||
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
||||
.filter((child): child is WallNode => child?.type === 'wall')
|
||||
const levelFences = levelChildren
|
||||
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
||||
.filter((child): child is FenceNode => child?.type === 'fence')
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
let wasCommitted = false
|
||||
|
||||
const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => {
|
||||
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
|
||||
if (!mesh) {
|
||||
return
|
||||
}
|
||||
|
||||
mesh.position.set(deltaX, 0, deltaZ)
|
||||
}
|
||||
|
||||
const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => {
|
||||
const originalCenterX = (fence.start[0] + fence.end[0]) / 2
|
||||
const originalCenterZ = (fence.start[1] + fence.end[1]) / 2
|
||||
useLiveTransforms.getState().set(fence.id, {
|
||||
position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ],
|
||||
rotation: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const clearPreviewState = () => {
|
||||
setMeshOffset(nodeId, 0, 0)
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
|
||||
for (const linkedFence of linkedOriginalsRef.current) {
|
||||
setMeshOffset(linkedFence.id, 0, 0)
|
||||
useLiveTransforms.getState().clear(linkedFence.id)
|
||||
}
|
||||
}
|
||||
|
||||
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])
|
||||
const deltaX = nextStart[0] - originalStart[0]
|
||||
const deltaZ = nextStart[1] - originalStart[1]
|
||||
setMeshOffset(nodeId, deltaX, deltaZ)
|
||||
setFenceLiveTransform(node, deltaX, deltaZ)
|
||||
|
||||
for (const linkedFence of linkedOriginalsRef.current) {
|
||||
setMeshOffset(linkedFence.id, deltaX, deltaZ)
|
||||
setFenceLiveTransform(
|
||||
{
|
||||
...node,
|
||||
id: linkedFence.id,
|
||||
start: linkedFence.start,
|
||||
end: linkedFence.end,
|
||||
},
|
||||
deltaX,
|
||||
deltaZ,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const [localX, localZ] = snapFenceDraftPoint({
|
||||
point: [event.localPosition[0], event.localPosition[2]],
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
ignoreFenceIds: [nodeId],
|
||||
})
|
||||
|
||||
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) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
])
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
for (const linkedFence of linkedOriginalsRef.current) {
|
||||
useLiveTransforms.getState().clear(linkedFence.id)
|
||||
}
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
clearPreviewState()
|
||||
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) {
|
||||
useLiveTransforms.getState().clear(nodeId)
|
||||
for (const linkedFence of linkedOriginalsRef.current) {
|
||||
useLiveTransforms.getState().clear(linkedFence.id)
|
||||
}
|
||||
} else {
|
||||
clearPreviewState()
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [exitMoveMode, node])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { AssetInput } from '@pascal-app/core'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { useDraftNode } from './use-draft-node'
|
||||
import { usePlacementCoordinator } from './use-placement-coordinator'
|
||||
|
||||
function ItemPlacementContent({ selectedItem }: { selectedItem: AssetInput }) {
|
||||
const draftNode = useDraftNode()
|
||||
|
||||
const cursor = usePlacementCoordinator({
|
||||
asset: selectedItem,
|
||||
draftNode,
|
||||
initDraft: (gridPosition) => {
|
||||
if (selectedItem && !selectedItem.attachTo) {
|
||||
draftNode.create(gridPosition, selectedItem)
|
||||
}
|
||||
},
|
||||
onCommitted: () => {
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
return <>{cursor}</>
|
||||
}
|
||||
|
||||
export const ItemTool: React.FC = () => {
|
||||
const selectedItem = useEditor((state) => state.selectedItem)
|
||||
|
||||
if (!selectedItem) return null
|
||||
return <ItemPlacementContent selectedItem={selectedItem} />
|
||||
}
|
||||
@@ -1,132 +1,50 @@
|
||||
import type {
|
||||
AnyNodeId,
|
||||
BuildingNode,
|
||||
CeilingNode,
|
||||
ColumnNode,
|
||||
DoorNode,
|
||||
ElevatorNode,
|
||||
FenceNode,
|
||||
ItemNode,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
SlabNode,
|
||||
SpawnNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { nodeRegistry } from '@pascal-app/core'
|
||||
import { Suspense } from 'react'
|
||||
import { Vector3 } from 'three'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { MoveBuildingContent } from '../building/move-building-tool'
|
||||
import { MoveCeilingTool } from '../ceiling/move-ceiling-tool'
|
||||
import { MoveColumnTool } from '../column/move-column-tool'
|
||||
import { MoveDoorTool } from '../door/move-door-tool'
|
||||
import { MoveElevatorTool } from '../elevator/move-elevator-tool'
|
||||
import { MoveFenceTool } from '../fence/move-fence-tool'
|
||||
import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool'
|
||||
import { MoveRoofTool } from '../roof/move-roof-tool'
|
||||
import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
|
||||
import { MoveSlabTool } from '../slab/move-slab-tool'
|
||||
import { MoveSpawnTool } from '../spawn/move-spawn-tool'
|
||||
import { MoveWallTool } from '../wall/move-wall-tool'
|
||||
import { MoveWindowTool } from '../window/move-window-tool'
|
||||
import type { PlacementState } from './placement-types'
|
||||
import { useDraftNode } from './use-draft-node'
|
||||
import { usePlacementCoordinator } from './use-placement-coordinator'
|
||||
|
||||
function getInitialState(node: {
|
||||
asset: { attachTo?: string }
|
||||
parentId: string | null
|
||||
}): PlacementState {
|
||||
const attachTo = node.asset.attachTo
|
||||
if (attachTo === 'wall' || attachTo === 'wall-side') {
|
||||
return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null }
|
||||
}
|
||||
if (attachTo === 'ceiling') {
|
||||
return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null }
|
||||
}
|
||||
return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
|
||||
}
|
||||
|
||||
function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
|
||||
const draftNode = useDraftNode()
|
||||
|
||||
const meta =
|
||||
typeof movingNode.metadata === 'object' && movingNode.metadata !== null
|
||||
? (movingNode.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
|
||||
const cursor = usePlacementCoordinator({
|
||||
asset: movingNode.asset,
|
||||
draftNode,
|
||||
// Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft
|
||||
initialState: isNew
|
||||
? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }
|
||||
: getInitialState(movingNode),
|
||||
// Preserve the original item's scale so Y-position calculations use the correct height
|
||||
defaultScale: isNew ? movingNode.scale : undefined,
|
||||
initDraft: (gridPosition) => {
|
||||
if (isNew) {
|
||||
// Duplicate: use the same create() path as ItemTool so ghost rendering works correctly.
|
||||
// Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry.
|
||||
gridPosition.copy(new Vector3(...movingNode.position))
|
||||
if (!movingNode.asset.attachTo) {
|
||||
draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale)
|
||||
}
|
||||
} else {
|
||||
draftNode.adopt(movingNode)
|
||||
gridPosition.copy(new Vector3(...movingNode.position))
|
||||
}
|
||||
},
|
||||
onCommitted: () => {
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
useEditor.getState().setMovingNode(null)
|
||||
return false
|
||||
},
|
||||
onCancel: () => {
|
||||
draftNode.destroy()
|
||||
useEditor.getState().setMovingNode(null)
|
||||
},
|
||||
})
|
||||
|
||||
return <>{cursor}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* MoveTool dispatcher. Routes to (in order):
|
||||
*
|
||||
* 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that
|
||||
* declare `capabilities.movable` (shelf, spawn, item-with-floor-attach,
|
||||
* …).
|
||||
* 2. `def.affordanceTools.move` — kind-owned move component
|
||||
* (slab / ceiling / wall / fence / column / item / door / window).
|
||||
* Lazy-loaded via `getRegistryAffordanceTool`.
|
||||
* 3. The narrow set of kinds that still have legacy movers because no
|
||||
* registry equivalent has been written yet (building / elevator /
|
||||
* roof / stair). Each of these has bespoke move semantics that
|
||||
* don't fit the generic mover and are not yet ported to a
|
||||
* kind-owned affordance.
|
||||
*/
|
||||
export const MoveTool: React.FC<{
|
||||
onNodeMoved?: (nodeId: AnyNodeId) => void
|
||||
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
|
||||
}> = ({ onNodeMoved, onSpawnMoved }) => {
|
||||
}> = ({ onNodeMoved }) => {
|
||||
const movingNode = useEditor((state) => state.movingNode)
|
||||
|
||||
if (!movingNode) return null
|
||||
|
||||
// Capability-driven dispatch. A registered kind opts INTO the generic
|
||||
// mover by declaring `capabilities.movable` — that's the "I'm a simple
|
||||
// translate-on-the-X/Z-plane node" signal (shelf, spawn, future
|
||||
// single-position items). Kinds with bespoke move semantics (wall
|
||||
// endpoint drag + linked-wall corner cascade + ALT-detach, fence
|
||||
// endpoint drag + curve sagitta, slab polygon vertex edit, stair
|
||||
// endpoint drag, etc.) deliberately OMIT `capabilities.movable` so
|
||||
// this branch falls through to their legacy per-kind movers below.
|
||||
//
|
||||
// Without this guard, every registered kind would be force-routed
|
||||
// through MoveRegistryNodeTool's "translate position" pattern,
|
||||
// breaking wall / fence / slab / stair endpoint UX (the smart
|
||||
// sims-style arrows that move the dragged endpoint while cascading
|
||||
// to linked walls / re-anchoring hosted children / etc.).
|
||||
const def = nodeRegistry.get(movingNode.type)
|
||||
if (def?.capabilities?.movable) {
|
||||
return <MoveRegistryNodeTool node={movingNode} />
|
||||
}
|
||||
|
||||
// Phase 5 Stage D: registry-driven move affordance (kind-owned
|
||||
// `DragAction` with bespoke semantics). Falls through to the legacy
|
||||
// per-kind chain below when the kind hasn't ported its move tool.
|
||||
const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move')
|
||||
if (RegistryMove) {
|
||||
return (
|
||||
@@ -138,20 +56,11 @@ export const MoveTool: React.FC<{
|
||||
|
||||
if (movingNode.type === 'building')
|
||||
return <MoveBuildingContent node={movingNode as BuildingNode} />
|
||||
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
|
||||
if (movingNode.type === 'elevator')
|
||||
return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
|
||||
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
|
||||
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
|
||||
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
|
||||
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
|
||||
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
|
||||
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
|
||||
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
|
||||
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
|
||||
if (movingNode.type === 'spawn')
|
||||
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
|
||||
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
|
||||
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
|
||||
return <MoveItemContent movingNode={movingNode as ItemNode} />
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -6,12 +6,15 @@ import type {
|
||||
GridEvent,
|
||||
ItemEvent,
|
||||
ItemNode,
|
||||
ShelfEvent,
|
||||
ShelfNode,
|
||||
WallEvent,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
getScaledDimensions,
|
||||
isLowProfileItemSurface,
|
||||
nodeRegistry,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -587,6 +590,156 @@ export const itemSurfaceStrategy = {
|
||||
},
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SHELF SURFACE STRATEGY
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Resolve the row Y closest to the cursor's local Y. Reads candidate row
|
||||
* positions from the kind's `capabilities.surfaces.custom` — the shelf
|
||||
* declaration emits one `SurfacePoint` per board's top surface. The
|
||||
* strategy stays kind-agnostic at this level: any future "multi-board"
|
||||
* kind that declares `surfaces.custom` with upward normals gets the
|
||||
* same hit behaviour for free.
|
||||
*/
|
||||
function getShelfRowSurfaceY(shelfNode: ShelfNode, localY: number): number | null {
|
||||
const def = nodeRegistry.get('shelf')
|
||||
const custom = def?.capabilities?.surfaces?.custom
|
||||
if (!custom) return null
|
||||
const candidates = custom(shelfNode as AnyNode)
|
||||
if (candidates.length === 0) return null
|
||||
let best = candidates[0]
|
||||
let bestDist = Math.abs(best!.position[1] - localY)
|
||||
for (let i = 1; i < candidates.length; i++) {
|
||||
const c = candidates[i]
|
||||
if (!c) continue
|
||||
const dist = Math.abs(c.position[1] - localY)
|
||||
if (dist < bestDist) {
|
||||
best = c
|
||||
bestDist = dist
|
||||
}
|
||||
}
|
||||
return best?.position[1] ?? null
|
||||
}
|
||||
|
||||
export const shelfSurfaceStrategy = {
|
||||
/**
|
||||
* Handle shelf:enter — transition the draft onto the closest shelf
|
||||
* row. Mirrors `itemSurfaceStrategy.enter` but reads candidate
|
||||
* surface heights from the shelf kind's `surfaces.custom` (one Y per
|
||||
* board) instead of `asset.surface.height`. Picks the row whose
|
||||
* surface Y is nearest the cursor's local Y so the user can target a
|
||||
* specific row by hovering near it.
|
||||
*/
|
||||
enter(ctx: PlacementContext, event: ShelfEvent): TransitionResult | null {
|
||||
if (ctx.asset.attachTo) return null
|
||||
const shelfNode = event.node as ShelfNode
|
||||
|
||||
if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId === shelfNode.id) {
|
||||
return null
|
||||
}
|
||||
if (!isUpwardShelfSurfaceHit(event)) return null
|
||||
|
||||
// Size check: draft footprint must fit on the shelf board (width × depth).
|
||||
const ourDims = ctx.draftItem
|
||||
? getScaledDimensions(ctx.draftItem)
|
||||
: (ctx.asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
if (ourDims[0] > shelfNode.width || ourDims[2] > shelfNode.depth) return null
|
||||
|
||||
const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
|
||||
if (!shelfMesh) return null
|
||||
|
||||
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
||||
const localPos = shelfMesh.worldToLocal(worldPos)
|
||||
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
|
||||
if (rowY === null) return null
|
||||
|
||||
const x = snapToGrid(localPos.x, ourDims[0])
|
||||
const z = snapToGrid(localPos.z, ourDims[2])
|
||||
|
||||
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
|
||||
|
||||
const surfaceQuat = new Quaternion()
|
||||
shelfMesh.getWorldQuaternion(surfaceQuat)
|
||||
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
|
||||
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
|
||||
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
|
||||
|
||||
return {
|
||||
stateUpdate: { surface: 'shelf-surface', shelfId: shelfNode.id },
|
||||
nodeUpdate: {
|
||||
position: [x, rowY, z],
|
||||
parentId: shelfNode.id,
|
||||
rotation: [draftRotation[0], localRotationY, draftRotation[2]],
|
||||
},
|
||||
cursorRotationY: ctx.currentCursorRotationY,
|
||||
gridPosition: [x, rowY, z],
|
||||
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||||
stopPropagation: true,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle shelf:move — re-derive the closest row each tick so the user
|
||||
* can slide between rows without leaving the shelf.
|
||||
*/
|
||||
move(ctx: PlacementContext, event: ShelfEvent): PlacementResult | null {
|
||||
if (ctx.state.surface !== 'shelf-surface') return null
|
||||
if (!(ctx.state.shelfId && ctx.draftItem)) return null
|
||||
if (event.node.id !== ctx.state.shelfId) return null
|
||||
|
||||
const shelfNode = event.node as ShelfNode
|
||||
const shelfMesh = sceneRegistry.nodes.get(shelfNode.id)
|
||||
if (!shelfMesh) return null
|
||||
|
||||
const ourDims = getScaledDimensions(ctx.draftItem)
|
||||
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
||||
const localPos = shelfMesh.worldToLocal(worldPos)
|
||||
const rowY = getShelfRowSurfaceY(shelfNode, localPos.y)
|
||||
if (rowY === null) return null
|
||||
|
||||
const x = snapToGrid(localPos.x, ourDims[0])
|
||||
const z = snapToGrid(localPos.z, ourDims[2])
|
||||
const worldSnapped = shelfMesh.localToWorld(new Vector3(x, rowY, z))
|
||||
|
||||
return {
|
||||
gridPosition: [x, rowY, z],
|
||||
cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z],
|
||||
cursorRotationY: ctx.currentCursorRotationY,
|
||||
nodeUpdate: { position: [x, rowY, z] },
|
||||
stopPropagation: true,
|
||||
dirtyNodeId: null,
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle shelf:click — commit placement on the active row.
|
||||
*/
|
||||
click(ctx: PlacementContext, event: ShelfEvent): CommitResult | null {
|
||||
if (ctx.state.surface !== 'shelf-surface') return null
|
||||
if (!(ctx.draftItem && ctx.state.shelfId)) return null
|
||||
if (event.node.id !== ctx.state.shelfId) return null
|
||||
|
||||
return {
|
||||
nodeUpdate: {
|
||||
position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z],
|
||||
parentId: ctx.state.shelfId,
|
||||
metadata: stripTransient(ctx.draftItem.metadata),
|
||||
},
|
||||
stopPropagation: true,
|
||||
dirtyNodeId: null,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed
|
||||
* for `ShelfEvent`. Re-uses the matrix-driven world normal calculation
|
||||
* via a tiny `ItemEvent`-shaped adapter — the function only reads
|
||||
* `event.normal` + `event.object`. */
|
||||
function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean {
|
||||
return isUpwardItemSurfaceHit(event as unknown as ItemEvent)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VALIDATION
|
||||
// ============================================================================
|
||||
@@ -603,6 +756,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato
|
||||
return ctx.state.surfaceItemId !== null
|
||||
}
|
||||
|
||||
// Shelf surface: same — size check already happened on enter
|
||||
if (ctx.state.surface === 'shelf-surface') {
|
||||
return ctx.state.shelfId !== null
|
||||
}
|
||||
|
||||
const attachTo = ctx.draftItem.asset.attachTo
|
||||
|
||||
const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo)
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { Vector3 } from 'three'
|
||||
// PLACEMENT STATE
|
||||
// ============================================================================
|
||||
|
||||
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface'
|
||||
export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface'
|
||||
|
||||
/**
|
||||
* Tracks which surface the draft item is currently on.
|
||||
@@ -23,6 +23,13 @@ export interface PlacementState {
|
||||
wallId: string | null
|
||||
ceilingId: string | null
|
||||
surfaceItemId: string | null
|
||||
/**
|
||||
* Active shelf when `surface === 'shelf-surface'`. Items host on the
|
||||
* shelf board closest to the cursor's local Y; the row index isn't
|
||||
* stored separately because every move re-derives it from cursor
|
||||
* position via `shelfRowSurfaceYs`.
|
||||
*/
|
||||
shelfId: string | null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -179,9 +179,28 @@ export function useDraftNode(): DraftNodeHandle {
|
||||
if (!draftRef.current) return
|
||||
|
||||
if (adoptedRef.current && originalStateRef.current) {
|
||||
// Move mode: restore original state instead of deleting
|
||||
// Move mode: restore original state instead of deleting — but only
|
||||
// if no other system has already committed a new position for this
|
||||
// node. The 2D `FloorplanRegistryMoveOverlay` commits via
|
||||
// `useScene.updateNodes` before unmounting the legacy mover, and
|
||||
// an unconditional restore here would wipe that commit. By
|
||||
// comparing the live state to the snapshot we took in `adopt()`,
|
||||
// we let an external committer's write stick.
|
||||
const original = originalStateRef.current
|
||||
const id = draftRef.current.id
|
||||
const live = useScene.getState().nodes[id as AnyNodeId] as ItemNode | undefined
|
||||
const livePosition = live?.position
|
||||
const externallyMoved =
|
||||
!!livePosition &&
|
||||
(livePosition[0] !== original.position[0] ||
|
||||
livePosition[1] !== original.position[1] ||
|
||||
livePosition[2] !== original.position[2])
|
||||
if (externallyMoved) {
|
||||
draftRef.current = null
|
||||
adoptedRef.current = false
|
||||
originalStateRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
useScene.getState().updateNode(id, {
|
||||
position: original.position,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getScaledDimensions,
|
||||
type ItemEvent,
|
||||
resolveLevelId,
|
||||
type ShelfEvent,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useLiveTransforms,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
checkCanPlace,
|
||||
floorStrategy,
|
||||
itemSurfaceStrategy,
|
||||
shelfSurfaceStrategy,
|
||||
wallStrategy,
|
||||
} from './placement-strategies'
|
||||
import type { PlacementState, TransitionResult } from './placement-types'
|
||||
@@ -286,7 +288,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
const gridPosition = useRef(new Vector3(0, 0, 0))
|
||||
const lastRawPos = useRef(new Vector3(0, 0, 0))
|
||||
const placementState = useRef<PlacementState>(
|
||||
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
|
||||
config.initialState ?? {
|
||||
surface: 'floor',
|
||||
wallId: null,
|
||||
ceilingId: null,
|
||||
surfaceItemId: null,
|
||||
shelfId: null,
|
||||
},
|
||||
)
|
||||
const shiftFreeRef = useRef(false)
|
||||
const previewBoundsSignatureRef = useRef<string | null>(null)
|
||||
@@ -435,6 +443,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
wallId: null,
|
||||
ceilingId: null,
|
||||
surfaceItemId: null,
|
||||
shelfId: null,
|
||||
}
|
||||
if (!asset.attachTo && placementState.current.surface === 'floor') {
|
||||
gridPosition.current.y = 0
|
||||
@@ -923,7 +932,67 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
}
|
||||
|
||||
const onItemClick = (event: ItemEvent) => {
|
||||
if (event.node.id === draftNode.current?.id) return
|
||||
// Click on the draft item itself. R3F dispatches click events to
|
||||
// the closest intersected mesh only — when the draft is hovering
|
||||
// on a host (shelf / table / etc.) the draft's mesh is *above*
|
||||
// the host's mesh, so the host's `${kind}:click` never fires.
|
||||
// If we're currently hosting on a shelf-surface, treat the
|
||||
// self-click as a commit on the active shelf so the user doesn't
|
||||
// have to aim around the cursor preview to drop the item.
|
||||
if (event.node.id === draftNode.current?.id) {
|
||||
const ctx = getContext()
|
||||
if (ctx.state.surface === 'shelf-surface' && ctx.state.shelfId) {
|
||||
const shelfNode = useScene.getState().nodes[ctx.state.shelfId as AnyNodeId]
|
||||
if (shelfNode && shelfNode.type === 'shelf') {
|
||||
const synthetic = { ...event, node: shelfNode } as unknown as ItemEvent
|
||||
const result = shelfSurfaceStrategy.click(ctx, synthetic as never)
|
||||
if (result) {
|
||||
event.stopPropagation()
|
||||
if (draftNode.current) {
|
||||
useLiveTransforms.getState().clear(draftNode.current.id)
|
||||
}
|
||||
draftNode.commit(result.nodeUpdate)
|
||||
if (configRef.current.onCommitted()) {
|
||||
const enterResult = shelfSurfaceStrategy.enter(ctx, synthetic as never)
|
||||
if (enterResult) {
|
||||
applyTransition(enterResult)
|
||||
} else {
|
||||
revalidate()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Same self-click forwarding for item-surface hosts (tables,
|
||||
// counters) — the draft mesh sits on top of the host mesh, so
|
||||
// the host's own click event is blocked by the cursor preview.
|
||||
if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId) {
|
||||
const hostNode = useScene.getState().nodes[ctx.state.surfaceItemId as AnyNodeId]
|
||||
if (hostNode && hostNode.type === 'item') {
|
||||
const synthetic = { ...event, node: hostNode } as ItemEvent
|
||||
const result = itemSurfaceStrategy.click(ctx, synthetic)
|
||||
if (result) {
|
||||
event.stopPropagation()
|
||||
if (draftNode.current) {
|
||||
useLiveTransforms.getState().clear(draftNode.current.id)
|
||||
}
|
||||
draftNode.commit(result.nodeUpdate)
|
||||
if (configRef.current.onCommitted()) {
|
||||
const enterResult = itemSurfaceStrategy.enter(ctx, synthetic)
|
||||
if (enterResult) {
|
||||
applyTransition(enterResult)
|
||||
} else {
|
||||
revalidate()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const result = itemSurfaceStrategy.click(getContext(), event)
|
||||
if (!result) return
|
||||
|
||||
@@ -1065,6 +1134,98 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Shelf Handlers ----
|
||||
//
|
||||
// Items can host on shelves the same way they host on tables and
|
||||
// counters (item-surface). The shelf's `surfaces.custom` exposes one
|
||||
// candidate Y per row; `shelfSurfaceStrategy` picks the closest one
|
||||
// to the cursor's local-Y so the user can target a specific row.
|
||||
|
||||
const onShelfEnter = (event: ShelfEvent) => {
|
||||
const result = shelfSurfaceStrategy.enter(getContext(), event)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
applyTransition(result)
|
||||
|
||||
if (!draftNode.current) {
|
||||
ensureDraft(result)
|
||||
} else if (result.nodeUpdate.parentId) {
|
||||
useScene.getState().updateNode(draftNode.current.id, result.nodeUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
const onShelfMove = (event: ShelfEvent) => {
|
||||
const ctx = getContext()
|
||||
if (ctx.state.surface !== 'shelf-surface') {
|
||||
// Cursor entered via a move event without an enter — try
|
||||
// transitioning in so the user doesn't need to mouse out + back
|
||||
// in to start hosting.
|
||||
const enterResult = shelfSurfaceStrategy.enter(ctx, event)
|
||||
if (!enterResult) return
|
||||
event.stopPropagation()
|
||||
applyTransition(enterResult)
|
||||
if (!draftNode.current) {
|
||||
ensureDraft(enterResult)
|
||||
} else if (enterResult.nodeUpdate.parentId) {
|
||||
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
|
||||
}
|
||||
return
|
||||
}
|
||||
const result = shelfSurfaceStrategy.move(ctx, event)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
|
||||
gridPosition.current.set(...result.gridPosition)
|
||||
const ic = worldToBuildingLocal(...result.cursorPosition)
|
||||
cursorGroupRef.current.position.set(ic.x, ic.y, ic.z)
|
||||
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
||||
|
||||
const draft = draftNode.current
|
||||
if (draft) {
|
||||
draft.position = result.gridPosition
|
||||
const mesh = sceneRegistry.nodes.get(draft.id)
|
||||
if (mesh) mesh.position.set(...result.gridPosition)
|
||||
useLiveTransforms.getState().set(draft.id, {
|
||||
position: result.cursorPosition,
|
||||
rotation: result.cursorRotationY,
|
||||
})
|
||||
}
|
||||
|
||||
revalidate()
|
||||
}
|
||||
|
||||
const onShelfLeave = (event: ShelfEvent) => {
|
||||
if (placementState.current.surface !== 'shelf-surface') return
|
||||
if (event.node.id !== placementState.current.shelfId) return
|
||||
event.stopPropagation()
|
||||
// Drop back to floor — same pattern as item-leave but without the
|
||||
// detachItemSurfaceToFloor (no scaled rotation hand-off to deal
|
||||
// with since the shelf rotation already composed cleanly).
|
||||
Object.assign(placementState.current, { surface: 'floor', shelfId: null })
|
||||
}
|
||||
|
||||
const onShelfClick = (event: ShelfEvent) => {
|
||||
const result = shelfSurfaceStrategy.click(getContext(), event)
|
||||
if (!result) return
|
||||
|
||||
event.stopPropagation()
|
||||
if (draftNode.current) {
|
||||
useLiveTransforms.getState().clear(draftNode.current.id)
|
||||
}
|
||||
draftNode.commit(result.nodeUpdate)
|
||||
|
||||
if (configRef.current.onCommitted()) {
|
||||
const enterResult = shelfSurfaceStrategy.enter(getContext(), event)
|
||||
if (enterResult) {
|
||||
applyTransition(enterResult)
|
||||
} else {
|
||||
revalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Keyboard rotation ----
|
||||
|
||||
const ROTATION_STEP = Math.PI / 2
|
||||
@@ -1239,6 +1400,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
emitter.on('ceiling:move', onCeilingMove)
|
||||
emitter.on('ceiling:click', onCeilingClick)
|
||||
emitter.on('ceiling:leave', onCeilingLeave)
|
||||
emitter.on('shelf:enter', onShelfEnter)
|
||||
emitter.on('shelf:move', onShelfMove)
|
||||
emitter.on('shelf:click', onShelfClick)
|
||||
emitter.on('shelf:leave', onShelfLeave)
|
||||
|
||||
return () => {
|
||||
tearingDown = true
|
||||
@@ -1263,6 +1428,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
emitter.off('ceiling:move', onCeilingMove)
|
||||
emitter.off('ceiling:click', onCeilingClick)
|
||||
emitter.off('ceiling:leave', onCeilingLeave)
|
||||
emitter.off('shelf:enter', onShelfEnter)
|
||||
emitter.off('shelf:move', onShelfMove)
|
||||
emitter.off('shelf:click', onShelfClick)
|
||||
emitter.off('shelf:leave', onShelfLeave)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
type SlabNode,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} 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 { snapFenceDraftPoint } from '../fence/fence-drafting'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
function translatePolygon(
|
||||
polygon: Array<[number, number]>,
|
||||
deltaX: number,
|
||||
deltaZ: number,
|
||||
): Array<[number, number]> {
|
||||
return polygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number])
|
||||
}
|
||||
|
||||
function getPolygonCenter(polygon: Array<[number, number]>): [number, number] {
|
||||
if (polygon.length === 0) return [0, 0]
|
||||
let sumX = 0
|
||||
let sumZ = 0
|
||||
for (const [x, z] of polygon) {
|
||||
sumX += x
|
||||
sumZ += z
|
||||
}
|
||||
return [sumX / polygon.length, sumZ / polygon.length]
|
||||
}
|
||||
|
||||
export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const originalPolygonRef = useRef(node.polygon.map(([x, z]) => [x, z] as [number, number]))
|
||||
const originalHolesRef = useRef(
|
||||
(node.holes ?? []).map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
|
||||
)
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const previewRef = useRef<{
|
||||
polygon: Array<[number, number]>
|
||||
holes: Array<Array<[number, number]>>
|
||||
} | null>(null)
|
||||
|
||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
||||
const center = getPolygonCenter(node.polygon)
|
||||
return [center[0], 0, center[1]]
|
||||
})
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const originalPolygon = originalPolygonRef.current
|
||||
const originalHoles = originalHolesRef.current
|
||||
const levelNode =
|
||||
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
|
||||
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
|
||||
: null
|
||||
const levelChildren = levelNode?.children ?? []
|
||||
const levelWalls = levelChildren
|
||||
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
||||
.filter((child): child is WallNode => child?.type === 'wall')
|
||||
const levelFences = levelChildren
|
||||
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
||||
.filter((child): child is FenceNode => child?.type === 'fence')
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
let wasCommitted = false
|
||||
|
||||
const applyPreview = (
|
||||
polygon: Array<[number, number]>,
|
||||
holes: Array<Array<[number, number]>>,
|
||||
) => {
|
||||
previewRef.current = { polygon, holes }
|
||||
const center = getPolygonCenter(polygon)
|
||||
setCursorLocalPos([center[0], 0, center[1]])
|
||||
useScene.getState().updateNode(node.id, { polygon, holes })
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
}
|
||||
|
||||
const restoreOriginal = () => {
|
||||
useScene.getState().updateNode(node.id, {
|
||||
holes: originalHoles,
|
||||
polygon: originalPolygon,
|
||||
})
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const [localX, localZ] = snapFenceDraftPoint({
|
||||
point: [event.localPosition[0], event.localPosition[2]],
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
})
|
||||
|
||||
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]
|
||||
|
||||
applyPreview(
|
||||
translatePolygon(originalPolygon, deltaX, deltaZ),
|
||||
originalHoles.map((hole) => translatePolygon(hole, deltaX, deltaZ)),
|
||||
)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const preview = previewRef.current ?? { polygon: originalPolygon, holes: originalHoles }
|
||||
|
||||
wasCommitted = true
|
||||
|
||||
// Restore original baseline while paused so the next resume+update
|
||||
// registers as a single tracked change (undo reverts to original).
|
||||
useScene.getState().updateNode(node.id, {
|
||||
polygon: originalPolygon,
|
||||
holes: originalHoles,
|
||||
})
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(node.id, preview)
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
restoreOriginal()
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [exitMoveMode, node.id])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
import { PolygonEditor } from '../shared/polygon-editor'
|
||||
|
||||
interface SlabBoundaryEditorProps {
|
||||
slabId: SlabNode['id']
|
||||
}
|
||||
|
||||
/**
|
||||
* Slab boundary editor - allows editing slab polygon vertices for a specific slab
|
||||
* Uses the generic PolygonEditor component
|
||||
*/
|
||||
export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }) => {
|
||||
const slabNode = useScene((state) => state.nodes[slabId])
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
|
||||
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
|
||||
|
||||
const handlePolygonChange = useCallback(
|
||||
(newPolygon: Array<[number, number]>) => {
|
||||
updateNode(slabId, { polygon: newPolygon })
|
||||
// Re-assert selection so the slab stays selected after the edit
|
||||
setSelection({ selectedIds: [slabId] })
|
||||
},
|
||||
[slabId, updateNode, setSelection],
|
||||
)
|
||||
|
||||
if (!slab?.polygon || slab.polygon.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
allowEdgeMove
|
||||
color="#a3a3a3"
|
||||
levelId={resolveLevelId(slab, useScene.getState().nodes)}
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={slab.polygon}
|
||||
surfaceHeight={slab.elevation ?? 0.05}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { resolveLevelId, type SlabNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback } from 'react'
|
||||
import { PolygonEditor } from '../shared/polygon-editor'
|
||||
|
||||
interface SlabHoleEditorProps {
|
||||
slabId: SlabNode['id']
|
||||
holeIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Slab hole editor - allows editing a specific hole polygon within a slab
|
||||
* Uses the generic PolygonEditor component
|
||||
*/
|
||||
export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeIndex }) => {
|
||||
const slabNode = useScene((state) => state.nodes[slabId])
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
|
||||
const slab = slabNode?.type === 'slab' ? (slabNode as SlabNode) : null
|
||||
const holes = slab?.holes || []
|
||||
const hole = holes[holeIndex]
|
||||
|
||||
const handlePolygonChange = useCallback(
|
||||
(newPolygon: Array<[number, number]>) => {
|
||||
const updatedHoles = [...holes]
|
||||
updatedHoles[holeIndex] = newPolygon
|
||||
updateNode(slabId, { holes: updatedHoles })
|
||||
// Re-assert selection so the slab stays selected after the edit
|
||||
setSelection({ selectedIds: [slabId] })
|
||||
},
|
||||
[slabId, holeIndex, holes, updateNode, setSelection],
|
||||
)
|
||||
|
||||
if (!(slab && hole) || hole.length < 3) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
allowEdgeMove
|
||||
allowPolygonMove
|
||||
color="#ef4444"
|
||||
levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
|
||||
minVertices={3}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={hole}
|
||||
surfaceHeight={slab.elevation ?? 0.05}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, 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'
|
||||
|
||||
const Y_OFFSET = 0.02
|
||||
|
||||
/**
|
||||
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
||||
*/
|
||||
const calculateSnapPoint = (
|
||||
lastPoint: [number, number],
|
||||
currentPoint: [number, number],
|
||||
): [number, number] => {
|
||||
const [x1, y1] = lastPoint
|
||||
const [x, y] = currentPoint
|
||||
|
||||
const dx = x - x1
|
||||
const dy = y - y1
|
||||
const absDx = Math.abs(dx)
|
||||
const absDy = Math.abs(dy)
|
||||
|
||||
// Calculate distances to horizontal, vertical, and diagonal lines
|
||||
const horizontalDist = absDy
|
||||
const verticalDist = absDx
|
||||
const diagonalDist = Math.abs(absDx - absDy)
|
||||
|
||||
// Find the minimum distance to determine which axis to snap to
|
||||
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
|
||||
|
||||
if (minDist === diagonalDist) {
|
||||
// Snap to 45° diagonal
|
||||
const diagonalLength = Math.min(absDx, absDy)
|
||||
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
||||
}
|
||||
if (minDist === horizontalDist) {
|
||||
// Snap to horizontal
|
||||
return [x, y1]
|
||||
}
|
||||
// Snap to vertical
|
||||
return [x1, y]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a slab with the given polygon points and returns its ID
|
||||
*/
|
||||
const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
|
||||
// Count existing slabs for naming
|
||||
const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length
|
||||
const name = `Slab ${slabCount + 1}`
|
||||
|
||||
const slab = SlabNode.parse({
|
||||
name,
|
||||
polygon: points,
|
||||
})
|
||||
|
||||
createNode(slab, levelId)
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
return slab.id
|
||||
}
|
||||
|
||||
export const SlabTool: React.FC = () => {
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const mainLineRef = useRef<Line>(null!)
|
||||
const closingLineRef = useRef<Line>(null!)
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
|
||||
const [points, setPoints] = useState<Array<[number, number]>>([])
|
||||
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
||||
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
|
||||
const [levelY, setLevelY] = useState(0)
|
||||
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
||||
const shiftPressed = useRef(false)
|
||||
|
||||
// Update cursor position and lines on grid move
|
||||
useEffect(() => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!cursorRef.current) return
|
||||
|
||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
||||
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
||||
const gridPosition: [number, number] = [gridX, gridZ]
|
||||
|
||||
setCursorPosition(gridPosition)
|
||||
setLevelY(event.localPosition[1])
|
||||
|
||||
// Calculate snapped display position (bypass snap when Shift is held)
|
||||
const lastPoint = points[points.length - 1]
|
||||
const displayPoint =
|
||||
shiftPressed.current || !lastPoint
|
||||
? gridPosition
|
||||
: calculateSnapPoint(lastPoint, gridPosition)
|
||||
setSnappedCursorPosition(displayPoint)
|
||||
|
||||
// Play snap sound when the snapped position actually changes (only when drawing)
|
||||
if (
|
||||
points.length > 0 &&
|
||||
previousSnappedPointRef.current &&
|
||||
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
|
||||
displayPoint[1] !== previousSnappedPointRef.current[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
previousSnappedPointRef.current = displayPoint
|
||||
cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1])
|
||||
}
|
||||
|
||||
const onGridClick = (_event: GridEvent) => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Use the last displayed snapped position (respects Shift state from onGridMove)
|
||||
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
|
||||
|
||||
// Check if clicking on the first point to close the shape
|
||||
const firstPoint = points[0]
|
||||
if (
|
||||
points.length >= 3 &&
|
||||
firstPoint &&
|
||||
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
||||
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
||||
) {
|
||||
// Create the slab and select it
|
||||
const slabId = commitSlabDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [slabId] })
|
||||
setPoints([])
|
||||
} else {
|
||||
// Add point to polygon
|
||||
setPoints([...points, clickPoint])
|
||||
}
|
||||
}
|
||||
|
||||
const onGridDoubleClick = (_event: GridEvent) => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Need at least 3 points to form a polygon
|
||||
if (points.length >= 3) {
|
||||
const slabId = commitSlabDrawing(currentLevelId, points)
|
||||
setSelection({ selectedIds: [slabId] })
|
||||
setPoints([])
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
if (points.length > 0) markToolCancelConsumed()
|
||||
setPoints([])
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = true
|
||||
}
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') shiftPressed.current = false
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('keyup', onKeyUp)
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('grid:double-click', onGridDoubleClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('keyup', onKeyUp)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('grid:double-click', onGridDoubleClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [currentLevelId, points, cursorPosition, setSelection])
|
||||
|
||||
// Update line geometries when points change
|
||||
useEffect(() => {
|
||||
if (!(mainLineRef.current && closingLineRef.current)) return
|
||||
|
||||
if (points.length === 0) {
|
||||
mainLineRef.current.visible = false
|
||||
closingLineRef.current.visible = false
|
||||
return
|
||||
}
|
||||
|
||||
const y = levelY + Y_OFFSET
|
||||
const snappedCursor = snappedCursorPosition
|
||||
|
||||
// Build main line points
|
||||
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, y, z))
|
||||
linePoints.push(new Vector3(snappedCursor[0], y, snappedCursor[1]))
|
||||
|
||||
// Update main line
|
||||
if (linePoints.length >= 2) {
|
||||
mainLineRef.current.geometry.dispose()
|
||||
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
|
||||
mainLineRef.current.visible = true
|
||||
} else {
|
||||
mainLineRef.current.visible = false
|
||||
}
|
||||
|
||||
// Update closing line (from cursor back to first point)
|
||||
const firstPoint = points[0]
|
||||
if (points.length >= 2 && firstPoint) {
|
||||
const closingPoints = [
|
||||
new Vector3(snappedCursor[0], y, snappedCursor[1]),
|
||||
new Vector3(firstPoint[0], y, firstPoint[1]),
|
||||
]
|
||||
closingLineRef.current.geometry.dispose()
|
||||
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
|
||||
closingLineRef.current.visible = true
|
||||
} else {
|
||||
closingLineRef.current.visible = false
|
||||
}
|
||||
}, [points, snappedCursorPosition, levelY])
|
||||
|
||||
// Create preview shape when we have 3+ points
|
||||
const previewShape = useMemo(() => {
|
||||
if (points.length < 3) return null
|
||||
|
||||
const snappedCursor = snappedCursorPosition
|
||||
|
||||
const allPoints = [...points, snappedCursor]
|
||||
|
||||
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
|
||||
// - Shape X -> World X
|
||||
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
|
||||
const firstPt = allPoints[0]
|
||||
if (!firstPt) return null
|
||||
|
||||
const shape = new Shape()
|
||||
shape.moveTo(firstPt[0], -firstPt[1])
|
||||
|
||||
for (let i = 1; i < allPoints.length; i++) {
|
||||
const pt = allPoints[i]
|
||||
if (pt) {
|
||||
shape.lineTo(pt[0], -pt[1])
|
||||
}
|
||||
}
|
||||
shape.closePath()
|
||||
|
||||
return shape
|
||||
}, [points, snappedCursorPosition])
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Cursor */}
|
||||
<CursorSphere ref={cursorRef} />
|
||||
|
||||
{/* Preview fill */}
|
||||
{previewShape && (
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
position={[0, levelY + Y_OFFSET, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<shapeGeometry args={[previewShape]} />
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
opacity={0.15}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Main line */}
|
||||
{/* @ts-ignore */}
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
// @ts-expect-error
|
||||
ref={mainLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial color="#818cf8" depthTest={false} depthWrite={false} linewidth={3} />
|
||||
</line>
|
||||
|
||||
{/* Closing line */}
|
||||
{/* @ts-ignore */}
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
// @ts-expect-error
|
||||
ref={closingLineRef}
|
||||
renderOrder={1}
|
||||
visible={false}
|
||||
>
|
||||
<bufferGeometry />
|
||||
<lineBasicNodeMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={2}
|
||||
opacity={0.5}
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
|
||||
{/* Point markers */}
|
||||
{points.map(([x, z], index) => (
|
||||
<CursorSphere
|
||||
color="#818cf8"
|
||||
height={0}
|
||||
key={index}
|
||||
position={[x, levelY + Y_OFFSET + 0.01, z]}
|
||||
showTooltip={false}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import '../../../three-types'
|
||||
|
||||
import {
|
||||
emitter,
|
||||
type GridEvent,
|
||||
type SpawnNode,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Vector3 } from 'three'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
||||
const worldVector = new Vector3()
|
||||
|
||||
function getLevelLocalSpawnPosition(node: SpawnNode, event: GridEvent): [number, number, number] {
|
||||
const levelObject = node.parentId ? sceneRegistry.nodes.get(node.parentId) : null
|
||||
if (!levelObject) {
|
||||
return [
|
||||
roundToHalf(event.localPosition[0]),
|
||||
event.localPosition[1],
|
||||
roundToHalf(event.localPosition[2]),
|
||||
]
|
||||
}
|
||||
|
||||
worldVector.set(event.position[0], event.position[1], event.position[2])
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
levelObject.worldToLocal(worldVector)
|
||||
|
||||
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
|
||||
}
|
||||
|
||||
export const MoveSpawnTool: React.FC<{
|
||||
node: SpawnNode
|
||||
onCommitted?: (nodeId: SpawnNode['id']) => void
|
||||
}> = ({ node, onCommitted }) => {
|
||||
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
let committed = false
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const nextPosition: [number, number, number] = [
|
||||
roundToHalf(event.localPosition[0]),
|
||||
event.localPosition[1],
|
||||
roundToHalf(event.localPosition[2]),
|
||||
]
|
||||
setPreviewPosition(nextPosition)
|
||||
useLiveTransforms.getState().set(node.id, {
|
||||
position: [...nextPosition],
|
||||
rotation: node.rotation,
|
||||
})
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const nextPosition = getLevelLocalSpawnPosition(node, event)
|
||||
|
||||
committed = true
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(node.id, { position: nextPosition })
|
||||
onCommitted?.(node.id)
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
useScene.temporal.getState().resume()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
if (!committed) {
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}
|
||||
}, [exitMoveMode, node, onCommitted])
|
||||
|
||||
return (
|
||||
<CursorSphere color="#60a5fa" height={2.2} position={previewPosition} showTooltip={false} />
|
||||
)
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import '../../../three-types'
|
||||
|
||||
import {
|
||||
emitter,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
SpawnNode,
|
||||
type SpawnNode as SpawnNodeType,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Group } from 'three'
|
||||
import { Vector3 } from 'three'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const SPAWN_ICON = (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt="Spawn Point"
|
||||
src="/icons/site.png"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
)
|
||||
|
||||
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
||||
const worldVector = new Vector3()
|
||||
|
||||
function getExistingSpawnIds() {
|
||||
const nodes = useScene.getState().nodes
|
||||
return Object.values(nodes)
|
||||
.filter((node) => node.type === 'spawn')
|
||||
.map((node) => node.id)
|
||||
.sort()
|
||||
}
|
||||
|
||||
function getLevelLocalSpawnPosition(
|
||||
levelId: LevelNode['id'],
|
||||
event: GridEvent,
|
||||
): [number, number, number] {
|
||||
const levelObject = sceneRegistry.nodes.get(levelId)
|
||||
if (!levelObject) {
|
||||
return [
|
||||
roundToHalf(event.localPosition[0]),
|
||||
event.localPosition[1],
|
||||
roundToHalf(event.localPosition[2]),
|
||||
]
|
||||
}
|
||||
|
||||
worldVector.set(event.position[0], event.position[1], event.position[2])
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
levelObject.worldToLocal(worldVector)
|
||||
|
||||
return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)]
|
||||
}
|
||||
|
||||
type SpawnToolProps = {
|
||||
currentLevelId: LevelNode['id'] | null
|
||||
onPlaced?: (spawnId: SpawnNodeType['id']) => void
|
||||
}
|
||||
|
||||
export const SpawnTool: React.FC<SpawnToolProps> = ({ currentLevelId, onPlaced }) => {
|
||||
const [, setCursorPosition] = useState<[number, number, number] | null>(null)
|
||||
const cursorRef = useRef<Group>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const nextPosition: [number, number, number] = [
|
||||
roundToHalf(event.localPosition[0]),
|
||||
event.localPosition[1],
|
||||
roundToHalf(event.localPosition[2]),
|
||||
]
|
||||
setCursorPosition(nextPosition)
|
||||
cursorRef.current?.position.set(nextPosition[0], nextPosition[1], nextPosition[2])
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const nextPosition = getLevelLocalSpawnPosition(currentLevelId, event)
|
||||
|
||||
const [existingSpawnId, ...duplicateSpawnIds] = getExistingSpawnIds()
|
||||
if (existingSpawnId) {
|
||||
useScene.getState().updateNode(existingSpawnId, {
|
||||
parentId: currentLevelId,
|
||||
position: nextPosition,
|
||||
rotation: 0,
|
||||
})
|
||||
if (duplicateSpawnIds.length > 0) {
|
||||
useScene.getState().deleteNodes(duplicateSpawnIds)
|
||||
}
|
||||
onPlaced?.(existingSpawnId)
|
||||
} else {
|
||||
const spawn = SpawnNode.parse({
|
||||
name: 'Spawn Point',
|
||||
position: nextPosition,
|
||||
rotation: 0,
|
||||
})
|
||||
useScene.getState().createNode(spawn, currentLevelId)
|
||||
onPlaced?.(spawn.id)
|
||||
}
|
||||
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
useEditor.getState().setTool(null)
|
||||
useEditor.getState().setMode('select')
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
}
|
||||
}, [currentLevelId, onPlaced])
|
||||
|
||||
if (!currentLevelId) return null
|
||||
|
||||
return (
|
||||
<CursorSphere
|
||||
color="#60a5fa"
|
||||
height={2.2}
|
||||
ref={cursorRef}
|
||||
showTooltip
|
||||
tooltipContent={SPAWN_ICON}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -9,29 +9,13 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { type ComponentType, lazy, Suspense } from 'react'
|
||||
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
|
||||
import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
||||
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
||||
import { CeilingTool } from './ceiling/ceiling-tool'
|
||||
import { ColumnTool } from './column/column-tool'
|
||||
import { DoorTool } from './door/door-tool'
|
||||
import { ElevatorTool } from './elevator/elevator-tool'
|
||||
import { CurveFenceTool } from './fence/curve-fence-tool'
|
||||
import { FenceTool } from './fence/fence-tool'
|
||||
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
|
||||
import { ItemTool } from './item/item-tool'
|
||||
import { MoveTool } from './item/move-tool'
|
||||
import { RoofTool } from './roof/roof-tool'
|
||||
import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
|
||||
import { SiteBoundaryEditor } from './site/site-boundary-editor'
|
||||
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
|
||||
import { SlabHoleEditor } from './slab/slab-hole-editor'
|
||||
import { SlabTool } from './slab/slab-tool'
|
||||
import { SpawnTool } from './spawn/spawn-tool'
|
||||
import { StairTool } from './stair/stair-tool'
|
||||
import { CurveWallTool } from './wall/curve-wall-tool'
|
||||
import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool'
|
||||
import { WallTool } from './wall/wall-tool'
|
||||
import { WindowTool } from './window/window-tool'
|
||||
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
|
||||
import { ZoneTool } from './zone/zone-tool'
|
||||
|
||||
@@ -50,25 +34,19 @@ function getRegistryTool(tool: Tool | null): ComponentType | null {
|
||||
return Comp
|
||||
}
|
||||
|
||||
// Legacy tool fallbacks — kinds whose placement tools haven't migrated
|
||||
// to `def.tool` yet. Wall / fence / slab / ceiling / door / window /
|
||||
// item / shelf / spawn now go through the registry path above.
|
||||
const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
||||
site: {
|
||||
'property-line': SiteBoundaryEditor,
|
||||
},
|
||||
structure: {
|
||||
wall: WallTool,
|
||||
fence: FenceTool,
|
||||
slab: SlabTool,
|
||||
ceiling: CeilingTool,
|
||||
roof: RoofTool,
|
||||
stair: StairTool,
|
||||
door: DoorTool,
|
||||
item: ItemTool,
|
||||
zone: ZoneTool,
|
||||
window: WindowTool,
|
||||
},
|
||||
furnish: {
|
||||
item: ItemTool,
|
||||
},
|
||||
furnish: {},
|
||||
}
|
||||
|
||||
export const ToolManager: React.FC = () => {
|
||||
@@ -146,9 +124,7 @@ export const ToolManager: React.FC = () => {
|
||||
const showBuildTool = mode === 'build' && tool !== null
|
||||
|
||||
// Registry-first: if the active tool's kind has a NodeDefinition with a
|
||||
// tool contribution, the registry-driven tool takes over. Otherwise fall
|
||||
// through to the legacy `tools` map below. Today the registry is empty so
|
||||
// RegistryToolComponent is always null — zero behavior change.
|
||||
// tool contribution, the registry-driven tool takes over.
|
||||
const RegistryToolComponent = showBuildTool ? getRegistryTool(tool) : null
|
||||
const useRegistryTool = RegistryToolComponent != null
|
||||
|
||||
@@ -187,9 +163,7 @@ export const ToolManager: React.FC = () => {
|
||||
<Suspense fallback={null}>
|
||||
<Registry slabId={selectedSlabId} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<SlabBoundaryEditor slabId={selectedSlabId} />
|
||||
)
|
||||
) : null
|
||||
})()}
|
||||
{showSlabHoleEditor &&
|
||||
selectedSlabId &&
|
||||
@@ -200,9 +174,7 @@ export const ToolManager: React.FC = () => {
|
||||
<Suspense fallback={null}>
|
||||
<Registry holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<SlabHoleEditor holeIndex={editingHole.holeIndex} slabId={selectedSlabId} />
|
||||
)
|
||||
) : null
|
||||
})()}
|
||||
{showCeilingBoundaryEditor &&
|
||||
selectedCeilingId &&
|
||||
@@ -212,9 +184,7 @@ export const ToolManager: React.FC = () => {
|
||||
<Suspense fallback={null}>
|
||||
<Registry ceilingId={selectedCeilingId} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<CeilingBoundaryEditor ceilingId={selectedCeilingId} />
|
||||
)
|
||||
) : null
|
||||
})()}
|
||||
{showCeilingHoleEditor &&
|
||||
selectedCeilingId &&
|
||||
@@ -225,9 +195,7 @@ export const ToolManager: React.FC = () => {
|
||||
<Suspense fallback={null}>
|
||||
<Registry ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
|
||||
)
|
||||
) : null
|
||||
})()}
|
||||
{movingWallEndpoint &&
|
||||
(() => {
|
||||
@@ -239,9 +207,7 @@ export const ToolManager: React.FC = () => {
|
||||
<Suspense fallback={null}>
|
||||
<RegistryAffordance target={movingWallEndpoint} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<MoveWallEndpointTool target={movingWallEndpoint} />
|
||||
)
|
||||
) : null
|
||||
})()}
|
||||
{movingFenceEndpoint &&
|
||||
(() => {
|
||||
@@ -253,9 +219,7 @@ export const ToolManager: React.FC = () => {
|
||||
<Suspense fallback={null}>
|
||||
<RegistryAffordance target={movingFenceEndpoint} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<MoveFenceEndpointTool target={movingFenceEndpoint} />
|
||||
)
|
||||
) : null
|
||||
})()}
|
||||
{curvingWall &&
|
||||
(() => {
|
||||
@@ -264,9 +228,7 @@ export const ToolManager: React.FC = () => {
|
||||
<Suspense fallback={null}>
|
||||
<Registry node={curvingWall} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<CurveWallTool node={curvingWall} />
|
||||
)
|
||||
) : null
|
||||
})()}
|
||||
{curvingFence &&
|
||||
(() => {
|
||||
@@ -275,9 +237,7 @@ export const ToolManager: React.FC = () => {
|
||||
<Suspense fallback={null}>
|
||||
<RegistryAffordance node={curvingFence} />
|
||||
</Suspense>
|
||||
) : (
|
||||
<CurveFenceTool node={curvingFence} />
|
||||
)
|
||||
) : null
|
||||
})()}
|
||||
{movingNode && movingNode.type !== 'building' && (
|
||||
<MoveTool
|
||||
@@ -286,16 +246,12 @@ export const ToolManager: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
{/* Registry-first: when the active tool's kind has a registered
|
||||
NodeDefinition with a tool contribution, mount it here. Today
|
||||
the registry is empty so this branch never fires. */}
|
||||
NodeDefinition with a tool contribution, mount it here. */}
|
||||
{!movingNode && useRegistryTool && RegistryToolComponent && (
|
||||
<Suspense fallback={null}>
|
||||
<RegistryToolComponent />
|
||||
</Suspense>
|
||||
)}
|
||||
{!movingNode && !useRegistryTool && showBuildTool && tool === 'spawn' && (
|
||||
<SpawnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
|
||||
)}
|
||||
{!movingNode && !useRegistryTool && showBuildTool && tool === 'column' && (
|
||||
<ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
|
||||
)}
|
||||
@@ -306,11 +262,7 @@ export const ToolManager: React.FC = () => {
|
||||
onPlaced={handlePlacedElevatorSelected}
|
||||
/>
|
||||
)}
|
||||
{!movingNode &&
|
||||
BuildToolComponent &&
|
||||
tool !== 'spawn' &&
|
||||
tool !== 'column' &&
|
||||
tool !== 'elevator' ? (
|
||||
{!movingNode && BuildToolComponent && tool !== 'column' && tool !== 'elevator' ? (
|
||||
<BuildToolComponent />
|
||||
) : null}
|
||||
</group>
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
getWallChordFrame,
|
||||
getWallMidpointHandlePoint,
|
||||
normalizeWallCurveOffset,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { getWallGridStep, snapScalarToGrid } from './wall-drafting'
|
||||
|
||||
export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
|
||||
const previousCurveOffsetRef = useRef<number | null>(null)
|
||||
const shiftPressedRef = useRef(false)
|
||||
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
|
||||
|
||||
const initialHandle = getWallMidpointHandlePoint(node)
|
||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
|
||||
initialHandle.x,
|
||||
0,
|
||||
initialHandle.y,
|
||||
])
|
||||
|
||||
const exitCurveMode = useCallback(() => {
|
||||
useEditor.getState().setCurvingWall(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const nodeId = node.id
|
||||
const originalCurveOffset = originalCurveOffsetRef.current
|
||||
const chord = getWallChordFrame(node)
|
||||
const maxCurveOffset = getMaxWallCurveOffset(node)
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
let wasCommitted = false
|
||||
|
||||
const applyPreview = (curveOffset: number) => {
|
||||
if (previewOffsetRef.current === curveOffset) {
|
||||
return
|
||||
}
|
||||
previewOffsetRef.current = curveOffset
|
||||
|
||||
const nextNode = {
|
||||
...node,
|
||||
curveOffset,
|
||||
}
|
||||
const handlePoint = getWallMidpointHandlePoint(nextNode)
|
||||
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
|
||||
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
}
|
||||
|
||||
const restoreOriginal = () => {
|
||||
if (previewOffsetRef.current === originalCurveOffset) {
|
||||
return
|
||||
}
|
||||
previewOffsetRef.current = originalCurveOffset
|
||||
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const snapStep = getWallGridStep()
|
||||
const localX = shiftPressedRef.current
|
||||
? event.localPosition[0]
|
||||
: snapScalarToGrid(event.localPosition[0], snapStep)
|
||||
const localZ = shiftPressedRef.current
|
||||
? event.localPosition[2]
|
||||
: snapScalarToGrid(event.localPosition[2], snapStep)
|
||||
|
||||
const offsetFromMidpoint = -(
|
||||
(localX - chord.midpoint.x) * chord.normal.x +
|
||||
(localZ - chord.midpoint.y) * chord.normal.y
|
||||
)
|
||||
const snappedOffset = shiftPressedRef.current
|
||||
? offsetFromMidpoint
|
||||
: snapScalarToGrid(offsetFromMidpoint, snapStep)
|
||||
const nextCurveOffset = normalizeWallCurveOffset(
|
||||
node,
|
||||
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
|
||||
)
|
||||
|
||||
if (
|
||||
previousCurveOffsetRef.current !== null &&
|
||||
nextCurveOffset !== previousCurveOffsetRef.current
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
previousCurveOffsetRef.current = nextCurveOffset
|
||||
|
||||
applyPreview(nextCurveOffset)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const curveOffset = previewOffsetRef.current
|
||||
wasCommitted = true
|
||||
|
||||
if (curveOffset !== originalCurveOffset) {
|
||||
// Restore original baseline while paused so the next resume+update
|
||||
// registers as a single tracked change (undo reverts to original).
|
||||
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
useScene.temporal.getState().pause()
|
||||
}
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
exitCurveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
useScene.temporal.getState().resume()
|
||||
markToolCancelConsumed()
|
||||
exitCurveMode()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = true
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
restoreOriginal()
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
}
|
||||
}, [exitCurveMode, node])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor, { type MovingWallEndpoint } from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import {
|
||||
formatAngleRadians,
|
||||
getAngleToSegmentReference,
|
||||
getSegmentAngleReferenceAtPoint,
|
||||
} from '../shared/segment-angle'
|
||||
import { isWallLongEnough, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
|
||||
|
||||
function samePoint(a: WallPlanPoint, b: WallPlanPoint) {
|
||||
return a[0] === b[0] && a[1] === b[1]
|
||||
}
|
||||
|
||||
type WallSegmentLike = {
|
||||
id: WallNode['id']
|
||||
start: WallPlanPoint
|
||||
end: WallPlanPoint
|
||||
curveOffset?: number
|
||||
}
|
||||
|
||||
type AngleLabelState = {
|
||||
label: string
|
||||
position: [number, number, number]
|
||||
} | null
|
||||
|
||||
function getEndpointAngleLabel(args: {
|
||||
preview: { start: WallPlanPoint; end: WallPlanPoint; curveOffset?: number }
|
||||
walls: WallSegmentLike[]
|
||||
nodeId: WallNode['id']
|
||||
}): AngleLabelState {
|
||||
const { preview, walls, nodeId } = args
|
||||
const endpoints = [
|
||||
{
|
||||
point: preview.start,
|
||||
},
|
||||
{
|
||||
point: preview.end,
|
||||
},
|
||||
]
|
||||
const targetSegment: WallSegmentLike = {
|
||||
id: nodeId,
|
||||
start: preview.start,
|
||||
end: preview.end,
|
||||
curveOffset: preview.curveOffset,
|
||||
}
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
const targetReference = getSegmentAngleReferenceAtPoint(endpoint.point, targetSegment)
|
||||
if (!targetReference) continue
|
||||
|
||||
const connectedWall = walls.find(
|
||||
(wall) =>
|
||||
wall.id !== nodeId && Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
|
||||
)
|
||||
if (!connectedWall) continue
|
||||
|
||||
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
|
||||
if (!connectedReference) continue
|
||||
|
||||
const angle = getAngleToSegmentReference(targetReference.vector, connectedReference)
|
||||
if (angle === null) continue
|
||||
|
||||
return {
|
||||
label: formatAngleRadians(angle),
|
||||
position: [endpoint.point[0], 0.34, endpoint.point[1]],
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
type LinkedWallSnapshot = {
|
||||
id: WallNode['id']
|
||||
start: WallPlanPoint
|
||||
end: WallPlanPoint
|
||||
curveOffset?: number
|
||||
}
|
||||
|
||||
function getLinkedWallSnapshots(args: {
|
||||
wallId: WallNode['id']
|
||||
wallParentId: string | null
|
||||
originalStart: WallPlanPoint
|
||||
originalEnd: WallPlanPoint
|
||||
}) {
|
||||
const { wallId, wallParentId, originalStart, originalEnd } = args
|
||||
const { nodes } = useScene.getState()
|
||||
const snapshots: LinkedWallSnapshot[] = []
|
||||
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (!(node?.type === 'wall' && node.id !== wallId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ((node.parentId ?? null) !== wallParentId) {
|
||||
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 WallPlanPoint,
|
||||
end: [...node.end] as WallPlanPoint,
|
||||
curveOffset: node.curveOffset,
|
||||
})
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
|
||||
function getLinkedWallUpdates(
|
||||
linkedWalls: LinkedWallSnapshot[],
|
||||
originalStart: WallPlanPoint,
|
||||
originalEnd: WallPlanPoint,
|
||||
nextStart: WallPlanPoint,
|
||||
nextEnd: WallPlanPoint,
|
||||
) {
|
||||
return linkedWalls.map((wall) => ({
|
||||
id: wall.id,
|
||||
curveOffset: wall.curveOffset,
|
||||
start: samePoint(wall.start, originalStart)
|
||||
? nextStart
|
||||
: samePoint(wall.start, originalEnd)
|
||||
? nextEnd
|
||||
: wall.start,
|
||||
end: samePoint(wall.end, originalStart)
|
||||
? nextStart
|
||||
: samePoint(wall.end, originalEnd)
|
||||
? nextEnd
|
||||
: wall.end,
|
||||
}))
|
||||
}
|
||||
|
||||
export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({ target }) => {
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const previousGridPosRef = useRef<WallPlanPoint | null>(null)
|
||||
const shiftPressedRef = useRef(false)
|
||||
const altPressedRef = useRef(false)
|
||||
const nodeIdRef = useRef(target.wall.id)
|
||||
const originalStartRef = useRef<WallPlanPoint>([...target.wall.start] as WallPlanPoint)
|
||||
const originalEndRef = useRef<WallPlanPoint>([...target.wall.end] as WallPlanPoint)
|
||||
const fixedPointRef = useRef<WallPlanPoint>(
|
||||
target.endpoint === 'start'
|
||||
? ([...target.wall.end] as WallPlanPoint)
|
||||
: ([...target.wall.start] as WallPlanPoint),
|
||||
)
|
||||
const linkedOriginalsRef = useRef(
|
||||
getLinkedWallSnapshots({
|
||||
wallId: target.wall.id,
|
||||
wallParentId: target.wall.parentId ?? null,
|
||||
originalStart: target.wall.start,
|
||||
originalEnd: target.wall.end,
|
||||
}),
|
||||
)
|
||||
const previewRef = useRef<{ start: WallPlanPoint; end: WallPlanPoint } | null>(null)
|
||||
const [angleLabel, setAngleLabel] = useState<AngleLabelState>(null)
|
||||
|
||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
||||
const point = target.endpoint === 'start' ? target.wall.start : target.wall.end
|
||||
return [point[0], 0, point[1]]
|
||||
})
|
||||
const [altPressed, setAltPressed] = useState(false)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingWallEndpoint(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const nodeId = nodeIdRef.current
|
||||
const originalStart = originalStartRef.current
|
||||
const originalEnd = originalEndRef.current
|
||||
const fixedPoint = fixedPointRef.current
|
||||
const levelWalls = Object.values(useScene.getState().nodes).filter(
|
||||
(node): node is WallNode =>
|
||||
node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
|
||||
)
|
||||
|
||||
pauseSceneHistory(useScene)
|
||||
let wasCommitted = false
|
||||
|
||||
const applyNodePreview = (
|
||||
updates: Array<{ id: WallNode['id']; start: WallPlanPoint; end: WallPlanPoint }>,
|
||||
) => {
|
||||
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 = (movingPoint: WallPlanPoint, detachLinkedWalls = false) => {
|
||||
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
||||
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
|
||||
const linkedUpdates = detachLinkedWalls
|
||||
? []
|
||||
: getLinkedWallUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
)
|
||||
previewRef.current = { start: nextStart, end: nextEnd }
|
||||
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
||||
setAngleLabel(
|
||||
getEndpointAngleLabel({
|
||||
preview: { start: nextStart, end: nextEnd, curveOffset: target.wall.curveOffset },
|
||||
walls: [
|
||||
...levelWalls.map((wall) => ({
|
||||
id: wall.id,
|
||||
start: wall.start,
|
||||
end: wall.end,
|
||||
curveOffset: wall.curveOffset,
|
||||
})),
|
||||
...linkedUpdates,
|
||||
],
|
||||
nodeId,
|
||||
}),
|
||||
)
|
||||
applyNodePreview([{ id: nodeId, start: nextStart, end: nextEnd }, ...linkedUpdates])
|
||||
}
|
||||
|
||||
const restoreOriginal = (clearAngleLabel = true) => {
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
if (clearAngleLabel) {
|
||||
setAngleLabel(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const planPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
const snappedPoint = snapWallDraftPoint({
|
||||
point: planPoint,
|
||||
walls: levelWalls,
|
||||
start: fixedPoint,
|
||||
angleSnap: !shiftPressedRef.current,
|
||||
ignoreWallIds: [nodeId],
|
||||
})
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
(snappedPoint[0] !== previousGridPosRef.current[0] ||
|
||||
snappedPoint[1] !== previousGridPosRef.current[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
previousGridPosRef.current = snappedPoint
|
||||
|
||||
applyPreview(snappedPoint, event.nativeEvent.altKey)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||
const hasChanged = !(
|
||||
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
|
||||
)
|
||||
|
||||
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
|
||||
wasCommitted = true
|
||||
|
||||
// Restore original baseline while paused so the next resume+update
|
||||
// registers as a single tracked change (undo reverts to original).
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
|
||||
resumeSceneHistory(useScene)
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: preview.start, end: preview.end },
|
||||
...(altPressedRef.current
|
||||
? []
|
||||
: getLinkedWallUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
preview.start,
|
||||
preview.end,
|
||||
)),
|
||||
])
|
||||
pauseSceneHistory(useScene)
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
}
|
||||
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
setAngleLabel(null)
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
resumeSceneHistory(useScene)
|
||||
setAngleLabel(null)
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = true
|
||||
}
|
||||
if (event.key === 'Alt') {
|
||||
altPressedRef.current = true
|
||||
setAltPressed(true)
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = false
|
||||
}
|
||||
if (event.key === 'Alt') {
|
||||
altPressedRef.current = false
|
||||
setAltPressed(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onWindowBlur = () => {
|
||||
shiftPressedRef.current = false
|
||||
altPressedRef.current = false
|
||||
setAltPressed(false)
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', onWindowBlur)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
restoreOriginal(false)
|
||||
}
|
||||
resumeSceneHistory(useScene)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', onWindowBlur)
|
||||
}
|
||||
}, [exitMoveMode, target])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
<Html
|
||||
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
|
||||
style={{ pointerEvents: 'none', touchAction: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<div className="translate-y-10">
|
||||
<div
|
||||
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
|
||||
altPressed
|
||||
? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
|
||||
: 'border-border bg-background/95 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{altPressed ? 'Detaching corner' : 'Alt to detach'}
|
||||
</div>
|
||||
</div>
|
||||
</Html>
|
||||
{angleLabel && <EndpointAngleLabel label={angleLabel.label} position={angleLabel.position} />}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function EndpointAngleLabel({
|
||||
label,
|
||||
position,
|
||||
}: {
|
||||
label: string
|
||||
position: [number, number, number]
|
||||
}) {
|
||||
return (
|
||||
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
||||
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
|
||||
{label}
|
||||
</div>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
@@ -1,804 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
constrainWallMoveDeltaToAxis,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
detectSpacesForLevel,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
getMaterialPresetByRef,
|
||||
getPerpendicularWallMoveAxis,
|
||||
pauseSceneHistory,
|
||||
planAutoSlabsForLevel,
|
||||
planWallMoveJunctions,
|
||||
resolveMaterial,
|
||||
resumeSceneHistory,
|
||||
type SlabNode,
|
||||
useScene,
|
||||
type WallMoveAxis,
|
||||
type WallMoveBridgePlan,
|
||||
type WallMoveJunctionPlan,
|
||||
type WallNode,
|
||||
WallNode as WallSchema,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, DoubleSide, Float32BufferAttribute } from 'three'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { getWallGridStep, isWallLongEnough, snapScalarToGrid } from './wall-drafting'
|
||||
|
||||
function rotateVector([x, z]: [number, 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 samePoint(a: [number, number], b: [number, number]) {
|
||||
return a[0] === b[0] && a[1] === b[1]
|
||||
}
|
||||
|
||||
function pointKey(point: [number, number]) {
|
||||
return `${point[0]}:${point[1]}`
|
||||
}
|
||||
|
||||
function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'] {
|
||||
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
|
||||
return meta
|
||||
}
|
||||
|
||||
const nextMeta = { ...(meta as Record<string, unknown>) } as Record<string, unknown>
|
||||
delete nextMeta.isNew
|
||||
return nextMeta as WallNode['metadata']
|
||||
}
|
||||
|
||||
type LinkedWallSnapshot = WallNode
|
||||
|
||||
type GhostWallPreview = {
|
||||
id: string
|
||||
start: [number, number]
|
||||
end: [number, number]
|
||||
color: string
|
||||
height: number
|
||||
}
|
||||
|
||||
function getLinkedWallSnapshots(args: {
|
||||
wallId: WallNode['id']
|
||||
wallParentId: string | null
|
||||
originalStart: [number, number]
|
||||
originalEnd: [number, number]
|
||||
}) {
|
||||
const { wallId, wallParentId, originalStart, originalEnd } = args
|
||||
const { nodes } = useScene.getState()
|
||||
const walls = Object.values(nodes).filter(
|
||||
(node): node is WallNode =>
|
||||
node?.type === 'wall' && node.id !== wallId && (node.parentId ?? null) === wallParentId,
|
||||
)
|
||||
const directlyLinkedWalls = walls.filter(
|
||||
(wall) =>
|
||||
samePoint(wall.start, originalStart) ||
|
||||
samePoint(wall.start, originalEnd) ||
|
||||
samePoint(wall.end, originalStart) ||
|
||||
samePoint(wall.end, originalEnd),
|
||||
)
|
||||
const contextPoints = new Set([pointKey(originalStart), pointKey(originalEnd)])
|
||||
|
||||
for (const wall of directlyLinkedWalls) {
|
||||
contextPoints.add(pointKey(wall.start))
|
||||
contextPoints.add(pointKey(wall.end))
|
||||
}
|
||||
|
||||
const snapshots: LinkedWallSnapshot[] = []
|
||||
const seenWallIds = new Set<WallNode['id']>()
|
||||
|
||||
for (const node of walls) {
|
||||
if (!contextPoints.has(pointKey(node.start)) && !contextPoints.has(pointKey(node.end))) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (seenWallIds.has(node.id)) {
|
||||
continue
|
||||
}
|
||||
seenWallIds.add(node.id)
|
||||
|
||||
snapshots.push({
|
||||
...node,
|
||||
start: [...node.start] as [number, number],
|
||||
end: [...node.end] as [number, number],
|
||||
children: [...(node.children ?? [])],
|
||||
})
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
|
||||
function getLinkedWallUpdates(
|
||||
linkedWalls: Array<{
|
||||
wall: LinkedWallSnapshot
|
||||
matchPoint?: [number, number]
|
||||
targetPoint?: [number, number]
|
||||
}>,
|
||||
originalStart: [number, number],
|
||||
originalEnd: [number, number],
|
||||
nextStart: [number, number],
|
||||
nextEnd: [number, number],
|
||||
) {
|
||||
return linkedWalls.map(({ wall, matchPoint, targetPoint }) => {
|
||||
if (matchPoint && targetPoint) {
|
||||
return {
|
||||
id: wall.id,
|
||||
start: samePoint(wall.start, matchPoint) ? targetPoint : wall.start,
|
||||
end: samePoint(wall.end, matchPoint) ? targetPoint : wall.end,
|
||||
}
|
||||
}
|
||||
|
||||
const targetStart = targetPoint ?? nextStart
|
||||
const targetEnd = targetPoint ?? nextEnd
|
||||
|
||||
return {
|
||||
id: wall.id,
|
||||
start: samePoint(wall.start, originalStart)
|
||||
? targetStart
|
||||
: samePoint(wall.start, originalEnd)
|
||||
? targetEnd
|
||||
: wall.start,
|
||||
end: samePoint(wall.end, originalStart)
|
||||
? targetStart
|
||||
: samePoint(wall.end, originalEnd)
|
||||
? targetEnd
|
||||
: wall.end,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getPlannedLinkedWallUpdates(
|
||||
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
|
||||
originalStart: [number, number],
|
||||
originalEnd: [number, number],
|
||||
nextStart: [number, number],
|
||||
nextEnd: [number, number],
|
||||
) {
|
||||
const movePlans = new Map<
|
||||
WallNode['id'],
|
||||
{ wall: LinkedWallSnapshot; matchPoint?: [number, number]; targetPoint?: [number, number] }
|
||||
>()
|
||||
|
||||
for (const wall of plan.linkedWallsToMove) {
|
||||
movePlans.set(wall.id, { wall })
|
||||
}
|
||||
|
||||
for (const targetPlan of plan.linkedWallTargetPlans) {
|
||||
movePlans.set(targetPlan.wall.id, {
|
||||
wall: targetPlan.wall,
|
||||
matchPoint: targetPlan.originalPoint,
|
||||
targetPoint: targetPlan.targetPoint,
|
||||
})
|
||||
}
|
||||
|
||||
return getLinkedWallUpdates(
|
||||
Array.from(movePlans.values()),
|
||||
originalStart,
|
||||
originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
)
|
||||
}
|
||||
|
||||
function wallSegmentExists(
|
||||
walls: Array<Pick<WallNode, 'start' | 'end'>>,
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
) {
|
||||
return walls.some(
|
||||
(wall) =>
|
||||
(samePoint(wall.start, start) && samePoint(wall.end, end)) ||
|
||||
(samePoint(wall.start, end) && samePoint(wall.end, start)),
|
||||
)
|
||||
}
|
||||
|
||||
function getWallGhostColor(wall: WallNode) {
|
||||
const presetColor =
|
||||
getMaterialPresetByRef(wall.materialPreset)?.mapProperties.color ??
|
||||
getMaterialPresetByRef(wall.interiorMaterialPreset)?.mapProperties.color ??
|
||||
getMaterialPresetByRef(wall.exteriorMaterialPreset)?.mapProperties.color
|
||||
|
||||
if (presetColor) {
|
||||
return presetColor
|
||||
}
|
||||
|
||||
return resolveMaterial(wall.material ?? wall.interiorMaterial ?? wall.exteriorMaterial).color
|
||||
}
|
||||
|
||||
function getWallsAfterUpdates(
|
||||
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
||||
updates: Array<{ id: AnyNodeId; data: Partial<WallNode> }>,
|
||||
) {
|
||||
const updateById = new Map(updates.map((update) => [update.id, update.data]))
|
||||
|
||||
return Object.values(nodes)
|
||||
.filter((node): node is WallNode => node?.type === 'wall')
|
||||
.map((wall) => {
|
||||
const update = updateById.get(wall.id as AnyNodeId)
|
||||
return update ? ({ ...wall, ...update } as WallNode) : wall
|
||||
})
|
||||
}
|
||||
|
||||
function cloneSlabSnapshot(slab: SlabNode): SlabNode {
|
||||
return {
|
||||
...slab,
|
||||
polygon: slab.polygon.map(([x, z]) => [x, z] as [number, number]),
|
||||
holes: slab.holes.map((hole) => hole.map(([x, z]) => [x, z] as [number, number])),
|
||||
holeMetadata: slab.holeMetadata.map((metadata) => ({ ...metadata })),
|
||||
}
|
||||
}
|
||||
|
||||
function getLevelSlabs(levelId: string, nodes: ReturnType<typeof useScene.getState>['nodes']) {
|
||||
return Object.values(nodes).filter(
|
||||
(entry): entry is SlabNode => entry?.type === 'slab' && (entry.parentId ?? null) === levelId,
|
||||
)
|
||||
}
|
||||
|
||||
function getLevelAutoSlabs(levelId: string, nodes: ReturnType<typeof useScene.getState>['nodes']) {
|
||||
return getLevelSlabs(levelId, nodes).filter((slab) => slab.autoFromWalls)
|
||||
}
|
||||
|
||||
function getLevelAutoSlabSnapshots(levelId: string) {
|
||||
return getLevelAutoSlabs(levelId, useScene.getState().nodes).map(cloneSlabSnapshot)
|
||||
}
|
||||
|
||||
function buildBridgeWallCreates(args: {
|
||||
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
|
||||
nextStart: [number, number]
|
||||
nextEnd: [number, number]
|
||||
existingWalls: WallNode[]
|
||||
wallCount: number
|
||||
}): Array<{ node: WallNode; parentId?: AnyNodeId }> {
|
||||
const { bridgePlans, nextStart, nextEnd, existingWalls, wallCount } = args
|
||||
const wallsForDuplicateCheck = [...existingWalls]
|
||||
const creates: Array<{ node: WallNode; parentId?: AnyNodeId }> = []
|
||||
|
||||
for (const plan of bridgePlans) {
|
||||
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
|
||||
|
||||
if (!isWallLongEnough(plan.originalPoint, nextPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { id: _id, parentId: _parentId, children: _children, ...sourceWall } = plan.wall
|
||||
const bridgeWall = WallSchema.parse({
|
||||
...sourceWall,
|
||||
name: `Wall ${wallCount + creates.length + 1}`,
|
||||
start: plan.originalPoint,
|
||||
end: nextPoint,
|
||||
children: [],
|
||||
metadata: stripWallIsNewMetadata(plan.wall.metadata),
|
||||
})
|
||||
|
||||
creates.push({
|
||||
node: bridgeWall,
|
||||
parentId: (plan.wall.parentId ?? undefined) as AnyNodeId | undefined,
|
||||
})
|
||||
wallsForDuplicateCheck.push(bridgeWall)
|
||||
}
|
||||
|
||||
return creates
|
||||
}
|
||||
|
||||
function buildBridgeWallPreviews(args: {
|
||||
bridgePlans: Array<WallMoveBridgePlan<LinkedWallSnapshot>>
|
||||
nextStart: [number, number]
|
||||
nextEnd: [number, number]
|
||||
existingWalls: WallNode[]
|
||||
}): Array<{ ghost: GhostWallPreview; wall: WallNode }> {
|
||||
const { bridgePlans, nextStart, nextEnd, existingWalls } = args
|
||||
const wallsForDuplicateCheck: Array<Pick<WallNode, 'start' | 'end'>> = [...existingWalls]
|
||||
const previews: Array<{ ghost: GhostWallPreview; wall: WallNode }> = []
|
||||
|
||||
for (const plan of bridgePlans) {
|
||||
const nextPoint = plan.movedEndpoint === 'start' ? nextStart : nextEnd
|
||||
|
||||
if (!isWallLongEnough(plan.originalPoint, nextPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (wallSegmentExists(wallsForDuplicateCheck, plan.originalPoint, nextPoint)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const { id: _id, children: _children, ...sourceWall } = plan.wall
|
||||
const wall = WallSchema.parse({
|
||||
...sourceWall,
|
||||
name: 'Wall Preview',
|
||||
start: plan.originalPoint,
|
||||
end: nextPoint,
|
||||
children: [],
|
||||
metadata: stripWallIsNewMetadata(plan.wall.metadata),
|
||||
})
|
||||
const ghost = {
|
||||
id: `${plan.wall.id}:${plan.movedEndpoint}:${previews.length}`,
|
||||
start: [...plan.originalPoint] as [number, number],
|
||||
end: [...nextPoint] as [number, number],
|
||||
color: getWallGhostColor(plan.wall),
|
||||
height: plan.wall.height ?? DEFAULT_WALL_HEIGHT,
|
||||
}
|
||||
previews.push({ ghost, wall })
|
||||
wallsForDuplicateCheck.push(wall)
|
||||
}
|
||||
|
||||
return previews
|
||||
}
|
||||
|
||||
function setPreviewGeometryAttributes(
|
||||
geometry: BufferGeometry,
|
||||
positions: number[],
|
||||
normals: number[],
|
||||
uvs: number[],
|
||||
) {
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
|
||||
geometry.setAttribute('normal', new Float32BufferAttribute(normals, 3))
|
||||
geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2))
|
||||
geometry.setAttribute('uv2', new Float32BufferAttribute([...uvs], 2))
|
||||
}
|
||||
|
||||
function createWallPreviewGeometry(length: number, height: number) {
|
||||
const geometry = new BufferGeometry()
|
||||
setPreviewGeometryAttributes(
|
||||
geometry,
|
||||
[0, 0, 0, length, 0, 0, length, height, 0, 0, height, 0],
|
||||
[0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1],
|
||||
[0, 0, 1, 0, 1, 1, 0, 1],
|
||||
)
|
||||
geometry.setIndex([0, 1, 2, 0, 2, 3])
|
||||
geometry.computeBoundingSphere()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function GhostWallPreviewMesh({ preview }: { preview: GhostWallPreview }) {
|
||||
const dx = preview.end[0] - preview.start[0]
|
||||
const dz = preview.end[1] - preview.start[1]
|
||||
const length = Math.hypot(dx, dz)
|
||||
const angle = -Math.atan2(dz, dx)
|
||||
const geometry = useMemo(() => {
|
||||
return length < 0.01 ? null : createWallPreviewGeometry(length, preview.height)
|
||||
}, [length, preview.height])
|
||||
|
||||
useEffect(() => () => geometry?.dispose(), [geometry])
|
||||
|
||||
if (!geometry) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<group position={[preview.start[0], 0.02, preview.start[1]]} rotation={[0, angle, 0]}>
|
||||
<mesh frustumCulled={false} layers={EDITOR_LAYER} renderOrder={2}>
|
||||
<primitive attach="geometry" object={geometry} />
|
||||
<meshBasicMaterial
|
||||
color={preview.color}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.32}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
const meta =
|
||||
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
|
||||
? (node.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
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 originalCenterRef = useRef<[number, number]>([
|
||||
(node.start[0] + node.end[0]) / 2,
|
||||
(node.start[1] + node.end[1]) / 2,
|
||||
])
|
||||
const originalHalfVectorRef = useRef<[number, number]>([
|
||||
(node.end[0] - node.start[0]) / 2,
|
||||
(node.end[1] - node.start[1]) / 2,
|
||||
])
|
||||
const moveAxisRef = useRef<WallMoveAxis | null>(
|
||||
getPerpendicularWallMoveAxis(node.start, node.end),
|
||||
)
|
||||
const linkedOriginalsRef = useRef<LinkedWallSnapshot[]>(
|
||||
isNew
|
||||
? []
|
||||
: getLinkedWallSnapshots({
|
||||
wallId: node.id,
|
||||
wallParentId: node.parentId ?? null,
|
||||
originalStart: node.start,
|
||||
originalEnd: node.end,
|
||||
}),
|
||||
)
|
||||
const originalAutoSlabsRef = useRef<SlabNode[]>(
|
||||
node.parentId ? getLevelAutoSlabSnapshots(node.parentId) : [],
|
||||
)
|
||||
const dragAnchorRef = useRef<[number, number] | null>(null)
|
||||
const nodeIdRef = useRef(node.id)
|
||||
const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null)
|
||||
const pendingRotationRef = useRef(0)
|
||||
const shiftPressedRef = useRef(false)
|
||||
|
||||
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 [ghostWallPreviews, setGhostWallPreviews] = useState<GhostWallPreview[]>([])
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const nodeId = nodeIdRef.current
|
||||
const originalStart = originalStartRef.current
|
||||
const originalEnd = originalEndRef.current
|
||||
const originalCenter = originalCenterRef.current
|
||||
const originalHalfVector = originalHalfVectorRef.current
|
||||
const levelId = node.parentId ?? null
|
||||
const originalAutoSlabs = originalAutoSlabsRef.current
|
||||
|
||||
pauseSceneHistory(useScene)
|
||||
let shouldRestoreOnCleanup = true
|
||||
|
||||
const applyNodePreview = (
|
||||
updates: Array<{ id: WallNode['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 applyLiveAutoSlabPreview = (walls: WallNode[]) => {
|
||||
if (!levelId) {
|
||||
return
|
||||
}
|
||||
|
||||
const levelWalls = walls.filter((wall) => (wall.parentId ?? null) === levelId)
|
||||
const sceneState = useScene.getState()
|
||||
const { roomPolygons } = detectSpacesForLevel(levelId, levelWalls)
|
||||
const slabPlan = planAutoSlabsForLevel(roomPolygons, getLevelSlabs(levelId, sceneState.nodes))
|
||||
|
||||
if (
|
||||
slabPlan.create.length === 0 &&
|
||||
slabPlan.update.length === 0 &&
|
||||
slabPlan.delete.length === 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
sceneState.applyNodeChanges({
|
||||
update: slabPlan.update.map((entry) => ({
|
||||
id: entry.id as AnyNodeId,
|
||||
data: entry.data,
|
||||
})),
|
||||
create: slabPlan.create.map((slab) => ({
|
||||
node: slab,
|
||||
parentId: levelId as AnyNodeId,
|
||||
})),
|
||||
delete: slabPlan.delete.map((id) => id as AnyNodeId),
|
||||
})
|
||||
}
|
||||
|
||||
const restoreAutoSlabPreview = () => {
|
||||
if (!levelId) {
|
||||
return
|
||||
}
|
||||
|
||||
const sceneState = useScene.getState()
|
||||
const originalIds = new Set(originalAutoSlabs.map((slab) => slab.id))
|
||||
const currentAutoSlabs = getLevelAutoSlabs(levelId, sceneState.nodes)
|
||||
const update = originalAutoSlabs
|
||||
.filter((slab) => sceneState.nodes[slab.id as AnyNodeId])
|
||||
.map((slab) => ({
|
||||
id: slab.id as AnyNodeId,
|
||||
data: cloneSlabSnapshot(slab),
|
||||
}))
|
||||
const create = originalAutoSlabs
|
||||
.filter((slab) => !sceneState.nodes[slab.id as AnyNodeId])
|
||||
.map((slab) => ({
|
||||
node: cloneSlabSnapshot(slab),
|
||||
parentId: levelId as AnyNodeId,
|
||||
}))
|
||||
const deleteIds = currentAutoSlabs
|
||||
.filter((slab) => !originalIds.has(slab.id))
|
||||
.map((slab) => slab.id as AnyNodeId)
|
||||
|
||||
if (update.length === 0 && create.length === 0 && deleteIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
sceneState.applyNodeChanges({
|
||||
update,
|
||||
create,
|
||||
delete: deleteIds,
|
||||
})
|
||||
}
|
||||
|
||||
const buildWallFromCenter = (center: [number, number]) => {
|
||||
const rotatedHalf = rotateVector(originalHalfVector, pendingRotationRef.current)
|
||||
const nextStart: [number, number] = [center[0] - rotatedHalf[0], center[1] - rotatedHalf[1]]
|
||||
const nextEnd: [number, number] = [center[0] + rotatedHalf[0], center[1] + rotatedHalf[1]]
|
||||
return { start: nextStart, end: nextEnd }
|
||||
}
|
||||
|
||||
const getMovePlan = (nextStart: [number, number], nextEnd: [number, number]) =>
|
||||
planWallMoveJunctions(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
)
|
||||
|
||||
const getLinkedPreviewUpdates = (
|
||||
plan: WallMoveJunctionPlan<LinkedWallSnapshot>,
|
||||
nextStart: [number, number],
|
||||
nextEnd: [number, number],
|
||||
) => {
|
||||
const movedUpdates = getPlannedLinkedWallUpdates(
|
||||
plan,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
)
|
||||
const movedById = new Map(movedUpdates.map((entry) => [entry.id, entry]))
|
||||
|
||||
return linkedOriginalsRef.current.map(
|
||||
(wall) => movedById.get(wall.id) ?? { id: wall.id, start: wall.start, end: wall.end },
|
||||
)
|
||||
}
|
||||
|
||||
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])
|
||||
const previewPlan = getMovePlan(nextStart, nextEnd)
|
||||
const previewUpdates = [
|
||||
{ id: nodeId, start: nextStart, end: nextEnd },
|
||||
...getLinkedPreviewUpdates(previewPlan, nextStart, nextEnd),
|
||||
]
|
||||
const previewCollapsedWallIds = new Set([
|
||||
...previewUpdates
|
||||
.filter((entry) => entry.id !== nodeId && !isWallLongEnough(entry.start, entry.end))
|
||||
.map((entry) => entry.id as AnyNodeId),
|
||||
...previewPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
|
||||
])
|
||||
const previewSceneWalls = getWallsAfterUpdates(
|
||||
useScene.getState().nodes,
|
||||
previewUpdates.map((entry) => ({
|
||||
id: entry.id as AnyNodeId,
|
||||
data: { start: entry.start, end: entry.end },
|
||||
})),
|
||||
).filter((wall) => !previewCollapsedWallIds.has(wall.id as AnyNodeId))
|
||||
const bridgePreviews = buildBridgeWallPreviews({
|
||||
bridgePlans: previewPlan.bridgePlans,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
existingWalls: previewSceneWalls,
|
||||
})
|
||||
const nextGhostWalls = bridgePreviews.map((preview) => preview.ghost)
|
||||
const virtualBridgeWalls = bridgePreviews.map((preview) => preview.wall)
|
||||
setGhostWallPreviews(nextGhostWalls)
|
||||
applyNodePreview(previewUpdates)
|
||||
applyLiveAutoSlabPreview([...previewSceneWalls, ...virtualBridgeWalls])
|
||||
}
|
||||
|
||||
const restoreOriginal = () => {
|
||||
setGhostWallPreviews([])
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
restoreAutoSlabPreview()
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const rawX = event.localPosition[0]
|
||||
const rawZ = event.localPosition[2]
|
||||
const snapStep = getWallGridStep()
|
||||
const localX = shiftPressedRef.current ? rawX : snapScalarToGrid(rawX, snapStep)
|
||||
const localZ = shiftPressedRef.current ? rawZ : snapScalarToGrid(rawZ, snapStep)
|
||||
|
||||
const anchor = dragAnchorRef.current ?? [localX, localZ]
|
||||
dragAnchorRef.current = anchor
|
||||
|
||||
const [deltaX, deltaZ] = constrainWallMoveDeltaToAxis(
|
||||
localX - anchor[0],
|
||||
localZ - anchor[1],
|
||||
moveAxisRef.current,
|
||||
)
|
||||
const constrainedGridPos: [number, number] = [anchor[0] + deltaX, anchor[1] + deltaZ]
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
(constrainedGridPos[0] !== previousGridPosRef.current[0] ||
|
||||
constrainedGridPos[1] !== previousGridPosRef.current[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
previousGridPosRef.current = constrainedGridPos
|
||||
|
||||
const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ]
|
||||
const nextWall = buildWallFromCenter(nextCenter)
|
||||
applyPreview(nextWall.start, nextWall.end)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||
|
||||
shouldRestoreOnCleanup = false
|
||||
|
||||
// Restore original baseline while paused so the next resume+update
|
||||
// registers as a single tracked change (undo reverts to original).
|
||||
setGhostWallPreviews([])
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
restoreAutoSlabPreview()
|
||||
|
||||
resumeSceneHistory(useScene)
|
||||
const commitPlan = getMovePlan(preview.start, preview.end)
|
||||
const linkedWallUpdates = getPlannedLinkedWallUpdates(
|
||||
commitPlan,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
preview.start,
|
||||
preview.end,
|
||||
)
|
||||
const collapsedLinkedWallIds = new Set([
|
||||
...linkedWallUpdates
|
||||
.filter((entry) => !isWallLongEnough(entry.start, entry.end))
|
||||
.map((entry) => entry.id as AnyNodeId),
|
||||
...commitPlan.wallsToDelete.map((wall) => wall.id as AnyNodeId),
|
||||
])
|
||||
|
||||
const commitUpdates = [
|
||||
{
|
||||
id: nodeId as AnyNodeId,
|
||||
data: isNew
|
||||
? {
|
||||
start: preview.start,
|
||||
end: preview.end,
|
||||
metadata: stripWallIsNewMetadata(node.metadata),
|
||||
}
|
||||
: { start: preview.start, end: preview.end },
|
||||
},
|
||||
...linkedWallUpdates
|
||||
.filter((entry) => !collapsedLinkedWallIds.has(entry.id as AnyNodeId))
|
||||
.map((entry) => ({
|
||||
id: entry.id as AnyNodeId,
|
||||
data: { start: entry.start, end: entry.end },
|
||||
})),
|
||||
]
|
||||
const sceneState = useScene.getState()
|
||||
const existingWalls = getWallsAfterUpdates(sceneState.nodes, commitUpdates).filter(
|
||||
(wall) => !collapsedLinkedWallIds.has(wall.id as AnyNodeId),
|
||||
)
|
||||
const bridgeCreates = buildBridgeWallCreates({
|
||||
bridgePlans: commitPlan.bridgePlans,
|
||||
nextStart: preview.start,
|
||||
nextEnd: preview.end,
|
||||
existingWalls,
|
||||
wallCount: Object.values(sceneState.nodes).filter((entry) => entry?.type === 'wall').length,
|
||||
})
|
||||
sceneState.applyNodeChanges({
|
||||
update: commitUpdates,
|
||||
create: bridgeCreates,
|
||||
delete: Array.from(collapsedLinkedWallIds),
|
||||
})
|
||||
|
||||
pauseSceneHistory(useScene)
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
let rotationDelta = 0
|
||||
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
|
||||
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
|
||||
|
||||
if (rotationDelta === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
pendingRotationRef.current += rotationDelta
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
|
||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||
const currentCenter: [number, number] = [
|
||||
(preview.start[0] + preview.end[0]) / 2,
|
||||
(preview.start[1] + preview.end[1]) / 2,
|
||||
]
|
||||
const nextWall = buildWallFromCenter(currentCenter)
|
||||
moveAxisRef.current = getPerpendicularWallMoveAxis(nextWall.start, nextWall.end)
|
||||
applyPreview(nextWall.start, nextWall.end)
|
||||
}
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
shouldRestoreOnCleanup = false
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
resumeSceneHistory(useScene)
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
|
||||
return () => {
|
||||
if (shouldRestoreOnCleanup) {
|
||||
restoreOriginal()
|
||||
}
|
||||
shiftPressedRef.current = false
|
||||
resumeSceneHistory(useScene)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
}
|
||||
}, [exitMoveMode, isNew, node.metadata, node.parentId])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
{ghostWallPreviews.map((preview) => (
|
||||
<GhostWallPreviewMesh key={preview.id} preview={preview} />
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useEffect, useRef, useState } 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 {
|
||||
formatAngleRadians,
|
||||
getAngleToSegmentReference,
|
||||
getSegmentAngleReferenceAtPoint,
|
||||
} from '../shared/segment-angle'
|
||||
import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
|
||||
|
||||
const WALL_HEIGHT = 2.5
|
||||
const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22
|
||||
const DRAFT_ANGLE_LABEL_Y = 0.28
|
||||
|
||||
type DraftAngleLabel = {
|
||||
id: string
|
||||
label: string
|
||||
position: [number, number, number]
|
||||
}
|
||||
|
||||
type DraftMeasurementState = {
|
||||
lengthLabel: string
|
||||
lengthPosition: [number, number, number]
|
||||
angleLabels: DraftAngleLabel[]
|
||||
} | null
|
||||
|
||||
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
||||
if (unit === 'imperial') {
|
||||
const feet = value * 3.280_84
|
||||
const wholeFeet = Math.floor(feet)
|
||||
const inches = Math.round((feet - wholeFeet) * 12)
|
||||
if (inches === 12) return `${wholeFeet + 1}'0"`
|
||||
return `${wholeFeet}'${inches}"`
|
||||
}
|
||||
|
||||
return `${Number.parseFloat(value.toFixed(2))}m`
|
||||
}
|
||||
|
||||
function getDraftAngleLabels(
|
||||
start: WallPlanPoint,
|
||||
end: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
): DraftAngleLabel[] {
|
||||
const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]]
|
||||
const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]]
|
||||
const endpoints = [
|
||||
{ id: 'start', point: start, draftVector: draftFromStart },
|
||||
{ id: 'end', point: end, draftVector: draftFromEnd },
|
||||
]
|
||||
const labels: DraftAngleLabel[] = []
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
const connectedWall = walls.find((wall) =>
|
||||
Boolean(getSegmentAngleReferenceAtPoint(endpoint.point, wall)),
|
||||
)
|
||||
if (!connectedWall) continue
|
||||
|
||||
const connectedReference = getSegmentAngleReferenceAtPoint(endpoint.point, connectedWall)
|
||||
if (!connectedReference) continue
|
||||
|
||||
const angle = getAngleToSegmentReference(endpoint.draftVector, connectedReference)
|
||||
if (angle === null) continue
|
||||
|
||||
labels.push({
|
||||
id: endpoint.id,
|
||||
label: formatAngleRadians(angle),
|
||||
position: [endpoint.point[0], DRAFT_ANGLE_LABEL_Y, endpoint.point[1]],
|
||||
})
|
||||
}
|
||||
|
||||
return labels
|
||||
}
|
||||
|
||||
function getDraftMeasurementState(
|
||||
start: WallPlanPoint,
|
||||
end: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
unit: 'metric' | 'imperial',
|
||||
): DraftMeasurementState {
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[1] - start[1]
|
||||
const length = Math.hypot(dx, dz)
|
||||
|
||||
if (length < 0.01) return null
|
||||
|
||||
return {
|
||||
lengthLabel: formatMeasurement(length, unit),
|
||||
lengthPosition: [(start[0] + end[0]) / 2, DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
|
||||
angleLabels: getDraftAngleLabels(start, end, walls),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update wall preview mesh geometry to create a vertical plane between two points
|
||||
*/
|
||||
const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
|
||||
// Calculate direction and perpendicular for wall thickness
|
||||
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()
|
||||
|
||||
// Create wall shape (vertical rectangle in XY plane)
|
||||
const shape = new Shape()
|
||||
shape.moveTo(0, 0)
|
||||
shape.lineTo(length, 0)
|
||||
shape.lineTo(length, WALL_HEIGHT)
|
||||
shape.lineTo(0, WALL_HEIGHT)
|
||||
shape.closePath()
|
||||
|
||||
// Create geometry
|
||||
const geometry = new ShapeGeometry(shape)
|
||||
|
||||
// Calculate rotation angle
|
||||
// Negate the angle to fix the opposite direction issue
|
||||
const angle = -Math.atan2(direction.z, direction.x)
|
||||
|
||||
// Position at start point and rotate
|
||||
mesh.position.set(start.x, start.y, start.z)
|
||||
mesh.rotation.y = angle
|
||||
|
||||
// Dispose old geometry and assign new one
|
||||
if (mesh.geometry) {
|
||||
mesh.geometry.dispose()
|
||||
}
|
||||
mesh.geometry = geometry
|
||||
}
|
||||
|
||||
const getCurrentLevelWalls = (): WallNode[] => {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
const { nodes } = useScene.getState()
|
||||
|
||||
if (!currentLevelId) return []
|
||||
|
||||
const levelNode = nodes[currentLevelId]
|
||||
if (!levelNode || levelNode.type !== 'level') return []
|
||||
|
||||
return (levelNode as LevelNode).children
|
||||
.map((childId) => nodes[childId])
|
||||
.filter((node): node is WallNode => node?.type === 'wall')
|
||||
}
|
||||
|
||||
export const WallTool: React.FC = () => {
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const wallPreviewRef = useRef<Mesh>(null!)
|
||||
// All positions are building-local: this tool is inside the ToolManager building group,
|
||||
// so local coords are used for both data and visual positioning.
|
||||
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||
const endingPoint = useRef(new Vector3(0, 0, 0))
|
||||
const buildingState = useRef(0)
|
||||
const shiftPressed = useRef(false)
|
||||
const [draftMeasurement, setDraftMeasurement] = useState<DraftMeasurementState>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let gridPosition: WallPlanPoint = [0, 0]
|
||||
let previousWallEnd: [number, number] | null = null
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (!(cursorRef.current && wallPreviewRef.current)) return
|
||||
|
||||
const walls = getCurrentLevelWalls()
|
||||
// event.localPosition is building-local — consistent with stored wall start/end
|
||||
const localPoint: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
gridPosition = snapWallDraftPoint({ point: localPoint, walls })
|
||||
|
||||
if (buildingState.current === 1) {
|
||||
const snappedLocal = snapWallDraftPoint({
|
||||
point: localPoint,
|
||||
walls,
|
||||
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)
|
||||
|
||||
// Play snap sound only when the actual wall end position changes
|
||||
const currentWallEnd: [number, number] = [snappedLocal[0], snappedLocal[1]]
|
||||
if (
|
||||
previousWallEnd &&
|
||||
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
previousWallEnd = currentWallEnd
|
||||
|
||||
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
|
||||
setDraftMeasurement(
|
||||
getDraftMeasurementState(
|
||||
[startingPoint.current.x, startingPoint.current.z],
|
||||
snappedLocal,
|
||||
walls,
|
||||
unit,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// Not drawing a wall yet, show the snapped anchor point.
|
||||
cursorRef.current.position.set(gridPosition[0], event.localPosition[1], gridPosition[1])
|
||||
setDraftMeasurement(null)
|
||||
}
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const walls = getCurrentLevelWalls()
|
||||
const localClick: WallPlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
|
||||
if (buildingState.current === 0) {
|
||||
const snappedStart = snapWallDraftPoint({ point: localClick, walls })
|
||||
gridPosition = snappedStart
|
||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
buildingState.current = 1
|
||||
wallPreviewRef.current.visible = true
|
||||
setDraftMeasurement(null)
|
||||
} else if (buildingState.current === 1) {
|
||||
const snappedEnd = snapWallDraftPoint({
|
||||
point: localClick,
|
||||
walls,
|
||||
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
|
||||
// Both start and end are building-local ✓
|
||||
createWallOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd)
|
||||
wallPreviewRef.current.visible = false
|
||||
buildingState.current = 0
|
||||
setDraftMeasurement(null)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
wallPreviewRef.current.visible = false
|
||||
setDraftMeasurement(null)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}, [unit])
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Cursor indicator */}
|
||||
<CursorSphere ref={cursorRef} />
|
||||
|
||||
{/* Wall preview */}
|
||||
<mesh layers={EDITOR_LAYER} ref={wallPreviewRef} renderOrder={1} visible={false}>
|
||||
<shapeGeometry />
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.5}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{draftMeasurement && (
|
||||
<>
|
||||
<DraftMeasurementLabel
|
||||
label={draftMeasurement.lengthLabel}
|
||||
position={draftMeasurement.lengthPosition}
|
||||
/>
|
||||
{draftMeasurement.angleLabels.map((angleLabel) => (
|
||||
<DraftMeasurementLabel
|
||||
key={angleLabel.id}
|
||||
label={angleLabel.label}
|
||||
position={angleLabel.position}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function DraftMeasurementLabel({
|
||||
label,
|
||||
position,
|
||||
}: {
|
||||
label: string
|
||||
position: [number, number, number]
|
||||
}) {
|
||||
return (
|
||||
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
|
||||
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
|
||||
{label}
|
||||
</div>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
snapToHalf,
|
||||
} from '../item/placement-math'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef_44_44,
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool.
|
||||
*
|
||||
* Move mode (metadata.isNew falsy):
|
||||
* Adopts the existing window, pauses temporal. On commit: restores original state
|
||||
* (clean undo baseline) then resumes + updateNode (undo reverts to original position).
|
||||
* On cancel: restores original state.
|
||||
*
|
||||
* Duplicate mode (metadata.isNew = true):
|
||||
* The node is a freshly created transient copy. On commit: deletes transient + resumes
|
||||
* + createNode (undo removes the new window entirely). On cancel: deletes the node.
|
||||
*/
|
||||
export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => {
|
||||
const cursorGroupRef = useRef<Group>(null!)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const meta =
|
||||
typeof movingWindowNode.metadata === 'object' && movingWindowNode.metadata !== null
|
||||
? (movingWindowNode.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
|
||||
// Save original state (only used in move mode)
|
||||
const original = {
|
||||
position: [...movingWindowNode.position] as [number, number, number],
|
||||
rotation: [...movingWindowNode.rotation] as [number, number, number],
|
||||
side: movingWindowNode.side,
|
||||
parentId: movingWindowNode.parentId,
|
||||
wallId: movingWindowNode.wallId,
|
||||
metadata: movingWindowNode.metadata,
|
||||
}
|
||||
|
||||
if (!isNew) {
|
||||
// Move mode: mark the existing window as transient so it hides while being repositioned
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
metadata: { ...meta, isTransient: true },
|
||||
})
|
||||
}
|
||||
|
||||
let currentWallId: string | null = movingWindowNode.parentId
|
||||
|
||||
const markWallDirty = (wallId: string | null) => {
|
||||
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
||||
}
|
||||
|
||||
const getLevelId = () => useViewer.getState().selection.levelId
|
||||
const getLevelYOffset = () => {
|
||||
const id = getLevelId()
|
||||
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||
}
|
||||
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||
spatialGridManager.getSlabElevationForWall(
|
||||
wallEvent.node.parentId ?? '',
|
||||
wallEvent.node.start,
|
||||
wallEvent.node.end,
|
||||
)
|
||||
|
||||
const hideCursor = () => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
}
|
||||
|
||||
const updateCursor = (
|
||||
worldPosition: [number, number, number],
|
||||
cursorRotationY: number,
|
||||
valid: boolean,
|
||||
) => {
|
||||
const group = cursorGroupRef.current
|
||||
if (!group) return
|
||||
group.visible = true
|
||||
group.position.set(...worldPosition)
|
||||
group.rotation.y = cursorRotationY
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
hideCursor()
|
||||
return
|
||||
}
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
const prevWallId = currentWallId
|
||||
currentWallId = event.node.id
|
||||
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
useLiveTransforms.getState().set(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: itemRotation,
|
||||
})
|
||||
|
||||
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
|
||||
markWallDirty(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
hideCursor()
|
||||
return
|
||||
}
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
if (currentWallId !== event.node.id) {
|
||||
// Wall changed mid-move: must updateNode to reparent
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
markWallDirty(currentWallId)
|
||||
currentWallId = event.node.id
|
||||
} else {
|
||||
// Same wall: update Three.js mesh directly to avoid store churn
|
||||
// collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
|
||||
const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
|
||||
if (windowMesh) {
|
||||
windowMesh.position.set(clampedX, clampedY, 0)
|
||||
windowMesh.rotation.set(0, itemRotation, 0)
|
||||
windowMesh.updateMatrixWorld(true)
|
||||
}
|
||||
}
|
||||
useLiveTransforms.getState().set(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: itemRotation,
|
||||
})
|
||||
markWallDirty(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) return
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
if (!valid) return
|
||||
|
||||
let placedId: string
|
||||
|
||||
if (isNew) {
|
||||
// Duplicate mode: delete transient + resume + createNode
|
||||
// Undo will remove the newly created node entirely
|
||||
useScene.getState().deleteNode(movingWindowNode.id)
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
const node = WindowNode.parse({
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
width: movingWindowNode.width,
|
||||
height: movingWindowNode.height,
|
||||
windowType: movingWindowNode.windowType,
|
||||
operationState: movingWindowNode.operationState,
|
||||
awningDirection: movingWindowNode.awningDirection,
|
||||
casementStyle: movingWindowNode.casementStyle,
|
||||
hingesSide: movingWindowNode.hingesSide,
|
||||
frameThickness: movingWindowNode.frameThickness,
|
||||
frameDepth: movingWindowNode.frameDepth,
|
||||
columnRatios: movingWindowNode.columnRatios,
|
||||
rowRatios: movingWindowNode.rowRatios,
|
||||
columnDividerThickness: movingWindowNode.columnDividerThickness,
|
||||
rowDividerThickness: movingWindowNode.rowDividerThickness,
|
||||
sill: movingWindowNode.sill,
|
||||
sillDepth: movingWindowNode.sillDepth,
|
||||
sillThickness: movingWindowNode.sillThickness,
|
||||
})
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
placedId = node.id
|
||||
} else {
|
||||
// Move mode: restore original (clean baseline) + resume + updateNode
|
||||
// Undo will revert to the original position
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
if (original.parentId && original.parentId !== event.node.id) {
|
||||
markWallDirty(original.parentId)
|
||||
}
|
||||
placedId = movingWindowNode.id
|
||||
}
|
||||
|
||||
markWallDirty(event.node.id)
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
hideCursor()
|
||||
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
||||
exitMoveMode()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallLeave = () => {
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
if (isNew) return // No original to restore for duplicates
|
||||
// Move mode: restore to original position while off-wall
|
||||
if (currentWallId && currentWallId !== original.parentId) {
|
||||
markWallDirty(currentWallId)
|
||||
}
|
||||
currentWallId = original.parentId
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
if (isNew) {
|
||||
useScene.getState().deleteNode(movingWindowNode.id)
|
||||
if (currentWallId) markWallDirty(currentWallId)
|
||||
} else {
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
hideCursor()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
emitter.on('wall:leave', onWallLeave)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
||||
const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
|
||||
| WindowNode
|
||||
| undefined
|
||||
const currentMeta = current?.metadata as Record<string, unknown> | undefined
|
||||
if (currentMeta?.isTransient) {
|
||||
if (isNew) {
|
||||
useScene.getState().deleteNode(movingWindowNode.id)
|
||||
if (currentWallId) markWallDirty(currentWallId)
|
||||
} else {
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
}
|
||||
}
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('wall:enter', onWallEnter)
|
||||
emitter.off('wall:move', onWallMove)
|
||||
emitter.off('wall:click', onWallClick)
|
||||
emitter.off('wall:leave', onWallLeave)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [movingWindowNode, exitMoveMode])
|
||||
|
||||
const edgesGeo = useMemo(() => {
|
||||
const boxGeo = new BoxGeometry(
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.frameDepth ?? 0.07,
|
||||
)
|
||||
const geo = new EdgesGeometry(boxGeo)
|
||||
boxGeo.dispose()
|
||||
return geo
|
||||
}, [movingWindowNode])
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
|
||||
* Wall XZ uses level-local coordinates (levels only offset in Y, not XZ).
|
||||
* Pass levelYOffset (the level group's current world Y) and slabElevation (the
|
||||
* wall mesh's Y within the level group) so the cursor lands at the correct world
|
||||
* height — matching how WallSystem positions the wall mesh at slabElevation.
|
||||
*/
|
||||
export function wallLocalToWorld(
|
||||
wallNode: WallNode,
|
||||
localX: number,
|
||||
localY: number,
|
||||
levelYOffset = 0,
|
||||
slabElevation = 0,
|
||||
): [number, number, number] {
|
||||
const wallAngle = Math.atan2(
|
||||
wallNode.end[1] - wallNode.start[1],
|
||||
wallNode.end[0] - wallNode.start[0],
|
||||
)
|
||||
return [
|
||||
wallNode.start[0] + localX * Math.cos(wallAngle),
|
||||
slabElevation + localY + levelYOffset,
|
||||
wallNode.start[1] + localX * Math.sin(wallAngle),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps window center position so it stays fully within wall bounds.
|
||||
*/
|
||||
export function clampToWall(
|
||||
wallNode: WallNode,
|
||||
localX: number,
|
||||
localY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): { clampedX: number; clampedY: number } {
|
||||
const dx = wallNode.end[0] - wallNode.start[0]
|
||||
const dz = wallNode.end[1] - wallNode.start[1]
|
||||
const wallLength = Math.sqrt(dx * dx + dz * dz)
|
||||
const wallHeight = wallNode.height ?? 2.5
|
||||
|
||||
const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
|
||||
const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY))
|
||||
return { clampedX, clampedY }
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly checks the wall's children for bounding-box overlap with a proposed window.
|
||||
* Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center).
|
||||
* The spatial grid only tracks `item` nodes, so windows must be checked this way.
|
||||
* Reads the wall's latest children from the store (not the event node) to avoid stale data.
|
||||
*/
|
||||
export function hasWallChildOverlap(
|
||||
wallId: string,
|
||||
clampedX: number,
|
||||
clampedY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
ignoreId?: string,
|
||||
): boolean {
|
||||
const nodes = useScene.getState().nodes
|
||||
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
|
||||
if (!wallNode) return true // Block if wall not found
|
||||
const halfW = width / 2
|
||||
const halfH = height / 2
|
||||
const newBottom = clampedY - halfH
|
||||
const newTop = clampedY + halfH
|
||||
const newLeft = clampedX - halfW
|
||||
const newRight = clampedX + halfW
|
||||
|
||||
for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
|
||||
if (childId === ignoreId) continue
|
||||
const child = nodes[childId as AnyNodeId]
|
||||
if (!child) continue
|
||||
|
||||
let childLeft: number, childRight: number, childBottom: number, childTop: number
|
||||
|
||||
if (child.type === 'item') {
|
||||
const item = child as ItemNode
|
||||
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
|
||||
const [w, h] = getScaledDimensions(item)
|
||||
childLeft = item.position[0] - w / 2
|
||||
childRight = item.position[0] + w / 2
|
||||
childBottom = item.position[1] // items store bottom Y
|
||||
childTop = item.position[1] + h
|
||||
} else if (child.type === 'window') {
|
||||
const win = child as WindowNode
|
||||
childLeft = win.position[0] - win.width / 2
|
||||
childRight = win.position[0] + win.width / 2
|
||||
childBottom = win.position[1] - win.height / 2 // windows store center Y
|
||||
childTop = win.position[1] + win.height / 2
|
||||
} else if (child.type === 'door') {
|
||||
const door = child as DoorNode
|
||||
childLeft = door.position[0] - door.width / 2
|
||||
childRight = door.position[0] + door.width / 2
|
||||
childBottom = door.position[1] - door.height / 2 // doors store center Y
|
||||
childTop = door.position[1] + door.height / 2
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
const xOverlap = newLeft < childRight && newRight > childLeft
|
||||
const yOverlap = newBottom < childTop && newTop > childBottom
|
||||
if (xOverlap && yOverlap) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments } from 'three'
|
||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
snapToHalf,
|
||||
} from '../item/placement-math'
|
||||
import { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
|
||||
// Shared edge material — reuse across renders, just toggle color
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef_44_44, // red-500 default (invalid)
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* Window tool — places WindowNodes on walls only.
|
||||
* Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions.
|
||||
*/
|
||||
export const WindowTool: React.FC = () => {
|
||||
const draftRef = useRef<WindowNode | null>(null)
|
||||
const cursorGroupRef = useRef<Group>(null!)
|
||||
const edgesRef = useRef<LineSegments>(null!)
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const getLevelId = () => useViewer.getState().selection.levelId
|
||||
const getLevelYOffset = () => {
|
||||
const id = getLevelId()
|
||||
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||
}
|
||||
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||
spatialGridManager.getSlabElevationForWall(
|
||||
wallEvent.node.parentId ?? '',
|
||||
wallEvent.node.start,
|
||||
wallEvent.node.end,
|
||||
)
|
||||
|
||||
const markWallDirty = (wallId: string) => {
|
||||
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
||||
}
|
||||
|
||||
const destroyDraft = () => {
|
||||
if (!draftRef.current) return
|
||||
const wallId = draftRef.current.parentId
|
||||
useScene.getState().deleteNode(draftRef.current.id)
|
||||
draftRef.current = null
|
||||
// Rebuild wall so it removes the cutout from the deleted draft
|
||||
if (wallId) markWallDirty(wallId)
|
||||
}
|
||||
|
||||
const hideCursor = () => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
}
|
||||
|
||||
const updateCursor = (
|
||||
worldPosition: [number, number, number],
|
||||
cursorRotationY: number,
|
||||
valid: boolean,
|
||||
) => {
|
||||
const group = cursorGroupRef.current
|
||||
if (!group) return
|
||||
group.visible = true
|
||||
group.position.set(...worldPosition)
|
||||
group.rotation.y = cursorRotationY
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
return
|
||||
}
|
||||
const levelId = getLevelId()
|
||||
if (!levelId) return
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== levelId) return
|
||||
|
||||
destroyDraft()
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
|
||||
const width = 1.5
|
||||
const height = 1.5
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
||||
|
||||
const node = WindowNode.parse({
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
draftRef.current = node
|
||||
|
||||
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
return
|
||||
}
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
|
||||
const width = draftRef.current?.width ?? 1.5
|
||||
const height = draftRef.current?.height ?? 1.5
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
||||
|
||||
if (draftRef.current) {
|
||||
if (event.node.id !== draftRef.current.parentId) {
|
||||
// Wall changed without enter/leave: must updateNode to reparent
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
} else {
|
||||
// Same wall: update Three.js mesh directly to avoid store churn
|
||||
const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId)
|
||||
if (draftMesh) {
|
||||
draftMesh.position.set(clampedX, clampedY, 0)
|
||||
draftMesh.rotation.set(0, itemRotation, 0)
|
||||
draftMesh.updateMatrixWorld(true)
|
||||
}
|
||||
markWallDirty(event.node.id)
|
||||
}
|
||||
}
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
width,
|
||||
height,
|
||||
draftRef.current?.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (!draftRef.current) return
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) return
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
)
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
draftRef.current.id,
|
||||
)
|
||||
if (!valid) return
|
||||
|
||||
const draft = draftRef.current
|
||||
draftRef.current = null
|
||||
|
||||
// Delete transient draft (paused, invisible to undo)
|
||||
useScene.getState().deleteNode(draft.id)
|
||||
|
||||
// Resume → create permanent node (single undoable action)
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
const levelId = getLevelId()
|
||||
const state = useScene.getState()
|
||||
const windowCount = Object.values(state.nodes).filter((n) => {
|
||||
if (n.type !== 'window') return false
|
||||
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
||||
return wall?.parentId === levelId
|
||||
}).length
|
||||
const name = `Window ${windowCount + 1}`
|
||||
|
||||
const node = WindowNode.parse({
|
||||
name,
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
width: draft.width,
|
||||
height: draft.height,
|
||||
windowType: draft.windowType,
|
||||
operationState: draft.operationState,
|
||||
awningDirection: draft.awningDirection,
|
||||
casementStyle: draft.casementStyle,
|
||||
hingesSide: draft.hingesSide,
|
||||
frameThickness: draft.frameThickness,
|
||||
frameDepth: draft.frameDepth,
|
||||
columnRatios: draft.columnRatios,
|
||||
rowRatios: draft.rowRatios,
|
||||
columnDividerThickness: draft.columnDividerThickness,
|
||||
rowDividerThickness: draft.rowDividerThickness,
|
||||
sill: draft.sill,
|
||||
sillDepth: draft.sillDepth,
|
||||
sillThickness: draft.sillThickness,
|
||||
})
|
||||
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().pause()
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallLeave = () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
emitter.on('wall:leave', onWallLeave)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('wall:enter', onWallEnter)
|
||||
emitter.off('wall:move', onWallMove)
|
||||
emitter.off('wall:click', onWallClick)
|
||||
emitter.off('wall:leave', onWallLeave)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cursor geometry: window outline rectangle (width × height × frameDepth)
|
||||
const boxGeo = new BoxGeometry(1.5, 1.5, 0.07)
|
||||
const edgesGeo = new EdgesGeometry(boxGeo)
|
||||
boxGeo.dispose()
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments
|
||||
geometry={edgesGeo}
|
||||
layers={EDITOR_LAYER}
|
||||
material={edgeMaterial}
|
||||
ref={edgesRef}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -33,10 +33,7 @@ export const tools: ToolConfig[] = [
|
||||
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
|
||||
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
||||
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
|
||||
// Registry-driven shelf node — placed via the registry-first ToolManager
|
||||
// shim from Phase 0. No icon file shipped; using a placeholder icon until
|
||||
// Phase 4 derives palette entries from `definition.presentation.icon`.
|
||||
{ id: 'shelf', iconSrc: '/icons/column.png', label: 'Shelf' },
|
||||
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
|
||||
]
|
||||
|
||||
export function StructureTools() {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
||||
|
||||
export function CeilingHelper() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ShortcutToken value="Left click" />
|
||||
<span className="text-muted-foreground">Add point</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ShortcutToken value="Shift" />
|
||||
<span className="text-muted-foreground">Allow non-45° angles</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ShortcutToken value="Esc" />
|
||||
<span className="text-muted-foreground">Cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,12 +4,9 @@ import { nodeRegistry } from '@pascal-app/core'
|
||||
import { useIsMobile } from '../../../hooks/use-mobile'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { BuildingHelper } from './building-helper'
|
||||
import { CeilingHelper } from './ceiling-helper'
|
||||
import { ItemHelper } from './item-helper'
|
||||
import { RegisteredToolHelper } from './registered-tool-helper'
|
||||
import { RoofHelper } from './roof-helper'
|
||||
import { SlabHelper } from './slab-helper'
|
||||
import { WallHelper } from './wall-helper'
|
||||
|
||||
export function HelperManager() {
|
||||
const mode = useEditor((s) => s.mode)
|
||||
@@ -29,10 +26,9 @@ export function HelperManager() {
|
||||
return null
|
||||
}
|
||||
|
||||
// Registry-first: if the active tool matches a registered kind whose
|
||||
// definition supplies `toolHints`, render via the generic helper.
|
||||
// Otherwise fall through to the hand-written per-tool helpers below.
|
||||
// Legacy helpers get deleted as their kind migrates `toolHints` in.
|
||||
// Registry-first: kinds with `def.toolHints` render through the generic
|
||||
// `RegisteredToolHelper`. Today that covers ceiling / door / fence /
|
||||
// item / shelf / slab / spawn / wall / window.
|
||||
if (tool) {
|
||||
const def = nodeRegistry.get(tool)
|
||||
if (def?.toolHints && def.toolHints.length > 0) {
|
||||
@@ -40,19 +36,9 @@ export function HelperManager() {
|
||||
}
|
||||
}
|
||||
|
||||
// Show appropriate helper based on current tool
|
||||
switch (tool) {
|
||||
case 'wall':
|
||||
return <WallHelper />
|
||||
case 'item':
|
||||
return <ItemHelper />
|
||||
case 'slab':
|
||||
return <SlabHelper />
|
||||
case 'ceiling':
|
||||
return <CeilingHelper />
|
||||
case 'roof':
|
||||
return <RoofHelper />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
// Legacy fallback — only `roof` remains because it hasn't migrated to
|
||||
// `def.tool` / `def.toolHints` yet (no Stage D port). When roof
|
||||
// migrates, this switch deletes outright.
|
||||
if (tool === 'roof') return <RoofHelper />
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
||||
|
||||
export function SlabHelper() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ShortcutToken value="Left click" />
|
||||
<span className="text-muted-foreground">Add point</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ShortcutToken value="Shift" />
|
||||
<span className="text-muted-foreground">Allow non-45° angles</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ShortcutToken value="Esc" />
|
||||
<span className="text-muted-foreground">Cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { ShortcutToken } from '../primitives/shortcut-token'
|
||||
|
||||
export function WallHelper() {
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-1/2 right-4 z-40 flex -translate-y-1/2 flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ShortcutToken value="Left click" />
|
||||
<span className="text-muted-foreground">Set wall start / end</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ShortcutToken value="Shift" />
|
||||
<span className="text-muted-foreground">Allow non-45° angles</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<ShortcutToken value="Esc" />
|
||||
<span className="text-muted-foreground">Cancel</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,933 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
COLUMN_PRESETS,
|
||||
type ColumnNode,
|
||||
type ColumnPresetId,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { ToggleControl } from '../controls/toggle-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
const SELECT_CLASS =
|
||||
'h-10 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground outline-none transition-colors hover:bg-[#3e3e3e] focus:ring-1 focus:ring-border'
|
||||
|
||||
const COLUMN_PRESET_OPTIONS = Object.entries(COLUMN_PRESETS).map(([value, preset]) => ({
|
||||
value: value as ColumnPresetId,
|
||||
label: preset.label,
|
||||
}))
|
||||
|
||||
const COLUMN_PROPORTION_PRESETS = {
|
||||
slender: {
|
||||
label: 'Slender',
|
||||
height: 3.6,
|
||||
width: 0.34,
|
||||
baseHeight: 0.18,
|
||||
capitalHeight: 0.16,
|
||||
baseWidthScale: 1.18,
|
||||
capitalWidthScale: 1.16,
|
||||
edgeSoftness: 0.02,
|
||||
},
|
||||
standard: {
|
||||
label: 'Standard',
|
||||
height: 2.9,
|
||||
width: 0.44,
|
||||
baseHeight: 0.22,
|
||||
capitalHeight: 0.2,
|
||||
baseWidthScale: 1.24,
|
||||
capitalWidthScale: 1.22,
|
||||
edgeSoftness: 0.025,
|
||||
},
|
||||
heavy: {
|
||||
label: 'Heavy',
|
||||
height: 3,
|
||||
width: 0.58,
|
||||
baseHeight: 0.28,
|
||||
capitalHeight: 0.26,
|
||||
baseWidthScale: 1.34,
|
||||
capitalWidthScale: 1.3,
|
||||
edgeSoftness: 0.035,
|
||||
},
|
||||
stout: {
|
||||
label: 'Short / Stout',
|
||||
height: 2.2,
|
||||
width: 0.62,
|
||||
baseHeight: 0.3,
|
||||
capitalHeight: 0.28,
|
||||
baseWidthScale: 1.38,
|
||||
capitalWidthScale: 1.34,
|
||||
edgeSoftness: 0.04,
|
||||
},
|
||||
} as const
|
||||
|
||||
type ColumnProportionPresetId = keyof typeof COLUMN_PROPORTION_PRESETS
|
||||
|
||||
const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(
|
||||
([value, preset]) => ({
|
||||
value: value as ColumnProportionPresetId,
|
||||
label: preset.label,
|
||||
}),
|
||||
)
|
||||
|
||||
const SUPPORT_STYLE_OPTIONS: Array<{ label: string; value: ColumnNode['supportStyle'] }> = [
|
||||
{ label: 'Vertical', value: 'vertical' },
|
||||
{ label: 'A-Frame', value: 'a-frame' },
|
||||
{ label: 'Y Support', value: 'y-frame' },
|
||||
{ label: 'V Support', value: 'v-frame' },
|
||||
{ label: 'X Brace', value: 'x-brace' },
|
||||
{ label: 'K Brace', value: 'k-brace' },
|
||||
{ label: 'Single Strut', value: 'single-strut' },
|
||||
{ label: 'Tripod', value: 'tripod' },
|
||||
{ label: 'Trestle', value: 'trestle' },
|
||||
{ label: 'Portal Frame', value: 'portal-frame' },
|
||||
{ label: 'Box Frame', value: 'box-frame' },
|
||||
]
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function presetUpdates(presetId: ColumnPresetId): Partial<ColumnNode> {
|
||||
const { label, ...preset } = COLUMN_PRESETS[presetId]
|
||||
return {
|
||||
name: label,
|
||||
supportStyle: 'supportStyle' in preset ? preset.supportStyle : 'vertical',
|
||||
...preset,
|
||||
}
|
||||
}
|
||||
|
||||
function proportionUpdates(
|
||||
node: ColumnNode,
|
||||
presetId: ColumnProportionPresetId,
|
||||
): Partial<ColumnNode> {
|
||||
const preset = COLUMN_PROPORTION_PRESETS[presetId]
|
||||
const depth =
|
||||
node.crossSection === 'rectangular'
|
||||
? clamp(preset.width * (node.depth / Math.max(node.width, 0.01)), 0.12, 1.6)
|
||||
: preset.width
|
||||
const shaftCornerRadius = Math.min(node.shaftCornerRadius ?? 0.035, preset.width * 0.18)
|
||||
|
||||
return {
|
||||
height: preset.height,
|
||||
width: preset.width,
|
||||
depth,
|
||||
radius: preset.width / 2,
|
||||
baseHeight: preset.baseHeight,
|
||||
capitalHeight: preset.capitalHeight,
|
||||
baseWidthScale: preset.baseWidthScale,
|
||||
baseDepthScale: preset.baseWidthScale,
|
||||
capitalWidthScale: preset.capitalWidthScale,
|
||||
capitalDepthScale: preset.capitalWidthScale,
|
||||
edgeSoftness: preset.edgeSoftness,
|
||||
shaftCornerRadius,
|
||||
}
|
||||
}
|
||||
|
||||
function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial<ColumnNode> {
|
||||
if (shaftProfile === 'tapered') {
|
||||
return {
|
||||
shaftProfile,
|
||||
shaftTaper: 0.14,
|
||||
shaftBulge: 0,
|
||||
shaftStartScale: 0.82,
|
||||
shaftEndScale: 0.72,
|
||||
shaftSegmentCount: 32,
|
||||
}
|
||||
}
|
||||
|
||||
if (shaftProfile === 'bulged') {
|
||||
return {
|
||||
shaftProfile,
|
||||
shaftTaper: 0,
|
||||
shaftBulge: 0.12,
|
||||
shaftStartScale: 0.68,
|
||||
shaftEndScale: 0.68,
|
||||
shaftSegmentCount: 32,
|
||||
}
|
||||
}
|
||||
|
||||
if (shaftProfile === 'hourglass') {
|
||||
return {
|
||||
shaftProfile,
|
||||
shaftTaper: 0,
|
||||
shaftBulge: 0.12,
|
||||
shaftStartScale: 0.84,
|
||||
shaftEndScale: 0.84,
|
||||
shaftSegmentCount: 32,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shaftProfile,
|
||||
shaftTaper: 0,
|
||||
shaftBulge: 0,
|
||||
shaftStartScale: 0.72,
|
||||
shaftEndScale: 0.72,
|
||||
shaftSegmentCount: 1,
|
||||
shaftTwistStep: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function ColumnPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ColumnNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<ColumnNode>) => {
|
||||
if (!selectedId) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
},
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!selectedId) return
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
deleteNode(selectedId as AnyNode['id'])
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [deleteNode, selectedId, setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
if (!(node && node.type === 'column' && selectedId && selectedCount === 1)) return null
|
||||
const shaftProfile = node.shaftProfile ?? 'straight'
|
||||
const supportStyle = node.supportStyle ?? 'vertical'
|
||||
const isBraceSupport =
|
||||
supportStyle === 'a-frame' ||
|
||||
supportStyle === 'y-frame' ||
|
||||
supportStyle === 'v-frame' ||
|
||||
supportStyle === 'x-brace' ||
|
||||
supportStyle === 'k-brace' ||
|
||||
supportStyle === 'single-strut' ||
|
||||
supportStyle === 'tripod' ||
|
||||
supportStyle === 'trestle' ||
|
||||
supportStyle === 'portal-frame' ||
|
||||
supportStyle === 'box-frame'
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/column.png"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Column'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Preset">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value) return
|
||||
handleUpdate(presetUpdates(event.target.value as ColumnPresetId))
|
||||
}}
|
||||
value=""
|
||||
>
|
||||
<option value="">Apply preset...</option>
|
||||
{COLUMN_PRESET_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Shape">
|
||||
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
|
||||
{SUPPORT_STYLE_OPTIONS.map((option) => {
|
||||
const isSelected = supportStyle === option.value
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-h-12 items-center rounded-lg border px-2.5 text-left text-xs transition-colors',
|
||||
isSelected
|
||||
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
|
||||
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
||||
)}
|
||||
key={option.value}
|
||||
onClick={() =>
|
||||
handleUpdate({
|
||||
supportStyle: option.value,
|
||||
...(option.value !== 'vertical'
|
||||
? {
|
||||
crossSection: 'rectangular',
|
||||
width: node.braceWidth ?? node.width,
|
||||
depth: node.braceDepth ?? node.depth,
|
||||
baseStyle: 'none',
|
||||
capitalStyle: 'none',
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate font-medium">{option.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{isBraceSupport ? (
|
||||
<>
|
||||
<SliderControl
|
||||
label="Brace Width"
|
||||
max={0.8}
|
||||
min={0.04}
|
||||
onChange={(value) => handleUpdate({ braceWidth: value, width: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={node.braceWidth ?? node.width}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Brace Depth"
|
||||
max={0.8}
|
||||
min={0.04}
|
||||
onChange={(value) => handleUpdate({ braceDepth: value, depth: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={node.braceDepth ?? node.depth}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) =>
|
||||
handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })
|
||||
}
|
||||
value={node.crossSection}
|
||||
>
|
||||
<option value="round">Round</option>
|
||||
<option value="square">Square</option>
|
||||
<option value="rectangular">Rectangular</option>
|
||||
</select>
|
||||
<SliderControl
|
||||
label="Edge Softness"
|
||||
max={0.12}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ edgeSoftness: value })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.edgeSoftness ?? 0.025}
|
||||
/>
|
||||
{(node.crossSection === 'square' || node.crossSection === 'rectangular') && (
|
||||
<SliderControl
|
||||
label="Shaft Corner Radius"
|
||||
max={0.3}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftCornerRadius: value })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.shaftCornerRadius ?? 0.035}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Dimensions">
|
||||
{!isBraceSupport && (
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value) return
|
||||
handleUpdate(proportionUpdates(node, event.target.value as ColumnProportionPresetId))
|
||||
}}
|
||||
value=""
|
||||
>
|
||||
<option value="">Apply proportion...</option>
|
||||
{COLUMN_PROPORTION_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={6}
|
||||
min={0.8}
|
||||
onChange={(value) => handleUpdate({ height: value })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={node.height}
|
||||
/>
|
||||
{isBraceSupport ? (
|
||||
<>
|
||||
{(supportStyle === 'a-frame' ||
|
||||
supportStyle === 'x-brace' ||
|
||||
supportStyle === 'k-brace' ||
|
||||
supportStyle === 'single-strut' ||
|
||||
supportStyle === 'tripod' ||
|
||||
supportStyle === 'trestle' ||
|
||||
supportStyle === 'portal-frame' ||
|
||||
supportStyle === 'box-frame') && (
|
||||
<SliderControl
|
||||
label="Bottom Spread"
|
||||
max={4}
|
||||
min={0.2}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
braceBottomSpread: value,
|
||||
braceTopSpread:
|
||||
supportStyle === 'a-frame'
|
||||
? Math.min(node.braceTopSpread ?? 0.12, value)
|
||||
: (node.braceTopSpread ?? 1),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={node.braceBottomSpread ?? 1.2}
|
||||
/>
|
||||
)}
|
||||
<SliderControl
|
||||
label={supportStyle === 'y-frame' ? 'Fork Spread' : 'Top Spread'}
|
||||
max={
|
||||
supportStyle === 'y-frame' ||
|
||||
supportStyle === 'v-frame' ||
|
||||
supportStyle === 'x-brace' ||
|
||||
supportStyle === 'k-brace' ||
|
||||
supportStyle === 'single-strut' ||
|
||||
supportStyle === 'tripod' ||
|
||||
supportStyle === 'trestle' ||
|
||||
supportStyle === 'box-frame'
|
||||
? 4
|
||||
: Math.max(0.2, node.braceBottomSpread ?? 1.2)
|
||||
}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ braceTopSpread: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={
|
||||
node.braceTopSpread ??
|
||||
(supportStyle === 'y-frame' ||
|
||||
supportStyle === 'v-frame' ||
|
||||
supportStyle === 'x-brace' ||
|
||||
supportStyle === 'k-brace' ||
|
||||
supportStyle === 'single-strut' ||
|
||||
supportStyle === 'tripod' ||
|
||||
supportStyle === 'trestle' ||
|
||||
supportStyle === 'portal-frame' ||
|
||||
supportStyle === 'box-frame'
|
||||
? 1
|
||||
: 0.12)
|
||||
}
|
||||
/>
|
||||
<ToggleControl
|
||||
checked={node.bracePlateEnabled ?? true}
|
||||
label="Connector Plates"
|
||||
onChange={(checked) => handleUpdate({ bracePlateEnabled: checked })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SliderControl
|
||||
label="Width"
|
||||
max={1.6}
|
||||
min={0.12}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
width: value,
|
||||
radius: value / 2,
|
||||
...(node.crossSection === 'rectangular' ? {} : { depth: value }),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={node.width}
|
||||
/>
|
||||
{node.crossSection === 'rectangular' && (
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
max={1.6}
|
||||
min={0.12}
|
||||
onChange={(value) => handleUpdate({ depth: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={node.depth}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
{!isBraceSupport && (
|
||||
<PanelSection title="Shaft">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) =>
|
||||
handleUpdate(
|
||||
shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']),
|
||||
)
|
||||
}
|
||||
value={shaftProfile}
|
||||
>
|
||||
<option value="straight">Straight</option>
|
||||
<option value="tapered">Tapered</option>
|
||||
<option value="bulged">Bulged</option>
|
||||
<option value="hourglass">Hourglass</option>
|
||||
</select>
|
||||
{shaftProfile === 'straight' && (
|
||||
<SliderControl
|
||||
label="Shaft Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.72}
|
||||
/>
|
||||
)}
|
||||
{shaftProfile === 'tapered' && (
|
||||
<>
|
||||
<SliderControl
|
||||
label="Bottom Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftStartScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.82}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Top Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ shaftEndScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftEndScale ?? 0.72}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Taper"
|
||||
max={0.45}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftTaper: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.shaftTaper ?? 0.14}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{shaftProfile === 'bulged' && (
|
||||
<>
|
||||
<SliderControl
|
||||
label="End Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ shaftStartScale: value, shaftEndScale: value })
|
||||
}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.68}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Bulge"
|
||||
max={0.35}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftBulge: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.shaftBulge ?? 0.12}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{shaftProfile === 'hourglass' && (
|
||||
<>
|
||||
<SliderControl
|
||||
label="End Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ shaftStartScale: value, shaftEndScale: value })
|
||||
}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.84}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Waist"
|
||||
max={0.35}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ shaftBulge: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.shaftBulge ?? 0.12}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Segment Twist"
|
||||
max={90}
|
||||
min={-90}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
shaftTwistStep: value,
|
||||
...(Math.abs(value) > 0.001 && (node.shaftSegmentCount ?? 1) < 8
|
||||
? { shaftSegmentCount: 12 }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
precision={0}
|
||||
step={5}
|
||||
unit="°"
|
||||
value={node.shaftTwistStep ?? 0}
|
||||
/>
|
||||
{Math.abs(node.shaftTwistStep ?? 0) > 0.001 && (
|
||||
<SliderControl
|
||||
label="Twist Segments"
|
||||
max={48}
|
||||
min={4}
|
||||
onChange={(value) => handleUpdate({ shaftSegmentCount: Math.round(value) })}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={node.shaftSegmentCount ?? 12}
|
||||
/>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Ring Pairs"
|
||||
max={4}
|
||||
min={0}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
ringCount: Math.round(value) * 2,
|
||||
ringPlacement: 'ends',
|
||||
ringSpread: node.ringSpread ?? 0.16,
|
||||
ringThickness: node.ringThickness ?? 0.055,
|
||||
})
|
||||
}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={Math.ceil((node.ringCount ?? 0) / 2)}
|
||||
/>
|
||||
{(node.ringCount ?? 0) > 0 && (
|
||||
<SliderControl
|
||||
label="Ring Thickness"
|
||||
max={0.14}
|
||||
min={0.01}
|
||||
onChange={(value) => handleUpdate({ ringThickness: value })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={node.ringThickness ?? 0.055}
|
||||
/>
|
||||
)}
|
||||
{(node.ringCount ?? 0) > 0 && (
|
||||
<SliderControl
|
||||
label="Ring Spread"
|
||||
max={0.45}
|
||||
min={0.04}
|
||||
onChange={(value) => handleUpdate({ ringSpread: value, ringPlacement: 'ends' })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.ringSpread ?? 0.16}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{!isBraceSupport && (
|
||||
<PanelSection title="Ends">
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) => {
|
||||
const capitalStyle = event.target.value as ColumnNode['capitalStyle']
|
||||
handleUpdate({
|
||||
capitalStyle,
|
||||
...(capitalStyle === 'none'
|
||||
? {}
|
||||
: {
|
||||
capitalHeight: Math.max(node.capitalHeight, 0.12),
|
||||
capitalTierCount:
|
||||
capitalStyle === 'stepped'
|
||||
? Math.max(node.capitalTierCount ?? 3, 3)
|
||||
: node.capitalTierCount,
|
||||
capitalWidthScale: Math.max(
|
||||
node.capitalWidthScale ?? 1.3,
|
||||
capitalStyle === 'stepped' ? 1.42 : 1.28,
|
||||
),
|
||||
capitalDepthScale: Math.max(
|
||||
node.capitalDepthScale ?? 1.3,
|
||||
capitalStyle === 'stepped' ? 1.42 : 1.28,
|
||||
),
|
||||
capitalStepSpread:
|
||||
capitalStyle === 'stepped'
|
||||
? Math.max(node.capitalStepSpread ?? 0.34, 0.34)
|
||||
: node.capitalStepSpread,
|
||||
}),
|
||||
})
|
||||
}}
|
||||
value={node.capitalStyle === 'simple-slab' ? 'simple' : (node.capitalStyle ?? 'simple')}
|
||||
>
|
||||
<option value="none">No Top</option>
|
||||
<option value="simple">Simple Top</option>
|
||||
<option value="stepped">Stepped Top</option>
|
||||
<option value="rounded">Rounded Top</option>
|
||||
</select>
|
||||
{node.capitalStyle !== 'none' && (
|
||||
<SliderControl
|
||||
label="Top Height"
|
||||
max={0.8}
|
||||
min={0.06}
|
||||
onChange={(value) => handleUpdate({ capitalHeight: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={node.capitalHeight}
|
||||
/>
|
||||
)}
|
||||
{node.capitalStyle !== 'none' && (
|
||||
<SliderControl
|
||||
label="Top Width"
|
||||
max={2.4}
|
||||
min={0.6}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
capitalWidthScale: value,
|
||||
...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.capitalWidthScale ?? 1.28}
|
||||
/>
|
||||
)}
|
||||
{node.capitalStyle !== 'none' && node.crossSection === 'rectangular' && (
|
||||
<SliderControl
|
||||
label="Top Depth"
|
||||
max={2.4}
|
||||
min={0.6}
|
||||
onChange={(value) => handleUpdate({ capitalDepthScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.capitalDepthScale ?? node.capitalWidthScale ?? 1.28}
|
||||
/>
|
||||
)}
|
||||
{node.capitalStyle === 'stepped' && (
|
||||
<SliderControl
|
||||
label="Top Tiers"
|
||||
max={8}
|
||||
min={3}
|
||||
onChange={(value) => handleUpdate({ capitalTierCount: Math.round(value) })}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={node.capitalTierCount ?? 3}
|
||||
/>
|
||||
)}
|
||||
{node.capitalStyle === 'stepped' && (
|
||||
<SliderControl
|
||||
label="Top Step Spread"
|
||||
max={0.9}
|
||||
min={0.05}
|
||||
onChange={(value) => handleUpdate({ capitalStepSpread: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.capitalStepSpread ?? 0.34}
|
||||
/>
|
||||
)}
|
||||
<select
|
||||
className={`${SELECT_CLASS} mt-2`}
|
||||
onChange={(event) => {
|
||||
const baseStyle = event.target.value as ColumnNode['baseStyle']
|
||||
handleUpdate({
|
||||
baseStyle,
|
||||
...(baseStyle === 'none'
|
||||
? {}
|
||||
: {
|
||||
baseHeight: Math.max(node.baseHeight, 0.12),
|
||||
baseTierCount:
|
||||
baseStyle === 'stepped-square'
|
||||
? Math.max(node.baseTierCount ?? 3, 3)
|
||||
: node.baseTierCount,
|
||||
baseWidthScale: Math.max(
|
||||
node.baseWidthScale ?? 1.24,
|
||||
baseStyle === 'stepped-square' ? 1.42 : 1.24,
|
||||
),
|
||||
baseDepthScale: Math.max(
|
||||
node.baseDepthScale ?? 1.24,
|
||||
baseStyle === 'stepped-square' ? 1.42 : 1.24,
|
||||
),
|
||||
baseStepSpread:
|
||||
baseStyle === 'stepped-square'
|
||||
? Math.max(node.baseStepSpread ?? 0.34, 0.34)
|
||||
: node.baseStepSpread,
|
||||
basePlinthHeightRatio:
|
||||
baseStyle === 'round-rings'
|
||||
? (node.basePlinthHeightRatio ?? 0.44)
|
||||
: node.basePlinthHeightRatio,
|
||||
baseRoundBandScale:
|
||||
baseStyle === 'round-rings'
|
||||
? (node.baseRoundBandScale ?? 0.92)
|
||||
: node.baseRoundBandScale,
|
||||
baseNeckScale:
|
||||
baseStyle === 'round-rings'
|
||||
? (node.baseNeckScale ?? 0.72)
|
||||
: node.baseNeckScale,
|
||||
}),
|
||||
})
|
||||
}}
|
||||
value={node.baseStyle ?? 'square-plinth'}
|
||||
>
|
||||
<option value="none">No Bottom</option>
|
||||
<option value="simple-square">Simple Block Bottom</option>
|
||||
<option value="square-plinth">Square Plinth Bottom</option>
|
||||
<option value="stepped-square">Stepped Bottom</option>
|
||||
<option value="round-rings">Rounded Bottom</option>
|
||||
</select>
|
||||
{node.baseStyle !== 'none' && (
|
||||
<SliderControl
|
||||
label="Bottom Height"
|
||||
max={0.8}
|
||||
min={0.06}
|
||||
onChange={(value) => handleUpdate({ baseHeight: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={node.baseHeight}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle !== 'none' && (
|
||||
<SliderControl
|
||||
label="Bottom Width"
|
||||
max={2.4}
|
||||
min={0.6}
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
baseWidthScale: value,
|
||||
...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }),
|
||||
})
|
||||
}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.baseWidthScale ?? 1.24}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle !== 'none' && node.crossSection === 'rectangular' && (
|
||||
<SliderControl
|
||||
label="Bottom Depth"
|
||||
max={2.4}
|
||||
min={0.6}
|
||||
onChange={(value) => handleUpdate({ baseDepthScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.baseDepthScale ?? node.baseWidthScale ?? 1.24}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle === 'round-rings' && (
|
||||
<SliderControl
|
||||
label="Plinth Thickness"
|
||||
max={0.7}
|
||||
min={0.2}
|
||||
onChange={(value) => handleUpdate({ basePlinthHeightRatio: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.basePlinthHeightRatio ?? 0.44}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle === 'round-rings' && (
|
||||
<SliderControl
|
||||
label="Round Band Width"
|
||||
max={1.2}
|
||||
min={0.5}
|
||||
onChange={(value) => handleUpdate({ baseRoundBandScale: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.baseRoundBandScale ?? 0.92}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle === 'round-rings' && (
|
||||
<SliderControl
|
||||
label="Neck Width"
|
||||
max={1}
|
||||
min={0.35}
|
||||
onChange={(value) => handleUpdate({ baseNeckScale: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.baseNeckScale ?? 0.72}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle === 'stepped-square' && (
|
||||
<SliderControl
|
||||
label="Bottom Tiers"
|
||||
max={8}
|
||||
min={3}
|
||||
onChange={(value) => handleUpdate({ baseTierCount: Math.round(value) })}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={node.baseTierCount ?? 3}
|
||||
/>
|
||||
)}
|
||||
{node.baseStyle === 'stepped-square' && (
|
||||
<SliderControl
|
||||
label="Bottom Step Spread"
|
||||
max={0.9}
|
||||
min={0.05}
|
||||
onChange={(value) => handleUpdate({ baseStepSpread: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
value={node.baseStepSpread ?? 0.34}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Transform">
|
||||
<SliderControl
|
||||
label="Yaw"
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(value) => handleUpdate({ rotation: (value * Math.PI) / 180 })}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,934 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type ElevatorNode,
|
||||
ElevatorNode as ElevatorNodeSchema,
|
||||
type LevelNode,
|
||||
requestElevatorLevel,
|
||||
useInteractive,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Send, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { resolveElevatorNodeSupportY, resolveElevatorSupportY } from '../../../lib/elevator-support'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
function findLevelId(levels: LevelNode[], levelId: string | null | undefined) {
|
||||
if (!levelId) return null
|
||||
return levels.some((level) => level.id === levelId) ? levelId : null
|
||||
}
|
||||
|
||||
function getLegacyServedLevels(node: ElevatorNode | undefined, levels: LevelNode[]) {
|
||||
if (!node || node.fromLevelId || node.toLevelId || !node.servedLevelIds?.length) return []
|
||||
const servedIds = new Set(node.servedLevelIds)
|
||||
return levels.filter((level) => servedIds.has(level.id))
|
||||
}
|
||||
|
||||
function getResolvedFromLevelId(node: ElevatorNode | undefined, levels: LevelNode[]) {
|
||||
if (!node) return levels[0]?.id ?? ''
|
||||
const legacyServedLevels = getLegacyServedLevels(node, levels)
|
||||
return (
|
||||
findLevelId(levels, node.fromLevelId) ??
|
||||
legacyServedLevels[0]?.id ??
|
||||
findLevelId(levels, node.defaultLevelId) ??
|
||||
levels[0]?.id ??
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
function getResolvedToLevelId(
|
||||
node: ElevatorNode | undefined,
|
||||
levels: LevelNode[],
|
||||
fromLevelId: string,
|
||||
) {
|
||||
if (!node) return levels[0]?.id ?? ''
|
||||
|
||||
const explicitTo = findLevelId(levels, node.toLevelId)
|
||||
if (explicitTo) return explicitTo
|
||||
const legacyServedLevels = getLegacyServedLevels(node, levels)
|
||||
const legacyTo = legacyServedLevels[legacyServedLevels.length - 1]?.id
|
||||
if (legacyTo) return legacyTo
|
||||
|
||||
const fromIndex = levels.findIndex((level) => level.id === fromLevelId)
|
||||
const fallbackIndex = fromIndex >= 0 ? Math.min(fromIndex + 1, levels.length - 1) : 0
|
||||
return levels[fallbackIndex]?.id ?? fromLevelId
|
||||
}
|
||||
|
||||
function getServiceLevels(levels: LevelNode[], fromLevelId: string, toLevelId: string) {
|
||||
const fromIndex = levels.findIndex((level) => level.id === fromLevelId)
|
||||
const toIndex = levels.findIndex((level) => level.id === toLevelId)
|
||||
if (fromIndex < 0 && toIndex < 0) return []
|
||||
|
||||
const resolvedFromIndex = fromIndex >= 0 ? fromIndex : toIndex
|
||||
const resolvedToIndex =
|
||||
toIndex >= 0 ? toIndex : Math.min(Math.max(resolvedFromIndex, 0) + 1, levels.length - 1)
|
||||
const minIndex = Math.min(resolvedFromIndex, resolvedToIndex)
|
||||
const maxIndex = Math.max(resolvedFromIndex, resolvedToIndex)
|
||||
|
||||
return levels.slice(minIndex, maxIndex + 1)
|
||||
}
|
||||
|
||||
function stripDuplicateFlags(metadata: ElevatorNode['metadata']) {
|
||||
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
|
||||
return metadata
|
||||
}
|
||||
|
||||
const nextMeta = { ...(metadata as Record<string, unknown>) }
|
||||
delete nextMeta.isNew
|
||||
delete nextMeta.isTransient
|
||||
return nextMeta as ElevatorNode['metadata']
|
||||
}
|
||||
|
||||
type ElevatorMetricKey =
|
||||
| 'width'
|
||||
| 'depth'
|
||||
| 'shaftWidth'
|
||||
| 'shaftDepth'
|
||||
| 'shaftWallThickness'
|
||||
| 'cabHeight'
|
||||
| 'doorWidth'
|
||||
| 'doorHeight'
|
||||
|
||||
type ElevatorAccessField = 'disabledLevelIds' | 'serviceOnlyLevelIds'
|
||||
|
||||
const DOOR_STYLE_OPTIONS: Array<{
|
||||
label: string
|
||||
value: ElevatorNode['doorStyle']
|
||||
}> = [
|
||||
{ label: 'Center opening', value: 'center-opening' },
|
||||
{ label: 'Single left', value: 'single-left' },
|
||||
{ label: 'Single right', value: 'single-right' },
|
||||
]
|
||||
|
||||
const DOOR_PANEL_STYLE_OPTIONS: Array<{
|
||||
label: string
|
||||
value: ElevatorNode['doorPanelStyle']
|
||||
}> = [
|
||||
{ label: 'Glass frame', value: 'glass-frame' },
|
||||
{ label: 'Solid panel', value: 'solid-panel' },
|
||||
{ label: 'Segmented panel', value: 'segmented-panel' },
|
||||
]
|
||||
|
||||
const SHAFT_STYLE_OPTIONS: Array<{
|
||||
label: string
|
||||
value: ElevatorNode['shaftStyle']
|
||||
}> = [
|
||||
{ label: 'Solid', value: 'solid' },
|
||||
{ label: 'Glass', value: 'glass' },
|
||||
]
|
||||
|
||||
function roundMeters(value: number) {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
function getResolvedShaftWidth(node: ElevatorNode) {
|
||||
return Math.max(node.shaftWidth ?? node.width, node.width, 0.8)
|
||||
}
|
||||
|
||||
function getResolvedShaftDepth(node: ElevatorNode) {
|
||||
return Math.max(node.shaftDepth ?? node.depth, node.depth, 0.8)
|
||||
}
|
||||
|
||||
function getResolvedShaftWallThickness(node: ElevatorNode) {
|
||||
return Math.max(node.shaftWallThickness ?? 0.09, 0.04)
|
||||
}
|
||||
|
||||
function radiansToDegrees(radians: number) {
|
||||
return Math.round((radians * 180) / Math.PI)
|
||||
}
|
||||
|
||||
function degreesToRadians(degrees: number) {
|
||||
return (degrees * Math.PI) / 180
|
||||
}
|
||||
|
||||
export function ElevatorPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const runtime = useInteractive(
|
||||
useShallow((s) => {
|
||||
const state = selectedId ? s.elevators[selectedId as AnyNodeId] : null
|
||||
if (!state) return null
|
||||
return {
|
||||
currentLevelId: state.currentLevelId,
|
||||
queue: state.queue,
|
||||
targetLevelId: state.targetLevelId,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ElevatorNode | undefined) : undefined,
|
||||
)
|
||||
const liveOverrides = useLiveNodeOverrides((s) =>
|
||||
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
|
||||
)
|
||||
const liveTransform = useLiveTransforms((s) =>
|
||||
selectedId ? s.get(selectedId as AnyNodeId) : undefined,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!selectedId) return
|
||||
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
|
||||
useLiveTransforms.getState().clear(selectedId as AnyNodeId)
|
||||
}
|
||||
}, [selectedId])
|
||||
|
||||
const levels = useScene(
|
||||
useShallow((s) => {
|
||||
if (!(node?.parentId && s.nodes[node.parentId as AnyNodeId]?.type === 'building')) return []
|
||||
const building = s.nodes[node.parentId as AnyNodeId]
|
||||
if (building?.type !== 'building') return []
|
||||
return building.children
|
||||
.map((childId) => s.nodes[childId as AnyNodeId])
|
||||
.filter((entry): entry is LevelNode => entry?.type === 'level')
|
||||
.sort((left, right) => left.level - right.level)
|
||||
}),
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<ElevatorNode>) => {
|
||||
if (!selectedId) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
},
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const clearLivePreview = useCallback(() => {
|
||||
if (!selectedId) return
|
||||
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
|
||||
useLiveTransforms.getState().clear(selectedId as AnyNodeId)
|
||||
}, [selectedId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!(selectedId && node?.type === 'elevator')) return
|
||||
const supportY = resolveElevatorNodeSupportY(node)
|
||||
if (node.position[1] >= supportY - 1e-4) return
|
||||
|
||||
updateNode(selectedId as AnyNode['id'], {
|
||||
position: [node.position[0], supportY, node.position[2]],
|
||||
})
|
||||
}, [
|
||||
node?.defaultLevelId,
|
||||
node?.fromLevelId,
|
||||
node?.id,
|
||||
node?.parentId,
|
||||
node?.position[0],
|
||||
node?.position[1],
|
||||
node?.position[2],
|
||||
node?.type,
|
||||
selectedId,
|
||||
updateNode,
|
||||
])
|
||||
|
||||
const previewMetric = useCallback(
|
||||
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
|
||||
if (!selectedId) return
|
||||
useLiveNodeOverrides.getState().set(selectedId as AnyNodeId, { [key]: value })
|
||||
},
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const commitMetric = useCallback(
|
||||
<K extends ElevatorMetricKey>(key: K, value: ElevatorNode[K]) => {
|
||||
if (!selectedId) return
|
||||
|
||||
const hasChange = !(node && Math.abs(Number(node[key]) - Number(value)) <= 1e-6)
|
||||
if (hasChange) {
|
||||
updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<ElevatorNode>)
|
||||
}
|
||||
useLiveNodeOverrides.getState().clear(selectedId as AnyNodeId)
|
||||
},
|
||||
[node, selectedId, updateNode],
|
||||
)
|
||||
|
||||
const previewTransform = useCallback(
|
||||
(position: ElevatorNode['position'], rotation: ElevatorNode['rotation']) => {
|
||||
if (!selectedId) return
|
||||
useLiveTransforms.getState().set(selectedId as AnyNodeId, { position, rotation })
|
||||
},
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const commitTransform = useCallback(
|
||||
(position: ElevatorNode['position'], rotation: ElevatorNode['rotation']) => {
|
||||
if (!(selectedId && node)) return
|
||||
useLiveTransforms.getState().clear(selectedId as AnyNodeId)
|
||||
const positionChanged = node.position.some(
|
||||
(value, index) => Math.abs(value - position[index]!) > 1e-6,
|
||||
)
|
||||
const rotationChanged = Math.abs(node.rotation - rotation) > 1e-6
|
||||
if (positionChanged || rotationChanged) {
|
||||
updateNode(selectedId as AnyNode['id'], { position, rotation })
|
||||
}
|
||||
},
|
||||
[node, selectedId, updateNode],
|
||||
)
|
||||
|
||||
const getSupportedPosition = useCallback(
|
||||
(x: number, z: number): ElevatorNode['position'] => {
|
||||
if (!node) return [x, 0, z]
|
||||
const supportY = resolveElevatorSupportY({
|
||||
buildingId: node.parentId,
|
||||
preferredLevelId: node.fromLevelId ?? node.defaultLevelId,
|
||||
x,
|
||||
z,
|
||||
})
|
||||
return [x, supportY, z]
|
||||
},
|
||||
[node],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
clearLivePreview()
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [clearLivePreview, setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
clearLivePreview()
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [clearLivePreview, node, setMovingNode, setSelection])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!(node && node.parentId)) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
|
||||
const duplicate = ElevatorNodeSchema.parse({
|
||||
...structuredClone(node),
|
||||
id: undefined,
|
||||
name: node.name ? `${node.name} Copy` : 'Elevator Copy',
|
||||
position: [node.position[0] + 1, node.position[1], node.position[2] + 1],
|
||||
metadata: { ...(stripDuplicateFlags(node.metadata) as Record<string, unknown>), isNew: true },
|
||||
})
|
||||
|
||||
createNode(duplicate, node.parentId as AnyNodeId)
|
||||
clearLivePreview()
|
||||
setMovingNode(duplicate)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [clearLivePreview, node, createNode, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
clearLivePreview()
|
||||
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [clearLivePreview, selectedId, node, setSelection])
|
||||
|
||||
const requestLevel = useCallback(
|
||||
(levelId: LevelNode['id']) => {
|
||||
if (!node) return
|
||||
if ((node.disabledLevelIds ?? []).includes(levelId)) return
|
||||
requestElevatorLevel(node.id as AnyNodeId, levelId as AnyNodeId)
|
||||
},
|
||||
[node],
|
||||
)
|
||||
|
||||
const toggleLevelAccess = useCallback(
|
||||
(field: ElevatorAccessField, levelId: LevelNode['id']) => {
|
||||
if (!node) return
|
||||
const disabledIds = new Set(node.disabledLevelIds ?? [])
|
||||
const serviceOnlyIds = new Set(node.serviceOnlyLevelIds ?? [])
|
||||
const targetSet = field === 'disabledLevelIds' ? disabledIds : serviceOnlyIds
|
||||
|
||||
if (targetSet.has(levelId)) {
|
||||
targetSet.delete(levelId)
|
||||
} else {
|
||||
targetSet.add(levelId)
|
||||
}
|
||||
|
||||
if (field === 'disabledLevelIds' && disabledIds.has(levelId)) {
|
||||
serviceOnlyIds.delete(levelId)
|
||||
}
|
||||
if (field === 'serviceOnlyLevelIds' && serviceOnlyIds.has(levelId)) {
|
||||
disabledIds.delete(levelId)
|
||||
}
|
||||
|
||||
const nextServiceLevels = getServiceLevels(
|
||||
levels,
|
||||
getResolvedFromLevelId(node, levels),
|
||||
getResolvedToLevelId(node, levels, getResolvedFromLevelId(node, levels)),
|
||||
)
|
||||
const nextDefaultLevelId =
|
||||
node.defaultLevelId && !disabledIds.has(node.defaultLevelId)
|
||||
? node.defaultLevelId
|
||||
: (nextServiceLevels.find((level) => !disabledIds.has(level.id))?.id ??
|
||||
nextServiceLevels[0]?.id ??
|
||||
null)
|
||||
|
||||
handleUpdate({
|
||||
defaultLevelId: nextDefaultLevelId,
|
||||
disabledLevelIds: Array.from(disabledIds),
|
||||
serviceOnlyLevelIds: Array.from(serviceOnlyIds),
|
||||
})
|
||||
},
|
||||
[handleUpdate, levels, node],
|
||||
)
|
||||
|
||||
const handleServiceBoundaryChange = useCallback(
|
||||
(field: 'fromLevelId' | 'toLevelId', levelId: string) => {
|
||||
if (!node) return
|
||||
const nextFromLevelId =
|
||||
field === 'fromLevelId' ? levelId : getResolvedFromLevelId(node, levels)
|
||||
const nextToLevelId =
|
||||
field === 'toLevelId' ? levelId : getResolvedToLevelId(node, levels, nextFromLevelId)
|
||||
const nextServedLevels = getServiceLevels(levels, nextFromLevelId, nextToLevelId)
|
||||
const currentDefaultIsServed = nextServedLevels.some(
|
||||
(level) => level.id === node.defaultLevelId,
|
||||
)
|
||||
|
||||
handleUpdate({
|
||||
[field]: levelId || null,
|
||||
defaultLevelId: currentDefaultIsServed
|
||||
? node.defaultLevelId
|
||||
: nextFromLevelId || nextServedLevels[0]?.id || null,
|
||||
...(field === 'fromLevelId'
|
||||
? {
|
||||
position: [
|
||||
node.position[0],
|
||||
resolveElevatorSupportY({
|
||||
buildingId: node.parentId,
|
||||
preferredLevelId: nextFromLevelId,
|
||||
x: node.position[0],
|
||||
z: node.position[2],
|
||||
}),
|
||||
node.position[2],
|
||||
] as ElevatorNode['position'],
|
||||
}
|
||||
: {}),
|
||||
servedLevelIds: undefined,
|
||||
} as Partial<ElevatorNode>)
|
||||
},
|
||||
[node, levels, handleUpdate],
|
||||
)
|
||||
|
||||
if (!(node && node.type === 'elevator' && selectedId && selectedCount === 1)) return null
|
||||
|
||||
const displayNode = liveOverrides ? ({ ...node, ...liveOverrides } as ElevatorNode) : node
|
||||
const displayPosition = liveTransform?.position ?? displayNode.position
|
||||
const displayRotation = liveTransform?.rotation ?? displayNode.rotation
|
||||
const displayRotationDegrees = radiansToDegrees(displayRotation)
|
||||
const displayShaftWidth = getResolvedShaftWidth(displayNode)
|
||||
const displayShaftDepth = getResolvedShaftDepth(displayNode)
|
||||
const displayShaftWallThickness = getResolvedShaftWallThickness(displayNode)
|
||||
const fromLevelId = getResolvedFromLevelId(node, levels)
|
||||
const toLevelId = getResolvedToLevelId(node, levels, fromLevelId)
|
||||
const servedLevels = getServiceLevels(levels, fromLevelId, toLevelId)
|
||||
const servedLevelIdSet = new Set<string>(servedLevels.map((level) => level.id))
|
||||
const disabledLevelIds = new Set(
|
||||
(node.disabledLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)),
|
||||
)
|
||||
const serviceOnlyLevelIds = new Set(
|
||||
(node.serviceOnlyLevelIds ?? []).filter((levelId) => servedLevelIdSet.has(levelId)),
|
||||
)
|
||||
const enabledServedLevels = servedLevels.filter((level) => !disabledLevelIds.has(level.id))
|
||||
const defaultLevelOptions =
|
||||
enabledServedLevels.length > 0 ? enabledServedLevels : servedLevels.length > 0 ? servedLevels : levels
|
||||
const selectedDefaultLevelId = defaultLevelOptions.some(
|
||||
(level) => level.id === node.defaultLevelId,
|
||||
)
|
||||
? (node.defaultLevelId ?? '')
|
||||
: fromLevelId
|
||||
const activeLevelId =
|
||||
runtime?.currentLevelId ??
|
||||
(servedLevels.some((level) => level.id === node.defaultLevelId)
|
||||
? node.defaultLevelId
|
||||
: fromLevelId || levels[0]?.id) ??
|
||||
null
|
||||
const destinationOrderByLevelId = new Map<string, number>()
|
||||
const orderedDestinationIds: string[] = []
|
||||
if (runtime?.targetLevelId) orderedDestinationIds.push(runtime.targetLevelId)
|
||||
for (const levelId of runtime?.queue ?? []) {
|
||||
if (!orderedDestinationIds.includes(levelId)) orderedDestinationIds.push(levelId)
|
||||
}
|
||||
orderedDestinationIds.forEach((levelId, index) => {
|
||||
destinationOrderByLevelId.set(levelId, index + 1)
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/elevator.svg"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Elevator'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="text-destructive hover:text-destructive"
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label="X"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(value) => {
|
||||
const position = getSupportedPosition(value, displayPosition[2])
|
||||
previewTransform(position, displayRotation)
|
||||
}}
|
||||
onCommit={(value) => {
|
||||
const position = getSupportedPosition(value, displayPosition[2])
|
||||
commitTransform(position, displayRotation)
|
||||
}}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={roundMeters(displayPosition[0])}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Y"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(value) => {
|
||||
const position: ElevatorNode['position'] = [
|
||||
displayPosition[0],
|
||||
value,
|
||||
displayPosition[2],
|
||||
]
|
||||
previewTransform(position, displayRotation)
|
||||
}}
|
||||
onCommit={(value) => {
|
||||
const position: ElevatorNode['position'] = [
|
||||
displayPosition[0],
|
||||
value,
|
||||
displayPosition[2],
|
||||
]
|
||||
commitTransform(position, displayRotation)
|
||||
}}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={roundMeters(displayPosition[1])}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Z"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(value) => {
|
||||
const position = getSupportedPosition(displayPosition[0], value)
|
||||
previewTransform(position, displayRotation)
|
||||
}}
|
||||
onCommit={(value) => {
|
||||
const position = getSupportedPosition(displayPosition[0], value)
|
||||
commitTransform(position, displayRotation)
|
||||
}}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={roundMeters(displayPosition[2])}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Rotation">
|
||||
<SliderControl
|
||||
label="Yaw"
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(degrees) => previewTransform(displayPosition, degreesToRadians(degrees))}
|
||||
onCommit={(degrees) => commitTransform(displayPosition, degreesToRadians(degrees))}
|
||||
precision={0}
|
||||
restoreOnCommit={false}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={displayRotationDegrees}
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
commitTransform(displayPosition, displayRotation - Math.PI / 4)
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
commitTransform(displayPosition, displayRotation + Math.PI / 4)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Cab">
|
||||
<MetricControl
|
||||
label="Width"
|
||||
max={4}
|
||||
min={0.8}
|
||||
onChange={(value) => previewMetric('width', value)}
|
||||
onCommit={(value) => commitMetric('width', value)}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={displayNode.width}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Depth"
|
||||
max={4}
|
||||
min={0.8}
|
||||
onChange={(value) => previewMetric('depth', value)}
|
||||
onCommit={(value) => commitMetric('depth', value)}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={displayNode.depth}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Cab Height"
|
||||
max={4}
|
||||
min={1.8}
|
||||
onChange={(value) => previewMetric('cabHeight', value)}
|
||||
onCommit={(value) => commitMetric('cabHeight', value)}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={displayNode.cabHeight}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Shaft">
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Shaft Style
|
||||
</div>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
|
||||
onChange={(event) =>
|
||||
handleUpdate({ shaftStyle: event.target.value as ElevatorNode['shaftStyle'] })
|
||||
}
|
||||
value={displayNode.shaftStyle ?? 'solid'}
|
||||
>
|
||||
{SHAFT_STYLE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<MetricControl
|
||||
label="Shaft Width"
|
||||
max={5}
|
||||
min={displayNode.width}
|
||||
onChange={(value) => previewMetric('shaftWidth', Math.max(value, displayNode.width))}
|
||||
onCommit={(value) => commitMetric('shaftWidth', Math.max(value, displayNode.width))}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={displayShaftWidth}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Shaft Depth"
|
||||
max={5}
|
||||
min={displayNode.depth}
|
||||
onChange={(value) => previewMetric('shaftDepth', Math.max(value, displayNode.depth))}
|
||||
onCommit={(value) => commitMetric('shaftDepth', Math.max(value, displayNode.depth))}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={displayShaftDepth}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Wall Thickness"
|
||||
max={0.4}
|
||||
min={0.04}
|
||||
onChange={(value) => previewMetric('shaftWallThickness', value)}
|
||||
onCommit={(value) => commitMetric('shaftWallThickness', value)}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={displayShaftWallThickness}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Doors">
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Opening Style
|
||||
</div>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
|
||||
onChange={(event) =>
|
||||
handleUpdate({ doorStyle: event.target.value as ElevatorNode['doorStyle'] })
|
||||
}
|
||||
value={displayNode.doorStyle ?? 'center-opening'}
|
||||
>
|
||||
{DOOR_STYLE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Door Type
|
||||
</div>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
|
||||
onChange={(event) =>
|
||||
handleUpdate({
|
||||
doorPanelStyle: event.target.value as ElevatorNode['doorPanelStyle'],
|
||||
})
|
||||
}
|
||||
value={displayNode.doorPanelStyle ?? 'glass-frame'}
|
||||
>
|
||||
{DOOR_PANEL_STYLE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<MetricControl
|
||||
label="Door Width"
|
||||
max={Math.max(displayNode.width - 0.1, 0.5)}
|
||||
min={0.45}
|
||||
onChange={(value) => previewMetric('doorWidth', value)}
|
||||
onCommit={(value) => commitMetric('doorWidth', value)}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={displayNode.doorWidth}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Door Height"
|
||||
max={Math.max(displayNode.cabHeight - 0.1, 1.3)}
|
||||
min={1.2}
|
||||
onChange={(value) => previewMetric('doorHeight', value)}
|
||||
onCommit={(value) => commitMetric('doorHeight', value)}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={displayNode.doorHeight}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Service">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
From
|
||||
</div>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground"
|
||||
onChange={(event) =>
|
||||
handleServiceBoundaryChange('fromLevelId', event.target.value)
|
||||
}
|
||||
value={fromLevelId}
|
||||
>
|
||||
{levels.map((level) => (
|
||||
<option key={level.id} value={level.id}>
|
||||
{level.name || `Level ${level.level}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
To
|
||||
</div>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-2 text-sm text-foreground"
|
||||
onChange={(event) => handleServiceBoundaryChange('toLevelId', event.target.value)}
|
||||
value={toLevelId}
|
||||
>
|
||||
{levels.map((level) => (
|
||||
<option key={level.id} value={level.id}>
|
||||
{level.name || `Level ${level.level}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Default Floor
|
||||
</div>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
|
||||
onChange={(event) => handleUpdate({ defaultLevelId: event.target.value || null })}
|
||||
value={selectedDefaultLevelId}
|
||||
>
|
||||
{defaultLevelOptions.map((level) => (
|
||||
<option key={level.id} value={level.id}>
|
||||
{level.name || `Level ${level.level}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Access">
|
||||
<div className="space-y-2">
|
||||
{servedLevels.map((level) => {
|
||||
const isDisabled = disabledLevelIds.has(level.id)
|
||||
const isServiceOnly = serviceOnlyLevelIds.has(level.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-border/45 bg-[#2C2C2E] px-2.5 py-2"
|
||||
key={level.id}
|
||||
>
|
||||
<span className="min-w-0 truncate text-sm">
|
||||
{level.name || `Level ${level.level}`}
|
||||
</span>
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
<button
|
||||
className={`rounded-md border px-2 py-1 text-[11px] transition-colors ${
|
||||
isServiceOnly
|
||||
? 'border-sky-300/45 bg-sky-400/15 text-sky-100'
|
||||
: 'border-border/50 bg-black/15 text-muted-foreground hover:text-foreground'
|
||||
} ${isDisabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
disabled={isDisabled}
|
||||
onClick={() => toggleLevelAccess('serviceOnlyLevelIds', level.id)}
|
||||
type="button"
|
||||
>
|
||||
Service
|
||||
</button>
|
||||
<button
|
||||
className={`rounded-md border px-2 py-1 text-[11px] transition-colors ${
|
||||
isDisabled
|
||||
? 'border-red-300/45 bg-red-400/15 text-red-100'
|
||||
: 'border-border/50 bg-black/15 text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => toggleLevelAccess('disabledLevelIds', level.id)}
|
||||
type="button"
|
||||
>
|
||||
Disabled
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Destination">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{servedLevels.map((level) => {
|
||||
const isActive = activeLevelId === level.id
|
||||
const stopOrder = destinationOrderByLevelId.get(level.id)
|
||||
const isDisabled = disabledLevelIds.has(level.id)
|
||||
const isServiceOnly = serviceOnlyLevelIds.has(level.id)
|
||||
return (
|
||||
<button
|
||||
className={`flex min-h-11 items-center justify-between gap-2 rounded-lg border px-2.5 text-left transition-colors ${
|
||||
isDisabled
|
||||
? 'cursor-not-allowed border-border/35 bg-[#202024] text-muted-foreground/55'
|
||||
: isActive
|
||||
? 'border-emerald-400/45 bg-emerald-400/15 text-emerald-100'
|
||||
: 'border-border/50 bg-[#2C2C2E] text-foreground hover:bg-[#3e3e3e]'
|
||||
}`}
|
||||
disabled={isDisabled}
|
||||
key={level.id}
|
||||
onClick={() => requestLevel(level.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-xs">{level.name || `Level ${level.level}`}</span>
|
||||
{isDisabled ? (
|
||||
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
|
||||
Disabled
|
||||
</span>
|
||||
) : isServiceOnly ? (
|
||||
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
|
||||
Service
|
||||
</span>
|
||||
) : (
|
||||
stopOrder && (
|
||||
<span className="mt-0.5 text-[10px] font-medium uppercase tracking-[0.12em] text-current/65">
|
||||
Stop {stopOrder}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`flex h-6 min-w-6 items-center justify-center rounded-full border border-white/15 bg-black/20 ${
|
||||
stopOrder ? 'px-1.5 font-mono text-[11px] font-semibold' : ''
|
||||
}`}
|
||||
>
|
||||
{isDisabled ? '×' : (stopOrder ?? <Send className="h-3 w-3" />)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Motion">
|
||||
<SliderControl
|
||||
label="Speed"
|
||||
max={8}
|
||||
min={0.5}
|
||||
onChange={(value) => handleUpdate({ speed: value })}
|
||||
precision={1}
|
||||
step={0.1}
|
||||
unit="m/s"
|
||||
value={node.speed}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Door Time"
|
||||
max={2200}
|
||||
min={300}
|
||||
onChange={(value) => handleUpdate({ doorDurationMs: value })}
|
||||
step={50}
|
||||
unit="ms"
|
||||
value={node.doorDurationMs}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Dwell"
|
||||
max={5000}
|
||||
min={300}
|
||||
onChange={(value) => handleUpdate({ dwellMs: value })}
|
||||
step={100}
|
||||
unit="ms"
|
||||
value={node.dwellMs}
|
||||
/>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, getScaledDimensions, ItemNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Link, Link2Off, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { CollectionsPopover } from './collections/collections-popover'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
export function ItemPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as ItemNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
const [uniformScale, setUniformScale] = useState(true)
|
||||
|
||||
// Panel slider-drag fix recipe (plans/editor-node-registry.md). Item
|
||||
// panel has scale + position + rotation sliders — same Maximum update
|
||||
// depth cascade risk as fence / wall / etc. without the nodeRef.
|
||||
const nodeRef = useRef(node)
|
||||
nodeRef.current = node
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<ItemNode>) => {
|
||||
if (!selectedId) return
|
||||
const n = nodeRef.current
|
||||
if (!n) return
|
||||
useScene.getState().updateNode(selectedId as AnyNode['id'], updates)
|
||||
|
||||
if (n.asset.attachTo === 'wall' && n.parentId) {
|
||||
requestAnimationFrame(() => {
|
||||
useScene.getState().dirtyNodes.add(n.parentId as AnyNode['id'])
|
||||
})
|
||||
}
|
||||
},
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (node) {
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
const proto = ItemNode.parse({
|
||||
position: [...node.position] as [number, number, number],
|
||||
rotation: [...node.rotation] as [number, number, number],
|
||||
name: node.name,
|
||||
asset: node.asset,
|
||||
parentId: node.parentId,
|
||||
side: node.side,
|
||||
metadata: { isNew: true },
|
||||
})
|
||||
setMovingNode(proto)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!selectedId) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
deleteNode(selectedId as AnyNode['id'])
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [selectedId, deleteNode, setSelection])
|
||||
|
||||
if (!(node && node.type === 'item' && selectedId)) return null
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon={node.asset.thumbnail || '/icons/furniture.png'}
|
||||
onClose={handleClose}
|
||||
title={node.name || node.asset.name}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={node.position[0] + 2}
|
||||
min={node.position[0] - 2}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ position: [value, node.position[1], node.position[2]] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={node.position[1] + 2}
|
||||
min={node.position[1] - 2}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ position: [node.position[0], value, node.position[2]] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
Z<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={node.position[2] + 2}
|
||||
min={node.position[2] - 2}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ position: [node.position[0], node.position[1], value] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Rotation">
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
Y<sub className="ml-[1px] text-[11px] opacity-70">rot</sub>
|
||||
</>
|
||||
}
|
||||
max={Math.round((node.rotation[1] * 180) / Math.PI) + 45}
|
||||
min={Math.round((node.rotation[1] * 180) / Math.PI) - 45}
|
||||
onChange={(degrees) => {
|
||||
const radians = (degrees * Math.PI) / 180
|
||||
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation[1] * 180) / Math.PI)}
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
||||
const radians = ((currentDegrees - 45) * Math.PI) / 180
|
||||
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
||||
const radians = ((currentDegrees + 45) * Math.PI) / 180
|
||||
handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Scale">
|
||||
<div className="flex items-center justify-between px-2 pb-2">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Uniform Scale
|
||||
</span>
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground',
|
||||
uniformScale ? 'bg-[#3e3e3e]' : 'bg-[#2C2C2E] hover:bg-[#3e3e3e]',
|
||||
)}
|
||||
onClick={() => setUniformScale((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
{uniformScale ? <Link className="h-3.5 w-3.5" /> : <Link2Off className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{uniformScale ? (
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
XYZ<sub className="ml-[1px] text-[11px] opacity-70">scale</sub>
|
||||
</>
|
||||
}
|
||||
max={10}
|
||||
min={0.01}
|
||||
onChange={(value) => {
|
||||
const v = Math.max(0.01, value)
|
||||
handleUpdate({ scale: [v, v, v] })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
value={Math.round(node.scale[0] * 100) / 100}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
X<sub className="ml-[1px] text-[11px] opacity-70">scale</sub>
|
||||
</>
|
||||
}
|
||||
max={10}
|
||||
min={0.01}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ scale: [Math.max(0.01, value), node.scale[1], node.scale[2]] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
value={Math.round(node.scale[0] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
Y<sub className="ml-[1px] text-[11px] opacity-70">scale</sub>
|
||||
</>
|
||||
}
|
||||
max={10}
|
||||
min={0.01}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ scale: [node.scale[0], Math.max(0.01, value), node.scale[2]] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
value={Math.round(node.scale[1] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
Z<sub className="ml-[1px] text-[11px] opacity-70">scale</sub>
|
||||
</>
|
||||
}
|
||||
max={10}
|
||||
min={0.01}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ scale: [node.scale[0], node.scale[1], Math.max(0.01, value)] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
value={Math.round(node.scale[2] * 100) / 100}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Info">
|
||||
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-sm">
|
||||
<span>Dimensions</span>
|
||||
{(() => {
|
||||
const [w, h, d] = getScaledDimensions(node)
|
||||
return (
|
||||
<span className="font-mono text-white">
|
||||
{Math.round(w * 100) / 100}×{Math.round(h * 100) / 100}×{Math.round(d * 100) / 100}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Collections">
|
||||
<ActionGroup>
|
||||
<CollectionsPopover
|
||||
collectionIds={node.collectionIds}
|
||||
nodeId={selectedId as AnyNode['id']}
|
||||
>
|
||||
<ActionButton label="Manage collections…" />
|
||||
</CollectionsPopover>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="hover:bg-red-500/20"
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -24,23 +24,12 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { useIsMobile } from '../../../hooks/use-mobile'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ColumnPanel } from './column-panel'
|
||||
import { DoorPanel } from './door-panel'
|
||||
import { ElevatorPanel } from './elevator-panel'
|
||||
import { ItemPanel } from './item-panel'
|
||||
import { MobilePanelSheet } from './mobile-panel-sheet'
|
||||
import { MobileSelectionBar } from './mobile-selection-bar'
|
||||
import { getNodeDisplay } from './node-display'
|
||||
import { PaintPanel } from './paint-panel'
|
||||
import { ParametricInspector } from './parametric-inspector'
|
||||
import { ReferencePanel } from './reference-panel'
|
||||
import { RoofPanel } from './roof-panel'
|
||||
import { RoofSegmentPanel } from './roof-segment-panel'
|
||||
import { SpawnPanel } from './spawn-panel'
|
||||
import { StairPanel } from './stair-panel'
|
||||
import { StairSegmentPanel } from './stair-segment-panel'
|
||||
import { WallPanel } from './wall-panel'
|
||||
import { WindowPanel } from './window-panel'
|
||||
|
||||
type MovableNode =
|
||||
| ItemNode
|
||||
@@ -81,37 +70,15 @@ function isMovableNode(node: AnyNode | null): node is MovableNode {
|
||||
|
||||
function panelForType(type: string | null) {
|
||||
if (!type) return null
|
||||
switch (type) {
|
||||
case 'item':
|
||||
return <ItemPanel />
|
||||
case 'roof':
|
||||
return <RoofPanel />
|
||||
case 'roof-segment':
|
||||
return <RoofSegmentPanel />
|
||||
case 'stair':
|
||||
return <StairPanel />
|
||||
case 'stair-segment':
|
||||
return <StairSegmentPanel />
|
||||
case 'spawn':
|
||||
return <SpawnPanel />
|
||||
case 'column':
|
||||
return <ColumnPanel />
|
||||
case 'wall':
|
||||
return <WallPanel />
|
||||
case 'door':
|
||||
return <DoorPanel />
|
||||
case 'elevator':
|
||||
return <ElevatorPanel />
|
||||
case 'window':
|
||||
return <WindowPanel />
|
||||
default:
|
||||
// Registry fallback: any kind registered via @pascal-app/nodes with a
|
||||
// `parametrics` descriptor on its NodeDefinition gets an auto-derived
|
||||
// panel. Phase 4 will replace the hardcoded switch above with the
|
||||
// registry-first path; until then this fallback lets new kinds (shelf,
|
||||
// etc.) have a working inspector without per-kind panel files.
|
||||
return <ParametricInspector />
|
||||
}
|
||||
// Every kind now renders through `<ParametricInspector>`, which either
|
||||
// composes auto-derived editors from `parametrics.groups` or lazy-
|
||||
// loads the kind-owned panel via `parametrics.customPanel`. The
|
||||
// hardcoded switch is gone — all per-kind panel layout lives in
|
||||
// `nodes/src/<kind>/panel.tsx`. The `type` arg is preserved for
|
||||
// future cases where we might want a non-registry fallback (e.g.
|
||||
// reference scale, paint mode); leave the function shape intact.
|
||||
void type
|
||||
return <ParametricInspector />
|
||||
}
|
||||
|
||||
function MobilePanelLayer({
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getEffectiveRoofSurfaceMaterial,
|
||||
type MaterialSchema,
|
||||
type RoofNode,
|
||||
RoofNode as RoofNodeSchema,
|
||||
type RoofSegmentNode,
|
||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||
type RoofSurfaceMaterialRole,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { buildRoofSurfaceMaterialPatch } from '../../../lib/material-paint'
|
||||
import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
export function RoofPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
|
||||
)
|
||||
// Shallow selector — only re-renders when the segment list content changes.
|
||||
const segments = useScene(
|
||||
useShallow((s) => {
|
||||
if (!node) return []
|
||||
return (node.children ?? [])
|
||||
.map((childId) => s.nodes[childId as AnyNodeId] as RoofSegmentNode | undefined)
|
||||
.filter((n): n is RoofSegmentNode => n?.type === 'roof-segment')
|
||||
}),
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<RoofNode>) => {
|
||||
if (!selectedId) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
},
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const materialTargetRole =
|
||||
selectedMaterialTarget &&
|
||||
selectedMaterialTarget.nodeId === node?.id &&
|
||||
(selectedMaterialTarget.role === 'top' ||
|
||||
selectedMaterialTarget.role === 'edge' ||
|
||||
selectedMaterialTarget.role === 'wall')
|
||||
? selectedMaterialTarget.role
|
||||
: null
|
||||
const materialPickerValue =
|
||||
node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {}
|
||||
|
||||
const handleTargetedMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
if (!(node && materialTargetRole)) return
|
||||
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
|
||||
},
|
||||
[handleUpdate, materialTargetRole, node],
|
||||
)
|
||||
|
||||
const handleTargetedMaterialPresetChange = useCallback(
|
||||
(materialPreset: string) => {
|
||||
if (!(node && materialTargetRole)) return
|
||||
handleUpdate(
|
||||
buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset),
|
||||
)
|
||||
},
|
||||
[handleUpdate, materialTargetRole, node],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleAddSegment = useCallback(() => {
|
||||
if (!node) return
|
||||
const segment = RoofSegmentNodeSchema.parse({
|
||||
width: 6,
|
||||
depth: 6,
|
||||
wallHeight: 0.5,
|
||||
roofHeight: 2.5,
|
||||
roofType: 'gable',
|
||||
position: [2, 0, 2],
|
||||
})
|
||||
createNode(segment, node.id as AnyNodeId)
|
||||
}, [node, createNode])
|
||||
|
||||
const handleSelectSegment = useCallback(
|
||||
(segmentId: string) => {
|
||||
setSelection({ selectedIds: [segmentId as AnyNode['id']] })
|
||||
},
|
||||
[setSelection],
|
||||
)
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
|
||||
try {
|
||||
duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' })
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate roof', e)
|
||||
}
|
||||
}, [node])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (node) {
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
const parentId = node.parentId
|
||||
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
||||
if (parentId) {
|
||||
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
|
||||
}
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [selectedId, node, setSelection])
|
||||
|
||||
if (!(node && node.type === 'roof' && selectedId)) return null
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/roof.png"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Roof'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Segments">
|
||||
<div className="flex flex-col gap-1">
|
||||
{segments.map((seg, i) => (
|
||||
<button
|
||||
className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]"
|
||||
key={seg.id}
|
||||
onClick={() => handleSelectSegment(seg.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{seg.name || `Segment ${i + 1}`}</span>
|
||||
<span className="text-muted-foreground text-xs capitalize">{seg.roofType}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ActionGroup>
|
||||
<ActionButton
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add Segment"
|
||||
onClick={handleAddSegment}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label="X"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[0] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Y"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[1] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Z"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[2] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rotation"
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(degrees) => {
|
||||
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="hover:bg-red-500/20"
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
<PanelSection title="Material">
|
||||
{materialTargetRole ? null : (
|
||||
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
||||
Click the roof surface you want to edit. Materials apply to one target at a time.
|
||||
</div>
|
||||
)}
|
||||
<MaterialPicker
|
||||
disabled={!materialTargetRole}
|
||||
hideSideControl
|
||||
nodeType="roof"
|
||||
onChange={handleTargetedMaterialChange}
|
||||
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
|
||||
selectedMaterialPreset={materialPickerValue.materialPreset}
|
||||
value={materialPickerValue.material}
|
||||
/>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type RoofSegmentNode,
|
||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||
type RoofType,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
const ROOF_TYPE_OPTIONS: { label: string; value: RoofType }[] = [
|
||||
{ label: 'Hip', value: 'hip' },
|
||||
{ label: 'Gable', value: 'gable' },
|
||||
{ label: 'Shed', value: 'shed' },
|
||||
{ label: 'Flat', value: 'flat' },
|
||||
]
|
||||
|
||||
const ROOF_TYPE_OPTIONS_2: { label: string; value: RoofType }[] = [
|
||||
{ label: 'Gambrel', value: 'gambrel' },
|
||||
{ label: 'Dutch', value: 'dutch' },
|
||||
{ label: 'Mansard', value: 'mansard' },
|
||||
]
|
||||
|
||||
export function RoofSegmentPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofSegmentNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<RoofSegmentNode>) => {
|
||||
if (!selectedId) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
},
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (node?.parentId) {
|
||||
setSelection({ selectedIds: [node.parentId] })
|
||||
}
|
||||
}, [node?.parentId, setSelection])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node?.parentId) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
|
||||
let duplicateInfo = structuredClone(node) as any
|
||||
delete duplicateInfo.id
|
||||
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
||||
// Offset slightly so it's visible
|
||||
duplicateInfo.position = [
|
||||
duplicateInfo.position[0] + 1,
|
||||
duplicateInfo.position[1],
|
||||
duplicateInfo.position[2] + 1,
|
||||
]
|
||||
|
||||
try {
|
||||
const duplicate = RoofSegmentNodeSchema.parse(duplicateInfo)
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
setSelection({ selectedIds: [] })
|
||||
setMovingNode(duplicate)
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate roof segment', e)
|
||||
}
|
||||
}, [node, setSelection, setMovingNode])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (node) {
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
const parentId = node.parentId
|
||||
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
||||
if (parentId) {
|
||||
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
|
||||
setSelection({ selectedIds: [parentId] })
|
||||
} else {
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}, [selectedId, node, setSelection])
|
||||
|
||||
if (!(node && node.type === 'roof-segment' && selectedId)) return null
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/roof.png"
|
||||
onBack={handleBack}
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Roof Segment'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Roof Type">
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ roofType: v })}
|
||||
options={ROOF_TYPE_OPTIONS}
|
||||
value={node.roofType}
|
||||
/>
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ roofType: v })}
|
||||
options={ROOF_TYPE_OPTIONS_2}
|
||||
value={node.roofType}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Footprint">
|
||||
<SliderControl
|
||||
label="Width"
|
||||
max={25}
|
||||
min={0.5}
|
||||
onChange={(v) => handleUpdate({ width: v })}
|
||||
precision={2}
|
||||
step={0.5}
|
||||
unit="m"
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
max={25}
|
||||
min={0.5}
|
||||
onChange={(v) => handleUpdate({ depth: v })}
|
||||
precision={2}
|
||||
step={0.5}
|
||||
unit="m"
|
||||
value={Math.round(node.depth * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Heights">
|
||||
<SliderControl
|
||||
label="Wall"
|
||||
max={5}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ wallHeight: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.wallHeight * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Roof"
|
||||
max={15}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ roofHeight: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.roofHeight * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Structure">
|
||||
<SliderControl
|
||||
label="Wall Thick."
|
||||
max={1}
|
||||
min={0.05}
|
||||
onChange={(v) => handleUpdate({ wallThickness: v })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.wallThickness * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Deck Thick."
|
||||
max={0.3}
|
||||
min={0.04}
|
||||
onChange={(v) => handleUpdate({ deckThickness: v })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.deckThickness * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Overhang"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ overhang: v })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.overhang * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Shingle Thick."
|
||||
max={0.3}
|
||||
min={0.02}
|
||||
onChange={(v) => handleUpdate({ shingleThickness: v })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.shingleThickness * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label="X"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[0] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Y"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[1] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Z"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[2] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rotation"
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(degrees) => {
|
||||
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="hover:bg-red-500/20"
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type SpawnNode, useLiveTransforms, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
export function SpawnPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as SpawnNode | undefined) : undefined,
|
||||
)
|
||||
const [draftRotation, setDraftRotation] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!(node && node.type === 'spawn')) {
|
||||
setDraftRotation(null)
|
||||
return
|
||||
}
|
||||
|
||||
setDraftRotation(node.rotation)
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
}, [node?.id, node?.rotation, node?.type])
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<SpawnNode>) => {
|
||||
if (!(selectedId && node)) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
},
|
||||
[node, selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleRotationChange = useCallback(
|
||||
(degrees: number) => {
|
||||
if (!(node && selectedId)) return
|
||||
const nextRotation = (degrees * Math.PI) / 180
|
||||
setDraftRotation(nextRotation)
|
||||
useLiveTransforms.getState().set(selectedId as AnyNode['id'], {
|
||||
position: [...node.position],
|
||||
rotation: nextRotation,
|
||||
})
|
||||
},
|
||||
[node, selectedId],
|
||||
)
|
||||
|
||||
const commitRotation = useCallback(
|
||||
(degrees: number) => {
|
||||
if (!(node && selectedId)) return
|
||||
const nextRotation = (degrees * Math.PI) / 180
|
||||
useLiveTransforms.getState().clear(selectedId as AnyNode['id'])
|
||||
setDraftRotation(nextRotation)
|
||||
if (Math.abs(nextRotation - node.rotation) > 1e-6) {
|
||||
updateNode(selectedId as AnyNode['id'], { rotation: nextRotation })
|
||||
}
|
||||
},
|
||||
[node, selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!selectedId) return
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
deleteNode(selectedId as AnyNode['id'])
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [deleteNode, selectedId, setSelection])
|
||||
|
||||
if (!(node && node.type === 'spawn' && selectedId)) return null
|
||||
|
||||
const rotationDegrees = Math.round(((draftRotation ?? node.rotation) * 180) / Math.PI)
|
||||
const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI)
|
||||
|
||||
return (
|
||||
<PanelWrapper icon="/icons/site.png" onClose={handleClose} title="Spawn Point" width={300}>
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label="X"
|
||||
max={node.position[0] + 2}
|
||||
min={node.position[0] - 2}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ position: [value, node.position[1], node.position[2]] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Y"
|
||||
max={node.position[1] + 2}
|
||||
min={node.position[1] - 2}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ position: [node.position[0], value, node.position[2]] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Z"
|
||||
max={node.position[2] + 2}
|
||||
min={node.position[2] - 2}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ position: [node.position[0], node.position[1], value] })
|
||||
}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Facing">
|
||||
<SliderControl
|
||||
label="Yaw"
|
||||
max={storedRotationDegrees + 90}
|
||||
min={storedRotationDegrees - 90}
|
||||
onChange={handleRotationChange}
|
||||
onCommit={commitRotation}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={rotationDegrees}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-4 w-4" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
className="border-red-500/40 text-red-200 hover:bg-red-500/15"
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -1,577 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getEffectiveStairSurfaceMaterial,
|
||||
type LevelNode,
|
||||
type MaterialSchema,
|
||||
type StairNode,
|
||||
StairNode as StairNodeSchema,
|
||||
type StairRailingMode,
|
||||
type StairSegmentNode,
|
||||
StairSegmentNode as StairSegmentNodeSchema,
|
||||
type StairSlabOpeningMode,
|
||||
type StairSurfaceMaterialRole,
|
||||
type StairTopLandingMode,
|
||||
type StairType,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { buildStairSurfaceMaterialPatch } from '../../../lib/material-paint'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { duplicateStairSubtree } from '../../../lib/stair-duplication'
|
||||
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' },
|
||||
]
|
||||
|
||||
const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode }[] = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Destination', value: 'destination' },
|
||||
]
|
||||
|
||||
export function StairPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
|
||||
)
|
||||
const levels = useScene(
|
||||
useShallow((s) =>
|
||||
Object.values(s.nodes)
|
||||
.filter((entry): entry is LevelNode => entry.type === 'level')
|
||||
.sort((left, right) => left.level - right.level),
|
||||
),
|
||||
)
|
||||
const segments = useScene(
|
||||
useShallow((s) => {
|
||||
if (!selectedId) return []
|
||||
const stairNode = s.nodes[selectedId as AnyNode['id']] as StairNode | undefined
|
||||
if (stairNode?.type !== 'stair') return []
|
||||
return (stairNode.children ?? [])
|
||||
.map((childId) => s.nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||
.filter((entry): entry is StairSegmentNode => entry?.type === 'stair-segment')
|
||||
}),
|
||||
)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<StairNode>) => {
|
||||
if (!selectedId) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
},
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const materialTargetRole =
|
||||
selectedMaterialTarget &&
|
||||
selectedMaterialTarget.nodeId === node?.id &&
|
||||
(selectedMaterialTarget.role === 'railing' ||
|
||||
selectedMaterialTarget.role === 'tread' ||
|
||||
selectedMaterialTarget.role === 'side')
|
||||
? selectedMaterialTarget.role
|
||||
: null
|
||||
const materialPickerValue =
|
||||
node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {}
|
||||
|
||||
const handleTargetedMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
if (!(node && materialTargetRole)) return
|
||||
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
|
||||
},
|
||||
[handleUpdate, materialTargetRole, node],
|
||||
)
|
||||
|
||||
const handleTargetedMaterialPresetChange = useCallback(
|
||||
(materialPreset: string) => {
|
||||
if (!(node && materialTargetRole)) return
|
||||
handleUpdate(
|
||||
buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset),
|
||||
)
|
||||
},
|
||||
[handleUpdate, materialTargetRole, node],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const getLastSegmentFillDefaults = useCallback(() => {
|
||||
if (!node) return { fillToFloor: true }
|
||||
const children = node.children ?? []
|
||||
const lastChildId = children[children.length - 1]
|
||||
if (lastChildId) {
|
||||
const lastChild = useScene.getState().nodes[lastChildId as AnyNodeId] as
|
||||
| StairSegmentNode
|
||||
| undefined
|
||||
if (lastChild?.type === 'stair-segment') {
|
||||
return { fillToFloor: lastChild.fillToFloor }
|
||||
}
|
||||
}
|
||||
return { fillToFloor: true }
|
||||
}, [node])
|
||||
|
||||
const handleAddFlight = useCallback(() => {
|
||||
if (!node) return
|
||||
const { fillToFloor } = getLastSegmentFillDefaults()
|
||||
const segment = StairSegmentNodeSchema.parse({
|
||||
segmentType: 'stair',
|
||||
width: 1.0,
|
||||
length: 3.0,
|
||||
height: 2.5,
|
||||
stepCount: 10,
|
||||
attachmentSide: 'front',
|
||||
fillToFloor,
|
||||
thickness: 0.25,
|
||||
position: [0, 0, 0],
|
||||
})
|
||||
createNode(segment, node.id as AnyNodeId)
|
||||
}, [node, createNode, getLastSegmentFillDefaults])
|
||||
|
||||
const handleAddLanding = useCallback(() => {
|
||||
if (!node) return
|
||||
const { fillToFloor } = getLastSegmentFillDefaults()
|
||||
const segment = StairSegmentNodeSchema.parse({
|
||||
segmentType: 'landing',
|
||||
width: 1.0,
|
||||
length: 1.0,
|
||||
height: 0,
|
||||
stepCount: 0,
|
||||
attachmentSide: 'front',
|
||||
fillToFloor,
|
||||
thickness: 0.32,
|
||||
position: [0, 0, 0],
|
||||
})
|
||||
createNode(segment, node.id as AnyNodeId)
|
||||
}, [node, createNode, getLastSegmentFillDefaults])
|
||||
|
||||
const handleSelectSegment = useCallback(
|
||||
(segmentId: string) => {
|
||||
setSelection({ selectedIds: [segmentId as AnyNode['id']] })
|
||||
},
|
||||
[setSelection],
|
||||
)
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
|
||||
try {
|
||||
duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate stair', e)
|
||||
}
|
||||
}, [node])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (node) {
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
const parentId = node.parentId
|
||||
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
||||
if (parentId) {
|
||||
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
|
||||
}
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [selectedId, node, setSelection])
|
||||
|
||||
if (!(node && node.type === 'stair' && selectedId && selectedCount === 1)) return null
|
||||
|
||||
const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null
|
||||
const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/stairs.png"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Staircase'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Type">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate(
|
||||
value === 'spiral' && node.stairType !== 'spiral'
|
||||
? {
|
||||
stairType: value,
|
||||
sweepAngle: DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE,
|
||||
position: [node.position[0], 0, node.position[2]],
|
||||
}
|
||||
: { stairType: value },
|
||||
)
|
||||
}
|
||||
options={STAIR_TYPE_OPTIONS}
|
||||
value={node.stairType ?? 'straight'}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Opening">
|
||||
<div className="space-y-3">
|
||||
<ToggleControl
|
||||
checked={(node.slabOpeningMode ?? 'none') === 'destination'}
|
||||
label="Auto Cutout"
|
||||
onChange={(checked) =>
|
||||
handleUpdate({
|
||||
slabOpeningMode: checked ? 'destination' : 'none',
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
|
||||
From Level
|
||||
</div>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
|
||||
onChange={(event) => handleUpdate({ fromLevelId: event.target.value })}
|
||||
value={resolvedFromLevelId ?? ''}
|
||||
>
|
||||
{levels.map((level) => (
|
||||
<option key={level.id} value={level.id}>
|
||||
{level.name || `Level ${level.level + 1}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
|
||||
To Level
|
||||
</div>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
|
||||
onChange={(event) => handleUpdate({ toLevelId: event.target.value })}
|
||||
value={resolvedToLevelId ?? ''}
|
||||
>
|
||||
{levels.map((level) => (
|
||||
<option key={level.id} value={level.id}>
|
||||
{level.name || `Level ${level.level + 1}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<SegmentedControl
|
||||
onChange={(value) => handleUpdate({ slabOpeningMode: value as StairSlabOpeningMode })}
|
||||
options={STAIR_SLAB_OPENING_OPTIONS}
|
||||
value={node.slabOpeningMode ?? 'none'}
|
||||
/>
|
||||
|
||||
{(node.slabOpeningMode ?? 'none') === 'destination' ? (
|
||||
<MetricControl
|
||||
label="Opening Offset"
|
||||
max={0.5}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ openingOffset: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.openingOffset ?? 0) * 100) / 100}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
{node.stairType === 'straight' && (
|
||||
<PanelSection title="Segments">
|
||||
<div className="flex flex-col gap-1">
|
||||
{segments.map((seg, i) => (
|
||||
<button
|
||||
className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]"
|
||||
key={seg.id}
|
||||
onClick={() => handleSelectSegment(seg.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{seg.name || `Segment ${i + 1}`}</span>
|
||||
<span className="text-muted-foreground text-xs capitalize">{seg.segmentType}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<ActionButton
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add flight"
|
||||
onClick={handleAddFlight}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add landing"
|
||||
onClick={handleAddLanding}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{(node.stairType === 'curved' || node.stairType === 'spiral') && (
|
||||
<PanelSection title="Geometry">
|
||||
<MetricControl
|
||||
label="Width"
|
||||
max={10}
|
||||
min={0.4}
|
||||
onChange={(value) => handleUpdate({ width: value })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.width ?? 1) * 100) / 100}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Rise"
|
||||
max={10}
|
||||
min={0.2}
|
||||
onChange={(value) => handleUpdate({ totalRise: value })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Steps"
|
||||
max={32}
|
||||
min={2}
|
||||
onChange={(value) => handleUpdate({ stepCount: Math.max(2, Math.round(value)) })}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit=""
|
||||
value={Math.max(2, Math.round(node.stepCount ?? 10))}
|
||||
/>
|
||||
{node.stairType !== 'spiral' && (
|
||||
<ToggleControl
|
||||
checked={node.fillToFloor ?? true}
|
||||
label="Fit To Floor"
|
||||
onChange={(checked) => handleUpdate({ fillToFloor: checked })}
|
||||
/>
|
||||
)}
|
||||
{(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
|
||||
<MetricControl
|
||||
label="Thickness"
|
||||
max={1}
|
||||
min={0.02}
|
||||
onChange={(value) => handleUpdate({ thickness: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
|
||||
/>
|
||||
)}
|
||||
<MetricControl
|
||||
label="Inner Radius"
|
||||
max={10}
|
||||
min={node.stairType === 'spiral' ? 0.05 : 0.2}
|
||||
onChange={(value) => handleUpdate({ innerRadius: value })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.innerRadius ?? 0.9) * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Sweep"
|
||||
max={node.stairType === 'spiral' ? 720 : 270}
|
||||
min={node.stairType === 'spiral' ? -720 : -270}
|
||||
onChange={(degrees) => handleUpdate({ sweepAngle: (degrees * Math.PI) / 180 })}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round(((node.sweepAngle ?? Math.PI / 2) * 180) / Math.PI)}
|
||||
/>
|
||||
{node.stairType === 'spiral' && (
|
||||
<>
|
||||
<SegmentedControl
|
||||
onChange={(value) => handleUpdate({ topLandingMode: value })}
|
||||
options={TOP_LANDING_MODE_OPTIONS}
|
||||
value={node.topLandingMode ?? 'none'}
|
||||
/>
|
||||
{(node.topLandingMode ?? 'none') === 'integrated' && (
|
||||
<MetricControl
|
||||
label="Top Landing"
|
||||
max={5}
|
||||
min={0.3}
|
||||
onChange={(value) => handleUpdate({ topLandingDepth: value })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100}
|
||||
/>
|
||||
)}
|
||||
<ToggleControl
|
||||
checked={node.showCenterColumn ?? true}
|
||||
label="Center Column"
|
||||
onChange={(checked) => handleUpdate({ showCenterColumn: checked })}
|
||||
/>
|
||||
<ToggleControl
|
||||
checked={node.showStepSupports ?? true}
|
||||
label="Step Supports"
|
||||
onChange={(checked) => handleUpdate({ showStepSupports: checked })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label="X"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[0] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Y"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[1] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Z"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[2] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rotation"
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(degrees) => {
|
||||
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Railing">
|
||||
<SegmentedControl
|
||||
onChange={(value) => handleUpdate({ railingMode: value })}
|
||||
options={RAILING_MODE_OPTIONS}
|
||||
value={node.railingMode ?? 'none'}
|
||||
/>
|
||||
{(node.railingMode ?? 'none') !== 'none' && (
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={1.4}
|
||||
min={0.7}
|
||||
onChange={(value) => handleUpdate({ railingHeight: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
unit="m"
|
||||
value={Math.round((node.railingHeight ?? 0.92) * 100) / 100}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="hover:bg-red-500/20"
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
<PanelSection title="Material">
|
||||
{materialTargetRole ? null : (
|
||||
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
||||
Click the stair surface you want to edit. Materials apply to one target at a time.
|
||||
</div>
|
||||
)}
|
||||
<MaterialPicker
|
||||
disabled={!materialTargetRole}
|
||||
hideSideControl
|
||||
nodeType="stair"
|
||||
onChange={handleTargetedMaterialChange}
|
||||
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
|
||||
selectedMaterialPreset={materialPickerValue.materialPreset}
|
||||
value={materialPickerValue.material}
|
||||
/>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type AttachmentSide,
|
||||
type StairSegmentNode,
|
||||
StairSegmentNode as StairSegmentNodeSchema,
|
||||
type StairSegmentType,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
const SEGMENT_TYPE_OPTIONS: { label: string; value: StairSegmentType }[] = [
|
||||
{ label: 'Flight', value: 'stair' },
|
||||
{ label: 'Landing', value: 'landing' },
|
||||
]
|
||||
|
||||
const ATTACHMENT_SIDE_OPTIONS: { label: string; value: AttachmentSide }[] = [
|
||||
{ label: 'Front', value: 'front' },
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
]
|
||||
|
||||
export function StairSegmentPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairSegmentNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
// Boolean selector — re-renders only when this segment's position among the
|
||||
// parent stair's children flips to/from "first".
|
||||
const isFirstSegment = useScene((s) => {
|
||||
if (!node?.parentId) return true
|
||||
const parent = s.nodes[node.parentId as AnyNodeId]
|
||||
if (!parent || parent.type !== 'stair') return true
|
||||
const children = (parent as any).children ?? []
|
||||
return children[0] === node.id
|
||||
})
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<StairSegmentNode>) => {
|
||||
if (!selectedId) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
},
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (node?.parentId) {
|
||||
setSelection({ selectedIds: [node.parentId] })
|
||||
}
|
||||
}, [node?.parentId, setSelection])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node?.parentId) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
|
||||
let duplicateInfo = structuredClone(node) as any
|
||||
delete duplicateInfo.id
|
||||
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
||||
duplicateInfo.position = [
|
||||
duplicateInfo.position[0] + 1,
|
||||
duplicateInfo.position[1],
|
||||
duplicateInfo.position[2] + 1,
|
||||
]
|
||||
|
||||
try {
|
||||
const duplicate = StairSegmentNodeSchema.parse(duplicateInfo)
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
setSelection({ selectedIds: [] })
|
||||
setMovingNode(duplicate)
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate stair segment', e)
|
||||
}
|
||||
}, [node, setSelection, setMovingNode])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (node) {
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
const parentId = node.parentId
|
||||
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
||||
if (parentId) {
|
||||
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
|
||||
setSelection({ selectedIds: [parentId] })
|
||||
} else {
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}, [selectedId, node, setSelection])
|
||||
|
||||
if (!(node && node.type === 'stair-segment' && selectedId)) return null
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/stairs.png"
|
||||
onBack={handleBack}
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Stair Segment'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Type">
|
||||
<SegmentedControl
|
||||
onChange={(v) => {
|
||||
const updates: Partial<StairSegmentNode> = { segmentType: v }
|
||||
if (v === 'landing') {
|
||||
updates.height = 0
|
||||
updates.stepCount = 0
|
||||
updates.length = 1.0
|
||||
} else {
|
||||
updates.height = 2.5
|
||||
updates.stepCount = 10
|
||||
updates.length = 3.0
|
||||
}
|
||||
handleUpdate(updates)
|
||||
}}
|
||||
options={SEGMENT_TYPE_OPTIONS}
|
||||
value={node.segmentType}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{!isFirstSegment && (
|
||||
<PanelSection title="Attachment">
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ attachmentSide: v })}
|
||||
options={ATTACHMENT_SIDE_OPTIONS}
|
||||
value={node.attachmentSide}
|
||||
/>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Width"
|
||||
max={5}
|
||||
min={0.5}
|
||||
onChange={(v) => handleUpdate({ width: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Length"
|
||||
max={10}
|
||||
min={0.5}
|
||||
onChange={(v) => handleUpdate({ length: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.length * 100) / 100}
|
||||
/>
|
||||
{node.segmentType === 'stair' && (
|
||||
<>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={10}
|
||||
min={0.5}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Steps"
|
||||
max={30}
|
||||
min={2}
|
||||
onChange={(v) => handleUpdate({ stepCount: Math.round(v) })}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit=""
|
||||
value={node.stepCount}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Structure">
|
||||
<div className="flex items-center justify-between px-1 py-1">
|
||||
<span className="text-muted-foreground text-xs">Fill to floor</span>
|
||||
<button
|
||||
className={`relative h-5 w-10 rounded-full transition-colors ${
|
||||
node.fillToFloor ? 'bg-blue-500' : 'bg-[#3e3e3e]'
|
||||
}`}
|
||||
onClick={() => handleUpdate({ fillToFloor: !node.fillToFloor })}
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
className={`absolute top-1 h-3 w-3 rounded-full bg-white transition-transform ${
|
||||
node.fillToFloor ? 'left-6' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{!node.fillToFloor && (
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
max={1}
|
||||
min={0.05}
|
||||
onChange={(v) => handleUpdate({ thickness: v })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label="X"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[0] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Y"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[1] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Z"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[2] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rotation"
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(degrees) => {
|
||||
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="hover:bg-red-500/20"
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
getWallCurveLength,
|
||||
normalizeWallCurveOffset,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Move, Spline } from 'lucide-react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
export function WallPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
// Boolean selector — re-renders only when this specific wall's child
|
||||
// composition crosses the "has a door/window/wall-item" threshold.
|
||||
const hasWallChildrenBlockingCurve = useScene((s) => {
|
||||
if (!node) return false
|
||||
return (node.children ?? []).some((childId) => {
|
||||
const child = s.nodes[childId as AnyNodeId]
|
||||
if (!child) return false
|
||||
if (child.type === 'door' || child.type === 'window') return true
|
||||
if (child.type === 'item') {
|
||||
const attachTo = child.asset?.attachTo
|
||||
return attachTo === 'wall' || attachTo === 'wall-side'
|
||||
}
|
||||
return false
|
||||
})
|
||||
})
|
||||
|
||||
// Mirror the latest node into a ref so the slider handlers below have
|
||||
// stable identities across re-renders. Without this, every store tick
|
||||
// (one per pointermove during a slider drag) rebuilt the handler
|
||||
// refs, destabilising SliderControl's pointer-capture listeners and
|
||||
// combining with float drift in `getWallCurveLength` produced a
|
||||
// "Maximum update depth exceeded" cascade. Same fix in fence-panel.tsx.
|
||||
const nodeRef = useRef(node)
|
||||
nodeRef.current = node
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<WallNode>) => {
|
||||
if (!selectedId) return
|
||||
useScene.getState().updateNode(selectedId as AnyNode['id'], updates)
|
||||
},
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const handleUpdateLength = useCallback(
|
||||
(newLength: number) => {
|
||||
const n = nodeRef.current
|
||||
if (!n || newLength <= 0) return
|
||||
|
||||
const dx = n.end[0] - n.start[0]
|
||||
const dz = n.end[1] - n.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] = [
|
||||
n.start[0] + dirX * newLength,
|
||||
n.start[1] + dirZ * newLength,
|
||||
]
|
||||
|
||||
handleUpdate({ end: newEnd })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleCurve = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setCurvingWall(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setCurvingWall, setSelection])
|
||||
|
||||
if (!(node && node.type === 'wall' && selectedId)) return null
|
||||
|
||||
const dx = node.end[0] - node.start[0]
|
||||
const dz = node.end[1] - node.start[1]
|
||||
const length = getWallCurveLength(node)
|
||||
|
||||
const height = node.height ?? 2.5
|
||||
const thickness = node.thickness ?? 0.1
|
||||
const curveOffset = getClampedWallCurveOffset(node)
|
||||
const maxCurveOffset = getMaxWallCurveOffset(node)
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/wall.png"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Wall'}
|
||||
width={280}
|
||||
>
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Length"
|
||||
max={20}
|
||||
min={0.1}
|
||||
onChange={handleUpdateLength}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={length}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={6}
|
||||
min={0.1}
|
||||
onChange={(v) => handleUpdate({ height: Math.max(0.1, v) })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(height * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
max={1}
|
||||
min={0.05}
|
||||
onChange={(v) => handleUpdate({ thickness: Math.max(0.05, v) })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(thickness * 1000) / 1000}
|
||||
/>
|
||||
{!hasWallChildrenBlockingCurve && (
|
||||
<SliderControl
|
||||
label="Curve"
|
||||
max={Math.max(0.01, maxCurveOffset)}
|
||||
min={-Math.max(0.01, maxCurveOffset)}
|
||||
onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(curveOffset * 100) / 100}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
{!hasWallChildrenBlockingCurve && (
|
||||
<ActionButton
|
||||
icon={<Spline className="h-3.5 w-3.5" />}
|
||||
label="Curve"
|
||||
onClick={handleCurve}
|
||||
/>
|
||||
)}
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -1,984 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
useInteractive,
|
||||
useScene,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { usePresetsAdapter } from '../../../contexts/presets-context'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
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'
|
||||
import { PresetsPopover } from './presets/presets-popover'
|
||||
|
||||
function isSameWindowValue(current: unknown, next: unknown): boolean {
|
||||
if (typeof current === 'number' && typeof next === 'number') {
|
||||
return Math.abs(current - next) < 1e-6
|
||||
}
|
||||
|
||||
if (Array.isArray(current) && Array.isArray(next)) {
|
||||
return (
|
||||
current.length === next.length &&
|
||||
current.every((value, index) => isSameWindowValue(value, next[index]))
|
||||
)
|
||||
}
|
||||
|
||||
return Object.is(current, next)
|
||||
}
|
||||
|
||||
function getMaxSharedWindowRadius(width: number, height: number) {
|
||||
return Math.max(0, Math.min(width / 2, height / 2))
|
||||
}
|
||||
|
||||
function normalizeWindowCornerRadii(
|
||||
radii: [number, number, number, number],
|
||||
width: number,
|
||||
height: number,
|
||||
): [number, number, number, number] {
|
||||
const next = radii.map((radius) => Math.max(radius, 0)) as [number, number, number, number]
|
||||
const scale = Math.min(
|
||||
1,
|
||||
Math.max(width, 0) / Math.max(next[0] + next[1], 1e-6),
|
||||
Math.max(width, 0) / Math.max(next[3] + next[2], 1e-6),
|
||||
Math.max(height, 0) / Math.max(next[0] + next[3], 1e-6),
|
||||
Math.max(height, 0) / Math.max(next[1] + next[2], 1e-6),
|
||||
)
|
||||
|
||||
if (scale >= 1) return next
|
||||
|
||||
return next.map((radius) => radius * scale) as [number, number, number, number]
|
||||
}
|
||||
|
||||
function isSameRadiusTuple(
|
||||
current: [number, number, number, number],
|
||||
next: [number, number, number, number],
|
||||
) {
|
||||
return current.every((value, index) => Math.abs(value - (next[index] ?? 0)) < 1e-6)
|
||||
}
|
||||
|
||||
const windowTypeOptions: Array<{ label: string; value: WindowNode['windowType'] }> = [
|
||||
{ label: 'Fixed', value: 'fixed' },
|
||||
{ label: 'Sliding', value: 'sliding' },
|
||||
{ label: 'Casement', value: 'casement' },
|
||||
{ label: 'Awning', value: 'awning' },
|
||||
{ label: 'Single Hung', value: 'single-hung' },
|
||||
{ label: 'Double Hung', value: 'double-hung' },
|
||||
{ label: 'Bay', value: 'bay' },
|
||||
{ label: 'Bow', value: 'bow' },
|
||||
{ label: 'Louvered', value: 'louvered' },
|
||||
]
|
||||
|
||||
const shapedWindowTypes = new Set<WindowNode['windowType']>([
|
||||
'fixed',
|
||||
'casement',
|
||||
'awning',
|
||||
'hopper',
|
||||
'louvered',
|
||||
])
|
||||
|
||||
const silllessWindowTypes = new Set<WindowNode['windowType']>(['bay', 'bow'])
|
||||
|
||||
export function WindowPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const previewRef = useRef<{
|
||||
id: AnyNodeId
|
||||
key: keyof WindowNode
|
||||
value: unknown
|
||||
} | null>(null)
|
||||
|
||||
const adapter = usePresetsAdapter()
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
// Panel slider-drag fix recipe (plans/editor-node-registry.md). Without
|
||||
// it, the 15+ SliderControls in this panel would loop on drag.
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<WindowNode>) => {
|
||||
if (!selectedId) return
|
||||
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
|
||||
if (liveNode?.type !== 'window') return
|
||||
|
||||
const hasChange = Object.entries(updates).some(([key, value]) => {
|
||||
const currentValue = liveNode[key as keyof WindowNode]
|
||||
return !isSameWindowValue(currentValue, value)
|
||||
})
|
||||
if (!hasChange) return
|
||||
|
||||
useScene.getState().updateNode(selectedId as AnyNode['id'], updates)
|
||||
const scene = useScene.getState()
|
||||
scene.dirtyNodes.add(selectedId as AnyNodeId)
|
||||
if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId)
|
||||
},
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const previewWindowUpdate = useCallback(
|
||||
<K extends keyof WindowNode>(key: K, value: WindowNode[K]) => {
|
||||
if (!selectedId) return
|
||||
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
|
||||
if (liveNode?.type !== 'window') return
|
||||
|
||||
if (
|
||||
!(
|
||||
previewRef.current &&
|
||||
previewRef.current.id === selectedId &&
|
||||
previewRef.current.key === key
|
||||
)
|
||||
) {
|
||||
previewRef.current = {
|
||||
id: selectedId as AnyNodeId,
|
||||
key,
|
||||
value: liveNode[key],
|
||||
}
|
||||
}
|
||||
|
||||
if (isSameWindowValue(liveNode[key], value)) return
|
||||
|
||||
;(liveNode as WindowNode)[key] = value
|
||||
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
|
||||
},
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const commitWindowPreview = useCallback(
|
||||
<K extends keyof WindowNode>(key: K, value: WindowNode[K]) => {
|
||||
if (!selectedId) return
|
||||
|
||||
const scene = useScene.getState()
|
||||
const liveNode = scene.nodes[selectedId as AnyNodeId]
|
||||
const preview = previewRef.current
|
||||
if (liveNode?.type === 'window' && preview?.id === selectedId && preview.key === key) {
|
||||
;(liveNode as WindowNode)[key] = preview.value as WindowNode[K]
|
||||
scene.dirtyNodes.add(selectedId as AnyNodeId)
|
||||
}
|
||||
previewRef.current = null
|
||||
|
||||
useScene.getState().updateNode(selectedId as AnyNode['id'], {
|
||||
[key]: value,
|
||||
} as Partial<WindowNode>)
|
||||
scene.dirtyNodes.add(selectedId as AnyNodeId)
|
||||
},
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleFlip = useCallback(() => {
|
||||
if (!node) return
|
||||
handleUpdate({
|
||||
side: node.side === 'front' ? 'back' : 'front',
|
||||
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
|
||||
})
|
||||
}, [node, handleUpdate])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
deleteNode(selectedId as AnyNode['id'])
|
||||
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [selectedId, node, deleteNode, setSelection])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node?.parentId) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
useScene.temporal.getState().pause()
|
||||
const duplicate = WindowNode.parse({
|
||||
position: [...node.position] as [number, number, number],
|
||||
rotation: [...node.rotation] as [number, number, number],
|
||||
side: node.side,
|
||||
wallId: node.wallId,
|
||||
parentId: node.parentId,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
windowType: node.windowType,
|
||||
operationState: node.operationState,
|
||||
awningDirection: node.awningDirection,
|
||||
casementStyle: node.casementStyle,
|
||||
hingesSide: node.hingesSide,
|
||||
frameThickness: node.frameThickness,
|
||||
frameDepth: node.frameDepth,
|
||||
openingKind: node.openingKind,
|
||||
openingShape: node.openingShape,
|
||||
openingRadiusMode: node.openingRadiusMode ?? 'all',
|
||||
openingCornerRadii: [...(node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15])],
|
||||
cornerRadius: node.cornerRadius,
|
||||
archHeight: node.archHeight,
|
||||
openingRevealRadius: node.openingRevealRadius,
|
||||
columnRatios: [...node.columnRatios],
|
||||
rowRatios: [...node.rowRatios],
|
||||
columnDividerThickness: node.columnDividerThickness,
|
||||
rowDividerThickness: node.rowDividerThickness,
|
||||
sill: node.sill,
|
||||
sillDepth: node.sillDepth,
|
||||
sillThickness: node.sillThickness,
|
||||
metadata: { isNew: true },
|
||||
})
|
||||
useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
|
||||
setMovingNode(duplicate)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const getWindowPresetData = useCallback(() => {
|
||||
if (!node) return null
|
||||
return {
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
windowType: node.windowType,
|
||||
operationState: node.operationState,
|
||||
awningDirection: node.awningDirection,
|
||||
casementStyle: node.casementStyle,
|
||||
hingesSide: node.hingesSide,
|
||||
frameThickness: node.frameThickness,
|
||||
frameDepth: node.frameDepth,
|
||||
openingKind: node.openingKind,
|
||||
openingShape: node.openingShape,
|
||||
openingRadiusMode: node.openingRadiusMode ?? 'all',
|
||||
openingCornerRadii: node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15],
|
||||
cornerRadius: node.cornerRadius,
|
||||
archHeight: node.archHeight,
|
||||
openingRevealRadius: node.openingRevealRadius,
|
||||
columnRatios: node.columnRatios,
|
||||
rowRatios: node.rowRatios,
|
||||
columnDividerThickness: node.columnDividerThickness,
|
||||
rowDividerThickness: node.rowDividerThickness,
|
||||
sill: node.sill,
|
||||
sillDepth: node.sillDepth,
|
||||
sillThickness: node.sillThickness,
|
||||
}
|
||||
}, [node])
|
||||
|
||||
const handleSavePreset = useCallback(
|
||||
async (name: string) => {
|
||||
const data = getWindowPresetData()
|
||||
if (!(data && selectedId)) return
|
||||
const presetId = await adapter.savePreset('window', name, data)
|
||||
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
|
||||
},
|
||||
[getWindowPresetData, selectedId, adapter],
|
||||
)
|
||||
|
||||
const handleOverwritePreset = useCallback(
|
||||
async (id: string) => {
|
||||
const data = getWindowPresetData()
|
||||
if (!(data && selectedId)) return
|
||||
await adapter.overwritePreset('window', id, data)
|
||||
emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
|
||||
},
|
||||
[getWindowPresetData, selectedId, adapter],
|
||||
)
|
||||
|
||||
const handleApplyPreset = useCallback(
|
||||
(data: Record<string, unknown>) => {
|
||||
handleUpdate(data as Partial<WindowNode>)
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
if (!(node && node.type === 'window' && selectedId)) return null
|
||||
|
||||
const numCols = node.columnRatios.length
|
||||
const numRows = node.rowRatios.length
|
||||
|
||||
const colSum = node.columnRatios.reduce((a, b) => a + b, 0)
|
||||
const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
|
||||
const normCols = node.columnRatios.map((r) => r / colSum)
|
||||
const normRows = node.rowRatios.map((r) => r / rowSum)
|
||||
const isOpening = node.openingKind === 'opening'
|
||||
const openingShape = node.openingShape ?? 'rectangle'
|
||||
const windowShape =
|
||||
openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
|
||||
const openingRadiusMode = node.openingRadiusMode ?? 'all'
|
||||
const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
|
||||
const cornerRadius = node.cornerRadius ?? 0.15
|
||||
const archHeight = node.archHeight ?? 0.35
|
||||
const openingRevealRadius = node.openingRevealRadius ?? 0.025
|
||||
const maxRoundedRadius = Math.max(0.01, getMaxSharedWindowRadius(node.width, node.height))
|
||||
const displayedWindowType = node.windowType === 'hopper' ? 'awning' : (node.windowType ?? 'fixed')
|
||||
const awningDirection = node.windowType === 'hopper' ? 'down' : (node.awningDirection ?? 'up')
|
||||
const isOperableWindow =
|
||||
node.windowType === 'sliding' ||
|
||||
node.windowType === 'casement' ||
|
||||
node.windowType === 'awning' ||
|
||||
node.windowType === 'hopper' ||
|
||||
node.windowType === 'single-hung' ||
|
||||
node.windowType === 'double-hung' ||
|
||||
node.windowType === 'louvered'
|
||||
const supportsWindowShape = shapedWindowTypes.has(node.windowType ?? 'fixed')
|
||||
const supportsGrid = node.windowType === 'fixed'
|
||||
const supportsSill = !silllessWindowTypes.has(node.windowType)
|
||||
|
||||
const setOperationState = (value: number) => {
|
||||
useInteractive.getState().cancelWindowAnimation(node.id)
|
||||
useInteractive.getState().removeWindowOpenState(node.id)
|
||||
handleUpdate({ operationState: Math.max(0, Math.min(1, value)) })
|
||||
}
|
||||
|
||||
const getDimensionUpdates = (updates: Partial<Pick<WindowNode, 'width' | 'height'>>) => {
|
||||
const nextWidth = updates.width ?? node.width
|
||||
const nextHeight = updates.height ?? node.height
|
||||
const nextUpdates: Partial<WindowNode> = { ...updates }
|
||||
|
||||
if (openingShape === 'rounded') {
|
||||
if (openingRadiusMode === 'individual') {
|
||||
const currentRadii = openingCornerRadii as [number, number, number, number]
|
||||
const nextRadii = normalizeWindowCornerRadii(
|
||||
openingCornerRadii as [number, number, number, number],
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
)
|
||||
if (!isSameRadiusTuple(currentRadii, nextRadii)) {
|
||||
nextUpdates.openingCornerRadii = nextRadii
|
||||
}
|
||||
} else {
|
||||
const nextRadius = Math.min(
|
||||
Math.max(cornerRadius, 0),
|
||||
getMaxSharedWindowRadius(nextWidth, nextHeight),
|
||||
)
|
||||
if (Math.abs(nextRadius - cornerRadius) > 1e-6) {
|
||||
nextUpdates.cornerRadius = nextRadius
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (openingShape === 'arch') {
|
||||
const nextArchHeight = Math.min(Math.max(archHeight, 0.05), Math.max(nextHeight, 0.05))
|
||||
if (Math.abs(nextArchHeight - archHeight) > 1e-6) {
|
||||
nextUpdates.archHeight = nextArchHeight
|
||||
}
|
||||
}
|
||||
|
||||
return nextUpdates
|
||||
}
|
||||
|
||||
const setOpeningCornerRadius = (index: number, value: number, commit = false) => {
|
||||
const next = [...openingCornerRadii] as [number, number, number, number]
|
||||
next[index] = value
|
||||
if (commit) {
|
||||
commitWindowPreview('openingCornerRadii', next)
|
||||
} else {
|
||||
previewWindowUpdate('openingCornerRadii', next)
|
||||
}
|
||||
}
|
||||
|
||||
const setColumnRatio = (index: number, newVal: number) => {
|
||||
const clamped = Math.max(0.05, Math.min(0.95, newVal))
|
||||
const neighborIdx = index < numCols - 1 ? index + 1 : index - 1
|
||||
const delta = clamped - normCols[index]!
|
||||
const neighborVal = Math.max(0.05, normCols[neighborIdx]! - delta)
|
||||
const newRatios = normCols.map((v, i) => {
|
||||
if (i === index) return clamped
|
||||
if (i === neighborIdx) return neighborVal
|
||||
return v
|
||||
})
|
||||
handleUpdate({ columnRatios: newRatios })
|
||||
}
|
||||
|
||||
const setRowRatio = (index: number, newVal: number) => {
|
||||
const clamped = Math.max(0.05, Math.min(0.95, newVal))
|
||||
const neighborIdx = index < numRows - 1 ? index + 1 : index - 1
|
||||
const delta = clamped - normRows[index]!
|
||||
const neighborVal = Math.max(0.05, normRows[neighborIdx]! - delta)
|
||||
const newRatios = normRows.map((v, i) => {
|
||||
if (i === index) return clamped
|
||||
if (i === neighborIdx) return neighborVal
|
||||
return v
|
||||
})
|
||||
handleUpdate({ rowRatios: newRatios })
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/window.png"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Window'}
|
||||
width={320}
|
||||
>
|
||||
{/* Presets strip */}
|
||||
<div className="border-border/30 border-b px-3 pt-2.5 pb-1.5">
|
||||
<PresetsPopover
|
||||
isAuthenticated={adapter.isAuthenticated}
|
||||
onApply={handleApplyPreset}
|
||||
onDelete={(id) => adapter.deletePreset(id)}
|
||||
onFetchPresets={(tab) => adapter.fetchPresets('window', tab)}
|
||||
onOverwrite={handleOverwritePreset}
|
||||
onRename={(id, name) => adapter.renamePreset(id, name)}
|
||||
onSave={handleSavePreset}
|
||||
onToggleCommunity={adapter.togglePresetCommunity}
|
||||
tabs={adapter.tabs}
|
||||
type="window"
|
||||
>
|
||||
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 font-medium text-muted-foreground text-xs transition-colors hover:bg-[#3e3e3e] hover:text-foreground">
|
||||
<BookMarked className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>Presets</span>
|
||||
</button>
|
||||
</PresetsPopover>
|
||||
</div>
|
||||
|
||||
<PanelSection title="Type">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
openingKind: value as WindowNode['openingKind'],
|
||||
...(value === 'opening'
|
||||
? {
|
||||
openingShape,
|
||||
openingRadiusMode,
|
||||
openingCornerRadii,
|
||||
cornerRadius,
|
||||
archHeight,
|
||||
openingRevealRadius,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: 'window', label: 'Window' },
|
||||
{ value: 'opening', label: 'Opening' },
|
||||
]}
|
||||
value={node.openingKind ?? 'window'}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{!isOpening && (
|
||||
<PanelSection title="Window Type">
|
||||
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
|
||||
{windowTypeOptions.map((option) => {
|
||||
const isSelected = displayedWindowType === option.value
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-h-12 items-center rounded-lg border px-2.5 text-left text-xs transition-colors',
|
||||
isSelected
|
||||
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
|
||||
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
||||
)}
|
||||
key={option.value}
|
||||
onClick={() =>
|
||||
handleUpdate({
|
||||
windowType: option.value,
|
||||
...(option.value === 'awning' ? { awningDirection } : {}),
|
||||
...(!shapedWindowTypes.has(option.value)
|
||||
? { openingShape: 'rectangle' }
|
||||
: {}),
|
||||
...(silllessWindowTypes.has(option.value) ? { sill: false } : {}),
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate font-medium">{option.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{displayedWindowType === 'awning' && (
|
||||
<div className="mt-2">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
windowType: 'awning',
|
||||
awningDirection: value as WindowNode['awningDirection'],
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: 'up', label: 'Up' },
|
||||
{ value: 'down', label: 'Down' },
|
||||
]}
|
||||
value={awningDirection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{node.windowType === 'casement' && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({ casementStyle: value as WindowNode['casementStyle'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'single', label: 'Single' },
|
||||
{ value: 'french', label: 'French' },
|
||||
]}
|
||||
value={node.casementStyle ?? 'single'}
|
||||
/>
|
||||
{(node.casementStyle ?? 'single') === 'single' && (
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({ hingesSide: value as WindowNode['hingesSide'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'left', label: 'Left' },
|
||||
{ value: 'right', label: 'Right' },
|
||||
]}
|
||||
value={node.hingesSide ?? 'left'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isOperableWindow && (
|
||||
<div className="mt-2">
|
||||
<SliderControl
|
||||
label="Open"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={setOperationState}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
value={Math.round((node.operationState ?? 0) * 100) / 100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
{!isOpening && (
|
||||
<div className="px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
className="w-full"
|
||||
icon={<FlipHorizontal2 className="h-4 w-4" />}
|
||||
label="Flip Side"
|
||||
onClick={handleFlip}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Width"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate(getDimensionUpdates({ width: v }))}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate(getDimensionUpdates({ height: v }))}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{!isOpening && supportsWindowShape && (
|
||||
<PanelSection title="Corner Shape">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
openingShape: value as WindowNode['openingShape'],
|
||||
...(value === 'rounded'
|
||||
? {
|
||||
openingRadiusMode,
|
||||
openingCornerRadii,
|
||||
cornerRadius: Math.min(cornerRadius, maxRoundedRadius),
|
||||
openingRevealRadius,
|
||||
sill: false,
|
||||
}
|
||||
: {}),
|
||||
...(value === 'arch' ? { archHeight } : {}),
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: 'rectangle', label: 'Rect' },
|
||||
{ value: 'rounded', label: 'Rounded' },
|
||||
{ value: 'arch', label: 'Arch' },
|
||||
]}
|
||||
value={windowShape}
|
||||
/>
|
||||
{windowShape === 'rounded' && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'individual', label: 'Individual' },
|
||||
]}
|
||||
value={openingRadiusMode}
|
||||
/>
|
||||
{openingRadiusMode === 'all' ? (
|
||||
<SliderControl
|
||||
label="Corner Radius"
|
||||
max={maxRoundedRadius}
|
||||
min={0}
|
||||
onChange={(value) => previewWindowUpdate('cornerRadius', value)}
|
||||
onCommit={(value) => commitWindowPreview('cornerRadius', value)}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(cornerRadius * 100) / 100}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{[
|
||||
['Top Left', 0],
|
||||
['Top Right', 1],
|
||||
['Bottom Right', 2],
|
||||
['Bottom Left', 3],
|
||||
].map(([label, index]) => (
|
||||
<SliderControl
|
||||
key={label}
|
||||
label={label}
|
||||
max={maxRoundedRadius}
|
||||
min={0}
|
||||
onChange={(value) => setOpeningCornerRadius(index as number, value)}
|
||||
onCommit={(value) => setOpeningCornerRadius(index as number, value, true)}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Reveal Radius"
|
||||
max={0.08}
|
||||
min={0}
|
||||
onChange={(value) => previewWindowUpdate('openingRevealRadius', value)}
|
||||
onCommit={(value) => commitWindowPreview('openingRevealRadius', value)}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(openingRevealRadius * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{windowShape === 'arch' && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Arch Height"
|
||||
max={Math.max(0.05, node.height)}
|
||||
min={0.05}
|
||||
onChange={(value) => handleUpdate({ archHeight: value })}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(archHeight * 100) / 100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{isOpening && (
|
||||
<PanelSection title="Opening Shape">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({ openingShape: value as WindowNode['openingShape'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'rectangle', label: 'Rect' },
|
||||
{ value: 'rounded', label: 'Rounded' },
|
||||
{ value: 'arch', label: 'Arch' },
|
||||
]}
|
||||
value={openingShape}
|
||||
/>
|
||||
{openingShape === 'rounded' && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'individual', label: 'Individual' },
|
||||
]}
|
||||
value={openingRadiusMode}
|
||||
/>
|
||||
{openingRadiusMode === 'all' ? (
|
||||
<SliderControl
|
||||
label="Corner Radius"
|
||||
max={maxRoundedRadius}
|
||||
min={0}
|
||||
onChange={(value) => previewWindowUpdate('cornerRadius', value)}
|
||||
onCommit={(value) => commitWindowPreview('cornerRadius', value)}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(cornerRadius * 100) / 100}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{[
|
||||
['Top Left', 0],
|
||||
['Top Right', 1],
|
||||
['Bottom Right', 2],
|
||||
['Bottom Left', 3],
|
||||
].map(([label, index]) => (
|
||||
<SliderControl
|
||||
key={label}
|
||||
label={label}
|
||||
max={maxRoundedRadius}
|
||||
min={0}
|
||||
onChange={(value) => setOpeningCornerRadius(index as number, value)}
|
||||
onCommit={(value) => setOpeningCornerRadius(index as number, value, true)}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Reveal Radius"
|
||||
max={0.08}
|
||||
min={0}
|
||||
onChange={(value) => previewWindowUpdate('openingRevealRadius', value)}
|
||||
onCommit={(value) => commitWindowPreview('openingRevealRadius', value)}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(openingRevealRadius * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{openingShape === 'arch' && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Arch Height"
|
||||
max={Math.max(0.05, node.height)}
|
||||
min={0.05}
|
||||
onChange={(value) => handleUpdate({ archHeight: value })}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(archHeight * 100) / 100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{!isOpening && (
|
||||
<>
|
||||
<PanelSection title="Frame">
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{supportsGrid && (
|
||||
<PanelSection title="Grid">
|
||||
<SliderControl
|
||||
label="Columns"
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={numCols}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rows"
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={numRows}
|
||||
/>
|
||||
|
||||
{numCols > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Col Widths
|
||||
</div>
|
||||
{normCols.map((ratio, i) => (
|
||||
<SliderControl
|
||||
key={`c-${i}`}
|
||||
label={`C${i + 1}`}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setColumnRatio(i, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{numRows > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Row Heights
|
||||
</div>
|
||||
{normRows.map((ratio, i) => (
|
||||
<SliderControl
|
||||
key={`r-${i}`}
|
||||
label={`R${i + 1}`}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setRowRatio(i, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{supportsSill && (
|
||||
<PanelSection title="Sill">
|
||||
<ToggleControl
|
||||
checked={node.sill}
|
||||
label="Enable Sill"
|
||||
onChange={(checked) => handleUpdate({ sill: checked })}
|
||||
/>
|
||||
{node.sill && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ sillDepth: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.sillDepth * 1000) / 1000}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ sillThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.sillThickness * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="hover:bg-red-500/20"
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -130,8 +130,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
area += polygon[i]?.[0] * polygon[j]?.[1]
|
||||
area -= polygon[j]?.[0] * polygon[i]?.[1]
|
||||
const pi = polygon[i]
|
||||
const pj = polygon[j]
|
||||
if (!(pi && pj)) continue
|
||||
area += pi[0] * pj[1]
|
||||
area -= pj[0] * pi[1]
|
||||
}
|
||||
|
||||
return Math.abs(area) / 2
|
||||
|
||||
@@ -38,7 +38,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({
|
||||
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
actions={<TreeNodeActions nodeId={node.id} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
@@ -53,7 +53,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({
|
||||
<InlineRenameInput
|
||||
defaultName="Fence"
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
nodeId={node.id}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
|
||||
@@ -91,8 +91,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
area += polygon[i]?.[0] * polygon[j]?.[1]
|
||||
area -= polygon[j]?.[0] * polygon[i]?.[1]
|
||||
const pi = polygon[i]
|
||||
const pj = polygon[j]
|
||||
if (!(pi && pj)) continue
|
||||
area += pi[0] * pj[1]
|
||||
area -= pj[0] * pi[1]
|
||||
}
|
||||
|
||||
return Math.abs(area) / 2
|
||||
|
||||
@@ -77,49 +77,61 @@ interface TreeNodeProps {
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
// Per-kind tree-node components keyed by `node.type`. Lookup replaces
|
||||
// the legacy switch — adding a kind to this map is now the only edit
|
||||
// needed in this file (the switch's `case '<kind>':` clauses were
|
||||
// flagged by the Phase 6 grep gate as the last per-kind dispatch
|
||||
// outside the registry; future work moves these to a
|
||||
// `def.presentation`-driven generic tree-node and removes this map
|
||||
// entirely).
|
||||
const treeNodeByType: Record<
|
||||
string,
|
||||
React.ComponentType<{ depth: number; isLast?: boolean; nodeId: AnyNodeId }>
|
||||
> = {
|
||||
building: BuildingTreeNode as React.ComponentType<{
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
nodeId: AnyNodeId
|
||||
}>,
|
||||
ceiling: CeilingTreeNode,
|
||||
column: ColumnTreeNode,
|
||||
elevator: ElevatorTreeNode,
|
||||
level: LevelTreeNode as React.ComponentType<{
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
nodeId: AnyNodeId
|
||||
}>,
|
||||
shelf: ShelfTreeNode as React.ComponentType<{
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
nodeId: AnyNodeId
|
||||
}>,
|
||||
slab: SlabTreeNode,
|
||||
spawn: SpawnTreeNode as React.ComponentType<{
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
nodeId: AnyNodeId
|
||||
}>,
|
||||
wall: WallTreeNode,
|
||||
fence: FenceTreeNode,
|
||||
roof: RoofTreeNode,
|
||||
stair: StairTreeNode,
|
||||
door: DoorTreeNode,
|
||||
window: WindowTreeNode,
|
||||
zone: ZoneTreeNode as React.ComponentType<{
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
nodeId: AnyNodeId
|
||||
}>,
|
||||
item: ItemTreeNode,
|
||||
}
|
||||
|
||||
export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
||||
const nodeType = useScene((state) => state.nodes[nodeId]?.type)
|
||||
|
||||
if (!nodeType) return null
|
||||
|
||||
switch (nodeType) {
|
||||
case 'building':
|
||||
return (
|
||||
<BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
|
||||
)
|
||||
case 'ceiling':
|
||||
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'column':
|
||||
return <ColumnTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'elevator':
|
||||
return <ElevatorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'level':
|
||||
return <LevelTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `level_${string}`} />
|
||||
case 'shelf':
|
||||
return <ShelfTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `shelf_${string}`} />
|
||||
case 'slab':
|
||||
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'spawn':
|
||||
return <SpawnTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `spawn_${string}`} />
|
||||
case 'wall':
|
||||
return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'fence':
|
||||
return <FenceTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'roof':
|
||||
return <RoofTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'stair':
|
||||
return <StairTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'item':
|
||||
return <ItemTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'door':
|
||||
return <DoorTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'window':
|
||||
return <WindowTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
case 'zone':
|
||||
return <ZoneTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `zone_${string}`} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
const Component = treeNodeByType[nodeType]
|
||||
if (!Component) return null
|
||||
return <Component depth={depth} isLast={isLast} nodeId={nodeId} />
|
||||
})
|
||||
|
||||
interface TreeNodeWrapperProps {
|
||||
|
||||
@@ -12,7 +12,7 @@ export const ViewerZoneSystem = () => {
|
||||
const structureLayer = useEditor.getState().structureLayer
|
||||
const nodes = useScene.getState().nodes
|
||||
|
||||
sceneRegistry.byType.zone.forEach((id) => {
|
||||
sceneRegistry.byType.zone!.forEach((id) => {
|
||||
const obj = sceneRegistry.nodes.get(id)
|
||||
if (!obj) return
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@ export {
|
||||
type SnapshotCameraData,
|
||||
ThumbnailGenerator,
|
||||
} from './components/editor/thumbnail-generator'
|
||||
// SVG path builders for arc / annular-sector / arrow-head shapes —
|
||||
// inlined into `kind: 'path'` / `kind: 'polygon'` primitives by curved
|
||||
// stair rendering in `nodes/src/stair/floorplan.ts`.
|
||||
export {
|
||||
buildSvgAnnularSectorPath,
|
||||
buildSvgArcPath,
|
||||
buildSvgArrowHeadPoints,
|
||||
getArcPlanPoint,
|
||||
} from './components/editor-2d/svg-paths'
|
||||
// Phase 5 Stage D transitional exports — pure drafting / angle helpers
|
||||
// consumed by kind-owned drag actions in @pascal-app/nodes. Stage F
|
||||
// cleanup moves these into @pascal-app/nodes (fence/drafting.ts +
|
||||
@@ -13,6 +22,29 @@ export {
|
||||
type FencePlanPoint,
|
||||
snapFenceDraftPoint,
|
||||
} from './components/tools/fence/fence-drafting'
|
||||
// Placement-math helpers — shared by kind-owned placement tools in
|
||||
// `@pascal-app/nodes` (wall curve sagitta snap, door / window placement,
|
||||
// item drop) so kinds don't reach into editor internals.
|
||||
export {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
snapToGrid,
|
||||
snapToHalf,
|
||||
snapUpToGridStep,
|
||||
stripTransient,
|
||||
} from './components/tools/item/placement-math'
|
||||
export type { PlacementState } from './components/tools/item/placement-types'
|
||||
// Item placement / move primitives. Re-exported here so the registry-driven
|
||||
// item move-tool in `@pascal-app/nodes` can compose them — same hooks the
|
||||
// legacy `MoveItemContent` + `ItemTool` use. Once item placement is fully
|
||||
// owned by `nodes`, these can be inlined there and dropped from editor.
|
||||
export { type DraftNodeHandle, useDraftNode } from './components/tools/item/use-draft-node'
|
||||
export {
|
||||
type PlacementCoordinatorConfig,
|
||||
usePlacementCoordinator,
|
||||
} from './components/tools/item/use-placement-coordinator'
|
||||
export { CursorSphere } from './components/tools/shared/cursor-sphere'
|
||||
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
|
||||
export {
|
||||
@@ -24,10 +56,32 @@ export {
|
||||
getAngleToSegmentReference,
|
||||
getSegmentAngleReferenceAtPoint,
|
||||
} from './components/tools/shared/segment-angle'
|
||||
// Stair placement defaults — used by the kind-owned stair / stair-segment
|
||||
// panels. Re-exported from `components/tools/stair/stair-defaults.ts`.
|
||||
export {
|
||||
DEFAULT_CURVED_STAIR_INNER_RADIUS,
|
||||
DEFAULT_CURVED_STAIR_SWEEP_ANGLE,
|
||||
DEFAULT_SPIRAL_SHOW_CENTER_COLUMN,
|
||||
DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS,
|
||||
DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE,
|
||||
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 './components/tools/stair/stair-defaults'
|
||||
export {
|
||||
createWallOnCurrentLevel,
|
||||
getWallGridStep,
|
||||
isWallLongEnough,
|
||||
snapPointToGrid,
|
||||
snapScalarToGrid,
|
||||
snapWallDraftPoint,
|
||||
type WallPlanPoint,
|
||||
@@ -36,16 +90,23 @@ export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu
|
||||
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
|
||||
export { useCommandPalette } from './components/ui/command-palette'
|
||||
export { ActionButton, ActionGroup } from './components/ui/controls/action-button'
|
||||
export { MaterialPicker } from './components/ui/controls/material-picker'
|
||||
export { MetricControl } from './components/ui/controls/metric-control'
|
||||
export { PanelSection } from './components/ui/controls/panel-section'
|
||||
export { SegmentedControl } from './components/ui/controls/segmented-control'
|
||||
export { SliderControl } from './components/ui/controls/slider-control'
|
||||
export { ToggleControl } from './components/ui/controls/toggle-control'
|
||||
export { FloatingLevelSelector } from './components/ui/floating-level-selector'
|
||||
export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items'
|
||||
// Item collections UI — used by the kind-owned ItemPanel in nodes/.
|
||||
export { CollectionsPopover } from './components/ui/panels/collections/collections-popover'
|
||||
// Phase 5 Stage E — kinds with bespoke editors (slab holes list,
|
||||
// ceiling height presets, etc.) use `parametrics.customPanel` to mount
|
||||
// a kind-owned panel and need PanelWrapper for the chrome.
|
||||
export { PanelWrapper } from './components/ui/panels/panel-wrapper'
|
||||
// Presets popover — used by kind-owned door / window panels for their
|
||||
// hardware / type / opening presets.
|
||||
export { PresetsPopover } from './components/ui/panels/presets/presets-popover'
|
||||
export { PALETTE_COLORS } from './components/ui/primitives/color-dot'
|
||||
export { useSidebarStore } from './components/ui/primitives/sidebar'
|
||||
export { Slider } from './components/ui/primitives/slider'
|
||||
@@ -60,7 +121,7 @@ export {
|
||||
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
|
||||
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
|
||||
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
|
||||
export { PresetsProvider } from './contexts/presets-context'
|
||||
export { PresetsProvider, usePresetsAdapter } from './contexts/presets-context'
|
||||
export type { SaveStatus } from './hooks/use-auto-save'
|
||||
// useDragAction is the React-side glue for the registry's DragAction
|
||||
// primitive. Public so registry-driven kinds (Phase 5+ Stage D ports)
|
||||
@@ -69,9 +130,44 @@ export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
|
||||
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
|
||||
export { markToolCancelConsumed } from './hooks/use-keyboard'
|
||||
export { EDITOR_LAYER } from './lib/constants'
|
||||
// Helper libs used by the kind-owned roof / stair / elevator panels.
|
||||
export {
|
||||
resolveCurrentBuildingId,
|
||||
resolveElevatorNodeSupportY,
|
||||
resolveElevatorSupportLevelId,
|
||||
resolveElevatorSupportY,
|
||||
} from './lib/elevator-support'
|
||||
// Floor-plan stair helpers — the cumulative-transform walk
|
||||
// (`computeFloorplanStairSegmentTransforms`) and the rich segment-entry
|
||||
// builder (`buildFloorplanStairEntry`) used by the kind-owned stair
|
||||
// floor-plan emitter in `@pascal-app/nodes/src/stair/floorplan.ts`.
|
||||
// Each flight's transform depends on every prior sibling's length /
|
||||
// height / `attachmentSide`, so individual stair-segments can't compute
|
||||
// their own polygon in isolation — the stair (parent) owns the
|
||||
// computation and emits the whole stack as one registry entry.
|
||||
export {
|
||||
buildFloorplanStairEntry,
|
||||
type FloorplanStairArrowEntry,
|
||||
type FloorplanStairEntry,
|
||||
type FloorplanStairSegmentEntry,
|
||||
} from './lib/floorplan'
|
||||
export {
|
||||
buildRoofSurfaceMaterialPatch,
|
||||
buildSingleSurfaceMaterialPatch,
|
||||
buildStairSurfaceMaterialPatch,
|
||||
buildWallSurfaceMaterialPatch,
|
||||
getActivePaintMaterialLabel,
|
||||
hasActivePaintMaterial,
|
||||
} from './lib/material-paint'
|
||||
export { duplicateRoofSubtree } from './lib/roof-duplication'
|
||||
export type { SceneGraph } from './lib/scene'
|
||||
export { applySceneGraphToEditor } from './lib/scene'
|
||||
export { triggerSFX } from './lib/sfx-bus'
|
||||
export { duplicateStairSubtree } from './lib/stair-duplication'
|
||||
// `cn` (twMerge + clsx) — used by kind-owned panels in `@pascal-app/
|
||||
// nodes` so they don't need their own copy / their own tailwind-merge
|
||||
// dependency.
|
||||
export { cn } from './lib/utils'
|
||||
export { default as useAudio } from './store/use-audio'
|
||||
export { type CommandAction, useCommandRegistry } from './store/use-command-registry'
|
||||
export type {
|
||||
|
||||
@@ -33,50 +33,62 @@ function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Material field keys per kind, used by the `structure` duplicate preset
|
||||
* to strip materials from the cloned subtree. Lookup table replaces the
|
||||
* legacy per-kind switch — the Phase 6 grep gate flagged `case '<kind>':`
|
||||
* in this file as the remaining per-kind dispatch outside the registry.
|
||||
*
|
||||
* Future: move this to a `capabilities.materialFields` declaration on
|
||||
* each kind's `NodeDefinition` so adding a new kind with materials is a
|
||||
* registry-only edit. Today the registry doesn't surface material fields
|
||||
* in a uniform way (each kind's panel reads / writes them directly), so
|
||||
* this map mirrors the legacy behavior 1:1.
|
||||
*/
|
||||
const MATERIAL_FIELDS_BY_KIND: Record<string, ReadonlyArray<string>> = {
|
||||
wall: [
|
||||
'material',
|
||||
'materialPreset',
|
||||
'interiorMaterial',
|
||||
'interiorMaterialPreset',
|
||||
'exteriorMaterial',
|
||||
'exteriorMaterialPreset',
|
||||
],
|
||||
slab: ['material', 'materialPreset'],
|
||||
ceiling: ['material', 'materialPreset'],
|
||||
fence: ['material', 'materialPreset'],
|
||||
shelf: ['material', 'materialPreset'],
|
||||
'roof-segment': ['material', 'materialPreset'],
|
||||
'stair-segment': ['material', 'materialPreset'],
|
||||
window: ['material', 'materialPreset'],
|
||||
door: ['material', 'materialPreset'],
|
||||
roof: [
|
||||
'material',
|
||||
'materialPreset',
|
||||
'topMaterial',
|
||||
'topMaterialPreset',
|
||||
'edgeMaterial',
|
||||
'edgeMaterialPreset',
|
||||
'wallMaterial',
|
||||
'wallMaterialPreset',
|
||||
],
|
||||
stair: [
|
||||
'material',
|
||||
'materialPreset',
|
||||
'railingMaterial',
|
||||
'railingMaterialPreset',
|
||||
'treadMaterial',
|
||||
'treadMaterialPreset',
|
||||
'sideMaterial',
|
||||
'sideMaterialPreset',
|
||||
],
|
||||
}
|
||||
|
||||
function stripMaterials(node: AnyNode): AnyNode {
|
||||
const fields = MATERIAL_FIELDS_BY_KIND[node.type]
|
||||
if (!fields) return node
|
||||
const next = { ...node } as Record<string, unknown>
|
||||
|
||||
switch (node.type) {
|
||||
case 'wall':
|
||||
delete next.material
|
||||
delete next.materialPreset
|
||||
delete next.interiorMaterial
|
||||
delete next.interiorMaterialPreset
|
||||
delete next.exteriorMaterial
|
||||
delete next.exteriorMaterialPreset
|
||||
break
|
||||
case 'slab':
|
||||
case 'ceiling':
|
||||
case 'fence':
|
||||
case 'roof-segment':
|
||||
case 'stair-segment':
|
||||
case 'window':
|
||||
case 'door':
|
||||
delete next.material
|
||||
delete next.materialPreset
|
||||
break
|
||||
case 'roof':
|
||||
delete next.material
|
||||
delete next.materialPreset
|
||||
delete next.topMaterial
|
||||
delete next.topMaterialPreset
|
||||
delete next.edgeMaterial
|
||||
delete next.edgeMaterialPreset
|
||||
delete next.wallMaterial
|
||||
delete next.wallMaterialPreset
|
||||
break
|
||||
case 'stair':
|
||||
delete next.material
|
||||
delete next.materialPreset
|
||||
delete next.railingMaterial
|
||||
delete next.railingMaterialPreset
|
||||
delete next.treadMaterial
|
||||
delete next.treadMaterialPreset
|
||||
delete next.sideMaterial
|
||||
delete next.sideMaterialPreset
|
||||
break
|
||||
}
|
||||
|
||||
for (const field of fields) delete next[field]
|
||||
return next as AnyNode
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type MaterialTarget,
|
||||
type RoofNode,
|
||||
type RoofSurfaceMaterialRole,
|
||||
type ShelfNode,
|
||||
type SlabNode,
|
||||
type StairNode,
|
||||
type StairSurfaceMaterialRole,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
|
||||
export type PaintableMaterialTarget = Extract<
|
||||
MaterialTarget,
|
||||
'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling'
|
||||
'wall' | 'roof' | 'stair' | 'fence' | 'column' | 'slab' | 'ceiling' | 'shelf'
|
||||
>
|
||||
|
||||
export type SingleSurfaceMaterialRole = 'surface'
|
||||
@@ -133,7 +134,7 @@ export function buildStairSurfaceMaterialPatch(
|
||||
}
|
||||
|
||||
export function buildSingleSurfaceMaterialPatch<
|
||||
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode,
|
||||
TNode extends FenceNode | ColumnNode | SlabNode | CeilingNode | ShelfNode,
|
||||
>(material: MaterialSchema | undefined, materialPreset: string | undefined): Partial<TNode> {
|
||||
return {
|
||||
material,
|
||||
@@ -222,7 +223,8 @@ export function resolveActivePaintMaterialFromSelection(params: {
|
||||
(selectedNode.type === 'fence' ||
|
||||
selectedNode.type === 'column' ||
|
||||
selectedNode.type === 'slab' ||
|
||||
selectedNode.type === 'ceiling') &&
|
||||
selectedNode.type === 'ceiling' ||
|
||||
selectedNode.type === 'shelf') &&
|
||||
selectedMaterialTarget.role === 'surface'
|
||||
) {
|
||||
const target = selectedNode.type
|
||||
@@ -280,5 +282,9 @@ export function resolvePaintTargetFromSelection(params: {
|
||||
return 'ceiling'
|
||||
}
|
||||
|
||||
if (selectedNode.type === 'shelf') {
|
||||
return 'shelf'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -283,7 +283,11 @@ export function syncEditorSelectionFromCurrentScene() {
|
||||
|
||||
if (shouldRestoreEditorUiState) {
|
||||
if (restoredSelection) {
|
||||
useViewer.getState().setSelection(restoredSelection)
|
||||
// PersistedSelectionPath carries plain `string` ids (read from
|
||||
// localStorage, no branded-template-literal guarantee). The viewer's
|
||||
// SelectionPath expects branded ids. The runtime values match the
|
||||
// brand; the cast bridges the static gap.
|
||||
useViewer.getState().setSelection(restoredSelection as never)
|
||||
useEditor.setState(
|
||||
restoredEditorUiState.phase === 'site'
|
||||
? (selectionDrivenEditorUiState ?? restoredEditorUiState)
|
||||
@@ -305,7 +309,7 @@ export function syncEditorSelectionFromCurrentScene() {
|
||||
}
|
||||
|
||||
if (restoredSelection) {
|
||||
useViewer.getState().setSelection(restoredSelection)
|
||||
useViewer.getState().setSelection(restoredSelection as never)
|
||||
if (selectionDrivenEditorUiState) {
|
||||
useEditor.setState(selectionDrivenEditorUiState)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user