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,12 @@
import type { DoorNode, FloorplanGeometry, GeometryContext } from '@pascal-app/core'
import { buildWallHostedOpeningContextualDimensions } from '../wall/contextual-dimensions'
export function buildDoorContextualDimensions(
node: DoorNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
return buildWallHostedOpeningContextualDimensions(node, ctx, {
showClearancesWhileMoving: false,
useExteriorNormal: false,
})
}
+2
View File
@@ -16,6 +16,7 @@ import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-open
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
import { readHostWallCeiling } from '../shared/wall-opening-ceiling'
import { wallFloorplanSiblingOverrides } from '../wall/floorplan-overrides'
import { buildDoorContextualDimensions } from './contextual-dimensions'
import { scaleHandleHeight } from './door-math'
import { buildDoorFloorplan } from './floorplan'
import { doorWidthAffordance } from './floorplan-affordances'
@@ -174,6 +175,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
category: 'structure',
extensions: {
'pascal:editor/floorplan': {
contextualDimensions: buildDoorContextualDimensions,
schedule: buildDoorFloorplanSchedule,
} satisfies FloorplanNodeExtension<DoorNodeType>,
},
@@ -3,6 +3,7 @@ import {
type DoorNode,
type FloorplanAffordance,
type FloorplanAffordanceSession,
useLiveNodeOverrides,
useScene,
type WallNode,
} from '@pascal-app/core'
@@ -23,11 +24,8 @@ type DoorWidthPayload = { side: 'start' | 'end' }
* - `'end'`: arrow at the edge closer to `wall.end`. The wall-start
* edge stays fixed.
*
* Uses the scene-write preview pattern (writes directly to `useScene`
* each tick): the registry layer's `effectiveNode` only merges live
* overrides for walls, so an override-based preview wouldn't show on
* doors. The dispatcher snapshots / pauses history at start, so per-tick
* scene writes still collapse to one undoable entry on commit.
* Preview state stays in the live override store so the scene graph is
* written only once, when the drag commits.
*/
export const doorWidthAffordance: FloorplanAffordance<DoorNode> = {
start({ node, payload, nodes, initialPlanPoint }): FloorplanAffordanceSession {
@@ -86,18 +84,11 @@ export const doorWidthAffordance: FloorplanAffordance<DoorNode> = {
const newDoorX = anchorX + growDir * (newWidth / 2)
lastWidth = newWidth
lastDoorX = newDoorX
// Scene-write preview so the 2D plan + 3D viewer both pick up
// the change immediately. The dispatcher paused history at
// session start, so per-tick writes don't pollute undo.
useScene.getState().updateNodes([
{
id: doorId,
data: {
width: newWidth,
position: [newDoorX, initialDoorY, initialDoorZ],
},
},
])
useLiveNodeOverrides.getState().set(doorId, {
width: newWidth,
position: [newDoorX, initialDoorY, initialDoorZ],
})
useScene.getState().markDirty(doorId)
},
canCommit() {
// Width is always clamped to >= MIN_DOOR_WIDTH inside apply, so
@@ -110,6 +101,7 @@ export const doorWidthAffordance: FloorplanAffordance<DoorNode> = {
// fields that differ from the pre-drag snapshot — if the user
// drags back to the original size by accident, the diff is empty
// and the door would otherwise revert to its starting state).
useLiveNodeOverrides.getState().clear(doorId)
useScene.getState().updateNodes([
{
id: doorId,
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, test } from 'bun:test'
import { DoorNode, type FloorplanGeometry, type GeometryContext, WallNode } from '@pascal-app/core'
import { buildDoorFloorplan } from './floorplan'
const wall = WallNode.parse({
id: 'wall_door-plan',
start: [0, 0],
end: [4, 0],
thickness: 0.2,
})
function buildDoor(values: Partial<DoorNode> = {}): FloorplanGeometry[] {
const door = DoorNode.parse({
id: 'door_plan',
parentId: wall.id,
wallId: wall.id,
position: [2, 1.05, 0],
width: 1,
...values,
})
const geometry = buildDoorFloorplan(door, {
children: [],
parent: wall,
resolve: () => undefined,
siblings: [],
} as GeometryContext)
expect(geometry?.kind).toBe('group')
return geometry?.kind === 'group' ? geometry.children : []
}
describe('buildDoorFloorplan documentation symbols', () => {
test('shows hinge, strike, panic hardware, and an arched overhead line', () => {
const geometry = buildDoor({
doorType: 'hinged',
openingShape: 'arch',
archHeight: 0.45,
panicBar: true,
})
expect(geometry.filter((item) => item.kind === 'rect')).toHaveLength(2)
expect(
geometry.some(
(item) =>
item.kind === 'line' && item.strokeLinecap === 'square' && item.strokeWidth === 2.2,
),
).toBe(true)
expect(geometry.some((item) => item.kind === 'path' && item.strokeDasharray === '4 3')).toBe(
true,
)
})
test('documents a rounded frameless opening without swing hardware', () => {
const geometry = buildDoor({
openingKind: 'opening',
openingShape: 'rounded',
cornerRadius: 0.2,
panicBar: true,
})
expect(geometry.filter((item) => item.kind === 'rect')).toHaveLength(0)
expect(geometry.some((item) => item.kind === 'line' && item.strokeLinecap === 'square')).toBe(
false,
)
expect(geometry.some((item) => item.kind === 'path' && item.strokeDasharray === '4 3')).toBe(
true,
)
})
})
+71 -6
View File
@@ -5,7 +5,11 @@ import type {
GeometryContext,
WallNode,
} from '@pascal-app/core'
import { readFloorplanGeometryMetadata, withFloorplanGeometryMetadata } from '@pascal-app/editor'
import {
readFloorplanContext,
readFloorplanGeometryMetadata,
withFloorplanGeometryMetadata,
} from '@pascal-app/editor'
import {
buildOpeningMarkAnnotation,
type OpeningFloorplanLevelData,
@@ -41,10 +45,9 @@ import { buildOpeningPlacementDimensions } from '../shared/opening-placement-dim
* mounted on). Returns null when the parent isn't a wall (orphaned
* doors during placement etc.).
*
* Skipped vs the full legacy for now: hinge / strike cubes (small
* indicator squares at the rotation pivots), rounded-opening shape
* variants, panic bar markers. Those are rare visual variations the
* follow-up port can revisit.
* Swing leaves include hinge / strike indicators and panic hardware
* when present. Rounded and arched heads are shown as dashed overhead
* lines because their geometry sits above the horizontal plan cut.
*/
export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): FloorplanGeometry | null {
const wall = ctx.parent as WallNode | null
@@ -123,6 +126,27 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
},
]
if (node.openingShape !== 'rectangle') {
const overheadRise =
Math.min(width, node.openingShape === 'arch' ? node.archHeight : node.cornerRadius) * 0.2
const overheadSide = swingDirection === 'inward' ? 1 : -1
const startX = cx - dirX * halfWidth
const startZ = cz - dirZ * halfWidth
const endX = cx + dirX * halfWidth
const endZ = cz + dirZ * halfWidth
children.push({
kind: 'path',
d: `M ${startX} ${startZ} Q ${cx + perpX * overheadRise * overheadSide} ${cz + perpZ * overheadRise * overheadSide} ${endX} ${endZ}`,
fill: 'none',
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.4 : 1,
strokeOpacity: 0.8,
strokeDasharray: '4 3',
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
})
}
// Swing geometry. A leaf is drawn as a wedge fill + dashed swing arc +
// solid leaf line. `drawSwingLeaf` emits one leaf given its hinge, the
// closed-leaf vector (hinge → strike, whose length is the swing
@@ -192,6 +216,47 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
strokeLinecap: 'round',
vectorEffect: 'non-scaling-stroke',
})
const markerSize = Math.min(0.05, Math.max(0.025, radius * 0.05))
const markerHalf = markerSize / 2
const hardwareMarkers: Array<[number, number]> = [
[hX, hZ],
[closedTipX, closedTipZ],
]
for (const [markerX, markerZ] of hardwareMarkers) {
children.push({
kind: 'rect',
x: markerX - markerHalf,
y: markerZ - markerHalf,
width: markerSize,
height: markerSize,
fill: fillColor,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 1.4 : 1,
vectorEffect: 'non-scaling-stroke',
})
}
if (node.panicBar) {
const leafX = (tipX - hX) / radius
const leafZ = (tipZ - hZ) / radius
const barCenterX = hX + leafX * radius * 0.7
const barCenterZ = hZ + leafZ * radius * 0.7
const barHalfLength = Math.min(0.12, Math.max(0.06, depth * 0.75))
const barX = -leafZ * barHalfLength
const barZ = leafX * barHalfLength
children.push({
kind: 'line',
x1: barCenterX - barX,
y1: barCenterZ - barZ,
x2: barCenterX + barX,
y2: barCenterZ + barZ,
stroke: accentColor,
strokeWidth: showSelectedChrome ? 2.6 : 2.2,
strokeLinecap: 'square',
vectorEffect: 'non-scaling-stroke',
})
}
}
const isDoubleLeaf = node.doorType === 'double' || node.doorType === 'french'
@@ -721,7 +786,7 @@ export function buildDoorFloorplan(node: DoorNode, ctx: GeometryContext): Floorp
// Placement-measurement dimensions — distances to adjacent openings
// (or wall ends) on each side. Only visible while actively moving
// (the user clicked Move or grabbed the orange dot).
if (view?.moving) {
if (view?.moving && readFloorplanContext(ctx).automaticDimensions) {
for (const dim of buildOpeningPlacementDimensions(node, ctx)) {
children.push(dim)
}