Refactor editor selection and viewer integration

This commit is contained in:
sudhir
2026-05-01 13:54:23 +05:30
parent 09bfb0b484
commit b3f7957076
10 changed files with 1242 additions and 249 deletions
+2
View File
@@ -35,6 +35,8 @@ export const DoorNode = BaseNode.extend({
// Opening mode // Opening mode
openingKind: z.enum(['door', 'opening']).default('door'), openingKind: z.enum(['door', 'opening']).default('door'),
openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'), openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'),
openingRadiusMode: z.enum(['all', 'individual']).default('all'),
openingTopRadii: z.tuple([z.number(), z.number()]).default([0.15, 0.15]),
cornerRadius: z.number().min(0).default(0.15), cornerRadius: z.number().min(0).default(0.15),
archHeight: z.number().min(0).default(0.45), archHeight: z.number().min(0).default(0.45),
openingRevealRadius: z.number().min(0).default(0.025), openingRevealRadius: z.number().min(0).default(0.025),
+11
View File
@@ -19,6 +19,17 @@ export const WindowNode = BaseNode.extend({
width: z.number().default(1.5), width: z.number().default(1.5),
height: z.number().default(1.5), height: z.number().default(1.5),
// Opening mode - when set to "opening", the window is only a shaped cutout
openingKind: z.enum(['window', 'opening']).default('window'),
openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'),
openingRadiusMode: z.enum(['all', 'individual']).default('all'),
openingCornerRadii: z
.tuple([z.number(), z.number(), z.number(), z.number()])
.default([0.15, 0.15, 0.15, 0.15]),
cornerRadius: z.number().default(0.15),
archHeight: z.number().default(0.35),
openingRevealRadius: z.number().default(0.025),
// Frame // Frame
frameThickness: z.number().default(0.05), frameThickness: z.number().default(0.05),
frameDepth: z.number().default(0.07), frameDepth: z.number().default(0.07),
+140 -28
View File
@@ -5,7 +5,7 @@ import { computeBoundsTree } from 'three-mesh-bvh'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager' import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type { AnyNode, AnyNodeId, DoorNode, WallNode } from '../../schema' import type { AnyNode, AnyNodeId, DoorNode, WallNode, WindowNode } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve' import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint' import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
@@ -546,8 +546,11 @@ function collectCutoutBrushes(
for (const child of childrenNodes) { for (const child of childrenNodes) {
if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue
if (child.type === 'door' && child.openingKind === 'opening') { if (
brushes.push(createDoorOpeningCutoutBrush(child, wallThickness)) (child.type === 'door' && child.openingKind === 'opening') ||
(child.type === 'window' && child.openingKind === 'opening')
) {
brushes.push(createShapedOpeningCutoutBrush(child, wallThickness))
continue continue
} }
@@ -606,15 +609,23 @@ function collectCutoutBrushes(
return brushes return brushes
} }
function createDoorOpeningCutoutBrush(door: DoorNode, wallThickness: number): Brush { type ShapedOpeningNode = DoorNode | WindowNode
const shape = createDoorOpeningCutoutShape(door) type CornerRadii = {
topLeft: number
topRight: number
bottomRight: number
bottomLeft: number
}
function createShapedOpeningCutoutBrush(opening: ShapedOpeningNode, wallThickness: number): Brush {
const shape = createShapedOpeningCutoutShape(opening)
const depth = wallThickness * 2 const depth = wallThickness * 2
const bevelSize = const bevelSize =
door.openingShape === 'rounded' opening.openingShape === 'rounded'
? Math.min( ? Math.min(
Math.max(door.openingRevealRadius ?? 0.025, 0), Math.max(opening.openingRevealRadius ?? 0.025, 0),
Math.max(wallThickness * 0.45, 0.001), Math.max(wallThickness * 0.45, 0.001),
Math.max((door.cornerRadius ?? 0.15) * 0.45, 0.001), Math.max((opening.cornerRadius ?? 0.15) * 0.45, 0.001),
) )
: 0 : 0
const geometry = new THREE.ExtrudeGeometry(shape, { const geometry = new THREE.ExtrudeGeometry(shape, {
@@ -633,19 +644,19 @@ function createDoorOpeningCutoutBrush(door: DoorNode, wallThickness: number): Br
return new Brush(geometry) return new Brush(geometry)
} }
function createDoorOpeningCutoutShape(door: DoorNode): THREE.Shape { function createShapedOpeningCutoutShape(opening: ShapedOpeningNode): THREE.Shape {
const halfWidth = door.width / 2 const halfWidth = opening.width / 2
const bottom = door.position[1] - door.height / 2 const bottom = opening.position[1] - opening.height / 2
const top = door.position[1] + door.height / 2 const top = opening.position[1] + opening.height / 2
const centerX = door.position[0] const centerX = opening.position[0]
const left = centerX - halfWidth const left = centerX - halfWidth
const right = centerX + halfWidth const right = centerX + halfWidth
const width = Math.max(door.width, 1e-6) const width = Math.max(opening.width, 1e-6)
const height = Math.max(door.height, 1e-6) const height = Math.max(opening.height, 1e-6)
const shape = new THREE.Shape() const shape = new THREE.Shape()
if (door.openingShape === 'arch') { if (opening.openingShape === 'arch') {
const archHeight = Math.min(Math.max(door.archHeight ?? width / 2, 0.01), height) const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height)
const springY = top - archHeight const springY = top - archHeight
shape.moveTo(left, bottom) shape.moveTo(left, bottom)
@@ -657,17 +668,9 @@ function createDoorOpeningCutoutShape(door: DoorNode): THREE.Shape {
return shape return shape
} }
if (door.openingShape === 'rounded') { if (opening.openingShape === 'rounded') {
const radius = Math.min(Math.max(door.cornerRadius ?? 0.15, 0), width / 2, height) const radii = getRoundedOpeningRadii(opening, width, height)
applyRoundedOpeningShape(shape, left, right, bottom, top, radii)
shape.moveTo(left, bottom)
shape.lineTo(right, bottom)
shape.lineTo(right, top - radius)
shape.absarc(right - radius, top - radius, radius, 0, Math.PI / 2, false)
shape.lineTo(left + radius, top)
shape.absarc(left + radius, top - radius, radius, Math.PI / 2, Math.PI, false)
shape.lineTo(left, bottom)
shape.closePath()
return shape return shape
} }
@@ -678,3 +681,112 @@ function createDoorOpeningCutoutShape(door: DoorNode): THREE.Shape {
shape.closePath() shape.closePath()
return shape return shape
} }
function getRoundedOpeningRadii(
opening: ShapedOpeningNode,
width: number,
height: number,
): CornerRadii {
if (opening.type !== 'window') {
if (opening.openingRadiusMode === 'individual') {
const [topLeft = 0, topRight = 0] = opening.openingTopRadii ?? [0.15, 0.15]
return normalizeCornerRadii(
{
topLeft: Math.max(topLeft, 0),
topRight: Math.max(topRight, 0),
bottomRight: 0,
bottomLeft: 0,
},
width,
height,
)
}
const maxRadius = Math.min(width / 2, height)
const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius)
return { topLeft: radius, topRight: radius, bottomRight: 0, bottomLeft: 0 }
}
if (opening.openingRadiusMode === 'individual') {
const [topLeft = 0, topRight = 0, bottomRight = 0, bottomLeft = 0] =
opening.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
return normalizeCornerRadii(
{
topLeft: Math.max(topLeft, 0),
topRight: Math.max(topRight, 0),
bottomRight: Math.max(bottomRight, 0),
bottomLeft: Math.max(bottomLeft, 0),
},
width,
height,
)
}
const maxRadius = Math.min(width / 2, height / 2)
const radius = Math.min(Math.max(opening.cornerRadius ?? 0.15, 0), maxRadius)
return { topLeft: radius, topRight: radius, bottomRight: radius, bottomLeft: radius }
}
function normalizeCornerRadii(radii: CornerRadii, width: number, height: number): CornerRadii {
const next = { ...radii }
const maxScale = Math.min(
1,
width / Math.max(next.topLeft + next.topRight, 1e-6),
width / Math.max(next.bottomLeft + next.bottomRight, 1e-6),
height / Math.max(next.topLeft + next.bottomLeft, 1e-6),
height / Math.max(next.topRight + next.bottomRight, 1e-6),
)
if (maxScale < 1) {
next.topLeft *= maxScale
next.topRight *= maxScale
next.bottomRight *= maxScale
next.bottomLeft *= maxScale
}
return next
}
function applyRoundedOpeningShape(
shape: THREE.Shape,
left: number,
right: number,
bottom: number,
top: number,
radii: CornerRadii,
) {
const { topLeft, topRight, bottomRight, bottomLeft } = radii
shape.moveTo(left + bottomLeft, bottom)
shape.lineTo(right - bottomRight, bottom)
if (bottomRight > 1e-6) {
shape.absarc(right - bottomRight, bottom + bottomRight, bottomRight, -Math.PI / 2, 0, false)
} else {
shape.lineTo(right, bottom)
}
shape.lineTo(right, top - topRight)
if (topRight > 1e-6) {
shape.absarc(right - topRight, top - topRight, topRight, 0, Math.PI / 2, false)
} else {
shape.lineTo(right, top)
}
shape.lineTo(left + topLeft, top)
if (topLeft > 1e-6) {
shape.absarc(left + topLeft, top - topLeft, topLeft, Math.PI / 2, Math.PI, false)
} else {
shape.lineTo(left, top)
}
shape.lineTo(left, bottom + bottomLeft)
if (bottomLeft > 1e-6) {
shape.absarc(left + bottomLeft, bottom + bottomLeft, bottomLeft, Math.PI, Math.PI * 1.5, false)
} else {
shape.lineTo(left, bottom)
}
shape.closePath()
}
@@ -81,8 +81,14 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
sill, sill,
sillDepth, sillDepth,
sillThickness, sillThickness,
openingKind,
} = node } = node
if (openingKind === 'opening') {
syncWindowCutout(node, mesh)
return
}
const innerW = width - 2 * frameThickness const innerW = width - 2 * frameThickness
const innerH = height - 2 * frameThickness const innerH = height - 2 * frameThickness
@@ -231,6 +237,10 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
) )
} }
syncWindowCutout(node, mesh)
}
function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
// ── Cutout (for wall CSG) — always full window dimensions, 1m deep ── // ── Cutout (for wall CSG) — always full window dimensions, 1m deep ──
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
if (!cutout) { if (!cutout) {
@@ -1,15 +1,13 @@
'use client' 'use client'
import '../../three-types' import '../../three-types'
import { KeyboardControls } from '@react-three/drei' import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
import { sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { KeyboardControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber' import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Euler, Vector3 } from 'three' import { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import BVHEcctrl from './first-person/bvh-ecctrl'
import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
import { import {
buildFirstPersonColliderWorldFromRegistry, buildFirstPersonColliderWorldFromRegistry,
deriveFirstPersonSpawn, deriveFirstPersonSpawn,
@@ -17,10 +15,15 @@ import {
type FirstPersonColliderWorld, type FirstPersonColliderWorld,
type FirstPersonSpawn, type FirstPersonSpawn,
} from './first-person/build-collider-world' } from './first-person/build-collider-world'
import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
import BVHEcctrl from './first-person/bvh-ecctrl'
const CAMERA_EYE_OFFSET = 0.45 const CAMERA_EYE_OFFSET = 0.45
const LOOK_SENSITIVITY = 0.002 const LOOK_SENSITIVITY = 0.002
const CONTROLLER_CENTER_FROM_EYE = 0.85 const CONTROLLER_CENTER_FROM_EYE = 0.85
const DOOR_INTERACTION_DISTANCE = 2.5
const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
const DOOR_LEAF_INTERACTION_DEPTH = 0.08
const keyboardMap = [ const keyboardMap = [
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] }, { name: 'forward', keys: ['ArrowUp', 'KeyW'] },
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] }, { name: 'backward', keys: ['ArrowDown', 'KeyS'] },
@@ -32,13 +35,21 @@ const keyboardMap = [
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
const cameraEuler = new Euler(0, 0, 0, 'YXZ') const cameraEuler = new Euler(0, 0, 0, 'YXZ')
const centerScreenPoint = new Vector2(0, 0)
const doorInteractionRaycaster = new Raycaster()
const doorLeafBox = new Box3()
const doorLeafInverseMatrix = new Matrix4()
const doorLeafLocalHit = new Vector3()
const doorLeafLocalRay = new Ray()
const doorLeafMatrix = new Matrix4()
const doorLeafWorldHit = new Vector3()
const spawnWorldPosition = new Vector3() const spawnWorldPosition = new Vector3()
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
const resolvePlacedSpawnNode = ( const resolvePlacedSpawnNode = (
nodes: ReturnType<typeof useScene.getState>['nodes'], nodes: ReturnType<typeof useScene.getState>['nodes'],
_levelId: string | null, _levelId: string | null,
) => { ) => {
const candidates = Object.values(nodes).filter((node) => node.type === 'spawn') const candidates = Object.values(nodes).filter((node) => node.type === 'spawn')
if (candidates.length === 0) return null if (candidates.length === 0) return null
@@ -52,7 +63,101 @@ export const FirstPersonControls = () => {
const controllerRef = useRef<BVHEcctrlApi | null>(null) const controllerRef = useRef<BVHEcctrlApi | null>(null)
const yawRef = useRef(0) const yawRef = useRef(0)
const pitchRef = useRef(0) const pitchRef = useRef(0)
const interactableDoorIdRef = useRef<AnyNodeId | null>(null)
const worldRef = useRef<FirstPersonColliderWorld | null>(null)
const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null) const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
const [controllerStart, setControllerStart] = useState<{
position: [number, number, number]
yaw: number
} | null>(null)
const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => {
worldRef.current?.dispose()
worldRef.current = nextWorld
setWorld(nextWorld)
}, [])
const rebuildColliderWorld = useCallback(() => {
replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry())
}, [replaceColliderWorld])
const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
const nodes = useScene.getState().nodes
camera.updateMatrixWorld(true)
doorInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
let closestDoorId: AnyNodeId | null = null
let closestDistance = DOOR_INTERACTION_DISTANCE
for (const doorId of sceneRegistry.byType.door) {
const node = nodes[doorId as AnyNodeId]
if (node?.type !== 'door') continue
if (node.openingKind === 'opening') continue
if (node.segments.every((segment) => segment.type === 'empty')) continue
const object = sceneRegistry.nodes.get(doorId)
if (!object) continue
object.updateWorldMatrix(true, true)
const placementHit = doorInteractionRaycaster
.intersectObject(object, true)
.find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE)
if (placementHit && placementHit.distance < closestDistance) {
closestDoorId = doorId as AnyNodeId
closestDistance = placementHit.distance
}
const leafW = node.width - 2 * node.frameThickness
const leafH = node.height - node.frameThickness
if (leafW <= 0 || leafH <= 0) continue
const leafCenterY = -node.frameThickness / 2
const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, node.swingAngle ?? 0))
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
doorLeafMatrix
.copy(object.matrixWorld)
.multiply(new Matrix4().makeTranslation(hingeX, 0, 0))
.multiply(new Matrix4().makeRotationY(leafSwingRotation))
.multiply(new Matrix4().makeTranslation(-hingeX, leafCenterY, 0))
doorLeafInverseMatrix.copy(doorLeafMatrix).invert()
doorLeafBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2)
doorLeafBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2)
doorLeafLocalRay.copy(doorInteractionRaycaster.ray).applyMatrix4(doorLeafInverseMatrix)
const localHit = doorLeafLocalRay.intersectBox(doorLeafBox, doorLeafLocalHit)
if (!localHit) continue
doorLeafWorldHit.copy(localHit).applyMatrix4(doorLeafMatrix)
const hitDistance = doorLeafWorldHit.distanceTo(doorInteractionRaycaster.ray.origin)
if (hitDistance <= DOOR_INTERACTION_DISTANCE && hitDistance < closestDistance) {
closestDoorId = doorId as AnyNodeId
closestDistance = hitDistance
}
}
return closestDoorId
}, [camera])
const toggleInteractableDoor = useCallback(() => {
const doorId = interactableDoorIdRef.current ?? resolveInteractableDoorId()
if (!doorId) return
const node = useScene.getState().nodes[doorId]
if (node?.type !== 'door' || node.openingKind === 'opening') return
const currentSwingAngle = node.swingAngle ?? 0
useScene.getState().updateNode(doorId, {
swingAngle: currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
})
requestAnimationFrame(rebuildColliderWorld)
}, [rebuildColliderWorld, resolveInteractableDoorId])
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => { const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
@@ -84,25 +189,28 @@ export const FirstPersonControls = () => {
}, [placedSpawnNode]) }, [placedSpawnNode])
useEffect(() => { useEffect(() => {
const nextWorld = buildFirstPersonColliderWorldFromRegistry() rebuildColliderWorld()
if (!nextWorld) {
setWorld(null)
return
}
setWorld(nextWorld)
return () => { return () => {
nextWorld.dispose() worldRef.current?.dispose()
worldRef.current = null
setWorld(null) setWorld(null)
} }
}, [camera]) }, [rebuildColliderWorld])
useEffect(() => { useEffect(() => {
if (!world) return if (!world) return
yawRef.current = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw if (controllerStart) return
const spawn = placedSpawn ?? deriveFirstPersonSpawn(camera, world)
const [x, y, z] = spawn.position
yawRef.current = spawn.yaw
pitchRef.current = 0 pitchRef.current = 0
}, [camera, placedSpawn, world]) setControllerStart({
position: [x, y - CONTROLLER_CENTER_FROM_EYE, z],
yaw: spawn.yaw,
})
}, [camera, controllerStart, placedSpawn, world])
useEffect(() => { useEffect(() => {
const canvas = gl.domElement const canvas = gl.domElement
@@ -152,6 +260,10 @@ export const FirstPersonControls = () => {
document.exitPointerLock() document.exitPointerLock()
} }
useEditor.getState().setFirstPersonMode(false) useEditor.getState().setFirstPersonMode(false)
} else if (event.code === 'KeyE') {
event.preventDefault()
event.stopPropagation()
toggleInteractableDoor()
} }
} }
@@ -159,7 +271,7 @@ export const FirstPersonControls = () => {
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown, true) document.removeEventListener('keydown', handleKeyDown, true)
} }
}, [gl]) }, [gl, toggleInteractableDoor])
useFrame((_, delta) => { useFrame((_, delta) => {
if (!controllerRef.current?.group) return if (!controllerRef.current?.group) return
@@ -170,18 +282,21 @@ export const FirstPersonControls = () => {
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ') cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
camera.quaternion.setFromEuler(cameraEuler) camera.quaternion.setFromEuler(cameraEuler)
camera.updateMatrixWorld(true) camera.updateMatrixWorld(true)
const nextInteractableDoorId = resolveInteractableDoorId()
if (interactableDoorIdRef.current !== nextInteractableDoorId) {
interactableDoorIdRef.current = nextInteractableDoorId
useViewer.getState().setHoveredId(nextInteractableDoorId)
}
}) })
const controllerPosition = useMemo(() => { useEffect(() => {
if (!world) return null return () => {
const [x, y, z] = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).position if (useViewer.getState().hoveredId === interactableDoorIdRef.current) {
return [x, y - CONTROLLER_CENTER_FROM_EYE, z] as const useViewer.getState().setHoveredId(null)
}, [camera, placedSpawn, world]) }
}
const spawnYaw = useMemo(() => { }, [])
if (!world) return 0
return (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw
}, [camera, placedSpawn, world])
if (!world) { if (!world) {
return null return null
@@ -189,11 +304,11 @@ export const FirstPersonControls = () => {
return ( return (
<> <>
{controllerPosition && ( {controllerStart && (
<KeyboardControls map={keyboardMap}> <KeyboardControls map={keyboardMap}>
<BVHEcctrl <BVHEcctrl
ref={controllerRef} ref={controllerRef}
key={`${world.mesh.uuid}:${controllerPosition.join(':')}:${spawnYaw}`} key="first-person-controller"
colliderCapsuleArgs={[0.25, 0.8, 4, 8]} colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
colliderMeshes={[world.mesh]} colliderMeshes={[world.mesh]}
collisionCheckIteration={3} collisionCheckIteration={3}
@@ -213,7 +328,7 @@ export const FirstPersonControls = () => {
maxRunSpeed={5.5} maxRunSpeed={5.5}
maxSlope={1.2} maxSlope={1.2}
maxWalkSpeed={4} maxWalkSpeed={4}
position={controllerPosition} position={controllerStart.position}
acceleration={26} acceleration={26}
airDragFactor={0.3} airDragFactor={0.3}
deceleration={30} deceleration={30}
@@ -293,7 +408,9 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
<InlineControlHint label="Jump" keyLabel="Space" /> <InlineControlHint label="Jump" keyLabel="Space" />
<InlineControlHint label="Sprint" keyLabel="Shift" /> <InlineControlHint label="Sprint" keyLabel="Shift" />
<div className="h-px w-full bg-border/30" /> <div className="h-px w-full bg-border/30" />
<span className="text-center text-muted-foreground/60 text-xs">Click to look around</span> <span className="text-center text-muted-foreground/60 text-xs">
Click to look around
</span>
</div> </div>
</div> </div>
)} )}
@@ -1,11 +1,7 @@
import { sceneRegistry, useScene } from '@pascal-app/core' import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core'
import {
acceleratedRaycast,
computeBoundsTree,
disposeBoundsTree,
} from 'three-mesh-bvh'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import * as THREE from 'three' import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
const COLLIDER_NODE_TYPES = [ const COLLIDER_NODE_TYPES = [
'wall', 'wall',
@@ -16,6 +12,7 @@ const COLLIDER_NODE_TYPES = [
'roof', 'roof',
'roof-segment', 'roof-segment',
'door', 'door',
'window',
'item', 'item',
] as const ] as const
@@ -25,6 +22,7 @@ const DOWN = new THREE.Vector3(0, -1, 0)
const UP = new THREE.Vector3(0, 1, 0) const UP = new THREE.Vector3(0, 1, 0)
const SPAWN_EYE_HEIGHT = 1.65 const SPAWN_EYE_HEIGHT = 1.65
const RAYCAST_CLEARANCE = 25 const RAYCAST_CLEARANCE = 25
const DOOR_LEAF_COLLIDER_DEPTH = 0.06
export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
@@ -46,9 +44,7 @@ function isMesh(object: THREE.Object3D): object is THREE.Mesh {
} }
function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) { function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) {
return Array.isArray(material) return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
? material.some((entry) => entry.visible)
: material.visible
} }
function cloneWorldGeometry(mesh: THREE.Mesh) { function cloneWorldGeometry(mesh: THREE.Mesh) {
@@ -56,7 +52,9 @@ function cloneWorldGeometry(mesh: THREE.Mesh) {
const position = sourceGeometry.getAttribute('position') const position = sourceGeometry.getAttribute('position')
if (!position || position.count < 3) return null if (!position || position.count < 3) return null
const workingGeometry = sourceGeometry.index ? sourceGeometry.toNonIndexed() : sourceGeometry.clone() const workingGeometry = sourceGeometry.index
? sourceGeometry.toNonIndexed()
: sourceGeometry.clone()
const cleanGeometry = new THREE.BufferGeometry() const cleanGeometry = new THREE.BufferGeometry()
cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone()) cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone())
@@ -80,9 +78,14 @@ function cloneWorldGeometry(mesh: THREE.Mesh) {
} }
function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) { function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) {
if (type === 'window') {
const node = useScene.getState().nodes[nodeId as AnyNodeId]
return node?.type === 'window' && node.openingKind === 'opening'
}
if (type !== 'door') return false if (type !== 'door') return false
const node = useScene.getState().nodes[nodeId] const node = useScene.getState().nodes[nodeId as AnyNodeId]
if (!node || node.type !== 'door') return false if (!node || node.type !== 'door') return false
if (node.openingKind === 'opening') return true if (node.openingKind === 'opening') return true
@@ -92,6 +95,42 @@ function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPE
return node.segments.every((segment) => segment.type === 'empty') return node.segments.every((segment) => segment.type === 'empty')
} }
function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
const hasLeafContent = node.segments.some((segment) => segment.type !== 'empty')
if (!hasLeafContent) return null
const leafW = node.width - 2 * node.frameThickness
const leafH = node.height - node.frameThickness
if (leafW <= 0 || leafH <= 0) return null
const leafCenterY = -node.frameThickness / 2
const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, node.swingAngle ?? 0))
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
root.updateWorldMatrix(true, false)
const sourceGeometry = new THREE.BoxGeometry(
leafW,
leafH,
DOOR_LEAF_COLLIDER_DEPTH,
).toNonIndexed()
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', sourceGeometry.getAttribute('position').clone())
geometry.setAttribute('normal', sourceGeometry.getAttribute('normal').clone())
sourceGeometry.dispose()
const matrix = root.matrixWorld
.clone()
.multiply(new THREE.Matrix4().makeTranslation(hingeX, 0, 0))
.multiply(new THREE.Matrix4().makeRotationY(leafSwingRotation))
.multiply(new THREE.Matrix4().makeTranslation(-hingeX, leafCenterY, 0))
geometry.applyMatrix4(matrix)
return geometry
}
function buildRegisteredNodeTypeLookup() { function buildRegisteredNodeTypeLookup() {
const nodeTypes = new Map<string, ColliderNodeType>() const nodeTypes = new Map<string, ColliderNodeType>()
@@ -164,6 +203,17 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
const root = sceneRegistry.nodes.get(nodeId) const root = sceneRegistry.nodes.get(nodeId)
if (!root) continue if (!root) continue
if (type === 'door') {
const node = useScene.getState().nodes[nodeId as AnyNodeId]
if (node?.type !== 'door') continue
const doorGeometry = createDoorLeafColliderGeometry(root, node)
if (doorGeometry) {
geometries.push(doorGeometry)
}
continue
}
root.updateMatrixWorld(true) root.updateMatrixWorld(true)
geometries.push( geometries.push(
...collectColliderGeometriesFromNode( ...collectColliderGeometriesFromNode(
@@ -182,7 +232,9 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
} }
const mergedGeometry = mergeGeometries(geometries, false) const mergedGeometry = mergeGeometries(geometries, false)
geometries.forEach((geometry) => geometry.dispose()) geometries.forEach((geometry) => {
geometry.dispose()
})
if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) { if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
mergedGeometry?.dispose() mergedGeometry?.dispose()
@@ -247,7 +299,8 @@ export function deriveFirstPersonSpawn(
} }
for (const [x, z] of candidates) { for (const [x, z] of candidates) {
const topY = Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE const topY =
Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE
raycaster.set(new THREE.Vector3(x, topY, z), DOWN) raycaster.set(new THREE.Vector3(x, topY, z), DOWN)
const intersections = raycaster.intersectObject(world.mesh, false) const intersections = raycaster.intersectObject(world.mesh, false)
const hit = intersections.find((intersection) => { const hit = intersections.find((intersection) => {
@@ -265,11 +318,7 @@ export function deriveFirstPersonSpawn(
} }
return { return {
position: [ position: [camera.position.x, Math.max(camera.position.y, SPAWN_EYE_HEIGHT), camera.position.z],
camera.position.x,
Math.max(camera.position.y, SPAWN_EYE_HEIGHT),
camera.position.z,
],
yaw, yaw,
} }
} }
@@ -4154,6 +4154,137 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
const detailStrokeWidth = isSelected || isSelectionHighlighted ? '1.05' : '0.75' const detailStrokeWidth = isSelected || isSelectionHighlighted ? '1.05' : '0.75'
const markerX = (p1!.x + p2!.x + p3!.x + p4!.x) / 4 const markerX = (p1!.x + p2!.x + p3!.x + p4!.x) / 4
const markerY = (p1!.y + p2!.y + p3!.y + p4!.y) / 4 const markerY = (p1!.y + p2!.y + p3!.y + p4!.y) / 4
const windowOpeningShape = opening.openingShape ?? 'rectangle'
if (opening.openingKind === 'opening') {
const detailInset = Math.min(tangentLength * 0.14, 0.18)
const detailStart = {
x: centerLine.start.x + tangentX * detailInset,
y: centerLine.start.y + tangentY * detailInset,
}
const detailEnd = {
x: centerLine.end.x - tangentX * detailInset,
y: centerLine.end.y - tangentY * detailInset,
}
const detailControl = {
x: (detailStart.x + detailEnd.x) / 2 + normalX * normalLength * 0.34,
y: (detailStart.y + detailEnd.y) / 2 + normalY * normalLength * 0.34,
}
const detailPath =
windowOpeningShape === 'rectangle'
? null
: `M ${toSvgX(detailStart.x)} ${toSvgY(detailStart.y)} Q ${toSvgX(detailControl.x)} ${toSvgY(detailControl.y)} ${toSvgX(detailEnd.x)} ${toSvgY(detailEnd.y)}`
return (
<g
key={opening.id}
onClick={
canSelectGeometry
? (event) => {
event.stopPropagation()
onOpeningSelect(opening.id, event)
}
: undefined
}
onDoubleClick={
canFocusGeometry
? (event) => {
event.stopPropagation()
onOpeningDoubleClick(opening)
}
: undefined
}
onPointerDown={
canFocusGeometry && isSelected
? (event) => {
if (event.button === 0) {
onOpeningPointerDown(opening.id, event)
}
}
: undefined
}
onPointerEnter={
canSelectGeometry
? () => {
onWallHoverChange(null)
onOpeningHoverChange(opening.id)
}
: undefined
}
onPointerLeave={canSelectGeometry ? () => onOpeningHoverChange(null) : undefined}
style={{ cursor: EDITOR_CURSOR }}
>
{canSelectGeometry && (
<polygon
fill="transparent"
points={points}
pointerEvents="all"
stroke="transparent"
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
vectorEffect="non-scaling-stroke"
/>
)}
<polygon
fill={isDeleteHovered ? palette.deleteFill : '#ffffff'}
points={points}
stroke={isDeleteHovered ? palette.deleteStroke : symbolStroke}
strokeDasharray={windowOpeningShape === 'rectangle' ? 'none' : '0.18 0.08'}
strokeOpacity={1}
strokeWidth={symbolStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
{detailPath ? (
<path
d={detailPath}
fill="none"
stroke={isDeleteHovered ? palette.deleteStroke : symbolStroke}
strokeLinecap="round"
strokeWidth={detailStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
) : (
<line
stroke={isDeleteHovered ? palette.deleteStroke : symbolStroke}
strokeWidth={detailStrokeWidth}
vectorEffect="non-scaling-stroke"
x1={toSvgX(detailStart.x)}
x2={toSvgX(detailEnd.x)}
y1={toSvgY(detailStart.y)}
y2={toSvgY(detailEnd.y)}
/>
)}
{isSelected ? (
<>
<circle
cx={toSvgX(markerX)}
cy={toSvgY(markerY)}
fill="#f97316"
r="0.1"
vectorEffect="non-scaling-stroke"
/>
<circle
cx={toSvgX(markerX)}
cy={toSvgY(markerY)}
fill="none"
r="0.17"
stroke="rgba(249, 115, 22, 0.4)"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
<circle
cx={toSvgX(markerX)}
cy={toSvgY(markerY)}
fill="none"
r="0.17"
stroke="#ffffff"
strokeWidth="1.5"
vectorEffect="non-scaling-stroke"
/>
</>
) : null}
</g>
)
}
return ( return (
<g <g
@@ -4345,7 +4476,22 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
const depthExtraOffset = 0.005 const depthExtraOffset = 0.005
const doorCubeSize = Math.min(Math.max(width * 0.08, 0.06), 0.12) const doorCubeSize = Math.min(Math.max(width * 0.08, 0.06), 0.12)
const doorCubeInset = doorCubeSize * 0.5 const doorCubeInset = doorCubeSize * 0.5
const doorCubeStroke = palette.openingStroke const doorAccent =
isSelected || isSelectionHighlighted ? '#f97316' : 'rgba(100, 116, 139, 0.82)'
const doorStroke = isDeleteHovered ? palette.deleteStroke : doorAccent
const doorSoftStroke =
isSelected || isSelectionHighlighted
? 'rgba(251, 146, 60, 0.62)'
: 'rgba(148, 163, 184, 0.58)'
const doorLeafFill =
isSelected || isSelectionHighlighted ? 'rgba(255, 247, 237, 0.98)' : '#ffffff'
const doorOpeningFill =
isSelected || isSelectionHighlighted ? 'rgba(255, 247, 237, 0.98)' : '#ffffff'
const doorSwingFill =
isSelected || isSelectionHighlighted
? 'rgba(251, 146, 60, 0.08)'
: 'rgba(148, 163, 184, 0.08)'
const doorCubeStroke = doorStroke
const hingeTangentSign = hingesSide === 'left' ? 1 : -1 const hingeTangentSign = hingesSide === 'left' ? 1 : -1
const hingeCubeCenter = { const hingeCubeCenter = {
x: hx + nx * hingeTangentSign * doorCubeInset, x: hx + nx * hingeTangentSign * doorCubeInset,
@@ -4491,6 +4637,48 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
] ]
.map((point) => `${point.x},${point.y}`) .map((point) => `${point.x},${point.y}`)
.join(' ') .join(' ')
const swingSweepPath =
swingRadius > 1e-6
? `M ${leafStart.x} ${leafStart.y} L ${leafEnd.x} ${leafEnd.y} A ${swingRadius} ${swingRadius} 0 0 ${sweepFlag} ${arcEnd.x} ${arcEnd.y} Z`
: null
const jambTickSize = doorCubeSize * 0.82
const hingeMarkerRadius = Math.min(Math.max(doorCubeSize * 0.22, 0.018), 0.034)
const strikeTickStart = {
x: strikeCubeCenter.x - px * swingSign * jambTickSize * 0.5,
y: strikeCubeCenter.y - py * swingSign * jambTickSize * 0.5,
}
const strikeTickEnd = {
x: strikeCubeCenter.x + px * swingSign * jambTickSize * 0.5,
y: strikeCubeCenter.y + py * swingSign * jambTickSize * 0.5,
}
const closedLeafHintPoints = [
{
x: leafStart.x - nx * leafHalfThickness * 0.7,
y: leafStart.y - ny * leafHalfThickness * 0.7,
},
{
x: arcEnd.x - nx * leafHalfThickness * 0.7,
y: arcEnd.y - ny * leafHalfThickness * 0.7,
},
{
x: arcEnd.x + nx * leafHalfThickness * 0.7,
y: arcEnd.y + ny * leafHalfThickness * 0.7,
},
{
x: leafStart.x + nx * leafHalfThickness * 0.7,
y: leafStart.y + ny * leafHalfThickness * 0.7,
},
]
.map((point) => `${point.x},${point.y}`)
.join(' ')
const openingCenterLineStart = {
x: (svgP1.x + svgP4.x) / 2,
y: (svgP1.y + svgP4.y) / 2,
}
const openingCenterLineEnd = {
x: (svgP2.x + svgP3.x) / 2,
y: (svgP2.y + svgP3.y) / 2,
}
return ( return (
<g <g
@@ -4546,25 +4734,39 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
{openingPlanPath ? ( {openingPlanPath ? (
<path <path
d={openingPlanPath} d={openingPlanPath}
fill="#ffffff" fill={doorOpeningFill}
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke} stroke={doorStroke}
strokeWidth="1.25" strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
) : ( ) : (
<polygon <polygon
fill="#ffffff" fill={doorOpeningFill}
points={doorBackgroundPoints} points={doorBackgroundPoints}
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke} stroke={doorStroke}
strokeWidth="1.25" strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
)} )}
<line
stroke={doorSoftStroke}
strokeDasharray="0.08 0.06"
strokeLinecap="round"
strokeWidth="0.85"
vectorEffect="non-scaling-stroke"
x1={openingCenterLineStart.x}
x2={openingCenterLineEnd.x}
y1={openingCenterLineStart.y}
y2={openingCenterLineEnd.y}
/>
{archPlanPath && ( {archPlanPath && (
<path <path
d={archPlanPath} d={archPlanPath}
fill="none" fill="none"
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke} stroke={doorStroke}
strokeLinecap="round"
strokeWidth={arcStrokeWidth} strokeWidth={arcStrokeWidth}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
@@ -4572,36 +4774,108 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
</> </>
) : ( ) : (
<> <>
<polygon fill="#ffffff" points={doorBackgroundPoints} stroke="none" /> <polygon
fill="rgba(255, 255, 255, 0.94)"
points={doorBackgroundPoints}
stroke="none"
/>
{swingSweepPath && (
<path
d={swingSweepPath}
fill={doorSwingFill}
stroke="none"
vectorEffect="non-scaling-stroke"
/>
)}
{swingAngle > 0.03 && (
<polygon
fill="none"
points={closedLeafHintPoints}
stroke={doorSoftStroke}
strokeDasharray="0.08 0.06"
strokeLinecap="round"
strokeWidth="0.8"
vectorEffect="non-scaling-stroke"
/>
)}
{[hingeCubeCenter, strikeCubeCenter].map((point, index) => ( {[hingeCubeCenter, strikeCubeCenter].map((point, index) => (
<rect <rect
fill="#ffffff" fill={index === 0 ? doorLeafFill : '#ffffff'}
height={doorCubeSize} height={doorCubeSize}
key={`${opening.id}:door-cube:${index}`} key={`${opening.id}:door-cube:${index}`}
stroke={doorCubeStroke} rx={doorCubeSize * 0.12}
strokeWidth="1.25" stroke={index === 0 ? doorStroke : doorSoftStroke}
strokeWidth={index === 0 ? '1.35' : '1'}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
width={doorCubeSize} width={doorCubeSize}
x={point.x - doorCubeSize / 2} x={point.x - doorCubeSize / 2}
y={point.y - doorCubeSize / 2} y={point.y - doorCubeSize / 2}
/> />
))} ))}
<circle
cx={hingeCubeCenter.x}
cy={hingeCubeCenter.y}
fill={doorStroke}
r={hingeMarkerRadius}
vectorEffect="non-scaling-stroke"
/>
<line
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="1.1"
vectorEffect="non-scaling-stroke"
x1={strikeTickStart.x}
x2={strikeTickEnd.x}
y1={strikeTickStart.y}
y2={strikeTickEnd.y}
/>
<polygon <polygon
fill="#ffffff" fill={doorLeafFill}
points={leafPolygonPoints} points={leafPolygonPoints}
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke} stroke={doorStroke}
strokeWidth="1.25" strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
<path <path
d={`M ${leafEnd.x} ${leafEnd.y} A ${swingRadius} ${swingRadius} 0 0 ${sweepFlag} ${arcEnd.x} ${arcEnd.y}`} d={`M ${leafEnd.x} ${leafEnd.y} A ${swingRadius} ${swingRadius} 0 0 ${sweepFlag} ${arcEnd.x} ${arcEnd.y}`}
fill="none" fill="none"
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke} stroke={doorStroke}
strokeLinecap="round"
strokeWidth={arcStrokeWidth} strokeWidth={arcStrokeWidth}
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
</> </>
)} )}
{isSelected ? (
<>
<circle
cx={cx}
cy={cy}
fill="#f97316"
r="0.1"
vectorEffect="non-scaling-stroke"
/>
<circle
cx={cx}
cy={cy}
fill="none"
r="0.17"
stroke="rgba(249, 115, 22, 0.4)"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
<circle
cx={cx}
cy={cy}
fill="none"
r="0.17"
stroke="#ffffff"
strokeWidth="1.5"
vectorEffect="non-scaling-stroke"
/>
</>
) : null}
</g> </g>
) )
} }
@@ -15,6 +15,7 @@ interface SliderControlProps {
step?: number step?: number
className?: string className?: string
unit?: string unit?: string
restoreOnCommit?: boolean
} }
function stepPrecision(s: number): number { function stepPrecision(s: number): number {
@@ -56,6 +57,7 @@ export function SliderControl({
step = 1, step = 1,
className, className,
unit = '', unit = '',
restoreOnCommit = true,
}: SliderControlProps) { }: SliderControlProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
@@ -161,7 +163,10 @@ export function SliderControl({
const newValue = clamp( const newValue = clamp(
Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))), Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
) )
onChange(newValue) if (newValue !== valueRef.current) {
valueRef.current = newValue
onChange(newValue)
}
}, },
[step, clamp, onChange], [step, clamp, onChange],
) )
@@ -175,7 +180,7 @@ export function SliderControl({
setIsDragging(false) setIsDragging(false)
e.currentTarget.releasePointerCapture(e.pointerId) e.currentTarget.releasePointerCapture(e.pointerId)
if (originValue !== finalVal) { if (originValue !== finalVal && restoreOnCommit) {
onChange(originValue) onChange(originValue)
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
onChange(finalVal) onChange(finalVal)
@@ -185,7 +190,7 @@ export function SliderControl({
onCommit?.(finalVal) onCommit?.(finalVal)
} }
}, },
[onChange, onCommit], [onChange, onCommit, restoreOnCommit],
) )
const handleValueClick = useCallback(() => { const handleValueClick = useCallback(() => {
@@ -9,7 +9,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback, useRef } from 'react'
import { usePresetsAdapter } from '../../../contexts/presets-context' import { usePresetsAdapter } from '../../../contexts/presets-context'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
@@ -22,12 +22,32 @@ import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
import { PresetsPopover } from './presets/presets-popover' import { PresetsPopover } from './presets/presets-popover'
function isSameDoorValue(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) => isSameDoorValue(value, next[index]))
)
}
return Object.is(current, next)
}
export function DoorPanel() { export function DoorPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode) const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const previewRef = useRef<{
id: AnyNodeId
key: keyof DoorNode
value: unknown
} | null>(null)
const adapter = usePresetsAdapter() const adapter = usePresetsAdapter()
@@ -37,10 +57,57 @@ export function DoorPanel() {
const handleUpdate = useCallback( const handleUpdate = useCallback(
(updates: Partial<DoorNode>) => { (updates: Partial<DoorNode>) => {
if (!selectedId) return if (!(selectedId && node)) return
const hasChange = Object.entries(updates).some(([key, value]) => {
const currentValue = node[key as keyof DoorNode]
return !isSameDoorValue(currentValue, value)
})
if (!hasChange) return
updateNode(selectedId as AnyNode['id'], updates) updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
}, },
[selectedId, node, updateNode],
)
const previewDoorUpdate = useCallback(
<K extends keyof DoorNode>(key: K, value: DoorNode[K]) => {
if (!selectedId) return
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
if (liveNode?.type !== 'door') return
if (!(previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key)) {
previewRef.current = {
id: selectedId as AnyNodeId,
key,
value: liveNode[key],
}
}
if (isSameDoorValue(liveNode[key], value)) return
;(liveNode as DoorNode)[key] = value
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId],
)
const commitDoorPreview = useCallback(
<K extends keyof DoorNode>(key: K, value: DoorNode[K]) => {
if (!selectedId) return
const scene = useScene.getState()
const liveNode = scene.nodes[selectedId as AnyNodeId]
const preview = previewRef.current
if (liveNode?.type === 'door' && preview?.id === selectedId && preview.key === key) {
;(liveNode as DoorNode)[key] = preview.value as DoorNode[K]
scene.dirtyNodes.add(selectedId as AnyNodeId)
}
previewRef.current = null
updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<DoorNode>)
scene.dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId, updateNode], [selectedId, updateNode],
) )
@@ -134,6 +201,8 @@ export function DoorPanel() {
frameDepth: node.frameDepth, frameDepth: node.frameDepth,
openingKind: node.openingKind, openingKind: node.openingKind,
openingShape: node.openingShape, openingShape: node.openingShape,
openingRadiusMode: node.openingRadiusMode ?? 'all',
openingTopRadii: node.openingTopRadii ?? [0.15, 0.15],
cornerRadius: node.cornerRadius, cornerRadius: node.cornerRadius,
archHeight: node.archHeight, archHeight: node.archHeight,
openingRevealRadius: node.openingRevealRadius, openingRevealRadius: node.openingRevealRadius,
@@ -185,9 +254,22 @@ export function DoorPanel() {
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum) const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
const isOpening = node.openingKind === 'opening' const isOpening = node.openingKind === 'opening'
const openingShape = node.openingShape ?? 'rectangle' const openingShape = node.openingShape ?? 'rectangle'
const openingRadiusMode = node.openingRadiusMode ?? 'all'
const openingTopRadii = node.openingTopRadii ?? [0.15, 0.15]
const cornerRadius = node.cornerRadius ?? 0.15 const cornerRadius = node.cornerRadius ?? 0.15
const archHeight = node.archHeight ?? 0.45 const archHeight = node.archHeight ?? 0.45
const openingRevealRadius = node.openingRevealRadius ?? 0.025 const openingRevealRadius = node.openingRevealRadius ?? 0.025
const maxRoundedRadius = Math.max(0.01, Math.min(node.width / 2, node.height))
const setOpeningTopRadius = (index: number, value: number, commit = false) => {
const next = [...openingTopRadii] as [number, number]
next[index] = value
if (commit) {
commitDoorPreview('openingTopRadii', next)
} else {
previewDoorUpdate('openingTopRadii', next)
}
}
return ( return (
<PanelWrapper <PanelWrapper
@@ -226,6 +308,8 @@ export function DoorPanel() {
? { ? {
openingKind: v, openingKind: v,
openingShape, openingShape,
openingRadiusMode,
openingTopRadii,
cornerRadius, cornerRadius,
archHeight, archHeight,
openingRevealRadius, openingRevealRadius,
@@ -276,6 +360,7 @@ export function DoorPanel() {
min={0.5} min={0.5}
onChange={(v) => handleUpdate({ width: v })} onChange={(v) => handleUpdate({ width: v })}
precision={2} precision={2}
restoreOnCommit={false}
step={0.05} step={0.05}
unit="m" unit="m"
value={Math.round(node.width * 100) / 100} value={Math.round(node.width * 100) / 100}
@@ -288,6 +373,7 @@ export function DoorPanel() {
handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] }) handleUpdate({ height: v, position: [node.position[0], v / 2, node.position[2]] })
} }
precision={2} precision={2}
restoreOnCommit={false}
step={0.05} step={0.05}
unit="m" unit="m"
value={Math.round(node.height * 100) / 100} value={Math.round(node.height * 100) / 100}
@@ -301,7 +387,9 @@ export function DoorPanel() {
onChange={(v) => onChange={(v) =>
handleUpdate({ handleUpdate({
openingShape: v, openingShape: v,
...(v === 'rounded' ? { cornerRadius, openingRevealRadius } : {}), ...(v === 'rounded'
? { openingRadiusMode, openingTopRadii, cornerRadius, openingRevealRadius }
: {}),
...(v === 'arch' ? { archHeight } : {}), ...(v === 'arch' ? { archHeight } : {}),
}) })
} }
@@ -315,21 +403,57 @@ export function DoorPanel() {
</div> </div>
{openingShape === 'rounded' && ( {openingShape === 'rounded' && (
<> <>
<SliderControl <div className="flex flex-col gap-2 px-1 pb-1">
label="Corner Radius" <SegmentedControl
max={Math.min(node.width / 2, node.height)} onChange={(v) =>
min={0} handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] })
onChange={(v) => handleUpdate({ cornerRadius: v })} }
precision={2} options={[
step={0.05} { label: 'All', value: 'all' },
unit="m" { label: 'Individual', value: 'individual' },
value={Math.round(cornerRadius * 100) / 100} ]}
/> value={openingRadiusMode}
/>
</div>
{openingRadiusMode === 'all' ? (
<SliderControl
label="Corner Radius"
max={maxRoundedRadius}
min={0}
onChange={(v) => previewDoorUpdate('cornerRadius', v)}
onCommit={(v) => commitDoorPreview('cornerRadius', v)}
precision={2}
step={0.05}
unit="m"
value={Math.round(cornerRadius * 100) / 100}
/>
) : (
<>
{[
['Top Left', 0],
['Top Right', 1],
].map(([label, index]) => (
<SliderControl
key={label}
label={label}
max={maxRoundedRadius}
min={0}
onChange={(v) => setOpeningTopRadius(index as number, v)}
onCommit={(v) => setOpeningTopRadius(index as number, v, true)}
precision={2}
step={0.05}
unit="m"
value={Math.round((openingTopRadii[index as number] ?? 0) * 100) / 100}
/>
))}
</>
)}
<SliderControl <SliderControl
label="Reveal Radius" label="Reveal Radius"
max={0.08} max={0.08}
min={0} min={0}
onChange={(v) => handleUpdate({ openingRevealRadius: v })} onChange={(v) => previewDoorUpdate('openingRevealRadius', v)}
onCommit={(v) => commitDoorPreview('openingRevealRadius', v)}
precision={3} precision={3}
step={0.005} step={0.005}
unit="m" unit="m"
@@ -9,24 +9,75 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback, useRef } from 'react'
import { usePresetsAdapter } from '../../../contexts/presets-context' import { usePresetsAdapter } from '../../../contexts/presets-context'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control' import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control' import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper' import { PanelWrapper } from './panel-wrapper'
import { PresetsPopover } from './presets/presets-popover' import { PresetsPopover } from './presets/presets-popover'
function isSameWindowValue(current: unknown, next: unknown): boolean {
if (typeof current === 'number' && typeof next === 'number') {
return Math.abs(current - next) < 1e-6
}
if (Array.isArray(current) && Array.isArray(next)) {
return (
current.length === next.length &&
current.every((value, index) => isSameWindowValue(value, next[index]))
)
}
return Object.is(current, next)
}
function getMaxSharedWindowRadius(width: number, height: number) {
return Math.max(0, Math.min(width / 2, height / 2))
}
function normalizeWindowCornerRadii(
radii: [number, number, number, number],
width: number,
height: number,
): [number, number, number, number] {
const next = radii.map((radius) => Math.max(radius, 0)) as [number, number, number, number]
const scale = Math.min(
1,
Math.max(width, 0) / Math.max(next[0] + next[1], 1e-6),
Math.max(width, 0) / Math.max(next[3] + next[2], 1e-6),
Math.max(height, 0) / Math.max(next[0] + next[3], 1e-6),
Math.max(height, 0) / Math.max(next[1] + next[2], 1e-6),
)
if (scale >= 1) return next
return next.map((radius) => radius * scale) as [number, number, number, number]
}
function isSameRadiusTuple(
current: [number, number, number, number],
next: [number, number, number, number],
) {
return current.every((value, index) => Math.abs(value - next[index]) < 1e-6)
}
export function WindowPanel() { export function WindowPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode) const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const previewRef = useRef<{
id: AnyNodeId
key: keyof WindowNode
value: unknown
} | null>(null)
const adapter = usePresetsAdapter() const adapter = usePresetsAdapter()
@@ -36,10 +87,59 @@ export function WindowPanel() {
const handleUpdate = useCallback( const handleUpdate = useCallback(
(updates: Partial<WindowNode>) => { (updates: Partial<WindowNode>) => {
if (!selectedId) return if (!(selectedId && node)) return
const hasChange = Object.entries(updates).some(([key, value]) => {
const currentValue = node[key as keyof WindowNode]
return !isSameWindowValue(currentValue, value)
})
if (!hasChange) return
updateNode(selectedId as AnyNode['id'], updates) updateNode(selectedId as AnyNode['id'], updates)
useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) useScene.getState().dirtyNodes.add(selectedId as AnyNodeId)
}, },
[selectedId, node, updateNode],
)
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
updateNode(selectedId as AnyNode['id'], { [key]: value } as Partial<WindowNode>)
scene.dirtyNodes.add(selectedId as AnyNodeId)
},
[selectedId, updateNode], [selectedId, updateNode],
) )
@@ -84,6 +184,13 @@ export function WindowPanel() {
height: node.height, height: node.height,
frameThickness: node.frameThickness, frameThickness: node.frameThickness,
frameDepth: node.frameDepth, 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], columnRatios: [...node.columnRatios],
rowRatios: [...node.rowRatios], rowRatios: [...node.rowRatios],
columnDividerThickness: node.columnDividerThickness, columnDividerThickness: node.columnDividerThickness,
@@ -105,6 +212,13 @@ export function WindowPanel() {
height: node.height, height: node.height,
frameThickness: node.frameThickness, frameThickness: node.frameThickness,
frameDepth: node.frameDepth, 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, columnRatios: node.columnRatios,
rowRatios: node.rowRatios, rowRatios: node.rowRatios,
columnDividerThickness: node.columnDividerThickness, columnDividerThickness: node.columnDividerThickness,
@@ -151,6 +265,58 @@ export function WindowPanel() {
const rowSum = node.rowRatios.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 normCols = node.columnRatios.map((r) => r / colSum)
const normRows = node.rowRatios.map((r) => r / rowSum) const normRows = node.rowRatios.map((r) => r / rowSum)
const isOpening = node.openingKind === 'opening'
const openingShape = node.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 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 setColumnRatio = (index: number, newVal: number) => {
const clamped = Math.max(0.05, Math.min(0.95, newVal)) const clamped = Math.max(0.05, Math.min(0.95, newVal))
@@ -206,6 +372,31 @@ export function WindowPanel() {
</PresetsPopover> </PresetsPopover>
</div> </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>
<PanelSection title="Position"> <PanelSection title="Position">
<SliderControl <SliderControl
label={ label={
@@ -231,22 +422,25 @@ export function WindowPanel() {
unit="m" unit="m"
value={Math.round(node.position[1] * 100) / 100} value={Math.round(node.position[1] * 100) / 100}
/> />
<div className="px-1 pt-2 pb-1"> {!isOpening && (
<ActionButton <div className="px-1 pt-2 pb-1">
className="w-full" <ActionButton
icon={<FlipHorizontal2 className="h-4 w-4" />} className="w-full"
label="Flip Side" icon={<FlipHorizontal2 className="h-4 w-4" />}
onClick={handleFlip} label="Flip Side"
/> onClick={handleFlip}
</div> />
</div>
)}
</PanelSection> </PanelSection>
<PanelSection title="Dimensions"> <PanelSection title="Dimensions">
<SliderControl <SliderControl
label="Width" label="Width"
min={0} min={0}
onChange={(v) => handleUpdate({ width: v })} onChange={(v) => handleUpdate(getDimensionUpdates({ width: v }))}
precision={2} precision={2}
restoreOnCommit={false}
step={0.1} step={0.1}
unit="m" unit="m"
value={Math.round(node.width * 100) / 100} value={Math.round(node.width * 100) / 100}
@@ -254,157 +448,252 @@ export function WindowPanel() {
<SliderControl <SliderControl
label="Height" label="Height"
min={0} min={0}
onChange={(v) => handleUpdate({ height: v })} onChange={(v) => handleUpdate(getDimensionUpdates({ height: v }))}
precision={2} precision={2}
restoreOnCommit={false}
step={0.1} step={0.1}
unit="m" unit="m"
value={Math.round(node.height * 100) / 100} value={Math.round(node.height * 100) / 100}
/> />
</PanelSection> </PanelSection>
<PanelSection title="Frame"> {isOpening && (
<SliderControl <PanelSection title="Opening Shape">
label="Thickness" <SegmentedControl
min={0} onChange={(value) =>
onChange={(v) => handleUpdate({ frameThickness: v })} handleUpdate({ openingShape: value as WindowNode['openingShape'] })
precision={3} }
step={0.01} options={[
unit="m" { value: 'rectangle', label: 'Rect' },
value={Math.round(node.frameThickness * 1000) / 1000} { value: 'rounded', label: 'Rounded' },
/> { value: 'arch', label: 'Arch' },
<SliderControl ]}
label="Depth" value={openingShape}
min={0} />
onChange={(v) => handleUpdate({ frameDepth: v })} {openingShape === 'rounded' && (
precision={3} <div className="mt-2 flex flex-col gap-1">
step={0.01} <SegmentedControl
unit="m" onChange={(value) =>
value={Math.round(node.frameDepth * 1000) / 1000} handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] })
/> }
</PanelSection> options={[
{ value: 'all', label: 'All' },
<PanelSection title="Grid"> { value: 'individual', label: 'Individual' },
<SliderControl ]}
label="Columns" value={openingRadiusMode}
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}
/> />
))} {openingRadiusMode === 'all' ? (
<div className="mt-1 border-border/50 border-t pt-1"> <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 <SliderControl
label="Divider" label="Reveal Radius"
max={0.1} max={0.08}
min={0.005} min={0}
onChange={(v) => handleUpdate({ columnDividerThickness: v })} onChange={(value) => previewWindowUpdate('openingRevealRadius', value)}
onCommit={(value) => commitWindowPreview('openingRevealRadius', value)}
precision={3} precision={3}
step={0.01} step={0.005}
unit="m" unit="m"
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000} value={Math.round(openingRevealRadius * 1000) / 1000}
/> />
</div> </div>
</div> )}
)} {openingShape === 'arch' && (
<div className="mt-2 flex flex-col gap-1">
{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 <SliderControl
key={`r-${i}`} label="Arch Height"
label={`R${i + 1}`} max={Math.max(0.05, node.height)}
max={95} min={0.05}
min={5} onChange={(value) => handleUpdate({ archHeight: value })}
onChange={(v) => setRowRatio(i, v / 100)} precision={2}
precision={1} step={0.05}
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" unit="m"
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000} value={Math.round(archHeight * 100) / 100}
/> />
</div> </div>
</div> )}
)} </PanelSection>
</PanelSection> )}
<PanelSection title="Sill"> {!isOpening && (
<ToggleControl <>
checked={node.sill} <PanelSection title="Frame">
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 <SliderControl
label="Thickness" label="Thickness"
min={0} min={0}
onChange={(v) => handleUpdate({ sillThickness: v })} onChange={(v) => handleUpdate({ frameThickness: v })}
precision={3} precision={3}
step={0.01} step={0.01}
unit="m" unit="m"
value={Math.round(node.sillThickness * 1000) / 1000} value={Math.round(node.frameThickness * 1000) / 1000}
/> />
</div> <SliderControl
)} label="Depth"
</PanelSection> min={0}
onChange={(v) => handleUpdate({ frameDepth: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.frameDepth * 1000) / 1000}
/>
</PanelSection>
<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>
<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"> <PanelSection title="Actions">
<ActionGroup> <ActionGroup>