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