Promote the client GLB export into the baked-artifact format from
plans/editor-baked-glb-export.md (phases 0 and 1).
- NodeMaterial -> classic MeshStandardMaterial conversion at export. The
viewer's MeshStandard/LambertNodeMaterial set isNodeMaterial, not
isMeshStandardMaterial, so GLTFExporter would otherwise drop every
surface to a blank default. KTX2 (compressed) maps are decompressed via
WebGPUTextureUtils so the exporter can embed them (PNG for now; KTX2
re-encode is deferred to the phase-3 bake worker).
- Identity stamping from sceneRegistry: node.name = pascalId and
extras = { pascalId, kind, label?, openable?, clips? }; all other
userData stripped so editor/runtime ephemera never reach glTF extras.
- Door/window open clips baked from the build-once + pose-at-t primitives
(door pascalSwingLeaf marker, window poseWindowMovingParts). Clips named
by label ("Door 1: open"), carry extras.loop = false (consumers play
once and hold; dumb glTF players still loop).
- Cutout fix: door/window selection hitboxes hide via material.visible,
which onlyVisible misses, so the hitbox box plugged the wall opening.
Non-renderable container meshes now keep their node but lose geometry;
childless ones are removed.
- Editor-overlay stripping mirrors the thumbnail capture: emit
thumbnail:before/after-capture so scene-layer affordances (handles,
ceiling/site brackets) self-hide, and drop anything off SCENE_LAYER
(gizmos, grid, zone fills).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
186 lines
6.2 KiB
TypeScript
186 lines
6.2 KiB
TypeScript
import {
|
|
type AnyNodeId,
|
|
emitter,
|
|
sceneRegistry,
|
|
useInteractive,
|
|
useScene,
|
|
type WindowNode,
|
|
} from '@pascal-app/core'
|
|
import { useFrame } from '@react-three/fiber'
|
|
import type { Object3D } from 'three'
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* Pose a window's moving parts (sash/panel/slats) at `value` (0 = closed,
|
|
* 1 = open) by mutating the named child groups under `mesh`. Returns true when
|
|
* the window type has a direct pose path and the named parts were found.
|
|
*
|
|
* This is the single source of truth for window kinematics: the live animation
|
|
* system poses the registered scene mesh, and the GLB exporter poses an export
|
|
* clone to sample the open/close keyframes for a baked animation clip.
|
|
*/
|
|
export function poseWindowMovingParts(
|
|
node: WindowNode,
|
|
mesh: Object3D | undefined,
|
|
value: number,
|
|
): boolean {
|
|
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
|
|
}
|
|
|
|
function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) {
|
|
const node = useScene.getState().nodes[windowId]
|
|
if (node?.type !== 'window') return false
|
|
return poseWindowMovingParts(node, sceneRegistry.nodes.get(windowId), value)
|
|
}
|
|
|
|
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
|
|
}
|