Skip to content
8 changes: 8 additions & 0 deletions packages/core/src/registry/handles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export {
discoverPlugins,
getHostRefFields,
getSelectableKinds,
hasRegistry3DMoveTool,
isDrawnViaTool,
isDrawnViaToolKind,
isPresettable,
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/registry/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/registry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
241 changes: 241 additions & 0 deletions packages/core/src/services/opening-guides.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading