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:
co-authored by
Claude Opus 4.8
parent
8a5c232685
commit
3ac6b27eca
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user