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 { type ComponentType, lazy, Suspense } from 'react'
|
||||||
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
|
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
|
||||||
import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
|
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 { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
|
||||||
import { ElevatorTool } from './elevator/elevator-tool'
|
import { ElevatorTool } from './elevator/elevator-tool'
|
||||||
import { MoveTool } from './item/move-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
|
tools above. Lives inside the building-local group so the
|
||||||
building-local guide coords render at the right world position. */}
|
building-local guide coords render at the right world position. */}
|
||||||
<Alignment3DGuideLayer />
|
<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. */}
|
{/* "Magnetic" beacon at the active wall-draft snap point. */}
|
||||||
<WallSnapBeaconLayer />
|
<WallSnapBeaconLayer />
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -300,6 +300,11 @@ export type {
|
|||||||
WorkspaceMode,
|
WorkspaceMode,
|
||||||
} from './store/use-editor'
|
} from './store/use-editor'
|
||||||
export { default as useEditor } 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 {
|
export {
|
||||||
type PaletteView,
|
type PaletteView,
|
||||||
type PaletteViewProps,
|
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
|
||||||
@@ -28,6 +28,7 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import { clearOpeningGuides3D, publishOpeningGuides3D } from '../shared/opening-guides-runtime'
|
||||||
import {
|
import {
|
||||||
getRoofWallOpeningCursorPose,
|
getRoofWallOpeningCursorPose,
|
||||||
type RoofWallOpeningTarget,
|
type RoofWallOpeningTarget,
|
||||||
@@ -125,6 +126,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
const hideCursor = () => {
|
const hideCursor = () => {
|
||||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
clearOpeningGuides3D()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alignment candidates — anchors of every OTHER alignable object (the
|
// Alignment candidates — anchors of every OTHER alignable object (the
|
||||||
@@ -253,6 +255,26 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
target.cursorRotation,
|
target.cursorRotation,
|
||||||
target.valid,
|
target.valid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
publishOpeningGuides3D({
|
||||||
|
wall: target.wallNode,
|
||||||
|
movingId: movingDoorNode.id,
|
||||||
|
centerS: target.clampedX,
|
||||||
|
centerY: target.clampedY,
|
||||||
|
width: movingDoorNode.width,
|
||||||
|
height: movingDoorNode.height,
|
||||||
|
// Doors sit on the floor — no sill/head or vertical alignment guides.
|
||||||
|
includeVertical: false,
|
||||||
|
nodes: useScene.getState().nodes,
|
||||||
|
toWorld: (s, y) =>
|
||||||
|
wallLocalToWorld(
|
||||||
|
target.wallNode,
|
||||||
|
s,
|
||||||
|
y,
|
||||||
|
getLevelYOffset(),
|
||||||
|
getSlabElevation(target.event),
|
||||||
|
),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
const onWallEnter = (event: WallEvent) => {
|
||||||
@@ -416,6 +438,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
lastTarget = null
|
lastTarget = null
|
||||||
lastRoofEvent = event
|
lastRoofEvent = event
|
||||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
|
// Opening guides are wall-specific; clear them when over a roof face.
|
||||||
|
clearOpeningGuides3D()
|
||||||
if (currentHostId !== target.segment.id) {
|
if (currentHostId !== target.segment.id) {
|
||||||
useScene.getState().updateNode(movingDoorNode.id, {
|
useScene.getState().updateNode(movingDoorNode.id, {
|
||||||
position: target.position,
|
position: target.position,
|
||||||
@@ -599,6 +623,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
}
|
}
|
||||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
clearOpeningGuides3D()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
emitter.off('wall:enter', onWallEnter)
|
emitter.off('wall:enter', onWallEnter)
|
||||||
emitter.off('wall:move', onWallMove)
|
emitter.off('wall:move', onWallMove)
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Runtime glue between the pure `computeOpeningGuides` geometry (core) and the
|
||||||
|
// editor's 3D guide store, used by the door/window move + placement tools. Lives
|
||||||
|
// in `nodes` (not core) because it talks to the editor store; kept thin so each
|
||||||
|
// tool's per-tick hook is a single call.
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
computeOpeningGuides,
|
||||||
|
detectVerticalAlignment,
|
||||||
|
type OpeningSpan,
|
||||||
|
type WallNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { type OpeningGuide3D, useOpeningGuides } from '@pascal-app/editor'
|
||||||
|
|
||||||
|
// Parity with `snapLocalXToNeighbors`' along-wall threshold.
|
||||||
|
const SILL_SNAP_THRESHOLD_M = 0.08
|
||||||
|
// Hide a dimension that has collapsed to nothing (sill flush to the floor, or
|
||||||
|
// head flush to the wall top) so it doesn't render a zero-length "0m" pill.
|
||||||
|
const MIN_DIMENSION_M = 0.02
|
||||||
|
|
||||||
|
/** Maps a wall-local point (s along the wall, y above the wall base) to the move
|
||||||
|
* tool's render frame — the caller passes its own `wallLocalToWorld` closure so
|
||||||
|
* the guides land in exactly the same (building-local) frame as the drag cursor. */
|
||||||
|
type ToWorld = (s: number, y: number) => [number, number, number]
|
||||||
|
|
||||||
|
/** The moving opening's same-wall neighbours, as wall-local spans. */
|
||||||
|
export function collectOpeningSiblings(
|
||||||
|
wall: WallNode,
|
||||||
|
movingId: string,
|
||||||
|
nodes: Record<string, AnyNode>,
|
||||||
|
): OpeningSpan[] {
|
||||||
|
const out: OpeningSpan[] = []
|
||||||
|
const childIds = Array.isArray(wall.children) ? wall.children : []
|
||||||
|
for (const childId of childIds) {
|
||||||
|
if (childId === movingId) continue
|
||||||
|
const node = nodes[childId as AnyNodeId]
|
||||||
|
if (!node || (node.type !== 'door' && node.type !== 'window')) continue
|
||||||
|
out.push({
|
||||||
|
id: node.id,
|
||||||
|
centerS: node.position[0],
|
||||||
|
width: node.width,
|
||||||
|
centerY: node.position[1],
|
||||||
|
height: node.height,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical sill/centre/top snap for a window — the chosen "snap + guide"
|
||||||
|
* behaviour. Returns the snapped wall-local Y when a sibling sill/centre/top is
|
||||||
|
* within threshold, else null so the caller falls back to the grid. Mirrors
|
||||||
|
* `snapLocalXToNeighbors` on the vertical axis.
|
||||||
|
*/
|
||||||
|
export function resolveSillSnap(args: {
|
||||||
|
wall: WallNode
|
||||||
|
movingId: string
|
||||||
|
localX: number
|
||||||
|
localY: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
nodes: Record<string, AnyNode>
|
||||||
|
}): number | null {
|
||||||
|
const siblings = collectOpeningSiblings(args.wall, args.movingId, args.nodes)
|
||||||
|
const match = detectVerticalAlignment(
|
||||||
|
{
|
||||||
|
id: args.movingId,
|
||||||
|
centerS: args.localX,
|
||||||
|
width: args.width,
|
||||||
|
centerY: args.localY,
|
||||||
|
height: args.height,
|
||||||
|
},
|
||||||
|
siblings,
|
||||||
|
SILL_SNAP_THRESHOLD_M,
|
||||||
|
)
|
||||||
|
return match ? args.localY + match.snap : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute and publish the 3D opening guides for the current drag tick. */
|
||||||
|
export function publishOpeningGuides3D(args: {
|
||||||
|
wall: WallNode
|
||||||
|
movingId: string
|
||||||
|
centerS: number
|
||||||
|
centerY: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
includeVertical: boolean
|
||||||
|
toWorld: ToWorld
|
||||||
|
nodes: Record<string, AnyNode>
|
||||||
|
}): void {
|
||||||
|
const { wall, centerS, centerY, width, toWorld } = args
|
||||||
|
const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
||||||
|
const wallHeight = wall.height ?? 2.5
|
||||||
|
const siblings = collectOpeningSiblings(wall, args.movingId, args.nodes)
|
||||||
|
const guides = computeOpeningGuides({
|
||||||
|
moving: { id: args.movingId, centerS, width, centerY, height: args.height },
|
||||||
|
siblings,
|
||||||
|
wall: { length: wallLength, height: wallHeight },
|
||||||
|
includeVertical: args.includeVertical,
|
||||||
|
})
|
||||||
|
|
||||||
|
const out: OpeningGuide3D[] = []
|
||||||
|
|
||||||
|
if (guides.sillHead) {
|
||||||
|
if (guides.sillHead.sill > MIN_DIMENSION_M) {
|
||||||
|
out.push({
|
||||||
|
kind: 'dimension',
|
||||||
|
from: toWorld(centerS, 0),
|
||||||
|
to: toWorld(centerS, guides.sillHead.bottomY),
|
||||||
|
value: guides.sillHead.sill,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (guides.sillHead.head > MIN_DIMENSION_M) {
|
||||||
|
out.push({
|
||||||
|
kind: 'dimension',
|
||||||
|
from: toWorld(centerS, guides.sillHead.topY),
|
||||||
|
to: toWorld(centerS, wallHeight),
|
||||||
|
value: guides.sillHead.head,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const gap of guides.gaps) {
|
||||||
|
out.push({
|
||||||
|
kind: 'dimension',
|
||||||
|
from: toWorld(gap.fromS, centerY),
|
||||||
|
to: toWorld(gap.toS, centerY),
|
||||||
|
value: gap.distance,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guides.vertical) {
|
||||||
|
const target = siblings.find((s) => s.id === guides.vertical?.targetId)
|
||||||
|
if (target) {
|
||||||
|
const lo = Math.min(centerS - width / 2, target.centerS - target.width / 2)
|
||||||
|
const hi = Math.max(centerS + width / 2, target.centerS + target.width / 2)
|
||||||
|
out.push({
|
||||||
|
kind: 'align-line',
|
||||||
|
from: toWorld(lo, guides.vertical.y),
|
||||||
|
to: toWorld(hi, guides.vertical.y),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guides.equalSpacing) {
|
||||||
|
for (const seg of guides.equalSpacing.segments) {
|
||||||
|
out.push({
|
||||||
|
kind: 'badge',
|
||||||
|
at: toWorld((seg.fromS + seg.toS) / 2, centerY),
|
||||||
|
value: guides.equalSpacing.gap,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useOpeningGuides.getState().set(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearOpeningGuides3D(): void {
|
||||||
|
useOpeningGuides.getState().clear()
|
||||||
|
}
|
||||||
@@ -29,6 +29,11 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
|
||||||
import { LineBasicNodeMaterial } from 'three/webgpu'
|
import { LineBasicNodeMaterial } from 'three/webgpu'
|
||||||
|
import {
|
||||||
|
clearOpeningGuides3D,
|
||||||
|
publishOpeningGuides3D,
|
||||||
|
resolveSillSnap,
|
||||||
|
} from '../shared/opening-guides-runtime'
|
||||||
import {
|
import {
|
||||||
getRoofWallOpeningCursorPose,
|
getRoofWallOpeningCursorPose,
|
||||||
type RoofWallOpeningTarget,
|
type RoofWallOpeningTarget,
|
||||||
@@ -151,6 +156,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
const hideCursor = () => {
|
const hideCursor = () => {
|
||||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
clearOpeningGuides3D()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alignment candidates — anchors of every OTHER alignable object (the
|
// Alignment candidates — anchors of every OTHER alignable object (the
|
||||||
@@ -206,8 +212,21 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
}
|
}
|
||||||
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
|
const targetLocalX = dragAnchor.startX + (rawLocalX - dragAnchor.rawX)
|
||||||
const targetRawLocalY = dragAnchor.startY + (rawLocalY - dragAnchor.rawY)
|
const targetRawLocalY = dragAnchor.startY + (rawLocalY - dragAnchor.rawY)
|
||||||
const targetLocalY =
|
// Vertical sill alignment (snap + guide): a sibling's sill/centre/top wins
|
||||||
event.nativeEvent?.shiftKey === true ? targetRawLocalY : snapToHalf(targetRawLocalY)
|
// over the 0.5m grid when within threshold; Shift bypasses both.
|
||||||
|
const bypassY = event.nativeEvent?.shiftKey === true
|
||||||
|
const sillSnapped = bypassY
|
||||||
|
? null
|
||||||
|
: resolveSillSnap({
|
||||||
|
wall: event.node,
|
||||||
|
movingId: movingWindowNode.id,
|
||||||
|
localX: targetLocalX,
|
||||||
|
localY: targetRawLocalY,
|
||||||
|
width: movingWindowNode.width,
|
||||||
|
height: movingWindowNode.height,
|
||||||
|
nodes: useScene.getState().nodes,
|
||||||
|
})
|
||||||
|
const targetLocalY = bypassY ? targetRawLocalY : (sillSnapped ?? snapToHalf(targetRawLocalY))
|
||||||
const localX = resolveWallSlideAlignment({
|
const localX = resolveWallSlideAlignment({
|
||||||
wallNode: event.node,
|
wallNode: event.node,
|
||||||
rawLocalX: targetLocalX,
|
rawLocalX: targetLocalX,
|
||||||
@@ -284,6 +303,25 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
target.cursorRotation,
|
target.cursorRotation,
|
||||||
target.valid,
|
target.valid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
publishOpeningGuides3D({
|
||||||
|
wall: target.wallNode,
|
||||||
|
movingId: movingWindowNode.id,
|
||||||
|
centerS: target.clampedX,
|
||||||
|
centerY: target.clampedY,
|
||||||
|
width: movingWindowNode.width,
|
||||||
|
height: movingWindowNode.height,
|
||||||
|
includeVertical: true,
|
||||||
|
nodes: useScene.getState().nodes,
|
||||||
|
toWorld: (s, y) =>
|
||||||
|
wallLocalToWorld(
|
||||||
|
target.wallNode,
|
||||||
|
s,
|
||||||
|
y,
|
||||||
|
getLevelYOffset(),
|
||||||
|
getSlabElevation(target.event),
|
||||||
|
),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
const onWallEnter = (event: WallEvent) => {
|
||||||
@@ -459,6 +497,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
lastTarget = null
|
lastTarget = null
|
||||||
lastRoofEvent = event
|
lastRoofEvent = event
|
||||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
|
// Opening guides are wall-specific; clear them when over a roof face.
|
||||||
|
clearOpeningGuides3D()
|
||||||
if (currentHostId !== target.segment.id) {
|
if (currentHostId !== target.segment.id) {
|
||||||
useScene.getState().updateNode(movingWindowNode.id, {
|
useScene.getState().updateNode(movingWindowNode.id, {
|
||||||
position: target.position,
|
position: target.position,
|
||||||
@@ -644,6 +684,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
}
|
}
|
||||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
clearOpeningGuides3D()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
emitter.off('wall:enter', onWallEnter)
|
emitter.off('wall:enter', onWallEnter)
|
||||||
emitter.off('wall:move', onWallMove)
|
emitter.off('wall:move', onWallMove)
|
||||||
|
|||||||
Reference in New Issue
Block a user