editor: improve floorplan modes and annotations (#549)
CI / quality (push) Has been cancelled
mcp-ci / ci (push) Has been cancelled

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* fix(editor): anchor floorplan cursor to snapped point

* fix(nodes): preview floorplan edits through live overrides

* feat(editor): add context-aware floorplan modes

* fix(nodes): render crisp wall selection hatching

* fix(editor): cap floorplan handles at extreme zoom

* fix(editor): keep zone labels upright after rotation

* refactor(editor): make referenced annotations registry-driven

* refactor(nodes): colocate contextual dimension builders

* fix(editor): use mode-driven angle snapping

* fix(nodes): use mode-driven move snapping

* chore(editor): update react scan tooling

* refactor(floorplan): streamline construction documentation

* refactor(floorplan): remove wall assembly roadmap

* fix(floorplan): migrate retired scene data

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sudhir Yadav
2026-07-27 18:57:59 -04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent daa1f3e99b
commit ab76686b8b
121 changed files with 3535 additions and 7840 deletions
@@ -0,0 +1,59 @@
import {
type FloorplanGeometry,
type FloorplanPoint,
type GeometryContext,
resolveAutoZonePolygon,
type ZoneNode,
} from '@pascal-app/core'
import { formatAreaLabel } from '@pascal-app/editor'
export function buildZoneContextualDimensions(
node: ZoneNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const polygon = resolveAutoZonePolygon(node, ctx.resolve)
if (polygon.length < 3) return null
const { area, centroid } = polygonAreaAndCentroid(polygon)
if (area <= 1e-6) return null
return {
kind: 'dimension-label',
appearance: 'outlined',
cx: centroid[0],
cy: centroid[1],
text: formatAreaLabel(area, ctx.viewState?.unit ?? 'metric', 1),
angle: 0,
}
}
function polygonAreaAndCentroid(points: readonly FloorplanPoint[]): {
area: number
centroid: FloorplanPoint
} {
let twiceSignedArea = 0
let weightedX = 0
let weightedY = 0
for (let index = 0; index < points.length; index += 1) {
const current = points[index]!
const next = points[(index + 1) % points.length]!
const cross = current[0] * next[1] - next[0] * current[1]
twiceSignedArea += cross
weightedX += (current[0] + next[0]) * cross
weightedY += (current[1] + next[1]) * cross
}
const area = Math.abs(twiceSignedArea) / 2
if (Math.abs(twiceSignedArea) <= 1e-9) {
const sum = points.reduce(
(acc, point) => [acc[0] + point[0], acc[1] + point[1]] as FloorplanPoint,
[0, 0] as FloorplanPoint,
)
return {
area,
centroid: [sum[0] / points.length, sum[1] / points.length],
}
}
return {
area,
centroid: [weightedX / (3 * twiceSignedArea), weightedY / (3 * twiceSignedArea)],
}
}
+2
View File
@@ -5,6 +5,7 @@ import {
} from '@pascal-app/core'
import type { FloorplanNodeExtension } from '@pascal-app/editor'
import { polygonMeasurementFeatures } from '../shared/polygon-measurement'
import { buildZoneContextualDimensions } from './contextual-dimensions'
import { buildZoneFloorplan } from './floorplan'
import {
zoneAddVertexAffordance,
@@ -32,6 +33,7 @@ export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
category: 'site',
extensions: {
'pascal:editor/floorplan': {
contextualDimensions: buildZoneContextualDimensions,
schedule: buildRoomFloorplanSchedule,
} satisfies FloorplanNodeExtension<ZoneNode>,
},
+3 -1
View File
@@ -113,7 +113,9 @@ export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): Floorp
stroke,
),
)
children.push(...buildRoomClearDimensions(node, ctx))
if (floorplanContext.automaticDimensions) {
children.push(...buildRoomClearDimensions(node, ctx))
}
} else if (name) {
children.push({
kind: 'text',
@@ -53,33 +53,10 @@ function enclosure(points: Array<[number, number]>) {
return { context, nodes, walls, zone }
}
function withFinishAssembly(wall: WallNode): WallNode {
function withFinishedThickness(wall: WallNode): WallNode {
return WallNode.parse({
...wall,
thickness: undefined,
assemblyLayers: [
{
id: `${wall.id}_core`,
role: 'structure',
side: 'core',
thickness: 0.2,
datumEligible: ['structural-face'],
},
{
id: `${wall.id}_interior-finish`,
role: 'interior-finish',
side: 'interior',
thickness: 0.02,
datumEligible: ['finish-face'],
},
{
id: `${wall.id}_exterior-finish`,
role: 'exterior-finish',
side: 'exterior',
thickness: 0.02,
datumEligible: ['finish-face'],
},
],
thickness: 0.24,
})
}
@@ -147,23 +124,23 @@ describe('buildRoomClearDimensions', () => {
).toEqual(['2.8m', '3.8m'])
})
test('dimensions finish faces when every boundary wall has assembly finish datums', () => {
test('dimensions finish faces using each boundary wall thickness', () => {
const { context, nodes, walls, zone } = enclosure([
[0, 0],
[4, 0],
[4, 3],
[0, 3],
])
const assembledWalls = walls.map(withFinishAssembly)
const assembledNodes = { ...nodes }
for (const wall of assembledWalls) assembledNodes[wall.id] = wall
const finishedWalls = walls.map(withFinishedThickness)
const finishedNodes = { ...nodes }
for (const wall of finishedWalls) finishedNodes[wall.id] = wall
const result = dimensions(
buildRoomClearDimensions(
{ ...zone, clearDimensionPolicy: 'finish-faces' },
{
...context,
resolve: (id) => assembledNodes[id],
resolve: (id) => finishedNodes[id],
},
),
)
@@ -181,7 +158,7 @@ describe('buildRoomClearDimensions', () => {
WallNode.parse({ id: 'wall_b_bottom', parentId: 'level_main', start: [4, 0], end: [8, 0] }),
WallNode.parse({ id: 'wall_b_right', parentId: 'level_main', start: [8, 0], end: [8, 3] }),
WallNode.parse({ id: 'wall_b_top', parentId: 'level_main', start: [8, 3], end: [4, 3] }),
].map(withFinishAssembly)
].map(withFinishedThickness)
const zoneA = ZoneNode.parse({
id: 'zone_a',
parentId: 'level_main',
@@ -254,7 +231,7 @@ describe('buildRoomClearDimensions', () => {
expect(result.map((entry) => entry.text).sort()).toEqual(['1.8m', '1.8m', '3.8m', '3.8m'])
})
test('suppresses dimensions when the requested datum cannot be proven', () => {
test('suppresses dimensions when the room or requested policy is invalid', () => {
const { context, nodes, walls, zone } = enclosure([
[0, 0],
[4, 0],
@@ -264,8 +241,10 @@ describe('buildRoomClearDimensions', () => {
expect(buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'none' }, context)).toEqual([])
expect(
buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'finish-faces' }, context),
).toEqual([])
dimensions(
buildRoomClearDimensions({ ...zone, clearDimensionPolicy: 'finish-faces' }, context),
),
).toHaveLength(2)
expect(buildRoomClearDimensions({ ...zone, enclosureStatus: 'open' }, context)).toEqual([])
expect(buildRoomClearDimensions({ ...zone, autoFromWalls: false }, context)).toEqual([])
@@ -3,8 +3,7 @@ import {
type FloorplanGeometry,
type FloorplanPoint,
type GeometryContext,
getWallAssemblyFaceOffsets,
resolveWallAssemblyDatumReferences,
getWallThickness,
type SpaceBoundaryFace,
type WallNode,
type ZoneNode,
@@ -31,11 +30,6 @@ type FaceLine = {
type DimensionGeometry = Extract<FloorplanGeometry, { kind: 'dimension' }>
type ClearDimensionPolicy = Extract<
ZoneNode['clearDimensionPolicy'],
'inside-faces' | 'finish-faces'
>
export function buildRoomClearDimensions(
node: ZoneNode,
ctx: GeometryContext,
@@ -72,7 +66,7 @@ export function buildRoomClearDimensions(
if (!space) return []
const wallsById = new Map(walls.map((wall) => [wall.id, wall]))
const faceLines = resolveClearFaceLines(space.boundaryFaces, wallsById, node.clearDimensionPolicy)
const faceLines = resolveClearFaceLines(space.boundaryFaces, wallsById)
if (!faceLines) return []
const unit = ctx.viewState?.unit ?? 'metric'
@@ -104,13 +98,12 @@ export function buildRoomClearDimensions(
function resolveClearFaceLines(
boundaryFaces: readonly SpaceBoundaryFace[],
wallsById: ReadonlyMap<string, WallNode>,
policy: ClearDimensionPolicy,
): FaceLine[] | null {
const faceLines: FaceLine[] = []
for (const boundary of boundaryFaces) {
const wall = wallsById.get(boundary.wallId)
if (!wall || Math.abs(wall.curveOffset ?? 0) > LINE_TOLERANCE) return null
const line = offsetBoundaryFace(boundary, wall, policy)
const line = offsetBoundaryFace(boundary, wall)
if (!line) return null
faceLines.push(line)
}
@@ -228,11 +221,7 @@ function buildRectilinearClearDimensions(
return dimensions
}
function offsetBoundaryFace(
boundary: SpaceBoundaryFace,
wall: WallNode,
policy: ClearDimensionPolicy,
): FaceLine | null {
function offsetBoundaryFace(boundary: SpaceBoundaryFace, wall: WallNode): FaceLine | null {
const first = boundary.points[0]
const last = boundary.points[boundary.points.length - 1]
if (!(first && last)) return null
@@ -241,32 +230,13 @@ function offsetBoundaryFace(
if (!wallDirection) return null
const normal: FloorplanPoint = [-wallDirection[1], wallDirection[0]]
const side = boundary.face === 'front' ? 1 : -1
const faces = getWallAssemblyFaceOffsets(wall)
const offset =
policy === 'finish-faces'
? resolveFinishFaceOffset(wall, side)
: side > 0
? faces.exterior
: faces.interior
if (offset === null) return null
const offset = (getWallThickness(wall) / 2) * side
return {
start: [first[0] + normal[0] * offset, first[1] + normal[1] * offset],
end: [last[0] + normal[0] * offset, last[1] + normal[1] * offset],
}
}
function resolveFinishFaceOffset(wall: WallNode, side: 1 | -1): number | null {
if ((wall.assemblyLayers ?? []).length === 0) return null
const references = resolveWallAssemblyDatumReferences(wall).filter(
(reference) => reference.datum === 'finish-face',
)
const matching = references
.filter((reference) => Math.sign(reference.offset) === side)
.map((reference) => reference.offset)
if (matching.length === 0) return null
return side > 0 ? Math.max(...matching) : Math.min(...matching)
}
function clearFacePolygon(faceLines: readonly FaceLine[]): FloorplanPoint[] | null {
const vertices = faceLines.map((line, index) => {
const previous = faceLines[(index + faceLines.length - 1) % faceLines.length]!
@@ -421,8 +391,8 @@ function buildRoomToRoomClearDimensions(
const currentBoundary = currentBoundaryByWallId.get(wallId)
const neighborBoundary = neighborBoundaryByWallId.get(wallId)
if (!(wall && currentBoundary && neighborBoundary)) continue
const currentLine = offsetBoundaryFace(currentBoundary, wall, 'finish-faces')
const neighborLine = offsetBoundaryFace(neighborBoundary, wall, 'finish-faces')
const currentLine = offsetBoundaryFace(currentBoundary, wall)
const neighborLine = offsetBoundaryFace(neighborBoundary, wall)
if (!(currentLine && neighborLine)) continue
const dimension = dimensionAcrossSharedRoomWall(
currentLine,