Merge remote-tracking branch 'origin/main' into feat/baked-glb-export

# Conflicts:
#	packages/editor/src/components/editor/export-manager.tsx
This commit is contained in:
Wassim SAMAD
2026-06-25 12:34:24 -04:00
141 changed files with 15999 additions and 2062 deletions
@@ -108,6 +108,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
@@ -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
}
@@ -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,
}
@@ -85,6 +85,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