diff --git a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx
index fb7299f6..8b39ef50 100644
--- a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx
+++ b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx
@@ -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) => (
-
+ {guides.map((guide) => (
+
))}
>
)
@@ -114,17 +114,31 @@ function GuideSegment({
to: OpeningGuideVec3
material: LineBasicNodeMaterial
}) {
- // Build a concrete THREE.Line and mount it via : the intrinsic
- // JSX element collides with React's SVG , so 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 (the intrinsic JSX element collides with
+ // React's SVG ). `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
}
diff --git a/packages/editor/src/store/use-opening-guides.ts b/packages/editor/src/store/use-opening-guides.ts
index ca2cc71a..6b9cf2c8 100644
--- a/packages/editor/src/store/use-opening-guides.ts
+++ b/packages/editor/src/store/use-opening-guides.ts
@@ -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 `` 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((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
diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx
index d3799bab..ec6e37de 100644
--- a/packages/nodes/src/door/move-tool.tsx
+++ b/packages/nodes/src/door/move-tool.tsx
@@ -644,6 +644,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
boxGeo.dispose()
return geo
}, [movingDoorNode])
+ useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return (
diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx
index 78dda6e7..d93971cd 100644
--- a/packages/nodes/src/door/tool.tsx
+++ b/packages/nodes/src/door/tool.tsx
@@ -22,7 +22,7 @@ import {
useAlignmentGuides,
} from '@pascal-app/editor'
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 { LineBasicNodeMaterial } from 'three/webgpu'
import {
@@ -585,10 +585,16 @@ const DoorTool: React.FC = () => {
}
}, [])
- // Cursor geometry: door outline.
- const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
- const edgesGeo = new EdgesGeometry(boxGeo)
- boxGeo.dispose()
+ // 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 geo = new EdgesGeometry(boxGeo)
+ boxGeo.dispose()
+ return geo
+ }, [])
+ useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return (
diff --git a/packages/nodes/src/shared/opening-guides-runtime.ts b/packages/nodes/src/shared/opening-guides-runtime.ts
index dcc22b49..1729e923 100644
--- a/packages/nodes/src/shared/opening-guides-runtime.ts
+++ b/packages/nodes/src/shared/opening-guides-runtime.ts
@@ -105,10 +105,14 @@ export function publishOpeningGuides3D(args: {
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 + `` pill mounted as the
+ // set churns each tick — see `OpeningGuide3D`.
if (guides.sillHead) {
if (guides.sillHead.sill > MIN_DIMENSION_M) {
out.push({
kind: 'dimension',
+ id: 'sill',
from: toWorld(centerS, 0),
to: toWorld(centerS, guides.sillHead.bottomY),
value: guides.sillHead.sill,
@@ -117,6 +121,7 @@ export function publishOpeningGuides3D(args: {
if (guides.sillHead.head > MIN_DIMENSION_M) {
out.push({
kind: 'dimension',
+ id: 'head',
from: toWorld(centerS, guides.sillHead.topY),
to: toWorld(centerS, wallHeight),
value: guides.sillHead.head,
@@ -127,6 +132,7 @@ export function publishOpeningGuides3D(args: {
for (const gap of guides.gaps) {
out.push({
kind: 'dimension',
+ id: `gap:${gap.side}`,
from: toWorld(gap.fromS, centerY),
to: toWorld(gap.toS, centerY),
value: gap.distance,
@@ -140,6 +146,7 @@ export function publishOpeningGuides3D(args: {
const hi = Math.max(centerS + width / 2, target.centerS + target.width / 2)
out.push({
kind: 'align-line',
+ id: 'vertical',
from: toWorld(lo, guides.vertical.y),
to: toWorld(hi, guides.vertical.y),
})
@@ -147,13 +154,15 @@ export function publishOpeningGuides3D(args: {
}
if (guides.equalSpacing) {
- for (const seg of guides.equalSpacing.segments) {
+ const { gap, segments } = guides.equalSpacing
+ segments.forEach((seg, i) => {
out.push({
kind: 'badge',
+ id: `spacing:${i}`,
at: toWorld((seg.fromS + seg.toS) / 2, centerY),
- value: guides.equalSpacing.gap,
+ value: gap,
})
- }
+ })
}
useOpeningGuides.getState().set(out)
diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx
index 6f64a6de..2793e57b 100644
--- a/packages/nodes/src/window/move-tool.tsx
+++ b/packages/nodes/src/window/move-tool.tsx
@@ -702,6 +702,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
boxGeo.dispose()
return geo
}, [movingWindowNode])
+ useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return (
diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx
index 2e92c0f6..ccbce2fd 100644
--- a/packages/nodes/src/window/tool.tsx
+++ b/packages/nodes/src/window/tool.tsx
@@ -23,7 +23,7 @@ import {
useAlignmentGuides,
} from '@pascal-app/editor'
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 { LineBasicNodeMaterial } from 'three/webgpu'
import {
@@ -631,10 +631,16 @@ const WindowTool: React.FC = () => {
}
}, [])
- // Cursor geometry: window outline rectangle.
- const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
- const edgesGeo = new EdgesGeometry(boxGeo)
- boxGeo.dispose()
+ // 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 geo = new EdgesGeometry(boxGeo)
+ boxGeo.dispose()
+ return geo
+ }, [])
+ useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return (