perf(editor): stabilize 3D opening-guide rendering, remove per-tick GPU churn

Reuse one THREE.Line + preallocated position buffer per guide slot, mutating
endpoints in place each drag tick instead of rebuilding the geometry, line, and
two Vector3s and re-uploading the GPU buffer every frame. Key guides by a stable
semantic id (sill / head / gap:side / vertical / spacing:i) so a slot that
persists keeps its React element and drei <Html> pill mounted as the guide set
churns, rather than remounting under shifting index keys.

Also: make useOpeningGuides.clear() a no-op when already empty so the common
no-guide hover frame doesn't push a fresh [] and re-render to the same nothing;
dispose the move-tool cursor EdgesGeometry on unmount; and memoize the
placement-tool cursor EdgesGeometry (static fallback dims) so it isn't
reallocated and orphaned on every render during placement.

Reviewed by Codex (peer + adversarial): no correctness, hook-order, or GPU-leak
regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-15 12:36:06 -04:00
co-authored by Claude Opus 4.8
parent 86e9b3c8bf
commit 69aa272004
7 changed files with 77 additions and 32 deletions
@@ -2,8 +2,8 @@
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { memo, useEffect, useMemo } from 'react'
import { BufferGeometry, Line as ThreeLine, Vector3 } from 'three'
import { memo, useEffect, useLayoutEffect, useMemo } from 'react'
import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
import useOpeningGuides, {
@@ -56,8 +56,8 @@ export const OpeningGuides3DLayer = memo(function OpeningGuides3DLayer() {
if (guides.length === 0) return null
return (
<>
{guides.map((guide, i) => (
<OpeningGuide guide={guide} key={i} unit={unit} />
{guides.map((guide) => (
<OpeningGuide guide={guide} key={guide.id} unit={unit} />
))}
</>
)
@@ -114,17 +114,31 @@ function GuideSegment({
to: OpeningGuideVec3
material: LineBasicNodeMaterial
}) {
// Build a concrete THREE.Line and mount it via <primitive>: the intrinsic
// <line> JSX element collides with React's SVG <line>, so <primitive> keeps
// the typing clean and gives us direct control of layers + renderOrder.
const line = useMemo(() => {
const geometry = new BufferGeometry().setFromPoints([new Vector3(...from), new Vector3(...to)])
const object = new ThreeLine(geometry, material)
object.frustumCulled = false
object.layers.set(EDITOR_LAYER)
object.renderOrder = 1000
return object
}, [from, to, material])
// Build the THREE.Line once with a preallocated 2-point position buffer and
// mount it via <primitive> (the intrinsic <line> JSX element collides with
// React's SVG <line>). `material` is a module-level constant, so this memo
// runs exactly once per mounted slot; subsequent drag ticks mutate the
// existing buffer in place via the layout effect below rather than rebuilding
// the geometry, line, and GPU buffer every frame.
const { line, position } = useMemo(() => {
const position = new Float32BufferAttribute(new Float32Array(6), 3)
const geometry = new BufferGeometry()
geometry.setAttribute('position', position)
const line = new ThreeLine(geometry, material)
line.frustumCulled = false
line.layers.set(EDITOR_LAYER)
line.renderOrder = 1000
return { line, position }
}, [material])
const [fx, fy, fz] = from
const [tx, ty, tz] = to
useLayoutEffect(() => {
position.setXYZ(0, fx, fy, fz)
position.setXYZ(1, tx, ty, tz)
position.needsUpdate = true
}, [position, fx, fy, fz, tx, ty, tz])
useEffect(() => () => line.geometry.dispose(), [line])
return <primitive object={line} />
}
@@ -9,14 +9,19 @@ import { create } from 'zustand'
export type OpeningGuideVec3 = [number, number, number]
// A stable identity per guide slot (`sill`, `head`, `gap:left`, `vertical`,
// `spacing:0`, …) so the renderer can key by semantic role: as the guide set
// churns each drag tick, a slot that persists keeps its React element — and its
// drei `<Html>` portal — mounted instead of remounting when the list shape
// shifts under index keys.
export type OpeningGuide3D =
// A measured line + distance pill: sill (floor → bottom edge), head (top edge
// → wall top), or along-wall edge-to-edge proximity.
| { kind: 'dimension'; from: OpeningGuideVec3; to: OpeningGuideVec3; value: number }
| { kind: 'dimension'; id: string; from: OpeningGuideVec3; to: OpeningGuideVec3; value: number }
// A dashed line connecting two openings that share a sill / centre / top.
| { kind: 'align-line'; from: OpeningGuideVec3; to: OpeningGuideVec3 }
| { kind: 'align-line'; id: string; from: OpeningGuideVec3; to: OpeningGuideVec3 }
// A Figma-style "=" badge marking one gap in an equal-spacing run.
| { kind: 'badge'; at: OpeningGuideVec3; value: number }
| { kind: 'badge'; id: string; at: OpeningGuideVec3; value: number }
type OpeningGuidesState = {
guides: OpeningGuide3D[]
@@ -27,7 +32,10 @@ type OpeningGuidesState = {
const useOpeningGuides = create<OpeningGuidesState>((set) => ({
guides: [],
set: (guides) => set({ guides }),
clear: () => set({ guides: [] }),
// No-op when already empty so the common no-guide hover frame (fallback
// cursor, invalid target, roof hover) doesn't push a fresh `[]` and notify
// subscribers — the layer would re-render to the same nothing every tick.
clear: () => set((s) => (s.guides.length > 0 ? { guides: [] } : s)),
}))
export default useOpeningGuides