Refine window corner shape support

This commit is contained in:
sudhir
2026-05-07 13:34:16 +05:30
parent eef19804d1
commit 133d81e563
13 changed files with 3034 additions and 6 deletions
+2
View File
@@ -73,6 +73,8 @@ export {
type DoorInteractiveState, type DoorInteractiveState,
type ItemInteractiveState, type ItemInteractiveState,
useInteractive, useInteractive,
type WindowAnimationState,
type WindowInteractiveState,
} from './store/use-interactive' } from './store/use-interactive'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { clearSceneHistory, default as useScene } from './store/use-scene' export { clearSceneHistory, default as useScene } from './store/use-scene'
+1 -1
View File
@@ -82,7 +82,7 @@ export {
getWallSurfaceMaterialSignature, getWallSurfaceMaterialSignature,
WallNode, WallNode,
} from './nodes/wall' } from './nodes/wall'
export { WindowNode } from './nodes/window' export { WindowNode, WindowType } from './nodes/window'
export { ZoneNode } from './nodes/zone' export { ZoneNode } from './nodes/zone'
export type { AnyNodeId, AnyNodeType } from './types' export type { AnyNodeId, AnyNodeType } from './types'
// Union types // Union types
+22
View File
@@ -3,6 +3,20 @@ import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material' 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({ export const WindowNode = BaseNode.extend({
id: objectId('window'), id: objectId('window'),
type: nodeType('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 // Opening mode - when set to "opening", the window is only a shaped cutout
openingKind: z.enum(['window', 'opening']).default('window'), 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'), openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'),
openingRadiusMode: z.enum(['all', 'individual']).default('all'), openingRadiusMode: z.enum(['all', 'individual']).default('all'),
openingCornerRadii: z openingCornerRadii: z
@@ -50,6 +71,7 @@ export const WindowNode = BaseNode.extend({
}).describe(dedent`Window node - a parametric window placed on a wall }).describe(dedent`Window node - a parametric window placed on a wall
- position: center of the window in wall-local coordinate system - position: center of the window in wall-local coordinate system
- width/height: overall outer dimensions - width/height: overall outer dimensions
- windowType: explicit window family, defaulting old windows to fixed
- frameThickness: width of the frame members - frameThickness: width of the frame members
- frameDepth: how deep the frame sits within the wall - frameDepth: how deep the frame sits within the wall
- columnRatios/rowRatios: pane division ratios - columnRatios/rowRatios: pane division ratios
@@ -26,10 +26,25 @@ export type DoorAnimationState = {
persist: boolean 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 = { type InteractiveStore = {
items: Record<AnyNodeId, ItemInteractiveState> items: Record<AnyNodeId, ItemInteractiveState>
doors: Record<AnyNodeId, DoorInteractiveState> doors: Record<AnyNodeId, DoorInteractiveState>
doorAnimations: Record<AnyNodeId, DoorAnimationState> doorAnimations: Record<AnyNodeId, DoorAnimationState>
windows: Record<AnyNodeId, WindowInteractiveState>
windowAnimations: Record<AnyNodeId, WindowAnimationState>
/** Initialize a node's interactive state from its asset definition (idempotent) */ /** Initialize a node's interactive state from its asset definition (idempotent) */
initItem: (itemId: AnyNodeId, interactive: Interactive) => void initItem: (itemId: AnyNodeId, interactive: Interactive) => void
@@ -51,6 +66,18 @@ type InteractiveStore = {
/** Cancel a queued door animation */ /** Cancel a queued door animation */
cancelDoorAnimation: (doorId: AnyNodeId) => void 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 => { const defaultControlValue = (interactive: Interactive, index: number): ControlValue => {
@@ -70,6 +97,8 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
items: {}, items: {},
doors: {}, doors: {},
doorAnimations: {}, doorAnimations: {},
windows: {},
windowAnimations: {},
initItem: (itemId, interactive) => { initItem: (itemId, interactive) => {
const { controls } = interactive const { controls } = interactive
@@ -139,4 +168,39 @@ export const useInteractive = create<InteractiveStore>((set, get) => ({
return { doorAnimations: rest } 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 }
})
},
})) }))
@@ -6,6 +6,7 @@ import {
type AnyNodeId, type AnyNodeId,
type BuildingNode, type BuildingNode,
type CeilingNode, type CeilingNode,
type ColumnNode,
calculateLevelMiters, calculateLevelMiters,
DoorNode, DoorNode,
emitter, emitter,
@@ -589,6 +590,7 @@ type FloorplanItemEntry = {
type ReferenceFloorData = { type ReferenceFloorData = {
ceilingPolygons: CeilingPolygonEntry[] ceilingPolygons: CeilingPolygonEntry[]
columnEntries: ReferenceFloorColumnEntry[]
fenceEntries: FloorplanFenceEntry[] fenceEntries: FloorplanFenceEntry[]
itemEntries: FloorplanItemEntry[] itemEntries: FloorplanItemEntry[]
openingPolygons: OpeningPolygonEntry[] openingPolygons: OpeningPolygonEntry[]
@@ -596,6 +598,12 @@ type ReferenceFloorData = {
wallPolygons: WallPolygonEntry[] wallPolygons: WallPolygonEntry[]
} }
type ReferenceFloorColumnEntry = {
column: ColumnNode
points: string
polygon: Point2D[]
}
type FloorplanStairSegmentEntry = { type FloorplanStairSegmentEntry = {
centerLine: FloorplanLineSegment | null centerLine: FloorplanLineSegment | null
innerPoints: string 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 { function interpolatePlanPoint(start: Point2D, end: Point2D, t: number): Point2D {
return { return {
x: start.x + (end.x - start.x) * t, 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 }) => ( {data.openingPolygons.map(({ opening, points }) => (
<polygon <polygon
fill="rgba(255, 255, 255, 0.72)" 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 referenceWalls = children.filter((node): node is WallNode => node.type === 'wall')
const referenceFences = children.filter((node): node is FenceNode => node.type === 'fence') 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 referenceSlabs = children.filter((node): node is SlabNode => node.type === 'slab')
const referenceCeilings = children.filter( const referenceCeilings = children.filter(
(node): node is CeilingNode => node.type === 'ceiling', (node): node is CeilingNode => node.type === 'ceiling',
@@ -7886,6 +7951,21 @@ export function FloorplanPanel() {
return [{ fence, centerline, markerFrames: [], path }] 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 transformCache = new Map<string, SharedFloorplanNodeTransform | null>()
const itemEntries = referenceDescendants.flatMap((node) => { const itemEntries = referenceDescendants.flatMap((node) => {
if ( if (
@@ -7920,6 +8000,7 @@ export function FloorplanPanel() {
return { return {
ceilingPolygons, ceilingPolygons,
columnEntries,
fenceEntries, fenceEntries,
itemEntries, itemEntries,
openingPolygons, openingPolygons,
@@ -294,6 +294,11 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
parentId: event.node.id, parentId: event.node.id,
width: movingWindowNode.width, width: movingWindowNode.width,
height: movingWindowNode.height, height: movingWindowNode.height,
windowType: movingWindowNode.windowType,
operationState: movingWindowNode.operationState,
awningDirection: movingWindowNode.awningDirection,
casementStyle: movingWindowNode.casementStyle,
hingesSide: movingWindowNode.hingesSide,
frameThickness: movingWindowNode.frameThickness, frameThickness: movingWindowNode.frameThickness,
frameDepth: movingWindowNode.frameDepth, frameDepth: movingWindowNode.frameDepth,
columnRatios: movingWindowNode.columnRatios, columnRatios: movingWindowNode.columnRatios,
@@ -262,6 +262,11 @@ export const WindowTool: React.FC = () => {
parentId: event.node.id, parentId: event.node.id,
width: draft.width, width: draft.width,
height: draft.height, height: draft.height,
windowType: draft.windowType,
operationState: draft.operationState,
awningDirection: draft.awningDirection,
casementStyle: draft.casementStyle,
hingesSide: draft.hingesSide,
frameThickness: draft.frameThickness, frameThickness: draft.frameThickness,
frameDepth: draft.frameDepth, frameDepth: draft.frameDepth,
columnRatios: draft.columnRatios, columnRatios: draft.columnRatios,
@@ -4,6 +4,7 @@ import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
emitter, emitter,
useInteractive,
useScene, useScene,
WindowNode, WindowNode,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -11,6 +12,7 @@ import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react' import { useCallback, useRef } from 'react'
import { usePresetsAdapter } from '../../../contexts/presets-context' import { usePresetsAdapter } from '../../../contexts/presets-context'
import { cn } from '../../../lib/utils'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
@@ -67,6 +69,26 @@ function isSameRadiusTuple(
return current.every((value, index) => Math.abs(value - (next[index] ?? 0)) < 1e-6) 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() { export function WindowPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0]) const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
@@ -182,6 +204,11 @@ export function WindowPanel() {
parentId: node.parentId, parentId: node.parentId,
width: node.width, width: node.width,
height: node.height, height: node.height,
windowType: node.windowType,
operationState: node.operationState,
awningDirection: node.awningDirection,
casementStyle: node.casementStyle,
hingesSide: node.hingesSide,
frameThickness: node.frameThickness, frameThickness: node.frameThickness,
frameDepth: node.frameDepth, frameDepth: node.frameDepth,
openingKind: node.openingKind, openingKind: node.openingKind,
@@ -210,6 +237,11 @@ export function WindowPanel() {
return { return {
width: node.width, width: node.width,
height: node.height, height: node.height,
windowType: node.windowType,
operationState: node.operationState,
awningDirection: node.awningDirection,
casementStyle: node.casementStyle,
hingesSide: node.hingesSide,
frameThickness: node.frameThickness, frameThickness: node.frameThickness,
frameDepth: node.frameDepth, frameDepth: node.frameDepth,
openingKind: node.openingKind, openingKind: node.openingKind,
@@ -274,6 +306,22 @@ export function WindowPanel() {
const archHeight = node.archHeight ?? 0.35 const archHeight = node.archHeight ?? 0.35
const openingRevealRadius = node.openingRevealRadius ?? 0.025 const openingRevealRadius = node.openingRevealRadius ?? 0.025
const maxRoundedRadius = Math.max(0.01, getMaxSharedWindowRadius(node.width, node.height)) 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 getDimensionUpdates = (updates: Partial<Pick<WindowNode, 'width' | 'height'>>) => {
const nextWidth = updates.width ?? node.width const nextWidth = updates.width ?? node.width
@@ -398,6 +446,96 @@ export function WindowPanel() {
/> />
</PanelSection> </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' }
: {}),
})
}
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"> <PanelSection title="Position">
<SliderControl <SliderControl
label={ label={
@@ -458,7 +596,7 @@ export function WindowPanel() {
/> />
</PanelSection> </PanelSection>
{!isOpening && ( {!isOpening && !rectangleOnlyWindowTypes.has(node.windowType) && (
<PanelSection title="Corner Shape"> <PanelSection title="Corner Shape">
<SegmentedControl <SegmentedControl
onChange={(value) => onChange={(value) =>
@@ -470,6 +608,7 @@ export function WindowPanel() {
openingCornerRadii, openingCornerRadii,
cornerRadius: Math.min(cornerRadius, maxRoundedRadius), cornerRadius: Math.min(cornerRadius, maxRoundedRadius),
openingRevealRadius, openingRevealRadius,
sill: false,
} }
: {}), : {}),
...(value === 'arch' ? { archHeight } : {}), ...(value === 'arch' ? { archHeight } : {}),
+30 -1
View File
@@ -4,6 +4,7 @@ import { useEffect } from 'react'
import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction' import { closeDoorOpenState, toggleDoorOpenState } from '../lib/door-interaction'
import { runRedo, runUndo } from '../lib/history' import { runRedo, runUndo } from '../lib/history'
import { sfxEmitter } from '../lib/sfx-bus' import { sfxEmitter } from '../lib/sfx-bus'
import { closeWindowOpenState, toggleWindowOpenState } from '../lib/window-interaction'
import useEditor from '../store/use-editor' import useEditor from '../store/use-editor'
// Tools call this in their onCancel handler when they have an active mid-action to cancel, // 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) { } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.) // 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[] const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) { if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!] const node = useScene.getState().nodes[selectedNodeIds[0]!]
@@ -156,6 +157,20 @@ export const useKeyboard = ({
toggleDoorOpenState(node.id) toggleDoorOpenState(node.id)
sfxEmitter.emit('sfx:item-rotate') 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) { } else if (node && 'rotation' in node) {
e.preventDefault() e.preventDefault()
const ROTATION_STEP = Math.PI / 4 const ROTATION_STEP = Math.PI / 4
@@ -182,6 +197,20 @@ export const useKeyboard = ({
closeDoorOpenState(node.id) closeDoorOpenState(node.id)
sfxEmitter.emit('sfx:item-rotate') 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) { } else if (node && 'rotation' in node) {
e.preventDefault() e.preventDefault()
const ROTATION_STEP = Math.PI / 4 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
}
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 { StairSystem } from '../../systems/stair/stair-system'
import { WallCutout } from '../../systems/wall/wall-cutout' import { WallCutout } from '../../systems/wall/wall-cutout'
import { WallSystem } from '../../systems/wall/wall-system' import { WallSystem } from '../../systems/wall/wall-system'
import { WindowAnimationSystem } from '../../systems/window/window-animation-system'
import { WindowSystem } from '../../systems/window/window-system' import { WindowSystem } from '../../systems/window/window-system'
import { ZoneSystem } from '../../systems/zone/zone-system' import { ZoneSystem } from '../../systems/zone/zone-system'
import { ErrorBoundary } from '../error-boundary' import { ErrorBoundary } from '../error-boundary'
@@ -227,6 +228,7 @@ const Viewer: React.FC<ViewerProps> = ({
{/* Core systems */} {/* Core systems */}
<CeilingSystem /> <CeilingSystem />
<DoorAnimationSystem /> <DoorAnimationSystem />
<WindowAnimationSystem />
<DoorSystem /> <DoorSystem />
<FenceSystem /> <FenceSystem />
<ItemSystem /> <ItemSystem />
@@ -0,0 +1,108 @@
import { type AnyNodeId, sceneRegistry, useInteractive, useScene } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import {
AWNING_WINDOW_SASH_NAME,
CASEMENT_WINDOW_SASH_NAME,
FRENCH_CASEMENT_LEFT_SASH_NAME,
FRENCH_CASEMENT_RIGHT_SASH_NAME,
HOPPER_WINDOW_SASH_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 === '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 })
}
}
}, 2)
return null
}
File diff suppressed because it is too large Load Diff