Merge origin/main into feat/placement-interaction-overhaul

Resolve 7 conflicts keeping our snapping migration + floorplan perf work as
source of truth, combined with main's MEP run-continuation / Alt-detach /
latch handles. Rebuilt two import blocks the auto-merge silently truncated
(node-arrow-handles.tsx, duct-fitting/move-tool.tsx).

Verified: tsc clean across core/viewer/editor/nodes/mcp, 451 tests pass,
biome clean. Floorplan view-transform re-render storm confirmed pre-existing
(not introduced by this merge).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-28 16:22:47 -04:00
co-authored by Claude Opus 4.8
171 changed files with 19294 additions and 2224 deletions
@@ -112,6 +112,7 @@ export const DoorSystem = () => {
// Editing a scene material a door slot references must rebuild that door
// (door meshes are built by this system, not <GeometrySystem>).
useEffect(() => {
void sceneMaterials
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
if (node?.type !== 'door') continue
@@ -1109,6 +1110,7 @@ function addDoorLeaf(
hingeX,
hingeSide,
swingRotation,
openRotationY,
segments,
contentPadding,
handle,
@@ -1134,6 +1136,10 @@ function addDoorLeaf(
hingeX: number
hingeSide: 'left' | 'right'
swingRotation: number
// Leaf rotation (radians, about the hinge Y axis) at fully-open. The GLB
// exporter reads this off the leaf group to bake an open/close clip; it is
// the kinematic endpoint, independent of the current `swingRotation`.
openRotationY: number
segments: DoorNode['segments']
contentPadding: DoorNode['contentPadding']
handle: boolean
@@ -1164,6 +1170,10 @@ function addDoorLeaf(
const leafGroup = new THREE.Group()
leafGroup.position.set(hingeX, 0, 0)
leafGroup.rotation.y = swingRotation
// Marks this group as the swing leaf and records its fully-open angle so the
// GLB exporter can bake an open/close animation clip from a single pose. The
// exporter strips this marker before writing the file.
leafGroup.userData.pascalSwingLeaf = { axis: 'y', openRotationY }
mesh.add(leafGroup)
const addLeafBox = (
@@ -2486,6 +2496,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
hingeX: -insideWidth / 2,
hingeSide: 'left',
swingRotation: -clampedSwingAngle * swingDirectionSign,
openRotationY: (-Math.PI / 2) * swingDirectionSign,
segments,
contentPadding,
handle,
@@ -2514,6 +2525,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
hingeX: insideWidth / 2,
hingeSide: 'right',
swingRotation: clampedSwingAngle * swingDirectionSign,
openRotationY: (Math.PI / 2) * swingDirectionSign,
segments,
contentPadding,
handle,
@@ -2545,6 +2557,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
hingeX,
hingeSide: hingesSide,
swingRotation: clampedSwingAngle * swingDirectionSign * hingeDirectionSign,
openRotationY: (Math.PI / 2) * swingDirectionSign * hingeDirectionSign,
segments,
contentPadding,
handle,
@@ -0,0 +1,17 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import { Group } from 'three'
import { type GeometryBuildCacheEntry, shouldReuseGeometryBuild } from './geometry-system'
describe('shouldReuseGeometryBuild', () => {
test('rebuilds when the same node id remounts into a new group with the same key', () => {
const cache = new Map<string, GeometryBuildCacheEntry>()
const firstGroup = new Group()
const remountedGroup = new Group()
expect(shouldReuseGeometryBuild(cache, 'duct_1', firstGroup, 'same-key')).toBe(false)
expect(shouldReuseGeometryBuild(cache, 'duct_1', firstGroup, 'same-key')).toBe(true)
expect(shouldReuseGeometryBuild(cache, 'duct_1', remountedGroup, 'same-key')).toBe(false)
})
})
@@ -66,7 +66,7 @@ export const GeometrySystem = () => {
// `def.geometryKey`). Lets us skip a dispose+rebuild when a node is dirty
// but its geometry inputs are unchanged — e.g. an item reparenting onto a
// shelf dirties the shelf without altering its boards.
const builtGeometryKeyRef = useRef<Map<string, string>>(new Map())
const builtGeometryKeyRef = useRef<Map<string, GeometryBuildCacheEntry>>(new Map())
// Re-mark every geometry-backed node dirty whenever a viewer appearance
// value changes, so `def.geometry` builders re-run and pick up the new
@@ -93,6 +93,7 @@ export const GeometrySystem = () => {
// then mark it dirty. Scoped to nodes carrying a `scene:` ref so an
// unrelated material edit doesn't churn the whole scene.
useEffect(() => {
void sceneMaterials
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
const def = nodeRegistry.get(node.type)
@@ -187,11 +188,10 @@ export const GeometrySystem = () => {
// churn when an item reparents onto a shelf.
if (def.geometryKey) {
const builtKey = `${shading}|${textures}|${colorPreset}|${sceneTheme}|${def.geometryKey(effectiveNode)}`
if (builtGeometryKeyRef.current.get(id) === builtKey) {
if (shouldReuseGeometryBuild(builtGeometryKeyRef.current, id, group, builtKey)) {
clearDirty(id as AnyNodeId)
continue
}
builtGeometryKeyRef.current.set(id, builtKey)
}
const parentId = (node.parentId ?? null) as AnyNodeId | null
@@ -380,3 +380,20 @@ function isCachedMaterial(value: unknown): boolean {
}
export default GeometrySystem
export type GeometryBuildCacheEntry = {
group: Group
key: string
}
export function shouldReuseGeometryBuild(
cache: Map<string, GeometryBuildCacheEntry>,
id: string,
group: Group,
key: string,
): boolean {
const cached = cache.get(id)
if (cached?.group === group && cached.key === key) return true
cache.set(id, { group, key })
return false
}
@@ -0,0 +1,87 @@
'use client'
import type { Control, ControlValue } from '@pascal-app/core'
/** One interactive control (toggle / slider / temperature) rendered inside the
* item controls overlay. Shared by the parametric `InteractiveSystem` and the
* baked-GLB `GlbInteractive` overlay so both look and behave identically. */
export const ControlWidget = ({
control,
value,
onChange,
}: {
control: Control
value: ControlValue
onChange: (v: ControlValue) => void
}) => {
const labelStyle: React.CSSProperties = {
color: 'white',
fontSize: 11,
fontFamily: 'monospace',
display: 'flex',
flexDirection: 'column',
gap: 2,
}
if (control.kind === 'toggle') {
return (
<button
onClick={() => onChange(!value)}
style={{
background: value ? '#4ade80' : '#374151',
color: 'white',
border: 'none',
borderRadius: 4,
padding: '4px 8px',
cursor: 'pointer',
fontSize: 12,
fontFamily: 'monospace',
transition: 'background 0.2s',
}}
>
{control.label ?? (value ? 'On' : 'Off')}
</button>
)
}
if (control.kind === 'slider') {
return (
<label style={labelStyle}>
<span>
{control.label}: {value}
{control.unit ? ` ${control.unit}` : ''}
</span>
<input
max={control.max}
min={control.min}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
step={control.step}
type="range"
value={value as number}
/>
</label>
)
}
if (control.kind === 'temperature') {
return (
<label style={labelStyle}>
<span>
{control.label}: {value}°{control.unit}
</span>
<input
max={control.max}
min={control.min}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
step={1}
type="range"
value={value as number}
/>
</label>
)
}
return null
}
@@ -2,8 +2,6 @@
import {
type AnyNodeId,
type Control,
type ControlValue,
type ItemNode,
pointInPolygon,
sceneRegistry,
@@ -17,6 +15,7 @@ import { useEffect, useState } from 'react'
import { type Object3D, Vector3 } from 'three'
import { useShallow } from 'zustand/react/shallow'
import useViewer from '../../store/use-viewer'
import { ControlWidget } from './control-widget'
const _tempVec = new Vector3()
@@ -146,86 +145,3 @@ const ItemControlsOverlay = ({
itemObj,
)
}
// ---- Control widgets ----
const ControlWidget = ({
control,
value,
onChange,
}: {
control: Control
value: ControlValue
onChange: (v: ControlValue) => void
}) => {
const labelStyle: React.CSSProperties = {
color: 'white',
fontSize: 11,
fontFamily: 'monospace',
display: 'flex',
flexDirection: 'column',
gap: 2,
}
if (control.kind === 'toggle') {
return (
<button
onClick={() => onChange(!value)}
style={{
background: value ? '#4ade80' : '#374151',
color: 'white',
border: 'none',
borderRadius: 4,
padding: '4px 8px',
cursor: 'pointer',
fontSize: 12,
fontFamily: 'monospace',
transition: 'background 0.2s',
}}
>
{control.label ?? (value ? 'On' : 'Off')}
</button>
)
}
if (control.kind === 'slider') {
return (
<label style={labelStyle}>
<span>
{control.label}: {value}
{control.unit ? ` ${control.unit}` : ''}
</span>
<input
max={control.max}
min={control.min}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
step={control.step}
type="range"
value={value as number}
/>
</label>
)
}
if (control.kind === 'temperature') {
return (
<label style={labelStyle}>
<span>
{control.label}: {value}°{control.unit}
</span>
<input
max={control.max}
min={control.min}
onChange={(e) => onChange(Number(e.target.value))}
onPointerDown={(e) => e.stopPropagation()}
step={1}
type="range"
value={value as number}
/>
</label>
)
}
return null
}
@@ -36,6 +36,10 @@ function getWallHideState(
return hideWall
}
function sameMaterialArray(a: Material | Material[], b: Material[]): boolean {
return Array.isArray(a) && a.length === b.length && a.every((material, i) => material === b[i])
}
export const WallCutout = () => {
const lastCameraPosition = useRef(new Vector3())
const lastCameraTarget = useRef(new Vector3())
@@ -113,7 +117,13 @@ export const WallCutout = () => {
useScene.getState().materials,
)
if (hideWall) {
if (wallMode === 'translucent') {
;(wallMesh as Mesh).material = isDeleteHighlighted
? materials.deleteTranslucent
: isSelectionHighlighted
? getSelectionHighlightMaterials(materials.translucent)
: materials.translucent
} else if (hideWall) {
;(wallMesh as Mesh).material = isDeleteHighlighted
? materials.deleteInvisible
: isSelectionHighlighted
@@ -160,6 +170,11 @@ export const WallCutout = () => {
wallMesh.material = mats.visible
} else if (current === mats.deleteInvisible) {
wallMesh.material = mats.invisible
} else if (
current === mats.deleteTranslucent ||
sameMaterialArray(current, getSelectionHighlightMaterials(mats.translucent))
) {
wallMesh.material = mats.translucent
}
})
}
@@ -46,8 +46,10 @@ export type WallMaterialArray = [Material, Material, Material]
export interface WallMaterials {
visible: WallMaterialArray
invisible: WallMaterialArray
translucent: WallMaterialArray
deleteVisible: WallMaterialArray
deleteInvisible: WallMaterialArray
deleteTranslucent: WallMaterialArray
materialHash: string
}
@@ -297,6 +299,25 @@ function createInvisibleWallMaterial(color: string, shading: RenderShading): Mat
return material
}
function createTranslucentWallMaterial(color: string, shading: RenderShading): Material {
const material =
shading === 'solid'
? new MeshLambertNodeMaterial({
transparent: true,
color,
opacity: 0.35,
depthWrite: false,
})
: new MeshStandardNodeMaterial({
transparent: true,
color,
opacity: 0.35,
depthWrite: false,
})
return material
}
function mapWallMaterialArray(
materials: WallMaterialArray,
iteratee: (material: Material, index: number) => Material,
@@ -347,7 +368,13 @@ export function getMaterialsForWall(
}
if (existing) {
disposeOwnedMaterials([existing.invisible, existing.deleteVisible, existing.deleteInvisible])
disposeOwnedMaterials([
existing.invisible,
existing.translucent,
existing.deleteVisible,
existing.deleteInvisible,
existing.deleteTranslucent,
])
}
const wallRoleMaterial = createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme)
@@ -381,18 +408,39 @@ export function getMaterialsForWall(
),
]
const translucent: WallMaterialArray = [
createTranslucentWallMaterial(wallRoleColor, textures ? shading : 'solid'),
createTranslucentWallMaterial(
textures
? resolveWallFaceColor(wallNode, 'interior', sceneMaterials, wallRoleColor)
: wallRoleColor,
textures ? shading : 'solid',
),
createTranslucentWallMaterial(
textures
? resolveWallFaceColor(wallNode, 'exterior', sceneMaterials, wallRoleColor)
: wallRoleColor,
textures ? shading : 'solid',
),
]
const deleteVisible = mapWallMaterialArray(visible, (material) =>
createHighlightedWallMaterial(material, 'delete'),
)
const deleteInvisible = mapWallMaterialArray(invisible, (material) =>
createHighlightedWallMaterial(material, 'delete'),
)
const deleteTranslucent = mapWallMaterialArray(translucent, (material) =>
createHighlightedWallMaterial(material, 'delete'),
)
const result: WallMaterials = {
visible,
invisible,
translucent,
deleteVisible,
deleteInvisible,
deleteTranslucent,
materialHash,
}
@@ -7,6 +7,7 @@ import {
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,
@@ -28,12 +29,20 @@ function markWindowDirty(windowId: AnyNodeId) {
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)
/**
* 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
@@ -120,6 +129,12 @@ function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) {
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()
@@ -89,6 +89,7 @@ export const WindowSystem = () => {
// (window meshes are built by this system, not <GeometrySystem>, so its
// scene-material re-dirty doesn't cover them).
useEffect(() => {
void sceneMaterials
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
if (node?.type !== 'window') continue