From b3f795707650371fc9d9fdb095fcd14bdf7f99af Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 1 May 2026 13:54:23 +0530 Subject: [PATCH] Refactor editor selection and viewer integration --- packages/core/src/schema/nodes/door.ts | 2 + packages/core/src/schema/nodes/window.ts | 11 + .../core/src/systems/wall/wall-system.tsx | 168 +++++- .../core/src/systems/window/window-system.tsx | 10 + .../editor/first-person-controls.tsx | 181 +++++- .../first-person/build-collider-world.ts | 87 ++- .../src/components/editor/floorplan-panel.tsx | 306 +++++++++- .../components/ui/controls/slider-control.tsx | 11 +- .../src/components/ui/panels/door-panel.tsx | 152 ++++- .../src/components/ui/panels/window-panel.tsx | 563 +++++++++++++----- 10 files changed, 1242 insertions(+), 249 deletions(-) diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index 57e0ad46..8940270a 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -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), diff --git a/packages/core/src/schema/nodes/window.ts b/packages/core/src/schema/nodes/window.ts index 7e28b71f..a497175a 100644 --- a/packages/core/src/schema/nodes/window.ts +++ b/packages/core/src/schema/nodes/window.ts @@ -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), diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index ba47cea0..dc2bf8a2 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -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() +} diff --git a/packages/core/src/systems/window/window-system.tsx b/packages/core/src/systems/window/window-system.tsx index d27e2857..65d68455 100644 --- a/packages/core/src/systems/window/window-system.tsx +++ b/packages/core/src/systems/window/window-system.tsx @@ -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) { diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index b6365ce9..ee39342e 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -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,13 +35,21 @@ 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') const resolvePlacedSpawnNode = ( nodes: ReturnType['nodes'], _levelId: string | null, - ) => { +) => { const candidates = Object.values(nodes).filter((node) => node.type === 'spawn') if (candidates.length === 0) return null @@ -52,7 +63,101 @@ export const FirstPersonControls = () => { const controllerRef = useRef(null) const yawRef = useRef(0) const pitchRef = useRef(0) + const interactableDoorIdRef = useRef(null) + const worldRef = useRef(null) const [world, setWorld] = useState(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(() => { 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 && ( { 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 }) => {
- Click to look around + + Click to look around +
)} diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts index 0b951ede..c373d6da 100644 --- a/packages/editor/src/components/editor/first-person/build-collider-world.ts +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -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() @@ -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, } } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index d77dffee..bf3b5f9c 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -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 ( + { + 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 && ( + + )} + + {detailPath ? ( + + ) : ( + + )} + {isSelected ? ( + <> + + + + + ) : null} + + ) + } return ( `${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 ( ) : ( )} + {archPlanPath && ( @@ -4572,36 +4774,108 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ ) : ( <> - + + {swingSweepPath && ( + + )} + {swingAngle > 0.03 && ( + + )} {[hingeCubeCenter, strikeCubeCenter].map((point, index) => ( ))} + + )} + {isSelected ? ( + <> + + + + + ) : null} ) } diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index e2bb41d8..12a1f954 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -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))), ) - onChange(newValue) + 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(() => { diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index 22142ee4..363a859c 100755 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -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) => { - 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( + (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( + (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) + 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 ( 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() { {openingShape === 'rounded' && ( <> - handleUpdate({ cornerRadius: v })} - precision={2} - step={0.05} - unit="m" - value={Math.round(cornerRadius * 100) / 100} - /> +
+ + handleUpdate({ openingRadiusMode: v as DoorNode['openingRadiusMode'] }) + } + options={[ + { label: 'All', value: 'all' }, + { label: 'Individual', value: 'individual' }, + ]} + value={openingRadiusMode} + /> +
+ {openingRadiusMode === 'all' ? ( + 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]) => ( + 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} + /> + ))} + + )} handleUpdate({ openingRevealRadius: v })} + onChange={(v) => previewDoorUpdate('openingRevealRadius', v)} + onCommit={(v) => commitDoorPreview('openingRevealRadius', v)} precision={3} step={0.005} unit="m" diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx index a41891b0..53162017 100755 --- a/packages/editor/src/components/ui/panels/window-panel.tsx +++ b/packages/editor/src/components/ui/panels/window-panel.tsx @@ -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) => { - 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( + (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( + (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) + 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>) => { + const nextWidth = updates.width ?? node.width + const nextHeight = updates.height ?? node.height + const nextUpdates: Partial = { ...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() { + + + 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'} + /> + + -
- } - label="Flip Side" - onClick={handleFlip} - /> -
+ {!isOpening && ( +
+ } + label="Flip Side" + onClick={handleFlip} + /> +
+ )}
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,157 +448,252 @@ export function WindowPanel() { 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} /> - - handleUpdate({ frameThickness: v })} - precision={3} - step={0.01} - unit="m" - value={Math.round(node.frameThickness * 1000) / 1000} - /> - handleUpdate({ frameDepth: v })} - precision={3} - step={0.01} - unit="m" - value={Math.round(node.frameDepth * 1000) / 1000} - /> - - - - { - const n = Math.max(1, Math.min(8, Math.round(v))) - handleUpdate({ columnRatios: Array(n).fill(1 / n) }) - }} - precision={0} - step={1} - value={numCols} - /> - { - 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 && ( -
-
- Col Widths -
- {normCols.map((ratio, i) => ( - setColumnRatio(i, v / 100)} - precision={1} - step={1} - unit="%" - value={Math.round(ratio * 100 * 10) / 10} + {isOpening && ( + + + handleUpdate({ openingShape: value as WindowNode['openingShape'] }) + } + options={[ + { value: 'rectangle', label: 'Rect' }, + { value: 'rounded', label: 'Rounded' }, + { value: 'arch', label: 'Arch' }, + ]} + value={openingShape} + /> + {openingShape === 'rounded' && ( +
+ + handleUpdate({ openingRadiusMode: value as WindowNode['openingRadiusMode'] }) + } + options={[ + { value: 'all', label: 'All' }, + { value: 'individual', label: 'Individual' }, + ]} + value={openingRadiusMode} /> - ))} -
+ {openingRadiusMode === 'all' ? ( + 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]) => ( + 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} + /> + ))} + + )} handleUpdate({ columnDividerThickness: v })} + label="Reveal Radius" + max={0.08} + min={0} + onChange={(value) => previewWindowUpdate('openingRevealRadius', value)} + onCommit={(value) => commitWindowPreview('openingRevealRadius', value)} precision={3} - step={0.01} + step={0.005} unit="m" - value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000} + value={Math.round(openingRevealRadius * 1000) / 1000} />
-
- )} - - {numRows > 1 && ( -
-
- Row Heights -
- {normRows.map((ratio, i) => ( + )} + {openingShape === 'arch' && ( +
setRowRatio(i, v / 100)} - precision={1} - step={1} - unit="%" - value={Math.round(ratio * 100 * 10) / 10} - /> - ))} -
- handleUpdate({ rowDividerThickness: v })} - precision={3} - step={0.01} + 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((node.rowDividerThickness ?? 0.03) * 1000) / 1000} + value={Math.round(archHeight * 100) / 100} />
-
- )} - + )} + + )} - - handleUpdate({ sill: checked })} - /> - {node.sill && ( -
- handleUpdate({ sillDepth: v })} - precision={3} - step={0.01} - unit="m" - value={Math.round(node.sillDepth * 1000) / 1000} - /> + {!isOpening && ( + <> + handleUpdate({ sillThickness: v })} + onChange={(v) => handleUpdate({ frameThickness: v })} precision={3} step={0.01} unit="m" - value={Math.round(node.sillThickness * 1000) / 1000} + value={Math.round(node.frameThickness * 1000) / 1000} /> -
- )} -
+ handleUpdate({ frameDepth: v })} + precision={3} + step={0.01} + unit="m" + value={Math.round(node.frameDepth * 1000) / 1000} + /> + + + + { + const n = Math.max(1, Math.min(8, Math.round(v))) + handleUpdate({ columnRatios: Array(n).fill(1 / n) }) + }} + precision={0} + step={1} + value={numCols} + /> + { + 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 && ( +
+
+ Col Widths +
+ {normCols.map((ratio, i) => ( + setColumnRatio(i, v / 100)} + precision={1} + step={1} + unit="%" + value={Math.round(ratio * 100 * 10) / 10} + /> + ))} +
+ handleUpdate({ columnDividerThickness: v })} + precision={3} + step={0.01} + unit="m" + value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000} + /> +
+
+ )} + + {numRows > 1 && ( +
+
+ Row Heights +
+ {normRows.map((ratio, i) => ( + setRowRatio(i, v / 100)} + precision={1} + step={1} + unit="%" + value={Math.round(ratio * 100 * 10) / 10} + /> + ))} +
+ handleUpdate({ rowDividerThickness: v })} + precision={3} + step={0.01} + unit="m" + value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000} + /> +
+
+ )} +
+ + + handleUpdate({ sill: checked })} + /> + {node.sill && ( +
+ handleUpdate({ sillDepth: v })} + precision={3} + step={0.01} + unit="m" + value={Math.round(node.sillDepth * 1000) / 1000} + /> + handleUpdate({ sillThickness: v })} + precision={3} + step={0.01} + unit="m" + value={Math.round(node.sillThickness * 1000) / 1000} + /> +
+ )} +
+ + )}