Merge pull request #295 from sudhir9297/feat/window-improvement
feat: Expand window types, animations, and first-person behavior
This commit is contained in:
@@ -146,6 +146,13 @@ type DoorAnimationEvents = {
|
||||
}
|
||||
}
|
||||
|
||||
type WindowAnimationEvents = {
|
||||
'window:animation-completed': {
|
||||
windowId: WindowNode['id']
|
||||
field: 'operationState'
|
||||
}
|
||||
}
|
||||
|
||||
type PresetEvents = {
|
||||
'preset:generate-thumbnail': { presetId: string; nodeId: string }
|
||||
'preset:thumbnail-updated': { presetId: string; thumbnailUrl: string }
|
||||
@@ -189,6 +196,7 @@ type EditorEvents = GridEvents &
|
||||
ToolEvents &
|
||||
GuideEvents &
|
||||
DoorAnimationEvents &
|
||||
WindowAnimationEvents &
|
||||
PresetEvents &
|
||||
ThumbnailEvents &
|
||||
SnapshotEvents &
|
||||
|
||||
@@ -73,6 +73,8 @@ export {
|
||||
type DoorInteractiveState,
|
||||
type ItemInteractiveState,
|
||||
useInteractive,
|
||||
type WindowAnimationState,
|
||||
type WindowInteractiveState,
|
||||
} from './store/use-interactive'
|
||||
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||
|
||||
@@ -82,7 +82,7 @@ export {
|
||||
getWallSurfaceMaterialSignature,
|
||||
WallNode,
|
||||
} from './nodes/wall'
|
||||
export { WindowNode } from './nodes/window'
|
||||
export { WindowNode, WindowType } from './nodes/window'
|
||||
export { ZoneNode } from './nodes/zone'
|
||||
export type { AnyNodeId, AnyNodeType } from './types'
|
||||
// Union types
|
||||
|
||||
@@ -3,6 +3,20 @@ import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
|
||||
export const WindowType = z.enum([
|
||||
'fixed',
|
||||
'sliding',
|
||||
'casement',
|
||||
'awning',
|
||||
'hopper',
|
||||
'single-hung',
|
||||
'double-hung',
|
||||
'bay',
|
||||
'bow',
|
||||
'louvered',
|
||||
])
|
||||
export type WindowType = z.infer<typeof WindowType>
|
||||
|
||||
export const WindowNode = BaseNode.extend({
|
||||
id: objectId('window'),
|
||||
type: nodeType('window'),
|
||||
@@ -21,6 +35,13 @@ export const WindowNode = BaseNode.extend({
|
||||
|
||||
// Opening mode - when set to "opening", the window is only a shaped cutout
|
||||
openingKind: z.enum(['window', 'opening']).default('window'),
|
||||
|
||||
// Window family
|
||||
windowType: WindowType.default('fixed'),
|
||||
operationState: z.number().min(0).max(1).default(0),
|
||||
awningDirection: z.enum(['up', 'down']).default('up'),
|
||||
casementStyle: z.enum(['single', 'french']).default('single'),
|
||||
hingesSide: z.enum(['left', 'right']).default('left'),
|
||||
openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'),
|
||||
openingRadiusMode: z.enum(['all', 'individual']).default('all'),
|
||||
openingCornerRadii: z
|
||||
@@ -50,6 +71,7 @@ export const WindowNode = BaseNode.extend({
|
||||
}).describe(dedent`Window node - a parametric window placed on a wall
|
||||
- position: center of the window in wall-local coordinate system
|
||||
- width/height: overall outer dimensions
|
||||
- windowType: explicit window family, defaulting old windows to fixed
|
||||
- frameThickness: width of the frame members
|
||||
- frameDepth: how deep the frame sits within the wall
|
||||
- columnRatios/rowRatios: pane division ratios
|
||||
|
||||
@@ -26,10 +26,25 @@ export type DoorAnimationState = {
|
||||
persist: boolean
|
||||
}
|
||||
|
||||
export type WindowInteractiveState = {
|
||||
operationState?: number
|
||||
}
|
||||
|
||||
export type WindowAnimationState = {
|
||||
field: keyof WindowInteractiveState
|
||||
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>
|
||||
windows: Record<AnyNodeId, WindowInteractiveState>
|
||||
windowAnimations: Record<AnyNodeId, WindowAnimationState>
|
||||
|
||||
/** Initialize a node's interactive state from its asset definition (idempotent) */
|
||||
initItem: (itemId: AnyNodeId, interactive: Interactive) => void
|
||||
@@ -51,6 +66,18 @@ type InteractiveStore = {
|
||||
|
||||
/** Cancel a queued door animation */
|
||||
cancelDoorAnimation: (doorId: AnyNodeId) => void
|
||||
|
||||
/** Set transient window open state without committing it to the scene node */
|
||||
setWindowOpenState: (windowId: AnyNodeId, value: WindowInteractiveState) => void
|
||||
|
||||
/** Clear transient window open state */
|
||||
removeWindowOpenState: (windowId: AnyNodeId) => void
|
||||
|
||||
/** Queue a window animation for the viewer frame loop */
|
||||
startWindowAnimation: (windowId: AnyNodeId, value: WindowAnimationState) => void
|
||||
|
||||
/** Cancel a queued window animation */
|
||||
cancelWindowAnimation: (windowId: AnyNodeId) => void
|
||||
}
|
||||
|
||||
const defaultControlValue = (interactive: Interactive, index: number): ControlValue => {
|
||||
@@ -70,6 +97,8 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
|
||||
items: {},
|
||||
doors: {},
|
||||
doorAnimations: {},
|
||||
windows: {},
|
||||
windowAnimations: {},
|
||||
|
||||
initItem: (itemId, interactive) => {
|
||||
const { controls } = interactive
|
||||
@@ -139,4 +168,39 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
|
||||
return { doorAnimations: rest }
|
||||
})
|
||||
},
|
||||
|
||||
setWindowOpenState: (windowId, value) => {
|
||||
set((state) => ({
|
||||
windows: {
|
||||
...state.windows,
|
||||
[windowId]: {
|
||||
...state.windows[windowId],
|
||||
...value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
},
|
||||
|
||||
removeWindowOpenState: (windowId) => {
|
||||
set((state) => {
|
||||
const { [windowId]: _, ...rest } = state.windows
|
||||
return { windows: rest }
|
||||
})
|
||||
},
|
||||
|
||||
startWindowAnimation: (windowId, value) => {
|
||||
set((state) => ({
|
||||
windowAnimations: {
|
||||
...state.windowAnimations,
|
||||
[windowId]: value,
|
||||
},
|
||||
}))
|
||||
},
|
||||
|
||||
cancelWindowAnimation: (windowId) => {
|
||||
set((state) => {
|
||||
const { [windowId]: _, ...rest } = state.windowAnimations
|
||||
return { windowAnimations: rest }
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -8,10 +8,16 @@ 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 {
|
||||
closeDoorOpenState,
|
||||
DOOR_SWING_OPEN_ANGLE,
|
||||
isOperationDoorType,
|
||||
toggleDoorOpenState,
|
||||
} from '../../lib/door-interaction'
|
||||
import {
|
||||
closeWindowOpenState,
|
||||
isOperableWindowType,
|
||||
toggleWindowOpenState,
|
||||
} from '../../lib/window-interaction'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import {
|
||||
buildFirstPersonColliderWorldFromRegistry,
|
||||
@@ -55,6 +61,12 @@ const doorOpeningMatrix = new Matrix4()
|
||||
const doorOpeningWorldHit = new Vector3()
|
||||
const spawnWorldPosition = new Vector3()
|
||||
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
|
||||
const windowInteractionRaycaster = new Raycaster()
|
||||
|
||||
type FirstPersonInteractableTarget = {
|
||||
id: AnyNodeId
|
||||
type: 'door' | 'window'
|
||||
}
|
||||
|
||||
const resolvePlacedSpawnNode = (
|
||||
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
||||
@@ -73,7 +85,7 @@ export const FirstPersonControls = () => {
|
||||
const controllerRef = useRef<BVHEcctrlApi | null>(null)
|
||||
const yawRef = useRef(0)
|
||||
const pitchRef = useRef(0)
|
||||
const interactableDoorIdRef = useRef<AnyNodeId | null>(null)
|
||||
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
|
||||
const worldRef = useRef<FirstPersonColliderWorld | null>(null)
|
||||
const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
|
||||
const [controllerStart, setControllerStart] = useState<{
|
||||
@@ -189,15 +201,94 @@ export const FirstPersonControls = () => {
|
||||
return closestDoorId
|
||||
}, [camera])
|
||||
|
||||
const toggleInteractableDoor = useCallback(() => {
|
||||
const doorId = interactableDoorIdRef.current ?? resolveInteractableDoorId()
|
||||
if (!doorId) return
|
||||
const resolveInteractableWindowId = useCallback((): AnyNodeId | null => {
|
||||
const nodes = useScene.getState().nodes
|
||||
camera.updateMatrixWorld(true)
|
||||
windowInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
|
||||
|
||||
let closestWindowId: AnyNodeId | null = null
|
||||
let closestDistance = DOOR_INTERACTION_DISTANCE
|
||||
|
||||
for (const windowId of sceneRegistry.byType.window) {
|
||||
const node = nodes[windowId as AnyNodeId]
|
||||
if (node?.type !== 'window') continue
|
||||
if (node.openingKind === 'opening') continue
|
||||
if (!isOperableWindowType(node.windowType)) continue
|
||||
|
||||
const object = sceneRegistry.nodes.get(windowId)
|
||||
if (!object) continue
|
||||
|
||||
const hit = windowInteractionRaycaster
|
||||
.intersectObject(object, true)
|
||||
.find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE)
|
||||
if (!(hit && hit.distance < closestDistance)) continue
|
||||
|
||||
closestWindowId = windowId as AnyNodeId
|
||||
closestDistance = hit.distance
|
||||
}
|
||||
|
||||
return closestWindowId
|
||||
}, [camera])
|
||||
|
||||
const resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => {
|
||||
const doorId = resolveInteractableDoorId()
|
||||
if (doorId) return { id: doorId, type: 'door' }
|
||||
|
||||
const windowId = resolveInteractableWindowId()
|
||||
if (windowId) return { id: windowId, type: 'window' }
|
||||
|
||||
return null
|
||||
}, [resolveInteractableDoorId, resolveInteractableWindowId])
|
||||
|
||||
const toggleInteractableTarget = useCallback(() => {
|
||||
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
||||
if (!target) return
|
||||
|
||||
if (target.type === 'window') {
|
||||
const node = useScene.getState().nodes[target.id]
|
||||
if (
|
||||
node?.type !== 'window' ||
|
||||
node.openingKind === 'opening' ||
|
||||
!isOperableWindowType(node.windowType)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
toggleWindowOpenState(target.id, { persist: false })
|
||||
return
|
||||
}
|
||||
|
||||
const doorId = target.id
|
||||
|
||||
const node = useScene.getState().nodes[doorId]
|
||||
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
||||
|
||||
toggleDoorOpenState(doorId, { persist: false })
|
||||
}, [resolveInteractableDoorId])
|
||||
}, [resolveInteractableTarget])
|
||||
|
||||
const closeInteractableTarget = useCallback(() => {
|
||||
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
||||
if (!target) return
|
||||
|
||||
if (target.type === 'window') {
|
||||
const node = useScene.getState().nodes[target.id]
|
||||
if (
|
||||
node?.type !== 'window' ||
|
||||
node.openingKind === 'opening' ||
|
||||
!isOperableWindowType(node.windowType)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
closeWindowOpenState(target.id, { persist: false })
|
||||
return
|
||||
}
|
||||
|
||||
const node = useScene.getState().nodes[target.id]
|
||||
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
||||
|
||||
closeDoorOpenState(target.id, { persist: false })
|
||||
}, [resolveInteractableTarget])
|
||||
|
||||
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
|
||||
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
|
||||
@@ -240,7 +331,11 @@ export const FirstPersonControls = () => {
|
||||
|
||||
useEffect(() => {
|
||||
emitter.on('door:animation-completed', rebuildColliderWorld)
|
||||
return () => emitter.off('door:animation-completed', rebuildColliderWorld)
|
||||
emitter.on('window:animation-completed', rebuildColliderWorld)
|
||||
return () => {
|
||||
emitter.off('door:animation-completed', rebuildColliderWorld)
|
||||
emitter.off('window:animation-completed', rebuildColliderWorld)
|
||||
}
|
||||
}, [rebuildColliderWorld])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -308,7 +403,11 @@ export const FirstPersonControls = () => {
|
||||
} else if (event.code === 'KeyE' || event.code === 'KeyR') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
toggleInteractableDoor()
|
||||
toggleInteractableTarget()
|
||||
} else if (event.code === 'KeyT') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeInteractableTarget()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +415,7 @@ export const FirstPersonControls = () => {
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
}, [gl, toggleInteractableDoor])
|
||||
}, [closeInteractableTarget, gl, toggleInteractableTarget])
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!controllerRef.current?.group) return
|
||||
@@ -328,16 +427,20 @@ export const FirstPersonControls = () => {
|
||||
camera.quaternion.setFromEuler(cameraEuler)
|
||||
camera.updateMatrixWorld(true)
|
||||
|
||||
const nextInteractableDoorId = resolveInteractableDoorId()
|
||||
if (interactableDoorIdRef.current !== nextInteractableDoorId) {
|
||||
interactableDoorIdRef.current = nextInteractableDoorId
|
||||
useViewer.getState().setHoveredId(nextInteractableDoorId)
|
||||
const nextInteractableTarget = resolveInteractableTarget()
|
||||
const previousInteractableTarget = interactableTargetRef.current
|
||||
if (
|
||||
previousInteractableTarget?.id !== nextInteractableTarget?.id ||
|
||||
previousInteractableTarget?.type !== nextInteractableTarget?.type
|
||||
) {
|
||||
interactableTargetRef.current = nextInteractableTarget
|
||||
useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (useViewer.getState().hoveredId === interactableDoorIdRef.current) {
|
||||
if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) {
|
||||
useViewer.getState().setHoveredId(null)
|
||||
}
|
||||
}
|
||||
@@ -452,6 +555,8 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
|
||||
<div className="h-px w-full bg-border/30" />
|
||||
<InlineControlHint label="Jump" keyLabel="Space" />
|
||||
<InlineControlHint label="Sprint" keyLabel="Shift" />
|
||||
<InlineControlHint label="Interact" keyLabel="E / R" />
|
||||
<InlineControlHint label="Close" keyLabel="T" />
|
||||
<div className="h-px w-full bg-border/30" />
|
||||
<span className="text-center text-muted-foreground/60 text-xs">
|
||||
Click to look around
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
type CeilingNode,
|
||||
type ColumnNode,
|
||||
calculateLevelMiters,
|
||||
DoorNode,
|
||||
emitter,
|
||||
@@ -589,6 +590,7 @@ type FloorplanItemEntry = {
|
||||
|
||||
type ReferenceFloorData = {
|
||||
ceilingPolygons: CeilingPolygonEntry[]
|
||||
columnEntries: ReferenceFloorColumnEntry[]
|
||||
fenceEntries: FloorplanFenceEntry[]
|
||||
itemEntries: FloorplanItemEntry[]
|
||||
openingPolygons: OpeningPolygonEntry[]
|
||||
@@ -596,6 +598,12 @@ type ReferenceFloorData = {
|
||||
wallPolygons: WallPolygonEntry[]
|
||||
}
|
||||
|
||||
type ReferenceFloorColumnEntry = {
|
||||
column: ColumnNode
|
||||
points: string
|
||||
polygon: Point2D[]
|
||||
}
|
||||
|
||||
type FloorplanStairSegmentEntry = {
|
||||
centerLine: FloorplanLineSegment | null
|
||||
innerPoints: string
|
||||
@@ -1644,6 +1652,51 @@ function getRotatedRectanglePolygon(
|
||||
})
|
||||
}
|
||||
|
||||
function getColumnPlanFootprint(column: ColumnNode): Point2D[] {
|
||||
const center = { x: column.position[0], y: column.position[2] }
|
||||
const shaftWidth =
|
||||
column.crossSection === 'round' ||
|
||||
column.crossSection === 'octagonal' ||
|
||||
column.crossSection === 'sixteen-sided'
|
||||
? column.radius * 2
|
||||
: column.width
|
||||
const shaftDepth =
|
||||
column.crossSection === 'round' ||
|
||||
column.crossSection === 'octagonal' ||
|
||||
column.crossSection === 'sixteen-sided'
|
||||
? column.radius * 2
|
||||
: column.depth
|
||||
const width = Math.max(
|
||||
shaftWidth,
|
||||
column.width * column.baseWidthScale,
|
||||
column.width * column.capitalWidthScale,
|
||||
)
|
||||
const depth = Math.max(
|
||||
shaftDepth,
|
||||
column.depth * column.baseDepthScale,
|
||||
column.depth * column.capitalDepthScale,
|
||||
)
|
||||
|
||||
if (column.crossSection === 'square' || column.crossSection === 'rectangular') {
|
||||
return getRotatedRectanglePolygon(center, width, depth, column.rotation)
|
||||
}
|
||||
|
||||
const segmentCount =
|
||||
column.crossSection === 'octagonal' ? 8 : column.crossSection === 'sixteen-sided' ? 16 : 32
|
||||
|
||||
return Array.from({ length: segmentCount }, (_, index) => {
|
||||
const angle = (index / segmentCount) * Math.PI * 2
|
||||
const localX = Math.cos(angle) * (width / 2)
|
||||
const localY = Math.sin(angle) * (depth / 2)
|
||||
const [offsetX, offsetY] = rotatePlanVector(localX, localY, column.rotation)
|
||||
|
||||
return {
|
||||
x: center.x + offsetX,
|
||||
y: center.y + offsetY,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function interpolatePlanPoint(start: Point2D, end: Point2D, t: number): Point2D {
|
||||
return {
|
||||
x: start.x + (end.x - start.x) * t,
|
||||
@@ -3711,6 +3764,17 @@ const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer(
|
||||
/>
|
||||
))}
|
||||
|
||||
{data.columnEntries.map(({ column, points }) => (
|
||||
<polygon
|
||||
fill="rgba(124, 58, 237, 0.12)"
|
||||
key={column.id}
|
||||
points={points}
|
||||
stroke="rgba(88, 28, 135, 0.55)"
|
||||
strokeWidth={1.1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
|
||||
{data.openingPolygons.map(({ opening, points }) => (
|
||||
<polygon
|
||||
fill="rgba(255, 255, 255, 0.72)"
|
||||
@@ -7783,6 +7847,7 @@ export function FloorplanPanel() {
|
||||
)
|
||||
const referenceWalls = children.filter((node): node is WallNode => node.type === 'wall')
|
||||
const referenceFences = children.filter((node): node is FenceNode => node.type === 'fence')
|
||||
const referenceColumns = children.filter((node): node is ColumnNode => node.type === 'column')
|
||||
const referenceSlabs = children.filter((node): node is SlabNode => node.type === 'slab')
|
||||
const referenceCeilings = children.filter(
|
||||
(node): node is CeilingNode => node.type === 'ceiling',
|
||||
@@ -7886,6 +7951,21 @@ export function FloorplanPanel() {
|
||||
return [{ fence, centerline, markerFrames: [], path }]
|
||||
})
|
||||
|
||||
const columnEntries = referenceColumns.flatMap((column) => {
|
||||
const polygon = getColumnPlanFootprint(column)
|
||||
if (polygon.length < 3) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
column,
|
||||
points: formatPolygonPoints(polygon),
|
||||
polygon,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const transformCache = new Map<string, SharedFloorplanNodeTransform | null>()
|
||||
const itemEntries = referenceDescendants.flatMap((node) => {
|
||||
if (
|
||||
@@ -7920,6 +8000,7 @@ export function FloorplanPanel() {
|
||||
|
||||
return {
|
||||
ceilingPolygons,
|
||||
columnEntries,
|
||||
fenceEntries,
|
||||
itemEntries,
|
||||
openingPolygons,
|
||||
|
||||
@@ -294,6 +294,11 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
||||
parentId: event.node.id,
|
||||
width: movingWindowNode.width,
|
||||
height: movingWindowNode.height,
|
||||
windowType: movingWindowNode.windowType,
|
||||
operationState: movingWindowNode.operationState,
|
||||
awningDirection: movingWindowNode.awningDirection,
|
||||
casementStyle: movingWindowNode.casementStyle,
|
||||
hingesSide: movingWindowNode.hingesSide,
|
||||
frameThickness: movingWindowNode.frameThickness,
|
||||
frameDepth: movingWindowNode.frameDepth,
|
||||
columnRatios: movingWindowNode.columnRatios,
|
||||
|
||||
@@ -262,6 +262,11 @@ export const WindowTool: React.FC = () => {
|
||||
parentId: event.node.id,
|
||||
width: draft.width,
|
||||
height: draft.height,
|
||||
windowType: draft.windowType,
|
||||
operationState: draft.operationState,
|
||||
awningDirection: draft.awningDirection,
|
||||
casementStyle: draft.casementStyle,
|
||||
hingesSide: draft.hingesSide,
|
||||
frameThickness: draft.frameThickness,
|
||||
frameDepth: draft.frameDepth,
|
||||
columnRatios: draft.columnRatios,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
useInteractive,
|
||||
useScene,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -11,6 +12,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { usePresetsAdapter } from '../../../contexts/presets-context'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
@@ -67,6 +69,26 @@ function isSameRadiusTuple(
|
||||
return current.every((value, index) => Math.abs(value - (next[index] ?? 0)) < 1e-6)
|
||||
}
|
||||
|
||||
const windowTypeOptions: Array<{ label: string; value: WindowNode['windowType'] }> = [
|
||||
{ label: 'Fixed', value: 'fixed' },
|
||||
{ label: 'Sliding', value: 'sliding' },
|
||||
{ label: 'Casement', value: 'casement' },
|
||||
{ label: 'Awning', value: 'awning' },
|
||||
{ label: 'Single Hung', value: 'single-hung' },
|
||||
{ label: 'Double Hung', value: 'double-hung' },
|
||||
{ label: 'Bay', value: 'bay' },
|
||||
{ label: 'Bow', value: 'bow' },
|
||||
{ label: 'Louvered', value: 'louvered' },
|
||||
]
|
||||
|
||||
const rectangleOnlyWindowTypes = new Set<WindowNode['windowType']>([
|
||||
'sliding',
|
||||
'single-hung',
|
||||
'double-hung',
|
||||
'bay',
|
||||
'bow',
|
||||
])
|
||||
|
||||
export function WindowPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
@@ -182,6 +204,11 @@ export function WindowPanel() {
|
||||
parentId: node.parentId,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
windowType: node.windowType,
|
||||
operationState: node.operationState,
|
||||
awningDirection: node.awningDirection,
|
||||
casementStyle: node.casementStyle,
|
||||
hingesSide: node.hingesSide,
|
||||
frameThickness: node.frameThickness,
|
||||
frameDepth: node.frameDepth,
|
||||
openingKind: node.openingKind,
|
||||
@@ -210,6 +237,11 @@ export function WindowPanel() {
|
||||
return {
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
windowType: node.windowType,
|
||||
operationState: node.operationState,
|
||||
awningDirection: node.awningDirection,
|
||||
casementStyle: node.casementStyle,
|
||||
hingesSide: node.hingesSide,
|
||||
frameThickness: node.frameThickness,
|
||||
frameDepth: node.frameDepth,
|
||||
openingKind: node.openingKind,
|
||||
@@ -274,6 +306,22 @@ export function WindowPanel() {
|
||||
const archHeight = node.archHeight ?? 0.35
|
||||
const openingRevealRadius = node.openingRevealRadius ?? 0.025
|
||||
const maxRoundedRadius = Math.max(0.01, getMaxSharedWindowRadius(node.width, node.height))
|
||||
const displayedWindowType = node.windowType === 'hopper' ? 'awning' : (node.windowType ?? 'fixed')
|
||||
const awningDirection = node.windowType === 'hopper' ? 'down' : (node.awningDirection ?? 'up')
|
||||
const isOperableWindow =
|
||||
node.windowType === 'sliding' ||
|
||||
node.windowType === 'casement' ||
|
||||
node.windowType === 'awning' ||
|
||||
node.windowType === 'hopper' ||
|
||||
node.windowType === 'single-hung' ||
|
||||
node.windowType === 'double-hung' ||
|
||||
node.windowType === 'louvered'
|
||||
|
||||
const setOperationState = (value: number) => {
|
||||
useInteractive.getState().cancelWindowAnimation(node.id)
|
||||
useInteractive.getState().removeWindowOpenState(node.id)
|
||||
handleUpdate({ operationState: Math.max(0, Math.min(1, value)) })
|
||||
}
|
||||
|
||||
const getDimensionUpdates = (updates: Partial<Pick<WindowNode, 'width' | 'height'>>) => {
|
||||
const nextWidth = updates.width ?? node.width
|
||||
@@ -398,6 +446,97 @@ export function WindowPanel() {
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{!isOpening && (
|
||||
<PanelSection title="Window Type">
|
||||
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
|
||||
{windowTypeOptions.map((option) => {
|
||||
const isSelected = displayedWindowType === option.value
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex min-h-12 items-center rounded-lg border px-2.5 text-left text-xs transition-colors',
|
||||
isSelected
|
||||
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
|
||||
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
|
||||
)}
|
||||
key={option.value}
|
||||
onClick={() =>
|
||||
handleUpdate({
|
||||
windowType: option.value,
|
||||
...(option.value === 'awning' ? { awningDirection } : {}),
|
||||
...(rectangleOnlyWindowTypes.has(option.value)
|
||||
? { openingShape: 'rectangle' }
|
||||
: {}),
|
||||
...(option.value === 'bay' || option.value === 'bow' ? { sill: false } : {}),
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate font-medium">{option.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{displayedWindowType === 'awning' && (
|
||||
<div className="mt-2">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({
|
||||
windowType: 'awning',
|
||||
awningDirection: value as WindowNode['awningDirection'],
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: 'up', label: 'Up' },
|
||||
{ value: 'down', label: 'Down' },
|
||||
]}
|
||||
value={awningDirection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{node.windowType === 'casement' && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({ casementStyle: value as WindowNode['casementStyle'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'single', label: 'Single' },
|
||||
{ value: 'french', label: 'French' },
|
||||
]}
|
||||
value={node.casementStyle ?? 'single'}
|
||||
/>
|
||||
{(node.casementStyle ?? 'single') === 'single' && (
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
handleUpdate({ hingesSide: value as WindowNode['hingesSide'] })
|
||||
}
|
||||
options={[
|
||||
{ value: 'left', label: 'Left' },
|
||||
{ value: 'right', label: 'Right' },
|
||||
]}
|
||||
value={node.hingesSide ?? 'left'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isOperableWindow && (
|
||||
<div className="mt-2">
|
||||
<SliderControl
|
||||
label="Open"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={setOperationState}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
value={Math.round((node.operationState ?? 0) * 100) / 100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label={
|
||||
@@ -458,7 +597,7 @@ export function WindowPanel() {
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{!isOpening && (
|
||||
{!isOpening && !rectangleOnlyWindowTypes.has(node.windowType) && (
|
||||
<PanelSection title="Corner Shape">
|
||||
<SegmentedControl
|
||||
onChange={(value) =>
|
||||
@@ -470,6 +609,7 @@ export function WindowPanel() {
|
||||
openingCornerRadii,
|
||||
cornerRadius: Math.min(cornerRadius, maxRoundedRadius),
|
||||
openingRevealRadius,
|
||||
sill: false,
|
||||
}
|
||||
: {}),
|
||||
...(value === 'arch' ? { archHeight } : {}),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect } from 'react'
|
||||
import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
|
||||
import { runRedo, runUndo } from '../lib/history'
|
||||
import { sfxEmitter } from '../lib/sfx-bus'
|
||||
import { closeWindowOpenState, toggleWindowOpenState } from '../lib/window-interaction'
|
||||
import useEditor from '../store/use-editor'
|
||||
|
||||
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
|
||||
@@ -146,7 +147,7 @@ export const useKeyboard = ({
|
||||
}
|
||||
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
|
||||
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
|
||||
// Doors use R to toggle their leaf open/closed around the hinge.
|
||||
// Operable doors/windows use R to toggle their open/closed state.
|
||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||
if (selectedNodeIds.length === 1) {
|
||||
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||
@@ -156,6 +157,20 @@ export const useKeyboard = ({
|
||||
toggleDoorOpenState(node.id)
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
}
|
||||
} else if (
|
||||
node?.type === 'window' &&
|
||||
node.openingKind !== 'opening' &&
|
||||
(node.windowType === 'sliding' ||
|
||||
node.windowType === 'casement' ||
|
||||
node.windowType === 'awning' ||
|
||||
node.windowType === 'hopper' ||
|
||||
node.windowType === 'single-hung' ||
|
||||
node.windowType === 'double-hung' ||
|
||||
node.windowType === 'louvered')
|
||||
) {
|
||||
e.preventDefault()
|
||||
toggleWindowOpenState(node.id)
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
} else if (node && 'rotation' in node) {
|
||||
e.preventDefault()
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
@@ -182,6 +197,20 @@ export const useKeyboard = ({
|
||||
closeDoorOpenState(node.id)
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
}
|
||||
} else if (
|
||||
node?.type === 'window' &&
|
||||
node.openingKind !== 'opening' &&
|
||||
(node.windowType === 'sliding' ||
|
||||
node.windowType === 'casement' ||
|
||||
node.windowType === 'awning' ||
|
||||
node.windowType === 'hopper' ||
|
||||
node.windowType === 'single-hung' ||
|
||||
node.windowType === 'double-hung' ||
|
||||
node.windowType === 'louvered')
|
||||
) {
|
||||
e.preventDefault()
|
||||
closeWindowOpenState(node.id)
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
} else if (node && 'rotation' in node) {
|
||||
e.preventDefault()
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
useInteractive,
|
||||
useScene,
|
||||
type WindowInteractiveState,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export const WINDOW_TOGGLE_ANIMATION_MS = 520
|
||||
|
||||
type WindowOpenAnimationOptions = {
|
||||
persist?: boolean
|
||||
}
|
||||
|
||||
export function isOperableWindowType(windowType: string | undefined) {
|
||||
return (
|
||||
windowType === 'sliding' ||
|
||||
windowType === 'casement' ||
|
||||
windowType === 'awning' ||
|
||||
windowType === 'hopper' ||
|
||||
windowType === 'single-hung' ||
|
||||
windowType === 'double-hung' ||
|
||||
windowType === 'louvered'
|
||||
)
|
||||
}
|
||||
|
||||
function getDisplayedWindowValue(windowId: AnyNodeId, nodeValue: number | undefined) {
|
||||
const interactive = useInteractive.getState()
|
||||
const runtimeValue = interactive.windows[windowId]?.operationState
|
||||
if (runtimeValue !== undefined) return runtimeValue
|
||||
|
||||
const queuedValue = interactive.windowAnimations[windowId]?.from
|
||||
if (queuedValue !== undefined) return queuedValue
|
||||
|
||||
return nodeValue ?? 0
|
||||
}
|
||||
|
||||
function startWindowOpenAnimation(
|
||||
windowId: AnyNodeId,
|
||||
field: keyof WindowInteractiveState,
|
||||
from: number,
|
||||
to: number,
|
||||
options?: WindowOpenAnimationOptions,
|
||||
) {
|
||||
useInteractive.getState().startWindowAnimation(windowId, {
|
||||
field,
|
||||
from,
|
||||
to,
|
||||
startedAt: null,
|
||||
durationMs: WINDOW_TOGGLE_ANIMATION_MS,
|
||||
persist: options?.persist ?? true,
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleWindowOpenState(windowId: AnyNodeId, options?: WindowOpenAnimationOptions) {
|
||||
const node = useScene.getState().nodes[windowId]
|
||||
if (
|
||||
node?.type !== 'window' ||
|
||||
node.openingKind === 'opening' ||
|
||||
!isOperableWindowType(node.windowType)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentOpenAmount = getDisplayedWindowValue(windowId, node.operationState)
|
||||
startWindowOpenAnimation(
|
||||
windowId,
|
||||
'operationState',
|
||||
currentOpenAmount,
|
||||
currentOpenAmount >= 0.5 ? 0 : 1,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
export function closeWindowOpenState(windowId: AnyNodeId, options?: WindowOpenAnimationOptions) {
|
||||
const node = useScene.getState().nodes[windowId]
|
||||
if (
|
||||
node?.type !== 'window' ||
|
||||
node.openingKind === 'opening' ||
|
||||
!isOperableWindowType(node.windowType)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentOpenAmount = getDisplayedWindowValue(windowId, node.operationState)
|
||||
startWindowOpenAnimation(windowId, 'operationState', currentOpenAmount, 0, options)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { SlabSystem } from '../../systems/slab/slab-system'
|
||||
import { StairSystem } from '../../systems/stair/stair-system'
|
||||
import { WallCutout } from '../../systems/wall/wall-cutout'
|
||||
import { WallSystem } from '../../systems/wall/wall-system'
|
||||
import { WindowAnimationSystem } from '../../systems/window/window-animation-system'
|
||||
import { WindowSystem } from '../../systems/window/window-system'
|
||||
import { ZoneSystem } from '../../systems/zone/zone-system'
|
||||
import { ErrorBoundary } from '../error-boundary'
|
||||
@@ -227,6 +228,7 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
{/* Core systems */}
|
||||
<CeilingSystem />
|
||||
<DoorAnimationSystem />
|
||||
<WindowAnimationSystem />
|
||||
<DoorSystem />
|
||||
<FenceSystem />
|
||||
<ItemSystem />
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
sceneRegistry,
|
||||
useInteractive,
|
||||
useScene,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import {
|
||||
AWNING_WINDOW_SASH_NAME,
|
||||
CASEMENT_WINDOW_SASH_NAME,
|
||||
DOUBLE_HUNG_BOTTOM_SASH_NAME,
|
||||
DOUBLE_HUNG_TOP_SASH_NAME,
|
||||
FRENCH_CASEMENT_LEFT_SASH_NAME,
|
||||
FRENCH_CASEMENT_RIGHT_SASH_NAME,
|
||||
HOPPER_WINDOW_SASH_NAME,
|
||||
LOUVERED_WINDOW_SLATS_NAME,
|
||||
SINGLE_HUNG_ACTIVE_SASH_NAME,
|
||||
SLIDING_WINDOW_ACTIVE_PANEL_NAME,
|
||||
} from './window-system'
|
||||
|
||||
const easeWindowAnimation = (value: number) => value * value * (3 - 2 * value)
|
||||
|
||||
function markWindowDirty(windowId: AnyNodeId) {
|
||||
const scene = useScene.getState()
|
||||
const node = scene.nodes[windowId]
|
||||
scene.dirtyNodes.add(windowId)
|
||||
}
|
||||
|
||||
function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) {
|
||||
const node = useScene.getState().nodes[windowId]
|
||||
if (node?.type !== 'window') return false
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(windowId)
|
||||
|
||||
if (node.windowType === 'sliding') {
|
||||
const activePanel = mesh?.getObjectByName(SLIDING_WINDOW_ACTIVE_PANEL_NAME)
|
||||
if (!activePanel) return false
|
||||
|
||||
const innerW = node.width - 2 * node.frameThickness
|
||||
const panelOverlap = Math.min(Math.max(node.frameThickness * 0.9, 0.04), innerW * 0.12)
|
||||
const travel = Math.max(innerW / 2 - panelOverlap, 0) * value
|
||||
activePanel.position.x = -innerW / 4 - panelOverlap / 4 + travel
|
||||
return true
|
||||
}
|
||||
|
||||
if (node.windowType === 'single-hung') {
|
||||
const activeSash = mesh?.getObjectByName(SINGLE_HUNG_ACTIVE_SASH_NAME)
|
||||
if (!activeSash) return false
|
||||
|
||||
const innerH = node.height - 2 * node.frameThickness
|
||||
const panelOverlap = Math.min(Math.max(node.frameThickness * 0.9, 0.04), innerH * 0.12)
|
||||
const travel = Math.max(innerH / 2 - panelOverlap, 0) * value
|
||||
activeSash.position.y = -innerH / 4 - panelOverlap / 4 + travel
|
||||
return true
|
||||
}
|
||||
|
||||
if (node.windowType === 'double-hung') {
|
||||
const topSash = mesh?.getObjectByName(DOUBLE_HUNG_TOP_SASH_NAME)
|
||||
const bottomSash = mesh?.getObjectByName(DOUBLE_HUNG_BOTTOM_SASH_NAME)
|
||||
if (!(topSash && bottomSash)) return false
|
||||
|
||||
const innerH = node.height - 2 * node.frameThickness
|
||||
const panelOverlap = Math.min(Math.max(node.frameThickness * 0.9, 0.04), innerH * 0.12)
|
||||
const travel = Math.max(innerH / 2 - panelOverlap, 0) * value
|
||||
topSash.position.y = innerH / 4 + panelOverlap / 4 - travel
|
||||
bottomSash.position.y = -innerH / 4 - panelOverlap / 4 + travel
|
||||
return true
|
||||
}
|
||||
|
||||
if (node.windowType === 'louvered') {
|
||||
const slats = mesh?.getObjectByName(LOUVERED_WINDOW_SLATS_NAME)
|
||||
if (!slats) return false
|
||||
|
||||
const slatAngle = -value * (Math.PI / 3)
|
||||
for (const slat of slats.children) {
|
||||
slat.rotation.x = slatAngle
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (node.windowType === 'casement') {
|
||||
if ((node.casementStyle ?? 'single') === 'french') {
|
||||
const leftSash = mesh?.getObjectByName(FRENCH_CASEMENT_LEFT_SASH_NAME)
|
||||
const rightSash = mesh?.getObjectByName(FRENCH_CASEMENT_RIGHT_SASH_NAME)
|
||||
if (!(leftSash && rightSash)) return false
|
||||
|
||||
leftSash.rotation.y = -value * (Math.PI / 2)
|
||||
rightSash.rotation.y = value * (Math.PI / 2)
|
||||
return true
|
||||
}
|
||||
|
||||
const sash = mesh?.getObjectByName(CASEMENT_WINDOW_SASH_NAME)
|
||||
if (!sash) return false
|
||||
|
||||
const hingeSign = (node.hingesSide ?? 'left') === 'left' ? -1 : 1
|
||||
sash.rotation.y = hingeSign * value * (Math.PI / 2)
|
||||
return true
|
||||
}
|
||||
|
||||
if (node.windowType === 'awning') {
|
||||
const sash = mesh?.getObjectByName(AWNING_WINDOW_SASH_NAME)
|
||||
if (!sash) return false
|
||||
|
||||
sash.rotation.x = -value * (Math.PI / 3)
|
||||
return true
|
||||
}
|
||||
|
||||
if (node.windowType === 'hopper') {
|
||||
const sash =
|
||||
mesh?.getObjectByName(AWNING_WINDOW_SASH_NAME) ??
|
||||
mesh?.getObjectByName(HOPPER_WINDOW_SASH_NAME)
|
||||
if (!sash) return false
|
||||
|
||||
sash.rotation.x = -value * (Math.PI / 3)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const WindowAnimationSystem = () => {
|
||||
useFrame(({ clock }) => {
|
||||
const interactive = useInteractive.getState()
|
||||
const entries = Object.entries(interactive.windowAnimations)
|
||||
if (entries.length === 0) return
|
||||
|
||||
const now = clock.getElapsedTime() * 1000
|
||||
|
||||
for (const [windowId, animation] of entries) {
|
||||
const typedWindowId = windowId as AnyNodeId
|
||||
const scene = useScene.getState()
|
||||
const node = scene.nodes[typedWindowId]
|
||||
if (node?.type !== 'window') {
|
||||
interactive.cancelWindowAnimation(typedWindowId)
|
||||
interactive.removeWindowOpenState(typedWindowId)
|
||||
continue
|
||||
}
|
||||
|
||||
const startedAt = animation.startedAt ?? now
|
||||
if (animation.startedAt === null) {
|
||||
interactive.startWindowAnimation(typedWindowId, { ...animation, startedAt })
|
||||
}
|
||||
|
||||
const progress = Math.min(1, (now - startedAt) / animation.durationMs)
|
||||
const value = animation.from + (animation.to - animation.from) * easeWindowAnimation(progress)
|
||||
interactive.setWindowOpenState(typedWindowId, { [animation.field]: value })
|
||||
const appliedDirectly = applyDirectWindowAnimation(typedWindowId, value)
|
||||
if (!appliedDirectly) markWindowDirty(typedWindowId)
|
||||
|
||||
if (progress < 1) continue
|
||||
|
||||
interactive.cancelWindowAnimation(typedWindowId)
|
||||
if (animation.persist) {
|
||||
scene.updateNode(typedWindowId, { [animation.field]: animation.to })
|
||||
interactive.removeWindowOpenState(typedWindowId)
|
||||
markWindowDirty(typedWindowId)
|
||||
} else {
|
||||
interactive.setWindowOpenState(typedWindowId, { [animation.field]: animation.to })
|
||||
}
|
||||
emitter.emit('window:animation-completed', {
|
||||
windowId: typedWindowId as WindowNode['id'],
|
||||
field: animation.field,
|
||||
})
|
||||
}
|
||||
}, 2)
|
||||
|
||||
return null
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user