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