feat(editor): 2D plan proximity + equal-spacing guides for openings

Route the door/window floor-plan placement dimensions through the new
opening-guides service:
  - edge-to-edge clearance to the nearest neighbour (or wall end) on each
    side, now with overlap suppression (previously nearest-only, ad-hoc).
  - Figma-style equal-spacing — a "=" badge per gap on the wall centreline
    whenever the moving opening is part of a run of 3+ (near-)equally-spaced
    openings.

Adds the `equal-spacing-badge` FloorplanGeometry primitive, its 2D renderer
(distinct pink accent), and overlay registration. Shown while placing/moving.
Sill height + vertical alignment are 3D-only (a top-down plan has no vertical
axis) and land in the next phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-14 17:25:49 -04:00
co-authored by Claude Opus 4.8
parent 8a5c232685
commit 3ac6b27eca
3 changed files with 127 additions and 52 deletions
+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
@@ -1656,6 +1656,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
@@ -1959,6 +2011,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
'rotate-arrow', 'rotate-arrow',
'dimension', 'dimension',
'dimension-label', 'dimension-label',
'equal-spacing-badge',
]) ])
/** /**
@@ -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