diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts
index 03cbd617..cc073c50 100644
--- a/packages/core/src/registry/types.ts
+++ b/packages/core/src/registry/types.ts
@@ -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
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx
index 120f642c..9c83ecfa 100644
--- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx
+++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx
@@ -1656,6 +1656,58 @@ function InteractiveGeometry({
)
}
+ 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 (
+
+
+
+ {label}
+
+
+ )
+ }
case 'dimension': {
if (!palette) return <>>
const stroke = g.stroke ?? palette.measurementStroke
@@ -1959,6 +2011,7 @@ const OVERLAY_KINDS = new Set([
'rotate-arrow',
'dimension',
'dimension-label',
+ 'equal-spacing-badge',
])
/**
diff --git a/packages/nodes/src/shared/opening-placement-dimensions.ts b/packages/nodes/src/shared/opening-placement-dimensions.ts
index 04960755..42d375fb 100644
--- a/packages/nodes/src/shared/opening-placement-dimensions.ts
+++ b/packages/nodes/src/shared/opening-placement-dimensions.ts
@@ -1,10 +1,13 @@
import {
type AnyNode,
type AnyNodeId,
+ computeOpeningGuides,
type DoorNode,
type FloorplanGeometry,
+ type FloorplanPoint,
type GeometryContext,
isCurvedWall,
+ type OpeningSpan,
type WallNode,
type WindowNode,
} from '@pascal-app/core'
@@ -49,79 +52,84 @@ export function buildOpeningPlacementDimensions(
// walls) via ctx.resolve to compute the centroid.
const outwardNormal = computeOutwardNormal(wall, ctx, dirX, dirZ)
- const halfWidth = opening.width / 2
- const startDist = opening.position[0] - halfWidth
- const endDist = opening.position[0] + halfWidth
+ const wallThickness = wall.thickness ?? 0.1
+ const halfThickness = wallThickness / 2
+ const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.32
- // Walk wall.children to find adjacent openings (door OR window).
- // ctx.siblings only includes same-kind nodes; doors + windows need
- // each other so we go via the parent's children directly.
+ // Outer-face projection for the placement dimensions (so extension lines stay
+ // short and the layout matches the legacy treatment); centreline projection
+ // for the equal-spacing badges, which sit on the solid wall between openings.
+ const facePoint = (along: number): readonly [number, number] => [
+ x1 + dirX * along + outwardNormal[0] * halfThickness,
+ z1 + dirZ * along + outwardNormal[1] * halfThickness,
+ ]
+ const centrePoint = (along: number): FloorplanPoint => [x1 + dirX * along, z1 + dirZ * along]
+ const round = (value: number) => Number.parseFloat(value.toFixed(2))
+
+ // This wall's OTHER openings as wall-local spans. `ctx.siblings` only includes
+ // same-kind nodes; doors and windows need each other, so resolve the wall's
+ // children directly.
const childIds = ((wall as unknown as { children?: AnyNodeId[] }).children ?? []) as AnyNodeId[]
- let leftBoundary: number | null = null
- let rightBoundary: number | null = null
+ const siblings: OpeningSpan[] = []
for (const childId of childIds) {
if (childId === opening.id) continue
const sibling = ctx.resolve(childId) as AnyNode | undefined
if (!sibling || (sibling.type !== 'door' && sibling.type !== 'window')) continue
const sib = sibling as DoorNode | WindowNode
- const sibStart = sib.position[0] - sib.width / 2
- const sibEnd = sib.position[0] + sib.width / 2
- if (sibEnd <= startDist && (leftBoundary === null || sibEnd > leftBoundary)) {
- leftBoundary = sibEnd
- }
- if (sibStart >= endDist && (rightBoundary === null || sibStart < rightBoundary)) {
- rightBoundary = sibStart
- }
+ siblings.push({
+ id: sib.id,
+ centerS: sib.position[0],
+ width: sib.width,
+ centerY: sib.position[1],
+ height: sib.height,
+ })
}
- const leftFromDist = leftBoundary ?? 0
- const rightToDist = rightBoundary ?? wallLength
-
- // Place the dimension line at a constant offset from the wall's
- // outer face — same value the legacy uses for its placement
- // measurements (`FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET`). The
- // dimension's `start` / `end` are points on that outer face (not
- // the wall centerline), so the extension lines stay short and the
- // overall layout matches the legacy treatment 1:1.
- const wallThickness = wall.thickness ?? 0.1
- const halfThickness = wallThickness / 2
- const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.32
-
- // Project a point on the wall axis at distance `along` onto the
- // wall's outer face by adding `halfThickness * outwardNormal`.
- const facePoint = (along: number): readonly [number, number] => [
- x1 + dirX * along + outwardNormal[0] * halfThickness,
- z1 + dirZ * along + outwardNormal[1] * halfThickness,
- ]
+ const guides = computeOpeningGuides({
+ moving: {
+ id: opening.id,
+ centerS: opening.position[0],
+ width: opening.width,
+ centerY: opening.position[1],
+ height: opening.height,
+ },
+ siblings,
+ wall: { length: wallLength, height: wall.height ?? 2.5 },
+ // The 2D plan is top-down: sill/head height and vertical alignment aren't
+ // representable here — those belong to the 3D viewport.
+ includeVertical: false,
+ })
const out: FloorplanGeometry[] = []
- const leftDistance = startDist - leftFromDist
- if (leftDistance >= 0.01) {
+ // Edge-to-edge clearance to the nearest neighbour (or wall end) on each side.
+ for (const gap of guides.gaps) {
+ const lo = Math.min(gap.fromS, gap.toS)
+ const hi = Math.max(gap.fromS, gap.toS)
out.push({
kind: 'dimension',
- start: facePoint(leftFromDist),
- end: facePoint(startDist),
+ start: facePoint(lo),
+ end: facePoint(hi),
offsetNormal: outwardNormal,
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
extensionOvershoot: 0.12,
- text: `${Number.parseFloat(leftDistance.toFixed(2))}m`,
+ text: `${round(gap.distance)}m`,
stroke: '#f97316',
})
}
- const rightDistance = rightToDist - endDist
- if (rightDistance >= 0.01) {
- out.push({
- kind: 'dimension',
- start: facePoint(endDist),
- end: facePoint(rightToDist),
- offsetNormal: outwardNormal,
- offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
- extensionOvershoot: 0.12,
- text: `${Number.parseFloat(rightDistance.toFixed(2))}m`,
- stroke: '#f97316',
- })
+ // Equal-spacing rhythm — a "=" badge per equal gap, on the wall centreline.
+ if (guides.equalSpacing) {
+ const wallAngle = Math.atan2(dz, dx)
+ const text = `${round(guides.equalSpacing.gap)}m`
+ for (const seg of guides.equalSpacing.segments) {
+ out.push({
+ kind: 'equal-spacing-badge',
+ point: centrePoint((seg.fromS + seg.toS) / 2),
+ text,
+ angle: wallAngle,
+ })
+ }
}
return out