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