editor: improve floorplan modes and annotations (#549)
* 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:
co-authored by
Claude Opus 4.6
parent
daa1f3e99b
commit
ab76686b8b
@@ -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)],
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user