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:
Aymeric Rabot
2026-06-15 13:01:32 -04:00
committed by GitHub
26 changed files with 1568 additions and 87 deletions
+8
View File
@@ -116,6 +116,14 @@ export type LinearResizeHandle<N> = {
anchor: HandleAnchor
currentValue: (node: N) => number
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
* committed write both land on the SELECTED node. When this returns
+1
View File
@@ -18,6 +18,7 @@ export {
discoverPlugins,
getHostRefFields,
getSelectableKinds,
hasRegistry3DMoveTool,
isDrawnViaTool,
isDrawnViaToolKind,
isPresettable,
+14
View File
@@ -146,6 +146,20 @@ export function isRegistryMovable(kind: string): boolean {
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
* explicit `capabilities.presettable` boolean wins; otherwise the kind
+14
View File
@@ -440,6 +440,20 @@ export type FloorplanGeometry =
/** Rotation in radians. The renderer auto-flips to keep text upright. */
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
* endpoints out past the dimension line, two dimension line halves
+20
View File
@@ -49,6 +49,26 @@ export {
moveToward,
resolveMovable,
} 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 {
DEFAULT_ANGLE_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 02 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>
)
}
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': {
if (!palette) return <></>
const stroke = g.stroke ?? palette.measurementStroke
@@ -1978,6 +2030,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
'rotate-arrow',
'dimension',
'dimension-label',
'equal-spacing-badge',
])
/**
@@ -12,12 +12,27 @@ import {
DoubleSide,
ExtrudeGeometry,
type Group,
type Intersection,
Mesh,
type Raycaster,
Shape,
TorusGeometry,
} from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { MeshBasicNodeMaterial } from 'three/webgpu'
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_COLOR = '#8381ed'
@@ -382,6 +397,7 @@ export function InvisibleHandleHitArea({
onPointerDown={onPointerDown}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
raycast={hitAreaRaycast}
renderOrder={HIT_AREA_RENDER_ORDER}
scale={scale}
/>
@@ -47,6 +47,7 @@ import { createEditorApi } from '../../lib/editor-api'
import { sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
import useEditor from '../../store/use-editor'
import useOpeningGuides from '../../store/use-opening-guides'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { formatAngleRadians } from '../tools/shared/segment-angle'
import {
@@ -70,6 +71,10 @@ const _resizePositionW = new Vector3()
const _resizeRay = new Ray()
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) {
target.set(0, 0, 0)
if (axis === 'x') target.x = 1
@@ -589,6 +594,9 @@ function LinearArrow({
// floating dimension pill (via `activeHandleDrag`) and its own in-world
// chip is suppressed — matches the wall height handle.
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 basePosition = descriptor.placement.position(node, placementSceneApi)
// `freezeOffset` (in node-local frame) cancels the mesh's `position`
@@ -671,6 +679,7 @@ function LinearArrow({
if (measureLabel) {
useEditor.getState().setActiveHandleDrag(null)
}
if (onDrag) useOpeningGuides.getState().clear()
},
move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
const currentPointer =
@@ -686,7 +695,10 @@ function LinearArrow({
? snapScalar(rawNext, gridSnapStep)
: rawNext
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
// 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 (
<HandleArrow
@@ -1288,16 +1300,20 @@ function TapActionArrow({
)
}
// Default 'arrow' shape — the standard chevron.
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 (
<HandleArrow
cursor={cursor}
hover={isHovered}
onHoverChange={setIsHovered}
onPointerDown={onActivate}
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
shape="chevron"
placement={{ position, rotation, baseScale }}
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) => {
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)
if (!interaction) return
@@ -1665,6 +1669,11 @@ export const SelectionManager = () => {
if (movingNode || curvingWall || curvingFence) return
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 currentPhase = useEditor.getState().phase
@@ -1692,6 +1701,7 @@ export const SelectionManager = () => {
}
const onLeave = (event: NodeEvent) => {
if (useViewer.getState().inputDragging) return
const nodeId = event?.node?.id
if (nodeId && useViewer.getState().hoveredId === nodeId) {
useViewer.setState({ hoveredId: null })
@@ -10,6 +10,7 @@ import { useViewer } from '@pascal-app/viewer'
import { type ComponentType, lazy, Suspense } from 'react'
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer'
import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
import { ElevatorTool } from './elevator/elevator-tool'
import { MoveTool } from './item/move-tool'
@@ -283,6 +284,9 @@ export const ToolManager: React.FC = () => {
tools above. Lives inside the building-local group so the
building-local guide coords render at the right world position. */}
<Alignment3DGuideLayer />
{/* Wall-plane proximity / sill / equal-spacing guides for openings,
published by the door/window move tools in the same world frame. */}
<OpeningGuides3DLayer />
{/* "Magnetic" beacon at the active wall-draft snap point. */}
<WallSnapBeaconLayer />
</group>
+5
View File
@@ -300,6 +300,11 @@ export type {
WorkspaceMode,
} from './store/use-editor'
export { default as useEditor } from './store/use-editor'
export {
default as useOpeningGuides,
type OpeningGuide3D,
type OpeningGuideVec3,
} from './store/use-opening-guides'
export {
type PaletteView,
type PaletteViewProps,
@@ -62,14 +62,16 @@ describe('resolveDirectRotationDragDelta', () => {
})
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'
registerTestDefinition(kind, { floorplanMoveTarget: {} as never })
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'
registerTestDefinition(kind, {
affordanceTools: {
@@ -77,7 +79,7 @@ describe('canDirectMoveNode', () => {
} 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', () => {
@@ -90,4 +92,11 @@ describe('canDirectMoveNode', () => {
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,
DEFAULT_ANGLE_STEP,
type HandleDescriptor,
hasRegistry3DMoveTool,
nodeRegistry,
type SceneApi,
useScene,
@@ -34,7 +35,10 @@ export function canDirectRotateNode(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 {
@@ -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
+25
View File
@@ -6,6 +6,7 @@ import type {
RoofSegmentNode,
WallNode,
} from '@pascal-app/core'
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
import { scaleHandleHeight } from './door-math'
@@ -19,6 +20,9 @@ const SIDE_HANDLE_OFFSET = 0.24
const HEIGHT_HANDLE_OFFSET = 0.24
const MIN_DOOR_HEIGHT = 0.5
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 {
if (!door.wallId) return Number.POSITIVE_INFINITY
@@ -53,6 +57,7 @@ function doorWidthHandle(side: 'left' | 'right'): HandleDescriptor<DoorNodeType>
return readWallLength(n, scene)
},
currentValue: (n) => n.width,
onDrag: (node) => publishOpeningResizeGuides(node, false),
apply: (initial, newWidth) => {
// Anchored edge stays fixed in wall-local coords. Door rotation is
// 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)
},
currentValue: (n) => n.height,
onDrag: (node) => publishOpeningResizeGuides(node, false),
apply: (initial, newHeight) => {
const bottom = initial.position[1] - initial.height / 2
// 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>[] = [
doorMoveHandle(),
doorWidthHandle('left'),
doorWidthHandle('right'),
doorHeightHandle(),
+22
View File
@@ -29,6 +29,10 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
clearOpeningGuides3D,
publishOpeningGuidesForWallEvent,
} from '../shared/opening-guides-runtime'
import {
getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget,
@@ -138,6 +142,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
}
// Alignment candidates — anchors of every OTHER alignable object (the
@@ -266,6 +271,19 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
target.cursorRotation,
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) => {
@@ -485,6 +503,8 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
lastTarget = null
lastRoofEvent = event
useLiveTransforms.getState().clear(movingDoorNode.id)
// Opening guides are wall-specific; clear them when over a roof face.
clearOpeningGuides3D()
if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingDoorNode.id, {
position: target.position,
@@ -697,6 +717,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
}
useLiveTransforms.getState().clear(movingDoorNode.id)
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
@@ -723,6 +744,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
boxGeo.dispose()
return geo
}, [movingDoorNode])
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return (
<group ref={cursorGroupRef} visible={false}>
+34 -4
View File
@@ -26,6 +26,10 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
clearOpeningGuides3D,
publishOpeningGuidesForWallEvent,
} from '../shared/opening-guides-runtime'
import {
getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget,
@@ -118,6 +122,7 @@ const DoorTool: React.FC = () => {
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
setFallbackPose(null)
}
@@ -148,6 +153,7 @@ const DoorTool: React.FC = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
setFallbackPose({ position, rotationY: 0 })
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
}
const showRoofFallbackCursor = (event: RoofEvent) => {
@@ -254,6 +260,20 @@ const DoorTool: React.FC = () => {
cursorRotationY,
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 }
}
@@ -319,6 +339,7 @@ const DoorTool: React.FC = () => {
triggerSFX('sfx:structure-build')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
}
// ── Direct wall-mesh hover ──────────────────────────────────────
@@ -467,6 +488,8 @@ const DoorTool: React.FC = () => {
useScene.getState().createNode(node, segment.id as AnyNodeId)
draftRef.current = node
}
// Opening guides are wall-specific; clear them while over a roof face.
clearOpeningGuides3D()
updateRoofCursor(target, event.node as RoofNode)
event.stopPropagation()
}
@@ -575,6 +598,7 @@ const DoorTool: React.FC = () => {
destroyDraft()
hideCursor()
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallHover)
emitter.off('wall:move', onWallHover)
@@ -590,10 +614,16 @@ const DoorTool: React.FC = () => {
}
}, [])
// Cursor geometry: door outline.
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
const edgesGeo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
// Cursor geometry: door outline. Static dims, so build it once and dispose on
// unmount rather than reallocating (and orphaning) an EdgesGeometry on every
// re-render during placement.
const edgesGeo = useMemo(() => {
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
const geo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
return geo
}, [])
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return (
<>
@@ -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 {
type AnyNode,
type AnyNodeId,
computeOpeningGuides,
type DoorNode,
type FloorplanGeometry,
type FloorplanPoint,
type GeometryContext,
isCurvedWall,
type OpeningSpan,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
@@ -49,79 +52,84 @@ export function buildOpeningPlacementDimensions(
// walls) via ctx.resolve to compute the centroid.
const outwardNormal = computeOutwardNormal(wall, ctx, dirX, dirZ)
const halfWidth = opening.width / 2
const startDist = opening.position[0] - halfWidth
const endDist = opening.position[0] + halfWidth
const wallThickness = wall.thickness ?? 0.1
const halfThickness = wallThickness / 2
const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.32
// Walk wall.children to find adjacent openings (door OR window).
// ctx.siblings only includes same-kind nodes; doors + windows need
// each other so we go via the parent's children directly.
// Outer-face projection for the placement dimensions (so extension lines stay
// short and the layout matches the legacy treatment); centreline projection
// 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[]
let leftBoundary: number | null = null
let rightBoundary: number | null = null
const siblings: OpeningSpan[] = []
for (const childId of childIds) {
if (childId === opening.id) continue
const sibling = ctx.resolve(childId) as AnyNode | undefined
if (!sibling || (sibling.type !== 'door' && sibling.type !== 'window')) continue
const sib = sibling as DoorNode | WindowNode
const sibStart = sib.position[0] - sib.width / 2
const sibEnd = sib.position[0] + sib.width / 2
if (sibEnd <= startDist && (leftBoundary === null || sibEnd > leftBoundary)) {
leftBoundary = sibEnd
}
if (sibStart >= endDist && (rightBoundary === null || sibStart < rightBoundary)) {
rightBoundary = sibStart
}
siblings.push({
id: sib.id,
centerS: sib.position[0],
width: sib.width,
centerY: sib.position[1],
height: sib.height,
})
}
const leftFromDist = leftBoundary ?? 0
const rightToDist = rightBoundary ?? wallLength
// Place the dimension line at a constant offset from the wall's
// outer face — same value the legacy uses for its placement
// measurements (`FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET`). The
// dimension's `start` / `end` are points on that outer face (not
// the wall centerline), so the extension lines stay short and the
// overall layout matches the legacy treatment 1:1.
const wallThickness = wall.thickness ?? 0.1
const halfThickness = wallThickness / 2
const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.32
// 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 guides = computeOpeningGuides({
moving: {
id: opening.id,
centerS: opening.position[0],
width: opening.width,
centerY: opening.position[1],
height: opening.height,
},
siblings,
wall: { length: wallLength, height: wall.height ?? 2.5 },
// The 2D plan is top-down: sill/head height and vertical alignment aren't
// representable here — those belong to the 3D viewport.
includeVertical: false,
})
const out: FloorplanGeometry[] = []
const leftDistance = startDist - leftFromDist
if (leftDistance >= 0.01) {
// Edge-to-edge clearance to the nearest neighbour (or wall end) on each side.
for (const gap of guides.gaps) {
const lo = Math.min(gap.fromS, gap.toS)
const hi = Math.max(gap.fromS, gap.toS)
out.push({
kind: 'dimension',
start: facePoint(leftFromDist),
end: facePoint(startDist),
start: facePoint(lo),
end: facePoint(hi),
offsetNormal: outwardNormal,
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
extensionOvershoot: 0.12,
text: `${Number.parseFloat(leftDistance.toFixed(2))}m`,
text: `${round(gap.distance)}m`,
stroke: '#f97316',
})
}
const rightDistance = rightToDist - endDist
if (rightDistance >= 0.01) {
out.push({
kind: 'dimension',
start: facePoint(endDist),
end: facePoint(rightToDist),
offsetNormal: outwardNormal,
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
extensionOvershoot: 0.12,
text: `${Number.parseFloat(rightDistance.toFixed(2))}m`,
stroke: '#f97316',
})
// Equal-spacing rhythm — a "=" badge per equal gap, on the wall centreline.
if (guides.equalSpacing) {
const wallAngle = Math.atan2(dz, dx)
const text = `${round(guides.equalSpacing.gap)}m`
for (const seg of guides.equalSpacing.segments) {
out.push({
kind: 'equal-spacing-badge',
point: centrePoint((seg.fromS + seg.toS) / 2),
text,
angle: wallAngle,
})
}
}
return out
+25
View File
@@ -6,6 +6,7 @@ import type {
WallNode,
WindowNode as WindowNodeType,
} from '@pascal-app/core'
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
import { buildWindowFloorplan } from './floorplan'
@@ -18,6 +19,9 @@ const SIDE_HANDLE_OFFSET = 0.24
const HEIGHT_HANDLE_OFFSET = 0.24
const MIN_WINDOW_HEIGHT = 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 {
if (!w.wallId) return Number.POSITIVE_INFINITY
@@ -47,6 +51,7 @@ function windowWidthHandle(side: 'left' | 'right'): HandleDescriptor<WindowNodeT
return readWallLength(n, scene)
},
currentValue: (n) => n.width,
onDrag: (node) => publishOpeningResizeGuides(node, true),
apply: (initial, newWidth) => {
const rotY = initial.rotation[1]
const armX = Math.cos(rotY)
@@ -94,6 +99,7 @@ function windowHeightHandle(edge: 'top' | 'bottom'): HandleDescriptor<WindowNode
: Math.max(MIN_WINDOW_HEIGHT, anchored)
},
currentValue: (n) => n.height,
onDrag: (node) => publishOpeningResizeGuides(node, true),
apply: (initial, newHeight) => {
// Anchored edge stays in wall-local Y; opposite edge moves.
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>[] = [
windowMoveHandle(),
windowWidthHandle('left'),
windowWidthHandle('right'),
windowHeightHandle('top'),
+37 -2
View File
@@ -30,6 +30,11 @@ import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
clearOpeningGuides3D,
publishOpeningGuidesForWallEvent,
resolveSillSnap,
} from '../shared/opening-guides-runtime'
import {
getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget,
@@ -172,6 +177,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
}
// 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 targetRawLocalY = dragAnchor.startY + (rawLocalY - dragAnchor.rawY)
const targetLocalY =
event.nativeEvent?.shiftKey === true ? targetRawLocalY : snapToHalf(targetRawLocalY)
// Vertical sill alignment (snap + guide): a sibling's sill/centre/top wins
// 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({
wallNode: event.node,
rawLocalX: targetLocalX,
@@ -308,6 +327,18 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
target.cursorRotation,
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) => {
@@ -531,6 +562,8 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
lastTarget = null
lastRoofEvent = event
useLiveTransforms.getState().clear(movingWindowNode.id)
// Opening guides are wall-specific; clear them when over a roof face.
clearOpeningGuides3D()
if (currentHostId !== target.segment.id) {
useScene.getState().updateNode(movingWindowNode.id, {
position: target.position,
@@ -739,6 +772,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
}
useLiveTransforms.getState().clear(movingWindowNode.id)
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
@@ -765,6 +799,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
boxGeo.dispose()
return geo
}, [movingWindowNode])
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return (
<group ref={cursorGroupRef} visible={false}>
+70 -5
View File
@@ -27,6 +27,11 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import {
clearOpeningGuides3D,
publishOpeningGuidesForWallEvent,
resolveSillSnap,
} from '../shared/opening-guides-runtime'
import {
getRoofWallOpeningCursorPose,
type RoofWallOpeningTarget,
@@ -124,6 +129,7 @@ const WindowTool: React.FC = () => {
const hideCursor = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
setFallbackPose(null)
}
@@ -155,6 +161,7 @@ const WindowTool: React.FC = () => {
if (cursorGroupRef.current) cursorGroupRef.current.visible = false
setFallbackPose({ position, rotationY: 0 })
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
}
const showRoofFallbackCursor = (event: RoofEvent) => {
@@ -167,6 +174,32 @@ const WindowTool: React.FC = () => {
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.
const resolveWallPlacement = (
wall: WallNode,
@@ -186,7 +219,15 @@ const WindowTool: React.FC = () => {
bypass,
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 valid = !hasWallChildOverlap(wall.id, clampedX, clampedY, width, height, ignoreId)
return { clampedX, clampedY, valid }
@@ -274,6 +315,20 @@ const WindowTool: React.FC = () => {
cursorRotationY,
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 }
}
@@ -333,6 +388,7 @@ const WindowTool: React.FC = () => {
triggerSFX('sfx:structure-build')
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
}
// ── Direct wall-mesh hover ──────────────────────────────────────
@@ -488,6 +544,8 @@ const WindowTool: React.FC = () => {
useScene.getState().createNode(node, segment.id as AnyNodeId)
draftRef.current = node
}
// Opening guides are wall-specific; clear them while over a roof face.
clearOpeningGuides3D()
updateRoofCursor(target, event.node as RoofNode)
event.stopPropagation()
}
@@ -590,6 +648,7 @@ const WindowTool: React.FC = () => {
destroyDraft()
hideCursor()
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallHover)
emitter.off('wall:move', onWallHover)
@@ -605,10 +664,16 @@ const WindowTool: React.FC = () => {
}
}, [])
// Cursor geometry: window outline rectangle.
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
const edgesGeo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
// Cursor geometry: window outline rectangle. Static dims, so build it once and
// dispose on unmount rather than reallocating (and orphaning) an EdgesGeometry
// on every re-render during placement.
const edgesGeo = useMemo(() => {
const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07)
const geo = new EdgesGeometry(boxGeo)
boxGeo.dispose()
return geo
}, [])
useEffect(() => () => edgesGeo.dispose(), [edgesGeo])
return (
<>
+22 -14
View File
@@ -36,52 +36,60 @@ export function useNodeEvents<K extends AnyNodeType>(node: NodeByKind<K>, type:
emitter.emit(eventKey, payload as never)
}
// Suppress node pointer events while an interaction drag is in
// progress. `cameraDragging` covers orbit/pan/dolly; `inputDragging`
// covers host-driven drags (editor handle arrows etc.). Without
// this, the synthesized click on pointerup would reroute selection
// to whatever mesh the cursor lands on at release.
const isInteractionActive = () => {
// Camera drags (orbit / pan / dolly) suppress ALL node pointer events.
//
// `inputDragging` (host-driven drags: handle arrows, press-drag moves)
// additionally suppresses the SELECTION events — without it the click
// synthesized on pointer-release would reroute selection to whatever mesh
// 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()
return s.cameraDragging || s.inputDragging
}
return {
onPointerDown: (e: ThreeEvent<PointerEvent>) => {
if (isInteractionActive()) return
if (selectionSuppressed()) return
if (e.button !== 0) return
emit('pointerdown', e)
},
onPointerUp: (e: ThreeEvent<PointerEvent>) => {
if (isInteractionActive()) return
if (selectionSuppressed()) return
if (e.button !== 0) return
emit('pointerup', e)
// 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.
emit('click', e)
},
onClick: (e: ThreeEvent<PointerEvent>) => {
onClick: (_e: ThreeEvent<PointerEvent>) => {
// Disable default R3F click since we synthesize it on pointerup
// This prevents double-clicks from firing twice.
},
onPointerEnter: (e: ThreeEvent<PointerEvent>) => {
if (isInteractionActive()) return
if (spatialSuppressed()) return
emit('enter', e)
},
onPointerLeave: (e: ThreeEvent<PointerEvent>) => {
if (isInteractionActive()) return
if (spatialSuppressed()) return
emit('leave', e)
},
onPointerMove: (e: ThreeEvent<PointerEvent>) => {
if (isInteractionActive()) return
if (spatialSuppressed()) return
emit('move', e)
},
onDoubleClick: (e: ThreeEvent<PointerEvent>) => {
if (isInteractionActive()) return
if (selectionSuppressed()) return
emit('double-click', e)
},
onContextMenu: (e: ThreeEvent<PointerEvent>) => {
if (isInteractionActive()) return
if (selectionSuppressed()) return
emit('context-menu', e)
},
}