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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user