feat: curved wall and fixes (#236)

* feat: add polygon movement support to editor and implement dedicated move tools for walls, slabs, and ceilings

* feat: add uv2 attribute to ceiling and slab geometries for lightmap support

* Fix curved wall miters and align roof position sliders

* Fix wall measurement label and extension anchors

* Fix WebGPU renderer initialization order

* fix(editor): scope wall movement to the selected level

---------

Co-authored-by: Pascal <open@pascal.app>
This commit is contained in:
Sudhir Yadav
2026-04-15 12:41:16 -04:00
committed by GitHub
co-authored by Pascal
parent 57df224948
commit b1709de44a
37 changed files with 2307 additions and 188 deletions
@@ -1,5 +1,6 @@
import { type SlabNode, useRegistry } from '@pascal-app/core'
import { useMemo, useRef } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import type { Mesh } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
import {
@@ -17,10 +18,20 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
const material = useMemo(() => {
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
if (presetMaterial) return presetMaterial
const mat = node.material
if (!mat) return DEFAULT_SLAB_MATERIAL
return createMaterial(mat)
const sourceMaterial = presetMaterial ?? (node.material ? createMaterial(node.material) : DEFAULT_SLAB_MATERIAL)
const slabMaterial = sourceMaterial.clone()
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
// pipeline variants that can fail when geometry is regenerated while a
// transparent/custom material is attached.
slabMaterial.transparent = false
slabMaterial.opacity = 1
slabMaterial.alphaMap = null
slabMaterial.side = THREE.DoubleSide
slabMaterial.depthWrite = true
slabMaterial.needsUpdate = true
return slabMaterial
}, [
node.material,
node.material?.preset,
@@ -29,6 +40,12 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
node.materialPreset,
])
useEffect(() => {
return () => {
material.dispose()
}
}, [material])
return (
<mesh
castShadow
@@ -106,11 +106,11 @@ const Viewer: React.FC<ViewerProps> = ({
camera={{ position: [50, 50, 50], fov: 50 }}
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
dpr={[1, 1.5]}
gl={(props) => {
gl={async (props) => {
const renderer = new THREE.WebGPURenderer(props as any)
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 0.9
// renderer.init() // Only use when using <DebugRenderer />
await renderer.init()
return renderer
}}
resize={{
@@ -53,7 +53,6 @@ const PostProcessingPasses = () => {
const hasPipelineErrorRef = useRef(false)
const retryCountRef = useRef(0)
const rebuildTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [isInitialized, setIsInitialized] = useState(false)
// Background color uniform — updated every frame via lerp, read by the TSL pipeline.
// Initialised from the current theme so there's no flash on first render.
@@ -85,35 +84,6 @@ const PostProcessingPasses = () => {
setPipelineVersion((v) => v + 1)
}, [])
// Renderer initialization
useEffect(() => {
let mounted = true
const initRenderer = async () => {
try {
if (renderer && (renderer as any).init) {
await (renderer as any).init()
}
if (mounted) {
setIsInitialized(true)
}
} catch (error) {
console.error('[viewer] Failed to initialize renderer for post-processing.', error)
if (mounted) {
setIsInitialized(false)
}
}
}
initRenderer()
return () => {
mounted = false
}
}, [renderer])
// Reset retry state when project changes
useEffect(() => {
// Intentionally touch projectId so the effect reruns on project switches.
@@ -141,7 +111,7 @@ const PostProcessingPasses = () => {
void projectId
void pipelineVersion
if (!(renderer && scene && camera && isInitialized)) {
if (!(renderer && scene && camera)) {
return
}
@@ -298,7 +268,6 @@ const PostProcessingPasses = () => {
scene,
camera,
hoverHighlightMode,
isInitialized,
zoneLayers,
projectId,
pipelineVersion,
@@ -310,10 +279,6 @@ const PostProcessingPasses = () => {
bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4)
bgUniform.current.value.copy(bgCurrent.current)
if (!isInitialized) {
return
}
if (hasPipelineErrorRef.current || !renderPipelineRef.current) {
try {
if ((renderer as any).setClearAlpha) {