Merge pull request #408 from pascalorg/feat/opening-proximity-guides
Move handle + proximity / sill / equal-spacing guides for wall openings
This commit is contained in:
@@ -116,6 +116,14 @@ export type LinearResizeHandle<N> = {
|
|||||||
anchor: HandleAnchor
|
anchor: HandleAnchor
|
||||||
currentValue: (node: N) => number
|
currentValue: (node: N) => number
|
||||||
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
|
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
|
||||||
|
/**
|
||||||
|
* Optional per-tick hook fired while this handle is being dragged, with the
|
||||||
|
* live (in-progress, override-merged) node. A pure side-channel for transient
|
||||||
|
* feedback — doors/windows use it to publish proximity / sill guides for the
|
||||||
|
* edge being resized. The return value is ignored; the resize itself is driven
|
||||||
|
* by `apply`.
|
||||||
|
*/
|
||||||
|
onDrag?: (node: N, sceneApi: SceneApi) => void
|
||||||
/**
|
/**
|
||||||
* Cross-node redirect. By default the drag's live override + the
|
* Cross-node redirect. By default the drag's live override + the
|
||||||
* committed write both land on the SELECTED node. When this returns
|
* committed write both land on the SELECTED node. When this returns
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export {
|
|||||||
discoverPlugins,
|
discoverPlugins,
|
||||||
getHostRefFields,
|
getHostRefFields,
|
||||||
getSelectableKinds,
|
getSelectableKinds,
|
||||||
|
hasRegistry3DMoveTool,
|
||||||
isDrawnViaTool,
|
isDrawnViaTool,
|
||||||
isDrawnViaToolKind,
|
isDrawnViaToolKind,
|
||||||
isPresettable,
|
isPresettable,
|
||||||
|
|||||||
@@ -146,6 +146,20 @@ export function isRegistryMovable(kind: string): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the kind has a move tool that MOUNTS in the 3D viewport — the
|
||||||
|
* generic `capabilities.movable` mover or a bespoke `affordanceTools.move`.
|
||||||
|
* Narrower than {@link isRegistryMovable}, which also accepts floorplan-only
|
||||||
|
* movers (e.g. zone) that have no 3D tool. Gates 3D direct move: Ctrl/Meta-drag
|
||||||
|
* and the move-cross grip. Kept beside `isRegistryMovable` so the 2D and 3D
|
||||||
|
* movability predicates can't drift apart.
|
||||||
|
*/
|
||||||
|
export function hasRegistry3DMoveTool(kind: string): boolean {
|
||||||
|
const def = nodeRegistry.get(kind)
|
||||||
|
if (!def) return false
|
||||||
|
return def.capabilities.movable !== undefined || def.affordanceTools?.move !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the kind can be saved as a reusable preset. Default: an
|
* Whether the kind can be saved as a reusable preset. Default: an
|
||||||
* explicit `capabilities.presettable` boolean wins; otherwise the kind
|
* explicit `capabilities.presettable` boolean wins; otherwise the kind
|
||||||
|
|||||||
@@ -440,6 +440,20 @@ export type FloorplanGeometry =
|
|||||||
/** Rotation in radians. The renderer auto-flips to keep text upright. */
|
/** Rotation in radians. The renderer auto-flips to keep text upright. */
|
||||||
angle: number
|
angle: number
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Equal-spacing badge — a small accent pill marking one gap in a run of
|
||||||
|
* (near-)equally-spaced openings (the 2D counterpart of Figma's "=" distance
|
||||||
|
* chips). Emitted once per equal gap so the repeated value reads as a rhythm.
|
||||||
|
* `text` is the shared gap distance; `angle` orients the pill along the wall
|
||||||
|
* (the renderer auto-flips it upright).
|
||||||
|
*/
|
||||||
|
| {
|
||||||
|
kind: 'equal-spacing-badge'
|
||||||
|
point: FloorplanPoint
|
||||||
|
text: string
|
||||||
|
/** Rotation in radians. */
|
||||||
|
angle: number
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Architect's dimension overlay — extension lines from the edge
|
* Architect's dimension overlay — extension lines from the edge
|
||||||
* endpoints out past the dimension line, two dimension line halves
|
* endpoints out past the dimension line, two dimension line halves
|
||||||
|
|||||||
@@ -49,6 +49,26 @@ export {
|
|||||||
moveToward,
|
moveToward,
|
||||||
resolveMovable,
|
resolveMovable,
|
||||||
} from './movement'
|
} from './movement'
|
||||||
|
export {
|
||||||
|
type AlongWallAlignment,
|
||||||
|
type AlongWallFeature,
|
||||||
|
computeEdgeGaps,
|
||||||
|
computeOpeningGuides,
|
||||||
|
DEFAULT_OPENING_GUIDE_TOLERANCES,
|
||||||
|
detectAlongWallAlignment,
|
||||||
|
detectEqualSpacing,
|
||||||
|
detectVerticalAlignment,
|
||||||
|
type EdgeGap,
|
||||||
|
type EqualSpacingRun,
|
||||||
|
type OpeningGuideInput,
|
||||||
|
type OpeningGuides,
|
||||||
|
type OpeningGuideTolerances,
|
||||||
|
type OpeningSpan,
|
||||||
|
type SillHeadGuide,
|
||||||
|
type VerticalAlignment,
|
||||||
|
type VerticalFeature,
|
||||||
|
type WallExtent,
|
||||||
|
} from './opening-guides'
|
||||||
export {
|
export {
|
||||||
DEFAULT_ANGLE_STEP,
|
DEFAULT_ANGLE_STEP,
|
||||||
DEFAULT_GRID_STEP,
|
DEFAULT_GRID_STEP,
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import {
|
||||||
|
computeEdgeGaps,
|
||||||
|
computeOpeningGuides,
|
||||||
|
detectAlongWallAlignment,
|
||||||
|
detectEqualSpacing,
|
||||||
|
detectVerticalAlignment,
|
||||||
|
type OpeningSpan,
|
||||||
|
type WallExtent,
|
||||||
|
} from './opening-guides'
|
||||||
|
|
||||||
|
function span(id: string, centerS: number, width: number, centerY = 1, height = 1): OpeningSpan {
|
||||||
|
return { id, centerS, width, centerY, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
const WALL: WallExtent = { length: 10, height: 2.5 }
|
||||||
|
|
||||||
|
describe('detectEqualSpacing', () => {
|
||||||
|
test('returns null for fewer than three openings', () => {
|
||||||
|
const a = span('a', 0.5, 1)
|
||||||
|
const b = span('b', 2.5, 1)
|
||||||
|
expect(detectEqualSpacing([a, b], 'b', 0.03, 0.02)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects a run of equal gaps across three openings', () => {
|
||||||
|
// width 1 each: a[0,1] b[2,3] c[4,5] → two gaps of 1m.
|
||||||
|
const a = span('a', 0.5, 1)
|
||||||
|
const b = span('b', 2.5, 1)
|
||||||
|
const c = span('c', 4.5, 1)
|
||||||
|
const run = detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)
|
||||||
|
expect(run).not.toBeNull()
|
||||||
|
expect(run?.gap).toBeCloseTo(1)
|
||||||
|
expect(run?.segments).toHaveLength(2)
|
||||||
|
expect(run?.openingIds).toEqual(['a', 'b', 'c'])
|
||||||
|
expect(run?.segments[0]).toEqual({ fromS: 1, toS: 2 })
|
||||||
|
expect(run?.segments[1]).toEqual({ fromS: 3, toS: 4 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('extends a run across four openings (three gaps)', () => {
|
||||||
|
const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.5, 1), span('d', 6.5, 1)]
|
||||||
|
const run = detectEqualSpacing(openings, 'c', 0.03, 0.02)
|
||||||
|
expect(run?.segments).toHaveLength(3)
|
||||||
|
expect(run?.openingIds).toEqual(['a', 'b', 'c', 'd'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null when gaps differ beyond tolerance', () => {
|
||||||
|
const a = span('a', 0.5, 1) // [0,1]
|
||||||
|
const b = span('b', 2.5, 1) // [2,3] → gap 1
|
||||||
|
const c = span('c', 5, 1) // [4.5,5.5] → gap 1.5
|
||||||
|
expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null when the moving opening is not part of the equal run', () => {
|
||||||
|
const a = span('a', 0.5, 1)
|
||||||
|
const b = span('b', 2.5, 1)
|
||||||
|
const c = span('c', 4.5, 1) // a,b,c form equal gaps of 1
|
||||||
|
const d = span('d', 10, 1) // far right, breaks the run
|
||||||
|
expect(detectEqualSpacing([a, b, c, d], 'd', 0.03, 0.02)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a near-zero (touching) gap breaks a run', () => {
|
||||||
|
const a = span('a', 0.5, 1) // [0,1]
|
||||||
|
const b = span('b', 1.505, 1) // [1.005,2.005] → gap 0.005 < minGap
|
||||||
|
const c = span('c', 3.005, 1) // [2.505,3.505] → gap 0.5
|
||||||
|
expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('honours the equal-spacing tolerance', () => {
|
||||||
|
const a = span('a', 0.5, 1) // [0,1]
|
||||||
|
const b = span('b', 2.5, 1) // [2,3] → gap 1.0
|
||||||
|
const c = span('c', 4.52, 1) // [4.02,5.02] → gap 1.02
|
||||||
|
expect(detectEqualSpacing([a, b, c], 'b', 0.03, 0.02)?.segments).toHaveLength(2)
|
||||||
|
expect(detectEqualSpacing([a, b, c], 'b', 0.01, 0.02)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeEdgeGaps', () => {
|
||||||
|
test('measures clearance to the nearest neighbour on each side', () => {
|
||||||
|
const moving = span('m', 5, 1) // [4.5,5.5]
|
||||||
|
const left = span('l', 2, 1) // [1.5,2.5]
|
||||||
|
const right = span('r', 8, 1) // [7.5,8.5]
|
||||||
|
const gaps = computeEdgeGaps(moving, [left, right], WALL, 0.02)
|
||||||
|
const byside = Object.fromEntries(gaps.map((g) => [g.side, g]))
|
||||||
|
expect(byside.left?.distance).toBeCloseTo(2)
|
||||||
|
expect(byside.left?.target).toBe('opening')
|
||||||
|
expect(byside.left?.targetId).toBe('l')
|
||||||
|
expect(byside.right?.distance).toBeCloseTo(2)
|
||||||
|
expect(byside.right?.targetId).toBe('r')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to wall ends with no neighbour', () => {
|
||||||
|
const moving = span('m', 5, 1) // [4.5,5.5]
|
||||||
|
const gaps = computeEdgeGaps(moving, [], WALL, 0.02)
|
||||||
|
const byside = Object.fromEntries(gaps.map((g) => [g.side, g]))
|
||||||
|
expect(byside.left?.target).toBe('wall-start')
|
||||||
|
expect(byside.left?.distance).toBeCloseTo(4.5)
|
||||||
|
expect(byside.right?.target).toBe('wall-end')
|
||||||
|
expect(byside.right?.distance).toBeCloseTo(4.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('omits a side that is flush / overlapping (below minGap)', () => {
|
||||||
|
const moving = span('m', 5, 1) // [4.5,5.5]
|
||||||
|
const flush = span('l', 4, 1) // [3.5,4.5] right edge touches moving left
|
||||||
|
const gaps = computeEdgeGaps(moving, [flush], WALL, 0.02)
|
||||||
|
expect(gaps.find((g) => g.side === 'left')).toBeUndefined()
|
||||||
|
expect(gaps.find((g) => g.side === 'right')?.target).toBe('wall-end')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('detectAlongWallAlignment', () => {
|
||||||
|
test('detects edge-to-edge alignment within tolerance', () => {
|
||||||
|
const moving = span('m', 5, 2) // [4,6]
|
||||||
|
const sib = span('s', 7.05, 2) // left edge 6.05
|
||||||
|
const a = detectAlongWallAlignment(moving, [sib], 0.08)
|
||||||
|
expect(a?.movingFeature).toBe('right')
|
||||||
|
expect(a?.targetFeature).toBe('left')
|
||||||
|
expect(a?.snap).toBeCloseTo(0.05)
|
||||||
|
expect(a?.s).toBeCloseTo(6.05)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detects centre alignment', () => {
|
||||||
|
const moving = span('m', 5, 2)
|
||||||
|
const sib = span('s', 5.03, 0.5) // centre 5.03, edges far from moving edges
|
||||||
|
const a = detectAlongWallAlignment(moving, [sib], 0.08)
|
||||||
|
expect(a?.movingFeature).toBe('center')
|
||||||
|
expect(a?.targetFeature).toBe('center')
|
||||||
|
expect(a?.snap).toBeCloseTo(0.03)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null when nothing is within tolerance', () => {
|
||||||
|
const moving = span('m', 5, 2)
|
||||||
|
const sib = span('s', 9, 2)
|
||||||
|
expect(detectAlongWallAlignment(moving, [sib], 0.08)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('detectVerticalAlignment', () => {
|
||||||
|
test('detects a shared sill within tolerance', () => {
|
||||||
|
const moving = span('m', 5, 1, 1.5, 1) // sill 1.0
|
||||||
|
const sib = span('s', 8, 1, 2.04, 2) // sill 1.04
|
||||||
|
const a = detectVerticalAlignment(moving, [sib], 0.08)
|
||||||
|
expect(a?.movingFeature).toBe('sill')
|
||||||
|
expect(a?.targetFeature).toBe('sill')
|
||||||
|
expect(a?.snap).toBeCloseTo(0.04)
|
||||||
|
expect(a?.y).toBeCloseTo(1.04)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null when sills/tops differ beyond tolerance', () => {
|
||||||
|
const moving = span('m', 5, 1, 1.5, 1) // sill 1, top 2, centre 1.5
|
||||||
|
const sib = span('s', 8, 1, 0.4, 0.4) // sill 0.2, top 0.6, centre 0.4
|
||||||
|
expect(detectVerticalAlignment(moving, [sib], 0.08)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('computeOpeningGuides', () => {
|
||||||
|
test('includes sill/head for windows', () => {
|
||||||
|
const moving = span('m', 5, 1, 1.5, 1) // bottom 1, top 2
|
||||||
|
const guides = computeOpeningGuides({
|
||||||
|
moving,
|
||||||
|
siblings: [],
|
||||||
|
wall: WALL,
|
||||||
|
includeVertical: true,
|
||||||
|
})
|
||||||
|
expect(guides.sillHead?.sill).toBeCloseTo(1)
|
||||||
|
expect(guides.sillHead?.head).toBeCloseTo(0.5) // 2.5 - 2
|
||||||
|
expect(guides.sillHead?.bottomY).toBeCloseTo(1)
|
||||||
|
expect(guides.sillHead?.topY).toBeCloseTo(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('omits vertical guides for doors (sit on the floor)', () => {
|
||||||
|
const moving = span('m', 5, 1, 1, 2)
|
||||||
|
const sib = span('s', 8, 1, 1, 2)
|
||||||
|
const guides = computeOpeningGuides({
|
||||||
|
moving,
|
||||||
|
siblings: [sib],
|
||||||
|
wall: WALL,
|
||||||
|
includeVertical: false,
|
||||||
|
})
|
||||||
|
expect(guides.sillHead).toBeNull()
|
||||||
|
expect(guides.vertical).toBeNull()
|
||||||
|
// along-wall + proximity still computed for doors
|
||||||
|
expect(guides.gaps.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('combines proximity and equal-spacing in one pass', () => {
|
||||||
|
const moving = span('b', 2.5, 1)
|
||||||
|
const guides = computeOpeningGuides({
|
||||||
|
moving,
|
||||||
|
siblings: [span('a', 0.5, 1), span('c', 4.5, 1)],
|
||||||
|
wall: WALL,
|
||||||
|
includeVertical: true,
|
||||||
|
})
|
||||||
|
expect(guides.gaps).toHaveLength(2)
|
||||||
|
expect(guides.equalSpacing?.gap).toBeCloseTo(1)
|
||||||
|
expect(guides.equalSpacing?.openingIds).toEqual(['a', 'b', 'c'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('opening-guides — review regressions', () => {
|
||||||
|
test('detectEqualSpacing finds a run that starts partway through a drifting sequence', () => {
|
||||||
|
// gaps 1.00, 1.02, 1.04 — only [b,c,d] is equal within 0.03 and includes the
|
||||||
|
// moving opening; a first-gap-anchored greedy scan used to drop it.
|
||||||
|
const openings = [span('a', 0.5, 1), span('b', 2.5, 1), span('c', 4.52, 1), span('d', 6.56, 1)]
|
||||||
|
const run = detectEqualSpacing(openings, 'd', 0.03, 0.02)
|
||||||
|
expect(run?.openingIds).toEqual(['b', 'c', 'd'])
|
||||||
|
expect(run?.gap).toBeCloseTo(1.03)
|
||||||
|
expect(run?.segments).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detectEqualSpacing prefers the leftmost run on a length tie', () => {
|
||||||
|
// gaps 1,1,2,2 with the moving opening in the middle — two equal-length runs.
|
||||||
|
const openings = [
|
||||||
|
span('a', 0.5, 1),
|
||||||
|
span('b', 2.5, 1),
|
||||||
|
span('c', 4.5, 1),
|
||||||
|
span('d', 7.5, 1),
|
||||||
|
span('e', 10.5, 1),
|
||||||
|
]
|
||||||
|
expect(detectEqualSpacing(openings, 'c', 0.03, 0.02)?.openingIds).toEqual(['a', 'b', 'c'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('computeEdgeGaps suppresses both sides when a sibling overlaps', () => {
|
||||||
|
const moving = span('m', 5, 1) // [4.5,5.5]
|
||||||
|
const containing = span('s', 5, 2) // [4,6] straddles both edges
|
||||||
|
expect(computeEdgeGaps(moving, [containing], WALL, 0.02)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('alignment detectors ignore the moving opening if present in siblings', () => {
|
||||||
|
const moving = span('m', 5, 2, 1.5, 1)
|
||||||
|
expect(detectAlongWallAlignment(moving, [moving], 0.08)).toBeNull()
|
||||||
|
expect(detectVerticalAlignment(moving, [moving], 0.08)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('detectAlongWallAlignment reports a negative snap when the feature is past the target', () => {
|
||||||
|
const moving = span('m', 5, 2) // centre 5
|
||||||
|
const sib = span('s', 4.96, 0.5) // centre 4.96
|
||||||
|
const a = detectAlongWallAlignment(moving, [sib], 0.08)
|
||||||
|
expect(a?.movingFeature).toBe('center')
|
||||||
|
expect(a?.snap).toBeCloseTo(-0.04)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
// Proximity / alignment guides for wall-hosted openings (doors, windows).
|
||||||
|
//
|
||||||
|
// Pure geometry over a single host wall's LOCAL frame — no Three.js, no scene
|
||||||
|
// store, no React — so it runs identically for the 3D viewport and the 2D
|
||||||
|
// floor plan and is unit-testable in isolation. Callers extract the spans from
|
||||||
|
// the scene graph (an opening's `position[0]` is its along-wall centre, its
|
||||||
|
// `position[1]` its vertical centre with the wall base at y=0) and feed them in;
|
||||||
|
// the renderers transform the returned wall-local coordinates back to world
|
||||||
|
// (3D) or plan (2D).
|
||||||
|
//
|
||||||
|
// What it produces, mirroring the affordances architects expect (and Figma's
|
||||||
|
// smart guides):
|
||||||
|
// - sill/head : a window's bottom edge → floor and top edge → wall top.
|
||||||
|
// - edge gaps : along-wall clearance to the nearest neighbour opening (or
|
||||||
|
// the wall end) on each side.
|
||||||
|
// - alongWall : the moving opening's edge/centre lining up with a
|
||||||
|
// neighbour's edge/centre along the wall.
|
||||||
|
// - vertical : two openings sharing a sill / head / vertical centre.
|
||||||
|
// - equalSpacing : a run of 3+ openings with (near-)equal gaps between them.
|
||||||
|
//
|
||||||
|
// Detection is passive — it reports what currently coincides within tolerance
|
||||||
|
// and the snap delta that would make it exact, leaving the snap decision to the
|
||||||
|
// caller's manipulation policy (grid vs. alignment vs. Shift bypass).
|
||||||
|
|
||||||
|
/** An opening's footprint in its host wall's local frame. */
|
||||||
|
export type OpeningSpan = {
|
||||||
|
id: string
|
||||||
|
/** Centre along the wall, measured from `wall.start` (m). */
|
||||||
|
centerS: number
|
||||||
|
/** Along-wall extent (m). */
|
||||||
|
width: number
|
||||||
|
/** Vertical centre above the wall base (floor at y=0) (m). */
|
||||||
|
centerY: number
|
||||||
|
/** Vertical extent (m). */
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WallExtent = {
|
||||||
|
/** Wall length (m). */
|
||||||
|
length: number
|
||||||
|
/** Wall height (m). */
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpeningGuideTolerances = {
|
||||||
|
/** Max distance for an edge/centre to count as aligned with a neighbour (m). */
|
||||||
|
align: number
|
||||||
|
/** Max difference between two gaps for them to count as equal (m). */
|
||||||
|
equalSpacing: number
|
||||||
|
/** Gaps below this are treated as touching/overlap noise and ignored (m). */
|
||||||
|
minGap: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_OPENING_GUIDE_TOLERANCES: OpeningGuideTolerances = {
|
||||||
|
// Parity with the along-wall snap threshold (`ALONG_WALL_ALIGN_THRESHOLD_M`).
|
||||||
|
align: 0.08,
|
||||||
|
equalSpacing: 0.03,
|
||||||
|
minGap: 0.02,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which along-wall feature of an opening a guide references. */
|
||||||
|
export type AlongWallFeature = 'left' | 'center' | 'right'
|
||||||
|
/** Which vertical feature of an opening a guide references. */
|
||||||
|
export type VerticalFeature = 'sill' | 'center' | 'top'
|
||||||
|
|
||||||
|
export type SillHeadGuide = {
|
||||||
|
/** Floor (y=0) → the opening's bottom edge (m). */
|
||||||
|
sill: number
|
||||||
|
/** Wall-local y of the bottom edge. */
|
||||||
|
bottomY: number
|
||||||
|
/** The opening's top edge → the wall top (m). */
|
||||||
|
head: number
|
||||||
|
/** Wall-local y of the top edge. */
|
||||||
|
topY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EdgeGap = {
|
||||||
|
side: 'left' | 'right'
|
||||||
|
/** Clearance along the wall (m). */
|
||||||
|
distance: number
|
||||||
|
/** Wall-local s of the moving opening's edge. */
|
||||||
|
fromS: number
|
||||||
|
/** Wall-local s of the neighbour edge / wall end. */
|
||||||
|
toS: number
|
||||||
|
target: 'opening' | 'wall-start' | 'wall-end'
|
||||||
|
/** Set when `target === 'opening'`. */
|
||||||
|
targetId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AlongWallAlignment = {
|
||||||
|
/** Wall-local s the two features share. */
|
||||||
|
s: number
|
||||||
|
movingFeature: AlongWallFeature
|
||||||
|
targetId: string
|
||||||
|
targetFeature: AlongWallFeature
|
||||||
|
/** Delta to add to the moving opening's `centerS` to make them coincide. */
|
||||||
|
snap: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VerticalAlignment = {
|
||||||
|
/** Wall-local y the two features share. */
|
||||||
|
y: number
|
||||||
|
movingFeature: VerticalFeature
|
||||||
|
targetId: string
|
||||||
|
targetFeature: VerticalFeature
|
||||||
|
/** Delta to add to the moving opening's `centerY` to make them coincide. */
|
||||||
|
snap: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EqualSpacingRun = {
|
||||||
|
/** The repeated gap value (average of the run's gaps) (m). */
|
||||||
|
gap: number
|
||||||
|
/** The equal-gap segments along the wall, in order (left → right). */
|
||||||
|
segments: { fromS: number; toS: number }[]
|
||||||
|
/** Participating opening ids, ordered along the wall, including the moving one. */
|
||||||
|
openingIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpeningGuides = {
|
||||||
|
sillHead: SillHeadGuide | null
|
||||||
|
gaps: EdgeGap[]
|
||||||
|
alongWall: AlongWallAlignment | null
|
||||||
|
vertical: VerticalAlignment | null
|
||||||
|
equalSpacing: EqualSpacingRun | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OpeningGuideInput = {
|
||||||
|
moving: OpeningSpan
|
||||||
|
/** Other openings on the SAME wall (the moving opening excluded). */
|
||||||
|
siblings: readonly OpeningSpan[]
|
||||||
|
wall: WallExtent
|
||||||
|
/**
|
||||||
|
* Whether to compute vertical (sill/head/vertical-alignment) guides. True for
|
||||||
|
* windows; false for doors, which sit on the floor so their sill is always 0.
|
||||||
|
*/
|
||||||
|
includeVertical: boolean
|
||||||
|
tolerances?: Partial<OpeningGuideTolerances>
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftEdge = (s: OpeningSpan) => s.centerS - s.width / 2
|
||||||
|
const rightEdge = (s: OpeningSpan) => s.centerS + s.width / 2
|
||||||
|
const bottomEdge = (s: OpeningSpan) => s.centerY - s.height / 2
|
||||||
|
const topEdge = (s: OpeningSpan) => s.centerY + s.height / 2
|
||||||
|
|
||||||
|
function alongWallFeatureCoord(s: OpeningSpan, feature: AlongWallFeature): number {
|
||||||
|
if (feature === 'left') return leftEdge(s)
|
||||||
|
if (feature === 'right') return rightEdge(s)
|
||||||
|
return s.centerS
|
||||||
|
}
|
||||||
|
|
||||||
|
function verticalFeatureCoord(s: OpeningSpan, feature: VerticalFeature): number {
|
||||||
|
if (feature === 'sill') return bottomEdge(s)
|
||||||
|
if (feature === 'top') return topEdge(s)
|
||||||
|
return s.centerY
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALONG_WALL_FEATURES: AlongWallFeature[] = ['left', 'center', 'right']
|
||||||
|
const VERTICAL_FEATURES: VerticalFeature[] = ['sill', 'center', 'top']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edge-to-edge clearance from the moving opening to the nearest neighbour on
|
||||||
|
* each side, falling back to the wall ends when there is no neighbour — the
|
||||||
|
* "how much wall is left here" reading. Returns 0–2 gaps (one per side); a side
|
||||||
|
* is omitted when its clearance is below `minGap` (the opening is flush against
|
||||||
|
* or overlapping that neighbour).
|
||||||
|
*/
|
||||||
|
export function computeEdgeGaps(
|
||||||
|
moving: OpeningSpan,
|
||||||
|
siblings: readonly OpeningSpan[],
|
||||||
|
wall: WallExtent,
|
||||||
|
minGap: number,
|
||||||
|
): EdgeGap[] {
|
||||||
|
const movingLeft = leftEdge(moving)
|
||||||
|
const movingRight = rightEdge(moving)
|
||||||
|
|
||||||
|
// A sibling that straddles one of the moving opening's edges is an OVERLAP,
|
||||||
|
// not a neighbour: there is no clearance on that side, and we must not fall
|
||||||
|
// back to the wall end (which would report a misleading distance measured
|
||||||
|
// "through" the overlapping opening).
|
||||||
|
const leftCrossed = siblings.some((s) => leftEdge(s) < movingLeft && rightEdge(s) > movingLeft)
|
||||||
|
const rightCrossed = siblings.some((s) => leftEdge(s) < movingRight && rightEdge(s) > movingRight)
|
||||||
|
|
||||||
|
let leftNeighbour: { s: number; id: string } | null = null
|
||||||
|
let rightNeighbour: { s: number; id: string } | null = null
|
||||||
|
for (const sib of siblings) {
|
||||||
|
const sibRight = rightEdge(sib)
|
||||||
|
const sibLeft = leftEdge(sib)
|
||||||
|
// Entirely to the left of the moving opening → candidate left neighbour.
|
||||||
|
if (sibRight <= movingLeft && (leftNeighbour === null || sibRight > leftNeighbour.s)) {
|
||||||
|
leftNeighbour = { s: sibRight, id: sib.id }
|
||||||
|
}
|
||||||
|
// Entirely to the right → candidate right neighbour.
|
||||||
|
if (sibLeft >= movingRight && (rightNeighbour === null || sibLeft < rightNeighbour.s)) {
|
||||||
|
rightNeighbour = { s: sibLeft, id: sib.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gaps: EdgeGap[] = []
|
||||||
|
|
||||||
|
if (!leftCrossed) {
|
||||||
|
const leftToS = leftNeighbour ? leftNeighbour.s : 0
|
||||||
|
const leftDistance = movingLeft - leftToS
|
||||||
|
if (leftDistance >= minGap) {
|
||||||
|
gaps.push({
|
||||||
|
side: 'left',
|
||||||
|
distance: leftDistance,
|
||||||
|
fromS: movingLeft,
|
||||||
|
toS: leftToS,
|
||||||
|
target: leftNeighbour ? 'opening' : 'wall-start',
|
||||||
|
targetId: leftNeighbour?.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rightCrossed) {
|
||||||
|
const rightToS = rightNeighbour ? rightNeighbour.s : wall.length
|
||||||
|
const rightDistance = rightToS - movingRight
|
||||||
|
if (rightDistance >= minGap) {
|
||||||
|
gaps.push({
|
||||||
|
side: 'right',
|
||||||
|
distance: rightDistance,
|
||||||
|
fromS: movingRight,
|
||||||
|
toS: rightToS,
|
||||||
|
target: rightNeighbour ? 'opening' : 'wall-end',
|
||||||
|
targetId: rightNeighbour?.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The closest coincidence between any of the moving opening's edges/centre and
|
||||||
|
* any sibling's edges/centre along the wall, within `tolerance`. Edge-to-edge
|
||||||
|
* and centre-to-centre are weighed equally; the single closest pair wins
|
||||||
|
* (matching the one-guide-per-axis behaviour of the floor-plane resolver).
|
||||||
|
*/
|
||||||
|
export function detectAlongWallAlignment(
|
||||||
|
moving: OpeningSpan,
|
||||||
|
siblings: readonly OpeningSpan[],
|
||||||
|
tolerance: number,
|
||||||
|
): AlongWallAlignment | null {
|
||||||
|
let best: AlongWallAlignment | null = null
|
||||||
|
let bestAbs = tolerance
|
||||||
|
for (const movingFeature of ALONG_WALL_FEATURES) {
|
||||||
|
const movingCoord = alongWallFeatureCoord(moving, movingFeature)
|
||||||
|
for (const sib of siblings) {
|
||||||
|
if (sib.id === moving.id) continue
|
||||||
|
for (const targetFeature of ALONG_WALL_FEATURES) {
|
||||||
|
const targetCoord = alongWallFeatureCoord(sib, targetFeature)
|
||||||
|
const diff = targetCoord - movingCoord
|
||||||
|
const abs = Math.abs(diff)
|
||||||
|
if (abs <= bestAbs && (best === null || abs < bestAbs)) {
|
||||||
|
bestAbs = abs
|
||||||
|
best = {
|
||||||
|
s: targetCoord,
|
||||||
|
movingFeature,
|
||||||
|
targetId: sib.id,
|
||||||
|
targetFeature,
|
||||||
|
snap: diff,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The closest coincidence between the moving opening's sill/centre/top and any
|
||||||
|
* sibling's sill/centre/top, within `tolerance` — the "these two windows share
|
||||||
|
* a sill height" detector. Same single-best-match policy as the along-wall
|
||||||
|
* variant.
|
||||||
|
*/
|
||||||
|
export function detectVerticalAlignment(
|
||||||
|
moving: OpeningSpan,
|
||||||
|
siblings: readonly OpeningSpan[],
|
||||||
|
tolerance: number,
|
||||||
|
): VerticalAlignment | null {
|
||||||
|
let best: VerticalAlignment | null = null
|
||||||
|
let bestAbs = tolerance
|
||||||
|
for (const movingFeature of VERTICAL_FEATURES) {
|
||||||
|
const movingCoord = verticalFeatureCoord(moving, movingFeature)
|
||||||
|
for (const sib of siblings) {
|
||||||
|
if (sib.id === moving.id) continue
|
||||||
|
for (const targetFeature of VERTICAL_FEATURES) {
|
||||||
|
const targetCoord = verticalFeatureCoord(sib, targetFeature)
|
||||||
|
const diff = targetCoord - movingCoord
|
||||||
|
const abs = Math.abs(diff)
|
||||||
|
if (abs <= bestAbs && (best === null || abs < bestAbs)) {
|
||||||
|
bestAbs = abs
|
||||||
|
best = {
|
||||||
|
y: targetCoord,
|
||||||
|
movingFeature,
|
||||||
|
targetId: sib.id,
|
||||||
|
targetFeature,
|
||||||
|
snap: diff,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma-style equal-spacing detection: order all openings along the wall, look
|
||||||
|
* at the clearances BETWEEN consecutive openings, and return the longest run of
|
||||||
|
* ≥2 consecutive gaps that are equal within `tolerance` and that the moving
|
||||||
|
* opening participates in (so the badges only appear while the drag is actually
|
||||||
|
* forming or extending a series). Returns null when no such run exists.
|
||||||
|
*
|
||||||
|
* Gaps below `minGap` (touching/overlapping openings) break a run — a row of
|
||||||
|
* flush openings is not "equally spaced".
|
||||||
|
*/
|
||||||
|
export function detectEqualSpacing(
|
||||||
|
allOpenings: readonly OpeningSpan[],
|
||||||
|
movingId: string,
|
||||||
|
tolerance: number,
|
||||||
|
minGap: number,
|
||||||
|
): EqualSpacingRun | null {
|
||||||
|
if (allOpenings.length < 3) return null
|
||||||
|
const sorted = [...allOpenings].sort((a, b) => a.centerS - b.centerS)
|
||||||
|
const movingIndex = sorted.findIndex((s) => s.id === movingId)
|
||||||
|
if (movingIndex < 0) return null
|
||||||
|
|
||||||
|
// Clearance between opening i and i+1.
|
||||||
|
const gaps: { value: number; fromS: number; toS: number }[] = []
|
||||||
|
for (let i = 0; i < sorted.length - 1; i++) {
|
||||||
|
const a = sorted[i]
|
||||||
|
const b = sorted[i + 1]
|
||||||
|
if (!a || !b) continue
|
||||||
|
const fromS = rightEdge(a)
|
||||||
|
const toS = leftEdge(b)
|
||||||
|
gaps.push({ value: toS - fromS, fromS, toS })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longest contiguous window of gaps that are (a) each ≥ minGap and (b)
|
||||||
|
// mutually equal within tolerance (window max − min ≤ tolerance), spanning at
|
||||||
|
// least 2 gaps and including the moving opening. Brute force over windows
|
||||||
|
// (openings per wall are few). A first-gap-anchored greedy scan is NOT
|
||||||
|
// equivalent: it drops a valid run that begins partway through a drifting
|
||||||
|
// sequence — e.g. gaps 1.00, 1.02, 1.04 with the moving opening at the end,
|
||||||
|
// where [1.02, 1.04] is a real run. On a length tie the leftmost window wins,
|
||||||
|
// for determinism.
|
||||||
|
let best: EqualSpacingRun | null = null
|
||||||
|
for (let lo = 0; lo < gaps.length; lo++) {
|
||||||
|
let min = Number.POSITIVE_INFINITY
|
||||||
|
let max = Number.NEGATIVE_INFINITY
|
||||||
|
for (let hi = lo; hi < gaps.length; hi++) {
|
||||||
|
const gap = gaps[hi]
|
||||||
|
if (!gap || gap.value < minGap) break // a sub-minGap gap can't join a run
|
||||||
|
min = Math.min(min, gap.value)
|
||||||
|
max = Math.max(max, gap.value)
|
||||||
|
if (max - min > tolerance) break // extending only widens the spread
|
||||||
|
const gapCount = hi - lo + 1
|
||||||
|
if (gapCount < 2) continue
|
||||||
|
const firstOpening = lo // gap i sits between openings i and i+1
|
||||||
|
const lastOpening = hi + 1
|
||||||
|
if (movingIndex < firstOpening || movingIndex > lastOpening) continue
|
||||||
|
if (best !== null && gapCount <= best.segments.length) continue
|
||||||
|
const windowGaps = gaps.slice(lo, hi + 1)
|
||||||
|
best = {
|
||||||
|
gap: windowGaps.reduce((sum, g) => sum + g.value, 0) / windowGaps.length,
|
||||||
|
segments: windowGaps.map((g) => ({ fromS: g.fromS, toS: g.toS })),
|
||||||
|
openingIds: sorted.slice(firstOpening, lastOpening + 1).map((s) => s.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute every proximity/alignment guide for the moving opening in one pass.
|
||||||
|
* Pure: feed it the moving opening's wall-local span, its same-wall siblings,
|
||||||
|
* and the wall extent; render the result in whichever view.
|
||||||
|
*/
|
||||||
|
export function computeOpeningGuides(input: OpeningGuideInput): OpeningGuides {
|
||||||
|
const tol = { ...DEFAULT_OPENING_GUIDE_TOLERANCES, ...input.tolerances }
|
||||||
|
const { moving, siblings, wall, includeVertical } = input
|
||||||
|
|
||||||
|
const sillHead: SillHeadGuide | null = includeVertical
|
||||||
|
? {
|
||||||
|
sill: bottomEdge(moving),
|
||||||
|
bottomY: bottomEdge(moving),
|
||||||
|
head: wall.height - topEdge(moving),
|
||||||
|
topY: topEdge(moving),
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
return {
|
||||||
|
sillHead,
|
||||||
|
gaps: computeEdgeGaps(moving, siblings, wall, tol.minGap),
|
||||||
|
alongWall: detectAlongWallAlignment(moving, siblings, tol.align),
|
||||||
|
vertical: includeVertical ? detectVerticalAlignment(moving, siblings, tol.align) : null,
|
||||||
|
equalSpacing: detectEqualSpacing(
|
||||||
|
[moving, ...siblings],
|
||||||
|
moving.id,
|
||||||
|
tol.equalSpacing,
|
||||||
|
tol.minGap,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1675,6 +1675,58 @@ function InteractiveGeometry({
|
|||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
case 'equal-spacing-badge': {
|
||||||
|
// A distinct accent (Figma-style "=" rhythm) so equal spacing reads
|
||||||
|
// apart from the orange placement dimensions. Same screen-upright flip
|
||||||
|
// as the dimension-label case above.
|
||||||
|
const accent = '#ec4899'
|
||||||
|
let degrees = (g.angle * 180) / Math.PI
|
||||||
|
let screenDegrees = degrees + sceneRotationDeg
|
||||||
|
screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180
|
||||||
|
if (screenDegrees > 90) degrees -= 180
|
||||||
|
else if (screenDegrees <= -90) degrees += 180
|
||||||
|
|
||||||
|
const label = `= ${g.text}`
|
||||||
|
const padX = unitsPerPixel * 6
|
||||||
|
const padY = unitsPerPixel * 3
|
||||||
|
const fontSize = Math.max(unitsPerPixel * 10, 0.08)
|
||||||
|
const textWidth = label.length * unitsPerPixel * 6.2
|
||||||
|
const plateW = textWidth + padX * 2
|
||||||
|
const plateH = fontSize + padY * 2
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={keyHint}
|
||||||
|
pointerEvents="none"
|
||||||
|
transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${degrees})`}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
fill="#ffffff"
|
||||||
|
height={plateH}
|
||||||
|
opacity={0.95}
|
||||||
|
rx={unitsPerPixel * 3}
|
||||||
|
ry={unitsPerPixel * 3}
|
||||||
|
stroke={accent}
|
||||||
|
strokeWidth={unitsPerPixel * 0.75}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
width={plateW}
|
||||||
|
x={-plateW / 2}
|
||||||
|
y={-plateH / 2}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
dominantBaseline="middle"
|
||||||
|
fill={accent}
|
||||||
|
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
||||||
|
fontSize={fontSize}
|
||||||
|
fontWeight={700}
|
||||||
|
textAnchor="middle"
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
case 'dimension': {
|
case 'dimension': {
|
||||||
if (!palette) return <></>
|
if (!palette) return <></>
|
||||||
const stroke = g.stroke ?? palette.measurementStroke
|
const stroke = g.stroke ?? palette.measurementStroke
|
||||||
@@ -1978,6 +2030,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
|
|||||||
'rotate-arrow',
|
'rotate-arrow',
|
||||||
'dimension',
|
'dimension',
|
||||||
'dimension-label',
|
'dimension-label',
|
||||||
|
'equal-spacing-badge',
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,12 +12,27 @@ import {
|
|||||||
DoubleSide,
|
DoubleSide,
|
||||||
ExtrudeGeometry,
|
ExtrudeGeometry,
|
||||||
type Group,
|
type Group,
|
||||||
|
type Intersection,
|
||||||
|
Mesh,
|
||||||
|
type Raycaster,
|
||||||
Shape,
|
Shape,
|
||||||
TorusGeometry,
|
TorusGeometry,
|
||||||
} from 'three'
|
} from 'three'
|
||||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
|
|
||||||
|
// While a press-drag move is in flight (`placementDragMode`), the move tool
|
||||||
|
// owns the pointer and the handle rig rides the moving node — so a handle hit
|
||||||
|
// area would sit under the cursor and starve the tool's surface raycast
|
||||||
|
// (`wall:move` for openings, `grid:move` for free movers), freezing the drag.
|
||||||
|
// Make every handle hit area inert for the duration; the indicator mesh still
|
||||||
|
// renders (it's already NO_RAYCAST + depthTest off) so the grip stays visible.
|
||||||
|
function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void {
|
||||||
|
if (useEditor.getState().placementDragMode) return
|
||||||
|
Mesh.prototype.raycast.call(this, raycaster, intersects)
|
||||||
|
}
|
||||||
|
|
||||||
export const ARROW_SCALE = 0.65
|
export const ARROW_SCALE = 0.65
|
||||||
export const ARROW_COLOR = '#8381ed'
|
export const ARROW_COLOR = '#8381ed'
|
||||||
@@ -382,6 +397,7 @@ export function InvisibleHandleHitArea({
|
|||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerEnter={onPointerEnter}
|
onPointerEnter={onPointerEnter}
|
||||||
onPointerLeave={onPointerLeave}
|
onPointerLeave={onPointerLeave}
|
||||||
|
raycast={hitAreaRaycast}
|
||||||
renderOrder={HIT_AREA_RENDER_ORDER}
|
renderOrder={HIT_AREA_RENDER_ORDER}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { createEditorApi } from '../../lib/editor-api'
|
|||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
|
import useOpeningGuides from '../../store/use-opening-guides'
|
||||||
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
||||||
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
||||||
import {
|
import {
|
||||||
@@ -70,6 +71,10 @@ const _resizePositionW = new Vector3()
|
|||||||
const _resizeRay = new Ray()
|
const _resizeRay = new Ray()
|
||||||
const _resizeRayW = new Vector3()
|
const _resizeRayW = new Vector3()
|
||||||
|
|
||||||
|
// Tilt that stands a flat XZ-plane move cross up into a node's facing plane
|
||||||
|
// (its local XY = a wall face) for `plane: 'node-normal'` handles.
|
||||||
|
const NODE_NORMAL_TILT: [number, number, number] = [Math.PI / 2, 0, 0]
|
||||||
|
|
||||||
function axisVector(axis: 'x' | 'y' | 'z', target: Vector3) {
|
function axisVector(axis: 'x' | 'y' | 'z', target: Vector3) {
|
||||||
target.set(0, 0, 0)
|
target.set(0, 0, 0)
|
||||||
if (axis === 'x') target.x = 1
|
if (axis === 'x') target.x = 1
|
||||||
@@ -589,6 +594,9 @@ function LinearArrow({
|
|||||||
// floating dimension pill (via `activeHandleDrag`) and its own in-world
|
// floating dimension pill (via `activeHandleDrag`) and its own in-world
|
||||||
// chip is suppressed — matches the wall height handle.
|
// chip is suppressed — matches the wall height handle.
|
||||||
const measureLabel = descriptor.kind === 'linear-resize' ? descriptor.measureLabel : undefined
|
const measureLabel = descriptor.kind === 'linear-resize' ? descriptor.measureLabel : undefined
|
||||||
|
// Optional per-tick feedback hook (doors/windows publish proximity/sill guides
|
||||||
|
// for the edge being resized); cleared when the drag ends.
|
||||||
|
const onDrag = descriptor.kind === 'linear-resize' ? descriptor.onDrag : undefined
|
||||||
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
||||||
const basePosition = descriptor.placement.position(node, placementSceneApi)
|
const basePosition = descriptor.placement.position(node, placementSceneApi)
|
||||||
// `freezeOffset` (in node-local frame) cancels the mesh's `position`
|
// `freezeOffset` (in node-local frame) cancels the mesh's `position`
|
||||||
@@ -671,6 +679,7 @@ function LinearArrow({
|
|||||||
if (measureLabel) {
|
if (measureLabel) {
|
||||||
useEditor.getState().setActiveHandleDrag(null)
|
useEditor.getState().setActiveHandleDrag(null)
|
||||||
}
|
}
|
||||||
|
if (onDrag) useOpeningGuides.getState().clear()
|
||||||
},
|
},
|
||||||
move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
|
move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
|
||||||
const currentPointer =
|
const currentPointer =
|
||||||
@@ -686,7 +695,10 @@ function LinearArrow({
|
|||||||
? snapScalar(rawNext, gridSnapStep)
|
? snapScalar(rawNext, gridSnapStep)
|
||||||
: rawNext
|
: rawNext
|
||||||
const next = Math.min(maxBound, Math.max(minBound, snappedNext))
|
const next = Math.min(maxBound, Math.max(minBound, snappedNext))
|
||||||
return descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
|
const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
|
||||||
|
// Let the kind publish live guides for the edge being resized.
|
||||||
|
onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
|
||||||
|
return patch
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1230,7 +1242,7 @@ function TranslateArrow({
|
|||||||
|
|
||||||
// The cross is built flat in the XZ plane. On a wall, tilt it up about X so
|
// The cross is built flat in the XZ plane. On a wall, tilt it up about X so
|
||||||
// it lies in the item-local XY plane (= the wall face).
|
// it lies in the item-local XY plane (= the wall face).
|
||||||
const iconRotation: [number, number, number] = isWallPlane ? [Math.PI / 2, 0, 0] : [0, 0, 0]
|
const iconRotation: [number, number, number] = isWallPlane ? NODE_NORMAL_TILT : [0, 0, 0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HandleArrow
|
<HandleArrow
|
||||||
@@ -1288,16 +1300,20 @@ function TapActionArrow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default 'arrow' shape — the standard chevron.
|
|
||||||
const baseScale = zoom * ARROW_SCALE
|
const baseScale = zoom * ARROW_SCALE
|
||||||
|
// A `move-cross` with `plane: 'node-normal'` stands up into the node's facing
|
||||||
|
// plane (a wall face) like the door / window / wall-item move grips; other
|
||||||
|
// tap-actions keep their in-plane `rotationY`.
|
||||||
|
const rotation: [number, number, number] =
|
||||||
|
descriptor.plane === 'node-normal' ? NODE_NORMAL_TILT : [0, rotationY, 0]
|
||||||
return (
|
return (
|
||||||
<HandleArrow
|
<HandleArrow
|
||||||
cursor={cursor}
|
cursor={cursor}
|
||||||
hover={isHovered}
|
hover={isHovered}
|
||||||
onHoverChange={setIsHovered}
|
onHoverChange={setIsHovered}
|
||||||
onPointerDown={onActivate}
|
onPointerDown={onActivate}
|
||||||
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
|
placement={{ position, rotation, baseScale }}
|
||||||
shape="chevron"
|
shape={shape === 'move-cross' ? 'move-cross' : 'chevron'}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Html } from '@react-three/drei'
|
||||||
|
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, {
|
||||||
|
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, placement, and resize
|
||||||
|
* interactions 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) => (
|
||||||
|
<OpeningGuide guide={guide} key={guide.id} 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 the THREE.Line once with a preallocated 2-point position buffer and
|
||||||
|
// mount it via <primitive> (the intrinsic <line> JSX element collides with
|
||||||
|
// React's SVG <line>). `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 <primitive object={line} />
|
||||||
|
}
|
||||||
@@ -1101,7 +1101,11 @@ export const SelectionManager = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onEnter = (event: NodeEvent) => {
|
const onEnter = (event: NodeEvent) => {
|
||||||
if (boxSelectHandled) return
|
// A host-driven drag (handle resize/rotate) sets `inputDragging`.
|
||||||
|
// useNodeEvents now emits hover events during such a drag so surface
|
||||||
|
// move tools keep tracking the cursor — but paint preview must not fire
|
||||||
|
// mid-drag, so gate on `inputDragging` here too.
|
||||||
|
if (boxSelectHandled || useViewer.getState().inputDragging) return
|
||||||
|
|
||||||
const interaction = getPaintInteraction(event)
|
const interaction = getPaintInteraction(event)
|
||||||
if (!interaction) return
|
if (!interaction) return
|
||||||
@@ -1665,6 +1669,11 @@ export const SelectionManager = () => {
|
|||||||
if (movingNode || curvingWall || curvingFence) return
|
if (movingNode || curvingWall || curvingFence) return
|
||||||
|
|
||||||
const onEnter = (event: NodeEvent) => {
|
const onEnter = (event: NodeEvent) => {
|
||||||
|
// A host-driven drag (handle resize/rotate, box-select) sets
|
||||||
|
// `inputDragging`. useNodeEvents still emits hover events during it so
|
||||||
|
// surface move tools keep tracking — but the select-hover outline must
|
||||||
|
// stay put, so don't repaint under the cursor mid-drag.
|
||||||
|
if (useViewer.getState().inputDragging) return
|
||||||
const node = event.node
|
const node = event.node
|
||||||
const currentPhase = useEditor.getState().phase
|
const currentPhase = useEditor.getState().phase
|
||||||
|
|
||||||
@@ -1692,6 +1701,7 @@ export const SelectionManager = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onLeave = (event: NodeEvent) => {
|
const onLeave = (event: NodeEvent) => {
|
||||||
|
if (useViewer.getState().inputDragging) return
|
||||||
const nodeId = event?.node?.id
|
const nodeId = event?.node?.id
|
||||||
if (nodeId && useViewer.getState().hoveredId === nodeId) {
|
if (nodeId && useViewer.getState().hoveredId === nodeId) {
|
||||||
useViewer.setState({ hoveredId: null })
|
useViewer.setState({ hoveredId: null })
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -62,14 +62,16 @@ describe('resolveDirectRotationDragDelta', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('canDirectMoveNode', () => {
|
describe('canDirectMoveNode', () => {
|
||||||
test('excludes floorplan-only move targets from 3D direct move', () => {
|
// Accepts kinds with a 3D-mountable move tool (`movable` or
|
||||||
|
// `affordanceTools.move`); floorplan-only movers (zone) are excluded.
|
||||||
|
test('rejects floorplan-only move targets (no 3D tool mounts)', () => {
|
||||||
const kind = 'direct-move-floorplan-only-test'
|
const kind = 'direct-move-floorplan-only-test'
|
||||||
registerTestDefinition(kind, { floorplanMoveTarget: {} as never })
|
registerTestDefinition(kind, { floorplanMoveTarget: {} as never })
|
||||||
|
|
||||||
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('excludes bespoke move tools from 3D direct move', () => {
|
test('accepts kinds with a bespoke move tool', () => {
|
||||||
const kind = 'direct-move-bespoke-tool-test'
|
const kind = 'direct-move-bespoke-tool-test'
|
||||||
registerTestDefinition(kind, {
|
registerTestDefinition(kind, {
|
||||||
affordanceTools: {
|
affordanceTools: {
|
||||||
@@ -77,7 +79,7 @@ describe('canDirectMoveNode', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('accepts nodes with the generic movable capability', () => {
|
test('accepts nodes with the generic movable capability', () => {
|
||||||
@@ -90,4 +92,11 @@ describe('canDirectMoveNode', () => {
|
|||||||
|
|
||||||
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
|
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('rejects kinds with no registered move path', () => {
|
||||||
|
const kind = 'direct-move-none-test'
|
||||||
|
registerTestDefinition(kind, {})
|
||||||
|
|
||||||
|
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
createSceneApi,
|
createSceneApi,
|
||||||
DEFAULT_ANGLE_STEP,
|
DEFAULT_ANGLE_STEP,
|
||||||
type HandleDescriptor,
|
type HandleDescriptor,
|
||||||
|
hasRegistry3DMoveTool,
|
||||||
nodeRegistry,
|
nodeRegistry,
|
||||||
type SceneApi,
|
type SceneApi,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -34,7 +35,10 @@ export function canDirectRotateNode(node: AnyNode): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function canDirectMoveNode(node: AnyNode): boolean {
|
export function canDirectMoveNode(node: AnyNode): boolean {
|
||||||
return nodeRegistry.get(node.type)?.capabilities?.movable !== undefined
|
// 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that
|
||||||
|
// mounts in 3D — distinct from `isRegistryMovable`, which also accepts
|
||||||
|
// floorplan-only movers (zone) for the 2D plan.
|
||||||
|
return hasRegistry3DMoveTool(node.type)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function snapDirectRotationDelta(delta: number, free: boolean): number {
|
export function snapDirectRotationDelta(delta: number, free: boolean): number {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// 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]
|
||||||
|
|
||||||
|
// 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 =
|
||||||
|
// A measured line + distance pill: sill (floor → bottom edge), head (top edge
|
||||||
|
// → wall top), or along-wall edge-to-edge proximity.
|
||||||
|
| { 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'; id: string; from: OpeningGuideVec3; to: OpeningGuideVec3 }
|
||||||
|
// A Figma-style "=" badge marking one gap in an equal-spacing run.
|
||||||
|
| { kind: 'badge'; id: string; at: OpeningGuideVec3; value: number }
|
||||||
|
|
||||||
|
type OpeningGuidesState = {
|
||||||
|
guides: OpeningGuide3D[]
|
||||||
|
set(guides: OpeningGuide3D[]): void
|
||||||
|
clear(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const useOpeningGuides = create<OpeningGuidesState>((set) => ({
|
||||||
|
guides: [],
|
||||||
|
set: (guides) => 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
|
||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
RoofSegmentNode,
|
RoofSegmentNode,
|
||||||
WallNode,
|
WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
|
||||||
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
|
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
|
||||||
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
|
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
|
||||||
import { scaleHandleHeight } from './door-math'
|
import { scaleHandleHeight } from './door-math'
|
||||||
@@ -19,6 +20,9 @@ const SIDE_HANDLE_OFFSET = 0.24
|
|||||||
const HEIGHT_HANDLE_OFFSET = 0.24
|
const HEIGHT_HANDLE_OFFSET = 0.24
|
||||||
const MIN_DOOR_HEIGHT = 0.5
|
const MIN_DOOR_HEIGHT = 0.5
|
||||||
const MIN_DOOR_WIDTH = 0.3
|
const MIN_DOOR_WIDTH = 0.3
|
||||||
|
// How far the move cross floats off the wall face (+Z, the door's facing
|
||||||
|
// normal) so it's grabbable instead of buried in the leaf/frame.
|
||||||
|
const MOVE_HANDLE_LIFT = 0.12
|
||||||
|
|
||||||
function readWallLength(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
|
function readWallLength(door: DoorNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
|
||||||
if (!door.wallId) return Number.POSITIVE_INFINITY
|
if (!door.wallId) return Number.POSITIVE_INFINITY
|
||||||
@@ -53,6 +57,7 @@ function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor<DoorNodeType>
|
|||||||
return readWallLength(n, scene)
|
return readWallLength(n, scene)
|
||||||
},
|
},
|
||||||
currentValue: (n) => n.width,
|
currentValue: (n) => n.width,
|
||||||
|
onDrag: (node) => publishOpeningResizeGuides(node, false),
|
||||||
apply: (initial, newWidth) => {
|
apply: (initial, newWidth) => {
|
||||||
// Anchored edge stays fixed in wall-local coords. Door rotation is
|
// Anchored edge stays fixed in wall-local coords. Door rotation is
|
||||||
// applied by the inner ride group (the renderer mounts a nested
|
// applied by the inner ride group (the renderer mounts a nested
|
||||||
@@ -95,6 +100,7 @@ function doorHeightHandle(): HandleDescriptor<DoorNodeType> {
|
|||||||
return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom)
|
return Math.max(MIN_DOOR_HEIGHT, readWallHeight(n, scene) - bottom)
|
||||||
},
|
},
|
||||||
currentValue: (n) => n.height,
|
currentValue: (n) => n.height,
|
||||||
|
onDrag: (node) => publishOpeningResizeGuides(node, false),
|
||||||
apply: (initial, newHeight) => {
|
apply: (initial, newHeight) => {
|
||||||
const bottom = initial.position[1] - initial.height / 2
|
const bottom = initial.position[1] - initial.height / 2
|
||||||
// Scale the handle so it tracks the door instead of staying glued to a
|
// Scale the handle so it tracks the door instead of staying glued to a
|
||||||
@@ -112,7 +118,26 @@ function doorHeightHandle(): HandleDescriptor<DoorNodeType> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Press-drag move grip at the door centre, standing in the wall face. Routes
|
||||||
|
// through the same move tool as the floating Move button (3D
|
||||||
|
// `affordanceTools.move`, 2D `floorplanMoveTarget`) — wall slide + re-host onto
|
||||||
|
// another wall — but `engageMoveDrag` commits on release, with no second click.
|
||||||
|
function doorMoveHandle(): HandleDescriptor<DoorNodeType> {
|
||||||
|
return {
|
||||||
|
kind: 'tap-action',
|
||||||
|
shape: 'move-cross',
|
||||||
|
plane: 'node-normal',
|
||||||
|
portal: 'grandparent',
|
||||||
|
cursor: 'move',
|
||||||
|
onActivate: (node, _scene, editor) => editor.engageMoveDrag(node),
|
||||||
|
placement: {
|
||||||
|
position: () => [0, 0, MOVE_HANDLE_LIFT],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const doorHandles: HandleDescriptor<DoorNodeType>[] = [
|
const doorHandles: HandleDescriptor<DoorNodeType>[] = [
|
||||||
|
doorMoveHandle(),
|
||||||
doorWidthHandle('left'),
|
doorWidthHandle('left'),
|
||||||
doorWidthHandle('right'),
|
doorWidthHandle('right'),
|
||||||
doorHeightHandle(),
|
doorHeightHandle(),
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ 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,
|
||||||
|
publishOpeningGuidesForWallEvent,
|
||||||
|
} from '../shared/opening-guides-runtime'
|
||||||
import {
|
import {
|
||||||
getRoofWallOpeningCursorPose,
|
getRoofWallOpeningCursorPose,
|
||||||
type RoofWallOpeningTarget,
|
type RoofWallOpeningTarget,
|
||||||
@@ -138,6 +142,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
|
||||||
@@ -266,6 +271,19 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
|||||||
target.cursorRotation,
|
target.cursorRotation,
|
||||||
target.valid,
|
target.valid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
publishOpeningGuidesForWallEvent({
|
||||||
|
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,
|
||||||
|
levelYOffset: getLevelYOffset(),
|
||||||
|
slabElevation: getSlabElevation(target.event),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
const onWallEnter = (event: WallEvent) => {
|
||||||
@@ -485,6 +503,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,
|
||||||
@@ -697,6 +717,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)
|
||||||
@@ -723,6 +744,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}>
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } 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 {
|
||||||
|
clearOpeningGuides3D,
|
||||||
|
publishOpeningGuidesForWallEvent,
|
||||||
|
} from '../shared/opening-guides-runtime'
|
||||||
import {
|
import {
|
||||||
getRoofWallOpeningCursorPose,
|
getRoofWallOpeningCursorPose,
|
||||||
type RoofWallOpeningTarget,
|
type RoofWallOpeningTarget,
|
||||||
@@ -118,6 +122,7 @@ const DoorTool: React.FC = () => {
|
|||||||
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()
|
||||||
setFallbackPose(null)
|
setFallbackPose(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +153,7 @@ const DoorTool: React.FC = () => {
|
|||||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||||
setFallbackPose({ position, rotationY: 0 })
|
setFallbackPose({ position, rotationY: 0 })
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
clearOpeningGuides3D()
|
||||||
}
|
}
|
||||||
|
|
||||||
const showRoofFallbackCursor = (event: RoofEvent) => {
|
const showRoofFallbackCursor = (event: RoofEvent) => {
|
||||||
@@ -254,6 +260,20 @@ const DoorTool: React.FC = () => {
|
|||||||
cursorRotationY,
|
cursorRotationY,
|
||||||
valid,
|
valid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (draftRef.current) {
|
||||||
|
publishOpeningGuidesForWallEvent({
|
||||||
|
wall,
|
||||||
|
movingId: draftRef.current.id,
|
||||||
|
centerS: clampedX,
|
||||||
|
centerY: clampedY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
includeVertical: false,
|
||||||
|
levelYOffset: getLevelYOffset(),
|
||||||
|
slabElevation: getSlabElevationForWall(wall),
|
||||||
|
})
|
||||||
|
}
|
||||||
return { clampedX, clampedY, valid }
|
return { clampedX, clampedY, valid }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +339,7 @@ const DoorTool: React.FC = () => {
|
|||||||
triggerSFX('sfx:structure-build')
|
triggerSFX('sfx:structure-build')
|
||||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
clearOpeningGuides3D()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Direct wall-mesh hover ──────────────────────────────────────
|
// ── Direct wall-mesh hover ──────────────────────────────────────
|
||||||
@@ -467,6 +488,8 @@ const DoorTool: React.FC = () => {
|
|||||||
useScene.getState().createNode(node, segment.id as AnyNodeId)
|
useScene.getState().createNode(node, segment.id as AnyNodeId)
|
||||||
draftRef.current = node
|
draftRef.current = node
|
||||||
}
|
}
|
||||||
|
// Opening guides are wall-specific; clear them while over a roof face.
|
||||||
|
clearOpeningGuides3D()
|
||||||
updateRoofCursor(target, event.node as RoofNode)
|
updateRoofCursor(target, event.node as RoofNode)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
@@ -575,6 +598,7 @@ const DoorTool: React.FC = () => {
|
|||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
hideCursor()
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
clearOpeningGuides3D()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
emitter.off('wall:enter', onWallHover)
|
emitter.off('wall:enter', onWallHover)
|
||||||
emitter.off('wall:move', onWallHover)
|
emitter.off('wall:move', onWallHover)
|
||||||
@@ -590,10 +614,16 @@ const DoorTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Cursor geometry: door outline.
|
// Cursor geometry: door outline. Static dims, so build it once and dispose on
|
||||||
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
|
// unmount rather than reallocating (and orphaning) an EdgesGeometry on every
|
||||||
const edgesGeo = new EdgesGeometry(boxGeo)
|
// re-render during placement.
|
||||||
boxGeo.dispose()
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// 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,
|
||||||
|
sceneRegistry,
|
||||||
|
spatialGridManager,
|
||||||
|
useScene,
|
||||||
|
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[] = []
|
||||||
|
|
||||||
|
// 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.sill > MIN_DIMENSION_M) {
|
||||||
|
out.push({
|
||||||
|
kind: 'dimension',
|
||||||
|
id: 'sill',
|
||||||
|
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',
|
||||||
|
id: 'head',
|
||||||
|
from: toWorld(centerS, guides.sillHead.topY),
|
||||||
|
to: toWorld(centerS, wallHeight),
|
||||||
|
value: guides.sillHead.head,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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',
|
||||||
|
id: 'vertical',
|
||||||
|
from: toWorld(lo, guides.vertical.y),
|
||||||
|
to: toWorld(hi, guides.vertical.y),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guides.equalSpacing) {
|
||||||
|
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: gap,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useOpeningGuides.getState().set(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearOpeningGuides3D(): void {
|
||||||
|
useOpeningGuides.getState().clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wall-local (s along the wall, y above the wall base) → the move tool's render
|
||||||
|
* frame, given the level Y offset + slab elevation. Shared by the wall-event
|
||||||
|
* publisher (which already has them) and the resize publisher (which derives
|
||||||
|
* them from the scene). Same frame as `wallLocalToWorld`. */
|
||||||
|
function makeWallToWorld(wall: WallNode, levelYOffset: number, slabElevation: number): ToWorld {
|
||||||
|
const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
|
||||||
|
const cos = Math.cos(angle)
|
||||||
|
const sin = Math.sin(angle)
|
||||||
|
return (s, y) => [
|
||||||
|
wall.start[0] + s * cos,
|
||||||
|
slabElevation + y + levelYOffset,
|
||||||
|
wall.start[1] + s * sin,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like {@link makeWallToWorld} but derives the level Y + slab elevation from the
|
||||||
|
* scene, for callers without a wall event — i.e. the resize handles. */
|
||||||
|
export function wallToWorld(wall: WallNode): ToWorld {
|
||||||
|
const levelId = wall.parentId as AnyNodeId | undefined
|
||||||
|
const levelYOffset = levelId ? (sceneRegistry.nodes.get(levelId)?.position.y ?? 0) : 0
|
||||||
|
const slabElevation = spatialGridManager.getSlabElevationForWall(
|
||||||
|
wall.parentId ?? '',
|
||||||
|
wall.start,
|
||||||
|
wall.end,
|
||||||
|
)
|
||||||
|
return makeWallToWorld(wall, levelYOffset, slabElevation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish 3D opening guides for an opening being placed or moved on a wall via a
|
||||||
|
* wall event. The caller passes the level Y + slab elevation it already computed
|
||||||
|
* for the drag cursor, so the guides share the cursor's frame exactly — the one
|
||||||
|
* place the door/window move + placement tools publish from.
|
||||||
|
*/
|
||||||
|
export function publishOpeningGuidesForWallEvent(args: {
|
||||||
|
wall: WallNode
|
||||||
|
movingId: string
|
||||||
|
centerS: number
|
||||||
|
centerY: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
includeVertical: boolean
|
||||||
|
levelYOffset: number
|
||||||
|
slabElevation: number
|
||||||
|
}): void {
|
||||||
|
const { wall, levelYOffset, slabElevation, ...rest } = args
|
||||||
|
publishOpeningGuides3D({
|
||||||
|
...rest,
|
||||||
|
wall,
|
||||||
|
nodes: useScene.getState().nodes,
|
||||||
|
toWorld: makeWallToWorld(wall, levelYOffset, slabElevation),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish 3D opening guides for an opening being RESIZED via a handle arrow.
|
||||||
|
* Resolves the host wall + transform from the scene (no wall event), then reuses
|
||||||
|
* the shared publish. Doors pass `includeVertical: false` (they sit on the
|
||||||
|
* floor); windows pass `true` so a height drag also shows the live sill/head.
|
||||||
|
*/
|
||||||
|
export function publishOpeningResizeGuides(
|
||||||
|
node: {
|
||||||
|
id: string
|
||||||
|
parentId?: string | null
|
||||||
|
position: readonly [number, number, number]
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
},
|
||||||
|
includeVertical: boolean,
|
||||||
|
): void {
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
const wall = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined
|
||||||
|
if (wall?.type !== 'wall') return
|
||||||
|
publishOpeningGuides3D({
|
||||||
|
wall,
|
||||||
|
movingId: node.id,
|
||||||
|
centerS: node.position[0],
|
||||||
|
centerY: node.position[1],
|
||||||
|
width: node.width,
|
||||||
|
height: node.height,
|
||||||
|
includeVertical,
|
||||||
|
nodes,
|
||||||
|
toWorld: wallToWorld(wall),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
computeOpeningGuides,
|
||||||
type DoorNode,
|
type DoorNode,
|
||||||
type FloorplanGeometry,
|
type FloorplanGeometry,
|
||||||
|
type FloorplanPoint,
|
||||||
type GeometryContext,
|
type GeometryContext,
|
||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
|
type OpeningSpan,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
type WindowNode,
|
type WindowNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
@@ -49,79 +52,84 @@ export function buildOpeningPlacementDimensions(
|
|||||||
// walls) via ctx.resolve to compute the centroid.
|
// walls) via ctx.resolve to compute the centroid.
|
||||||
const outwardNormal = computeOutwardNormal(wall, ctx, dirX, dirZ)
|
const outwardNormal = computeOutwardNormal(wall, ctx, dirX, dirZ)
|
||||||
|
|
||||||
const halfWidth = opening.width / 2
|
const wallThickness = wall.thickness ?? 0.1
|
||||||
const startDist = opening.position[0] - halfWidth
|
const halfThickness = wallThickness / 2
|
||||||
const endDist = opening.position[0] + halfWidth
|
const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.32
|
||||||
|
|
||||||
// Walk wall.children to find adjacent openings (door OR window).
|
// Outer-face projection for the placement dimensions (so extension lines stay
|
||||||
// ctx.siblings only includes same-kind nodes; doors + windows need
|
// short and the layout matches the legacy treatment); centreline projection
|
||||||
// each other so we go via the parent's children directly.
|
// for the equal-spacing badges, which sit on the solid wall between openings.
|
||||||
|
const facePoint = (along: number): readonly [number, number] => [
|
||||||
|
x1 + dirX * along + outwardNormal[0] * halfThickness,
|
||||||
|
z1 + dirZ * along + outwardNormal[1] * halfThickness,
|
||||||
|
]
|
||||||
|
const centrePoint = (along: number): FloorplanPoint => [x1 + dirX * along, z1 + dirZ * along]
|
||||||
|
const round = (value: number) => Number.parseFloat(value.toFixed(2))
|
||||||
|
|
||||||
|
// This wall's OTHER openings as wall-local spans. `ctx.siblings` only includes
|
||||||
|
// same-kind nodes; doors and windows need each other, so resolve the wall's
|
||||||
|
// children directly.
|
||||||
const childIds = ((wall as unknown as { children?: AnyNodeId[] }).children ?? []) as AnyNodeId[]
|
const childIds = ((wall as unknown as { children?: AnyNodeId[] }).children ?? []) as AnyNodeId[]
|
||||||
let leftBoundary: number | null = null
|
const siblings: OpeningSpan[] = []
|
||||||
let rightBoundary: number | null = null
|
|
||||||
for (const childId of childIds) {
|
for (const childId of childIds) {
|
||||||
if (childId === opening.id) continue
|
if (childId === opening.id) continue
|
||||||
const sibling = ctx.resolve(childId) as AnyNode | undefined
|
const sibling = ctx.resolve(childId) as AnyNode | undefined
|
||||||
if (!sibling || (sibling.type !== 'door' && sibling.type !== 'window')) continue
|
if (!sibling || (sibling.type !== 'door' && sibling.type !== 'window')) continue
|
||||||
const sib = sibling as DoorNode | WindowNode
|
const sib = sibling as DoorNode | WindowNode
|
||||||
const sibStart = sib.position[0] - sib.width / 2
|
siblings.push({
|
||||||
const sibEnd = sib.position[0] + sib.width / 2
|
id: sib.id,
|
||||||
if (sibEnd <= startDist && (leftBoundary === null || sibEnd > leftBoundary)) {
|
centerS: sib.position[0],
|
||||||
leftBoundary = sibEnd
|
width: sib.width,
|
||||||
}
|
centerY: sib.position[1],
|
||||||
if (sibStart >= endDist && (rightBoundary === null || sibStart < rightBoundary)) {
|
height: sib.height,
|
||||||
rightBoundary = sibStart
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftFromDist = leftBoundary ?? 0
|
const guides = computeOpeningGuides({
|
||||||
const rightToDist = rightBoundary ?? wallLength
|
moving: {
|
||||||
|
id: opening.id,
|
||||||
// Place the dimension line at a constant offset from the wall's
|
centerS: opening.position[0],
|
||||||
// outer face — same value the legacy uses for its placement
|
width: opening.width,
|
||||||
// measurements (`FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET`). The
|
centerY: opening.position[1],
|
||||||
// dimension's `start` / `end` are points on that outer face (not
|
height: opening.height,
|
||||||
// the wall centerline), so the extension lines stay short and the
|
},
|
||||||
// overall layout matches the legacy treatment 1:1.
|
siblings,
|
||||||
const wallThickness = wall.thickness ?? 0.1
|
wall: { length: wallLength, height: wall.height ?? 2.5 },
|
||||||
const halfThickness = wallThickness / 2
|
// The 2D plan is top-down: sill/head height and vertical alignment aren't
|
||||||
const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.32
|
// representable here — those belong to the 3D viewport.
|
||||||
|
includeVertical: false,
|
||||||
// Project a point on the wall axis at distance `along` onto the
|
})
|
||||||
// wall's outer face by adding `halfThickness * outwardNormal`.
|
|
||||||
const facePoint = (along: number): readonly [number, number] => [
|
|
||||||
x1 + dirX * along + outwardNormal[0] * halfThickness,
|
|
||||||
z1 + dirZ * along + outwardNormal[1] * halfThickness,
|
|
||||||
]
|
|
||||||
|
|
||||||
const out: FloorplanGeometry[] = []
|
const out: FloorplanGeometry[] = []
|
||||||
|
|
||||||
const leftDistance = startDist - leftFromDist
|
// Edge-to-edge clearance to the nearest neighbour (or wall end) on each side.
|
||||||
if (leftDistance >= 0.01) {
|
for (const gap of guides.gaps) {
|
||||||
|
const lo = Math.min(gap.fromS, gap.toS)
|
||||||
|
const hi = Math.max(gap.fromS, gap.toS)
|
||||||
out.push({
|
out.push({
|
||||||
kind: 'dimension',
|
kind: 'dimension',
|
||||||
start: facePoint(leftFromDist),
|
start: facePoint(lo),
|
||||||
end: facePoint(startDist),
|
end: facePoint(hi),
|
||||||
offsetNormal: outwardNormal,
|
offsetNormal: outwardNormal,
|
||||||
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
|
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
|
||||||
extensionOvershoot: 0.12,
|
extensionOvershoot: 0.12,
|
||||||
text: `${Number.parseFloat(leftDistance.toFixed(2))}m`,
|
text: `${round(gap.distance)}m`,
|
||||||
stroke: '#f97316',
|
stroke: '#f97316',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const rightDistance = rightToDist - endDist
|
// Equal-spacing rhythm — a "=" badge per equal gap, on the wall centreline.
|
||||||
if (rightDistance >= 0.01) {
|
if (guides.equalSpacing) {
|
||||||
out.push({
|
const wallAngle = Math.atan2(dz, dx)
|
||||||
kind: 'dimension',
|
const text = `${round(guides.equalSpacing.gap)}m`
|
||||||
start: facePoint(endDist),
|
for (const seg of guides.equalSpacing.segments) {
|
||||||
end: facePoint(rightToDist),
|
out.push({
|
||||||
offsetNormal: outwardNormal,
|
kind: 'equal-spacing-badge',
|
||||||
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
|
point: centrePoint((seg.fromS + seg.toS) / 2),
|
||||||
extensionOvershoot: 0.12,
|
text,
|
||||||
text: `${Number.parseFloat(rightDistance.toFixed(2))}m`,
|
angle: wallAngle,
|
||||||
stroke: '#f97316',
|
})
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
WallNode,
|
WallNode,
|
||||||
WindowNode as WindowNodeType,
|
WindowNode as WindowNodeType,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
|
||||||
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
|
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
|
||||||
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
|
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
|
||||||
import { buildWindowFloorplan } from './floorplan'
|
import { buildWindowFloorplan } from './floorplan'
|
||||||
@@ -18,6 +19,9 @@ const SIDE_HANDLE_OFFSET = 0.24
|
|||||||
const HEIGHT_HANDLE_OFFSET = 0.24
|
const HEIGHT_HANDLE_OFFSET = 0.24
|
||||||
const MIN_WINDOW_HEIGHT = 0.3
|
const MIN_WINDOW_HEIGHT = 0.3
|
||||||
const MIN_WINDOW_WIDTH = 0.3
|
const MIN_WINDOW_WIDTH = 0.3
|
||||||
|
// How far the move cross floats off the wall face (+Z, the window's facing
|
||||||
|
// normal) so it's grabbable instead of buried in the sash/frame.
|
||||||
|
const MOVE_HANDLE_LIFT = 0.12
|
||||||
|
|
||||||
function readWallLength(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
|
function readWallLength(w: WindowNodeType, scene: { get: (id: AnyNodeId) => unknown }): number {
|
||||||
if (!w.wallId) return Number.POSITIVE_INFINITY
|
if (!w.wallId) return Number.POSITIVE_INFINITY
|
||||||
@@ -47,6 +51,7 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor<WindowNodeT
|
|||||||
return readWallLength(n, scene)
|
return readWallLength(n, scene)
|
||||||
},
|
},
|
||||||
currentValue: (n) => n.width,
|
currentValue: (n) => n.width,
|
||||||
|
onDrag: (node) => publishOpeningResizeGuides(node, true),
|
||||||
apply: (initial, newWidth) => {
|
apply: (initial, newWidth) => {
|
||||||
const rotY = initial.rotation[1]
|
const rotY = initial.rotation[1]
|
||||||
const armX = Math.cos(rotY)
|
const armX = Math.cos(rotY)
|
||||||
@@ -94,6 +99,7 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor<WindowNode
|
|||||||
: Math.max(MIN_WINDOW_HEIGHT, anchored)
|
: Math.max(MIN_WINDOW_HEIGHT, anchored)
|
||||||
},
|
},
|
||||||
currentValue: (n) => n.height,
|
currentValue: (n) => n.height,
|
||||||
|
onDrag: (node) => publishOpeningResizeGuides(node, true),
|
||||||
apply: (initial, newHeight) => {
|
apply: (initial, newHeight) => {
|
||||||
// Anchored edge stays in wall-local Y; opposite edge moves.
|
// Anchored edge stays in wall-local Y; opposite edge moves.
|
||||||
const anchorY =
|
const anchorY =
|
||||||
@@ -113,7 +119,26 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor<WindowNode
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Press-drag move grip at the window centre, standing in the wall face. Routes
|
||||||
|
// through the same move tool as the floating Move button (3D
|
||||||
|
// `affordanceTools.move`, 2D `floorplanMoveTarget`) — slide within the wall
|
||||||
|
// plane + re-host onto another wall — committing on release, no second click.
|
||||||
|
function windowMoveHandle(): HandleDescriptor<WindowNodeType> {
|
||||||
|
return {
|
||||||
|
kind: 'tap-action',
|
||||||
|
shape: 'move-cross',
|
||||||
|
plane: 'node-normal',
|
||||||
|
portal: 'grandparent',
|
||||||
|
cursor: 'move',
|
||||||
|
onActivate: (node, _scene, editor) => editor.engageMoveDrag(node),
|
||||||
|
placement: {
|
||||||
|
position: () => [0, 0, MOVE_HANDLE_LIFT],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const windowHandles: HandleDescriptor<WindowNodeType>[] = [
|
const windowHandles: HandleDescriptor<WindowNodeType>[] = [
|
||||||
|
windowMoveHandle(),
|
||||||
windowWidthHandle('left'),
|
windowWidthHandle('left'),
|
||||||
windowWidthHandle('right'),
|
windowWidthHandle('right'),
|
||||||
windowHeightHandle('top'),
|
windowHeightHandle('top'),
|
||||||
|
|||||||
@@ -30,6 +30,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,
|
||||||
|
publishOpeningGuidesForWallEvent,
|
||||||
|
resolveSillSnap,
|
||||||
|
} from '../shared/opening-guides-runtime'
|
||||||
import {
|
import {
|
||||||
getRoofWallOpeningCursorPose,
|
getRoofWallOpeningCursorPose,
|
||||||
type RoofWallOpeningTarget,
|
type RoofWallOpeningTarget,
|
||||||
@@ -172,6 +177,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
|
||||||
@@ -230,8 +236,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,
|
||||||
@@ -308,6 +327,18 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
target.cursorRotation,
|
target.cursorRotation,
|
||||||
target.valid,
|
target.valid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
publishOpeningGuidesForWallEvent({
|
||||||
|
wall: target.wallNode,
|
||||||
|
movingId: movingWindowNode.id,
|
||||||
|
centerS: target.clampedX,
|
||||||
|
centerY: target.clampedY,
|
||||||
|
width: movingWindowNode.width,
|
||||||
|
height: movingWindowNode.height,
|
||||||
|
includeVertical: true,
|
||||||
|
levelYOffset: getLevelYOffset(),
|
||||||
|
slabElevation: getSlabElevation(target.event),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onWallEnter = (event: WallEvent) => {
|
const onWallEnter = (event: WallEvent) => {
|
||||||
@@ -531,6 +562,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,
|
||||||
@@ -739,6 +772,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)
|
||||||
@@ -765,6 +799,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}>
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } 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 {
|
||||||
|
clearOpeningGuides3D,
|
||||||
|
publishOpeningGuidesForWallEvent,
|
||||||
|
resolveSillSnap,
|
||||||
|
} from '../shared/opening-guides-runtime'
|
||||||
import {
|
import {
|
||||||
getRoofWallOpeningCursorPose,
|
getRoofWallOpeningCursorPose,
|
||||||
type RoofWallOpeningTarget,
|
type RoofWallOpeningTarget,
|
||||||
@@ -124,6 +129,7 @@ const WindowTool: React.FC = () => {
|
|||||||
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()
|
||||||
setFallbackPose(null)
|
setFallbackPose(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +161,7 @@ const WindowTool: React.FC = () => {
|
|||||||
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
|
||||||
setFallbackPose({ position, rotationY: 0 })
|
setFallbackPose({ position, rotationY: 0 })
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
clearOpeningGuides3D()
|
||||||
}
|
}
|
||||||
|
|
||||||
const showRoofFallbackCursor = (event: RoofEvent) => {
|
const showRoofFallbackCursor = (event: RoofEvent) => {
|
||||||
@@ -167,6 +174,32 @@ const WindowTool: React.FC = () => {
|
|||||||
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z])
|
showGhostAt([x, getLevelYOffset() + FALLBACK_HEIGHT / 2 + FALLBACK_SILL_LIFT, z])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sill alignment (snap + guide): a sibling sill/centre/top wins over the
|
||||||
|
// 0.5m grid when within threshold; Shift bypasses both. `movingId` is the
|
||||||
|
// draft's id once it exists (so it's excluded from the sibling scan), or ''
|
||||||
|
// before the draft is created (nothing to exclude yet).
|
||||||
|
const resolvePlacementY = (args: {
|
||||||
|
wall: WallNode
|
||||||
|
movingId: string
|
||||||
|
localX: number
|
||||||
|
rawLocalY: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
bypassSnap: boolean
|
||||||
|
}): number => {
|
||||||
|
if (args.bypassSnap) return args.rawLocalY
|
||||||
|
const sillY = resolveSillSnap({
|
||||||
|
wall: args.wall,
|
||||||
|
movingId: args.movingId,
|
||||||
|
localX: args.localX,
|
||||||
|
localY: args.rawLocalY,
|
||||||
|
width: args.width,
|
||||||
|
height: args.height,
|
||||||
|
nodes: useScene.getState().nodes,
|
||||||
|
})
|
||||||
|
return sillY ?? snapToHalf(args.rawLocalY)
|
||||||
|
}
|
||||||
|
|
||||||
// Settle a wall target: alignment snap → sill clamp → overlap check.
|
// Settle a wall target: alignment snap → sill clamp → overlap check.
|
||||||
const resolveWallPlacement = (
|
const resolveWallPlacement = (
|
||||||
wall: WallNode,
|
wall: WallNode,
|
||||||
@@ -186,7 +219,15 @@ const WindowTool: React.FC = () => {
|
|||||||
bypass,
|
bypass,
|
||||||
bypassSnap,
|
bypassSnap,
|
||||||
})
|
})
|
||||||
const localY = bypassSnap ? rawLocalY : snapToHalf(rawLocalY)
|
const localY = resolvePlacementY({
|
||||||
|
wall,
|
||||||
|
movingId: ignoreId ?? '',
|
||||||
|
localX,
|
||||||
|
rawLocalY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
bypassSnap,
|
||||||
|
})
|
||||||
const { clampedX, clampedY } = clampToWall(wall, localX, localY, width, height)
|
const { clampedX, clampedY } = clampToWall(wall, localX, localY, width, height)
|
||||||
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
|
const valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
|
||||||
return { clampedX, clampedY, valid }
|
return { clampedX, clampedY, valid }
|
||||||
@@ -274,6 +315,20 @@ const WindowTool: React.FC = () => {
|
|||||||
cursorRotationY,
|
cursorRotationY,
|
||||||
valid,
|
valid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (draftRef.current) {
|
||||||
|
publishOpeningGuidesForWallEvent({
|
||||||
|
wall,
|
||||||
|
movingId: draftRef.current.id,
|
||||||
|
centerS: clampedX,
|
||||||
|
centerY: clampedY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
includeVertical: true,
|
||||||
|
levelYOffset: getLevelYOffset(),
|
||||||
|
slabElevation: getSlabElevationForWall(wall),
|
||||||
|
})
|
||||||
|
}
|
||||||
return { clampedX, clampedY, valid }
|
return { clampedX, clampedY, valid }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +388,7 @@ const WindowTool: React.FC = () => {
|
|||||||
triggerSFX('sfx:structure-build')
|
triggerSFX('sfx:structure-build')
|
||||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
clearOpeningGuides3D()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Direct wall-mesh hover ──────────────────────────────────────
|
// ── Direct wall-mesh hover ──────────────────────────────────────
|
||||||
@@ -488,6 +544,8 @@ const WindowTool: React.FC = () => {
|
|||||||
useScene.getState().createNode(node, segment.id as AnyNodeId)
|
useScene.getState().createNode(node, segment.id as AnyNodeId)
|
||||||
draftRef.current = node
|
draftRef.current = node
|
||||||
}
|
}
|
||||||
|
// Opening guides are wall-specific; clear them while over a roof face.
|
||||||
|
clearOpeningGuides3D()
|
||||||
updateRoofCursor(target, event.node as RoofNode)
|
updateRoofCursor(target, event.node as RoofNode)
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
@@ -590,6 +648,7 @@ const WindowTool: React.FC = () => {
|
|||||||
destroyDraft()
|
destroyDraft()
|
||||||
hideCursor()
|
hideCursor()
|
||||||
useAlignmentGuides.getState().clear()
|
useAlignmentGuides.getState().clear()
|
||||||
|
clearOpeningGuides3D()
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
emitter.off('wall:enter', onWallHover)
|
emitter.off('wall:enter', onWallHover)
|
||||||
emitter.off('wall:move', onWallHover)
|
emitter.off('wall:move', onWallHover)
|
||||||
@@ -605,10 +664,16 @@ const WindowTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Cursor geometry: window outline rectangle.
|
// Cursor geometry: window outline rectangle. Static dims, so build it once and
|
||||||
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
|
// dispose on unmount rather than reallocating (and orphaning) an EdgesGeometry
|
||||||
const edgesGeo = new EdgesGeometry(boxGeo)
|
// on every re-render during placement.
|
||||||
boxGeo.dispose()
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -36,52 +36,60 @@ export function useNodeEvents<K extends AnyNodeType>(node: NodeByKind<K>, type:
|
|||||||
emitter.emit(eventKey, payload as never)
|
emitter.emit(eventKey, payload as never)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suppress node pointer events while an interaction drag is in
|
// Camera drags (orbit / pan / dolly) suppress ALL node pointer events.
|
||||||
// progress. `cameraDragging` covers orbit/pan/dolly; `inputDragging`
|
//
|
||||||
// covers host-driven drags (editor handle arrows etc.). Without
|
// `inputDragging` (host-driven drags: handle arrows, press-drag moves)
|
||||||
// this, the synthesized click on pointerup would reroute selection
|
// additionally suppresses the SELECTION events — without it the click
|
||||||
// to whatever mesh the cursor lands on at release.
|
// synthesized on pointer-release would reroute selection to whatever mesh
|
||||||
const isInteractionActive = () => {
|
// sits under the cursor at release. It must NOT suppress the SPATIAL events
|
||||||
|
// (`enter` / `move` / `leave`): a surface-following move tool — a door /
|
||||||
|
// window sliding along a wall — runs WITH `inputDragging` set and depends on
|
||||||
|
// those events to track the cursor. Consumers that should ignore drag-time
|
||||||
|
// spatial events gate on `inputDragging` themselves (the editor's hover and
|
||||||
|
// paint paths, box-select), so emitting them during a drag only reaches the
|
||||||
|
// active move tool that wants them.
|
||||||
|
const spatialSuppressed = () => useViewer.getState().cameraDragging
|
||||||
|
const selectionSuppressed = () => {
|
||||||
const s = useViewer.getState()
|
const s = useViewer.getState()
|
||||||
return s.cameraDragging || s.inputDragging
|
return s.cameraDragging || s.inputDragging
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onPointerDown: (e: ThreeEvent<PointerEvent>) => {
|
onPointerDown: (e: ThreeEvent<PointerEvent>) => {
|
||||||
if (isInteractionActive()) return
|
if (selectionSuppressed()) return
|
||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
emit('pointerdown', e)
|
emit('pointerdown', e)
|
||||||
},
|
},
|
||||||
onPointerUp: (e: ThreeEvent<PointerEvent>) => {
|
onPointerUp: (e: ThreeEvent<PointerEvent>) => {
|
||||||
if (isInteractionActive()) return
|
if (selectionSuppressed()) return
|
||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
emit('pointerup', e)
|
emit('pointerup', e)
|
||||||
// Synthesize a click event on pointer up to be more forgiving than R3F's default onClick
|
// Synthesize a click event on pointer up to be more forgiving than R3F's default onClick
|
||||||
// which often fails if the mouse moves even 1 pixel.
|
// which often fails if the mouse moves even 1 pixel.
|
||||||
emit('click', e)
|
emit('click', e)
|
||||||
},
|
},
|
||||||
onClick: (e: ThreeEvent<PointerEvent>) => {
|
onClick: (_e: ThreeEvent<PointerEvent>) => {
|
||||||
// Disable default R3F click since we synthesize it on pointerup
|
// Disable default R3F click since we synthesize it on pointerup
|
||||||
// This prevents double-clicks from firing twice.
|
// This prevents double-clicks from firing twice.
|
||||||
},
|
},
|
||||||
onPointerEnter: (e: ThreeEvent<PointerEvent>) => {
|
onPointerEnter: (e: ThreeEvent<PointerEvent>) => {
|
||||||
if (isInteractionActive()) return
|
if (spatialSuppressed()) return
|
||||||
emit('enter', e)
|
emit('enter', e)
|
||||||
},
|
},
|
||||||
onPointerLeave: (e: ThreeEvent<PointerEvent>) => {
|
onPointerLeave: (e: ThreeEvent<PointerEvent>) => {
|
||||||
if (isInteractionActive()) return
|
if (spatialSuppressed()) return
|
||||||
emit('leave', e)
|
emit('leave', e)
|
||||||
},
|
},
|
||||||
onPointerMove: (e: ThreeEvent<PointerEvent>) => {
|
onPointerMove: (e: ThreeEvent<PointerEvent>) => {
|
||||||
if (isInteractionActive()) return
|
if (spatialSuppressed()) return
|
||||||
emit('move', e)
|
emit('move', e)
|
||||||
},
|
},
|
||||||
onDoubleClick: (e: ThreeEvent<PointerEvent>) => {
|
onDoubleClick: (e: ThreeEvent<PointerEvent>) => {
|
||||||
if (isInteractionActive()) return
|
if (selectionSuppressed()) return
|
||||||
emit('double-click', e)
|
emit('double-click', e)
|
||||||
},
|
},
|
||||||
onContextMenu: (e: ThreeEvent<PointerEvent>) => {
|
onContextMenu: (e: ThreeEvent<PointerEvent>) => {
|
||||||
if (isInteractionActive()) return
|
if (selectionSuppressed()) return
|
||||||
emit('context-menu', e)
|
emit('context-menu', e)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user