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
@@ -8,7 +8,14 @@ import {
useScene,
} from '@pascal-app/core'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react'
import {
forwardRef,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from 'react'
import * as THREE from 'three/webgpu'
import { hasDrawableGeometry } from '../../lib/drawable-geometry'
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
@@ -67,6 +74,38 @@ const DIRTY_BUILD_KINDS = new Set([
const warnedEmptyDraw = process.env.NODE_ENV === 'production' ? null : new WeakSet<object>()
function canCreateWebGLContext() {
if (typeof document === 'undefined') return false
const canvas = document.createElement('canvas')
try {
return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'))
} catch {
return false
}
}
function canMountGpuViewer() {
if (typeof window === 'undefined') return false
if (!('gpu' in navigator) && !canCreateWebGLContext()) return false
return true
}
function UnsupportedGpuViewerFallback() {
return (
<div className="flex h-full min-h-64 w-full items-center justify-center bg-[#fafafa] p-6 text-center text-neutral-900">
<div className="max-w-md rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm">
<h2 className="font-semibold text-lg">3D viewer unavailable</h2>
<p className="mt-2 text-neutral-600 text-sm">
This browser or environment does not expose WebGPU or WebGL, so Pascal cannot render the
3D scene here. Try opening the editor in a browser with hardware acceleration enabled.
</p>
</div>
</div>
)
}
/**
* Renderer-level safety net against the empty-vertex-buffer crash.
*
@@ -349,6 +388,16 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
}
}, [isolate])
const [rendererInitFailed, setRendererInitFailed] = useState(false)
// Capability detection runs after mount. We start optimistic (true) so the
// server-rendered markup and the first client render agree (no hydration
// mismatch); the effect flips it to false only on environments that expose
// neither WebGPU nor WebGL.
const [canMountViewer, setCanMountViewer] = useState(true)
useEffect(() => {
if (!canMountGpuViewer()) setCanMountViewer(false)
}, [])
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
const transparentBackground = useViewer((state) => state.transparentBackground)
useLayoutEffect(() => {
@@ -401,6 +450,17 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
// Desktops (fine pointer) keep the original 1.5 cap.
const maxDpr =
typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches ? 1.25 : 1.5
const showGpuFallback = !canMountViewer || rendererInitFailed
// When we can't mount the GPU canvas, the SceneReadyTracker never mounts and
// the host editor would otherwise wait on its scene-readiness timeout. Signal
// readiness explicitly so the host can drop its loader immediately.
useEffect(() => {
if (showGpuFallback) onSceneReadyChange?.(true)
}, [showGpuFallback, onSceneReadyChange])
if (showGpuFallback) {
return <UnsupportedGpuViewerFallback />
}
return (
<Canvas
camera={{ position: [50, 50, 50], fov: 50 }}
@@ -430,6 +490,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
// rejection forever.
if (canvas) WEBGPU_RENDERER_CACHE.delete(canvas)
console.error('[viewer] WebGPURenderer init failed', err)
setRendererInitFailed(true)
throw err
}
})()
+3 -3
View File
@@ -63,8 +63,8 @@ type ViewerState = {
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
wallMode: 'up' | 'cutaway' | 'down'
setWallMode: (mode: 'up' | 'cutaway' | 'down') => void
wallMode: 'up' | 'cutaway' | 'down' | 'translucent'
setWallMode: (mode: 'up' | 'cutaway' | 'down' | 'translucent') => void
showScans: boolean
setShowScans: (show: boolean) => void
@@ -145,7 +145,7 @@ const COLOR_PRESETS = ['clay', 'white', 'mono', 'blueprint'] as const
const EDGE_MODES = ['off', 'soft', 'strong'] as const
const UNITS = ['metric', 'imperial'] as const
const LEVEL_MODES = ['stacked', 'exploded', 'solo', 'manual'] as const
const WALL_MODES = ['up', 'cutaway', 'down'] as const
const WALL_MODES = ['up', 'cutaway', 'down', 'translucent'] as const
function pickString<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
return typeof value === 'string' && allowed.includes(value as T) ? (value as T) : fallback
@@ -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