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
openingKind: z.enum(['door', 'opening']).default('door'),
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),
archHeight: z.number().min(0).default(0.45),
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),
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
frameThickness: z.number().default(0.05),
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 { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
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 { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
@@ -546,8 +546,11 @@ function collectCutoutBrushes(
for (const child of childrenNodes) {
if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue
if (child.type === 'door' && child.openingKind === 'opening') {
brushes.push(createDoorOpeningCutoutBrush(child, wallThickness))
if (
(child.type === 'door' && child.openingKind === 'opening') ||
(child.type === 'window' && child.openingKind === 'opening')
) {
brushes.push(createShapedOpeningCutoutBrush(child, wallThickness))
continue
}
@@ -606,15 +609,23 @@ function collectCutoutBrushes(
return brushes
}
function createDoorOpeningCutoutBrush(door: DoorNode, wallThickness: number): Brush {
const shape = createDoorOpeningCutoutShape(door)
type ShapedOpeningNode = DoorNode | WindowNode
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 bevelSize =
door.openingShape === 'rounded'
opening.openingShape === 'rounded'
? 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((door.cornerRadius ?? 0.15) * 0.45, 0.001),
Math.max((opening.cornerRadius ?? 0.15) * 0.45, 0.001),
)
: 0
const geometry = new THREE.ExtrudeGeometry(shape, {
@@ -633,19 +644,19 @@ function createDoorOpeningCutoutBrush(door: DoorNode, wallThickness: number): Br
return new Brush(geometry)
}
function createDoorOpeningCutoutShape(door: DoorNode): THREE.Shape {
const halfWidth = door.width / 2
const bottom = door.position[1] - door.height / 2
const top = door.position[1] + door.height / 2
const centerX = door.position[0]
function createShapedOpeningCutoutShape(opening: ShapedOpeningNode): THREE.Shape {
const halfWidth = opening.width / 2
const bottom = opening.position[1] - opening.height / 2
const top = opening.position[1] + opening.height / 2
const centerX = opening.position[0]
const left = centerX - halfWidth
const right = centerX + halfWidth
const width = Math.max(door.width, 1e-6)
const height = Math.max(door.height, 1e-6)
const width = Math.max(opening.width, 1e-6)
const height = Math.max(opening.height, 1e-6)
const shape = new THREE.Shape()
if (door.openingShape === 'arch') {
const archHeight = Math.min(Math.max(door.archHeight ?? width / 2, 0.01), height)
if (opening.openingShape === 'arch') {
const archHeight = Math.min(Math.max(opening.archHeight ?? width / 2, 0.01), height)
const springY = top - archHeight
shape.moveTo(left, bottom)
@@ -657,17 +668,9 @@ function createDoorOpeningCutoutShape(door: DoorNode): THREE.Shape {
return shape
}
if (door.openingShape === 'rounded') {
const radius = Math.min(Math.max(door.cornerRadius ?? 0.15, 0), width / 2, height)
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()
if (opening.openingShape === 'rounded') {
const radii = getRoundedOpeningRadii(opening, width, height)
applyRoundedOpeningShape(shape, left, right, bottom, top, radii)
return shape
}
@@ -678,3 +681,112 @@ function createDoorOpeningCutoutShape(door: DoorNode): THREE.Shape {
shape.closePath()
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,
sillDepth,
sillThickness,
openingKind,
} = node
if (openingKind === 'opening') {
syncWindowCutout(node, mesh)
return
}
const innerW = width - 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 ──
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
if (!cutout) {
@@ -1,15 +1,13 @@
'use client'
import '../../three-types'
import { KeyboardControls } from '@react-three/drei'
import { sceneRegistry, useScene } from '@pascal-app/core'
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { KeyboardControls } from '@react-three/drei'
import { useFrame, useThree } from '@react-three/fiber'
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 BVHEcctrl from './first-person/bvh-ecctrl'
import type { BVHEcctrlApi } from './first-person/bvh-ecctrl'
import {
buildFirstPersonColliderWorldFromRegistry,
deriveFirstPersonSpawn,
@@ -17,10 +15,15 @@ import {
type FirstPersonColliderWorld,
type FirstPersonSpawn,
} 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 LOOK_SENSITIVITY = 0.002
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 = [
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
@@ -32,6 +35,14 @@ const keyboardMap = [
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
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 spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
@@ -52,7 +63,101 @@ export const FirstPersonControls = () => {
const controllerRef = useRef<BVHEcctrlApi | null>(null)
const yawRef = 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 [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>(() => {
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
@@ -84,25 +189,28 @@ export const FirstPersonControls = () => {
}, [placedSpawnNode])
useEffect(() => {
const nextWorld = buildFirstPersonColliderWorldFromRegistry()
if (!nextWorld) {
setWorld(null)
return
}
setWorld(nextWorld)
rebuildColliderWorld()
return () => {
nextWorld.dispose()
worldRef.current?.dispose()
worldRef.current = null
setWorld(null)
}
}, [camera])
}, [rebuildColliderWorld])
useEffect(() => {
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
}, [camera, placedSpawn, world])
setControllerStart({
position: [x, y - CONTROLLER_CENTER_FROM_EYE, z],
yaw: spawn.yaw,
})
}, [camera, controllerStart, placedSpawn, world])
useEffect(() => {
const canvas = gl.domElement
@@ -152,6 +260,10 @@ export const FirstPersonControls = () => {
document.exitPointerLock()
}
useEditor.getState().setFirstPersonMode(false)
} else if (event.code === 'KeyE') {
event.preventDefault()
event.stopPropagation()
toggleInteractableDoor()
}
}
@@ -159,7 +271,7 @@ export const FirstPersonControls = () => {
return () => {
document.removeEventListener('keydown', handleKeyDown, true)
}
}, [gl])
}, [gl, toggleInteractableDoor])
useFrame((_, delta) => {
if (!controllerRef.current?.group) return
@@ -170,18 +282,21 @@ export const FirstPersonControls = () => {
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
camera.quaternion.setFromEuler(cameraEuler)
camera.updateMatrixWorld(true)
const nextInteractableDoorId = resolveInteractableDoorId()
if (interactableDoorIdRef.current !== nextInteractableDoorId) {
interactableDoorIdRef.current = nextInteractableDoorId
useViewer.getState().setHoveredId(nextInteractableDoorId)
}
})
const controllerPosition = useMemo(() => {
if (!world) return null
const [x, y, z] = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).position
return [x, y - CONTROLLER_CENTER_FROM_EYE, z] as const
}, [camera, placedSpawn, world])
const spawnYaw = useMemo(() => {
if (!world) return 0
return (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw
}, [camera, placedSpawn, world])
useEffect(() => {
return () => {
if (useViewer.getState().hoveredId === interactableDoorIdRef.current) {
useViewer.getState().setHoveredId(null)
}
}
}, [])
if (!world) {
return null
@@ -189,11 +304,11 @@ export const FirstPersonControls = () => {
return (
<>
{controllerPosition && (
{controllerStart && (
<KeyboardControls map={keyboardMap}>
<BVHEcctrl
ref={controllerRef}
key={`${world.mesh.uuid}:${controllerPosition.join(':')}:${spawnYaw}`}
key="first-person-controller"
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
colliderMeshes={[world.mesh]}
collisionCheckIteration={3}
@@ -213,7 +328,7 @@ export const FirstPersonControls = () => {
maxRunSpeed={5.5}
maxSlope={1.2}
maxWalkSpeed={4}
position={controllerPosition}
position={controllerStart.position}
acceleration={26}
airDragFactor={0.3}
deceleration={30}
@@ -293,7 +408,9 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
<InlineControlHint label="Jump" keyLabel="Space" />
<InlineControlHint label="Sprint" keyLabel="Shift" />
<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>
)}
@@ -1,11 +1,7 @@
import { sceneRegistry, useScene } from '@pascal-app/core'
import {
acceleratedRaycast,
computeBoundsTree,
disposeBoundsTree,
} from 'three-mesh-bvh'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core'
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 = [
'wall',
@@ -16,6 +12,7 @@ const COLLIDER_NODE_TYPES = [
'roof',
'roof-segment',
'door',
'window',
'item',
] as const
@@ -25,6 +22,7 @@ const DOWN = new THREE.Vector3(0, -1, 0)
const UP = new THREE.Vector3(0, 1, 0)
const SPAWN_EYE_HEIGHT = 1.65
const RAYCAST_CLEARANCE = 25
const DOOR_LEAF_COLLIDER_DEPTH = 0.06
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[]) {
return Array.isArray(material)
? material.some((entry) => entry.visible)
: material.visible
return Array.isArray(material) ? material.some((entry) => entry.visible) : material.visible
}
function cloneWorldGeometry(mesh: THREE.Mesh) {
@@ -56,7 +52,9 @@ function cloneWorldGeometry(mesh: THREE.Mesh) {
const position = sourceGeometry.getAttribute('position')
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()
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]) {
if (type === 'window') {
const node = useScene.getState().nodes[nodeId as AnyNodeId]
return node?.type === 'window' && node.openingKind === 'opening'
}
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.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')
}
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() {
const nodeTypes = new Map<string, ColliderNodeType>()
@@ -164,6 +203,17 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
const root = sceneRegistry.nodes.get(nodeId)
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)
geometries.push(
...collectColliderGeometriesFromNode(
@@ -182,7 +232,9 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
}
const mergedGeometry = mergeGeometries(geometries, false)
geometries.forEach((geometry) => geometry.dispose())
geometries.forEach((geometry) => {
geometry.dispose()
})
if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
mergedGeometry?.dispose()
@@ -247,7 +299,8 @@ export function deriveFirstPersonSpawn(
}
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)
const intersections = raycaster.intersectObject(world.mesh, false)
const hit = intersections.find((intersection) => {
@@ -265,11 +318,7 @@ export function deriveFirstPersonSpawn(
}
return {
position: [
camera.position.x,
Math.max(camera.position.y, SPAWN_EYE_HEIGHT),
camera.position.z,
],
position: [camera.position.x, Math.max(camera.position.y, SPAWN_EYE_HEIGHT), camera.position.z],
yaw,
}
}
@@ -4154,6 +4154,137 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
const detailStrokeWidth = isSelected || isSelectionHighlighted ? '1.05' : '0.75'
const markerX = (p1!.x + p2!.x + p3!.x + p4!.x) / 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 (
<g
@@ -4345,7 +4476,22 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
const depthExtraOffset = 0.005
const doorCubeSize = Math.min(Math.max(width * 0.08, 0.06), 0.12)
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 hingeCubeCenter = {
x: hx + nx * hingeTangentSign * doorCubeInset,
@@ -4491,6 +4637,48 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
]
.map((point) => `${point.x},${point.y}`)
.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 (
<g
@@ -4546,25 +4734,39 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
{openingPlanPath ? (
<path
d={openingPlanPath}
fill="#ffffff"
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth="1.25"
fill={doorOpeningFill}
stroke={doorStroke}
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
) : (
<polygon
fill="#ffffff"
fill={doorOpeningFill}
points={doorBackgroundPoints}
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth="1.25"
stroke={doorStroke}
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
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 && (
<path
d={archPlanPath}
fill="none"
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
stroke={doorStroke}
strokeLinecap="round"
strokeWidth={arcStrokeWidth}
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) => (
<rect
fill="#ffffff"
fill={index === 0 ? doorLeafFill : '#ffffff'}
height={doorCubeSize}
key={`${opening.id}:door-cube:${index}`}
stroke={doorCubeStroke}
strokeWidth="1.25"
rx={doorCubeSize * 0.12}
stroke={index === 0 ? doorStroke : doorSoftStroke}
strokeWidth={index === 0 ? '1.35' : '1'}
vectorEffect="non-scaling-stroke"
width={doorCubeSize}
x={point.x - 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
fill="#ffffff"
fill={doorLeafFill}
points={leafPolygonPoints}
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth="1.25"
stroke={doorStroke}
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
<path
d={`M ${leafEnd.x} ${leafEnd.y} A ${swingRadius} ${swingRadius} 0 0 ${sweepFlag} ${arcEnd.x} ${arcEnd.y}`}
fill="none"
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
stroke={doorStroke}
strokeLinecap="round"
strokeWidth={arcStrokeWidth}
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>
)
}
@@ -15,6 +15,7 @@ interface SliderControlProps {
step?: number
className?: string
unit?: string
restoreOnCommit?: boolean
}
function stepPrecision(s: number): number {
@@ -56,6 +57,7 @@ export function SliderControl({
step = 1,
className,
unit = '',
restoreOnCommit = true,
}: SliderControlProps) {
const [isEditing, setIsEditing] = useState(false)
const [isDragging, setIsDragging] = useState(false)
@@ -161,7 +163,10 @@ export function SliderControl({
const newValue = clamp(
Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
)
if (newValue !== valueRef.current) {
valueRef.current = newValue
onChange(newValue)
}
},
[step, clamp, onChange],
)
@@ -175,7 +180,7 @@ export function SliderControl({
setIsDragging(false)
e.currentTarget.releasePointerCapture(e.pointerId)
if (originValue !== finalVal) {
if (originValue !== finalVal && restoreOnCommit) {
onChange(originValue)
useScene.temporal.getState().resume()
onChange(finalVal)
@@ -185,7 +190,7 @@ export function SliderControl({
onCommit?.(finalVal)
}
},
[onChange, onCommit],
[onChange, onCommit, restoreOnCommit],
)
const handleValueClick = useCallback(() => {
@@ -9,7 +9,7 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
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 { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
@@ -22,12 +22,32 @@ import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
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() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const previewRef = useRef<{
id: AnyNodeId
key: keyof DoorNode
value: unknown
} | null>(null)
const adapter = usePresetsAdapter()
@@ -37,10 +57,57 @@ export function DoorPanel() {
const handleUpdate = useCallback(
(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)
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],
)
@@ -134,6 +201,8 @@ export function DoorPanel() {
frameDepth: node.frameDepth,
openingKind: node.openingKind,
openingShape: node.openingShape,
openingRadiusMode: node.openingRadiusMode ?? 'all',
openingTopRadii: node.openingTopRadii ?? [0.15, 0.15],
cornerRadius: node.cornerRadius,
archHeight: node.archHeight,
openingRevealRadius: node.openingRevealRadius,
@@ -185,9 +254,22 @@ export function DoorPanel() {
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
const isOpening = node.openingKind === 'opening'
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 archHeight = node.archHeight ?? 0.45
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 (
<PanelWrapper
@@ -226,6 +308,8 @@ export function DoorPanel() {
? {
openingKind: v,
openingShape,
openingRadiusMode,
openingTopRadii,
cornerRadius,
archHeight,
openingRevealRadius,
@@ -276,6 +360,7 @@ export function DoorPanel() {
min={0.5}
onChange={(v) => handleUpdate({ width: v })}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
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]] })
}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={Math.round(node.height * 100) / 100}
@@ -301,7 +387,9 @@ export function DoorPanel() {
onChange={(v) =>
handleUpdate({
openingShape: v,
...(v === 'rounded' ? { cornerRadius, openingRevealRadius } : {}),
...(v === 'rounded'
? { openingRadiusMode, openingTopRadii, cornerRadius, openingRevealRadius }
: {}),
...(v === 'arch' ? { archHeight } : {}),
})
}
@@ -315,21 +403,57 @@ export function DoorPanel() {
</div>
{openingShape === 'rounded' && (
<>
<div className="flex flex-col gap-2 px-1 pb-1">
<SegmentedControl
onChange={(v) =>
handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] })
}
options={[
{ label: 'All', value: 'all' },
{ label: 'Individual', value: 'individual' },
]}
value={openingRadiusMode}
/>
</div>
{openingRadiusMode === 'all' ? (
<SliderControl
label="Corner Radius"
max={Math.min(node.width / 2, node.height)}
max={maxRoundedRadius}
min={0}
onChange={(v) => handleUpdate({ cornerRadius: v })}
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
label="Reveal Radius"
max={0.08}
min={0}
onChange={(v) => handleUpdate({ openingRevealRadius: v })}
onChange={(v) => previewDoorUpdate('openingRevealRadius', v)}
onCommit={(v) => commitDoorPreview('openingRevealRadius', v)}
precision={3}
step={0.005}
unit="m"
@@ -9,24 +9,75 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
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 { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
import { PresetsPopover } from './presets/presets-popover'
function isSameWindowValue(current: unknown, next: unknown): boolean {
if (typeof current === 'number' && typeof next === 'number') {
return Math.abs(current - next) < 1e-6
}
if (Array.isArray(current) && Array.isArray(next)) {
return (
current.length === next.length &&
current.every((value, index) => isSameWindowValue(value, next[index]))
)
}
return Object.is(current, next)
}
function getMaxSharedWindowRadius(width: number, height: number) {
return Math.max(0, Math.min(width / 2, height / 2))
}
function normalizeWindowCornerRadii(
radii: [number, number, number, number],
width: number,
height: number,
): [number, number, number, number] {
const next = radii.map((radius) => Math.max(radius, 0)) as [number, number, number, number]
const scale = Math.min(
1,
Math.max(width, 0) / Math.max(next[0] + next[1], 1e-6),
Math.max(width, 0) / Math.max(next[3] + next[2], 1e-6),
Math.max(height, 0) / Math.max(next[0] + next[3], 1e-6),
Math.max(height, 0) / Math.max(next[1] + next[2], 1e-6),
)
if (scale >= 1) return next
return next.map((radius) => radius * scale) as [number, number, number, number]
}
function isSameRadiusTuple(
current: [number, number, number, number],
next: [number, number, number, number],
) {
return current.every((value, index) => Math.abs(value - next[index]) < 1e-6)
}
export function WindowPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const previewRef = useRef<{
id: AnyNodeId
key: keyof WindowNode
value: unknown
} | null>(null)
const adapter = usePresetsAdapter()
@@ -36,10 +87,59 @@ export function WindowPanel() {
const handleUpdate = useCallback(
(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)
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],
)
@@ -84,6 +184,13 @@ export function WindowPanel() {
height: node.height,
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,
@@ -105,6 +212,13 @@ export function WindowPanel() {
height: node.height,
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,
@@ -151,6 +265,58 @@ export function WindowPanel() {
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 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 clamped = Math.max(0.05, Math.min(0.95, newVal))
@@ -206,6 +372,31 @@ export function WindowPanel() {
</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>
<PanelSection title="Position">
<SliderControl
label={
@@ -231,6 +422,7 @@ export function WindowPanel() {
unit="m"
value={Math.round(node.position[1] * 100) / 100}
/>
{!isOpening && (
<div className="px-1 pt-2 pb-1">
<ActionButton
className="w-full"
@@ -239,14 +431,16 @@ export function WindowPanel() {
onClick={handleFlip}
/>
</div>
)}
</PanelSection>
<PanelSection title="Dimensions">
<SliderControl
label="Width"
min={0}
onChange={(v) => handleUpdate({ width: v })}
onChange={(v) => handleUpdate(getDimensionUpdates({ width: v }))}
precision={2}
restoreOnCommit={false}
step={0.1}
unit="m"
value={Math.round(node.width * 100) / 100}
@@ -254,14 +448,107 @@ export function WindowPanel() {
<SliderControl
label="Height"
min={0}
onChange={(v) => handleUpdate({ height: v })}
onChange={(v) => handleUpdate(getDimensionUpdates({ height: v }))}
precision={2}
restoreOnCommit={false}
step={0.1}
unit="m"
value={Math.round(node.height * 100) / 100}
/>
</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}
step={0.05}
unit="m"
value={Math.round(archHeight * 100) / 100}
/>
</div>
)}
</PanelSection>
)}
{!isOpening && (
<>
<PanelSection title="Frame">
<SliderControl
label="Thickness"
@@ -405,6 +692,8 @@ export function WindowPanel() {
</div>
)}
</PanelSection>
</>
)}
<PanelSection title="Actions">
<ActionGroup>