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 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
+1
View File
@@ -18,6 +18,7 @@ export {
discoverPlugins, discoverPlugins,
getHostRefFields, getHostRefFields,
getSelectableKinds, getSelectableKinds,
hasRegistry3DMoveTool,
isDrawnViaTool, isDrawnViaTool,
isDrawnViaToolKind, isDrawnViaToolKind,
isPresettable, isPresettable,
+14
View File
@@ -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
+14
View File
@@ -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
+20
View File
@@ -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 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> </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>
+5
View File
@@ -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
+25
View File
@@ -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(),
+22
View File
@@ -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}>
+34 -4
View File
@@ -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
+25
View File
@@ -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'),
+37 -2
View File
@@ -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}>
+70 -5
View File
@@ -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 (
<> <>
+22 -14
View File
@@ -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)
}, },
} }