Skip to content
24 changes: 24 additions & 0 deletions packages/core/src/events/bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ColumnNode,
DoorNode,
FenceNode,
GuideNode,
ItemNode,
LevelNode,
RoofNode,
Expand Down Expand Up @@ -132,6 +133,26 @@ 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 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 }
Expand Down Expand Up @@ -173,6 +194,9 @@ type EditorEvents = GridEvents &
NodeEvents<'door', DoorEvent> &
CameraControlEvents &
ToolEvents &
GuideEvents &
DoorAnimationEvents &
WindowAnimationEvents &
PresetEvents &
ThumbnailEvents &
SnapshotEvents &
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -62,8 +69,12 @@ export {
} from './store/history-control'
export {
type ControlValue,
type DoorAnimationState,
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'
Expand Down
42 changes: 42 additions & 0 deletions packages/core/src/lib/door-operation.ts
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 1 addition & 1 deletion packages/core/src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/schema/nodes/door.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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'),
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/schema/nodes/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
129 changes: 129 additions & 0 deletions packages/core/src/store/use-interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,39 @@ 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
}

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
Expand All @@ -23,6 +54,30 @@ 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

/** 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 => {
Expand All @@ -40,6 +95,10 @@ const defaultControlValue = (interactive: Interactive, index: number): ControlVa

export const useInteractive = create<InteractiveStore>((set, get) => ({
items: {},
doors: {},
doorAnimations: {},
windows: {},
windowAnimations: {},

initItem: (itemId, interactive) => {
const { controls } = interactive
Expand Down Expand Up @@ -74,4 +133,74 @@ 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 }
})
},

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 }
})
},
}))
Loading
Loading