feat(editor): 3D viewport proximity + sill + equal-spacing guides for openings
Wire the opening-guides service into the 3D door/window move tools and render the
wall-plane guides as the spatial twin of the 2D plan guides:
- sill / head height (floor → bottom edge, top edge → wall top) — windows only
- edge-to-edge proximity dimensions to the nearest neighbour each side
- a sill-alignment line + SNAP when a window shares a neighbour's sill / centre
/ top (competes with the 0.5m grid, Shift bypasses) — the chosen
"snap + guide" behaviour
- Figma-style equal-spacing "=" badges across a run of openings
Adds `useOpeningGuides` (editor store) + `OpeningGuides3DLayer` (raw THREE.Line
overlays + Html pills, mounted beside Alignment3DGuideLayer) and a thin
`opening-guides-runtime` helper (collect siblings / sill snap / publish / clear)
called from the door + window move-tools at their per-tick `applyPreview` hook;
guides clear on commit / cancel / leave / roof-hover / unmount.
Guides render in the move cursor's building-local frame (reuses `wallLocalToWorld`)
so they track the dragged opening exactly. Codex-reviewed (roof-hover stale-guide
clear, collapsed-dimension suppression). Placement-time guides reuse the same
helper and are the next step.
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
3ac6b27eca
commit
6caba97f1b
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
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 { LineBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
import useOpeningGuides, {
|
||||
type OpeningGuide3D,
|
||||
type OpeningGuideVec3,
|
||||
} from '../../store/use-opening-guides'
|
||||
import { formatMeasurement } from './measurement-pill'
|
||||
|
||||
const DIMENSION_COLOR = 0x81_8c_f8 // indigo — a neutral measurement
|
||||
const ALIGN_COLOR = 0xef_44_44 // red — a snapped alignment (matches the 2D guide accent)
|
||||
const DIMENSION_PILL = '#6366f1'
|
||||
const BADGE_PILL = '#ec4899' // pink — matches the 2D equal-spacing badge
|
||||
|
||||
// Shared depth-test-off materials so the guides read on top of the wall and
|
||||
// don't rebuild GPU buffers as guides churn during a drag.
|
||||
const dimensionMaterial = new LineBasicNodeMaterial({
|
||||
color: DIMENSION_COLOR,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
transparent: true,
|
||||
})
|
||||
const alignMaterial = new LineBasicNodeMaterial({
|
||||
color: ALIGN_COLOR,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
transparent: true,
|
||||
})
|
||||
|
||||
const mid = (a: OpeningGuideVec3, b: OpeningGuideVec3): OpeningGuideVec3 => [
|
||||
(a[0] + b[0]) / 2,
|
||||
(a[1] + b[1]) / 2,
|
||||
(a[2] + b[2]) / 2,
|
||||
]
|
||||
|
||||
/**
|
||||
* Wall-plane proximity / alignment guides for the 3D editor — the spatial twin
|
||||
* of the floor-plan placement dimensions + equal-spacing badges. Subscribes to
|
||||
* `useOpeningGuides` (published by the door/window move tools each drag tick) and
|
||||
* draws sill/head + edge-proximity dimensions, a sill-alignment line, and
|
||||
* equal-spacing badges. Coordinates are already in the move tool's render frame
|
||||
* (the producer reuses the cursor's `wallLocalToWorld`, so they share the cursor's
|
||||
* building-local frame), so this layer mounts beside `Alignment3DGuideLayer` and
|
||||
* renders them as-is.
|
||||
*/
|
||||
export const OpeningGuides3DLayer = memo(function OpeningGuides3DLayer() {
|
||||
const guides = useOpeningGuides((s) => s.guides)
|
||||
const unit = useViewer((s) => s.unit)
|
||||
if (guides.length === 0) return null
|
||||
return (
|
||||
<>
|
||||
{guides.map((guide, i) => (
|
||||
<OpeningGuide guide={guide} key={i} unit={unit} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
function OpeningGuide({ guide, unit }: { guide: OpeningGuide3D; unit: 'metric' | 'imperial' }) {
|
||||
if (guide.kind === 'badge') {
|
||||
return (
|
||||
<Html
|
||||
center
|
||||
position={guide.at}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[20, 0]}
|
||||
>
|
||||
<div
|
||||
className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-sans font-semibold text-[11px] text-white"
|
||||
style={{ backgroundColor: BADGE_PILL }}
|
||||
>
|
||||
{`= ${formatMeasurement(guide.value, unit)}`}
|
||||
</div>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
const material = guide.kind === 'align-line' ? alignMaterial : dimensionMaterial
|
||||
return (
|
||||
<>
|
||||
<GuideSegment from={guide.from} material={material} to={guide.to} />
|
||||
{guide.kind === 'dimension' ? (
|
||||
<Html
|
||||
center
|
||||
position={mid(guide.from, guide.to)}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[20, 0]}
|
||||
>
|
||||
<div
|
||||
className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-medium font-sans text-[11px] text-white"
|
||||
style={{ backgroundColor: DIMENSION_PILL }}
|
||||
>
|
||||
{formatMeasurement(guide.value, unit)}
|
||||
</div>
|
||||
</Html>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GuideSegment({
|
||||
from,
|
||||
to,
|
||||
material,
|
||||
}: {
|
||||
from: OpeningGuideVec3
|
||||
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])
|
||||
useEffect(() => () => line.geometry.dispose(), [line])
|
||||
return <primitive object={line} />
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { type ComponentType, lazy, Suspense } from 'react'
|
||||
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
|
||||
import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
|
||||
import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer'
|
||||
import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
|
||||
import { ElevatorTool } from './elevator/elevator-tool'
|
||||
import { MoveTool } from './item/move-tool'
|
||||
@@ -283,6 +284,9 @@ export const ToolManager: React.FC = () => {
|
||||
tools above. Lives inside the building-local group so the
|
||||
building-local guide coords render at the right world position. */}
|
||||
<Alignment3DGuideLayer />
|
||||
{/* Wall-plane proximity / sill / equal-spacing guides for openings,
|
||||
published by the door/window move tools in the same world frame. */}
|
||||
<OpeningGuides3DLayer />
|
||||
{/* "Magnetic" beacon at the active wall-draft snap point. */}
|
||||
<WallSnapBeaconLayer />
|
||||
</group>
|
||||
|
||||
@@ -300,6 +300,11 @@ export type {
|
||||
WorkspaceMode,
|
||||
} from './store/use-editor'
|
||||
export { default as useEditor } from './store/use-editor'
|
||||
export {
|
||||
default as useOpeningGuides,
|
||||
type OpeningGuide3D,
|
||||
type OpeningGuideVec3,
|
||||
} from './store/use-opening-guides'
|
||||
export {
|
||||
type PaletteView,
|
||||
type PaletteViewProps,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Ephemeral store for the 3D opening proximity/alignment guides published by the
|
||||
// door/window move + placement tools during a drag — the wall-plane counterpart
|
||||
// of `useAlignmentGuides` (which only carries floor-plane XZ guides). Guides are
|
||||
// already transformed into the move tool's render frame — the same building-local
|
||||
// frame as the drag cursor (ToolManager's group) — so the renderer stays dumb.
|
||||
// Producers clear on commit, cancel, leave, and unmount.
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type OpeningGuideVec3 = [number, number, number]
|
||||
|
||||
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 }
|
||||
// A dashed line connecting two openings that share a sill / centre / top.
|
||||
| { kind: 'align-line'; from: OpeningGuideVec3; to: OpeningGuideVec3 }
|
||||
// A Figma-style "=" badge marking one gap in an equal-spacing run.
|
||||
| { kind: 'badge'; at: OpeningGuideVec3; value: number }
|
||||
|
||||
type OpeningGuidesState = {
|
||||
guides: OpeningGuide3D[]
|
||||
set(guides: OpeningGuide3D[]): void
|
||||
clear(): void
|
||||
}
|
||||
|
||||
const useOpeningGuides = create<OpeningGuidesState>((set) => ({
|
||||
guides: [],
|
||||
set: (guides) => set({ guides }),
|
||||
clear: () => set({ guides: [] }),
|
||||
}))
|
||||
|
||||
export default useOpeningGuides
|
||||
Reference in New Issue
Block a user