Merge remote-tracking branch 'origin/main' into feat/paint-slots

# Conflicts:
#	packages/core/src/store/use-scene.ts
#	packages/editor/src/components/editor/index.tsx
This commit is contained in:
Wassim SAMAD
2026-06-18 12:22:26 -04:00
415 changed files with 22805 additions and 1414 deletions
@@ -2606,3 +2606,13 @@ function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
}
cutout.visible = false
}
/**
* Build a fresh door mesh for preview/ghost rendering.
* Returns a mesh with an invisible hitbox root and visible children (frame, panels, hardware).
*/
export function buildDoorPreviewMesh(node: DoorNode): THREE.Mesh {
const mesh = new THREE.Mesh()
updateDoorMesh(node, mesh)
return mesh
}
@@ -68,6 +68,14 @@ export const GeometrySystem = () => {
// shelf dirties the shelf without altering its boards.
const builtGeometryKeyRef = useRef<Map<string, string>>(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
// shading / texture / preset / theme. These four are deliberate re-run
// TRIGGERS, not values read in the body — the effect re-fires on any
// change. They're primitives (stable by value), so listing them is safe;
// biome flags them as "unnecessary" because the body doesn't reference
// them, but dropping them silently breaks appearance-mode switching.
// biome-ignore lint/correctness/useExhaustiveDependencies: shading/textures/colorPreset/sceneTheme are intentional re-run triggers; removing them stops geometry from rebuilding on appearance change.
useEffect(() => {
const nodes = useScene.getState().nodes
for (const node of Object.values(nodes)) {
@@ -1,8 +1,7 @@
import { type LevelNode, sceneRegistry, useScene } from '@pascal-app/core'
import { getLevelHeight, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { lerp } from 'three/src/math/MathUtils.js'
import useViewer from '../../store/use-viewer'
import { getLevelHeight } from './level-utils'
const EXPLODED_GAP = 5
@@ -40,7 +39,11 @@ export const LevelSystem = () => {
obj.position.y = lerp(obj.position.y, targetY, delta * 12) // Smoothly animate to new Y position
obj.visible = levelMode !== 'solo' || level?.id === selectedLevel || !selectedLevel
cumulativeY += getLevelHeight(levelId, nodes)
cumulativeY += getLevelHeight(
levelId,
nodes,
(wallId) => sceneRegistry.nodes.get(wallId)?.position.y,
)
}
}, 5) // Using a lower priority so it runs after transforms from other systems have settled
return null
@@ -1,53 +1,4 @@
import {
type CeilingNode,
type LevelNode,
sceneRegistry,
useScene,
type WallNode,
} from '@pascal-app/core'
export const DEFAULT_LEVEL_HEIGHT = 2.5
// Cache: levelId → computed height. Invalidated when the nodes reference changes.
// Zustand produces a new `nodes` object on every mutation, so reference equality
// is a zero-cost way to detect stale data without any subscription overhead.
const heightCache = new Map<string, number>()
let lastNodesRef: object | null = null
export function getLevelHeight(
levelId: string,
nodes: ReturnType<typeof useScene.getState>['nodes'],
): number {
if (nodes !== lastNodesRef) {
heightCache.clear()
lastNodesRef = nodes
}
if (heightCache.has(levelId)) return heightCache.get(levelId)!
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
if (!level) return DEFAULT_LEVEL_HEIGHT
let maxTop = 0
for (const childId of level.children) {
const child = nodes[childId as keyof typeof nodes]
if (!child) continue
if (child.type === 'ceiling') {
const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
if (ch > maxTop) maxTop = ch
} else if (child.type === 'wall') {
let meshY = sceneRegistry.nodes.get(childId as any)?.position.y ?? 0
if (meshY < 0) meshY = 0
const top = meshY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
if (top > maxTop) maxTop = top
}
}
const height = maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT
heightCache.set(levelId, height)
return height
}
import { getLevelHeight, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core'
/**
* Instantly snaps all level Objects3D to their true stacked Y positions
@@ -90,7 +41,11 @@ export function snapLevelsToTruePositions(): () => void {
for (const { levelId, obj } of entries) {
obj.position.y = cumulativeY
obj.visible = true
cumulativeY += getLevelHeight(levelId, nodes)
cumulativeY += getLevelHeight(
levelId,
nodes,
(wallId) => sceneRegistry.nodes.get(wallId)?.position.y,
)
}
return () => {
@@ -3648,3 +3648,13 @@ function syncWindowCutout(node: WindowNode, mesh: THREE.Mesh) {
}
cutout.visible = false
}
/**
* Build a fresh window mesh for preview/ghost rendering.
* Returns a mesh with an invisible hitbox root and visible children (frame, glass, sash, hardware).
*/
export function buildWindowPreviewMesh(node: WindowNode): THREE.Mesh {
const mesh = new THREE.Mesh()
updateWindowMesh(node, mesh)
return mesh
}