From e741ba677ab6e6099b28522b25de7a0bfab78915 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 2 Jun 2026 23:59:08 -0400 Subject: [PATCH] fix(editor): outline polygon edit handles, fix vertex grab + console error (#365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundles three fixes to the shared PolygonEditor (slab/ceiling boundary & hole editing): - Console error: defer the cross-store onPolygonPreview(null) write into a useEffect so committing a drag no longer updates another component (NodeArrowHandles) during PolygonEditor's render. - Vertex grab: make the edge bar visual-only (raycast disabled) so it can no longer steal clicks from the vertex/midpoint handles overlapping it; edge dragging runs through the chevron arrow outside the polygon edge. - Outlines: render each handle as a single SCENE_LAYER mesh with a node material (MeshBasicNodeMaterial / useArrowMaterial) and pointer handlers attached directly, matching the registry arrow gizmos. This puts the handles in the ink-edge post-pass so vertex/midpoint cylinders, the chevron arrows, and the move sphere read as outlined 3D plates while staying grabbable. No paired hit mesh needed — the R3F event raycaster picks SCENE_LAYER too. Known follow-up (tracked, out of scope): SCENE_LAYER handles can appear in user-driven capture-mode snapshots, same as the existing arrow gizmos; to be hidden uniformly later via the thumbnail:before-capture toggle. Co-authored-by: Claude Opus 4.8 (1M context) --- .../tools/shared/polygon-editor.tsx | 290 +++++++++++++----- 1 file changed, 213 insertions(+), 77 deletions(-) diff --git a/packages/editor/src/components/tools/shared/polygon-editor.tsx b/packages/editor/src/components/tools/shared/polygon-editor.tsx index 80a10b75..4f14283e 100644 --- a/packages/editor/src/components/tools/shared/polygon-editor.tsx +++ b/packages/editor/src/components/tools/shared/polygon-editor.tsx @@ -1,16 +1,27 @@ import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' -import { createPortal } from '@react-three/fiber' +import { SCENE_LAYER } from '@pascal-app/viewer' +import { createPortal, type ThreeEvent } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, + Color, + CylinderGeometry, ExtrudeGeometry, Float32BufferAttribute, type Line, type Object3D, Shape, + SphereGeometry, } from 'three' +import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' +import { + ARROW_COLOR as EDGE_ARROW_COLOR, + ARROW_HOVER_COLOR as EDGE_ARROW_HOVER_COLOR, + ARROW_SCALE as EDGE_ARROW_SCALE, + useArrowMaterial, +} from '../../editor/node-arrow-handles' import { snapToHalf } from '../item/placement-math' const Y_OFFSET = 0.02 @@ -19,11 +30,14 @@ const Y_OFFSET = 0.02 // midpoint, pointing along the edge's outward normal — dragging an arrow // translates that edge only (its two vertices), leaving the opposite side // fixed. Reuses the existing 'edge' drag mode in PolygonEditor. -const EDGE_ARROW_COLOR = '#8381ed' -const EDGE_ARROW_HOVER_COLOR = '#a5b4fc' -const EDGE_ARROW_SCALE = 0.65 const EDGE_ARROW_OFFSET = 0.34 +// Disables R3F pointer-picking on a mesh. Used on the visual-only meshes — +// the edge bar and the border line — so they render without stealing pointer +// events that belong to the vertex/midpoint handles overlapping them. Mirrors +// the `NO_RAYCAST` sentinel in node-arrow-handles.tsx. +const NO_RAYCAST = () => null + function createEdgeArrowGeometry() { const shape = new Shape() shape.moveTo(0.22, 0) @@ -102,6 +116,135 @@ function getEdgeNormal(start: [number, number], end: [number, number]): [number, return [-dz / length, dx / length] } +type HandleClickHandler = (event: ThreeEvent) => void +type HandlePointerHandler = (event: ThreeEvent) => void + +type HandleHandlers = { + onClick?: HandleClickHandler + onDoubleClick?: HandleClickHandler + onPointerDown?: HandlePointerHandler + onPointerEnter?: HandlePointerHandler + onPointerLeave?: HandlePointerHandler +} + +function usePolygonNodeMaterial(color: string, opacity = 1): MeshBasicNodeMaterial { + const material = useMemo( + () => + new MeshBasicNodeMaterial({ + color: new Color(color), + depthTest: false, + depthWrite: true, + opacity, + transparent: true, + }), + [], + ) + + useEffect(() => { + material.color.set(color) + material.opacity = opacity + }, [color, material, opacity]) + useEffect(() => () => material.dispose(), [material]) + + return material +} + +// One mesh per handle: lives on SCENE_LAYER with a node material so the +// post-processing ink-edge pass outlines it, and carries the pointer handlers +// directly so it stays grabbable — matching the registry arrow gizmos in +// node-arrow-handles.tsx. No paired hit mesh is needed; the R3F event +// raycaster picks SCENE_LAYER meshes too. +function OutlinedCylinderHandle({ + radius, + height, + color, + opacity = 1, + position, + ...handlers +}: { + radius: number + height: number + color: string + opacity?: number + position: [number, number, number] +} & HandleHandlers) { + const geometry = useMemo(() => new CylinderGeometry(radius, radius, height, 16), [height, radius]) + const material = usePolygonNodeMaterial(color, opacity) + useEffect(() => () => geometry.dispose(), [geometry]) + + return ( + + ) +} + +function OutlinedSphereHandle({ + color, + position, + ...handlers +}: { + color: string + position: [number, number, number] +} & HandleHandlers) { + const geometry = useMemo(() => new SphereGeometry(0.09, 20, 20), []) + const material = usePolygonNodeMaterial(color) + useEffect(() => () => geometry.dispose(), [geometry]) + + return ( + + ) +} + +function OutlinedEdgeArrowHandle({ + geometry, + color, + position, + rotationY, + scale, + ...handlers +}: { + geometry: BufferGeometry + color: string + position: [number, number, number] + rotationY: number + scale: number +} & HandleHandlers) { + const material = useArrowMaterial() + useEffect(() => { + material.color.set(color) + }, [color, material]) + useEffect(() => () => material.dispose(), [material]) + + return ( + + ) +} + export const PolygonEditor: React.FC = ({ polygon, color = '#3b82f6', @@ -185,15 +328,41 @@ export const PolygonEditor: React.FC = ({ const lineRef = useRef(null!) const previousPositionRef = useRef<[number, number] | null>(null) - // Track the last polygon prop to detect external changes (undo/redo) + // Track the last polygon prop to detect external changes (undo/redo) or + // our own post-commit prop update arriving while a preview is still in + // flight. Either way, drop the stale preview/drag. + // + // This block runs during render, so it may only touch THIS component's + // own state (setPreviewPolygon / setDragState — React re-renders in + // place, which is the sanctioned "adjust state on prop change" pattern). + // The host `onPolygonPreview(null)` notification clears + // `useLiveNodeOverrides` — a store OTHER components subscribe to (e.g. + // NodeArrowHandles) — so calling it here throws "Cannot update a + // component while rendering a different component". Defer it to the + // effect below. const lastPolygonRef = useRef(polygon) + const pendingPreviewClearRef = useRef(false) if (polygon !== lastPolygonRef.current) { lastPolygonRef.current = polygon - // External change (e.g. undo/redo) — clear any stale preview/drag state - if (previewPolygon) updatePreviewPolygon(null) + // `previewPolygonRef` is the synchronously-updated source of truth for + // an in-flight preview (state can lag it by a render). + if (previewPolygonRef.current !== null || previewPolygon !== null) { + previewPolygonRef.current = null + setPreviewPolygon(null) + pendingPreviewClearRef.current = true + } if (dragState) setDragState(null) } + // Flush the deferred host preview-clear (see note above) after the + // commit, where writing to other stores is allowed. + useEffect(() => { + if (pendingPreviewClearRef.current) { + pendingPreviewClearRef.current = false + onPolygonPreviewRef.current?.(null) + } + }) + // The polygon to display (preview during drag, or actual polygon) const displayPolygon = previewPolygon ?? polygon @@ -445,13 +614,19 @@ export const PolygonEditor: React.FC = ({ const handleHeight = Math.max(MIN_HANDLE_HEIGHT, surfaceHeight + 0.02) const edgeHandleY = editY + handleHeight - EDGE_HANDLE_HEIGHT / 2 + // Interactive handles are single SCENE_LAYER node-material meshes (like the + // registry arrow gizmos) so the ink-edge pass outlines them while they stay + // grabbable. The edge BAR and border line stay on EDITOR_LAYER, visual-only + // (raycast disabled) so they never steal clicks from the vertex/midpoint + // handles overlapping them — edge dragging starts from the chevron arrow + // outside the polygon edge. const editorContent = ( {/* Border line */} {}} + raycast={NO_RAYCAST} // @ts-expect-error R3F element conflicts with SVG type ref={lineRef} renderOrder={10} @@ -475,10 +650,10 @@ export const PolygonEditor: React.FC = ({ const height = handleHeight return ( - { if (e.button !== 0) return e.stopPropagation() @@ -512,17 +687,14 @@ export const PolygonEditor: React.FC = ({ setHoveredVertex(null) }} position={[x!, editY + height / 2, z!]} - > - - - + radius={radius} + /> ) })} {allowPolygonMove && ( - { if (e.button !== 0) return e.stopPropagation() @@ -541,10 +713,7 @@ export const PolygonEditor: React.FC = ({ }) }} position={[polygonCenter[0], editY + handleHeight + 0.08, polygonCenter[1]]} - > - - - + /> )} {allowEdgeMove && @@ -577,26 +746,16 @@ export const PolygonEditor: React.FC = ({ return ( + {/* Edge bar — VISUAL ONLY (raycast disabled). It runs the full + length of the edge and overlaps the vertex/midpoint handles at + its ends + centre, so making it pickable let edges steal those + clicks. Edge dragging runs through the chevron arrow below, + which sits outside the polygon and never overlaps a + vertex/midpoint handle. */} { - if (e.button !== 0) return - e.stopPropagation() - }} - onPointerDown={(e) => { - if (e.button !== 0) return - e.stopPropagation() - beginEdgeDrag(e) - }} - onPointerEnter={(e) => { - e.stopPropagation() - setHoveredEdge(index) - }} - onPointerLeave={(e) => { - e.stopPropagation() - setHoveredEdge(null) - }} position={[midpoint[0], edgeHandleY, midpoint[1]]} + raycast={NO_RAYCAST} rotation={[0, rotationY, 0]} > @@ -606,19 +765,13 @@ export const PolygonEditor: React.FC = ({ transparent /> - {/* Per-side resize arrow — points outward from the edge. - Dragging it pulls (or pushes) only this edge's two - vertices along the outward normal; the opposite side - of the polygon stays put. - - Stays on SCENE_LAYER (no `layers={EDITOR_LAYER}`) so the - post-processing scenePass picks it up in the depth/normal - MRT and the ink-edge shader paints dark outlines on the - chevron — same treatment as the wall and registry height - arrows. The surrounding line/vertex/edge-box handles stay - on EDITOR_LAYER because they're not chevrons and reading - as flat overlays is the intended look there. */} - { if (e.button !== 0) return @@ -638,22 +791,9 @@ export const PolygonEditor: React.FC = ({ setHoveredEdge(null) }} position={[arrowX, edgeHandleY, arrowZ]} - rotation={[0, outwardAngle, 0]} + rotationY={outwardAngle} scale={EDGE_ARROW_SCALE} - > - - + /> ) })} @@ -666,9 +806,10 @@ export const PolygonEditor: React.FC = ({ const height = handleHeight return ( - { if (e.button !== 0) return e.stopPropagation() @@ -697,15 +838,10 @@ export const PolygonEditor: React.FC = ({ e.stopPropagation() setHoveredMidpoint(null) }} + opacity={isHovered ? 1 : 0.7} position={[x!, editY + height / 2, z!]} - > - - - + radius={radius} + /> ) })}