Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@
# Google Maps API key (enables address search)
# NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_google_maps_api_key

# React render debugging overlay (disabled by default)
# NEXT_PUBLIC_REACT_SCAN=false

# Dev server port (default: 3000)
# PORT=3000
5 changes: 4 additions & 1 deletion apps/editor/app/client-bootstrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@
import '../lib/bootstrap'
import { type ReactNode, useEffect } from 'react'

const shouldEnableReactScan =
process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_REACT_SCAN === 'true'

export function ClientBootstrap({ children }: { children: ReactNode }) {
useEffect(() => {
if (process.env.NODE_ENV !== 'development') return
if (!shouldEnableReactScan) return
// Loaded here (not via a `<Script>` tag in <head>) to avoid React's
// "script inside a React component" hydration warning. The package
// is already a direct dep, so we don't need the CDN auto-global.
Expand Down
5 changes: 0 additions & 5 deletions apps/editor/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,6 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable} ${barlow.variable}`}
lang="en"
>
<head>
{process.env.NODE_ENV === 'development' && (
<script async crossOrigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js" />
)}
</head>
<body className="font-sans">
<ClientBootstrap>{children}</ClientBootstrap>
{process.env.NODE_ENV === 'development' && <Agentation />}
Expand Down
6 changes: 3 additions & 3 deletions apps/editor/components/viewer-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,11 @@ function ViewModeControl() {

function CollapseSidebarButton() {
const isCollapsed = useSidebarStore((state) => state.isCollapsed)
const setIsCollapsed = useSidebarStore((state) => state.setIsCollapsed)

const toggle = useCallback(() => {
setIsCollapsed(!isCollapsed)
}, [isCollapsed, setIsCollapsed])
const sidebar = useSidebarStore.getState()
sidebar.setIsCollapsed(!sidebar.isCollapsed)
}, [])

return (
<div className={TOOLBAR_CONTAINER}>
Expand Down
8 changes: 1 addition & 7 deletions packages/editor/src/components/editor/editor-layout-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,7 @@ function LeftColumn({
tabs={tabs}
/>
{!isCollapsed && (
<div
className="relative flex h-full flex-col"
style={{
width,
transition: isDragging ? 'none' : 'width 150ms ease',
}}
>
<div className="relative flex h-full flex-col" style={{ width }}>
<div className="relative flex flex-1 flex-col overflow-hidden">
{renderTabContent(activePanel)}
{sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>}
Expand Down
167 changes: 98 additions & 69 deletions packages/viewer/src/components/viewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { type AnyNodeId, StairOpeningSystem } from '@pascal-app/core'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react'
import * as THREE from 'three/webgpu'
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
import { applyIsolation, clearIsolation } from '../../lib/isolation'
Expand Down Expand Up @@ -119,6 +119,32 @@ function ToneMappingExposure() {
return null
}

function CanvasResizeInvalidator() {
const gl = useThree((state) => state.gl)
const invalidate = useThree((state) => state.invalidate)
const setSize = useThree((state) => state.setSize)

useLayoutEffect(() => {
const target = gl.domElement.parentElement
if (!target || typeof ResizeObserver === 'undefined') return

const applySize = () => {
const rect = target.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return
setSize(rect.width, rect.height)
invalidate(3)
}

const observer = new ResizeObserver(applySize)
observer.observe(target)
applySize()

return () => observer.disconnect()
}, [gl, invalidate, setSize])

return null
}

interface ViewerProps {
children?: React.ReactNode
hoverStyles?: HoverStyles
Expand Down Expand Up @@ -227,86 +253,89 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
const maxDpr =
typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches ? 1.25 : 1.5
return (
<Canvas
camera={{ position: [50, 50, 50], fov: 50 }}
className={`transition-colors duration-700 ${isDark ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
dpr={[1, maxDpr]}
frameloop="never"
gl={
((props: { canvas?: HTMLCanvasElement }) => {
const canvas = props.canvas
const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined
if (cached) return cached
const promise = (async () => {
try {
const renderer = new THREE.WebGPURenderer(props as any)
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = getSceneTheme(
useViewer.getState().sceneTheme,
).toneMappingExposure
await renderer.init()
return renderer
} catch (err) {
// Drop the failed promise from the cache so a future Canvas
// mount on the same DOM can retry instead of inheriting the
// rejection forever.
if (canvas) WEBGPU_RENDERER_CACHE.delete(canvas)
console.error('[viewer] WebGPURenderer init failed', err)
throw err
}
})()
if (canvas) WEBGPU_RENDERER_CACHE.set(canvas, promise)
return promise
}) as any
}
resize={{
debounce: 100,
}}
shadows={{
type: THREE.PCFShadowMap,
enabled: true,
}}
>
<FrameLimiter fps={50} />
<ViewerCamera />
<GPUDeviceWatcher />
<ToneMappingExposure />

<ErrorBoundary fallback={null} scope="viewer-scene">
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
<div className="relative h-full w-full overflow-hidden">
<Canvas
camera={{ position: [50, 50, 50], fov: 50 }}
className={`absolute inset-0 h-full w-full transition-colors duration-700 ${
isDark ? 'bg-[#1f2433]' : 'bg-[#fafafa]'
}`}
dpr={[1, maxDpr]}
frameloop="never"
gl={
((props: { canvas?: HTMLCanvasElement }) => {
const canvas = props.canvas
const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined
if (cached) return cached
const promise = (async () => {
try {
const renderer = new THREE.WebGPURenderer(props as any)
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = getSceneTheme(
useViewer.getState().sceneTheme,
).toneMappingExposure
await renderer.init()
return renderer
} catch (err) {
// Drop the failed promise from the cache so a future Canvas
// mount on the same DOM can retry instead of inheriting the
// rejection forever.
if (canvas) WEBGPU_RENDERER_CACHE.delete(canvas)
console.error('[viewer] WebGPURenderer init failed', err)
throw err
}
})()
if (canvas) WEBGPU_RENDERER_CACHE.set(canvas, promise)
return promise
}) as any
}
resize={{ debounce: 0 }}
shadows={{
type: THREE.PCFShadowMap,
enabled: true,
}}
>
<CanvasResizeInvalidator />
<FrameLimiter fps={50} />
<ViewerCamera />
<GPUDeviceWatcher />
<ToneMappingExposure />

<ErrorBoundary fallback={null} scope="viewer-scene">
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
/> */}
<Lights />
{useBvh ? (
<SceneBvh>
<Lights />
{useBvh ? (
<SceneBvh>
<SceneRenderer />
</SceneBvh>
) : (
<SceneRenderer />
</SceneBvh>
) : (
<SceneRenderer />
)}
)}

{/* Generic slab-elevation lift for any kind that declares
{/* Generic slab-elevation lift for any kind that declares
`capabilities.floorPlaced`. Runs at frame priority 1 so it
lands its mesh.position.y override before the priority-2
systems below clear the dirty mark. */}
<FloorElevationSystem />
{/* Generic geometry rebuild loop for any registered kind that
<FloorElevationSystem />
{/* Generic geometry rebuild loop for any registered kind that
ships `def.geometry`. Reads dirtyNodes, calls the kind's pure
builder, swaps the registered group's children. See
wiki/architecture/node-definitions.md. */}
<GeometrySystem />
{/* Automated stair opening sync — updates slab/ceiling cutouts
<GeometrySystem />
{/* Automated stair opening sync — updates slab/ceiling cutouts
whenever stairs, slabs, or levels change. */}
<StairOpeningSystem />
{/* Mounts systems contributed by registry-backed kinds. Each
<StairOpeningSystem />
{/* Mounts systems contributed by registry-backed kinds. Each
kind's `def.system` is loaded via lazy() and rendered here,
ordered by `system.priority`. */}
<RegisteredSystems />
<PostProcessing hoverStyles={hoverStyles} />
{selectionManager === 'default' && <SelectionManager />}
{(perf || PERF_OVERLAY_ENABLED) && <PerfMonitor />}
{children}
</ErrorBoundary>
</Canvas>
<RegisteredSystems />
<PostProcessing hoverStyles={hoverStyles} />
{selectionManager === 'default' && <SelectionManager />}
{(perf || PERF_OVERLAY_ENABLED) && <PerfMonitor />}
{children}
</ErrorBoundary>
</Canvas>
</div>
)
})

Expand Down
5 changes: 5 additions & 0 deletions packages/viewer/src/components/viewer/post-processing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,11 @@ const PostProcessingPasses = ({
if ((renderer as any).setClearAlpha) {
;(renderer as any).setClearAlpha(1)
}
if ((renderer as any).setClearColor) {
;(renderer as any).setClearColor(
getSceneTheme(useViewer.getState().sceneTheme).background,
)
}
const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0
;(renderer as any).render(scene, camera)
if (PERF_OVERLAY_ENABLED) {
Expand Down