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:
co-authored by
Claude Opus 4.8
parent
86e9b3c8bf
commit
69aa272004
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Html } from '@react-three/drei'
|
import { Html } from '@react-three/drei'
|
||||||
import { memo, useEffect, useMemo } from 'react'
|
import { memo, useEffect, useLayoutEffect, useMemo } from 'react'
|
||||||
import { BufferGeometry, Line as ThreeLine, Vector3 } from 'three'
|
import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { EDITOR_LAYER } from '../../lib/constants'
|
import { EDITOR_LAYER } from '../../lib/constants'
|
||||||
import useOpeningGuides, {
|
import useOpeningGuides, {
|
||||||
@@ -56,8 +56,8 @@ export const OpeningGuides3DLayer = memo(function OpeningGuides3DLayer() {
|
|||||||
if (guides.length === 0) return null
|
if (guides.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{guides.map((guide, i) => (
|
{guides.map((guide) => (
|
||||||
<OpeningGuide guide={guide} key={i} unit={unit} />
|
<OpeningGuide guide={guide} key={guide.id} unit={unit} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -114,17 +114,31 @@ function GuideSegment({
|
|||||||
to: OpeningGuideVec3
|
to: OpeningGuideVec3
|
||||||
material: LineBasicNodeMaterial
|
material: LineBasicNodeMaterial
|
||||||
}) {
|
}) {
|
||||||
// Build a concrete THREE.Line and mount it via <primitive>: the intrinsic
|
// Build the THREE.Line once with a preallocated 2-point position buffer and
|
||||||
// <line> JSX element collides with React's SVG <line>, so <primitive> keeps
|
// mount it via <primitive> (the intrinsic <line> JSX element collides with
|
||||||
// the typing clean and gives us direct control of layers + renderOrder.
|
// React's SVG <line>). `material` is a module-level constant, so this memo
|
||||||
const line = useMemo(() => {
|
// runs exactly once per mounted slot; subsequent drag ticks mutate the
|
||||||
const geometry = new BufferGeometry().setFromPoints([new Vector3(...from), new Vector3(...to)])
|
// existing buffer in place via the layout effect below rather than rebuilding
|
||||||
const object = new ThreeLine(geometry, material)
|
// the geometry, line, and GPU buffer every frame.
|
||||||
object.frustumCulled = false
|
const { line, position } = useMemo(() => {
|
||||||
object.layers.set(EDITOR_LAYER)
|
const position = new Float32BufferAttribute(new Float32Array(6), 3)
|
||||||
object.renderOrder = 1000
|
const geometry = new BufferGeometry()
|
||||||
return object
|
geometry.setAttribute('position', position)
|
||||||
}, [from, to, material])
|
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])
|
useEffect(() => () => line.geometry.dispose(), [line])
|
||||||
return <primitive object={line} />
|
return <primitive object={line} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,19 @@ import { create } from 'zustand'
|
|||||||
|
|
||||||
export type OpeningGuideVec3 = [number, number, number]
|
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 =
|
export type OpeningGuide3D =
|
||||||
// A measured line + distance pill: sill (floor → bottom edge), head (top edge
|
// A measured line + distance pill: sill (floor → bottom edge), head (top edge
|
||||||
// → wall top), or along-wall edge-to-edge proximity.
|
// → 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.
|
// 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.
|
// 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 = {
|
type OpeningGuidesState = {
|
||||||
guides: OpeningGuide3D[]
|
guides: OpeningGuide3D[]
|
||||||
@@ -27,7 +32,10 @@ type OpeningGuidesState = {
|
|||||||
const useOpeningGuides = create<OpeningGuidesState>((set) => ({
|
const useOpeningGuides = create<OpeningGuidesState>((set) => ({
|
||||||
guides: [],
|
guides: [],
|
||||||
set: (guides) => 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
|
export default useOpeningGuides
|
||||||
|
|||||||
@@ -644,6 +644,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
boxGeo.dispose()
|
boxGeo.dispose()
|
||||||
return geo
|
return geo
|
||||||
}, [movingDoorNode])
|
}, [movingDoorNode])
|
||||||
|
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={cursorGroupRef} visible={false}>
|
<group ref={cursorGroupRef} visible={false}>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
import {
|
import {
|
||||||
@@ -585,10 +585,16 @@ const DoorTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Cursor geometry: door outline.
|
// Cursor geometry: door outline. Static dims, so build it once and dispose on
|
||||||
|
// unmount rather than reallocating (and orphaning) an EdgesGeometry on every
|
||||||
|
// re-render during placement.
|
||||||
|
const edgesGeo = useMemo(() => {
|
||||||
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
|
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
|
||||||
const edgesGeo = new EdgesGeometry(boxGeo)
|
const geo = new EdgesGeometry(boxGeo)
|
||||||
boxGeo.dispose()
|
boxGeo.dispose()
|
||||||
|
return geo
|
||||||
|
}, [])
|
||||||
|
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={cursorGroupRef} visible={false}>
|
<group ref={cursorGroupRef} visible={false}>
|
||||||
|
|||||||
@@ -105,10 +105,14 @@ export function publishOpeningGuides3D(args: {
|
|||||||
|
|
||||||
const out: OpeningGuide3D[] = []
|
const out: OpeningGuide3D[] = []
|
||||||
|
|
||||||
|
// Stable `id`s keyed on the guide's semantic role (not list position) so the
|
||||||
|
// 3D layer can keep a persisting slot's element + `<Html>` pill mounted as the
|
||||||
|
// set churns each tick — see `OpeningGuide3D`.
|
||||||
if (guides.sillHead) {
|
if (guides.sillHead) {
|
||||||
if (guides.sillHead.sill > MIN_DIMENSION_M) {
|
if (guides.sillHead.sill > MIN_DIMENSION_M) {
|
||||||
out.push({
|
out.push({
|
||||||
kind: 'dimension',
|
kind: 'dimension',
|
||||||
|
id: 'sill',
|
||||||
from: toWorld(centerS, 0),
|
from: toWorld(centerS, 0),
|
||||||
to: toWorld(centerS, guides.sillHead.bottomY),
|
to: toWorld(centerS, guides.sillHead.bottomY),
|
||||||
value: guides.sillHead.sill,
|
value: guides.sillHead.sill,
|
||||||
@@ -117,6 +121,7 @@ export function publishOpeningGuides3D(args: {
|
|||||||
if (guides.sillHead.head > MIN_DIMENSION_M) {
|
if (guides.sillHead.head > MIN_DIMENSION_M) {
|
||||||
out.push({
|
out.push({
|
||||||
kind: 'dimension',
|
kind: 'dimension',
|
||||||
|
id: 'head',
|
||||||
from: toWorld(centerS, guides.sillHead.topY),
|
from: toWorld(centerS, guides.sillHead.topY),
|
||||||
to: toWorld(centerS, wallHeight),
|
to: toWorld(centerS, wallHeight),
|
||||||
value: guides.sillHead.head,
|
value: guides.sillHead.head,
|
||||||
@@ -127,6 +132,7 @@ export function publishOpeningGuides3D(args: {
|
|||||||
for (const gap of guides.gaps) {
|
for (const gap of guides.gaps) {
|
||||||
out.push({
|
out.push({
|
||||||
kind: 'dimension',
|
kind: 'dimension',
|
||||||
|
id: `gap:${gap.side}`,
|
||||||
from: toWorld(gap.fromS, centerY),
|
from: toWorld(gap.fromS, centerY),
|
||||||
to: toWorld(gap.toS, centerY),
|
to: toWorld(gap.toS, centerY),
|
||||||
value: gap.distance,
|
value: gap.distance,
|
||||||
@@ -140,6 +146,7 @@ export function publishOpeningGuides3D(args: {
|
|||||||
const hi = Math.max(centerS + width / 2, target.centerS + target.width / 2)
|
const hi = Math.max(centerS + width / 2, target.centerS + target.width / 2)
|
||||||
out.push({
|
out.push({
|
||||||
kind: 'align-line',
|
kind: 'align-line',
|
||||||
|
id: 'vertical',
|
||||||
from: toWorld(lo, guides.vertical.y),
|
from: toWorld(lo, guides.vertical.y),
|
||||||
to: toWorld(hi, guides.vertical.y),
|
to: toWorld(hi, guides.vertical.y),
|
||||||
})
|
})
|
||||||
@@ -147,13 +154,15 @@ export function publishOpeningGuides3D(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (guides.equalSpacing) {
|
if (guides.equalSpacing) {
|
||||||
for (const seg of guides.equalSpacing.segments) {
|
const { gap, segments } = guides.equalSpacing
|
||||||
|
segments.forEach((seg, i) => {
|
||||||
out.push({
|
out.push({
|
||||||
kind: 'badge',
|
kind: 'badge',
|
||||||
|
id: `spacing:${i}`,
|
||||||
at: toWorld((seg.fromS + seg.toS) / 2, centerY),
|
at: toWorld((seg.fromS + seg.toS) / 2, centerY),
|
||||||
value: guides.equalSpacing.gap,
|
value: gap,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useOpeningGuides.getState().set(out)
|
useOpeningGuides.getState().set(out)
|
||||||
|
|||||||
@@ -702,6 +702,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
boxGeo.dispose()
|
boxGeo.dispose()
|
||||||
return geo
|
return geo
|
||||||
}, [movingWindowNode])
|
}, [movingWindowNode])
|
||||||
|
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={cursorGroupRef} visible={false}>
|
<group ref={cursorGroupRef} visible={false}>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
useAlignmentGuides,
|
useAlignmentGuides,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
import {
|
import {
|
||||||
@@ -631,10 +631,16 @@ const WindowTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Cursor geometry: window outline rectangle.
|
// Cursor geometry: window outline rectangle. Static dims, so build it once and
|
||||||
|
// dispose on unmount rather than reallocating (and orphaning) an EdgesGeometry
|
||||||
|
// on every re-render during placement.
|
||||||
|
const edgesGeo = useMemo(() => {
|
||||||
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
|
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
|
||||||
const edgesGeo = new EdgesGeometry(boxGeo)
|
const geo = new EdgesGeometry(boxGeo)
|
||||||
boxGeo.dispose()
|
boxGeo.dispose()
|
||||||
|
return geo
|
||||||
|
}, [])
|
||||||
|
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={cursorGroupRef} visible={false}>
|
<group ref={cursorGroupRef} visible={false}>
|
||||||
|
|||||||
Reference in New Issue
Block a user