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,4 +1,4 @@
|
||||
import { loadPlugin, nodeRegistry } from '@pascal-app/core'
|
||||
import { discoverPlugins, loadPlugin, nodeRegistry } from '@pascal-app/core'
|
||||
import { builtinPlugin } from '@pascal-app/nodes'
|
||||
|
||||
// Idempotency guard: HMR can reload this module, but `registerNode` throws on
|
||||
@@ -12,10 +12,19 @@ function isDev(): boolean {
|
||||
return env?.NODE_ENV !== 'production'
|
||||
}
|
||||
|
||||
export function loadBuiltinNodes(): void {
|
||||
export async function loadBuiltinNodes(): Promise<void> {
|
||||
if (loaded) return
|
||||
loaded = true
|
||||
void loadPlugin(builtinPlugin)
|
||||
await loadPlugin(builtinPlugin)
|
||||
|
||||
// Phase 6 plugin discovery hook. Always called; default impl returns
|
||||
// `[]`. Apps that ship external node packs override the discovery via
|
||||
// `setPluginDiscovery(...)` before this module loads. See
|
||||
// `wiki/editor-plugin-authoring.md` for the contract.
|
||||
const externals = await discoverPlugins()
|
||||
for (const plugin of externals) {
|
||||
await loadPlugin(plugin)
|
||||
}
|
||||
|
||||
if (isDev()) {
|
||||
const kinds = Array.from(nodeRegistry.entries(), ([k]) => k)
|
||||
@@ -24,7 +33,7 @@ export function loadBuiltinNodes(): void {
|
||||
// "which path is running this kind?" Empty array = every kind is on
|
||||
// the legacy path. Kind in the array = registry path is live for it.
|
||||
console.info(
|
||||
`[pascal:registry] loaded ${builtinPlugin.id} v${builtinPlugin.apiVersion} (${kinds.length} kinds: ${kinds.join(', ') || '∅'})`,
|
||||
`[pascal:registry] loaded ${builtinPlugin.id} v${builtinPlugin.apiVersion} (${kinds.length} kinds: ${kinds.join(', ') || '∅'})${externals.length > 0 ? ` + ${externals.length} discovered plugin(s)` : ''}`,
|
||||
)
|
||||
}
|
||||
// Expose the registry on window for ad-hoc dev inspection. In prod the
|
||||
@@ -38,4 +47,4 @@ export function loadBuiltinNodes(): void {
|
||||
|
||||
// Run as a side effect on first import so any consumer of this module gets a
|
||||
// populated registry without remembering to call the function explicitly.
|
||||
loadBuiltinNodes()
|
||||
void loadBuiltinNodes()
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
LevelNode,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
ScanNode,
|
||||
ShelfNode,
|
||||
SiteNode,
|
||||
SlabNode,
|
||||
@@ -36,7 +37,14 @@ export interface GridEvent {
|
||||
*/
|
||||
localPosition: [number, number, number]
|
||||
faceIndex?: number
|
||||
object: Object3D
|
||||
/**
|
||||
* Optional: the hit Three.js object. Present when the grid event was
|
||||
* synthesized from a R3F mesh hit (the legacy grid-plane mesh path);
|
||||
* absent when emitted by the canvas-level raycaster in
|
||||
* `use-grid-events.ts`, where there is no specific mesh to attribute
|
||||
* the intersection to.
|
||||
*/
|
||||
object?: Object3D
|
||||
nativeEvent: ThreeEvent<PointerEvent>
|
||||
}
|
||||
|
||||
@@ -70,6 +78,8 @@ export type StairSegmentEvent = NodeEvent<StairSegmentNode>
|
||||
export type WindowEvent = NodeEvent<WindowNode>
|
||||
export type DoorEvent = NodeEvent<DoorNode>
|
||||
export type ElevatorEvent = NodeEvent<ElevatorNode>
|
||||
export type ScanEvent = NodeEvent<ScanNode>
|
||||
export type GuideEvent = NodeEvent<GuideNode>
|
||||
|
||||
// Event suffixes - exported for use in hooks
|
||||
export const eventSuffixes = [
|
||||
@@ -201,6 +211,8 @@ type EditorEvents = GridEvents &
|
||||
NodeEvents<'stair-segment', StairSegmentEvent> &
|
||||
NodeEvents<'window', WindowEvent> &
|
||||
NodeEvents<'door', DoorEvent> &
|
||||
NodeEvents<'scan', ScanEvent> &
|
||||
NodeEvents<'guide', GuideEvent> &
|
||||
CameraControlEvents &
|
||||
ToolEvents &
|
||||
GuideEvents &
|
||||
|
||||
@@ -3,43 +3,20 @@
|
||||
import { useLayoutEffect } from 'react'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
const KNOWN_NODE_KINDS = [
|
||||
'site',
|
||||
'building',
|
||||
'ceiling',
|
||||
'column',
|
||||
'elevator',
|
||||
'level',
|
||||
'wall',
|
||||
'fence',
|
||||
'item',
|
||||
'slab',
|
||||
'spawn',
|
||||
'zone',
|
||||
'roof',
|
||||
'roof-segment',
|
||||
'stair',
|
||||
'stair-segment',
|
||||
'scan',
|
||||
'guide',
|
||||
'window',
|
||||
'door',
|
||||
] as const
|
||||
// `byType` is a Proxy-backed Map keyed by kind. Sets are created lazily on
|
||||
// first access, so any kind (built-in or plugin-contributed) participates
|
||||
// without needing a hardcoded seed list. The previous `KNOWN_NODE_KINDS`
|
||||
// array was a pre-seed for autocomplete; with every kind now flowing
|
||||
// through `nodeRegistry`, the seed is redundant.
|
||||
//
|
||||
// The type expresses that *any* string key returns a `Set<string>` — the
|
||||
// Proxy auto-creates on first access so there's no `undefined` branch at
|
||||
// runtime. Without this shape, `noUncheckedIndexedAccess` would force
|
||||
// every caller to defend against an impossible undefined.
|
||||
type ByTypeMap = { [kind: string]: Set<string> }
|
||||
const byTypeStore = new Map<string, Set<string>>()
|
||||
|
||||
type KnownNodeKind = (typeof KNOWN_NODE_KINDS)[number]
|
||||
// Allow registry-registered (plugin) kinds while keeping autocomplete for built-ins.
|
||||
type NodeKind = KnownNodeKind | (string & {})
|
||||
|
||||
type ByTypeShape = Record<KnownNodeKind, Set<string>> & Record<string, Set<string>>
|
||||
|
||||
const byTypeStore = new Map<string, Set<string>>(
|
||||
KNOWN_NODE_KINDS.map((k) => [k, new Set<string>()]),
|
||||
)
|
||||
|
||||
// Auto-creates a Set the first time an unknown kind is accessed. This is what
|
||||
// lets registry-registered (and future plugin-contributed) kinds participate
|
||||
// in `byType` without being hardcoded here.
|
||||
const byTypeProxy = new Proxy({} as ByTypeShape, {
|
||||
const byTypeProxy = new Proxy({} as ByTypeMap, {
|
||||
get(_target, key) {
|
||||
if (typeof key !== 'string') return undefined
|
||||
let set = byTypeStore.get(key)
|
||||
@@ -67,9 +44,8 @@ export const sceneRegistry = {
|
||||
// Master lookup: ID -> Object3D
|
||||
nodes: new Map<string, THREE.Object3D>(),
|
||||
|
||||
// Categorized lookups: Kind -> Set of IDs.
|
||||
// Backed by a Proxy so registry-registered kinds get a Set on first touch,
|
||||
// while built-in kinds remain present from module init for fast paths.
|
||||
// Categorized lookups: Kind -> Set of IDs. Backed by a Proxy so any kind
|
||||
// gets a Set on first touch — no hardcoded list.
|
||||
byType: byTypeProxy,
|
||||
|
||||
/** Remove all entries. Call when unloading a scene to prevent stale 3D refs. */
|
||||
@@ -81,7 +57,7 @@ export const sceneRegistry = {
|
||||
},
|
||||
}
|
||||
|
||||
export function useRegistry(id: string, type: NodeKind, ref: React.RefObject<THREE.Object3D>) {
|
||||
export function useRegistry(id: string, type: string, ref: React.RefObject<THREE.Object3D>) {
|
||||
useLayoutEffect(() => {
|
||||
const obj = ref.current
|
||||
if (!obj) return
|
||||
@@ -89,11 +65,10 @@ export function useRegistry(id: string, type: NodeKind, ref: React.RefObject<THR
|
||||
// 1. Add to master map
|
||||
sceneRegistry.nodes.set(id, obj)
|
||||
|
||||
// 2. Add to type-specific set — Proxy auto-creates on first access so the
|
||||
// assertion is safe; TS just can't see through the Proxy.
|
||||
// 2. Add to type-specific set — Proxy auto-creates on first access.
|
||||
sceneRegistry.byType[type]!.add(id)
|
||||
|
||||
// 4. Cleanup when component unmounts
|
||||
// 3. Cleanup when component unmounts
|
||||
return () => {
|
||||
sceneRegistry.nodes.delete(id)
|
||||
sceneRegistry.byType[type]!.delete(id)
|
||||
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"@react-three/fiber": "^9",
|
||||
"lucide-react": "^1",
|
||||
"react": "^18 || ^19",
|
||||
"three": "^0.184"
|
||||
"three": "^0.184",
|
||||
"zustand": "^5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "^0.8.0",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BuildingNode as BuildingNodeSchema, type NodeDefinition } from '@pascal-app/core'
|
||||
import { buildingParametrics } from './parametrics'
|
||||
import { BuildingNode } from './schema'
|
||||
|
||||
/**
|
||||
* Building — Stage A. Container for levels; can be translated /
|
||||
* rotated as a whole (movable + rotatable on Y). The legacy
|
||||
* `MoveBuildingContent` handles building-wide drag; the registry
|
||||
* fallback would translate position, which is close to right —
|
||||
* but kept legacy at Stage A to avoid disturbing the building's
|
||||
* world-space group transform handling.
|
||||
*/
|
||||
export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
|
||||
kind: 'building',
|
||||
schemaVersion: 1,
|
||||
schema: BuildingNode,
|
||||
category: 'site',
|
||||
|
||||
defaults: () => {
|
||||
const stub = BuildingNodeSchema.parse({ id: 'building_default' as never, type: 'building' })
|
||||
const { id: _id, type: _type, ...rest } = stub
|
||||
return rest
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
// Building is a container — sidebar / building switcher drive
|
||||
// selection, never 3D click. Same reasoning as `level` / `site`.
|
||||
duplicable: false,
|
||||
deletable: false,
|
||||
},
|
||||
|
||||
parametrics: buildingParametrics,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
|
||||
presentation: {
|
||||
label: 'Building',
|
||||
description: 'A building container holding one or more levels.',
|
||||
icon: { kind: 'url', src: '/icons/building.png' },
|
||||
paletteSection: 'site',
|
||||
paletteOrder: 6,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description: 'A building container that groups levels.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { buildingDefinition } from './definition'
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { BuildingNode, ParametricDescriptor } from '@pascal-app/core'
|
||||
|
||||
export const buildingParametrics: ParametricDescriptor<BuildingNode> = {
|
||||
groups: [],
|
||||
}
|
||||
+5
-2
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { type BuildingNode, useRegistry } from '@pascal-app/core'
|
||||
import { NodeRenderer, useNodeEvents } from '@pascal-app/viewer'
|
||||
import { useRef } from 'react'
|
||||
import type { Group } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
export const BuildingRenderer = ({ node }: { node: BuildingNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
@@ -23,3 +24,5 @@ export const BuildingRenderer = ({ node }: { node: BuildingNode }) => {
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default BuildingRenderer
|
||||
@@ -0,0 +1 @@
|
||||
export { BuildingNode } from '@pascal-app/core'
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { buildCeilingFloorplan } from './floorplan'
|
||||
import {
|
||||
ceilingAddVertexAffordance,
|
||||
ceilingMoveEdgeAffordance,
|
||||
ceilingMoveVertexAffordance,
|
||||
} from './floorplan-affordances'
|
||||
import { ceilingFloorplanMoveTarget } from './floorplan-move'
|
||||
import { ceilingParametrics } from './parametrics'
|
||||
import { CeilingNode } from './schema'
|
||||
|
||||
@@ -76,6 +82,18 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
|
||||
priority: 4,
|
||||
},
|
||||
floorplan: buildCeilingFloorplan,
|
||||
// 2D move handler — translates polygon by cursor delta from first
|
||||
// pointer position. Mirror of slab; 3D `MoveCeilingTool` skips
|
||||
// 2D-sourced grid events so they don't double-write on commit.
|
||||
floorplanMoveTarget: ceilingFloorplanMoveTarget,
|
||||
// Sister to `affordanceTools['boundary-edit']`. Same `polygon` field;
|
||||
// SVG vertex handles dispatch to this affordance via the floor-plan
|
||||
// registry layer.
|
||||
floorplanAffordances: {
|
||||
'move-vertex': ceilingMoveVertexAffordance,
|
||||
'add-vertex': ceilingAddVertexAffordance,
|
||||
'move-edge': ceilingMoveEdgeAffordance,
|
||||
},
|
||||
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Trace ceiling outline' },
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { CeilingNode } from '@pascal-app/core'
|
||||
import {
|
||||
createPolygonAddVertexAffordance,
|
||||
createPolygonMoveEdgeAffordance,
|
||||
createPolygonVertexAffordance,
|
||||
} from '../shared/polygon-vertex-affordance'
|
||||
|
||||
/**
|
||||
* 2D drag affordances for ceiling. Same three operations as slab
|
||||
* (`move-vertex`, `add-vertex`, `move-edge`), each accepting an
|
||||
* optional `holeIndex`. See `slab/floorplan-affordances.ts` for the
|
||||
* full contract.
|
||||
*/
|
||||
export const ceilingMoveVertexAffordance = createPolygonVertexAffordance<CeilingNode>('ceiling')
|
||||
export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance<CeilingNode>('ceiling')
|
||||
export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance<CeilingNode>('ceiling')
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type CeilingNode,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for ceiling — mirrors the 3D `MoveCeilingTool`
|
||||
* live-drag pattern. See the equivalent module in `slab/floorplan-move.ts`
|
||||
* for the full rationale; the only ceiling-specific detail is the
|
||||
* preserved Y offset (`CeilingSystem` positions the mesh at `height − 0.01`
|
||||
* on rebuild, so the direct `mesh.position.y` mirrors that to avoid a
|
||||
* vertical teleport when the React group position is reconciled).
|
||||
*/
|
||||
const GRID_STEP = 0.5
|
||||
|
||||
function translatePolygon(
|
||||
polygon: ReadonlyArray<readonly [number, number]>,
|
||||
dx: number,
|
||||
dz: number,
|
||||
): Array<[number, number]> {
|
||||
return polygon.map(([x, z]) => [x + dx, z + dz] as [number, number])
|
||||
}
|
||||
|
||||
export const ceilingFloorplanMoveTarget: FloorplanMoveTarget<CeilingNode> = ({ node }) => {
|
||||
const ceilingId = node.id as AnyNodeId
|
||||
const originalPolygon = node.polygon.map(([x, z]) => [x, z] as [number, number])
|
||||
const originalHoles = (node.holes ?? []).map((hole) =>
|
||||
hole.map(([x, z]) => [x, z] as [number, number]),
|
||||
)
|
||||
const height = node.height ?? 2.5
|
||||
let anchor: [number, number] | null = null
|
||||
let lastDelta: [number, number] = [0, 0]
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [ceilingId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const snapped: WallPlanPoint = modifiers.shiftKey
|
||||
? ([planPoint[0], planPoint[1]] as WallPlanPoint)
|
||||
: snapPointToGrid([planPoint[0], planPoint[1]] as WallPlanPoint, GRID_STEP)
|
||||
if (!anchor) {
|
||||
anchor = [snapped[0], snapped[1]]
|
||||
return
|
||||
}
|
||||
const dx = snapped[0] - anchor[0]
|
||||
const dz = snapped[1] - anchor[1]
|
||||
lastDelta = [dx, dz]
|
||||
useLiveTransforms.getState().set(ceilingId, {
|
||||
position: [dx, 0, dz],
|
||||
rotation: 0,
|
||||
})
|
||||
const mesh = sceneRegistry.nodes.get(ceilingId) as THREE.Object3D | undefined
|
||||
// Preserve ceiling height — `CeilingSystem` sets `mesh.position.y =
|
||||
// height − 0.01` on each rebuild; mirror that during the drag so
|
||||
// the mesh stays at ceiling height (not collapsed to y=0).
|
||||
if (mesh) mesh.position.set(dx, height - 0.01, dz)
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[ceilingId] as CeilingNode | undefined
|
||||
if (!live || live.type !== 'ceiling') return false
|
||||
const [dx, dz] = lastDelta
|
||||
if (dx === 0 && dz === 0) return false
|
||||
// Sync commit sequence — see `slab/floorplan-move.ts` for the
|
||||
// full ordering rationale (scene write → direct markDirty →
|
||||
// useLiveTransforms.clear, all sync in this handler so React
|
||||
// render + CeilingSystem rebuild land in the same paint).
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: ceilingId,
|
||||
data: {
|
||||
polygon: translatePolygon(originalPolygon, dx, dz),
|
||||
holes: originalHoles.map((h) => translatePolygon(h, dx, dz)),
|
||||
},
|
||||
},
|
||||
])
|
||||
useScene.getState().markDirty(ceilingId)
|
||||
useLiveTransforms.getState().clear(ceilingId)
|
||||
return true
|
||||
},
|
||||
}
|
||||
return session
|
||||
}
|
||||
@@ -1,15 +1,29 @@
|
||||
import type { CeilingNode, FloorplanGeometry, FloorplanPoint } from '@pascal-app/core'
|
||||
import type {
|
||||
CeilingNode,
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
GeometryContext,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Stage C floor-plan builder for ceiling. Renders the polygon outline
|
||||
* as a dashed boundary (ceilings are above and would visually obscure
|
||||
* the slab/walls if drawn solid). Same shape as slab but visually
|
||||
* distinct.
|
||||
* Stage C floor-plan builder for ceiling. Dashed boundary (ceilings sit
|
||||
* above the slab); when selected, mounts the same boundary editor as
|
||||
* slab — vertex + midpoint + edge handles on the outer ring AND every
|
||||
* hole, with `holeIndex` carried in the handle payloads.
|
||||
*/
|
||||
export function buildCeilingFloorplan(node: CeilingNode): FloorplanGeometry | null {
|
||||
export function buildCeilingFloorplan(
|
||||
node: CeilingNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const polygon = node.polygon
|
||||
if (!polygon || polygon.length < 3) return null
|
||||
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
const isSelected = view?.selected ?? false
|
||||
const isHighlighted = view?.highlighted ?? false
|
||||
const showSelectedChrome = isSelected || isHighlighted
|
||||
|
||||
const outer: FloorplanPoint[] = polygon.map(([x, z]) => [x, z] as FloorplanPoint)
|
||||
|
||||
const ring = (points: FloorplanPoint[]) => {
|
||||
@@ -25,13 +39,71 @@ export function buildCeilingFloorplan(node: CeilingNode): FloorplanGeometry | nu
|
||||
segments.push(ring(hole.map(([x, z]) => [x, z] as FloorplanPoint)))
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'path',
|
||||
d: segments.join(' '),
|
||||
fill: 'none',
|
||||
stroke: '#94a3b8',
|
||||
strokeWidth: 0.03,
|
||||
strokeDasharray: '0.15 0.1',
|
||||
opacity: 0.7,
|
||||
const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#94a3b8'
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'path',
|
||||
d: segments.join(' '),
|
||||
fill: 'none',
|
||||
stroke,
|
||||
strokeWidth: showSelectedChrome ? 0.04 : 0.03,
|
||||
strokeDasharray: '0.15 0.1',
|
||||
opacity: showSelectedChrome ? 0.95 : 0.7,
|
||||
},
|
||||
]
|
||||
|
||||
if (isSelected) {
|
||||
appendRingEditor(children, polygon, undefined)
|
||||
holes.forEach((hole, holeIndex) => {
|
||||
if (hole.length >= 3) appendRingEditor(children, hole, holeIndex)
|
||||
})
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
/**
|
||||
* Same boundary editor as slab — see `nodes/src/slab/floorplan.ts` for
|
||||
* the contract. The kinds differ only in their fill / stroke chrome;
|
||||
* the editor primitives are identical.
|
||||
*/
|
||||
function appendRingEditor(
|
||||
children: FloorplanGeometry[],
|
||||
ring: ReadonlyArray<readonly [number, number]>,
|
||||
holeIndex: number | undefined,
|
||||
): void {
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const a = ring[i]!
|
||||
const b = ring[(i + 1) % ring.length]!
|
||||
children.push({
|
||||
kind: 'edge-handle',
|
||||
x1: a[0],
|
||||
y1: a[1],
|
||||
x2: b[0],
|
||||
y2: b[1],
|
||||
affordance: 'move-edge',
|
||||
payload: { holeIndex, edgeIndex: i },
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const a = ring[i]!
|
||||
const b = ring[(i + 1) % ring.length]!
|
||||
children.push({
|
||||
kind: 'midpoint-handle',
|
||||
point: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2],
|
||||
affordance: 'add-vertex',
|
||||
payload: { holeIndex, edgeIndex: i },
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const [x, z] = ring[i]!
|
||||
children.push({
|
||||
kind: 'endpoint-handle',
|
||||
point: [x, z],
|
||||
state: 'idle',
|
||||
affordance: 'move-vertex',
|
||||
payload: { holeIndex, vertexIndex: i },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type * as THREE from 'three'
|
||||
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — ceiling whole-move tool.
|
||||
@@ -57,6 +58,24 @@ function setMeshOffset(id: AnyNodeId, deltaX: number, deltaZ: number, height: nu
|
||||
if (mesh) mesh.position.set(deltaX, height - 0.01, deltaZ)
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinguish 3D-canvas grid events (this tool) from 2D floor-plan
|
||||
* grid events (`ceilingFloorplanMoveTarget` + `FloorplanRegistryMoveOverlay`
|
||||
* Path 1). See the equivalent helper in `slab/move-tool.tsx` for the
|
||||
* full rationale.
|
||||
*/
|
||||
function isFloorplanSourcedEvent(event: GridEvent): boolean {
|
||||
const native: unknown = event.nativeEvent
|
||||
const candidate =
|
||||
(native as { target?: unknown; nativeEvent?: { target?: unknown } } | null) ?? null
|
||||
const target =
|
||||
(candidate?.target as Element | null | undefined) ??
|
||||
(candidate?.nativeEvent as { target?: Element | null } | undefined)?.target ??
|
||||
null
|
||||
if (!target || typeof (target as Element).closest !== 'function') return false
|
||||
return (target as Element).closest('[data-floorplan-scene]') != null
|
||||
}
|
||||
|
||||
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]))
|
||||
@@ -112,6 +131,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
if (isFloorplanSourcedEvent(event)) return
|
||||
const localX = snap(event.localPosition[0])
|
||||
const localZ = snap(event.localPosition[2])
|
||||
|
||||
@@ -130,6 +150,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (isFloorplanSourcedEvent(event)) return
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
@@ -176,11 +197,126 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
|
||||
}
|
||||
}, [exitMoveMode, node.id])
|
||||
|
||||
return (
|
||||
<CeilingMovePreview
|
||||
ceilingId={node.id}
|
||||
cursorLocalPos={cursorLocalPos}
|
||||
height={node.height ?? 2.5}
|
||||
originalHoles={originalHolesRef.current}
|
||||
originalPolygon={originalPolygonRef.current}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Translucent fill + bright outline showing where the ceiling will land
|
||||
* during the drag. Mirrors the legacy `MoveCeilingTool` overlay so the
|
||||
* 3D viewer has a visible cue from above (the ceiling's child grid mesh
|
||||
* is hidden by default — without this preview the only mesh that shows
|
||||
* from above is the (translated) translucent ceiling itself, which is
|
||||
* easy to miss). Works for both the 3D `grid:move` path (this tool
|
||||
* writes `useLiveTransforms.position = [Δx, 0, Δz]` directly) and the
|
||||
* 2D floor-plan move path (`slab/ceiling/floorplan-move.ts` writes the
|
||||
* same value); we subscribe to that store so the preview tracks the
|
||||
* current delta regardless of which mover is driving it.
|
||||
*/
|
||||
function CeilingMovePreview({
|
||||
ceilingId,
|
||||
cursorLocalPos,
|
||||
height,
|
||||
originalHoles,
|
||||
originalPolygon,
|
||||
}: {
|
||||
ceilingId: AnyNodeId
|
||||
cursorLocalPos: [number, number, number]
|
||||
height: number
|
||||
originalHoles: Array<Array<[number, number]>>
|
||||
originalPolygon: Array<[number, number]>
|
||||
}) {
|
||||
const live = useLiveTransforms((s) => s.get(ceilingId))
|
||||
const dx = live?.position[0] ?? 0
|
||||
const dz = live?.position[2] ?? 0
|
||||
|
||||
const previewPolygon = useMemo(
|
||||
() => originalPolygon.map(([x, z]) => [x + dx, z + dz] as [number, number]),
|
||||
[originalPolygon, dx, dz],
|
||||
)
|
||||
const previewHoles = useMemo(
|
||||
() => originalHoles.map((hole) => hole.map(([x, z]) => [x + dx, z + dz] as [number, number])),
|
||||
[originalHoles, dx, dz],
|
||||
)
|
||||
|
||||
const previewFillGeometry = useMemo(
|
||||
() => createCeilingPreviewGeometry(previewPolygon, previewHoles),
|
||||
[previewPolygon, previewHoles],
|
||||
)
|
||||
const previewOutlineGeometry = useMemo(
|
||||
() => createCeilingOutlineGeometry(previewPolygon),
|
||||
[previewPolygon],
|
||||
)
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh geometry={previewFillGeometry} position={[0, height + 0.012, 0]}>
|
||||
<meshBasicMaterial
|
||||
color="#f5f5f4"
|
||||
depthWrite={false}
|
||||
opacity={0.3}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
{/* @ts-ignore - `<line>` is a valid R3F intrinsic but conflicts with SVG line typing */}
|
||||
<line geometry={previewOutlineGeometry} position={[0, height + 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 first = polygon[0]!
|
||||
shape.moveTo(first[0], -first[1])
|
||||
for (let i = 1; i < polygon.length; i++) {
|
||||
const pt = polygon[i]!
|
||||
shape.lineTo(pt[0], -pt[1])
|
||||
}
|
||||
shape.closePath()
|
||||
|
||||
for (const holePolygon of holes) {
|
||||
if (holePolygon.length < 3) continue
|
||||
const hole = new Path()
|
||||
const hf = holePolygon[0]!
|
||||
hole.moveTo(hf[0], -hf[1])
|
||||
for (let i = 1; i < holePolygon.length; i++) {
|
||||
const pt = holePolygon[i]!
|
||||
hole.lineTo(pt[0], -pt[1])
|
||||
}
|
||||
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 first = polygon[0]!
|
||||
points.push(new Vector3(first[0], 0, first[1]))
|
||||
geometry.setFromPoints(points)
|
||||
return geometry
|
||||
}
|
||||
|
||||
export default MoveCeilingTool
|
||||
|
||||
@@ -1,15 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import { CeilingRenderer } from '@pascal-app/viewer'
|
||||
import {
|
||||
type CeilingNode,
|
||||
getMaterialPresetByRef,
|
||||
resolveMaterial,
|
||||
useRegistry,
|
||||
} from '@pascal-app/core'
|
||||
import { NodeRenderer, useNodeEvents } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute } from 'three'
|
||||
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
||||
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
|
||||
function createEmptyGeometry() {
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute([], 3))
|
||||
return geometry
|
||||
}
|
||||
|
||||
const gridScale = 5
|
||||
const gridX = positionWorld.x.mul(gridScale).fract()
|
||||
const gridY = positionWorld.z.mul(gridScale).fract()
|
||||
const lineWidth = 0.05
|
||||
const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX))
|
||||
const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY))
|
||||
const gridPattern = lineX.max(lineY)
|
||||
const gridOpacity = mix(float(0.2), float(0.6), gridPattern)
|
||||
|
||||
function createCeilingMaterials(color = '#999999') {
|
||||
const topMaterial = new MeshBasicNodeMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
side: FrontSide,
|
||||
})
|
||||
topMaterial.opacityNode = gridOpacity
|
||||
|
||||
const bottomMaterial = new MeshBasicNodeMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
side: BackSide,
|
||||
})
|
||||
|
||||
return { topMaterial, bottomMaterial }
|
||||
}
|
||||
|
||||
const ceilingMaterialCache = new Map<string, ReturnType<typeof createCeilingMaterials>>()
|
||||
|
||||
function getCeilingMaterials(color = '#999999') {
|
||||
const cacheKey = color
|
||||
const cached = ceilingMaterialCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const materials = createCeilingMaterials(color)
|
||||
ceilingMaterialCache.set(cacheKey, materials)
|
||||
return materials
|
||||
}
|
||||
|
||||
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
const placeholderGeometry = useMemo(createEmptyGeometry, [])
|
||||
const gridPlaceholderGeometry = useMemo(createEmptyGeometry, [])
|
||||
|
||||
useRegistry(node.id, 'ceiling', ref)
|
||||
const handlers = useNodeEvents(node, 'ceiling')
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
placeholderGeometry.dispose()
|
||||
gridPlaceholderGeometry.dispose()
|
||||
},
|
||||
[gridPlaceholderGeometry, placeholderGeometry],
|
||||
)
|
||||
|
||||
const materials = useMemo(() => {
|
||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||
const props = preset?.mapProperties ?? resolveMaterial(node.material)
|
||||
const color = props.color || '#999999'
|
||||
return getCeilingMaterials(color)
|
||||
}, [
|
||||
node.materialPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
])
|
||||
|
||||
return (
|
||||
<mesh geometry={placeholderGeometry} material={materials.bottomMaterial} ref={ref}>
|
||||
<mesh
|
||||
geometry={gridPlaceholderGeometry}
|
||||
material={materials.topMaterial}
|
||||
name="ceiling-grid"
|
||||
{...handlers}
|
||||
scale={0}
|
||||
visible={false}
|
||||
/>
|
||||
{node.children.map((childId) => (
|
||||
<NodeRenderer key={childId} nodeId={childId} />
|
||||
))}
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap-export of the legacy `CeilingRenderer`.
|
||||
*
|
||||
* Ceiling's renderer uses TSL shader code for the grid-line pattern
|
||||
* (~100 lines incl. material setup) — too much to duplicate at Stage A.
|
||||
* The legacy file stays in viewer; the registry imports it through the
|
||||
* public export. Phase 5 Stage B/F (per-kind migration stages, see
|
||||
* plans/editor-node-registry.md) moves the renderer body into this
|
||||
* folder and deletes the legacy file.
|
||||
*/
|
||||
export default CeilingRenderer
|
||||
|
||||
@@ -3,13 +3,10 @@
|
||||
import { CeilingSystem } from '@pascal-app/viewer'
|
||||
|
||||
/**
|
||||
* Registry-driven ceiling system bundle. Re-exports the legacy
|
||||
* `CeilingSystem` so it mounts via `RegisteredSystems` when ceiling is
|
||||
* registry-driven. `<LegacySystem kind="ceiling">` in viewer/components/
|
||||
* viewer/index.tsx short-circuits whenever `nodeRegistry.has('ceiling')`
|
||||
* is true — same shape wall / fence / slab use.
|
||||
* Registry-driven ceiling system bundle. Wraps `CeilingSystem` so it
|
||||
* mounts via `RegisteredSystems`.
|
||||
*
|
||||
* Future Phase 5+: extract polygon triangulation + hole CSG into a pure
|
||||
* Future: extract polygon triangulation + hole CSG into a pure
|
||||
* `buildCeilingGeometry(node)` and migrate to `def.geometry`.
|
||||
*/
|
||||
const CeilingSystems = () => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ColumnNode as ColumnNodeSchema, type NodeDefinition } from '@pascal-app/core'
|
||||
import { buildColumnFloorplan } from './floorplan'
|
||||
import { columnParametrics } from './parametrics'
|
||||
import { ColumnNode } from './schema'
|
||||
|
||||
/**
|
||||
* Column — Stage A registration. Wrap-export of the legacy
|
||||
* `ColumnRenderer` (no system — column geometry is computed inline in
|
||||
* the renderer). Inspector / move / floorplan still go through legacy
|
||||
* paths via panel-manager.tsx / item-move-tool.tsx / floorplan-panel.tsx
|
||||
* (their hardcoded `case 'column':` entries fire before the registry
|
||||
* fallback).
|
||||
*
|
||||
* Capabilities: column doesn't declare `movable` because its move is
|
||||
* bespoke (legacy MoveColumnTool snaps to slab + free placement on
|
||||
* the X/Z plane with rotation).
|
||||
*
|
||||
* Defaults computed via stub-parse so we leverage every zod
|
||||
* `.default()` annotation on the schema (~60 fields).
|
||||
*/
|
||||
export const columnDefinition: NodeDefinition<typeof ColumnNode> = {
|
||||
kind: 'column',
|
||||
schemaVersion: 1,
|
||||
schema: ColumnNode,
|
||||
category: 'structure',
|
||||
|
||||
defaults: () => {
|
||||
const stub = ColumnNodeSchema.parse({ id: 'column_default' as never, type: 'column' })
|
||||
const { id: _id, type: _type, ...rest } = stub
|
||||
return rest
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
},
|
||||
|
||||
parametrics: columnParametrics,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
// Stage D — 3D move-tool (registry-driven). Replaces the legacy
|
||||
// `MoveColumnTool` in editor's dispatcher. Same 0.5m grid snap +
|
||||
// live-transform preview the legacy used.
|
||||
affordanceTools: {
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
floorplan: buildColumnFloorplan,
|
||||
|
||||
presentation: {
|
||||
label: 'Column',
|
||||
description: 'A parametric column with configurable cross-section, base, and capital.',
|
||||
icon: { kind: 'url', src: '/icons/column.png' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 70,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description: 'A parametric column placed on a slab or level.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type {
|
||||
ColumnNode,
|
||||
FloorplanGeometry,
|
||||
FloorplanPoint,
|
||||
GeometryContext,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Stage C floor-plan builder for column. Inlined from the legacy
|
||||
* `getColumnPlanFootprint` helper in `floorplan-panel.tsx`. The
|
||||
* footprint shape depends on `crossSection` (square / rectangular /
|
||||
* round / octagonal / sixteen-sided) and `supportStyle` (vertical /
|
||||
* a-frame / x-brace / etc.) — brace supports use a rotated rectangle
|
||||
* spanning the base spread; standalone columns use the shaft profile.
|
||||
*
|
||||
* When selected, switches to a themed accent stroke and emits a move
|
||||
* handle at the column center. No dimension overlay (columns don't
|
||||
* have a natural "length" axis like a wall).
|
||||
*/
|
||||
export function buildColumnFloorplan(
|
||||
node: ColumnNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const polygon = getColumnPlanFootprint(node)
|
||||
if (polygon.length < 3) return null
|
||||
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
const isSelected = view?.selected ?? false
|
||||
const isHighlighted = view?.highlighted ?? false
|
||||
const showSelectedChrome = isSelected || isHighlighted
|
||||
|
||||
const stroke = showSelectedChrome && palette ? palette.selectedStroke : '#374151'
|
||||
const fill = showSelectedChrome ? '#fed7aa' : '#9ca3af'
|
||||
|
||||
const points: FloorplanPoint[] = polygon.map((p) => [p.x, p.y] as FloorplanPoint)
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'polygon',
|
||||
points,
|
||||
fill,
|
||||
stroke,
|
||||
strokeWidth: showSelectedChrome ? 0.03 : 0.02,
|
||||
opacity: 0.92,
|
||||
},
|
||||
]
|
||||
|
||||
// Hatch overlay on selected — same `<defs>` pattern as the wall.
|
||||
if (isSelected && palette) {
|
||||
children.push({
|
||||
kind: 'hatch',
|
||||
points,
|
||||
color: palette.selectedHatch,
|
||||
opacity: 0.7,
|
||||
})
|
||||
}
|
||||
|
||||
// Move handle at the column center when selected.
|
||||
if (isSelected) {
|
||||
children.push({
|
||||
kind: 'move-handle',
|
||||
point: [node.position[0], node.position[2]],
|
||||
})
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
// ── Inlined helpers from legacy floorplan-panel.tsx ───────────────────
|
||||
|
||||
type PlanPoint = { x: number; y: number }
|
||||
|
||||
function rotatePlanVector(x: number, y: number, rotation: number): [number, number] {
|
||||
const c = Math.cos(rotation)
|
||||
const s = Math.sin(rotation)
|
||||
return [x * c - y * s, x * s + y * c]
|
||||
}
|
||||
|
||||
function getRotatedRectanglePolygon(
|
||||
center: PlanPoint,
|
||||
width: number,
|
||||
depth: number,
|
||||
rotation: number,
|
||||
): PlanPoint[] {
|
||||
const halfW = width / 2
|
||||
const halfD = depth / 2
|
||||
const corners: Array<[number, number]> = [
|
||||
[-halfW, -halfD],
|
||||
[halfW, -halfD],
|
||||
[halfW, halfD],
|
||||
[-halfW, halfD],
|
||||
]
|
||||
return corners.map(([x, y]) => {
|
||||
const [rx, ry] = rotatePlanVector(x, y, rotation)
|
||||
return { x: center.x + rx, y: center.y + ry }
|
||||
})
|
||||
}
|
||||
|
||||
function getColumnPlanFootprint(column: ColumnNode): PlanPoint[] {
|
||||
const center: PlanPoint = { x: column.position[0], y: column.position[2] }
|
||||
|
||||
// Brace-support columns: rotated rectangle spanning the base spread.
|
||||
if (
|
||||
column.supportStyle === 'a-frame' ||
|
||||
column.supportStyle === 'y-frame' ||
|
||||
column.supportStyle === 'v-frame' ||
|
||||
column.supportStyle === 'x-brace' ||
|
||||
column.supportStyle === 'k-brace' ||
|
||||
column.supportStyle === 'single-strut' ||
|
||||
column.supportStyle === 'tripod' ||
|
||||
column.supportStyle === 'trestle' ||
|
||||
column.supportStyle === 'portal-frame' ||
|
||||
column.supportStyle === 'box-frame'
|
||||
) {
|
||||
const width = Math.max(
|
||||
column.supportStyle === 'a-frame' ||
|
||||
column.supportStyle === 'x-brace' ||
|
||||
column.supportStyle === 'k-brace' ||
|
||||
column.supportStyle === 'single-strut' ||
|
||||
column.supportStyle === 'tripod' ||
|
||||
column.supportStyle === 'trestle' ||
|
||||
column.supportStyle === 'portal-frame' ||
|
||||
column.supportStyle === 'box-frame'
|
||||
? (column.braceBottomSpread ?? 1.2)
|
||||
: 0,
|
||||
column.braceTopSpread ??
|
||||
(column.supportStyle === 'y-frame' ||
|
||||
column.supportStyle === 'v-frame' ||
|
||||
column.supportStyle === 'x-brace' ||
|
||||
column.supportStyle === 'k-brace' ||
|
||||
column.supportStyle === 'single-strut' ||
|
||||
column.supportStyle === 'tripod' ||
|
||||
column.supportStyle === 'trestle' ||
|
||||
column.supportStyle === 'portal-frame' ||
|
||||
column.supportStyle === 'box-frame'
|
||||
? 1
|
||||
: 0),
|
||||
(column.braceWidth ?? column.width) * 2,
|
||||
)
|
||||
const depth = Math.max(
|
||||
column.supportStyle === 'tripod' ||
|
||||
column.supportStyle === 'trestle' ||
|
||||
column.supportStyle === 'box-frame'
|
||||
? (column.braceTopSpread ?? 1)
|
||||
: 0,
|
||||
column.braceDepth ?? column.depth,
|
||||
0.08,
|
||||
)
|
||||
return getRotatedRectanglePolygon(center, width, depth, column.rotation)
|
||||
}
|
||||
|
||||
// Standalone column: shaft profile expanded for base + capital.
|
||||
const isRound =
|
||||
column.crossSection === 'round' ||
|
||||
column.crossSection === 'octagonal' ||
|
||||
column.crossSection === 'sixteen-sided'
|
||||
const shaftWidth = isRound ? column.radius * 2 : column.width
|
||||
const shaftDepth = isRound ? column.radius * 2 : column.depth
|
||||
const width = Math.max(
|
||||
shaftWidth,
|
||||
column.width * column.baseWidthScale,
|
||||
column.width * column.capitalWidthScale,
|
||||
)
|
||||
const depth = Math.max(
|
||||
shaftDepth,
|
||||
column.depth * column.baseDepthScale,
|
||||
column.depth * column.capitalDepthScale,
|
||||
)
|
||||
|
||||
if (column.crossSection === 'square' || column.crossSection === 'rectangular') {
|
||||
return getRotatedRectanglePolygon(center, width, depth, column.rotation)
|
||||
}
|
||||
|
||||
const segmentCount =
|
||||
column.crossSection === 'octagonal' ? 8 : column.crossSection === 'sixteen-sided' ? 16 : 32
|
||||
|
||||
return Array.from({ length: segmentCount }, (_, index) => {
|
||||
const angle = (index / segmentCount) * Math.PI * 2
|
||||
const localX = Math.cos(angle) * (width / 2)
|
||||
const localY = Math.sin(angle) * (depth / 2)
|
||||
const [offsetX, offsetY] = rotatePlanVector(localX, localY, column.rotation)
|
||||
return { x: center.x + offsetX, y: center.y + offsetY }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { columnDefinition } from './definition'
|
||||
+25
-11
@@ -1,24 +1,36 @@
|
||||
import '../../../three-types'
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
ColumnNode,
|
||||
type ColumnNode as ColumnNodeType,
|
||||
type ColumnNode,
|
||||
ColumnNode as ColumnNodeSchema,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { CursorSphere, markToolCancelConsumed, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
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'
|
||||
|
||||
/**
|
||||
* Phase 5 Stage D — column's registry-driven 3D move affordance.
|
||||
*
|
||||
* Replaces the legacy `MoveColumnTool` in `editor/src/components/tools/
|
||||
* column/move-column-tool.tsx`. Behaviour is identical: grid:move
|
||||
* snaps the cursor to a 0.5m grid and previews the column at that
|
||||
* position via `useLiveTransforms` + a direct `sceneRegistry.nodes.get
|
||||
* (id).position.set(...)` (the live-drag exception documented in
|
||||
* `wiki/architecture/tools.md`); grid:click commits via `useScene.
|
||||
* updateNode`. Cancel restores the pre-drag position.
|
||||
*
|
||||
* Wired via `def.affordanceTools.move`. The editor's `MoveTool`
|
||||
* dispatcher's `getRegistryAffordanceTool('column', 'move')` lookup
|
||||
* picks this up before its legacy chain reaches `<MoveColumnTool>`.
|
||||
*/
|
||||
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
||||
|
||||
export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
|
||||
function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
@@ -48,7 +60,7 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
|
||||
0,
|
||||
roundToHalf(event.localPosition[2]),
|
||||
]
|
||||
const nodeId = (node as { id?: ColumnNodeType['id'] }).id
|
||||
const nodeId = (node as { id?: ColumnNode['id'] }).id
|
||||
|
||||
if (nodeId && useScene.getState().nodes[nodeId]) {
|
||||
committed = true
|
||||
@@ -56,7 +68,7 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(nodeId, { position })
|
||||
} else if (node.parentId) {
|
||||
const column = ColumnNode.parse({
|
||||
const column = ColumnNodeSchema.parse({
|
||||
...node,
|
||||
id: undefined,
|
||||
metadata: {},
|
||||
@@ -68,7 +80,7 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
|
||||
}
|
||||
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
triggerSFX('sfx:item-place')
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
@@ -103,3 +115,5 @@ export function MoveColumnTool({ node }: { node: ColumnNodeType }) {
|
||||
|
||||
return <CursorSphere color="#a78bfa" height={node.height} position={previewPosition} />
|
||||
}
|
||||
|
||||
export default MoveColumnTool
|
||||
+253
-256
@@ -7,17 +7,20 @@ import {
|
||||
type ColumnPresetId,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
ActionButton,
|
||||
ActionGroup,
|
||||
cn,
|
||||
PanelSection,
|
||||
PanelWrapper,
|
||||
SliderControl,
|
||||
ToggleControl,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
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'
|
||||
@@ -178,7 +181,7 @@ function shaftProfileUpdates(shaftProfile: ColumnNode['shaftProfile']): Partial<
|
||||
}
|
||||
}
|
||||
|
||||
export function ColumnPanel() {
|
||||
export default function ColumnPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
@@ -204,14 +207,14 @@ export function ColumnPanel() {
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!selectedId) return
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
triggerSFX('sfx:structure-delete')
|
||||
deleteNode(selectedId as AnyNode['id'])
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [deleteNode, selectedId, setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
triggerSFX('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
@@ -488,9 +491,7 @@ export function ColumnPanel() {
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) =>
|
||||
handleUpdate(
|
||||
shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']),
|
||||
)
|
||||
handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))
|
||||
}
|
||||
value={shaftProfile}
|
||||
>
|
||||
@@ -547,9 +548,7 @@ export function ColumnPanel() {
|
||||
label="End Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ shaftStartScale: value, shaftEndScale: value })
|
||||
}
|
||||
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.68}
|
||||
@@ -571,9 +570,7 @@ export function ColumnPanel() {
|
||||
label="End Width"
|
||||
max={1.2}
|
||||
min={0.3}
|
||||
onChange={(value) =>
|
||||
handleUpdate({ shaftStartScale: value, shaftEndScale: value })
|
||||
}
|
||||
onChange={(value) => handleUpdate({ shaftStartScale: value, shaftEndScale: value })}
|
||||
precision={2}
|
||||
step={0.02}
|
||||
value={node.shaftStartScale ?? 0.84}
|
||||
@@ -661,246 +658,246 @@ export function ColumnPanel() {
|
||||
|
||||
{!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) =>
|
||||
<select
|
||||
className={SELECT_CLASS}
|
||||
onChange={(event) => {
|
||||
const capitalStyle = event.target.value as ColumnNode['capitalStyle']
|
||||
handleUpdate({
|
||||
capitalWidthScale: value,
|
||||
...(node.crossSection === 'rectangular' ? {} : { capitalDepthScale: value }),
|
||||
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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
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) =>
|
||||
}}
|
||||
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({
|
||||
baseWidthScale: value,
|
||||
...(node.crossSection === 'rectangular' ? {} : { baseDepthScale: value }),
|
||||
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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { ColumnNode } from './schema'
|
||||
|
||||
/**
|
||||
* Stage A inspector — minimal. Column has 60+ schema fields (cross-
|
||||
* section, shaft profile, capital style, base style, carvings, ring
|
||||
* placement, etc.); the legacy `<ColumnPanel>` renders these via
|
||||
* panel-manager's hardcoded switch. The descriptor below registers
|
||||
* the kind as "has parametric data" without trying to express the
|
||||
* full legacy panel — Stage E will replace it via `customPanel`.
|
||||
*/
|
||||
export const columnParametrics: ParametricDescriptor<ColumnNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Dimensions',
|
||||
fields: [
|
||||
{ key: 'height', kind: 'number', unit: 'm', min: 0.5, max: 6, step: 0.05 },
|
||||
{ key: 'width', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.01 },
|
||||
{ key: 'depth', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.01 },
|
||||
],
|
||||
},
|
||||
],
|
||||
customPanel: () => import('./panel'),
|
||||
}
|
||||
+11
-5
@@ -1,14 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { type ColumnNode, useLiveTransforms, useRegistry } from '@pascal-app/core'
|
||||
import { createContext, useContext, useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { baseMaterial, createMaterial, createMaterialFromPresetRef } from '../../../lib/materials'
|
||||
import {
|
||||
baseMaterial,
|
||||
createColumnBoxGeometry,
|
||||
createColumnCylinderGeometry,
|
||||
createColumnSphereGeometry,
|
||||
createColumnTorusGeometry,
|
||||
} from '../../../systems/column/column-geometry'
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
useNodeEvents,
|
||||
} from '@pascal-app/viewer'
|
||||
import { createContext, useContext, useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, type Material } from 'three'
|
||||
|
||||
const ColumnMaterialContext = createContext<Material>(baseMaterial as Material)
|
||||
const ColumnEdgeSoftnessContext = createContext(0.025)
|
||||
@@ -2165,3 +2169,5 @@ export const ColumnRenderer = ({ node }: { node: ColumnNode }) => {
|
||||
</ColumnMaterialContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ColumnRenderer
|
||||
@@ -0,0 +1 @@
|
||||
export { ColumnNode } from '@pascal-app/core'
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { buildDoorFloorplan } from './floorplan'
|
||||
import { doorFloorplanMoveTarget } from './floorplan-move'
|
||||
import { doorParametrics } from './parametrics'
|
||||
import { DoorNode } from './schema'
|
||||
|
||||
@@ -58,6 +59,23 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
// Stage C: floor-plan polygon. Needs ctx.parent (the wall) to compute
|
||||
// direction + perpendicular for the cutout footprint.
|
||||
floorplan: buildDoorFloorplan,
|
||||
// Stage D — placement (`def.tool`) + move-on-wall (`def.
|
||||
// affordanceTools.move`). Both ports of the legacy tools at
|
||||
// `editor/components/tools/door/`, relocated into the kind folder and
|
||||
// wired through ToolManager's registry-first dispatch (`def.tool` for
|
||||
// build-mode placement, `getRegistryAffordanceTool` for the move-on-
|
||||
// pick flow). Same legacy semantics: wall-event-driven snap, clamped
|
||||
// wall-local coords, hasWallChildOverlap guard, live mesh updates.
|
||||
tool: () => import('./tool'),
|
||||
affordanceTools: {
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
// 2D move-on-floorplan handler. When `useEditor.movingNode` is a
|
||||
// door and the floor plan is active, `FloorplanRegistryMoveOverlay`
|
||||
// dispatches to this instead of the generic translate path — pointer
|
||||
// snaps to the nearest wall, projects onto the wall axis, snaps
|
||||
// local-X to 0.5m, clamps inside wall bounds.
|
||||
floorplanMoveTarget: doorFloorplanMoveTarget,
|
||||
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place door on wall' },
|
||||
@@ -67,7 +85,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
presentation: {
|
||||
label: 'Door',
|
||||
description: 'A door cut into a wall. Animated open/close state.',
|
||||
icon: { kind: 'iconify', name: 'lucide:door-open' },
|
||||
icon: { kind: 'url', src: '/icons/door.png' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 50,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { snapToHalf } from '@pascal-app/editor'
|
||||
import { findClosestWallInPlan } from '../shared/wall-attach-target'
|
||||
import { clampToWall, hasWallChildOverlap } from './door-math'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for door — kicks in when the user clicks
|
||||
* "Move" on the door inspector (or action menu) and the floor-plan
|
||||
* view is active. Pointer in plan space → snap to nearest wall →
|
||||
* project onto wall axis → snap local-X to 0.5m grid → clamp inside
|
||||
* wall bounds → commit via `useScene.updateNodes`.
|
||||
*
|
||||
* Mirrors the 3D `move-tool.tsx` behaviour minus the R3F event plumbing:
|
||||
* - Re-parents on transition between walls (parentId + wallId).
|
||||
* - Adapts `side` + `rotation` from the wall normal under the pointer.
|
||||
* - hasWallChildOverlap blocks committing overlapping placements.
|
||||
*
|
||||
* Curved walls are skipped by `findClosestWallInPlan` — same guardrail
|
||||
* as the 3D port and the legacy `DoorTool` / `MoveDoorTool`.
|
||||
*/
|
||||
|
||||
export const doorFloorplanMoveTarget: FloorplanMoveTarget<DoorNode> = ({ node }) => {
|
||||
// Snapshot of the door's "valid" state at move-start — used by
|
||||
// canCommit to decide whether the current snapped position is OK.
|
||||
const startLevelId = (() => {
|
||||
// Walk up via parentId until we hit a node whose type isn't 'wall'
|
||||
// — that's the level (or null). The door is wall-hosted, so the
|
||||
// wall's parent is the level. Cached at start because the parent
|
||||
// chain doesn't change during a move.
|
||||
const wall = useScene.getState().nodes[node.parentId as AnyNodeId]
|
||||
return wall ? (wall.parentId as AnyNodeId | null) : null
|
||||
})()
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const nodes = useScene.getState().nodes
|
||||
const hit = findClosestWallInPlan(planPoint, nodes, startLevelId)
|
||||
if (!hit) return // pointer off any wall — keep door at last valid position
|
||||
|
||||
// Snap the wall-local X to 0.5m grid (Shift bypasses).
|
||||
const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)
|
||||
const { clampedX, clampedY } = clampToWall(hit.wall, snappedLocalX, node.width, node.height)
|
||||
|
||||
// Build the updates atomically — position + rotation + side +
|
||||
// parentId + wallId in a single scene write. The current door's
|
||||
// parent might be a different wall; re-anchoring requires moving
|
||||
// the node in the parent's children list (the registry's
|
||||
// updateNode does this when parentId changes).
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: node.id as AnyNodeId,
|
||||
data: {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, hit.itemRotation, 0],
|
||||
side: hit.side,
|
||||
parentId: hit.wall.id,
|
||||
wallId: hit.wall.id,
|
||||
},
|
||||
},
|
||||
])
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[node.id as AnyNodeId] as DoorNode | undefined
|
||||
if (!live || live.type !== 'door') return false
|
||||
// Block commit if the door overlaps any other wall child at its
|
||||
// current position. The 3D port has the same guard.
|
||||
const overlapping = hasWallChildOverlap(
|
||||
live.parentId as string,
|
||||
live.position[0],
|
||||
live.position[1],
|
||||
live.width,
|
||||
live.height,
|
||||
live.id,
|
||||
)
|
||||
return !overlapping
|
||||
},
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
@@ -5,18 +5,31 @@ import type {
|
||||
GeometryContext,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions'
|
||||
|
||||
/**
|
||||
* Stage C floor-plan builder for door. Doors render as a small polygon
|
||||
* sitting in the wall's cutout — width = door.width along the wall
|
||||
* direction, depth = wall.thickness perpendicular.
|
||||
* Stage C floor-plan builder for door. 1:1 visual port of the legacy
|
||||
* floorplan-panel door rendering:
|
||||
*
|
||||
* 1. The door footprint rectangle in the wall cutout (themed
|
||||
* accent stroke when selected).
|
||||
* 2. The door swing arc — a quarter-circle from the hinge to the
|
||||
* door's open position, modulated by `swingAngle`, `hingesSide`,
|
||||
* and `swingDirection`. Renders as a wedge of low-opacity fill so
|
||||
* the swept area reads at a glance.
|
||||
* 3. The door leaf — a thick line from the hinge to the open
|
||||
* position, terminating at the arc end.
|
||||
* 4. Center line through the cutout (matches the legacy's
|
||||
* `getOpeningCenterLine` segment for visual continuity).
|
||||
*
|
||||
* Requires `ctx.parent` to be a wall (door.parentId is the wall it's
|
||||
* mounted on). Returns null when the parent isn't a wall (orphaned
|
||||
* doors during placement etc.).
|
||||
*
|
||||
* Inlined from the legacy `getOpeningFootprint` helper in
|
||||
* floorplan-panel.tsx. Window's builder is structurally identical.
|
||||
* Skipped vs the full legacy for now: hinge / strike cubes (small
|
||||
* indicator squares at the rotation pivots), rounded-opening shape
|
||||
* variants, panic bar markers. Those are rare visual variations the
|
||||
* follow-up port can revisit.
|
||||
*/
|
||||
export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): FloorplanGeometry | null {
|
||||
const wall = ctx.parent as WallNode | null
|
||||
@@ -31,10 +44,11 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
|
||||
const dirX = dx / length
|
||||
const dirZ = dz / length
|
||||
// Perpendicular unit normal (rotate 90° CCW).
|
||||
const perpX = -dirZ
|
||||
const perpZ = dirX
|
||||
|
||||
const distance = node.position[0] // door's local X = distance along wall
|
||||
const distance = node.position[0]
|
||||
const width = node.width
|
||||
const depth = wall.thickness ?? 0.1
|
||||
const cx = x1 + dirX * distance
|
||||
@@ -42,6 +56,18 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
const halfWidth = width / 2
|
||||
const halfDepth = depth / 2
|
||||
|
||||
const isPlanFlipped = isOpeningPlanFlipped(node.rotation)
|
||||
const baseHingesSide = node.hingesSide ?? 'left'
|
||||
const baseSwingDirection = node.swingDirection ?? 'inward'
|
||||
const hingesSide = isPlanFlipped ? (baseHingesSide === 'left' ? 'right' : 'left') : baseHingesSide
|
||||
const swingDirection = isPlanFlipped
|
||||
? baseSwingDirection === 'inward'
|
||||
? 'outward'
|
||||
: 'inward'
|
||||
: baseSwingDirection
|
||||
const swingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0))
|
||||
|
||||
// Footprint rectangle in the cutout.
|
||||
const points: readonly FloorplanPoint[] = [
|
||||
[cx - dirX * halfWidth + perpX * halfDepth, cz - dirZ * halfWidth + perpZ * halfDepth],
|
||||
[cx + dirX * halfWidth + perpX * halfDepth, cz + dirZ * halfWidth + perpZ * halfDepth],
|
||||
@@ -49,12 +75,138 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
|
||||
[cx - dirX * halfWidth - perpX * halfDepth, cz - dirZ * halfWidth - perpZ * halfDepth],
|
||||
]
|
||||
|
||||
return {
|
||||
kind: 'polygon',
|
||||
points,
|
||||
fill: '#f8fafc',
|
||||
stroke: '#374151',
|
||||
strokeWidth: 0.015,
|
||||
opacity: 0.95,
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
const isSelected = view?.selected ?? false
|
||||
const isHighlighted = view?.highlighted ?? false
|
||||
const showSelectedChrome = isSelected || isHighlighted
|
||||
|
||||
// Match the legacy floor-plan door render: unselected is a quiet
|
||||
// grey accent so the door reads as a hole in the wall, selected is
|
||||
// a full orange treatment (body + outline) so the user can see at
|
||||
// a glance which door is targeted by the inspector / move handle.
|
||||
const accentColor = showSelectedChrome ? '#f97316' : 'rgba(100, 116, 139, 0.82)'
|
||||
const accentMuted = accentColor
|
||||
const fillColor = showSelectedChrome ? '#fed7aa' : '#ffffff'
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
// Background — the cutout is filled white so the swing arc sits on
|
||||
// a clean canvas (the wall hatch shows through otherwise).
|
||||
{
|
||||
kind: 'polygon',
|
||||
points,
|
||||
fill: fillColor,
|
||||
stroke: accentMuted,
|
||||
strokeWidth: showSelectedChrome ? 2 : 1.25,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinejoin: 'round',
|
||||
},
|
||||
]
|
||||
|
||||
// Swing geometry. The hinge sits at one end of the door along the
|
||||
// wall direction; the strike sits at the opposite end. The leaf
|
||||
// rotates around the hinge by `swingAngle` toward the inward /
|
||||
// outward side of the wall.
|
||||
const hingeTangentSign = hingesSide === 'left' ? 1 : -1
|
||||
const swingSign = swingDirection === 'inward' ? 1 : -1
|
||||
const hingeX = cx - dirX * halfWidth * hingeTangentSign
|
||||
const hingeZ = cz - dirZ * halfWidth * hingeTangentSign
|
||||
// Closed leaf vector points from hinge to strike (along the wall).
|
||||
const closedLeafX = dirX * width * hingeTangentSign
|
||||
const closedLeafZ = dirZ * width * hingeTangentSign
|
||||
|
||||
if (swingAngle > 1e-3 && width > 1e-3) {
|
||||
// Rotate the closed leaf vector by `swingAngle * swingSign *
|
||||
// hingeTangentSign` around the hinge to get the open leaf tip.
|
||||
const angle = swingAngle * swingSign * hingeTangentSign
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
const openLeafX = closedLeafX * cos - closedLeafZ * sin
|
||||
const openLeafZ = closedLeafX * sin + closedLeafZ * cos
|
||||
const tipX = hingeX + openLeafX
|
||||
const tipZ = hingeZ + openLeafZ
|
||||
|
||||
// Closed leaf tip — where the leaf would land if fully closed.
|
||||
const closedTipX = hingeX + closedLeafX
|
||||
const closedTipZ = hingeZ + closedLeafZ
|
||||
|
||||
// Swing arc — a path from closed tip to open tip via an arc
|
||||
// centered at the hinge. SVG's A command takes rx ry rotation
|
||||
// large-arc-flag sweep-flag x y. Sweep flag flips based on the
|
||||
// signed angle direction.
|
||||
const sweepFlag = angle >= 0 ? 1 : 0
|
||||
const arcPath = `M ${closedTipX} ${closedTipZ} A ${width} ${width} 0 0 ${sweepFlag} ${tipX} ${tipZ}`
|
||||
|
||||
// Swept wedge fill (light, low opacity) — gives the door a
|
||||
// visible "this is the open zone" treatment.
|
||||
children.push({
|
||||
kind: 'path',
|
||||
d: `M ${hingeX} ${hingeZ} L ${closedTipX} ${closedTipZ} ${arcPath
|
||||
.replace(/^M [^A]+/, '')
|
||||
.trim()} Z`,
|
||||
fill: accentColor,
|
||||
fillOpacity: showSelectedChrome ? 0.08 : 0.05,
|
||||
stroke: 'none',
|
||||
})
|
||||
|
||||
// The arc itself, stroked.
|
||||
children.push({
|
||||
kind: 'path',
|
||||
d: arcPath,
|
||||
fill: 'none',
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 1.6 : 1.1,
|
||||
strokeOpacity: 0.85,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinecap: 'round',
|
||||
})
|
||||
|
||||
// The door leaf — line from hinge to the open tip.
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: hingeX,
|
||||
y1: hingeZ,
|
||||
x2: tipX,
|
||||
y2: tipZ,
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 2.4 : 1.7,
|
||||
strokeLinecap: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
}
|
||||
|
||||
// Move handle — orange dot at the door center. Only visible when
|
||||
// selected. Pointer-down on this triggers `setMovingNode(door)`
|
||||
// → `FloorplanRegistryMoveOverlay` → `def.floorplanMoveTarget`.
|
||||
if (isSelected) {
|
||||
children.push({
|
||||
kind: 'move-handle',
|
||||
point: [cx, cz],
|
||||
})
|
||||
}
|
||||
|
||||
// Placement-measurement dimensions — distances to adjacent openings
|
||||
// (or wall ends) on each side. Only visible while actively moving
|
||||
// (the user clicked Move or grabbed the orange dot).
|
||||
if (view?.moving) {
|
||||
for (const dim of buildOpeningPlacementDimensions(node, ctx)) {
|
||||
children.push(dim)
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
/**
|
||||
* The opening's wall-normal orientation is encoded in the door's Y
|
||||
* rotation. When the door faces "inward" along an angle in [π/2, 3π/2],
|
||||
* the rendering needs the hinge side + swing direction flipped to
|
||||
* keep the visual swing on the correct side of the wall.
|
||||
*
|
||||
* Mirrors `isOpeningPlanFlipped` in `floorplan-panel.tsx`.
|
||||
*/
|
||||
function isOpeningPlanFlipped(rotation: readonly [number, number, number]): boolean {
|
||||
const normalized =
|
||||
((((rotation[1] % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)) + 1e-6) % (Math.PI * 2)
|
||||
return normalized > Math.PI / 2 && normalized < (Math.PI * 3) / 2
|
||||
}
|
||||
|
||||
+14
-12
@@ -9,20 +9,20 @@ import {
|
||||
useScene,
|
||||
type WallEvent,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
EDITOR_LAYER,
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
snapToHalf,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
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({
|
||||
@@ -32,7 +32,7 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
|
||||
const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => {
|
||||
const cursorGroupRef = useRef<Group>(null!)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
@@ -310,7 +310,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
triggerSFX('sfx:item-place')
|
||||
hideCursor()
|
||||
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
||||
exitMoveMode()
|
||||
@@ -410,3 +410,5 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default MoveDoorTool
|
||||
+88
-84
@@ -8,21 +8,23 @@ import {
|
||||
useInteractive,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
ActionButton,
|
||||
ActionGroup,
|
||||
cn,
|
||||
PanelSection,
|
||||
PanelWrapper,
|
||||
PresetsPopover,
|
||||
SegmentedControl,
|
||||
SliderControl,
|
||||
ToggleControl,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
usePresetsAdapter,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { BookMarked, Copy, DoorOpen, 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 { 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'
|
||||
import { PresetsPopover } from './presets/presets-popover'
|
||||
|
||||
const doorTypeOptions = [
|
||||
{ label: 'Hinged', value: 'hinged', available: true },
|
||||
@@ -106,7 +108,7 @@ function isSameDoorValue(current: unknown, next: unknown): boolean {
|
||||
return Object.is(current, next)
|
||||
}
|
||||
|
||||
export function DoorPanel() {
|
||||
export default function DoorPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
@@ -189,7 +191,9 @@ export function DoorPanel() {
|
||||
}
|
||||
previewRef.current = null
|
||||
|
||||
useScene.getState().updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<DoorNode>)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<DoorNode>)
|
||||
scene.dirtyNodes.add(selectedId as AnyNodeId)
|
||||
},
|
||||
[selectedId],
|
||||
@@ -209,14 +213,14 @@ export function DoorPanel() {
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
triggerSFX('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
triggerSFX('sfx:item-delete')
|
||||
deleteNode(selectedId as AnyNode['id'])
|
||||
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
setSelection({ selectedIds: [] })
|
||||
@@ -224,7 +228,7 @@ export function DoorPanel() {
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node?.parentId) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
triggerSFX('sfx:item-pick')
|
||||
useScene.temporal.getState().pause()
|
||||
const cloned = structuredClone(node) as any
|
||||
delete cloned.id
|
||||
@@ -985,75 +989,75 @@ export function DoorPanel() {
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{!isGarageDoor && (
|
||||
<PanelSection title="Content Padding">
|
||||
<SliderControl
|
||||
label="Horizontal"
|
||||
max={0.2}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(node.contentPadding[0] * 1000) / 1000}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Vertical"
|
||||
max={0.2}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(node.contentPadding[1] * 1000) / 1000}
|
||||
/>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{isSwingDoor && (
|
||||
<PanelSection title="Swing">
|
||||
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||
{supportsHingeSide && (
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Hinges Side
|
||||
</span>
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ hingesSide: v })}
|
||||
options={[
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
]}
|
||||
value={node.hingesSide}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Direction
|
||||
</span>
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ swingDirection: v })}
|
||||
options={[
|
||||
{ label: 'Inward', value: 'inward' },
|
||||
{ label: 'Outward', value: 'outward' },
|
||||
]}
|
||||
value={node.swingDirection}
|
||||
{!isGarageDoor && (
|
||||
<PanelSection title="Content Padding">
|
||||
<SliderControl
|
||||
label="Horizontal"
|
||||
max={0.2}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(node.contentPadding[0] * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Vertical"
|
||||
max={0.2}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(node.contentPadding[1] * 1000) / 1000}
|
||||
/>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{isSwingDoor && (
|
||||
<PanelSection title="Threshold">
|
||||
<ToggleControl
|
||||
checked={node.threshold}
|
||||
label="Enable Threshold"
|
||||
onChange={(checked) => handleUpdate({ threshold: checked })}
|
||||
/>
|
||||
{node.threshold && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{isSwingDoor && (
|
||||
<PanelSection title="Swing">
|
||||
<div className="flex flex-col gap-2 px-1 pb-1">
|
||||
{supportsHingeSide && (
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Hinges Side
|
||||
</span>
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ hingesSide: v })}
|
||||
options={[
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
]}
|
||||
value={node.hingesSide}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Direction
|
||||
</span>
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ swingDirection: v })}
|
||||
options={[
|
||||
{ label: 'Inward', value: 'inward' },
|
||||
{ label: 'Outward', value: 'outward' },
|
||||
]}
|
||||
value={node.swingDirection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{isSwingDoor && (
|
||||
<PanelSection title="Threshold">
|
||||
<ToggleControl
|
||||
checked={node.threshold}
|
||||
label="Enable Threshold"
|
||||
onChange={(checked) => handleUpdate({ threshold: checked })}
|
||||
/>
|
||||
{node.threshold && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={0.1}
|
||||
@@ -2,15 +2,12 @@ import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { DoorNode } from './schema'
|
||||
|
||||
/**
|
||||
* Minimal inspector descriptor for door. The legacy `<DoorPanel>` has
|
||||
* 29 SliderControls covering segments, hardware, hinges, panic bar,
|
||||
* opening shape, etc. — too elaborate for the auto-inspector at Stage A.
|
||||
* Legacy panel keeps rendering via the hardcoded `case 'door':` in
|
||||
* panel-manager.tsx. This descriptor only exposes the simple dimension
|
||||
* fields so the registry knows door has parametric data. Phase 5 Stage E
|
||||
* (drop legacy panel) will extend this — likely via
|
||||
* `parametrics.customPanel?` since door has too much non-numeric UI
|
||||
* (segmented controls, presets) to fit the generic auto-UI.
|
||||
* Stage E inspector for door. Mounts the kind-owned panel
|
||||
* (`panel.tsx`) via `customPanel` — door has 29+ controls (segments,
|
||||
* hardware, hinges, panic bar, opening shape, etc.) that can't fit
|
||||
* into the generic auto-inspector. The `groups` entries stay populated
|
||||
* so the registry still considers door "parametric" (for tooling that
|
||||
* lists kinds with editable schema).
|
||||
*/
|
||||
export const doorParametrics: ParametricDescriptor<DoorNode> = {
|
||||
groups: [
|
||||
@@ -29,4 +26,5 @@ export const doorParametrics: ParametricDescriptor<DoorNode> = {
|
||||
],
|
||||
},
|
||||
],
|
||||
customPanel: () => import('./panel'),
|
||||
}
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import { DoorRenderer } from '@pascal-app/viewer'
|
||||
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import { useNodeEvents } from '@pascal-app/viewer'
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { type Mesh, MeshBasicMaterial } from 'three'
|
||||
|
||||
const doorHitboxMaterial = new MeshBasicMaterial({ visible: false })
|
||||
|
||||
export const DoorRenderer = ({ node }: { node: DoorNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
|
||||
useRegistry(node.id, 'door', ref)
|
||||
useLayoutEffect(() => {
|
||||
useScene.getState().markDirty(node.id)
|
||||
}, [node.id])
|
||||
const handlers = useNodeEvents(node, 'door')
|
||||
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
|
||||
|
||||
return (
|
||||
<mesh
|
||||
castShadow
|
||||
material={doorHitboxMaterial}
|
||||
position={node.position}
|
||||
receiveShadow
|
||||
ref={ref}
|
||||
rotation={node.rotation}
|
||||
visible={node.visible}
|
||||
{...(isTransient ? {} : handlers)}
|
||||
>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap-export of the legacy `DoorRenderer`. The renderer is 33 lines
|
||||
* (thin placeholder + register + dirty-on-mount) — could be duplicated
|
||||
* but at Stage A re-export is sufficient. Phase 5 Stage F will inline
|
||||
* it here and delete the viewer-side file.
|
||||
*/
|
||||
export default DoorRenderer
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { DoorAnimationSystem, DoorSystem } from '@pascal-app/viewer'
|
||||
|
||||
/**
|
||||
* Registry-driven door system bundle. Door has TWO per-frame systems:
|
||||
* Registry-driven door system bundle.
|
||||
*
|
||||
* - **`DoorSystem`** — rebuilds frame / leaf / glass / hardware
|
||||
* geometry from `dirtyNodes`. Cascades dirty to the parent wall so
|
||||
@@ -13,14 +13,9 @@ import { DoorAnimationSystem, DoorSystem } from '@pascal-app/viewer'
|
||||
* folding) at frame priority 2, then marks the door dirty so the
|
||||
* geometry system rebuilds at priority 3.
|
||||
*
|
||||
* Both are wrapped in `<LegacySystem kind="door">` at the legacy mount
|
||||
* point; with door registered, those wrappers short-circuit and this
|
||||
* bundle takes over.
|
||||
*
|
||||
* Future Phase 5 Stage B: extract the geometry into a pure
|
||||
* `buildDoorGeometry(node, ctx)` and migrate to `def.geometry`. The
|
||||
* animation system stays as `def.system` (it's a real per-frame
|
||||
* concern, not a geometry build).
|
||||
* Future: extract the geometry into a pure `buildDoorGeometry(node, ctx)`
|
||||
* and migrate to `def.geometry`. The animation system stays as
|
||||
* `def.system` (it's a real per-frame concern, not a geometry build).
|
||||
*/
|
||||
const DoorSystems = () => {
|
||||
return (
|
||||
|
||||
+13
-11
@@ -8,19 +8,19 @@ import {
|
||||
useScene,
|
||||
type WallEvent,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
EDITOR_LAYER,
|
||||
getSideFromNormal,
|
||||
isValidWallSideFace,
|
||||
snapToHalf,
|
||||
triggerSFX,
|
||||
} from '@pascal-app/editor'
|
||||
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({
|
||||
@@ -34,7 +34,7 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
||||
* Door tool — places DoorNodes on walls only.
|
||||
* Doors always sit at floor level (clampedY = height/2).
|
||||
*/
|
||||
export const DoorTool: React.FC = () => {
|
||||
const DoorTool: React.FC = () => {
|
||||
const draftRef = useRef<DoorNode | null>(null)
|
||||
const cursorGroupRef = useRef<Group>(null!)
|
||||
const edgesRef = useRef<LineSegments>(null!)
|
||||
@@ -273,7 +273,7 @@ export const DoorTool: React.FC = () => {
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().pause()
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
triggerSFX('sfx:item-place')
|
||||
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -322,3 +322,5 @@ export const DoorTool: React.FC = () => {
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default DoorTool
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ElevatorNode as ElevatorNodeSchema, type NodeDefinition } from '@pascal-app/core'
|
||||
import { buildElevatorFloorplan } from './floorplan'
|
||||
import { elevatorParametrics } from './parametrics'
|
||||
import { ElevatorNode } from './schema'
|
||||
|
||||
/**
|
||||
* Elevator — Stage A registration. Wrap-exports the legacy renderer +
|
||||
* the three legacy systems (runtime / interaction / opening) bundled
|
||||
* as one `def.system`. Move / inspector still go through legacy
|
||||
* (`MoveElevatorTool`, `<ElevatorPanel>`) via panel-manager's
|
||||
* hardcoded switch.
|
||||
*/
|
||||
export const elevatorDefinition: NodeDefinition<typeof ElevatorNode> = {
|
||||
kind: 'elevator',
|
||||
schemaVersion: 1,
|
||||
schema: ElevatorNode,
|
||||
category: 'structure',
|
||||
|
||||
defaults: () => {
|
||||
const stub = ElevatorNodeSchema.parse({ id: 'elevator_default' as never, type: 'elevator' })
|
||||
const { id: _id, type: _type, ...rest } = stub
|
||||
return rest
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
},
|
||||
|
||||
parametrics: elevatorParametrics,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
system: {
|
||||
module: () => import('./system'),
|
||||
priority: 3,
|
||||
},
|
||||
floorplan: buildElevatorFloorplan,
|
||||
|
||||
presentation: {
|
||||
label: 'Elevator',
|
||||
description: 'A multi-level elevator shaft with configurable openings per level.',
|
||||
icon: { kind: 'url', src: '/icons/wallcut.png' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 80,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description: 'A multi-level elevator with shaft + openings per level.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type ElevatorNode,
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
resolveElevatorServiceLevelIds,
|
||||
useInteractive,
|
||||
useLiveNodeOverrides,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Stage C floor-plan emitter for elevator. Renders:
|
||||
*
|
||||
* - **Outer shaft footprint** — rotated rectangle (cab + wall thickness).
|
||||
* - **Cab indicator** — inner rectangle showing the cab's position within
|
||||
* the shaft. Highlighted when `runtime.currentLevelId` matches the
|
||||
* active level (i.e. the car is *on this floor*).
|
||||
* - **Door opening indicator** — a short marker on the front face
|
||||
* spanning `doorWidth` so users can see which way the doors open.
|
||||
* - **Selection / target / queued chrome** — selection stroke when
|
||||
* the elevator is selected, accent stroke when the runtime targets
|
||||
* this level (cab is travelling here) or this level is queued.
|
||||
*
|
||||
* Reads the elevator's live state via `useLiveNodeOverrides.getState()`
|
||||
* (inspector edits) and `useInteractive.getState().elevators[id]`
|
||||
* (runtime cab travel). Those reads are non-reactive on their own —
|
||||
* `FloorplanRegistryLayer` subscribes to both stores so the layer
|
||||
* re-renders when they change, propagating into this builder.
|
||||
*
|
||||
* Per-level served-level chips (the small floor-label badges on each
|
||||
* shaft side) are not emitted yet — they need an HTML-overlay primitive
|
||||
* in `FloorplanGeometry` to render properly (SVG `<text>` rotates with
|
||||
* the plan, which mangles label legibility). Tracked as follow-up; the
|
||||
* legacy `<FloorplanElevatorLayer>` still renders the chips for
|
||||
* pre-registry builds while we figure out the right primitive shape.
|
||||
*/
|
||||
|
||||
const STAGE_LEVEL_FILTER_HIDE = true
|
||||
|
||||
export function buildElevatorFloorplan(
|
||||
node: ElevatorNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
// Merge in any live overrides (inspector edits not yet committed).
|
||||
const overrides = useLiveNodeOverrides.getState().get(node.id)
|
||||
const display: ElevatorNode = overrides ? ({ ...node, ...overrides } as ElevatorNode) : node
|
||||
|
||||
// Service-level gate. If the active level isn't one the elevator
|
||||
// serves, render nothing — legacy behaviour. The level id comes via
|
||||
// `ctx.parent` (the elevator's parent in the tree is the level it's
|
||||
// hosted on, which is the active level when the registry layer walks
|
||||
// from `levelId`).
|
||||
const parentLevelId = ctx.parent?.id
|
||||
if (STAGE_LEVEL_FILTER_HIDE && parentLevelId) {
|
||||
const sceneNodes = collectAllNodes(ctx)
|
||||
const serviceLevelIds = resolveElevatorServiceLevelIds(display, sceneNodes)
|
||||
if (!serviceLevelIds.includes(parentLevelId as AnyNodeId)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const wallThickness = Math.max(display.shaftWallThickness ?? 0.09, 0.04)
|
||||
const cabWidth = Math.max(display.width, 0.8)
|
||||
const cabDepth = Math.max(display.depth, 0.8)
|
||||
const shaftWidth = Math.max(display.shaftWidth ?? display.width, cabWidth, 0.8)
|
||||
const shaftDepth = Math.max(display.shaftDepth ?? display.depth, cabDepth, 0.8)
|
||||
const doorWidth = Math.min(Math.max(display.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 center = { x: display.position[0], y: display.position[2] }
|
||||
const cos = Math.cos(display.rotation)
|
||||
const sin = Math.sin(display.rotation)
|
||||
const rotate = (lx: number, ly: number): [number, number] => {
|
||||
// Same clockwise convention as `rotatePlanVector` in editor — see
|
||||
// `wiki/architecture/tools.md` for why every plan-space rotation
|
||||
// uses this matrix and not the standard counter-clockwise one.
|
||||
return [lx * cos + ly * sin, -lx * sin + ly * cos]
|
||||
}
|
||||
|
||||
// Outer shaft footprint corners.
|
||||
const outerCorners: Array<readonly [number, number]> = [
|
||||
[-halfWidth, -halfDepth],
|
||||
[halfWidth, -halfDepth],
|
||||
[halfWidth, halfDepth],
|
||||
[-halfWidth, halfDepth],
|
||||
]
|
||||
const outerPoints: FloorplanPoint[] = outerCorners.map(([lx, ly]) => {
|
||||
const [rx, ry] = rotate(lx, ly)
|
||||
return [center.x + rx, center.y + ry]
|
||||
})
|
||||
|
||||
// Cab inner rectangle. The cab sits flush against the front face
|
||||
// (-Z in local coords) so its center is `-shaftDepth/2 + cabDepth/2`
|
||||
// away from shaft center.
|
||||
const cabCenterLocalY = -shaftDepth / 2 + cabDepth / 2
|
||||
const cabHalfW = cabWidth / 2
|
||||
const cabHalfD = cabDepth / 2
|
||||
const cabCorners: Array<readonly [number, number]> = [
|
||||
[-cabHalfW, cabCenterLocalY - cabHalfD],
|
||||
[cabHalfW, cabCenterLocalY - cabHalfD],
|
||||
[cabHalfW, cabCenterLocalY + cabHalfD],
|
||||
[-cabHalfW, cabCenterLocalY + cabHalfD],
|
||||
]
|
||||
const cabPoints: FloorplanPoint[] = cabCorners.map(([lx, ly]) => {
|
||||
const [rx, ry] = rotate(lx, ly)
|
||||
return [center.x + rx, center.y + ry]
|
||||
})
|
||||
|
||||
// Runtime state — current level / target level / queued.
|
||||
const runtime = useInteractive.getState().elevators[node.id]
|
||||
const isCarOnLevel = parentLevelId ? runtime?.currentLevelId === parentLevelId : false
|
||||
const isTargetLevel = parentLevelId ? runtime?.targetLevelId === parentLevelId : false
|
||||
const isQueuedLevel = parentLevelId
|
||||
? (runtime?.queue.includes(parentLevelId as never) ?? false)
|
||||
: false
|
||||
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
const isSelected = view?.selected ?? false
|
||||
const isHighlighted = view?.highlighted ?? false
|
||||
const showSelectedChrome = isSelected || isHighlighted
|
||||
|
||||
// Stroke selection — selected wins, then runtime target / queued
|
||||
// states get the accent palette colour so users can spot "the cab is
|
||||
// coming here" at a glance.
|
||||
const stroke =
|
||||
showSelectedChrome && palette
|
||||
? palette.selectedStroke
|
||||
: isTargetLevel || isQueuedLevel
|
||||
? '#0ea5e9'
|
||||
: '#475569'
|
||||
// Shaft fill — orange when selected, light slate otherwise. When the
|
||||
// car is *on this level*, the cab indicator inside gets the highlight
|
||||
// instead of the whole shaft (more legible).
|
||||
const shaftFill = showSelectedChrome ? '#fed7aa' : '#cbd5e1'
|
||||
const cabFill = isCarOnLevel ? '#22c55e' : showSelectedChrome ? '#fef3c7' : '#e2e8f0'
|
||||
const cabStroke = isCarOnLevel ? '#15803d' : '#475569'
|
||||
|
||||
const children: FloorplanGeometry[] = []
|
||||
|
||||
// Outer shaft.
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: outerPoints,
|
||||
fill: shaftFill,
|
||||
stroke,
|
||||
strokeWidth: showSelectedChrome ? 0.04 : 0.03,
|
||||
strokeLinejoin: 'round',
|
||||
opacity: 0.85,
|
||||
})
|
||||
|
||||
// Cab inner rectangle.
|
||||
children.push({
|
||||
kind: 'polygon',
|
||||
points: cabPoints,
|
||||
fill: cabFill,
|
||||
fillOpacity: isCarOnLevel ? 0.85 : 0.55,
|
||||
stroke: cabStroke,
|
||||
strokeWidth: 0.018,
|
||||
strokeLinejoin: 'round',
|
||||
opacity: 0.92,
|
||||
})
|
||||
|
||||
// Door opening indicator — a short line on the front edge centered
|
||||
// on the cab. The legacy renders a more complex slide / center-open
|
||||
// hint; this is the minimum useful signal.
|
||||
const doorY = -halfDepth
|
||||
const [doorStartX, doorStartY] = rotate(-doorWidth / 2, doorY)
|
||||
const [doorEndX, doorEndY] = rotate(doorWidth / 2, doorY)
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: center.x + doorStartX,
|
||||
y1: center.y + doorStartY,
|
||||
x2: center.x + doorEndX,
|
||||
y2: center.y + doorEndY,
|
||||
stroke: isCarOnLevel ? '#15803d' : '#0f172a',
|
||||
strokeWidth: 0.05,
|
||||
strokeLinecap: 'round',
|
||||
opacity: 0.92,
|
||||
})
|
||||
|
||||
// Served-level chips — vertical column of marker circles + level
|
||||
// numbers to the right of the shaft, only when selected and the
|
||||
// elevator serves more than one level. Mirrors the legacy
|
||||
// `<FloorplanElevatorLayer>` chip rendering (~line 6423 in
|
||||
// floorplan-panel.tsx).
|
||||
if (isSelected && parentLevelId) {
|
||||
const sceneNodes = collectAllNodes(ctx)
|
||||
const serviceLevelIds = resolveElevatorServiceLevelIds(display, sceneNodes)
|
||||
if (serviceLevelIds.length > 1) {
|
||||
const disabledLevelIds = new Set(display.disabledLevelIds ?? [])
|
||||
const serviceOnlyLevelIds = new Set(display.serviceOnlyLevelIds ?? [])
|
||||
const rangeStep = 0.18
|
||||
const rangeHeight = Math.max(0, (serviceLevelIds.length - 1) * rangeStep)
|
||||
const [rangeOffsetX, rangeOffsetY] = rotate(halfWidth + 0.38, 0)
|
||||
const rangeX = center.x + rangeOffsetX
|
||||
const rangeBottomY = center.y + rangeOffsetY + rangeHeight / 2
|
||||
const rangeTopY = center.y + rangeOffsetY - rangeHeight / 2
|
||||
|
||||
// Connector spine — single vertical line tying the chips to the
|
||||
// shaft. Sky blue, semi-transparent.
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: rangeX,
|
||||
y1: rangeTopY,
|
||||
x2: rangeX,
|
||||
y2: rangeBottomY,
|
||||
stroke: '#0ea5e9',
|
||||
strokeOpacity: 0.52,
|
||||
strokeWidth: 0.018,
|
||||
strokeLinecap: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
|
||||
// One chip per served level. Lowest level at the bottom of the
|
||||
// column, index increases upward — matches legacy ordering.
|
||||
serviceLevelIds.forEach((levelId, index) => {
|
||||
const isCurrent = runtime?.currentLevelId === levelId
|
||||
const isTarget = runtime?.targetLevelId === levelId
|
||||
// `resolveElevatorServiceLevelIds` returns plain `string[]`, but
|
||||
// the runtime queue is `AnyNodeId[]` (branded). The values agree
|
||||
// at runtime — narrowing through `as never` keeps the includes
|
||||
// call type-safe without dragging the brand into the helper's
|
||||
// public return type.
|
||||
const isQueued = runtime?.queue.includes(levelId as never) ?? false
|
||||
const isDisabled = disabledLevelIds.has(levelId)
|
||||
const isServiceOnly = serviceOnlyLevelIds.has(levelId)
|
||||
const isUnavailable = isDisabled || isServiceOnly
|
||||
|
||||
const markerFill = isCurrent
|
||||
? '#22c55e'
|
||||
: isTarget || isQueued
|
||||
? '#38bdf8'
|
||||
: isUnavailable
|
||||
? '#94a3b8'
|
||||
: '#ffffff'
|
||||
const markerStroke = isUnavailable ? '#64748b' : '#0369a1'
|
||||
const labelColor = isUnavailable ? '#64748b' : '#075985'
|
||||
const y = rangeBottomY - index * rangeStep
|
||||
|
||||
children.push({
|
||||
kind: 'circle',
|
||||
cx: rangeX,
|
||||
cy: y,
|
||||
r: 0.055,
|
||||
fill: markerFill,
|
||||
fillOpacity: isUnavailable ? 0.72 : 0.95,
|
||||
stroke: markerStroke,
|
||||
strokeWidth: 0.012,
|
||||
})
|
||||
children.push({
|
||||
kind: 'text',
|
||||
x: rangeX + 0.11,
|
||||
y,
|
||||
text: String(index + 1),
|
||||
fontSize: 0.13,
|
||||
fontWeight: 700,
|
||||
fill: labelColor,
|
||||
textAnchor: 'start',
|
||||
dominantBaseline: 'middle',
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
children.push({
|
||||
kind: 'move-handle',
|
||||
point: [display.position[0], display.position[2]],
|
||||
})
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx` exposes `resolve` and `children` / `siblings` / `parent`, but
|
||||
* not the full nodes map. `resolveElevatorServiceLevelIds` wants a
|
||||
* `Record<id, AnyNode>`; we rebuild it by walking the chain we DO have
|
||||
* access to. For the elevator's service-level check we only need the
|
||||
* elevator's parent (the level), its building, and any level siblings.
|
||||
* This is the minimum graph the resolver needs.
|
||||
*
|
||||
* If a future use needs the full nodes map for a builder, we'd surface
|
||||
* it through ctx — but doing so leaks the whole scene store into every
|
||||
* `def.floorplan` call. Narrow opt-in is the better default.
|
||||
*/
|
||||
function collectAllNodes(ctx: GeometryContext): Record<string, never> {
|
||||
// We need the building → levels graph for service-level resolution.
|
||||
// Walk up from the elevator: parent (level) → its parent (building) →
|
||||
// building.children (all levels). That's enough for the resolver.
|
||||
const out: Record<string, unknown> = {}
|
||||
const level = ctx.parent
|
||||
if (level) {
|
||||
out[level.id] = level
|
||||
const building = (level as { parentId?: string }).parentId
|
||||
? ctx.resolve((level as { parentId: string }).parentId as never)
|
||||
: undefined
|
||||
if (building) {
|
||||
out[building.id] = building
|
||||
const childIds = (building as unknown as { children?: string[] }).children
|
||||
if (Array.isArray(childIds)) {
|
||||
for (const cid of childIds) {
|
||||
const child = ctx.resolve(cid as never)
|
||||
if (child) out[child.id] = child
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out as Record<string, never>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { elevatorDefinition } from './definition'
|
||||
+24
-18
@@ -12,18 +12,22 @@ import {
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
ActionButton,
|
||||
ActionGroup,
|
||||
MetricControl,
|
||||
PanelSection,
|
||||
PanelWrapper,
|
||||
resolveElevatorNodeSupportY,
|
||||
resolveElevatorSupportY,
|
||||
SliderControl,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
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
|
||||
@@ -153,7 +157,7 @@ function degreesToRadians(degrees: number) {
|
||||
return (degrees * Math.PI) / 180
|
||||
}
|
||||
|
||||
export function ElevatorPanel() {
|
||||
export default function ElevatorPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
@@ -302,7 +306,7 @@ export function ElevatorPanel() {
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
triggerSFX('sfx:item-pick')
|
||||
clearLivePreview()
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
@@ -310,7 +314,7 @@ export function ElevatorPanel() {
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!(node && node.parentId)) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
triggerSFX('sfx:item-pick')
|
||||
|
||||
const duplicate = ElevatorNodeSchema.parse({
|
||||
...structuredClone(node),
|
||||
@@ -328,7 +332,7 @@ export function ElevatorPanel() {
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
triggerSFX('sfx:structure-delete')
|
||||
clearLivePreview()
|
||||
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
||||
setSelection({ selectedIds: [] })
|
||||
@@ -442,7 +446,11 @@ export function ElevatorPanel() {
|
||||
)
|
||||
const enabledServedLevels = servedLevels.filter((level) => !disabledLevelIds.has(level.id))
|
||||
const defaultLevelOptions =
|
||||
enabledServedLevels.length > 0 ? enabledServedLevels : servedLevels.length > 0 ? servedLevels : levels
|
||||
enabledServedLevels.length > 0
|
||||
? enabledServedLevels
|
||||
: servedLevels.length > 0
|
||||
? servedLevels
|
||||
: levels
|
||||
const selectedDefaultLevelId = defaultLevelOptions.some(
|
||||
(level) => level.id === node.defaultLevelId,
|
||||
)
|
||||
@@ -570,14 +578,14 @@ export function ElevatorPanel() {
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
triggerSFX('sfx:item-rotate')
|
||||
commitTransform(displayPosition, displayRotation - Math.PI / 4)
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
triggerSFX('sfx:item-rotate')
|
||||
commitTransform(displayPosition, displayRotation + Math.PI / 4)
|
||||
}}
|
||||
/>
|
||||
@@ -753,9 +761,7 @@ export function ElevatorPanel() {
|
||||
</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)
|
||||
}
|
||||
onChange={(event) => handleServiceBoundaryChange('fromLevelId', event.target.value)}
|
||||
value={fromLevelId}
|
||||
>
|
||||
{levels.map((level) => (
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ElevatorNode, ParametricDescriptor } from '@pascal-app/core'
|
||||
|
||||
export const elevatorParametrics: ParametricDescriptor<ElevatorNode> = {
|
||||
groups: [],
|
||||
customPanel: () => import('./panel'),
|
||||
}
|
||||
+5
-1
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useNodeEvents } from '@pascal-app/viewer'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
@@ -33,7 +36,6 @@ import {
|
||||
TorusGeometry,
|
||||
} from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
|
||||
const SHAFT_WALL_COLOR = '#d7dce4'
|
||||
const SHAFT_SIDE_COLOR = '#4b5563'
|
||||
@@ -1308,3 +1310,5 @@ export const ElevatorRenderer = ({ node }: { node: ElevatorNode }) => {
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default ElevatorRenderer
|
||||
@@ -0,0 +1 @@
|
||||
export { ElevatorNode } from '@pascal-app/core'
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { ElevatorOpeningSystem, ElevatorRuntimeSystem } from '@pascal-app/core'
|
||||
import { ElevatorInteractionSystem } from '@pascal-app/viewer'
|
||||
|
||||
/**
|
||||
* Composite system for elevator — bundles three per-frame systems:
|
||||
* `ElevatorRuntimeSystem` (cab travel + door state machine),
|
||||
* `ElevatorInteractionSystem` (call buttons / cab UI), and
|
||||
* `ElevatorOpeningSystem` (wall + slab cutout cascade).
|
||||
*/
|
||||
export default function ElevatorSystem() {
|
||||
return (
|
||||
<>
|
||||
<ElevatorRuntimeSystem />
|
||||
<ElevatorInteractionSystem />
|
||||
<ElevatorOpeningSystem />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { buildFenceFloorplan } from './floorplan'
|
||||
import { fenceMoveEndpointAffordance } from './floorplan-affordances'
|
||||
import { buildFenceGeometry } from './geometry'
|
||||
import { fenceParametrics } from './parametrics'
|
||||
import { FenceNode } from './schema'
|
||||
@@ -74,6 +75,13 @@ export const fenceDefinition: NodeDefinition<typeof FenceNode> = {
|
||||
// Legacy `floorplanFenceEntries` short-circuits to [] when fence is
|
||||
// registered (see floorplan-panel.tsx).
|
||||
floorplan: buildFenceFloorplan,
|
||||
// 2D drag affordance — sister to `actions/move-endpoint.ts`. The 3D
|
||||
// DragAction drives R3F grid events through `createDragSession`; this
|
||||
// one drives SVG pointer events through the floor-plan registry
|
||||
// dispatcher's snapshot + single-undo dance. Same legacy semantics.
|
||||
floorplanAffordances: {
|
||||
'move-endpoint': fenceMoveEndpointAffordance,
|
||||
},
|
||||
// Stage D — all four fence drag-affordances live in this folder.
|
||||
// curve / move-endpoint / move are 1:1 ports of the legacy tools
|
||||
// (same snap pipeline, same history dance, same cursor render),
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type FenceNode,
|
||||
type FloorplanAffordance,
|
||||
type FloorplanAffordanceSession,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { type FencePlanPoint, isWallLongEnough, snapFenceDraftPoint } from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* Floor-plan 2D drag affordances for fence — sister to the 3D
|
||||
* `actions/move-endpoint.ts` `DragAction`. Same legacy interaction
|
||||
* (endpoint snap pipeline + linked-fence cascade via `endpoint-match`
|
||||
* with an epsilon, ALT-detach), driven from SVG pointer events instead
|
||||
* of R3F grid events.
|
||||
*
|
||||
* Why not share the `DragAction`? The 3D code goes through
|
||||
* `createDragSession` which assumes a `SceneApi`-style helper bag
|
||||
* (snapshot, restoreAll, pauseHistory, resumeHistory). The 2D registry
|
||||
* layer owns those semantics directly via the dispatcher's snapshot +
|
||||
* pause/resume dance, so the affordance only needs the pure mutation
|
||||
* logic. The shape is intentionally close to the legacy fence drag —
|
||||
* 1:1 behaviorally.
|
||||
*/
|
||||
|
||||
const LINKED_FENCE_ENDPOINT_EPSILON = 0.025
|
||||
|
||||
type FenceEndpointPayload = { fenceId: AnyNodeId; endpoint: 'start' | 'end' }
|
||||
|
||||
function pointsNearlyEqual(a: FencePlanPoint, b: FencePlanPoint): boolean {
|
||||
return (
|
||||
Math.abs(a[0] - b[0]) <= LINKED_FENCE_ENDPOINT_EPSILON &&
|
||||
Math.abs(a[1] - b[1]) <= LINKED_FENCE_ENDPOINT_EPSILON
|
||||
)
|
||||
}
|
||||
|
||||
function collectLevel(
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
parentId: string | null,
|
||||
): { walls: WallNode[]; fences: FenceNode[] } {
|
||||
const walls: WallNode[] = []
|
||||
const fences: FenceNode[] = []
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (!node) continue
|
||||
if ((node.parentId ?? null) !== parentId) continue
|
||||
if (node.type === 'wall') walls.push(node as WallNode)
|
||||
else if (node.type === 'fence') fences.push(node as FenceNode)
|
||||
}
|
||||
return { walls, fences }
|
||||
}
|
||||
|
||||
function collectLinkedFences(
|
||||
fences: FenceNode[],
|
||||
draggedFenceId: AnyNodeId,
|
||||
linkedPoint: FencePlanPoint,
|
||||
): Array<{ id: AnyNodeId; start: FencePlanPoint; end: FencePlanPoint }> {
|
||||
const out: Array<{ id: AnyNodeId; start: FencePlanPoint; end: FencePlanPoint }> = []
|
||||
for (const fence of fences) {
|
||||
if (fence.id === draggedFenceId) continue
|
||||
if (!pointsNearlyEqual(fence.start, linkedPoint) && !pointsNearlyEqual(fence.end, linkedPoint))
|
||||
continue
|
||||
out.push({
|
||||
id: fence.id,
|
||||
start: [fence.start[0], fence.start[1]],
|
||||
end: [fence.end[0], fence.end[1]],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
||||
start({ node, payload, nodes }): FloorplanAffordanceSession {
|
||||
const { endpoint } = payload as FenceEndpointPayload
|
||||
const originalStart: FencePlanPoint = [node.start[0], node.start[1]]
|
||||
const originalEnd: FencePlanPoint = [node.end[0], node.end[1]]
|
||||
const originalMovingPoint = endpoint === 'start' ? originalStart : originalEnd
|
||||
const fixedPoint: FencePlanPoint = endpoint === 'start' ? originalEnd : originalStart
|
||||
|
||||
const parentId = node.parentId ?? null
|
||||
const { walls, fences } = collectLevel(nodes, parentId)
|
||||
const linkedOriginals = collectLinkedFences(fences, node.id, originalMovingPoint)
|
||||
|
||||
const affectedIds: AnyNodeId[] = [node.id, ...linkedOriginals.map((l) => l.id)]
|
||||
|
||||
return {
|
||||
affectedIds,
|
||||
apply({ planPoint, modifiers }) {
|
||||
// Re-collect siblings each tick: the user might be dragging a
|
||||
// fence whose sibling positions changed (the dragged fence
|
||||
// itself is excluded via `ignoreFenceIds`).
|
||||
const sceneNodes = useScene.getState().nodes
|
||||
const { walls: nextWalls, fences: nextFences } = collectLevel(sceneNodes, parentId)
|
||||
const snapped = snapFenceDraftPoint({
|
||||
point: planPoint as FencePlanPoint,
|
||||
walls: nextWalls,
|
||||
fences: nextFences,
|
||||
start: fixedPoint,
|
||||
angleSnap: !modifiers.shiftKey,
|
||||
ignoreFenceIds: [node.id],
|
||||
})
|
||||
const nextStart = endpoint === 'start' ? snapped : fixedPoint
|
||||
const nextEnd = endpoint === 'end' ? snapped : fixedPoint
|
||||
|
||||
const linkedUpdates = modifiers.altKey
|
||||
? []
|
||||
: linkedOriginals.map((l) => ({
|
||||
id: l.id,
|
||||
start: pointsNearlyEqual(l.start, originalMovingPoint) ? snapped : l.start,
|
||||
end: pointsNearlyEqual(l.end, originalMovingPoint) ? snapped : l.end,
|
||||
}))
|
||||
|
||||
useScene.getState().updateNodes([
|
||||
{ id: node.id, data: { start: nextStart, end: nextEnd } },
|
||||
...linkedUpdates.map((u) => ({
|
||||
id: u.id,
|
||||
data: { start: u.start, end: u.end },
|
||||
})),
|
||||
])
|
||||
},
|
||||
canCommit() {
|
||||
const finalFence = useScene.getState().nodes[node.id] as FenceNode | undefined
|
||||
return (
|
||||
!!finalFence &&
|
||||
finalFence.type === 'fence' &&
|
||||
isWallLongEnough(finalFence.start, finalFence.end)
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,32 +1,376 @@
|
||||
import type { FloorplanGeometry, FloorplanPoint } from '@pascal-app/core'
|
||||
import { isCurvedWall, sampleWallCenterline } from '@pascal-app/core'
|
||||
import {
|
||||
type FloorplanGeometry,
|
||||
type GeometryContext,
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
isCurvedWall,
|
||||
sampleWallCenterline,
|
||||
} from '@pascal-app/core'
|
||||
import type { FenceNode } from './schema'
|
||||
|
||||
/**
|
||||
* Stage C floor-plan builder for fence. Draws the fence centerline as
|
||||
* a polyline; thickness becomes the stroke width.
|
||||
* Stage C floor-plan builder for fence. 1:1 visual port of the legacy
|
||||
* `FloorplanFenceLayer` from `floorplan-panel.tsx`:
|
||||
*
|
||||
* Curved fences sample the centerline at 24 segments — same density the
|
||||
* legacy `floorplanFenceEntries` useMemo uses, so straight + curved
|
||||
* fences look comparable to the legacy rendering.
|
||||
* 1. Three stacked stroke paths along the centerline, all with
|
||||
* `vectorEffect: 'non-scaling-stroke'` so widths stay constant on
|
||||
* screen at any zoom:
|
||||
* a. Optional glow (semi-transparent, only when active/hovered).
|
||||
* b. White underlay — the visual "fence body" base layer.
|
||||
* c. Dark accent — the actual fence outline.
|
||||
* 2. Style-aware markers at computed positions along the centerline:
|
||||
* - `privacy`: rotated rectangle (vertical slat).
|
||||
* - `rail`: concentric circle stack (post + ring + tiny center).
|
||||
* - default `slat`: white X mark with a coloured X on top.
|
||||
* 3. Markers thinned when `showInfill === false` — only first + last
|
||||
* remain (matches legacy "endpoints only" mode).
|
||||
* 4. Selection chrome: dots on the endpoints + centered length label
|
||||
* (same shape as the wall builder).
|
||||
*
|
||||
* Visual nuances the legacy ships (side hatching to indicate thickness
|
||||
* direction, post markers along the centerline) are deferred — Phase 5
|
||||
* Stage D will revisit if real visual parity is needed.
|
||||
* `getFloorplanFenceMarkerTs` is inlined here — it was a private helper
|
||||
* in the legacy panel and is fence-specific, so it lives with the kind.
|
||||
*/
|
||||
export function buildFenceFloorplan(node: FenceNode): FloorplanGeometry {
|
||||
const points: FloorplanPoint[] = isCurvedWall(node)
|
||||
? sampleWallCenterline(node, 24).map((p) => [p.x, p.y] as FloorplanPoint)
|
||||
: [
|
||||
[node.start[0], node.start[1]],
|
||||
[node.end[0], node.end[1]],
|
||||
]
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function getFloorplanFenceLength(fence: FenceNode): number {
|
||||
return isCurvedWall(fence)
|
||||
? getWallCurveLength(fence)
|
||||
: Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1])
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute markers along the fence centerline. Spacing depends on
|
||||
* `postSpacing` (tighter for privacy style); `inset` keeps the first /
|
||||
* last marker away from the endpoints. Returns a list of t-values in
|
||||
* [0, 1] suitable for `getWallCurveFrameAt`.
|
||||
*/
|
||||
function getFloorplanFenceMarkerTs(fence: FenceNode): number[] {
|
||||
const length = getFloorplanFenceLength(fence)
|
||||
if (length <= 0.24) return [0.5]
|
||||
|
||||
const spacing = clamp(
|
||||
fence.style === 'privacy' ? fence.postSpacing * 0.72 : fence.postSpacing,
|
||||
0.34,
|
||||
1.5,
|
||||
)
|
||||
const inset = clamp(
|
||||
Math.max(fence.postSize * 1.25, fence.edgeInset * 10),
|
||||
0.18,
|
||||
Math.min(0.48, length * 0.22),
|
||||
)
|
||||
const usableLength = Math.max(length - inset * 2, 0)
|
||||
if (usableLength <= 0.001) return [0.5]
|
||||
|
||||
const markerCount = Math.max(1, Math.min(24, Math.floor(usableLength / spacing) + 1))
|
||||
if (markerCount === 1) return [0.5]
|
||||
|
||||
return Array.from({ length: markerCount }, (_, index) =>
|
||||
clamp((inset + (usableLength * index) / (markerCount - 1)) / length, 0.08, 0.92),
|
||||
)
|
||||
}
|
||||
|
||||
function buildCenterlinePathD(points: ReadonlyArray<{ x: number; y: number }>): string {
|
||||
if (points.length < 2) return ''
|
||||
const first = points[0]!
|
||||
return [`M ${first.x} ${first.y}`, ...points.slice(1).map((p) => `L ${p.x} ${p.y}`)].join(' ')
|
||||
}
|
||||
|
||||
function buildMarker(
|
||||
fence: FenceNode,
|
||||
point: { x: number; y: number },
|
||||
angleRadians: number,
|
||||
accentColor: string,
|
||||
surfaceColor: string,
|
||||
isActive: boolean,
|
||||
): FloorplanGeometry {
|
||||
const markerStrokeWidth = isActive ? 1.65 : 1.35
|
||||
|
||||
if (fence.style === 'privacy') {
|
||||
const w = clamp(fence.postSize * 0.58, 0.038, 0.068)
|
||||
const h = clamp(Math.max(fence.baseHeight * 0.5, fence.postSize * 1.4), 0.1, 0.17)
|
||||
// Surface plate underneath + accent rectangle on top — gives a clean
|
||||
// "punched out of the underlay stroke" look at all zooms.
|
||||
return {
|
||||
kind: 'group',
|
||||
transform: { translate: [point.x, point.y], rotate: angleRadians },
|
||||
children: [
|
||||
{
|
||||
kind: 'rect',
|
||||
x: -(w + 0.032) / 2,
|
||||
y: -(h + 0.038) / 2,
|
||||
width: w + 0.032,
|
||||
height: h + 0.038,
|
||||
rx: 0.014,
|
||||
ry: 0.014,
|
||||
fill: surfaceColor,
|
||||
},
|
||||
{
|
||||
kind: 'rect',
|
||||
x: -w / 2,
|
||||
y: -h / 2,
|
||||
width: w,
|
||||
height: h,
|
||||
rx: 0.01,
|
||||
ry: 0.01,
|
||||
fill: accentColor,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (fence.style === 'rail') {
|
||||
const r = clamp(fence.postSize * 0.52, 0.048, 0.078)
|
||||
return {
|
||||
kind: 'group',
|
||||
transform: { translate: [point.x, point.y] },
|
||||
children: [
|
||||
{ kind: 'circle', cx: 0, cy: 0, r: r + 0.018, fill: surfaceColor },
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: 0,
|
||||
cy: 0,
|
||||
r,
|
||||
fill: surfaceColor,
|
||||
stroke: accentColor,
|
||||
strokeWidth: markerStrokeWidth,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
},
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: 0,
|
||||
cy: 0,
|
||||
r: r * 0.34,
|
||||
fill: accentColor,
|
||||
fillOpacity: isActive ? 0.24 : 0.18,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Default — slat X mark.
|
||||
const half = clamp(fence.postSize * 0.42, 0.03, 0.055)
|
||||
return {
|
||||
kind: 'polyline',
|
||||
points,
|
||||
stroke: node.color || '#475569',
|
||||
strokeWidth: Math.max(node.thickness, 0.05),
|
||||
opacity: 0.9,
|
||||
kind: 'group',
|
||||
transform: { translate: [point.x, point.y], rotate: angleRadians },
|
||||
children: [
|
||||
// White underlay so the X shows against any background.
|
||||
{
|
||||
kind: 'line',
|
||||
x1: -half,
|
||||
y1: -half,
|
||||
x2: half,
|
||||
y2: half,
|
||||
stroke: surfaceColor,
|
||||
strokeWidth: 2.8,
|
||||
strokeLinecap: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: half,
|
||||
y1: -half,
|
||||
x2: -half,
|
||||
y2: half,
|
||||
stroke: surfaceColor,
|
||||
strokeWidth: 2.8,
|
||||
strokeLinecap: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
},
|
||||
// Accent X on top.
|
||||
{
|
||||
kind: 'line',
|
||||
x1: -half,
|
||||
y1: -half,
|
||||
x2: half,
|
||||
y2: half,
|
||||
stroke: accentColor,
|
||||
strokeWidth: markerStrokeWidth,
|
||||
strokeLinecap: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: half,
|
||||
y1: -half,
|
||||
x2: -half,
|
||||
y2: half,
|
||||
stroke: accentColor,
|
||||
strokeWidth: markerStrokeWidth,
|
||||
strokeLinecap: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFenceFloorplan(node: FenceNode, ctx: GeometryContext): FloorplanGeometry {
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
const isSelected = view?.selected ?? false
|
||||
const isHighlighted = view?.highlighted ?? false
|
||||
const isHovered = view?.hovered ?? false
|
||||
const isActive = isSelected || isHighlighted
|
||||
const showInteractiveChrome = isActive || isHovered
|
||||
|
||||
// Centerline path — sampled for curved fences so the underlay /
|
||||
// accent / glow all trace the same shape.
|
||||
const centerlinePoints = isCurvedWall(node)
|
||||
? sampleWallCenterline(node, 24)
|
||||
: [
|
||||
{ x: node.start[0], y: node.start[1] },
|
||||
{ x: node.end[0], y: node.end[1] },
|
||||
]
|
||||
const pathD = buildCenterlinePathD(centerlinePoints)
|
||||
|
||||
// Stroke shifts: selected wins; hover (not selected) → `wallHoverStroke`
|
||||
// (light blue from the legacy palette, same as walls); otherwise dark
|
||||
// accent. Mirrors the `fenceStroke` ternary in the legacy panel.
|
||||
const accentStroke =
|
||||
isActive && palette
|
||||
? palette.selectedStroke
|
||||
: isHovered && palette
|
||||
? palette.wallHoverStroke
|
||||
: '#111827'
|
||||
const glowStroke =
|
||||
isActive && palette
|
||||
? palette.selectedStroke
|
||||
: isHovered && palette
|
||||
? palette.wallHoverStroke
|
||||
: accentStroke
|
||||
const underlayStroke = 'rgba(255, 255, 255, 0.98)'
|
||||
// Surface (white) for marker plates — themed dark surface would look
|
||||
// wrong on the white underlay, so we hardcode white here.
|
||||
const markerSurface = '#ffffff'
|
||||
|
||||
// Widths step up on hover (between idle and active) — same pattern the
|
||||
// legacy panel uses for `fenceUnderlayWidth` / `fenceStrokeWidth`.
|
||||
const underlayWidth = isActive ? 6.5 : isHovered ? 6 : 5.2
|
||||
const accentWidth = isActive ? 2.6 : isHovered ? 2.35 : 2.05
|
||||
// Glow only appears when active or hovered. Opacity gradient matches
|
||||
// the legacy (0.22 active / 0.14 hover / 0 idle).
|
||||
const glowOpacity = isActive ? 0.22 : isHovered ? 0.14 : 0
|
||||
|
||||
// Marker frames. Filter to first+last when infill is off so the user
|
||||
// still sees end posts (matches legacy).
|
||||
const markerTs = getFloorplanFenceMarkerTs(node)
|
||||
const markerFrames = markerTs.map((t) => {
|
||||
const frame = getWallCurveFrameAt(node, t)
|
||||
return {
|
||||
point: frame.point,
|
||||
angle: Math.atan2(frame.tangent.y, frame.tangent.x),
|
||||
}
|
||||
})
|
||||
const visibleMarkers =
|
||||
(node.showInfill ?? true)
|
||||
? markerFrames
|
||||
: markerFrames.filter((_, i) => i === 0 || i === markerFrames.length - 1)
|
||||
|
||||
const children: FloorplanGeometry[] = []
|
||||
|
||||
// 1. Glow (only when active / highlighted / hovered). Wide,
|
||||
// low-opacity ring. Width steps with the interaction level so hover
|
||||
// is subtler than active.
|
||||
if (glowOpacity > 0) {
|
||||
children.push({
|
||||
kind: 'path',
|
||||
d: pathD,
|
||||
fill: 'none',
|
||||
stroke: glowStroke,
|
||||
strokeWidth: isActive ? 9.5 : isHovered ? 8.8 : 8.2,
|
||||
strokeOpacity: glowOpacity,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
}
|
||||
|
||||
// 2. White underlay — visible fence body base layer.
|
||||
children.push({
|
||||
kind: 'path',
|
||||
d: pathD,
|
||||
fill: 'none',
|
||||
stroke: underlayStroke,
|
||||
strokeOpacity: 0.98,
|
||||
strokeWidth: underlayWidth,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
|
||||
// 3. Dark accent on top.
|
||||
children.push({
|
||||
kind: 'path',
|
||||
d: pathD,
|
||||
fill: 'none',
|
||||
stroke: accentStroke,
|
||||
strokeWidth: accentWidth,
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
})
|
||||
|
||||
// 4. Style-aware markers. Pass `showInteractiveChrome` so hover also
|
||||
// bumps marker stroke widths slightly (legacy panel does the same).
|
||||
for (const marker of visibleMarkers) {
|
||||
children.push(
|
||||
buildMarker(
|
||||
node,
|
||||
marker.point,
|
||||
marker.angle,
|
||||
accentStroke,
|
||||
markerSurface,
|
||||
showInteractiveChrome,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Hit-line for click detection.
|
||||
children.push({
|
||||
kind: 'hit-line',
|
||||
x1: node.start[0],
|
||||
y1: node.start[1],
|
||||
x2: node.end[0],
|
||||
y2: node.end[1],
|
||||
strokeWidthPx: 18,
|
||||
cursor: 'pointer',
|
||||
})
|
||||
|
||||
// 6. Endpoint handles + length label when selected.
|
||||
if (isSelected) {
|
||||
children.push({
|
||||
kind: 'endpoint-handle',
|
||||
point: [node.start[0], node.start[1]],
|
||||
state: 'idle',
|
||||
affordance: 'move-endpoint',
|
||||
payload: { fenceId: node.id, endpoint: 'start' as const },
|
||||
})
|
||||
children.push({
|
||||
kind: 'endpoint-handle',
|
||||
point: [node.end[0], node.end[1]],
|
||||
state: 'idle',
|
||||
affordance: 'move-endpoint',
|
||||
payload: { fenceId: node.id, endpoint: 'end' as const },
|
||||
})
|
||||
|
||||
const length = getWallCurveLength(node)
|
||||
if (length >= 0.1) {
|
||||
const midX = (node.start[0] + node.end[0]) / 2
|
||||
const midZ = (node.start[1] + node.end[1]) / 2
|
||||
const dx = node.end[0] - node.start[0]
|
||||
const dz = node.end[1] - node.start[1]
|
||||
children.push({
|
||||
kind: 'dimension-label',
|
||||
cx: midX,
|
||||
cy: midZ,
|
||||
text: `${Number.parseFloat(length.toFixed(2))}m`,
|
||||
angle: Math.atan2(dz, dx),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { GuideNode as GuideNodeSchema, type NodeDefinition } from '@pascal-app/core'
|
||||
import { guideParametrics } from './parametrics'
|
||||
import { GuideNode } from './schema'
|
||||
|
||||
/**
|
||||
* Guide — Stage A. Measurement reference annotations placed by the
|
||||
* user (linear / area / arc). `GuideSystem` handles per-frame
|
||||
* positioning; the renderer mounts the visual marker + dimensioning
|
||||
* HUD via `<Html>`.
|
||||
*/
|
||||
export const guideDefinition: NodeDefinition<typeof GuideNode> = {
|
||||
kind: 'guide',
|
||||
schemaVersion: 1,
|
||||
schema: GuideNode,
|
||||
category: 'site',
|
||||
|
||||
defaults: () => {
|
||||
const stub = GuideNodeSchema.parse({ id: 'guide_default' as never, type: 'guide' })
|
||||
const { id: _id, type: _type, ...rest } = stub
|
||||
return rest
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: false,
|
||||
deletable: true,
|
||||
},
|
||||
|
||||
parametrics: guideParametrics,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
system: {
|
||||
module: () => import('./system'),
|
||||
priority: 5,
|
||||
},
|
||||
|
||||
presentation: {
|
||||
label: 'Guide',
|
||||
description: 'A measurement / reference annotation (linear, area, or arc).',
|
||||
icon: { kind: 'url', src: '/icons/blueprint.png' },
|
||||
paletteSection: 'site',
|
||||
paletteOrder: 30,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description: 'A measurement reference guide annotation.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { guideDefinition } from './definition'
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { GuideNode, ParametricDescriptor } from '@pascal-app/core'
|
||||
|
||||
export const guideParametrics: ParametricDescriptor<GuideNode> = {
|
||||
groups: [],
|
||||
}
|
||||
+5
-2
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { type GuideNode, useRegistry } from '@pascal-app/core'
|
||||
import { useAssetUrl, useViewer } from '@pascal-app/viewer'
|
||||
import { useLoader } from '@react-three/fiber'
|
||||
import { Suspense, useMemo, useRef } from 'react'
|
||||
import { DoubleSide, type Group, type Texture, TextureLoader } from 'three'
|
||||
import { float, texture } from 'three/tsl'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { useAssetUrl } from '../../../hooks/use-asset-url'
|
||||
import useViewer from '../../../store/use-viewer'
|
||||
|
||||
export const GuideRenderer = ({ node }: { node: GuideNode }) => {
|
||||
const showGuides = useViewer((s) => s.showGuides)
|
||||
@@ -67,3 +68,5 @@ const GuidePlane = ({ url, scale, opacity }: { url: string; scale: number; opaci
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
export default GuideRenderer
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user