Merge pull request #293 from sudhir9297/feat/door-improvement
Feat/door improvement
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
||||
ColumnNode,
|
||||
DoorNode,
|
||||
FenceNode,
|
||||
GuideNode,
|
||||
ItemNode,
|
||||
LevelNode,
|
||||
RoofNode,
|
||||
@@ -132,6 +133,19 @@ type ToolEvents = {
|
||||
'tool:cancel': undefined
|
||||
}
|
||||
|
||||
type GuideEvents = {
|
||||
'guide:set-reference-scale': { guideId: GuideNode['id'] }
|
||||
'guide:cancel-reference-scale': undefined
|
||||
'guide:deleted': { guideId: GuideNode['id'] }
|
||||
}
|
||||
|
||||
type DoorAnimationEvents = {
|
||||
'door:animation-completed': {
|
||||
doorId: DoorNode['id']
|
||||
field: 'operationState' | 'swingAngle'
|
||||
}
|
||||
}
|
||||
|
||||
type PresetEvents = {
|
||||
'preset:generate-thumbnail': { presetId: string; nodeId: string }
|
||||
'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string }
|
||||
@@ -173,6 +187,8 @@ type EditorEvents = GridEvents &
|
||||
NodeEvents<'door', DoorEvent> &
|
||||
CameraControlEvents &
|
||||
ToolEvents &
|
||||
GuideEvents &
|
||||
DoorAnimationEvents &
|
||||
PresetEvents &
|
||||
ThumbnailEvents &
|
||||
SnapshotEvents &
|
||||
|
||||
@@ -34,6 +34,13 @@ export {
|
||||
} from './hooks/spatial-grid/spatial-grid-sync'
|
||||
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
|
||||
export { loadAssetUrl, saveAsset } from './lib/asset-storage'
|
||||
export {
|
||||
clampDoorOperationState,
|
||||
getDoorRenderOpenAmount,
|
||||
getGarageVisibleOpeningRatio,
|
||||
isOperationDoorType,
|
||||
SECTIONAL_GARAGE_RENDER_OPEN_SCALE,
|
||||
} from './lib/door-operation'
|
||||
export { getRenderableSlabPolygon } from './lib/slab-polygon'
|
||||
export {
|
||||
detectSpacesForLevel,
|
||||
@@ -62,6 +69,8 @@ export {
|
||||
} from './store/history-control'
|
||||
export {
|
||||
type ControlValue,
|
||||
type DoorAnimationState,
|
||||
type DoorInteractiveState,
|
||||
type ItemInteractiveState,
|
||||
useInteractive,
|
||||
} from './store/use-interactive'
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { DoorNode, DoorType } from '../schema/nodes/door'
|
||||
|
||||
export const SECTIONAL_GARAGE_RENDER_OPEN_SCALE = 0.88
|
||||
|
||||
export function clampDoorOperationState(value: number | undefined) {
|
||||
return Math.max(0, Math.min(1, value ?? 0))
|
||||
}
|
||||
|
||||
export function isOperationDoorType(
|
||||
doorType: DoorType | DoorNode['doorType'] | string | undefined,
|
||||
) {
|
||||
return (
|
||||
doorType === 'folding' ||
|
||||
doorType === 'pocket' ||
|
||||
doorType === 'barn' ||
|
||||
doorType === 'sliding' ||
|
||||
doorType === 'garage-sectional' ||
|
||||
doorType === 'garage-rollup' ||
|
||||
doorType === 'garage-tiltup'
|
||||
)
|
||||
}
|
||||
|
||||
export function getDoorRenderOpenAmount(
|
||||
doorType: DoorType | DoorNode['doorType'],
|
||||
operationState: number | undefined,
|
||||
) {
|
||||
const openAmount = clampDoorOperationState(operationState)
|
||||
return doorType === 'garage-sectional'
|
||||
? openAmount * SECTIONAL_GARAGE_RENDER_OPEN_SCALE
|
||||
: openAmount
|
||||
}
|
||||
|
||||
export function getGarageVisibleOpeningRatio(
|
||||
doorType: DoorType | DoorNode['doorType'],
|
||||
operationState: number | undefined,
|
||||
) {
|
||||
if (doorType === 'garage-sectional') {
|
||||
return Math.min(1, clampDoorOperationState(operationState) / SECTIONAL_GARAGE_RENDER_OPEN_SCALE)
|
||||
}
|
||||
|
||||
return clampDoorOperationState(operationState)
|
||||
}
|
||||
@@ -18,6 +18,25 @@ export const DoorSegment = z.object({
|
||||
|
||||
export type DoorSegment = z.infer<typeof DoorSegment>
|
||||
|
||||
export const DoorCategory = z.enum(['interior', 'garage'])
|
||||
export const DoorType = z.enum([
|
||||
'hinged',
|
||||
'double',
|
||||
'french',
|
||||
'folding',
|
||||
'pocket',
|
||||
'barn',
|
||||
'sliding',
|
||||
'garage-sectional',
|
||||
'garage-rollup',
|
||||
'garage-tiltup',
|
||||
])
|
||||
export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead'])
|
||||
|
||||
export type DoorCategory = z.infer<typeof DoorCategory>
|
||||
export type DoorType = z.infer<typeof DoorType>
|
||||
export type DoorTrackStyle = z.infer<typeof DoorTrackStyle>
|
||||
|
||||
export const DoorNode = BaseNode.extend({
|
||||
id: objectId('door'),
|
||||
type: nodeType('door'),
|
||||
@@ -32,6 +51,15 @@ export const DoorNode = BaseNode.extend({
|
||||
width: z.number().default(0.9),
|
||||
height: z.number().default(2.1),
|
||||
|
||||
// Door family
|
||||
doorCategory: DoorCategory.default('interior'),
|
||||
doorType: DoorType.default('hinged'),
|
||||
leafCount: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).default(1),
|
||||
operationState: z.number().min(0).max(1).default(0),
|
||||
slideDirection: z.enum(['left', 'right']).default('left'),
|
||||
trackStyle: DoorTrackStyle.default('none'),
|
||||
garagePanelCount: z.number().int().min(1).max(12).default(4),
|
||||
|
||||
// Opening mode
|
||||
openingKind: z.enum(['door', 'opening']).default('door'),
|
||||
openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'),
|
||||
@@ -90,6 +118,7 @@ export const DoorNode = BaseNode.extend({
|
||||
panicBarHeight: z.number().default(1.0),
|
||||
}).describe(dedent`Door node - a parametric door placed on a wall
|
||||
- position: center of the door in wall-local coordinate system (Y = height/2, always at floor)
|
||||
- doorCategory/doorType: explicit operation family, defaulting old doors to interior hinged
|
||||
- openingKind/openingShape: hinged door or frameless wall opening shape
|
||||
- segments: rows stacked top to bottom, each defining its own columnRatios
|
||||
- type 'empty' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed
|
||||
|
||||
@@ -12,8 +12,24 @@ export type ItemInteractiveState = {
|
||||
controlValues: ControlValue[]
|
||||
}
|
||||
|
||||
export type DoorInteractiveState = {
|
||||
operationState?: number
|
||||
swingAngle?: number
|
||||
}
|
||||
|
||||
export type DoorAnimationState = {
|
||||
field: keyof DoorInteractiveState
|
||||
from: number
|
||||
to: number
|
||||
startedAt: number | null
|
||||
durationMs: number
|
||||
persist: boolean
|
||||
}
|
||||
|
||||
type InteractiveStore = {
|
||||
items: Record<AnyNodeId, ItemInteractiveState>
|
||||
doors: Record<AnyNodeId, DoorInteractiveState>
|
||||
doorAnimations: Record<AnyNodeId, DoorAnimationState>
|
||||
|
||||
/** Initialize a node's interactive state from its asset definition (idempotent) */
|
||||
initItem: (itemId: AnyNodeId, interactive: Interactive) => void
|
||||
@@ -23,6 +39,18 @@ type InteractiveStore = {
|
||||
|
||||
/** Remove a node's state (e.g. on unmount) */
|
||||
removeItem: (itemId: AnyNodeId) => void
|
||||
|
||||
/** Set transient door open state without committing it to the scene node */
|
||||
setDoorOpenState: (doorId: AnyNodeId, value: DoorInteractiveState) => void
|
||||
|
||||
/** Clear transient door open state */
|
||||
removeDoorOpenState: (doorId: AnyNodeId) => void
|
||||
|
||||
/** Queue a door animation for the viewer frame loop */
|
||||
startDoorAnimation: (doorId: AnyNodeId, value: DoorAnimationState) => void
|
||||
|
||||
/** Cancel a queued door animation */
|
||||
cancelDoorAnimation: (doorId: AnyNodeId) => void
|
||||
}
|
||||
|
||||
const defaultControlValue = (interactive: Interactive, index: number): ControlValue => {
|
||||
@@ -40,6 +68,8 @@ const defaultControlValue = (interactive: Interactive, index: number): ControlVa
|
||||
|
||||
export const useInteractive = create<InteractiveStore>((set, get) => ({
|
||||
items: {},
|
||||
doors: {},
|
||||
doorAnimations: {},
|
||||
|
||||
initItem: (itemId, interactive) => {
|
||||
const { controls } = interactive
|
||||
@@ -74,4 +104,39 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
|
||||
return { items: rest }
|
||||
})
|
||||
},
|
||||
|
||||
setDoorOpenState: (doorId, value) => {
|
||||
set((state) => ({
|
||||
doors: {
|
||||
...state.doors,
|
||||
[doorId]: {
|
||||
...state.doors[doorId],
|
||||
...value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
},
|
||||
|
||||
removeDoorOpenState: (doorId) => {
|
||||
set((state) => {
|
||||
const { [doorId]: _, ...rest } = state.doors
|
||||
return { doors: rest }
|
||||
})
|
||||
},
|
||||
|
||||
startDoorAnimation: (doorId, value) => {
|
||||
set((state) => ({
|
||||
doorAnimations: {
|
||||
...state.doorAnimations,
|
||||
[doorId]: value,
|
||||
},
|
||||
}))
|
||||
},
|
||||
|
||||
cancelDoorAnimation: (doorId) => {
|
||||
set((state) => {
|
||||
const { [doorId]: _, ...rest } = state.doorAnimations
|
||||
return { doorAnimations: rest }
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import '../../three-types'
|
||||
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { type AnyNodeId, emitter, sceneRegistry, useInteractive, 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 { Box3, Euler, Matrix4, Ray, Raycaster, Vector2, Vector3 } from 'three'
|
||||
import {
|
||||
DOOR_SWING_OPEN_ANGLE,
|
||||
isOperationDoorType,
|
||||
toggleDoorOpenState,
|
||||
} from '../../lib/door-interaction'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import {
|
||||
buildFirstPersonColliderWorldFromRegistry,
|
||||
@@ -22,7 +27,6 @@ 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'] },
|
||||
@@ -43,6 +47,12 @@ const doorLeafLocalHit = new Vector3()
|
||||
const doorLeafLocalRay = new Ray()
|
||||
const doorLeafMatrix = new Matrix4()
|
||||
const doorLeafWorldHit = new Vector3()
|
||||
const doorOpeningBox = new Box3()
|
||||
const doorOpeningInverseMatrix = new Matrix4()
|
||||
const doorOpeningLocalHit = new Vector3()
|
||||
const doorOpeningLocalRay = new Ray()
|
||||
const doorOpeningMatrix = new Matrix4()
|
||||
const doorOpeningWorldHit = new Vector3()
|
||||
const spawnWorldPosition = new Vector3()
|
||||
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
|
||||
|
||||
@@ -113,10 +123,45 @@ export const FirstPersonControls = () => {
|
||||
if (leafW <= 0 || leafH <= 0) continue
|
||||
|
||||
const leafCenterY = -node.frameThickness / 2
|
||||
|
||||
if (isOperationDoorType(node.doorType)) {
|
||||
doorOpeningMatrix
|
||||
.copy(object.matrixWorld)
|
||||
.multiply(new Matrix4().makeTranslation(0, leafCenterY, 0))
|
||||
doorOpeningInverseMatrix.copy(doorOpeningMatrix).invert()
|
||||
doorOpeningBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2)
|
||||
doorOpeningBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2)
|
||||
doorOpeningLocalRay
|
||||
.copy(doorInteractionRaycaster.ray)
|
||||
.applyMatrix4(doorOpeningInverseMatrix)
|
||||
|
||||
const localOpeningHit = doorOpeningLocalRay.intersectBox(
|
||||
doorOpeningBox,
|
||||
doorOpeningLocalHit,
|
||||
)
|
||||
if (!localOpeningHit) continue
|
||||
|
||||
doorOpeningWorldHit.copy(localOpeningHit).applyMatrix4(doorOpeningMatrix)
|
||||
const openingHitDistance = doorOpeningWorldHit.distanceTo(
|
||||
doorInteractionRaycaster.ray.origin,
|
||||
)
|
||||
|
||||
if (
|
||||
openingHitDistance <= DOOR_INTERACTION_DISTANCE &&
|
||||
openingHitDistance < closestDistance
|
||||
) {
|
||||
closestDoorId = doorId as AnyNodeId
|
||||
closestDistance = openingHitDistance
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
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 currentSwingAngle =
|
||||
useInteractive.getState().doors[doorId as AnyNodeId]?.swingAngle ?? node.swingAngle ?? 0
|
||||
const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, currentSwingAngle))
|
||||
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
|
||||
|
||||
doorLeafMatrix
|
||||
@@ -151,13 +196,8 @@ export const FirstPersonControls = () => {
|
||||
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])
|
||||
toggleDoorOpenState(doorId, { persist: false })
|
||||
}, [resolveInteractableDoorId])
|
||||
|
||||
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
|
||||
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
|
||||
@@ -198,6 +238,11 @@ export const FirstPersonControls = () => {
|
||||
}
|
||||
}, [rebuildColliderWorld])
|
||||
|
||||
useEffect(() => {
|
||||
emitter.on('door:animation-completed', rebuildColliderWorld)
|
||||
return () => emitter.off('door:animation-completed', rebuildColliderWorld)
|
||||
}, [rebuildColliderWorld])
|
||||
|
||||
useEffect(() => {
|
||||
if (!world) return
|
||||
if (controllerStart) return
|
||||
@@ -260,7 +305,7 @@ export const FirstPersonControls = () => {
|
||||
document.exitPointerLock()
|
||||
}
|
||||
useEditor.getState().setFirstPersonMode(false)
|
||||
} else if (event.code === 'KeyE') {
|
||||
} else if (event.code === 'KeyE' || event.code === 'KeyR') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleInteractableDoor()
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
getGarageVisibleOpeningRatio,
|
||||
type AnyNodeId,
|
||||
type DoorNode,
|
||||
isOperationDoorType,
|
||||
sceneRegistry,
|
||||
useInteractive,
|
||||
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'
|
||||
@@ -23,6 +31,7 @@ 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
|
||||
const OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD = 0.85
|
||||
|
||||
export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT
|
||||
|
||||
@@ -104,14 +113,46 @@ function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
|
||||
if (leafW <= 0 || leafH <= 0) return null
|
||||
|
||||
const leafCenterY = -node.frameThickness / 2
|
||||
const runtimeDoorState = useInteractive.getState().doors[node.id]
|
||||
const operationState = runtimeDoorState?.operationState ?? node.operationState
|
||||
const swingAngle = runtimeDoorState?.swingAngle ?? node.swingAngle
|
||||
|
||||
root.updateWorldMatrix(true, false)
|
||||
|
||||
if (node.doorType === 'garage-sectional' || node.doorType === 'garage-rollup') {
|
||||
const openAmount = getGarageVisibleOpeningRatio(node.doorType, operationState)
|
||||
const visibleHeight = leafH * (1 - openAmount)
|
||||
if (visibleHeight <= 0.12) return null
|
||||
|
||||
const sourceGeometry = new THREE.BoxGeometry(
|
||||
leafW,
|
||||
visibleHeight,
|
||||
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 visibleCenterY = leafCenterY - leafH / 2 + visibleHeight / 2
|
||||
geometry.applyMatrix4(
|
||||
root.matrixWorld.clone().multiply(new THREE.Matrix4().makeTranslation(0, visibleCenterY, 0)),
|
||||
)
|
||||
return geometry
|
||||
}
|
||||
|
||||
if (
|
||||
isOperationDoorType(node.doorType) &&
|
||||
(operationState ?? 0) >= OPERATION_DOOR_COLLIDER_OPEN_THRESHOLD
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
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 clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle ?? 0))
|
||||
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
|
||||
|
||||
root.updateWorldMatrix(true, false)
|
||||
|
||||
const sourceGeometry = new THREE.BoxGeometry(
|
||||
leafW,
|
||||
leafH,
|
||||
|
||||
@@ -4680,6 +4680,292 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
x: (svgP2.x + svgP3.x) / 2,
|
||||
y: (svgP2.y + svgP3.y) / 2,
|
||||
}
|
||||
const isFoldingDoor = opening.doorType === 'folding'
|
||||
const foldingPanelCount = opening.leafCount === 2 ? 2 : 4
|
||||
const foldingAmount = Math.max(0, Math.min(1, opening.operationState ?? 0))
|
||||
const foldingSpan = Math.max(1e-6, Math.hypot(svgP2.x - svgP1.x, svgP2.y - svgP1.y))
|
||||
const foldingPanelLength = foldingSpan / foldingPanelCount
|
||||
const foldingAngle = Math.PI * 0.44 * foldingAmount
|
||||
const foldingPoints = isFoldingDoor
|
||||
? Array.from({ length: foldingPanelCount + 1 }).reduce<Point2D[]>(
|
||||
(points, _, index) => {
|
||||
if (index === 0) return [{ x: svgP1.x, y: svgP1.y }]
|
||||
|
||||
const previous = points[index - 1]!
|
||||
const direction = (index - 1) % 2 === 0 ? -1 : 1
|
||||
const angle = direction * foldingAngle
|
||||
const along = Math.cos(angle) * foldingPanelLength
|
||||
const out = Math.sin(angle) * foldingPanelLength * swingSign
|
||||
points.push({
|
||||
x: previous.x + nx * along + px * out,
|
||||
y: previous.y + ny * along + py * out,
|
||||
})
|
||||
return points
|
||||
},
|
||||
[],
|
||||
)
|
||||
: []
|
||||
const foldingPath =
|
||||
foldingPoints.length > 0
|
||||
? foldingPoints
|
||||
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
|
||||
.join(' ')
|
||||
: null
|
||||
const isPocketDoor = opening.doorType === 'pocket'
|
||||
const pocketAmount = Math.max(0, Math.min(1, opening.operationState ?? 0))
|
||||
const pocketSign = opening.slideDirection === 'right' ? 1 : -1
|
||||
const pocketShift = pocketSign * foldingSpan * pocketAmount
|
||||
const pocketTrackStart =
|
||||
pocketSign > 0
|
||||
? svgP1
|
||||
: { x: svgP1.x - nx * foldingSpan, y: svgP1.y - ny * foldingSpan }
|
||||
const pocketTrackEnd =
|
||||
pocketSign > 0
|
||||
? { x: svgP2.x + nx * foldingSpan, y: svgP2.y + ny * foldingSpan }
|
||||
: svgP2
|
||||
const pocketLeafStart = {
|
||||
x: svgP1.x + nx * pocketShift + px * swingSign * doorCubeSize * 0.5,
|
||||
y: svgP1.y + ny * pocketShift + py * swingSign * doorCubeSize * 0.5,
|
||||
}
|
||||
const pocketLeafEnd = {
|
||||
x: svgP2.x + nx * pocketShift + px * swingSign * doorCubeSize * 0.5,
|
||||
y: svgP2.y + ny * pocketShift + py * swingSign * doorCubeSize * 0.5,
|
||||
}
|
||||
const pocketLeafPoints = [
|
||||
{
|
||||
x: pocketLeafStart.x - px * leafHalfThickness,
|
||||
y: pocketLeafStart.y - py * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: pocketLeafEnd.x - px * leafHalfThickness,
|
||||
y: pocketLeafEnd.y - py * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: pocketLeafEnd.x + px * leafHalfThickness,
|
||||
y: pocketLeafEnd.y + py * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: pocketLeafStart.x + px * leafHalfThickness,
|
||||
y: pocketLeafStart.y + py * leafHalfThickness,
|
||||
},
|
||||
]
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(' ')
|
||||
const isBarnDoor = opening.doorType === 'barn'
|
||||
const barnLeafStart = {
|
||||
x: pocketLeafStart.x + px * swingSign * doorCubeSize * 0.75,
|
||||
y: pocketLeafStart.y + py * swingSign * doorCubeSize * 0.75,
|
||||
}
|
||||
const barnLeafEnd = {
|
||||
x: pocketLeafEnd.x + px * swingSign * doorCubeSize * 0.75,
|
||||
y: pocketLeafEnd.y + py * swingSign * doorCubeSize * 0.75,
|
||||
}
|
||||
const barnLeafPoints = [
|
||||
{
|
||||
x: barnLeafStart.x - px * leafHalfThickness,
|
||||
y: barnLeafStart.y - py * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: barnLeafEnd.x - px * leafHalfThickness,
|
||||
y: barnLeafEnd.y - py * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: barnLeafEnd.x + px * leafHalfThickness,
|
||||
y: barnLeafEnd.y + py * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: barnLeafStart.x + px * leafHalfThickness,
|
||||
y: barnLeafStart.y + py * leafHalfThickness,
|
||||
},
|
||||
]
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(' ')
|
||||
const isSlidingDoor = opening.doorType === 'sliding'
|
||||
const slidingPanelSpan = foldingSpan * 0.54
|
||||
const slidingActiveOnRight = opening.slideDirection !== 'right'
|
||||
const slidingFixedSign = slidingActiveOnRight ? -1 : 1
|
||||
const slidingActiveSign = slidingActiveOnRight ? 1 : -1
|
||||
const slidingFixedCenter = slidingFixedSign * foldingSpan * 0.23
|
||||
const slidingActiveCenter =
|
||||
slidingActiveSign * foldingSpan * 0.23 -
|
||||
slidingActiveSign * foldingSpan * 0.44 * pocketAmount
|
||||
const slidingPanelPoints = (centerOffset: number, faceOffset: number) => {
|
||||
const start = {
|
||||
x:
|
||||
svgP1.x +
|
||||
nx * (centerOffset + (foldingSpan - slidingPanelSpan) / 2) +
|
||||
px * swingSign * faceOffset,
|
||||
y:
|
||||
svgP1.y +
|
||||
ny * (centerOffset + (foldingSpan - slidingPanelSpan) / 2) +
|
||||
py * swingSign * faceOffset,
|
||||
}
|
||||
const end = {
|
||||
x:
|
||||
svgP1.x +
|
||||
nx * (centerOffset + (foldingSpan + slidingPanelSpan) / 2) +
|
||||
px * swingSign * faceOffset,
|
||||
y:
|
||||
svgP1.y +
|
||||
ny * (centerOffset + (foldingSpan + slidingPanelSpan) / 2) +
|
||||
py * swingSign * faceOffset,
|
||||
}
|
||||
return [
|
||||
{ x: start.x - px * leafHalfThickness, y: start.y - py * leafHalfThickness },
|
||||
{ x: end.x - px * leafHalfThickness, y: end.y - py * leafHalfThickness },
|
||||
{ x: end.x + px * leafHalfThickness, y: end.y + py * leafHalfThickness },
|
||||
{ x: start.x + px * leafHalfThickness, y: start.y + py * leafHalfThickness },
|
||||
]
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(' ')
|
||||
}
|
||||
const slidingFixedPoints = slidingPanelPoints(slidingFixedCenter, doorCubeSize * 0.34)
|
||||
const slidingActivePoints = slidingPanelPoints(slidingActiveCenter, doorCubeSize * 0.68)
|
||||
const isGarageSectionalDoor = opening.doorType === 'garage-sectional'
|
||||
const isGarageRollupDoor = opening.doorType === 'garage-rollup'
|
||||
const isGarageTiltupDoor = opening.doorType === 'garage-tiltup'
|
||||
const garagePanelCount = Math.max(3, Math.min(12, opening.garagePanelCount ?? 4))
|
||||
const garagePanelLines = Array.from({ length: garagePanelCount - 1 }, (_, index) => {
|
||||
const t = (index + 1) / garagePanelCount
|
||||
return {
|
||||
start: {
|
||||
x: svgP1.x + (svgP2.x - svgP1.x) * t,
|
||||
y: svgP1.y + (svgP2.y - svgP1.y) * t,
|
||||
},
|
||||
end: {
|
||||
x: svgP1.x + (svgP2.x - svgP1.x) * t + px * swingSign * doorCubeSize * 0.78,
|
||||
y: svgP1.y + (svgP2.y - svgP1.y) * t + py * swingSign * doorCubeSize * 0.78,
|
||||
},
|
||||
}
|
||||
})
|
||||
const isDoubleSwingDoor = opening.doorType === 'double' || opening.doorType === 'french'
|
||||
const doubleLeafPlans = isDoubleSwingDoor
|
||||
? (
|
||||
[
|
||||
{
|
||||
key: 'left',
|
||||
hingePoint: { x: cx - nx * (width / 2), y: cy - ny * (width / 2) },
|
||||
strikePoint: { x: cx, y: cy },
|
||||
},
|
||||
{
|
||||
key: 'right',
|
||||
hingePoint: { x: cx + nx * (width / 2), y: cy + ny * (width / 2) },
|
||||
strikePoint: { x: cx, y: cy },
|
||||
},
|
||||
] as const
|
||||
).map(({ key, hingePoint, strikePoint }) => {
|
||||
const tangentSign = key === 'left' ? 1 : -1
|
||||
const planHingeCubeCenter = {
|
||||
x: hingePoint.x + nx * tangentSign * doorCubeInset,
|
||||
y: hingePoint.y + ny * tangentSign * doorCubeInset,
|
||||
}
|
||||
const planStrikeCubeCenter = {
|
||||
x: strikePoint.x - nx * tangentSign * doorCubeInset,
|
||||
y: strikePoint.y - ny * tangentSign * doorCubeInset,
|
||||
}
|
||||
const planLeafStart = {
|
||||
x:
|
||||
planHingeCubeCenter.x +
|
||||
px * swingSign * (doorCubeSize / 2) +
|
||||
nx * tangentSign * (doorCubeSize / 2 + leafHalfThickness),
|
||||
y:
|
||||
planHingeCubeCenter.y +
|
||||
py * swingSign * (doorCubeSize / 2) +
|
||||
ny * tangentSign * (doorCubeSize / 2 + leafHalfThickness),
|
||||
}
|
||||
const planArcEnd = {
|
||||
x:
|
||||
planStrikeCubeCenter.x +
|
||||
px * swingSign * (doorCubeSize / 2) -
|
||||
nx * tangentSign * (doorCubeSize / 2),
|
||||
y:
|
||||
planStrikeCubeCenter.y +
|
||||
py * swingSign * (doorCubeSize / 2) -
|
||||
ny * tangentSign * (doorCubeSize / 2),
|
||||
}
|
||||
const planSwingRadius = Math.hypot(
|
||||
planArcEnd.x - planLeafStart.x,
|
||||
planArcEnd.y - planLeafStart.y,
|
||||
)
|
||||
const planClosedLeafVector = {
|
||||
x: planArcEnd.x - planLeafStart.x,
|
||||
y: planArcEnd.y - planLeafStart.y,
|
||||
}
|
||||
const planOpenAngle = swingAngle * swingSign * tangentSign
|
||||
const planOpenCos = Math.cos(planOpenAngle)
|
||||
const planOpenSin = Math.sin(planOpenAngle)
|
||||
const planLeafEnd = {
|
||||
x:
|
||||
planLeafStart.x +
|
||||
planClosedLeafVector.x * planOpenCos -
|
||||
planClosedLeafVector.y * planOpenSin,
|
||||
y:
|
||||
planLeafStart.y +
|
||||
planClosedLeafVector.x * planOpenSin +
|
||||
planClosedLeafVector.y * planOpenCos,
|
||||
}
|
||||
const planSweepFlag =
|
||||
key === 'left'
|
||||
? swingDirection === 'inward'
|
||||
? 0
|
||||
: 1
|
||||
: swingDirection === 'inward'
|
||||
? 1
|
||||
: 0
|
||||
|
||||
return {
|
||||
key,
|
||||
hingeCubeCenter: planHingeCubeCenter,
|
||||
strikeCubeCenter: planStrikeCubeCenter,
|
||||
hingeMarkerX: planHingeCubeCenter.x,
|
||||
hingeMarkerY: planHingeCubeCenter.y,
|
||||
swingRadius: planSwingRadius,
|
||||
sweepFlag: planSweepFlag,
|
||||
arcEnd: planArcEnd,
|
||||
leafEnd: planLeafEnd,
|
||||
leafPolygonPoints: [
|
||||
{
|
||||
x: planLeafStart.x - nx * leafHalfThickness,
|
||||
y: planLeafStart.y - ny * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: planLeafEnd.x - nx * leafHalfThickness,
|
||||
y: planLeafEnd.y - ny * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: planLeafEnd.x + nx * leafHalfThickness,
|
||||
y: planLeafEnd.y + ny * leafHalfThickness,
|
||||
},
|
||||
{
|
||||
x: planLeafStart.x + nx * leafHalfThickness,
|
||||
y: planLeafStart.y + ny * leafHalfThickness,
|
||||
},
|
||||
]
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(' '),
|
||||
closedLeafHintPoints: [
|
||||
{
|
||||
x: planLeafStart.x - nx * leafHalfThickness * 0.7,
|
||||
y: planLeafStart.y - ny * leafHalfThickness * 0.7,
|
||||
},
|
||||
{
|
||||
x: planArcEnd.x - nx * leafHalfThickness * 0.7,
|
||||
y: planArcEnd.y - ny * leafHalfThickness * 0.7,
|
||||
},
|
||||
{
|
||||
x: planArcEnd.x + nx * leafHalfThickness * 0.7,
|
||||
y: planArcEnd.y + ny * leafHalfThickness * 0.7,
|
||||
},
|
||||
{
|
||||
x: planLeafStart.x + nx * leafHalfThickness * 0.7,
|
||||
y: planLeafStart.y + ny * leafHalfThickness * 0.7,
|
||||
},
|
||||
]
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(' '),
|
||||
}
|
||||
})
|
||||
: []
|
||||
|
||||
return (
|
||||
<g
|
||||
@@ -4780,72 +5066,367 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
points={doorBackgroundPoints}
|
||||
stroke="none"
|
||||
/>
|
||||
{swingSweepPath && (
|
||||
<path
|
||||
d={swingSweepPath}
|
||||
fill={doorSwingFill}
|
||||
stroke="none"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{isFoldingDoor ? (
|
||||
<>
|
||||
<path
|
||||
d={`M ${svgP1.x} ${svgP1.y} L ${svgP2.x} ${svgP2.y}`}
|
||||
fill="none"
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{foldingPath && (
|
||||
<path
|
||||
d={foldingPath}
|
||||
fill="none"
|
||||
stroke={doorStroke}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
{foldingPoints.map((point, index) => (
|
||||
<circle
|
||||
cx={point.x}
|
||||
cy={point.y}
|
||||
fill={
|
||||
index === 0 || index === foldingPoints.length - 1
|
||||
? doorStroke
|
||||
: doorLeafFill
|
||||
}
|
||||
key={`${opening.id}:folding-node:${index}`}
|
||||
r={
|
||||
index === 0 || index === foldingPoints.length - 1
|
||||
? hingeMarkerRadius
|
||||
: hingeMarkerRadius * 0.72
|
||||
}
|
||||
stroke={doorStroke}
|
||||
strokeWidth="0.8"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : isPocketDoor ? (
|
||||
<>
|
||||
<line
|
||||
stroke={doorSoftStroke}
|
||||
strokeDasharray="0.09 0.06"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={pocketTrackStart.x}
|
||||
x2={pocketTrackEnd.x}
|
||||
y1={pocketTrackStart.y}
|
||||
y2={pocketTrackEnd.y}
|
||||
/>
|
||||
<line
|
||||
stroke={doorStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={svgP1.x}
|
||||
x2={svgP2.x}
|
||||
y1={svgP1.y}
|
||||
y2={svgP2.y}
|
||||
/>
|
||||
<polygon
|
||||
fill={doorLeafFill}
|
||||
points={pocketLeafPoints}
|
||||
stroke={doorStroke}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<circle
|
||||
cx={pocketLeafEnd.x}
|
||||
cy={pocketLeafEnd.y}
|
||||
fill={doorStroke}
|
||||
r={hingeMarkerRadius * 0.72}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</>
|
||||
) : isBarnDoor ? (
|
||||
<>
|
||||
<line
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={pocketTrackStart.x}
|
||||
x2={pocketTrackEnd.x}
|
||||
y1={pocketTrackStart.y + py * swingSign * doorCubeSize * 1.15}
|
||||
y2={pocketTrackEnd.y + py * swingSign * doorCubeSize * 1.15}
|
||||
/>
|
||||
<line
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="0.9"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={svgP1.x}
|
||||
x2={svgP2.x}
|
||||
y1={svgP1.y}
|
||||
y2={svgP2.y}
|
||||
/>
|
||||
<polygon
|
||||
fill={doorLeafFill}
|
||||
points={barnLeafPoints}
|
||||
stroke={doorStroke}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{[0.28, 0.72].map((ratio) => {
|
||||
const wheel = {
|
||||
x: barnLeafStart.x + (barnLeafEnd.x - barnLeafStart.x) * ratio,
|
||||
y: barnLeafStart.y + (barnLeafEnd.y - barnLeafStart.y) * ratio,
|
||||
}
|
||||
return (
|
||||
<circle
|
||||
cx={wheel.x}
|
||||
cy={wheel.y}
|
||||
fill={doorStroke}
|
||||
key={`${opening.id}:barn-wheel:${ratio}`}
|
||||
r={hingeMarkerRadius * 0.62}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
) : isSlidingDoor ? (
|
||||
<>
|
||||
<line
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={svgP1.x}
|
||||
x2={svgP2.x}
|
||||
y1={svgP1.y + py * swingSign * doorCubeSize * 0.34}
|
||||
y2={svgP2.y + py * swingSign * doorCubeSize * 0.34}
|
||||
/>
|
||||
<line
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={svgP1.x}
|
||||
x2={svgP2.x}
|
||||
y1={svgP1.y + py * swingSign * doorCubeSize * 0.68}
|
||||
y2={svgP2.y + py * swingSign * doorCubeSize * 0.68}
|
||||
/>
|
||||
<polygon
|
||||
fill="rgba(224, 242, 254, 0.7)"
|
||||
points={slidingFixedPoints}
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<polygon
|
||||
fill={doorLeafFill}
|
||||
points={slidingActivePoints}
|
||||
stroke={doorStroke}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</>
|
||||
) : isGarageSectionalDoor || isGarageRollupDoor || isGarageTiltupDoor ? (
|
||||
<>
|
||||
<line
|
||||
stroke={doorStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={svgP1.x}
|
||||
x2={svgP2.x}
|
||||
y1={svgP1.y}
|
||||
y2={svgP2.y}
|
||||
/>
|
||||
<line
|
||||
stroke={doorSoftStroke}
|
||||
strokeDasharray="0.12 0.08"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={svgP1.x + px * swingSign * doorCubeSize * 0.78}
|
||||
x2={svgP2.x + px * swingSign * doorCubeSize * 0.78}
|
||||
y1={svgP1.y + py * swingSign * doorCubeSize * 0.78}
|
||||
y2={svgP2.y + py * swingSign * doorCubeSize * 0.78}
|
||||
/>
|
||||
{isGarageRollupDoor ? (
|
||||
<circle
|
||||
cx={(svgP1.x + svgP2.x) / 2 + px * swingSign * doorCubeSize * 0.78}
|
||||
cy={(svgP1.y + svgP2.y) / 2 + py * swingSign * doorCubeSize * 0.78}
|
||||
fill="none"
|
||||
r={doorCubeSize * 0.22}
|
||||
stroke={doorSoftStroke}
|
||||
strokeWidth="0.9"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
) : isGarageTiltupDoor ? (
|
||||
<line
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={svgP1.x + px * swingSign * doorCubeSize * 0.18}
|
||||
x2={svgP2.x + px * swingSign * doorCubeSize * 0.78}
|
||||
y1={svgP1.y + py * swingSign * doorCubeSize * 0.18}
|
||||
y2={svgP2.y + py * swingSign * doorCubeSize * 0.78}
|
||||
/>
|
||||
) : (
|
||||
garagePanelLines.map((line, index) => (
|
||||
<line
|
||||
key={`${opening.id}:garage-section:${index}`}
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="0.8"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={line.start.x}
|
||||
x2={line.end.x}
|
||||
y1={line.start.y}
|
||||
y2={line.end.y}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
) : isDoubleSwingDoor ? (
|
||||
<>
|
||||
{doubleLeafPlans.map((leaf) =>
|
||||
leaf.swingRadius > 1e-6 ? (
|
||||
<path
|
||||
d={`M ${leaf.arcEnd.x} ${leaf.arcEnd.y} A ${leaf.swingRadius} ${leaf.swingRadius} 0 0 ${leaf.sweepFlag} ${leaf.leafEnd.x} ${leaf.leafEnd.y}`}
|
||||
fill="none"
|
||||
key={`${opening.id}:double-sweep:${leaf.key}`}
|
||||
stroke={doorSoftStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="0.9"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
{swingAngle > 0.03 &&
|
||||
doubleLeafPlans.map((leaf) => (
|
||||
<polygon
|
||||
fill="none"
|
||||
key={`${opening.id}:double-hint:${leaf.key}`}
|
||||
points={leaf.closedLeafHintPoints}
|
||||
stroke={doorSoftStroke}
|
||||
strokeDasharray="0.08 0.06"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="0.8"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
{doubleLeafPlans.map((leaf) => (
|
||||
<rect
|
||||
fill={doorLeafFill}
|
||||
height={doorCubeSize}
|
||||
key={`${opening.id}:double-hinge:${leaf.key}`}
|
||||
rx={doorCubeSize * 0.12}
|
||||
stroke={doorStroke}
|
||||
strokeWidth="1.35"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
width={doorCubeSize}
|
||||
x={leaf.hingeCubeCenter.x - doorCubeSize / 2}
|
||||
y={leaf.hingeCubeCenter.y - doorCubeSize / 2}
|
||||
/>
|
||||
))}
|
||||
{doubleLeafPlans.map((leaf) => (
|
||||
<circle
|
||||
cx={leaf.hingeMarkerX}
|
||||
cy={leaf.hingeMarkerY}
|
||||
fill={doorStroke}
|
||||
key={`${opening.id}:double-hinge-marker:${leaf.key}`}
|
||||
r={hingeMarkerRadius}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
{doubleLeafPlans.map((leaf) => (
|
||||
<polygon
|
||||
fill={doorLeafFill}
|
||||
key={`${opening.id}:double-leaf:${leaf.key}`}
|
||||
points={leaf.leafPolygonPoints}
|
||||
stroke={doorStroke}
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{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={index === 0 ? doorLeafFill : '#ffffff'}
|
||||
height={doorCubeSize}
|
||||
key={`${opening.id}:door-cube:${index}`}
|
||||
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={doorLeafFill}
|
||||
points={leafPolygonPoints}
|
||||
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={doorStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={arcStrokeWidth}
|
||||
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={index === 0 ? doorLeafFill : '#ffffff'}
|
||||
height={doorCubeSize}
|
||||
key={`${opening.id}:door-cube:${index}`}
|
||||
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={doorLeafFill}
|
||||
points={leafPolygonPoints}
|
||||
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={doorStroke}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={arcStrokeWidth}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isSelected ? (
|
||||
|
||||
@@ -248,6 +248,13 @@ export const DoorTool: React.FC = () => {
|
||||
parentId: event.node.id,
|
||||
width: draft.width,
|
||||
height: draft.height,
|
||||
doorCategory: draft.doorCategory,
|
||||
doorType: draft.doorType,
|
||||
leafCount: draft.leafCount,
|
||||
operationState: draft.operationState,
|
||||
slideDirection: draft.slideDirection,
|
||||
trackStyle: draft.trackStyle,
|
||||
garagePanelCount: draft.garagePanelCount,
|
||||
frameThickness: draft.frameThickness,
|
||||
frameDepth: draft.frameDepth,
|
||||
threshold: draft.threshold,
|
||||
|
||||
@@ -98,6 +98,18 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
edgeMaterial.color.setHex(valid ? 0x22_c5_5e : 0xef_44_44)
|
||||
}
|
||||
|
||||
const getPlacementOrientation = (event: WallEvent) => {
|
||||
const faceSide = getSideFromNormal(event.normal)
|
||||
const side = movingDoorNode.side ?? faceSide
|
||||
const rotationOffset = side !== faceSide ? Math.PI : 0
|
||||
return {
|
||||
side,
|
||||
itemRotation: calculateItemRotation(event.normal) + rotationOffset,
|
||||
cursorRotation:
|
||||
calculateCursorRotation(event.normal, event.node.start, event.node.end) + rotationOffset,
|
||||
}
|
||||
}
|
||||
|
||||
const onWallEnter = (event: WallEvent) => {
|
||||
if (!isValidWallSideFace(event.normal)) return
|
||||
if (isCurvedWall(event.node)) {
|
||||
@@ -106,9 +118,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
}
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
@@ -167,9 +177,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
}
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const cursorRotation = calculateCursorRotation(event.normal, event.node.start, event.node.end)
|
||||
const { side, itemRotation, cursorRotation } = getPlacementOrientation(event)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
@@ -234,8 +242,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
||||
if (isCurvedWall(event.node)) return
|
||||
if (event.node.parentId !== getLevelId()) return
|
||||
|
||||
const side = getSideFromNormal(event.normal)
|
||||
const itemRotation = calculateItemRotation(event.normal)
|
||||
const { side, itemRotation } = getPlacementOrientation(event)
|
||||
|
||||
const localX = snapToHalf(event.localPosition[0])
|
||||
const { clampedX, clampedY } = clampToWall(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -88,8 +88,8 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
{
|
||||
title: 'Item Placement',
|
||||
shortcuts: [
|
||||
{ keys: ['R'], action: 'Rotate item clockwise by 90 degrees' },
|
||||
{ keys: ['T'], action: 'Rotate item counter-clockwise by 90 degrees' },
|
||||
{ keys: ['R'], action: 'Rotate item clockwise, or toggle selected door open/closed' },
|
||||
{ keys: ['T'], action: 'Rotate item counter-clockwise, or close selected door' },
|
||||
{
|
||||
keys: ['Shift'],
|
||||
action: 'Temporarily bypass placement validation constraints',
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
|
||||
import { runRedo, runUndo } from '../lib/history'
|
||||
import { sfxEmitter } from '../lib/sfx-bus'
|
||||
import useEditor from '../store/use-editor'
|
||||
|
||||
const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
|
||||
|
||||
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
|
||||
// so that the global Escape handler knows not to also switch to select mode.
|
||||
let _toolCancelConsumed = false
|
||||
@@ -154,11 +153,7 @@ export const useKeyboard = ({
|
||||
if (node?.type === 'door') {
|
||||
e.preventDefault()
|
||||
if (node.openingKind !== 'opening') {
|
||||
const currentSwingAngle = node.swingAngle ?? 0
|
||||
useScene.getState().updateNode(node.id, {
|
||||
swingAngle:
|
||||
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
|
||||
})
|
||||
toggleDoorOpenState(node.id)
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
}
|
||||
} else if (node && 'rotation' in node) {
|
||||
@@ -184,7 +179,7 @@ export const useKeyboard = ({
|
||||
if (node?.type === 'door') {
|
||||
e.preventDefault()
|
||||
if (node.openingKind !== 'opening') {
|
||||
useScene.getState().updateNode(node.id, { swingAngle: 0 })
|
||||
closeDoorOpenState(node.id)
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
}
|
||||
} else if (node && 'rotation' in node) {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type DoorInteractiveState,
|
||||
isOperationDoorType,
|
||||
useInteractive,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
|
||||
export const DOOR_TOGGLE_ANIMATION_MS = 520
|
||||
|
||||
export { isOperationDoorType }
|
||||
|
||||
type DoorOpenAnimationOptions = {
|
||||
persist?: boolean
|
||||
}
|
||||
|
||||
function getDisplayedDoorValue(
|
||||
doorId: AnyNodeId,
|
||||
field: keyof DoorInteractiveState,
|
||||
nodeValue: number | undefined,
|
||||
) {
|
||||
const interactive = useInteractive.getState()
|
||||
const runtimeValue = interactive.doors[doorId]?.[field]
|
||||
if (runtimeValue !== undefined) return runtimeValue
|
||||
|
||||
const queuedValue = interactive.doorAnimations[doorId]?.from
|
||||
if (queuedValue !== undefined) return queuedValue
|
||||
|
||||
return nodeValue ?? 0
|
||||
}
|
||||
|
||||
function startDoorOpenAnimation(
|
||||
doorId: AnyNodeId,
|
||||
field: keyof DoorInteractiveState,
|
||||
from: number,
|
||||
to: number,
|
||||
options?: DoorOpenAnimationOptions,
|
||||
) {
|
||||
useInteractive.getState().startDoorAnimation(doorId, {
|
||||
field,
|
||||
from,
|
||||
to,
|
||||
startedAt: null,
|
||||
durationMs: DOOR_TOGGLE_ANIMATION_MS,
|
||||
persist: options?.persist ?? true,
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleDoorOpenState(doorId: AnyNodeId, options?: DoorOpenAnimationOptions) {
|
||||
const node = useScene.getState().nodes[doorId]
|
||||
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
||||
|
||||
if (isOperationDoorType(node.doorType)) {
|
||||
const currentOpenAmount = getDisplayedDoorValue(doorId, 'operationState', node.operationState)
|
||||
startDoorOpenAnimation(
|
||||
doorId,
|
||||
'operationState',
|
||||
currentOpenAmount,
|
||||
currentOpenAmount >= 0.5 ? 0 : 1,
|
||||
options,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const currentSwingAngle = getDisplayedDoorValue(doorId, 'swingAngle', node.swingAngle)
|
||||
startDoorOpenAnimation(
|
||||
doorId,
|
||||
'swingAngle',
|
||||
currentSwingAngle,
|
||||
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
export function closeDoorOpenState(doorId: AnyNodeId, options?: DoorOpenAnimationOptions) {
|
||||
const node = useScene.getState().nodes[doorId]
|
||||
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
||||
|
||||
if (isOperationDoorType(node.doorType)) {
|
||||
const currentOpenAmount = getDisplayedDoorValue(doorId, 'operationState', node.operationState)
|
||||
startDoorOpenAnimation(doorId, 'operationState', currentOpenAmount, 0, options)
|
||||
return
|
||||
}
|
||||
|
||||
const currentSwingAngle = getDisplayedDoorValue(doorId, 'swingAngle', node.swingAngle)
|
||||
startDoorOpenAnimation(doorId, 'swingAngle', currentSwingAngle, 0, options)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three/webgpu'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import { CeilingSystem } from '../../systems/ceiling/ceiling-system'
|
||||
import { DoorAnimationSystem } from '../../systems/door/door-animation-system'
|
||||
import { DoorSystem } from '../../systems/door/door-system'
|
||||
import { FenceSystem } from '../../systems/fence/fence-system'
|
||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||
@@ -225,6 +226,7 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
<WallCutout />
|
||||
{/* Core systems */}
|
||||
<CeilingSystem />
|
||||
<DoorAnimationSystem />
|
||||
<DoorSystem />
|
||||
<FenceSystem />
|
||||
<ItemSystem />
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type AnyNodeId, type DoorNode, emitter, useInteractive, useScene } from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
|
||||
const easeDoorAnimation = (value: number) => value * value * (3 - 2 * value)
|
||||
|
||||
function markDoorDirty(doorId: AnyNodeId) {
|
||||
const scene = useScene.getState()
|
||||
const node = scene.nodes[doorId]
|
||||
scene.dirtyNodes.add(doorId)
|
||||
if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
}
|
||||
|
||||
export const DoorAnimationSystem = () => {
|
||||
useFrame(({ clock }) => {
|
||||
const interactive = useInteractive.getState()
|
||||
const entries = Object.entries(interactive.doorAnimations)
|
||||
if (entries.length === 0) return
|
||||
|
||||
const now = clock.getElapsedTime() * 1000
|
||||
|
||||
for (const [doorId, animation] of entries) {
|
||||
const typedDoorId = doorId as AnyNodeId
|
||||
const scene = useScene.getState()
|
||||
const node = scene.nodes[typedDoorId]
|
||||
if (node?.type !== 'door') {
|
||||
interactive.cancelDoorAnimation(typedDoorId)
|
||||
interactive.removeDoorOpenState(typedDoorId)
|
||||
continue
|
||||
}
|
||||
|
||||
const startedAt = animation.startedAt ?? now
|
||||
if (animation.startedAt === null) {
|
||||
interactive.startDoorAnimation(typedDoorId, { ...animation, startedAt })
|
||||
}
|
||||
|
||||
const progress = Math.min(1, (now - startedAt) / animation.durationMs)
|
||||
const value = animation.from + (animation.to - animation.from) * easeDoorAnimation(progress)
|
||||
interactive.setDoorOpenState(typedDoorId, { [animation.field]: value })
|
||||
markDoorDirty(typedDoorId)
|
||||
|
||||
if (progress < 1) continue
|
||||
|
||||
interactive.cancelDoorAnimation(typedDoorId)
|
||||
if (animation.persist) {
|
||||
scene.updateNode(typedDoorId, { [animation.field]: animation.to })
|
||||
interactive.removeDoorOpenState(typedDoorId)
|
||||
markDoorDirty(typedDoorId)
|
||||
} else {
|
||||
interactive.setDoorOpenState(typedDoorId, { [animation.field]: animation.to })
|
||||
}
|
||||
emitter.emit('door:animation-completed', {
|
||||
doorId: typedDoorId as DoorNode['id'],
|
||||
field: animation.field,
|
||||
})
|
||||
}
|
||||
}, 2)
|
||||
|
||||
return null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user