Merge remote-tracking branch 'origin/main' into fix/fri-8-may

# Conflicts:
#	packages/editor/src/components/tools/fence/move-fence-endpoint-tool.tsx
#	packages/editor/src/components/tools/select/box-select-tool.tsx
#	packages/editor/src/components/tools/wall/move-wall-tool.tsx
#	packages/editor/src/components/ui/panels/column-panel.tsx
#	packages/editor/src/components/ui/panels/door-panel.tsx
#	packages/editor/src/components/ui/panels/window-panel.tsx
#	packages/viewer/src/components/renderers/column/column-renderer.tsx
#	packages/viewer/src/components/viewer/index.tsx
#	packages/viewer/src/systems/slab/slab-system.tsx
This commit is contained in:
sudhir
2026-05-12 00:27:06 +05:30
241 changed files with 6976 additions and 5242 deletions
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useRef } from 'react'
import { MeshBasicMaterial, type Mesh } from 'three'
import { type Mesh, MeshBasicMaterial } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
const doorHitboxMaterial = new MeshBasicMaterial({ visible: false })
@@ -2,28 +2,12 @@ import { type FenceNode, useRegistry, useScene } from '@pascal-app/core'
import { useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import {
createMaterial,
createMaterialFromPresetRef,
DEFAULT_STAIR_MATERIAL,
} from '../../../lib/materials'
import { DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
export const FenceRenderer = ({ node }: { node: FenceNode }) => {
const ref = useRef<Mesh>(null!)
const handlers = useNodeEvents(node, 'fence')
const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material
if (!mat) return DEFAULT_STAIR_MATERIAL
return createMaterial(mat)
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
])
const material = useMemo(() => DEFAULT_STAIR_MATERIAL, [])
useRegistry(node.id, 'fence', ref)
useLayoutEffect(() => {
@@ -1,5 +1,5 @@
import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
@@ -24,10 +24,27 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
const parentNode = node.parentId
? (nodes[node.parentId as AnyNodeId] as StairNode | undefined)
: undefined
const material = useMemo(
() => getStraightStairSegmentBodyMaterials(node, parentNode),
[node, parentNode],
)
const material = useMemo(() => {
return getStraightStairSegmentBodyMaterials(node, parentNode)
}, [
node.materialPreset,
node.material,
node.material?.preset,
node.material?.properties,
node.material?.texture,
parentNode?.materialPreset,
parentNode?.material,
parentNode?.material?.preset,
parentNode?.material?.properties,
parentNode?.material?.texture,
parentNode?.railingMaterialPreset,
parentNode?.railingMaterial,
parentNode?.sideMaterialPreset,
parentNode?.sideMaterial,
parentNode?.treadMaterialPreset,
parentNode?.treadMaterial,
])
const placeholderGeometry = useMemo(() => {
const geometry = new THREE.BufferGeometry()
@@ -112,9 +112,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
receiveShadow
/>
) : null}
{!isSegmentBasedStair ? (
{isSegmentBasedStair ? null : (
<CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} />
) : null}
)}
<StairRailings material={railingMaterial} stair={node} />
{isSegmentBasedStair ? (
<group name="segments-wrapper" visible={false}>
@@ -292,7 +292,7 @@ function StairRailings({ stair, material }: { stair: StairNode; material: THREE.
))}
{railPaths.slice(1).map((segmentPath, index) => {
const previousPath = railPaths[index]
if (!previousPath || !segmentPath.connectFromPrevious) return null
if (!(previousPath && segmentPath.connectFromPrevious)) return null
if (previousPath.layout.segment.segmentType === 'landing') return null
if (segmentPath.layout.segment.segmentType === 'landing') return null
@@ -451,10 +451,10 @@ function CurvedStairBody({
{isSpiral && (stair.showCenterColumn ?? true) ? (
<mesh
castShadow
receiveShadow
material={sideMaterial}
name="stair-side"
position={[0, spiralColumnHeight / 2, 0]}
receiveShadow
>
<cylinderGeometry
args={[spiralColumnRadius, spiralColumnRadius, spiralColumnHeight, 10]}
@@ -6,7 +6,8 @@ type FrameLimiterProps = {
}
const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
const { advance, set, frameloop: initFrameloop } = useThree()
const { advance, set, frameloop: initFrameloop, scene, clock } = useThree()
const renderer = useThree((state) => state.gl)
useLayoutEffect(() => {
let elapsed = 0
@@ -14,7 +15,6 @@ const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
let i = 0
let raf: number | null = null
const interval = 1000 / fps
function tick(t: DOMHighResTimeStamp) {
raf = requestAnimationFrame(tick)
elapsed = t - then
@@ -24,10 +24,11 @@ const FrameLimiter: React.FC<FrameLimiterProps> = ({ fps = 50 }) => {
then = t - (elapsed % interval)
}
}
// Set frameloop to never, it will shut down the default render loop
set({ frameloop: 'never' })
// Kick off custom render loop
raf = requestAnimationFrame(tick)
// Restore initial setting
return () => {
if (raf) {
cancelAnimationFrame(raf)
+17 -11
View File
@@ -81,7 +81,7 @@ extend(THREE as any)
const WEBGPU_RENDERER_CACHE = new WeakMap<HTMLCanvasElement, Promise<THREE.WebGPURenderer>>()
/**
* Monitors the WebGPU device for loss events and logs them.
* Monitors the WebGPU device for loss / uncaptured errors and logs them.
* WebGPU device loss can happen when:
* - Tab is backgrounded and OS reclaims GPU
* - Driver crash or GPU reset
@@ -105,7 +105,7 @@ function GPUDeviceWatcher() {
useEffect(() => {
const backend = (gl as any).backend
const device = backend?.device as WebGPUDeviceLike | undefined
const device: GPUDevice | undefined = backend?.device
if (!device) {
console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', {
@@ -117,25 +117,25 @@ function GPUDeviceWatcher() {
console.log('[viewer] WebGPU device ready', {
label: device.label,
features: device.features ? Array.from(device.features) : [],
features: Array.from(device.features ?? []),
})
device.lost.then((info: WebGPUDeviceLossInfo) => {
device.lost.then((info) => {
console.error(
`[viewer] WebGPU device lost: reason="${info.reason ?? 'unknown'}", message="${info.message ?? ''}". ` +
`[viewer] WebGPU device lost: reason="${info.reason}", message="${info.message}". ` +
'The page must be reloaded to recover the GPU context.',
)
})
// Uncaptured errors are normally silent (only console-warned by Chrome at
// best). Pipe them to console.error so silent mobile crashes show up.
const onUncapturedError = (event: any) => {
console.error('[viewer] WebGPU uncaptured error:', event?.error?.message, event?.error)
const onUncapturedError = (event: GPUUncapturedErrorEvent) => {
console.error('[viewer] WebGPU uncaptured error:', event.error.message, event.error)
}
device.addEventListener?.('uncapturederror', onUncapturedError)
device.addEventListener('uncapturederror', onUncapturedError as EventListener)
return () => {
device.removeEventListener?.('uncapturederror', onUncapturedError)
device.removeEventListener('uncapturederror', onUncapturedError as EventListener)
}
}, [gl])
@@ -147,6 +147,7 @@ interface ViewerProps {
hoverStyles?: HoverStyles
selectionManager?: 'default' | 'custom'
perf?: boolean
useBvh?: boolean
}
const Viewer: React.FC<ViewerProps> = ({
@@ -154,6 +155,7 @@ const Viewer: React.FC<ViewerProps> = ({
hoverStyles = DEFAULT_HOVER_STYLES,
selectionManager = 'default',
perf = false,
useBvh = true,
}) => {
const theme = useViewer((state) => state.theme)
return (
@@ -216,9 +218,13 @@ const Viewer: React.FC<ViewerProps> = ({
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
/> */}
<Lights />
<SceneBvh>
{useBvh ? (
<SceneBvh>
<SceneRenderer />
</SceneBvh>
) : (
<SceneRenderer />
</SceneBvh>
)}
{/* Default Systems */}
<LevelSystem />
@@ -131,8 +131,6 @@ const PostProcessingPasses = ({
// Reset retry state when project changes
useEffect(() => {
// Intentionally touch projectId so the effect reruns on project switches.
void projectId
retryCountRef.current = 0
if (rebuildTimeoutRef.current !== null) {
clearTimeout(rebuildTimeoutRef.current)
@@ -168,11 +166,6 @@ const PostProcessingPasses = ({
// Build / rebuild the post-processing pipeline
useEffect(() => {
// Intentionally touch these so React/biome treat project switches and retry bumps
// as explicit rebuild triggers instead of accidental extra dependencies.
void projectId
void pipelineVersion
if (!(renderer && scene && camera)) {
console.warn('[viewer/post-processing] Skipping pipeline build — missing dependency.', {
hasRenderer: !!renderer,
@@ -192,24 +185,6 @@ const PostProcessingPasses = ({
hasPipelineErrorRef.current = false
// WebGPU availability check: SSGI, denoise, and RenderPipeline are all
// WebGPU-only APIs. When the browser falls back to WebGL2 (no
// `navigator.gpu`, or the device couldn't be created), building the
// pipeline either throws silently or produces a broken output where
// the scene renders for a few frames and then goes black as the retry
// loop fights the direct-render fallback path. Short-circuit here so
// `useFrame` uses the direct `renderer.render(scene, camera)` path
// exclusively and never attempts the TSL pipeline.
const hasWebGPU = typeof navigator !== 'undefined' && 'gpu' in navigator
if (!hasWebGPU) {
console.warn(
'[viewer] WebGPU unavailable — rendering without post-processing (SSGI, outlines, denoise).',
)
hasPipelineErrorRef.current = true
renderPipelineRef.current = null
return
}
// Clear outliner arrays synchronously to prevent stale Object3D refs
// from the previous project leaking into the new pipeline's outline passes.
const outliner = useViewer.getState().outliner