Refactor editor selection and viewer integration
This commit is contained in:
@@ -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<typeof useScene.getState>['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<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))),
|
||||
)
|
||||
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(() => {
|
||||
|
||||
@@ -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' && (
|
||||
<>
|
||||
<SliderControl
|
||||
label="Corner Radius"
|
||||
max={Math.min(node.width / 2, node.height)}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ cornerRadius: v })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(cornerRadius * 100) / 100}
|
||||
/>
|
||||
<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={maxRoundedRadius}
|
||||
min={0}
|
||||
onChange={(v) => previewDoorUpdate('cornerRadius', v)}
|
||||
onCommit={(v) => commitDoorPreview('cornerRadius', v)}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(cornerRadius * 100) / 100}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{[
|
||||
['Top Left', 0],
|
||||
['Top Right', 1],
|
||||
].map(([label, index]) => (
|
||||
<SliderControl
|
||||
key={label}
|
||||
label={label}
|
||||
max={maxRoundedRadius}
|
||||
min={0}
|
||||
onChange={(v) => setOpeningTopRadius(index as number, v)}
|
||||
onCommit={(v) => setOpeningTopRadius(index as number, v, true)}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((openingTopRadii[index as number] ?? 0) * 100) / 100}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<SliderControl
|
||||
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,22 +422,25 @@ export function WindowPanel() {
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
<div className="px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
className="w-full"
|
||||
icon={<FlipHorizontal2 className="h-4 w-4" />}
|
||||
label="Flip Side"
|
||||
onClick={handleFlip}
|
||||
/>
|
||||
</div>
|
||||
{!isOpening && (
|
||||
<div className="px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
className="w-full"
|
||||
icon={<FlipHorizontal2 className="h-4 w-4" />}
|
||||
label="Flip Side"
|
||||
onClick={handleFlip}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Width"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ 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() {
|
||||
<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>
|
||||
|
||||
<PanelSection title="Frame">
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.frameThickness * 1000) / 1000}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Grid">
|
||||
<SliderControl
|
||||
label="Columns"
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={numCols}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rows"
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={numRows}
|
||||
/>
|
||||
|
||||
{numCols > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Col Widths
|
||||
</div>
|
||||
{normCols.map((ratio, i) => (
|
||||
<SliderControl
|
||||
key={`c-${i}`}
|
||||
label={`C${i + 1}`}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setColumnRatio(i, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
{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="Divider"
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{numRows > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Row Heights
|
||||
</div>
|
||||
{normRows.map((ratio, i) => (
|
||||
)}
|
||||
{openingShape === 'arch' && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
key={`r-${i}`}
|
||||
label={`R${i + 1}`}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setRowRatio(i, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Sill">
|
||||
<ToggleControl
|
||||
checked={node.sill}
|
||||
label="Enable Sill"
|
||||
onChange={(checked) => handleUpdate({ sill: checked })}
|
||||
/>
|
||||
{node.sill && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ sillDepth: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.sillDepth * 1000) / 1000}
|
||||
/>
|
||||
{!isOpening && (
|
||||
<>
|
||||
<PanelSection title="Frame">
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
min={0}
|
||||
onChange={(v) => 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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.frameDepth * 1000) / 1000}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Grid">
|
||||
<SliderControl
|
||||
label="Columns"
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ columnRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={numCols}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rows"
|
||||
max={8}
|
||||
min={1}
|
||||
onChange={(v) => {
|
||||
const n = Math.max(1, Math.min(8, Math.round(v)))
|
||||
handleUpdate({ rowRatios: Array(n).fill(1 / n) })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
value={numRows}
|
||||
/>
|
||||
|
||||
{numCols > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Col Widths
|
||||
</div>
|
||||
{normCols.map((ratio, i) => (
|
||||
<SliderControl
|
||||
key={`c-${i}`}
|
||||
label={`C${i + 1}`}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setColumnRatio(i, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => handleUpdate({ columnDividerThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.columnDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{numRows > 1 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<div className="mb-1 px-1 font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
|
||||
Row Heights
|
||||
</div>
|
||||
{normRows.map((ratio, i) => (
|
||||
<SliderControl
|
||||
key={`r-${i}`}
|
||||
label={`R${i + 1}`}
|
||||
max={95}
|
||||
min={5}
|
||||
onChange={(v) => setRowRatio(i, v / 100)}
|
||||
precision={1}
|
||||
step={1}
|
||||
unit="%"
|
||||
value={Math.round(ratio * 100 * 10) / 10}
|
||||
/>
|
||||
))}
|
||||
<div className="mt-1 border-border/50 border-t pt-1">
|
||||
<SliderControl
|
||||
label="Divider"
|
||||
max={0.1}
|
||||
min={0.005}
|
||||
onChange={(v) => handleUpdate({ rowDividerThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.rowDividerThickness ?? 0.03) * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Sill">
|
||||
<ToggleControl
|
||||
checked={node.sill}
|
||||
label="Enable Sill"
|
||||
onChange={(checked) => handleUpdate({ sill: checked })}
|
||||
/>
|
||||
{node.sill && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ sillDepth: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.sillDepth * 1000) / 1000}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ sillThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round(node.sillThickness * 1000) / 1000}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
|
||||
Reference in New Issue
Block a user