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,5 +1,6 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { buildWindowFloorplan } from './floorplan'
|
||||
import { windowFloorplanMoveTarget } from './floorplan-move'
|
||||
import { windowParametrics } from './parametrics'
|
||||
import { WindowNode } from './schema'
|
||||
|
||||
@@ -47,6 +48,14 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
||||
// Stage C: floor-plan polygon. ctx.parent gives the wall for direction
|
||||
// + thickness — same shape as door.
|
||||
floorplan: buildWindowFloorplan,
|
||||
// Stage D — placement + move-on-wall. Same recipe as door. See
|
||||
// `nodes/src/window/{tool,move-tool,window-math}.ts`.
|
||||
tool: () => import('./tool'),
|
||||
affordanceTools: {
|
||||
move: () => import('./move-tool'),
|
||||
},
|
||||
// 2D move-on-floorplan handler — same shape as door.
|
||||
floorplanMoveTarget: windowFloorplanMoveTarget,
|
||||
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place window on wall' },
|
||||
@@ -56,7 +65,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
||||
presentation: {
|
||||
label: 'Window',
|
||||
description: 'A window cut into a wall. Animated open/close for opening windows.',
|
||||
icon: { kind: 'iconify', name: 'lucide:rectangle-horizontal' },
|
||||
icon: { kind: 'url', src: '/icons/window.png' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 60,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type FloorplanMoveTarget,
|
||||
type FloorplanMoveTargetSession,
|
||||
useScene,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { snapToHalf } from '@pascal-app/editor'
|
||||
import { findClosestWallInPlan } from '../shared/wall-attach-target'
|
||||
import { clampToWall, hasWallChildOverlap } from './window-math'
|
||||
|
||||
/**
|
||||
* 2D floor-plan move handler for window. Same shape as door (see
|
||||
* `nodes/src/door/floorplan-move.ts`) — pointer in plan space → snap
|
||||
* to nearest wall → project onto wall axis → snap local-X to 0.5m →
|
||||
* clamp inside wall bounds → commit.
|
||||
*
|
||||
* Window-specific: local Y (vertical position on the wall) is preserved
|
||||
* from the source node — we don't try to reposition the sill from a 2D
|
||||
* pointer (there's no Y signal in plan view). The 3D move tool handles
|
||||
* vertical motion; the 2D move is a horizontal-only re-anchor.
|
||||
*/
|
||||
|
||||
export const windowFloorplanMoveTarget: FloorplanMoveTarget<WindowNode> = ({ node }) => {
|
||||
const startLevelId = (() => {
|
||||
const wall = useScene.getState().nodes[node.parentId as AnyNodeId]
|
||||
return wall ? (wall.parentId as AnyNodeId | null) : null
|
||||
})()
|
||||
|
||||
// Preserve the source window's local Y — 2D move doesn't have a way
|
||||
// to express vertical motion, so we keep whatever vertical position
|
||||
// the window had when the move started.
|
||||
const startLocalY = node.position[1]
|
||||
|
||||
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
|
||||
|
||||
const snappedLocalX = modifiers.shiftKey ? hit.localX : snapToHalf(hit.localX)
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
hit.wall,
|
||||
snappedLocalX,
|
||||
startLocalY,
|
||||
node.width,
|
||||
node.height,
|
||||
)
|
||||
|
||||
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 WindowNode | undefined
|
||||
if (!live || live.type !== 'window') return false
|
||||
const overlapping = hasWallChildOverlap(
|
||||
live.parentId as string,
|
||||
live.position[0],
|
||||
live.position[1],
|
||||
live.width,
|
||||
live.height,
|
||||
live.id,
|
||||
)
|
||||
return !overlapping
|
||||
},
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
@@ -5,11 +5,20 @@ import type {
|
||||
WallNode,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dimensions'
|
||||
|
||||
/**
|
||||
* Stage C floor-plan builder for window. Mirrors door's shape — window
|
||||
* polygon sits in the wall's cutout, width along wall, depth = wall
|
||||
* thickness. Visually distinct via a glass-blue tint.
|
||||
* Stage C floor-plan builder for window. Mirrors the legacy
|
||||
* floorplan-panel window rendering:
|
||||
*
|
||||
* 1. Window footprint rectangle in the wall cutout (themed accent
|
||||
* stroke when selected).
|
||||
* 2. Inset inner outline — the "glass pane" frame inside the cutout.
|
||||
* 3. Center mullion line down the middle of the opening, along the
|
||||
* wall direction — the legacy's standard glass divider.
|
||||
*
|
||||
* Skipped vs the full legacy for now: arched / rounded opening shape
|
||||
* variants, multi-pane mullion grids.
|
||||
*/
|
||||
export function buildWindowFloorplan(
|
||||
node: WindowNode,
|
||||
@@ -45,12 +54,96 @@ export function buildWindowFloorplan(
|
||||
[cx - dirX * halfWidth - perpX * halfDepth, cz - dirZ * halfWidth - perpZ * halfDepth],
|
||||
]
|
||||
|
||||
return {
|
||||
kind: 'polygon',
|
||||
points,
|
||||
fill: '#bae6fd',
|
||||
stroke: '#0c4a6e',
|
||||
strokeWidth: 0.015,
|
||||
opacity: 0.8,
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
const isSelected = view?.selected ?? false
|
||||
const isHighlighted = view?.highlighted ?? false
|
||||
const showSelectedChrome = isSelected || isHighlighted
|
||||
|
||||
// Same selection treatment as door — selected windows get a full
|
||||
// orange body + outline so they read as the active target.
|
||||
const accentColor = showSelectedChrome ? '#f97316' : 'rgba(31, 41, 55, 0.92)'
|
||||
const fillColor = showSelectedChrome ? '#fed7aa' : 'rgba(255, 255, 255, 0.96)'
|
||||
|
||||
// Inner inset rectangle (the glass pane outline). Tangent inset
|
||||
// pulls the long sides in slightly; normal inset reduces the depth.
|
||||
const tangentInset = Math.min(width * 0.08, 0.12)
|
||||
const normalInset = Math.min(depth * 0.22, 0.07)
|
||||
const innerStartA: FloorplanPoint = [
|
||||
cx - dirX * (halfWidth - tangentInset) + perpX * (halfDepth - normalInset),
|
||||
cz - dirZ * (halfWidth - tangentInset) + perpZ * (halfDepth - normalInset),
|
||||
]
|
||||
const innerEndA: FloorplanPoint = [
|
||||
cx + dirX * (halfWidth - tangentInset) + perpX * (halfDepth - normalInset),
|
||||
cz + dirZ * (halfWidth - tangentInset) + perpZ * (halfDepth - normalInset),
|
||||
]
|
||||
const innerEndB: FloorplanPoint = [
|
||||
cx + dirX * (halfWidth - tangentInset) - perpX * (halfDepth - normalInset),
|
||||
cz + dirZ * (halfWidth - tangentInset) - perpZ * (halfDepth - normalInset),
|
||||
]
|
||||
const innerStartB: FloorplanPoint = [
|
||||
cx - dirX * (halfWidth - tangentInset) - perpX * (halfDepth - normalInset),
|
||||
cz - dirZ * (halfWidth - tangentInset) - perpZ * (halfDepth - normalInset),
|
||||
]
|
||||
|
||||
// Center mullion — from the midpoint of the left edge to the
|
||||
// midpoint of the right edge of the cutout.
|
||||
const mullionStart: FloorplanPoint = [cx - dirX * halfWidth, cz - dirZ * halfWidth]
|
||||
const mullionEnd: FloorplanPoint = [cx + dirX * halfWidth, cz + dirZ * halfWidth]
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
// Outer footprint — white fill so the wall hatch underneath
|
||||
// doesn't bleed through.
|
||||
{
|
||||
kind: 'polygon',
|
||||
points,
|
||||
fill: fillColor,
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 1.9 : 1.25,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinejoin: 'round',
|
||||
},
|
||||
// Inset glass-pane outline.
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: [innerStartA, innerEndA, innerEndB, innerStartB],
|
||||
fill: 'none',
|
||||
stroke: accentColor,
|
||||
strokeOpacity: 0.6,
|
||||
strokeWidth: showSelectedChrome ? 1.3 : 0.9,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinejoin: 'round',
|
||||
},
|
||||
// Center mullion.
|
||||
{
|
||||
kind: 'line',
|
||||
x1: mullionStart[0],
|
||||
y1: mullionStart[1],
|
||||
x2: mullionEnd[0],
|
||||
y2: mullionEnd[1],
|
||||
stroke: accentColor,
|
||||
strokeWidth: showSelectedChrome ? 1.6 : 1.1,
|
||||
strokeOpacity: 0.85,
|
||||
strokeLinecap: 'round',
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
},
|
||||
]
|
||||
|
||||
// Move handle — orange dot at the window center. Only when selected.
|
||||
if (isSelected) {
|
||||
children.push({
|
||||
kind: 'move-handle',
|
||||
point: [cx, cz],
|
||||
})
|
||||
}
|
||||
|
||||
// Placement-measurement dimensions when actively moving — same
|
||||
// contract as door (see `nodes/src/door/floorplan.ts`).
|
||||
if (view?.moving) {
|
||||
for (const dim of buildOpeningPlacementDimensions(node, ctx)) {
|
||||
children.push(dim)
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
WindowNode,
|
||||
} 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 { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef_44_44,
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* Move/duplicate tool for WindowNodes — wall-only, same guardrails as WindowTool.
|
||||
*
|
||||
* Move mode (metadata.isNew falsy):
|
||||
* Adopts the existing window, pauses temporal. On commit: restores original state
|
||||
* (clean undo baseline) then resumes + updateNode (undo reverts to original position).
|
||||
* On cancel: restores original state.
|
||||
*
|
||||
* Duplicate mode (metadata.isNew = true):
|
||||
* The node is a freshly created transient copy. On commit: deletes transient + resumes
|
||||
* + createNode (undo removes the new window entirely). On cancel: deletes the node.
|
||||
*/
|
||||
const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode }) => {
|
||||
const cursorGroupRef = useRef<Group>(null!)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const meta =
|
||||
typeof movingWindowNode.metadata === 'object' && movingWindowNode.metadata !== null
|
||||
? (movingWindowNode.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
const isNew = !!meta.isNew
|
||||
|
||||
// Save original state (only used in move mode)
|
||||
const original = {
|
||||
position: [...movingWindowNode.position] as [number, number, number],
|
||||
rotation: [...movingWindowNode.rotation] as [number, number, number],
|
||||
side: movingWindowNode.side,
|
||||
parentId: movingWindowNode.parentId,
|
||||
wallId: movingWindowNode.wallId,
|
||||
metadata: movingWindowNode.metadata,
|
||||
}
|
||||
|
||||
if (!isNew) {
|
||||
// Move mode: mark the existing window as transient so it hides while being repositioned
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
metadata: { ...meta, isTransient: true },
|
||||
})
|
||||
}
|
||||
|
||||
let currentWallId: string | null = movingWindowNode.parentId
|
||||
|
||||
const markWallDirty = (wallId: string | null) => {
|
||||
if (wallId) useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
||||
}
|
||||
|
||||
const getLevelId = () => useViewer.getState().selection.levelId
|
||||
const getLevelYOffset = () => {
|
||||
const id = getLevelId()
|
||||
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||
}
|
||||
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||
spatialGridManager.getSlabElevationForWall(
|
||||
wallEvent.node.parentId ?? '',
|
||||
wallEvent.node.start,
|
||||
wallEvent.node.end,
|
||||
)
|
||||
|
||||
const hideCursor = () => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
}
|
||||
|
||||
const updateCursor = (
|
||||
worldPosition: [number, number, number],
|
||||
cursorRotationY: number,
|
||||
valid: boolean,
|
||||
) => {
|
||||
const group = cursorGroupRef.current
|
||||
if (!group) return
|
||||
group.visible = true
|
||||
group.position.set(...worldPosition)
|
||||
group.rotation.y = cursorRotationY
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
hideCursor()
|
||||
return
|
||||
}
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
const prevWallId = currentWallId
|
||||
currentWallId = event.node.id
|
||||
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
useLiveTransforms.getState().set(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: itemRotation,
|
||||
})
|
||||
|
||||
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
|
||||
markWallDirty(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
hideCursor()
|
||||
return
|
||||
}
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
if (currentWallId !== event.node.id) {
|
||||
// Wall changed mid-move: must updateNode to reparent
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
markWallDirty(currentWallId)
|
||||
currentWallId = event.node.id
|
||||
} else {
|
||||
// Same wall: update Three.js mesh directly to avoid store churn
|
||||
// collectCutoutBrushes reads cutoutMesh.matrixWorld, not scene store positions
|
||||
const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId)
|
||||
if (windowMesh) {
|
||||
windowMesh.position.set(clampedX, clampedY, 0)
|
||||
windowMesh.rotation.set(0, itemRotation, 0)
|
||||
windowMesh.updateMatrixWorld(true)
|
||||
}
|
||||
}
|
||||
useLiveTransforms.getState().set(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: itemRotation,
|
||||
})
|
||||
markWallDirty(event.node.id)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) return
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
)
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.id,
|
||||
)
|
||||
if (!valid) return
|
||||
|
||||
let placedId: string
|
||||
|
||||
if (isNew) {
|
||||
// Duplicate mode: delete transient + resume + createNode
|
||||
// Undo will remove the newly created node entirely
|
||||
useScene.getState().deleteNode(movingWindowNode.id)
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
const node = WindowNode.parse({
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
width: movingWindowNode.width,
|
||||
height: movingWindowNode.height,
|
||||
windowType: movingWindowNode.windowType,
|
||||
operationState: movingWindowNode.operationState,
|
||||
awningDirection: movingWindowNode.awningDirection,
|
||||
casementStyle: movingWindowNode.casementStyle,
|
||||
hingesSide: movingWindowNode.hingesSide,
|
||||
frameThickness: movingWindowNode.frameThickness,
|
||||
frameDepth: movingWindowNode.frameDepth,
|
||||
columnRatios: movingWindowNode.columnRatios,
|
||||
rowRatios: movingWindowNode.rowRatios,
|
||||
columnDividerThickness: movingWindowNode.columnDividerThickness,
|
||||
rowDividerThickness: movingWindowNode.rowDividerThickness,
|
||||
sill: movingWindowNode.sill,
|
||||
sillDepth: movingWindowNode.sillDepth,
|
||||
sillThickness: movingWindowNode.sillThickness,
|
||||
})
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
placedId = node.id
|
||||
} else {
|
||||
// Move mode: restore original (clean baseline) + resume + updateNode
|
||||
// Undo will revert to the original position
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
if (original.parentId && original.parentId !== event.node.id) {
|
||||
markWallDirty(original.parentId)
|
||||
}
|
||||
placedId = movingWindowNode.id
|
||||
}
|
||||
|
||||
markWallDirty(event.node.id)
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
hideCursor()
|
||||
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
||||
exitMoveMode()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallLeave = () => {
|
||||
hideCursor()
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
if (isNew) return // No original to restore for duplicates
|
||||
// Move mode: restore to original position while off-wall
|
||||
if (currentWallId && currentWallId !== original.parentId) {
|
||||
markWallDirty(currentWallId)
|
||||
}
|
||||
currentWallId = original.parentId
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
if (isNew) {
|
||||
useScene.getState().deleteNode(movingWindowNode.id)
|
||||
if (currentWallId) markWallDirty(currentWallId)
|
||||
} else {
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
hideCursor()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
emitter.on('wall:leave', onWallLeave)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
|
||||
const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
|
||||
| WindowNode
|
||||
| undefined
|
||||
const currentMeta = current?.metadata as Record<string, unknown> | undefined
|
||||
if (currentMeta?.isTransient) {
|
||||
if (isNew) {
|
||||
useScene.getState().deleteNode(movingWindowNode.id)
|
||||
if (currentWallId) markWallDirty(currentWallId)
|
||||
} else {
|
||||
useScene.getState().updateNode(movingWindowNode.id, {
|
||||
position: original.position,
|
||||
rotation: original.rotation,
|
||||
side: original.side,
|
||||
parentId: original.parentId,
|
||||
wallId: original.wallId,
|
||||
metadata: original.metadata,
|
||||
})
|
||||
if (original.parentId) markWallDirty(original.parentId)
|
||||
}
|
||||
}
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('wall:enter', onWallEnter)
|
||||
emitter.off('wall:move', onWallMove)
|
||||
emitter.off('wall:click', onWallClick)
|
||||
emitter.off('wall:leave', onWallLeave)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [movingWindowNode, exitMoveMode])
|
||||
|
||||
const edgesGeo = useMemo(() => {
|
||||
const boxGeo = new BoxGeometry(
|
||||
movingWindowNode.width,
|
||||
movingWindowNode.height,
|
||||
movingWindowNode.frameDepth ?? 0.07,
|
||||
)
|
||||
const geo = new EdgesGeometry(boxGeo)
|
||||
boxGeo.dispose()
|
||||
return geo
|
||||
}, [movingWindowNode])
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments geometry={edgesGeo} layers={EDITOR_LAYER} material={edgeMaterial} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default MoveWindowTool
|
||||
@@ -0,0 +1,990 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
useInteractive,
|
||||
useScene,
|
||||
WindowNode,
|
||||
} 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, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
function isSameWindowValue(current: unknown, next: unknown): boolean {
|
||||
if (typeof current === 'number' && typeof next === 'number') {
|
||||
return Math.abs(current - next) < 1e-6
|
||||
}
|
||||
|
||||
if (Array.isArray(current) && Array.isArray(next)) {
|
||||
return (
|
||||
current.length === next.length &&
|
||||
current.every((value, index) => isSameWindowValue(value, next[index]))
|
||||
)
|
||||
}
|
||||
|
||||
return Object.is(current, next)
|
||||
}
|
||||
|
||||
function getMaxSharedWindowRadius(width: number, height: number) {
|
||||
return Math.max(0, Math.min(width / 2, height / 2))
|
||||
}
|
||||
|
||||
function normalizeWindowCornerRadii(
|
||||
radii: [number, number, number, number],
|
||||
width: number,
|
||||
height: number,
|
||||
): [number, number, number, number] {
|
||||
const next = radii.map((radius) => Math.max(radius, 0)) as [number, number, number, number]
|
||||
const scale = Math.min(
|
||||
1,
|
||||
Math.max(width, 0) / Math.max(next[0] + next[1], 1e-6),
|
||||
Math.max(width, 0) / Math.max(next[3] + next[2], 1e-6),
|
||||
Math.max(height, 0) / Math.max(next[0] + next[3], 1e-6),
|
||||
Math.max(height, 0) / Math.max(next[1] + next[2], 1e-6),
|
||||
)
|
||||
|
||||
if (scale >= 1) return next
|
||||
|
||||
return next.map((radius) => radius * scale) as [number, number, number, number]
|
||||
}
|
||||
|
||||
function isSameRadiusTuple(
|
||||
current: [number, number, number, number],
|
||||
next: [number, number, number, number],
|
||||
) {
|
||||
return current.every((value, index) => Math.abs(value - (next[index] ?? 0)) < 1e-6)
|
||||
}
|
||||
|
||||
const windowTypeOptions: Array<{ label: string; value: WindowNode['windowType'] }> = [
|
||||
{ label: 'Fixed', value: 'fixed' },
|
||||
{ label: 'Sliding', value: 'sliding' },
|
||||
{ label: 'Casement', value: 'casement' },
|
||||
{ label: 'Awning', value: 'awning' },
|
||||
{ label: 'Single Hung', value: 'single-hung' },
|
||||
{ label: 'Double Hung', value: 'double-hung' },
|
||||
{ label: 'Bay', value: 'bay' },
|
||||
{ label: 'Bow', value: 'bow' },
|
||||
{ label: 'Louvered', value: 'louvered' },
|
||||
]
|
||||
|
||||
const shapedWindowTypes = new Set<WindowNode['windowType']>([
|
||||
'fixed',
|
||||
'casement',
|
||||
'awning',
|
||||
'hopper',
|
||||
'louvered',
|
||||
])
|
||||
|
||||
const silllessWindowTypes = new Set<WindowNode['windowType']>(['bay', 'bow'])
|
||||
|
||||
export default function WindowPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const previewRef = useRef<{
|
||||
id: AnyNodeId
|
||||
key: keyof WindowNode
|
||||
value: unknown
|
||||
} | null>(null)
|
||||
|
||||
const adapter = usePresetsAdapter()
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WindowNode | undefined) : undefined,
|
||||
)
|
||||
|
||||
// Panel slider-drag fix recipe (plans/editor-node-registry.md). Without
|
||||
// it, the 15+ SliderControls in this panel would loop on drag.
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<WindowNode>) => {
|
||||
if (!selectedId) return
|
||||
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
|
||||
if (liveNode?.type !== 'window') return
|
||||
|
||||
const hasChange = Object.entries(updates).some(([key, value]) => {
|
||||
const currentValue = liveNode[key as keyof WindowNode]
|
||||
return !isSameWindowValue(currentValue, value)
|
||||
})
|
||||
if (!hasChange) return
|
||||
|
||||
useScene.getState().updateNode(selectedId as AnyNode['id'], updates)
|
||||
const scene = useScene.getState()
|
||||
scene.dirtyNodes.add(selectedId as AnyNodeId)
|
||||
if (liveNode.parentId) scene.dirtyNodes.add(liveNode.parentId as AnyNodeId)
|
||||
},
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const previewWindowUpdate = useCallback(
|
||||
<K extends keyof WindowNode>(key: K, value: WindowNode[K]) => {
|
||||
if (!selectedId) return
|
||||
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
|
||||
if (liveNode?.type !== 'window') return
|
||||
|
||||
if (
|
||||
!(
|
||||
previewRef.current &&
|
||||
previewRef.current.id === selectedId &&
|
||||
previewRef.current.key === key
|
||||
)
|
||||
) {
|
||||
previewRef.current = {
|
||||
id: selectedId as AnyNodeId,
|
||||
key,
|
||||
value: liveNode[key],
|
||||
}
|
||||
}
|
||||
|
||||
if (isSameWindowValue(liveNode[key], value)) return
|
||||
|
||||
;(liveNode as WindowNode)[key] = value
|
||||
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
|
||||
},
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const commitWindowPreview = useCallback(
|
||||
<K extends keyof WindowNode>(key: K, value: WindowNode[K]) => {
|
||||
if (!selectedId) return
|
||||
|
||||
const scene = useScene.getState()
|
||||
const liveNode = scene.nodes[selectedId as AnyNodeId]
|
||||
const preview = previewRef.current
|
||||
if (liveNode?.type === 'window' && preview?.id === selectedId && preview.key === key) {
|
||||
;(liveNode as WindowNode)[key] = preview.value as WindowNode[K]
|
||||
scene.dirtyNodes.add(selectedId as AnyNodeId)
|
||||
}
|
||||
previewRef.current = null
|
||||
|
||||
useScene.getState().updateNode(
|
||||
selectedId as AnyNode['id'],
|
||||
{
|
||||
[key]: value,
|
||||
} as Partial<WindowNode>,
|
||||
)
|
||||
scene.dirtyNodes.add(selectedId as AnyNodeId)
|
||||
},
|
||||
[selectedId],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleFlip = useCallback(() => {
|
||||
if (!node) return
|
||||
handleUpdate({
|
||||
side: node.side === 'front' ? 'back' : 'front',
|
||||
rotation: [node.rotation[0], node.rotation[1] + Math.PI, node.rotation[2]],
|
||||
})
|
||||
}, [node, handleUpdate])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
triggerSFX('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!(selectedId && node)) return
|
||||
triggerSFX('sfx:item-delete')
|
||||
deleteNode(selectedId as AnyNode['id'])
|
||||
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [selectedId, node, deleteNode, setSelection])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node?.parentId) return
|
||||
triggerSFX('sfx:item-pick')
|
||||
useScene.temporal.getState().pause()
|
||||
const duplicate = WindowNode.parse({
|
||||
position: [...node.position] as [number, number, number],
|
||||
rotation: [...node.rotation] as [number, number, number],
|
||||
side: node.side,
|
||||
wallId: node.wallId,
|
||||
parentId: node.parentId,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
windowType: node.windowType,
|
||||
operationState: node.operationState,
|
||||
awningDirection: node.awningDirection,
|
||||
casementStyle: node.casementStyle,
|
||||
hingesSide: node.hingesSide,
|
||||
frameThickness: node.frameThickness,
|
||||
frameDepth: node.frameDepth,
|
||||
openingKind: node.openingKind,
|
||||
openingShape: node.openingShape,
|
||||
openingRadiusMode: node.openingRadiusMode ?? 'all',
|
||||
openingCornerRadii: [...(node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15])],
|
||||
cornerRadius: node.cornerRadius,
|
||||
archHeight: node.archHeight,
|
||||
openingRevealRadius: node.openingRevealRadius,
|
||||
columnRatios: [...node.columnRatios],
|
||||
rowRatios: [...node.rowRatios],
|
||||
columnDividerThickness: node.columnDividerThickness,
|
||||
rowDividerThickness: node.rowDividerThickness,
|
||||
sill: node.sill,
|
||||
sillDepth: node.sillDepth,
|
||||
sillThickness: node.sillThickness,
|
||||
metadata: { isNew: true },
|
||||
})
|
||||
useScene.getState().createNode(duplicate, node.parentId as AnyNodeId)
|
||||
setMovingNode(duplicate)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const getWindowPresetData = useCallback(() => {
|
||||
if (!node) return null
|
||||
return {
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
windowType: node.windowType,
|
||||
operationState: node.operationState,
|
||||
awningDirection: node.awningDirection,
|
||||
casementStyle: node.casementStyle,
|
||||
hingesSide: node.hingesSide,
|
||||
frameThickness: node.frameThickness,
|
||||
frameDepth: node.frameDepth,
|
||||
openingKind: node.openingKind,
|
||||
openingShape: node.openingShape,
|
||||
openingRadiusMode: node.openingRadiusMode ?? 'all',
|
||||
openingCornerRadii: node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15],
|
||||
cornerRadius: node.cornerRadius,
|
||||
archHeight: node.archHeight,
|
||||
openingRevealRadius: node.openingRevealRadius,
|
||||
columnRatios: node.columnRatios,
|
||||
rowRatios: node.rowRatios,
|
||||
columnDividerThickness: node.columnDividerThickness,
|
||||
rowDividerThickness: node.rowDividerThickness,
|
||||
sill: node.sill,
|
||||
sillDepth: node.sillDepth,
|
||||
sillThickness: node.sillThickness,
|
||||
}
|
||||
}, [node])
|
||||
|
||||
const handleSavePreset = useCallback(
|
||||
async (name: string) => {
|
||||
const data = getWindowPresetData()
|
||||
if (!(data && selectedId)) return
|
||||
const presetId = await adapter.savePreset('window', name, data)
|
||||
if (presetId) emitter.emit('preset:generate-thumbnail', { presetId, nodeId: selectedId })
|
||||
},
|
||||
[getWindowPresetData, selectedId, adapter],
|
||||
)
|
||||
|
||||
const handleOverwritePreset = useCallback(
|
||||
async (id: string) => {
|
||||
const data = getWindowPresetData()
|
||||
if (!(data && selectedId)) return
|
||||
await adapter.overwritePreset('window', id, data)
|
||||
emitter.emit('preset:generate-thumbnail', { presetId: id, nodeId: selectedId })
|
||||
},
|
||||
[getWindowPresetData, selectedId, adapter],
|
||||
)
|
||||
|
||||
const handleApplyPreset = useCallback(
|
||||
(data: Record<string, unknown>) => {
|
||||
handleUpdate(data as Partial<WindowNode>)
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
if (!(node && node.type === 'window' && selectedId)) return null
|
||||
|
||||
const numCols = node.columnRatios.length
|
||||
const numRows = node.rowRatios.length
|
||||
|
||||
const colSum = node.columnRatios.reduce((a, b) => a + b, 0)
|
||||
const rowSum = node.rowRatios.reduce((a, b) => a + b, 0)
|
||||
const normCols = node.columnRatios.map((r) => r / colSum)
|
||||
const normRows = node.rowRatios.map((r) => r / rowSum)
|
||||
const isOpening = node.openingKind === 'opening'
|
||||
const openingShape = node.openingShape ?? 'rectangle'
|
||||
const windowShape =
|
||||
openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
|
||||
const openingRadiusMode = node.openingRadiusMode ?? 'all'
|
||||
const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
|
||||
const cornerRadius = node.cornerRadius ?? 0.15
|
||||
const archHeight = node.archHeight ?? 0.35
|
||||
const openingRevealRadius = node.openingRevealRadius ?? 0.025
|
||||
const maxRoundedRadius = Math.max(0.01, getMaxSharedWindowRadius(node.width, node.height))
|
||||
const displayedWindowType = node.windowType === 'hopper' ? 'awning' : (node.windowType ?? 'fixed')
|
||||
const awningDirection = node.windowType === 'hopper' ? 'down' : (node.awningDirection ?? 'up')
|
||||
const isOperableWindow =
|
||||
node.windowType === 'sliding' ||
|
||||
node.windowType === 'casement' ||
|
||||
node.windowType === 'awning' ||
|
||||
node.windowType === 'hopper' ||
|
||||
node.windowType === 'single-hung' ||
|
||||
node.windowType === 'double-hung' ||
|
||||
node.windowType === 'louvered'
|
||||
const supportsWindowShape = shapedWindowTypes.has(node.windowType ?? 'fixed')
|
||||
const supportsGrid = node.windowType === 'fixed'
|
||||
const supportsSill = !silllessWindowTypes.has(node.windowType)
|
||||
|
||||
const setOperationState = (value: number) => {
|
||||
useInteractive.getState().cancelWindowAnimation(node.id)
|
||||
useInteractive.getState().removeWindowOpenState(node.id)
|
||||
handleUpdate({ operationState: Math.max(0, Math.min(1, value)) })
|
||||
}
|
||||
|
||||
const getDimensionUpdates = (updates: Partial<Pick<WindowNode, 'width' | 'height'>>) => {
|
||||
const nextWidth = updates.width ?? node.width
|
||||
const nextHeight = updates.height ?? node.height
|
||||
const nextUpdates: Partial<WindowNode> = { ...updates }
|
||||
|
||||
if (openingShape === 'rounded') {
|
||||
if (openingRadiusMode === 'individual') {
|
||||
const currentRadii = openingCornerRadii as [number, number, number, number]
|
||||
const nextRadii = normalizeWindowCornerRadii(
|
||||
openingCornerRadii as [number, number, number, number],
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
)
|
||||
if (!isSameRadiusTuple(currentRadii, nextRadii)) {
|
||||
nextUpdates.openingCornerRadii = nextRadii
|
||||
}
|
||||
} else {
|
||||
const nextRadius = Math.min(
|
||||
Math.max(cornerRadius, 0),
|
||||
getMaxSharedWindowRadius(nextWidth, nextHeight),
|
||||
)
|
||||
if (Math.abs(nextRadius - cornerRadius) > 1e-6) {
|
||||
nextUpdates.cornerRadius = nextRadius
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (openingShape === 'arch') {
|
||||
const nextArchHeight = Math.min(Math.max(archHeight, 0.05), Math.max(nextHeight, 0.05))
|
||||
if (Math.abs(nextArchHeight - archHeight) > 1e-6) {
|
||||
nextUpdates.archHeight = nextArchHeight
|
||||
}
|
||||
}
|
||||
|
||||
return nextUpdates
|
||||
}
|
||||
|
||||
const setOpeningCornerRadius = (index: number, value: number, commit = false) => {
|
||||
const next = [...openingCornerRadii] as [number, number, number, number]
|
||||
next[index] = value
|
||||
if (commit) {
|
||||
commitWindowPreview('openingCornerRadii', next)
|
||||
} else {
|
||||
previewWindowUpdate('openingCornerRadii', next)
|
||||
}
|
||||
}
|
||||
|
||||
const setColumnRatio = (index: number, newVal: number) => {
|
||||
const clamped = Math.max(0.05, Math.min(0.95, newVal))
|
||||
const neighborIdx = index < numCols - 1 ? index + 1 : index - 1
|
||||
const delta = clamped - normCols[index]!
|
||||
const neighborVal = Math.max(0.05, normCols[neighborIdx]! - delta)
|
||||
const newRatios = normCols.map((v, i) => {
|
||||
if (i === index) return clamped
|
||||
if (i === neighborIdx) return neighborVal
|
||||
return v
|
||||
})
|
||||
handleUpdate({ columnRatios: newRatios })
|
||||
}
|
||||
|
||||
const setRowRatio = (index: number, newVal: number) => {
|
||||
const clamped = Math.max(0.05, Math.min(0.95, newVal))
|
||||
const neighborIdx = index < numRows - 1 ? index + 1 : index - 1
|
||||
const delta = clamped - normRows[index]!
|
||||
const neighborVal = Math.max(0.05, normRows[neighborIdx]! - delta)
|
||||
const newRatios = normRows.map((v, i) => {
|
||||
if (i === index) return clamped
|
||||
if (i === neighborIdx) return neighborVal
|
||||
return v
|
||||
})
|
||||
handleUpdate({ rowRatios: newRatios })
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/window.png"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Window'}
|
||||
width={320}
|
||||
>
|
||||
{/* Presets strip */}
|
||||
<div className="border-border/30 border-b px-3 pt-2.5 pb-1.5">
|
||||
<PresetsPopover
|
||||
isAuthenticated={adapter.isAuthenticated}
|
||||
onApply={handleApplyPreset}
|
||||
onDelete={(id) => adapter.deletePreset(id)}
|
||||
onFetchPresets={(tab) => adapter.fetchPresets('window', tab)}
|
||||
onOverwrite={handleOverwritePreset}
|
||||
onRename={(id, name) => adapter.renamePreset(id, name)}
|
||||
onSave={handleSavePreset}
|
||||
onToggleCommunity={adapter.togglePresetCommunity}
|
||||
tabs={adapter.tabs}
|
||||
type="window"
|
||||
>
|
||||
<button className="flex w-full items-center gap-2 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 font-medium text-muted-foreground text-xs transition-colors hover:bg-[#3e3e3e] hover:text-foreground">
|
||||
<BookMarked className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>Presets</span>
|
||||
</button>
|
||||
</PresetsPopover>
|
||||
</div>
|
||||
|
||||
<PanelSection title="Type">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
openingKind: value as WindowNode['openingKind'],
|
||||
...(value === 'opening'
|
||||
? {
|
||||
openingShape,
|
||||
openingRadiusMode,
|
||||
openingCornerRadii,
|
||||
cornerRadius,
|
||||
archHeight,
|
||||
openingRevealRadius,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: 'window', label: 'Window' },
|
||||
{ value: 'opening', label: 'Opening' },
|
||||
]}
|
||||
value={node.openingKind ?? 'window'}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{!isOpening && (
|
||||
<PanelSection title="Window Type">
|
||||
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
|
||||
{windowTypeOptions.map((option) => {
|
||||
const isSelected = displayedWindowType === option.value
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-h-12 items-center rounded-lg border px-2.5 text-left text-xs transition-colors',
|
||||
isSelected
|
||||
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
|
||||
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
||||
)}
|
||||
key={option.value}
|
||||
onClick={() =>
|
||||
handleUpdate({
|
||||
windowType: option.value,
|
||||
...(option.value === 'awning' ? { awningDirection } : {}),
|
||||
...(!shapedWindowTypes.has(option.value)
|
||||
? { openingShape: 'rectangle' }
|
||||
: {}),
|
||||
...(silllessWindowTypes.has(option.value) ? { sill: false } : {}),
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate font-medium">{option.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{displayedWindowType === 'awning' && (
|
||||
<div className="mt-2">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
windowType: 'awning',
|
||||
awningDirection: value as WindowNode['awningDirection'],
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: 'up', label: 'Up' },
|
||||
{ value: 'down', label: 'Down' },
|
||||
]}
|
||||
value={awningDirection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{node.windowType === 'casement' && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({ casementStyle: value as WindowNode['casementStyle'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'single', label: 'Single' },
|
||||
{ value: 'french', label: 'French' },
|
||||
]}
|
||||
value={node.casementStyle ?? 'single'}
|
||||
/>
|
||||
{(node.casementStyle ?? 'single') === 'single' && (
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({ hingesSide: value as WindowNode['hingesSide'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'left', label: 'Left' },
|
||||
{ value: 'right', label: 'Right' },
|
||||
]}
|
||||
value={node.hingesSide ?? 'left'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isOperableWindow && (
|
||||
<div className="mt-2">
|
||||
<SliderControl
|
||||
label="Open"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={setOperationState}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
value={Math.round((node.operationState ?? 0) * 100) / 100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label={
|
||||
<>
|
||||
Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
{!isOpening && (
|
||||
<div className="px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
className="w-full"
|
||||
icon={<FlipHorizontal2 className="h-4 w-4" />}
|
||||
label="Flip Side"
|
||||
onClick={handleFlip}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Width"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate(getDimensionUpdates({ width: v }))}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate(getDimensionUpdates({ height: v }))}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{!isOpening && supportsWindowShape && (
|
||||
<PanelSection title="Corner Shape">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
openingShape: value as WindowNode['openingShape'],
|
||||
...(value === 'rounded'
|
||||
? {
|
||||
openingRadiusMode,
|
||||
openingCornerRadii,
|
||||
cornerRadius: Math.min(cornerRadius, maxRoundedRadius),
|
||||
openingRevealRadius,
|
||||
sill: false,
|
||||
}
|
||||
: {}),
|
||||
...(value === 'arch' ? { archHeight } : {}),
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: 'rectangle', label: 'Rect' },
|
||||
{ value: 'rounded', label: 'Rounded' },
|
||||
{ value: 'arch', label: 'Arch' },
|
||||
]}
|
||||
value={windowShape}
|
||||
/>
|
||||
{windowShape === 'rounded' && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'individual', label: 'Individual' },
|
||||
]}
|
||||
value={openingRadiusMode}
|
||||
/>
|
||||
{openingRadiusMode === 'all' ? (
|
||||
<SliderControl
|
||||
label="Corner Radius"
|
||||
max={maxRoundedRadius}
|
||||
min={0}
|
||||
onChange={(value) => previewWindowUpdate('cornerRadius', value)}
|
||||
onCommit={(value) => commitWindowPreview('cornerRadius', value)}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(cornerRadius * 100) / 100}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{[
|
||||
['Top Left', 0],
|
||||
['Top Right', 1],
|
||||
['Bottom Right', 2],
|
||||
['Bottom Left', 3],
|
||||
].map(([label, index]) => (
|
||||
<SliderControl
|
||||
key={label}
|
||||
label={label}
|
||||
max={maxRoundedRadius}
|
||||
min={0}
|
||||
onChange={(value) => setOpeningCornerRadius(index as number, value)}
|
||||
onCommit={(value) => setOpeningCornerRadius(index as number, value, true)}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Reveal Radius"
|
||||
max={0.08}
|
||||
min={0}
|
||||
onChange={(value) => previewWindowUpdate('openingRevealRadius', value)}
|
||||
onCommit={(value) => commitWindowPreview('openingRevealRadius', value)}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(openingRevealRadius * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{windowShape === 'arch' && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Arch Height"
|
||||
max={Math.max(0.05, node.height)}
|
||||
min={0.05}
|
||||
onChange={(value) => handleUpdate({ archHeight: value })}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(archHeight * 100) / 100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{isOpening && (
|
||||
<PanelSection title="Opening Shape">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({ openingShape: value as WindowNode['openingShape'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'rectangle', label: 'Rect' },
|
||||
{ value: 'rounded', label: 'Rounded' },
|
||||
{ value: 'arch', label: 'Arch' },
|
||||
]}
|
||||
value={openingShape}
|
||||
/>
|
||||
{openingShape === 'rounded' && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'individual', label: 'Individual' },
|
||||
]}
|
||||
value={openingRadiusMode}
|
||||
/>
|
||||
{openingRadiusMode === 'all' ? (
|
||||
<SliderControl
|
||||
label="Corner Radius"
|
||||
max={maxRoundedRadius}
|
||||
min={0}
|
||||
onChange={(value) => previewWindowUpdate('cornerRadius', value)}
|
||||
onCommit={(value) => commitWindowPreview('cornerRadius', value)}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(cornerRadius * 100) / 100}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{[
|
||||
['Top Left', 0],
|
||||
['Top Right', 1],
|
||||
['Bottom Right', 2],
|
||||
['Bottom Left', 3],
|
||||
].map(([label, index]) => (
|
||||
<SliderControl
|
||||
key={label}
|
||||
label={label}
|
||||
max={maxRoundedRadius}
|
||||
min={0}
|
||||
onChange={(value) => setOpeningCornerRadius(index as number, value)}
|
||||
onCommit={(value) => setOpeningCornerRadius(index as number, value, true)}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((openingCornerRadii[index as number] ?? 0) * 100) / 100}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Reveal Radius"
|
||||
max={0.08}
|
||||
min={0}
|
||||
onChange={(value) => previewWindowUpdate('openingRevealRadius', value)}
|
||||
onCommit={(value) => commitWindowPreview('openingRevealRadius', value)}
|
||||
precision={3}
|
||||
step={0.005}
|
||||
unit="m"
|
||||
value={Math.round(openingRevealRadius * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{openingShape === 'arch' && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Arch Height"
|
||||
max={Math.max(0.05, node.height)}
|
||||
min={0.05}
|
||||
onChange={(value) => handleUpdate({ archHeight: value })}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(archHeight * 100) / 100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{!isOpening && (
|
||||
<>
|
||||
<PanelSection title="Frame">
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{supportsGrid && (
|
||||
<PanelSection title="Grid">
|
||||
<SliderControl
|
||||
label="Columns"
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={numCols}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rows"
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={numRows}
|
||||
/>
|
||||
|
||||
{numCols > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Col Widths
|
||||
</div>
|
||||
{normCols.map((ratio, i) => (
|
||||
<SliderControl
|
||||
key={`c-${i}`}
|
||||
label={`C${i + 1}`}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setColumnRatio(i, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{numRows > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Row Heights
|
||||
</div>
|
||||
{normRows.map((ratio, i) => (
|
||||
<SliderControl
|
||||
key={`r-${i}`}
|
||||
label={`R${i + 1}`}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setRowRatio(i, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
{supportsSill && (
|
||||
<PanelSection title="Sill">
|
||||
<ToggleControl
|
||||
checked={node.sill}
|
||||
label="Enable Sill"
|
||||
onChange={(checked) => handleUpdate({ sill: checked })}
|
||||
/>
|
||||
{node.sill && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ sillDepth: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.sillDepth * 1000) / 1000}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ sillThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.sillThickness * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="hover:bg-red-500/20"
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -18,4 +18,8 @@ export const windowParametrics: ParametricDescriptor<WindowNode> = {
|
||||
],
|
||||
},
|
||||
],
|
||||
// Stage E — kind-owned panel mounted by <ParametricInspector>. Window
|
||||
// has 15+ controls (sashes, dividers, sill, frame, opening shape)
|
||||
// that don't fit the generic auto-inspector.
|
||||
customPanel: () => import('./panel'),
|
||||
}
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { WindowRenderer } from '@pascal-app/viewer'
|
||||
import { useRegistry, useScene, type WindowNode } from '@pascal-app/core'
|
||||
import { createMaterial, DEFAULT_WINDOW_MATERIAL, useNodeEvents } from '@pascal-app/viewer'
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
|
||||
export const WindowRenderer = ({ node }: { node: WindowNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
|
||||
useRegistry(node.id, 'window', ref)
|
||||
useLayoutEffect(() => {
|
||||
useScene.getState().markDirty(node.id)
|
||||
}, [node.id])
|
||||
const handlers = useNodeEvents(node, 'window')
|
||||
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
|
||||
|
||||
const material = useMemo(() => {
|
||||
const mat = node.material
|
||||
if (!mat) return DEFAULT_WINDOW_MATERIAL
|
||||
return createMaterial(mat)
|
||||
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
|
||||
return (
|
||||
<mesh
|
||||
material={material}
|
||||
position={node.position}
|
||||
ref={ref}
|
||||
rotation={node.rotation}
|
||||
visible={node.visible}
|
||||
{...(isTransient ? {} : handlers)}
|
||||
>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap-export of the legacy `WindowRenderer`. Thin (~36 lines); Phase 5
|
||||
* Stage F inlines it here and deletes the viewer-side file.
|
||||
*/
|
||||
export default WindowRenderer
|
||||
|
||||
@@ -3,17 +3,13 @@
|
||||
import { WindowAnimationSystem, WindowSystem } from '@pascal-app/viewer'
|
||||
|
||||
/**
|
||||
* Registry-driven window system bundle. Same shape as door — two
|
||||
* per-frame systems wrapped together:
|
||||
* Registry-driven window system bundle.
|
||||
*
|
||||
* - **`WindowSystem`** — rebuilds frame / sash / divider / sill /
|
||||
* muntin geometry. Cascades dirty to parent wall for the cutout.
|
||||
* - **`WindowAnimationSystem`** — advances sash/panel open state at
|
||||
* frame priority 2, then marks the window dirty for the geometry
|
||||
* rebuild at priority 3.
|
||||
*
|
||||
* Both wrapped in `<LegacySystem kind="window">` legacy mounts; with
|
||||
* window registered, those short-circuit and this bundle takes over.
|
||||
*/
|
||||
const WindowSystems = () => {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
isCurvedWall,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useScene,
|
||||
type WallEvent,
|
||||
WindowNode,
|
||||
} 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 { clampToWall, hasWallChildOverlap, wallLocalToWorld } from './window-math'
|
||||
|
||||
// Shared edge material — reuse across renders, just toggle color
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
color: 0xef_44_44, // red-500 default (invalid)
|
||||
linewidth: 3,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* Window tool — places WindowNodes on walls only.
|
||||
* Shows a rectangle cursor (green = valid, red = invalid) matching window dimensions.
|
||||
*/
|
||||
const WindowTool: React.FC = () => {
|
||||
const draftRef = useRef<WindowNode | null>(null)
|
||||
const cursorGroupRef = useRef<Group>(null!)
|
||||
const edgesRef = useRef<LineSegments>(null!)
|
||||
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const getLevelId = () => useViewer.getState().selection.levelId
|
||||
const getLevelYOffset = () => {
|
||||
const id = getLevelId()
|
||||
return id ? (sceneRegistry.nodes.get(id as AnyNodeId)?.position.y ?? 0) : 0
|
||||
}
|
||||
const getSlabElevation = (wallEvent: WallEvent) =>
|
||||
spatialGridManager.getSlabElevationForWall(
|
||||
wallEvent.node.parentId ?? '',
|
||||
wallEvent.node.start,
|
||||
wallEvent.node.end,
|
||||
)
|
||||
|
||||
const markWallDirty = (wallId: string) => {
|
||||
useScene.getState().dirtyNodes.add(wallId as AnyNodeId)
|
||||
}
|
||||
|
||||
const destroyDraft = () => {
|
||||
if (!draftRef.current) return
|
||||
const wallId = draftRef.current.parentId
|
||||
useScene.getState().deleteNode(draftRef.current.id)
|
||||
draftRef.current = null
|
||||
// Rebuild wall so it removes the cutout from the deleted draft
|
||||
if (wallId) markWallDirty(wallId)
|
||||
}
|
||||
|
||||
const hideCursor = () => {
|
||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||
}
|
||||
|
||||
const updateCursor = (
|
||||
worldPosition: [number, number, number],
|
||||
cursorRotationY: number,
|
||||
valid: boolean,
|
||||
) => {
|
||||
const group = cursorGroupRef.current
|
||||
if (!group) return
|
||||
group.visible = true
|
||||
group.position.set(...worldPosition)
|
||||
group.rotation.y = cursorRotationY
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
return
|
||||
}
|
||||
const levelId = getLevelId()
|
||||
if (!levelId) return
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== levelId) return
|
||||
|
||||
destroyDraft()
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
|
||||
const width = 1.5
|
||||
const height = 1.5
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
||||
|
||||
const node = WindowNode.parse({
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
metadata: { isTransient: true },
|
||||
})
|
||||
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
draftRef.current = node
|
||||
|
||||
const valid = !hasWallChildOverlap(event.node.id, clampedX, clampedY, width, height, node.id)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallMove = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
return
|
||||
}
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
|
||||
const width = draftRef.current?.width ?? 1.5
|
||||
const height = draftRef.current?.height ?? 1.5
|
||||
|
||||
const { clampedX, clampedY } = clampToWall(event.node, localX, localY, width, height)
|
||||
|
||||
if (draftRef.current) {
|
||||
if (event.node.id !== draftRef.current.parentId) {
|
||||
// Wall changed without enter/leave: must updateNode to reparent
|
||||
useScene.getState().updateNode(draftRef.current.id, {
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
parentId: event.node.id,
|
||||
wallId: event.node.id,
|
||||
})
|
||||
} else {
|
||||
// Same wall: update Three.js mesh directly to avoid store churn
|
||||
const draftMesh = sceneRegistry.nodes.get(draftRef.current.id as AnyNodeId)
|
||||
if (draftMesh) {
|
||||
draftMesh.position.set(clampedX, clampedY, 0)
|
||||
draftMesh.rotation.set(0, itemRotation, 0)
|
||||
draftMesh.updateMatrixWorld(true)
|
||||
}
|
||||
markWallDirty(event.node.id)
|
||||
}
|
||||
}
|
||||
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
width,
|
||||
height,
|
||||
draftRef.current?.id,
|
||||
)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
event.node,
|
||||
clampedX,
|
||||
clampedY,
|
||||
getLevelYOffset(),
|
||||
getSlabElevation(event),
|
||||
),
|
||||
cursorRotation,
|
||||
valid,
|
||||
)
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallClick = (event: WallEvent) => {
|
||||
if (!draftRef.current) return
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) return
|
||||
// Only interact with walls on the current level
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const localY = snapToHalf(event.localPosition[1])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
event.node,
|
||||
localX,
|
||||
localY,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
)
|
||||
const valid = !hasWallChildOverlap(
|
||||
event.node.id,
|
||||
clampedX,
|
||||
clampedY,
|
||||
draftRef.current.width,
|
||||
draftRef.current.height,
|
||||
draftRef.current.id,
|
||||
)
|
||||
if (!valid) return
|
||||
|
||||
const draft = draftRef.current
|
||||
draftRef.current = null
|
||||
|
||||
// Delete transient draft (paused, invisible to undo)
|
||||
useScene.getState().deleteNode(draft.id)
|
||||
|
||||
// Resume → create permanent node (single undoable action)
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
const levelId = getLevelId()
|
||||
const state = useScene.getState()
|
||||
const windowCount = Object.values(state.nodes).filter((n) => {
|
||||
if (n.type !== 'window') return false
|
||||
const wall = n.parentId ? state.nodes[n.parentId as AnyNodeId] : undefined
|
||||
return wall?.parentId === levelId
|
||||
}).length
|
||||
const name = `Window ${windowCount + 1}`
|
||||
|
||||
const node = WindowNode.parse({
|
||||
name,
|
||||
position: [clampedX, clampedY, 0],
|
||||
rotation: [0, itemRotation, 0],
|
||||
side,
|
||||
wallId: event.node.id,
|
||||
parentId: event.node.id,
|
||||
width: draft.width,
|
||||
height: draft.height,
|
||||
windowType: draft.windowType,
|
||||
operationState: draft.operationState,
|
||||
awningDirection: draft.awningDirection,
|
||||
casementStyle: draft.casementStyle,
|
||||
hingesSide: draft.hingesSide,
|
||||
frameThickness: draft.frameThickness,
|
||||
frameDepth: draft.frameDepth,
|
||||
columnRatios: draft.columnRatios,
|
||||
rowRatios: draft.rowRatios,
|
||||
columnDividerThickness: draft.columnDividerThickness,
|
||||
rowDividerThickness: draft.rowDividerThickness,
|
||||
sill: draft.sill,
|
||||
sillDepth: draft.sillDepth,
|
||||
sillThickness: draft.sillThickness,
|
||||
})
|
||||
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().pause()
|
||||
triggerSFX('sfx:item-place')
|
||||
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
const onWallLeave = () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
}
|
||||
|
||||
emitter.on('wall:enter', onWallEnter)
|
||||
emitter.on('wall:move', onWallMove)
|
||||
emitter.on('wall:click', onWallClick)
|
||||
emitter.on('wall:leave', onWallLeave)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
|
||||
return () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
useScene.temporal.getState().resume()
|
||||
emitter.off('wall:enter', onWallEnter)
|
||||
emitter.off('wall:move', onWallMove)
|
||||
emitter.off('wall:click', onWallClick)
|
||||
emitter.off('wall:leave', onWallLeave)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cursor geometry: window outline rectangle (width × height × frameDepth)
|
||||
const boxGeo = new BoxGeometry(1.5, 1.5, 0.07)
|
||||
const edgesGeo = new EdgesGeometry(boxGeo)
|
||||
boxGeo.dispose()
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef} visible={false}>
|
||||
<lineSegments
|
||||
geometry={edgesGeo}
|
||||
layers={EDITOR_LAYER}
|
||||
material={edgeMaterial}
|
||||
ref={edgesRef}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default WindowTool
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
/**
|
||||
* Converts wall-local (X along wall, Y = height above wall base) to world XYZ.
|
||||
* Wall XZ uses level-local coordinates (levels only offset in Y, not XZ).
|
||||
* Pass levelYOffset (the level group's current world Y) and slabElevation (the
|
||||
* wall mesh's Y within the level group) so the cursor lands at the correct world
|
||||
* height — matching how WallSystem positions the wall mesh at slabElevation.
|
||||
*/
|
||||
export function wallLocalToWorld(
|
||||
wallNode: WallNode,
|
||||
localX: number,
|
||||
localY: number,
|
||||
levelYOffset = 0,
|
||||
slabElevation = 0,
|
||||
): [number, number, number] {
|
||||
const wallAngle = Math.atan2(
|
||||
wallNode.end[1] - wallNode.start[1],
|
||||
wallNode.end[0] - wallNode.start[0],
|
||||
)
|
||||
return [
|
||||
wallNode.start[0] + localX * Math.cos(wallAngle),
|
||||
slabElevation + localY + levelYOffset,
|
||||
wallNode.start[1] + localX * Math.sin(wallAngle),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps window center position so it stays fully within wall bounds.
|
||||
*/
|
||||
export function clampToWall(
|
||||
wallNode: WallNode,
|
||||
localX: number,
|
||||
localY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): { clampedX: number; clampedY: number } {
|
||||
const dx = wallNode.end[0] - wallNode.start[0]
|
||||
const dz = wallNode.end[1] - wallNode.start[1]
|
||||
const wallLength = Math.sqrt(dx * dx + dz * dz)
|
||||
const wallHeight = wallNode.height ?? 2.5
|
||||
|
||||
const clampedX = Math.max(width / 2, Math.min(wallLength - width / 2, localX))
|
||||
const clampedY = Math.max(height / 2, Math.min(wallHeight - height / 2, localY))
|
||||
return { clampedX, clampedY }
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly checks the wall's children for bounding-box overlap with a proposed window.
|
||||
* Works for both `item` type (position[1] = bottom) and `window` type (position[1] = center).
|
||||
* The spatial grid only tracks `item` nodes, so windows must be checked this way.
|
||||
* Reads the wall's latest children from the store (not the event node) to avoid stale data.
|
||||
*/
|
||||
export function hasWallChildOverlap(
|
||||
wallId: string,
|
||||
clampedX: number,
|
||||
clampedY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
ignoreId?: string,
|
||||
): boolean {
|
||||
const nodes = useScene.getState().nodes
|
||||
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
|
||||
if (!wallNode) return true // Block if wall not found
|
||||
const halfW = width / 2
|
||||
const halfH = height / 2
|
||||
const newBottom = clampedY - halfH
|
||||
const newTop = clampedY + halfH
|
||||
const newLeft = clampedX - halfW
|
||||
const newRight = clampedX + halfW
|
||||
|
||||
for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
|
||||
if (childId === ignoreId) continue
|
||||
const child = nodes[childId as AnyNodeId]
|
||||
if (!child) continue
|
||||
|
||||
let childLeft: number, childRight: number, childBottom: number, childTop: number
|
||||
|
||||
if (child.type === 'item') {
|
||||
const item = child as ItemNode
|
||||
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
|
||||
const [w, h] = getScaledDimensions(item)
|
||||
childLeft = item.position[0] - w / 2
|
||||
childRight = item.position[0] + w / 2
|
||||
childBottom = item.position[1] // items store bottom Y
|
||||
childTop = item.position[1] + h
|
||||
} else if (child.type === 'window') {
|
||||
const win = child as WindowNode
|
||||
childLeft = win.position[0] - win.width / 2
|
||||
childRight = win.position[0] + win.width / 2
|
||||
childBottom = win.position[1] - win.height / 2 // windows store center Y
|
||||
childTop = win.position[1] + win.height / 2
|
||||
} else if (child.type === 'door') {
|
||||
const door = child as DoorNode
|
||||
childLeft = door.position[0] - door.width / 2
|
||||
childRight = door.position[0] + door.width / 2
|
||||
childBottom = door.position[1] - door.height / 2 // doors store center Y
|
||||
childTop = door.position[1] + door.height / 2
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
const xOverlap = newLeft < childRight && newRight > childLeft
|
||||
const yOverlap = newBottom < childTop && newTop > childBottom
|
||||
if (xOverlap && yOverlap) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user