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
@@ -14,7 +14,6 @@ import type {
|
||||
DoorNode,
|
||||
DormerNode,
|
||||
DownspoutNode,
|
||||
DrawingSheetNode,
|
||||
DuctFittingNode,
|
||||
DuctSegmentNode,
|
||||
DuctTerminalNode,
|
||||
@@ -126,7 +125,6 @@ export type SolarPanelEvent = NodeEvent<SolarPanelNode>
|
||||
export type SkylightEvent = NodeEvent<SkylightNode>
|
||||
export type DormerEvent = NodeEvent<DormerNode>
|
||||
export type DownspoutEvent = NodeEvent<DownspoutNode>
|
||||
export type DrawingSheetEvent = NodeEvent<DrawingSheetNode>
|
||||
export type DuctSegmentEvent = NodeEvent<DuctSegmentNode>
|
||||
export type DuctFittingEvent = NodeEvent<DuctFittingNode>
|
||||
export type DuctTerminalEvent = NodeEvent<DuctTerminalNode>
|
||||
@@ -327,7 +325,6 @@ type EditorEvents = GridEvents &
|
||||
NodeEvents<'skylight', SkylightEvent> &
|
||||
NodeEvents<'dormer', DormerEvent> &
|
||||
NodeEvents<'downspout', DownspoutEvent> &
|
||||
NodeEvents<'drawing-sheet', DrawingSheetEvent> &
|
||||
NodeEvents<'duct-segment', DuctSegmentEvent> &
|
||||
NodeEvents<'duct-fitting', DuctFittingEvent> &
|
||||
NodeEvents<'duct-terminal', DuctTerminalEvent> &
|
||||
|
||||
@@ -12,7 +12,6 @@ export type {
|
||||
ConstructionDimensionEvent,
|
||||
DoorEvent,
|
||||
DormerEvent,
|
||||
DrawingSheetEvent,
|
||||
ElevatorEvent,
|
||||
EventSuffix,
|
||||
FenceEvent,
|
||||
|
||||
@@ -226,38 +226,6 @@ describe('cloneNodesInto', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('regenerates drawing-sheet identities while preserving external level references', () => {
|
||||
const original = makeNode('drawing-sheet_a101', 'drawing-sheet', {
|
||||
placedViews: [{ id: 'drawing-view_main', levelId: 'level_existing' }],
|
||||
generalNoteSetIds: [],
|
||||
generalNoteSets: [],
|
||||
generalNotes: [],
|
||||
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }],
|
||||
keyedNoteInstances: [
|
||||
{
|
||||
id: 'keyed-note-instance_a',
|
||||
definitionId: 'keyed-note_a',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [1, 1],
|
||||
},
|
||||
],
|
||||
keyedNoteLegend: [],
|
||||
documentMarkers: [],
|
||||
schedules: [],
|
||||
})
|
||||
|
||||
const { nodes } = cloneNodesInto([original], { rootId: original.id as AnyNodeId })
|
||||
const cloned = nodes[0]
|
||||
|
||||
expect(cloned?.type).toBe('drawing-sheet')
|
||||
if (cloned?.type === 'drawing-sheet') {
|
||||
expect(cloned.placedViews[0]?.levelId).toBe('level_existing')
|
||||
expect(cloned.placedViews[0]?.id).not.toBe('drawing-view_main')
|
||||
expect(cloned.keyedNoteInstances[0]?.definitionId).toBe(cloned.keyedNoteDefinitions[0]?.id)
|
||||
expect(cloned.keyedNoteInstances[0]?.placedViewId).toBe(cloned.placedViews[0]?.id)
|
||||
}
|
||||
})
|
||||
|
||||
test('parents the cloned root under opts.parentId when supplied', () => {
|
||||
const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' })
|
||||
const { nodes } = cloneNodesInto([orig], {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
remapMeasurementReferences,
|
||||
} from '../lib/measurement-geometry'
|
||||
import { generateId } from '../schema/base'
|
||||
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
|
||||
// Generic, opinion-free primitives the host app composes to implement
|
||||
@@ -176,10 +175,6 @@ export function cloneNodesInto(
|
||||
if (cloned.type === 'construction-dimension') {
|
||||
cloned = remapConstructionDimensionReferences(cloned, idMap)
|
||||
}
|
||||
if (cloned.type === 'drawing-sheet') {
|
||||
cloned = remapDrawingSheetReferences(cloned, idMap)
|
||||
}
|
||||
|
||||
if (original.id === opts.rootId) {
|
||||
if (opts.position) {
|
||||
;(cloned as { position: [number, number, number] }).position = [
|
||||
|
||||
@@ -85,25 +85,6 @@ export {
|
||||
getEffectiveDormerSurfaceMaterial,
|
||||
} from './nodes/dormer'
|
||||
export { DownspoutNode } from './nodes/downspout'
|
||||
export {
|
||||
DrawingSheetAnnotationProfile,
|
||||
DrawingSheetDocumentMarker,
|
||||
DrawingSheetDocumentMarkerKind,
|
||||
DrawingSheetGeneralNote,
|
||||
DrawingSheetGeneralNoteSet,
|
||||
DrawingSheetKeyedNote,
|
||||
DrawingSheetKeyedNoteDefinition,
|
||||
DrawingSheetKeyedNoteInstance,
|
||||
DrawingSheetNode,
|
||||
DrawingSheetOrientation,
|
||||
DrawingSheetPaperSize,
|
||||
DrawingSheetPlacedView,
|
||||
DrawingSheetRect,
|
||||
DrawingSheetScale,
|
||||
DrawingSheetSchedulePlacement,
|
||||
DrawingSheetTitleBlock,
|
||||
remapDrawingSheetReferences,
|
||||
} from './nodes/drawing-sheet'
|
||||
export { DuctFittingNode } from './nodes/duct-fitting'
|
||||
export { DuctSegmentNode } from './nodes/duct-segment'
|
||||
export { DuctTerminalNode } from './nodes/duct-terminal'
|
||||
@@ -248,9 +229,6 @@ export { StructuralGridNode } from './nodes/structural-grid'
|
||||
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
|
||||
export { TurbineVentNode } from './nodes/turbine-vent'
|
||||
export type {
|
||||
WallAssemblyDatumReference,
|
||||
WallAssemblyDatumSide,
|
||||
WallAssemblyLayer,
|
||||
WallBandSurfaceSlotId,
|
||||
WallFaceBand,
|
||||
WallFaceBandConfig,
|
||||
@@ -263,18 +241,11 @@ export {
|
||||
buildEnabledWallFaceBandPatch,
|
||||
buildWallFaceBandCountPatch,
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getWallAssemblyDatumReferenceId,
|
||||
getWallAssemblyFaceOffsets,
|
||||
getWallAssemblyLayers,
|
||||
getWallAssemblyThickness,
|
||||
getWallBandSlotId,
|
||||
getWallDatumEligibleLayers,
|
||||
getWallFaceBandConfig,
|
||||
getWallFaceBandForHeight,
|
||||
getWallSurfaceMaterialSignature,
|
||||
getWallSurfaceSideFromBandSlot,
|
||||
resolveWallAssemblyDatumReference,
|
||||
resolveWallAssemblyDatumReferences,
|
||||
WALL_CHAIR_RAIL_DEFAULT,
|
||||
WALL_CHAIR_RAIL_SLOT_DEFAULT,
|
||||
WALL_CROWN_DEFAULT,
|
||||
@@ -285,8 +256,6 @@ export {
|
||||
WALL_SLOT_DEFAULT,
|
||||
WALL_SURFACE_SLOT_DEFAULTS,
|
||||
WALL_TRIM_DEFAULTS,
|
||||
WallAssemblyLayerRole,
|
||||
WallDimensionDatum,
|
||||
WallNode,
|
||||
WallTreatmentSide,
|
||||
WallTrimProfile,
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { DrawingSheetNode } from './drawing-sheet'
|
||||
import { ElevatorNode } from './elevator'
|
||||
import { LevelNode } from './level'
|
||||
|
||||
export const BuildingNode = BaseNode.extend({
|
||||
id: objectId('building'),
|
||||
type: nodeType('building'),
|
||||
children: z
|
||||
.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id, DrawingSheetNode.shape.id]))
|
||||
.default([]),
|
||||
children: z.array(z.union([LevelNode.shape.id, ElevatorNode.shape.id])).default([]),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
}).describe(
|
||||
@@ -18,7 +15,7 @@ export const BuildingNode = BaseNode.extend({
|
||||
Building node - used to represent a building
|
||||
- position: position in site coordinate system
|
||||
- rotation: rotation in site coordinate system
|
||||
- children: array of level nodes, building-level systems such as elevators, and drawing sheets
|
||||
- children: array of level nodes and building-level systems such as elevators
|
||||
`,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { BuildingNode } from './building'
|
||||
import { DrawingSheetNode, remapDrawingSheetReferences } from './drawing-sheet'
|
||||
|
||||
describe('DrawingSheetNode', () => {
|
||||
test('creates persistent sheet defaults', () => {
|
||||
const sheet = DrawingSheetNode.parse({})
|
||||
|
||||
expect(sheet.type).toBe('drawing-sheet')
|
||||
expect(sheet.id).toMatch(/^drawing-sheet_/)
|
||||
expect(sheet).toMatchObject({
|
||||
sheetNumber: 'A1.0',
|
||||
sheetTitle: 'Floor Plan',
|
||||
paperSize: 'arch-b',
|
||||
orientation: 'landscape',
|
||||
customPaperWidth: null,
|
||||
customPaperHeight: null,
|
||||
annotationProfile: 'architectural-default',
|
||||
placedViews: [],
|
||||
generalNoteSetIds: [],
|
||||
generalNoteSets: [],
|
||||
generalNotes: [],
|
||||
keyedNoteDefinitions: [],
|
||||
keyedNoteInstances: [],
|
||||
keyedNoteLegend: [],
|
||||
documentMarkers: [],
|
||||
schedules: [],
|
||||
titleBlock: {
|
||||
projectName: '',
|
||||
projectNumber: '',
|
||||
clientName: '',
|
||||
drawnBy: '',
|
||||
checkedBy: '',
|
||||
issueDate: '',
|
||||
revision: '',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('stores placed views, notes, schedules, and title-block metadata', () => {
|
||||
const sheet = DrawingSheetNode.parse({
|
||||
sheetNumber: 'A2.1',
|
||||
sheetTitle: 'Enlarged Plans',
|
||||
paperSize: 'custom',
|
||||
customPaperWidth: 24,
|
||||
customPaperHeight: 36,
|
||||
placedViews: [
|
||||
{
|
||||
id: 'drawing-view_main',
|
||||
drawingType: 'floor-plan',
|
||||
drawingNumber: '2',
|
||||
title: 'Main Floor Plan',
|
||||
levelId: 'level_main',
|
||||
scale: '1/4"=1\'-0"',
|
||||
viewport: { x: 1, y: 1, width: 12, height: 8 },
|
||||
},
|
||||
],
|
||||
generalNoteSetIds: ['sheet-note-set_project'],
|
||||
generalNoteSets: [
|
||||
{
|
||||
id: 'sheet-note-set_project',
|
||||
name: 'Project Notes',
|
||||
notes: [{ id: 'sheet-note_project-1', number: 1, text: 'COORDINATE WITH OWNER.' }],
|
||||
},
|
||||
],
|
||||
generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'VERIFY DIMENSIONS.' }],
|
||||
keyedNoteDefinitions: [
|
||||
{ id: 'keyed-note_patch-slab', key: 'A', text: 'PATCH EXISTING SLAB.' },
|
||||
],
|
||||
keyedNoteInstances: [
|
||||
{
|
||||
id: 'keyed-note-instance_patch-slab-1',
|
||||
definitionId: 'keyed-note_patch-slab',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [3.25, 2.5],
|
||||
},
|
||||
{
|
||||
id: 'keyed-note-instance_patch-slab-2',
|
||||
definitionId: 'keyed-note_patch-slab',
|
||||
position: [5, 4],
|
||||
},
|
||||
],
|
||||
keyedNoteLegend: [{ key: 'A', text: 'ALIGN WITH EXISTING WALL.' }],
|
||||
documentMarkers: [
|
||||
{
|
||||
id: 'sheet-marker_wall-a',
|
||||
kind: 'wall-tag',
|
||||
label: 'W1',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [2, 3],
|
||||
},
|
||||
{
|
||||
id: 'sheet-marker_revision-a',
|
||||
kind: 'revision-cloud',
|
||||
label: '1',
|
||||
revisionId: 'A',
|
||||
points: [
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
[2, 2],
|
||||
[1, 2],
|
||||
],
|
||||
},
|
||||
],
|
||||
schedules: [
|
||||
{
|
||||
id: 'sheet-schedule_room',
|
||||
scheduleType: 'room',
|
||||
title: 'Room Schedule',
|
||||
region: { x: 15, y: 1, width: 6, height: 5 },
|
||||
},
|
||||
],
|
||||
titleBlock: {
|
||||
projectName: 'House',
|
||||
projectNumber: '2401',
|
||||
clientName: 'Owner',
|
||||
},
|
||||
})
|
||||
|
||||
expect(sheet.placedViews[0]).toMatchObject({
|
||||
drawingType: 'floor-plan',
|
||||
levelId: 'level_main',
|
||||
annotationProfile: 'architectural-default',
|
||||
showNorthArrow: true,
|
||||
showGraphicScale: true,
|
||||
})
|
||||
expect(sheet.generalNotes[0]?.text).toBe('VERIFY DIMENSIONS.')
|
||||
expect(sheet.generalNoteSetIds).toEqual(['sheet-note-set_project'])
|
||||
expect(sheet.generalNoteSets[0]).toMatchObject({
|
||||
id: 'sheet-note-set_project',
|
||||
name: 'Project Notes',
|
||||
notes: [{ text: 'COORDINATE WITH OWNER.' }],
|
||||
})
|
||||
expect(sheet.keyedNoteLegend[0]).toEqual({
|
||||
key: 'A',
|
||||
text: 'ALIGN WITH EXISTING WALL.',
|
||||
})
|
||||
expect(sheet.keyedNoteDefinitions[0]).toEqual({
|
||||
id: 'keyed-note_patch-slab',
|
||||
key: 'A',
|
||||
text: 'PATCH EXISTING SLAB.',
|
||||
})
|
||||
expect(sheet.keyedNoteInstances).toHaveLength(2)
|
||||
expect(sheet.keyedNoteInstances[0]).toMatchObject({
|
||||
definitionId: 'keyed-note_patch-slab',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [3.25, 2.5],
|
||||
})
|
||||
expect(sheet.keyedNoteInstances[1]?.placedViewId).toBeNull()
|
||||
expect(sheet.documentMarkers).toHaveLength(2)
|
||||
expect(sheet.documentMarkers[0]).toMatchObject({
|
||||
kind: 'wall-tag',
|
||||
label: 'W1',
|
||||
position: [2, 3],
|
||||
})
|
||||
expect(sheet.documentMarkers[1]).toMatchObject({
|
||||
kind: 'revision-cloud',
|
||||
revisionId: 'A',
|
||||
points: [
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
[2, 2],
|
||||
[1, 2],
|
||||
],
|
||||
})
|
||||
expect(sheet.schedules[0]?.title).toBe('Room Schedule')
|
||||
expect(sheet.titleBlock).toMatchObject({
|
||||
projectName: 'House',
|
||||
projectNumber: '2401',
|
||||
clientName: 'Owner',
|
||||
drawnBy: '',
|
||||
})
|
||||
})
|
||||
|
||||
test('can live under a building instead of a level', () => {
|
||||
const sheet = DrawingSheetNode.parse({ id: 'drawing-sheet_a101' })
|
||||
|
||||
expect(BuildingNode.parse({ children: ['level_main', sheet.id] }).children).toEqual([
|
||||
'level_main',
|
||||
sheet.id,
|
||||
])
|
||||
})
|
||||
|
||||
test('remaps sheet-local identities and their references together', () => {
|
||||
const sheet = DrawingSheetNode.parse({
|
||||
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
|
||||
generalNoteSetIds: ['sheet-note-set_project'],
|
||||
generalNoteSets: [
|
||||
{
|
||||
id: 'sheet-note-set_project',
|
||||
notes: [{ id: 'sheet-note_set-1', number: 1, text: 'SET NOTE' }],
|
||||
},
|
||||
],
|
||||
generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'SHEET NOTE' }],
|
||||
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'KEYED NOTE' }],
|
||||
keyedNoteInstances: [
|
||||
{
|
||||
id: 'keyed-note-instance_a1',
|
||||
definitionId: 'keyed-note_a',
|
||||
placedViewId: 'drawing-view_main',
|
||||
},
|
||||
],
|
||||
documentMarkers: [{ id: 'sheet-marker_a', placedViewId: 'drawing-view_main', label: 'A' }],
|
||||
schedules: [{ id: 'sheet-schedule_a' }],
|
||||
})
|
||||
const remapped = remapDrawingSheetReferences(sheet, new Map([['level_main', 'level_cloned']]))
|
||||
|
||||
expect(remapped.placedViews[0]?.id).not.toBe(sheet.placedViews[0]?.id)
|
||||
expect(remapped.placedViews[0]?.levelId).toBe('level_cloned')
|
||||
expect(remapped.generalNoteSetIds[0]).toBe(remapped.generalNoteSets[0]?.id)
|
||||
expect(remapped.generalNoteSets[0]?.notes[0]?.id).not.toBe(
|
||||
sheet.generalNoteSets[0]?.notes[0]?.id,
|
||||
)
|
||||
expect(remapped.generalNotes[0]?.id).not.toBe(sheet.generalNotes[0]?.id)
|
||||
expect(remapped.keyedNoteInstances[0]?.definitionId).toBe(remapped.keyedNoteDefinitions[0]?.id)
|
||||
expect(remapped.keyedNoteInstances[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
|
||||
expect(remapped.documentMarkers[0]?.placedViewId).toBe(remapped.placedViews[0]?.id)
|
||||
expect(remapped.keyedNoteInstances[0]?.id).not.toBe(sheet.keyedNoteInstances[0]?.id)
|
||||
expect(remapped.documentMarkers[0]?.id).not.toBe(sheet.documentMarkers[0]?.id)
|
||||
expect(remapped.schedules[0]?.id).not.toBe(sheet.schedules[0]?.id)
|
||||
})
|
||||
})
|
||||
@@ -1,263 +0,0 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, generateId, nodeType, objectId } from '../base'
|
||||
import { ConstructionDrawingType } from './construction-dimension'
|
||||
|
||||
const PositiveFinite = z.number().finite().positive()
|
||||
const SheetCoordinate = z.number().finite().min(0)
|
||||
|
||||
export const DrawingSheetPaperSize = z.enum([
|
||||
'letter',
|
||||
'tabloid',
|
||||
'arch-a',
|
||||
'arch-b',
|
||||
'arch-c',
|
||||
'a4',
|
||||
'a3',
|
||||
'custom',
|
||||
])
|
||||
export const DrawingSheetOrientation = z.enum(['portrait', 'landscape'])
|
||||
export const DrawingSheetScale = z.enum([
|
||||
'1:20',
|
||||
'1:25',
|
||||
'1:50',
|
||||
'1:75',
|
||||
'1:100',
|
||||
'1/8"=1\'-0"',
|
||||
'1/4"=1\'-0"',
|
||||
'1/2"=1\'-0"',
|
||||
'1"=1\'-0"',
|
||||
])
|
||||
export const DrawingSheetAnnotationProfile = z.enum([
|
||||
'architectural-default',
|
||||
'presentation',
|
||||
'permit',
|
||||
])
|
||||
|
||||
export const DrawingSheetRect = z.object({
|
||||
x: SheetCoordinate.default(0),
|
||||
y: SheetCoordinate.default(0),
|
||||
width: PositiveFinite.default(1),
|
||||
height: PositiveFinite.default(1),
|
||||
})
|
||||
|
||||
export const DrawingSheetPlacedView = z.object({
|
||||
id: objectId('drawing-view'),
|
||||
drawingType: ConstructionDrawingType.default('floor-plan'),
|
||||
drawingNumber: z.string().trim().min(1).max(24).default('1'),
|
||||
title: z.string().trim().min(1).max(80).default('Floor Plan'),
|
||||
levelId: objectId('level').nullable().default(null),
|
||||
scale: DrawingSheetScale.default('1/4"=1\'-0"'),
|
||||
viewport: DrawingSheetRect.default({ x: 0.5, y: 0.5, width: 7, height: 5 }),
|
||||
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
|
||||
showNorthArrow: z.boolean().default(true),
|
||||
showGraphicScale: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export const DrawingSheetGeneralNote = z.object({
|
||||
id: objectId('sheet-note'),
|
||||
number: z.number().int().positive().default(1),
|
||||
text: z.string().trim().min(1).max(500).default('GENERAL NOTE'),
|
||||
})
|
||||
|
||||
export const DrawingSheetGeneralNoteSet = z.object({
|
||||
id: objectId('sheet-note-set'),
|
||||
name: z.string().trim().min(1).max(80).default('General Notes'),
|
||||
notes: z.array(DrawingSheetGeneralNote).max(200).default([]),
|
||||
})
|
||||
|
||||
export const DrawingSheetKeyedNote = z.object({
|
||||
key: z.string().trim().min(1).max(16).default('1'),
|
||||
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
|
||||
})
|
||||
|
||||
export const DrawingSheetKeyedNoteDefinition = z.object({
|
||||
id: objectId('keyed-note'),
|
||||
key: z.string().trim().min(1).max(16).default('1'),
|
||||
text: z.string().trim().min(1).max(500).default('KEYED NOTE'),
|
||||
})
|
||||
|
||||
export const DrawingSheetKeyedNoteInstance = z.object({
|
||||
id: objectId('keyed-note-instance'),
|
||||
definitionId: DrawingSheetKeyedNoteDefinition.shape.id,
|
||||
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
|
||||
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
|
||||
})
|
||||
|
||||
export const DrawingSheetDocumentMarkerKind = z.enum([
|
||||
'wall-tag',
|
||||
'glazing-tag',
|
||||
'assembly-tag',
|
||||
'section-callout',
|
||||
'elevation-callout',
|
||||
'detail-reference',
|
||||
'delta-marker',
|
||||
'revision-cloud',
|
||||
])
|
||||
|
||||
export const DrawingSheetDocumentMarker = z.object({
|
||||
id: objectId('sheet-marker'),
|
||||
kind: DrawingSheetDocumentMarkerKind.default('detail-reference'),
|
||||
placedViewId: DrawingSheetPlacedView.shape.id.nullable().default(null),
|
||||
label: z.string().trim().min(1).max(32).default('1'),
|
||||
title: z.string().trim().max(120).default(''),
|
||||
sheetReference: z.string().trim().max(24).default(''),
|
||||
drawingReference: z.string().trim().max(24).default(''),
|
||||
revisionId: z.string().trim().max(16).default(''),
|
||||
position: z.tuple([SheetCoordinate, SheetCoordinate]).default([0.5, 0.5]),
|
||||
endPosition: z.tuple([SheetCoordinate, SheetCoordinate]).nullable().default(null),
|
||||
points: z
|
||||
.array(z.tuple([SheetCoordinate, SheetCoordinate]))
|
||||
.max(64)
|
||||
.default([]),
|
||||
})
|
||||
|
||||
export const DrawingSheetSchedulePlacement = z.object({
|
||||
id: objectId('sheet-schedule'),
|
||||
scheduleType: z.enum(['room', 'door', 'window', 'finish', 'custom']).default('room'),
|
||||
title: z.string().trim().min(1).max(80).default('Room Schedule'),
|
||||
region: DrawingSheetRect.default({ x: 0.5, y: 6, width: 4, height: 1.5 }),
|
||||
})
|
||||
|
||||
export const DrawingSheetTitleBlock = z.object({
|
||||
projectName: z.string().trim().max(120).default(''),
|
||||
projectNumber: z.string().trim().max(40).default(''),
|
||||
clientName: z.string().trim().max(120).default(''),
|
||||
drawnBy: z.string().trim().max(40).default(''),
|
||||
checkedBy: z.string().trim().max(40).default(''),
|
||||
issueDate: z.string().trim().max(40).default(''),
|
||||
revision: z.string().trim().max(20).default(''),
|
||||
})
|
||||
|
||||
const DEFAULT_DRAWING_SHEET_TITLE_BLOCK: DrawingSheetTitleBlock = {
|
||||
projectName: '',
|
||||
projectNumber: '',
|
||||
clientName: '',
|
||||
drawnBy: '',
|
||||
checkedBy: '',
|
||||
issueDate: '',
|
||||
revision: '',
|
||||
}
|
||||
|
||||
export const DrawingSheetNode = BaseNode.extend({
|
||||
id: objectId('drawing-sheet'),
|
||||
type: nodeType('drawing-sheet'),
|
||||
sheetNumber: z.string().trim().min(1).max(24).default('A1.0'),
|
||||
sheetTitle: z.string().trim().min(1).max(100).default('Floor Plan'),
|
||||
paperSize: DrawingSheetPaperSize.default('arch-b'),
|
||||
orientation: DrawingSheetOrientation.default('landscape'),
|
||||
customPaperWidth: PositiveFinite.nullable().default(null),
|
||||
customPaperHeight: PositiveFinite.nullable().default(null),
|
||||
placedViews: z.array(DrawingSheetPlacedView).max(32).default([]),
|
||||
annotationProfile: DrawingSheetAnnotationProfile.default('architectural-default'),
|
||||
generalNoteSetIds: z.array(DrawingSheetGeneralNoteSet.shape.id).max(32).default([]),
|
||||
generalNoteSets: z.array(DrawingSheetGeneralNoteSet).max(64).default([]),
|
||||
generalNotes: z.array(DrawingSheetGeneralNote).max(200).default([]),
|
||||
keyedNoteDefinitions: z.array(DrawingSheetKeyedNoteDefinition).max(200).default([]),
|
||||
keyedNoteInstances: z.array(DrawingSheetKeyedNoteInstance).max(500).default([]),
|
||||
keyedNoteLegend: z.array(DrawingSheetKeyedNote).max(200).default([]),
|
||||
documentMarkers: z.array(DrawingSheetDocumentMarker).max(500).default([]),
|
||||
schedules: z.array(DrawingSheetSchedulePlacement).max(32).default([]),
|
||||
titleBlock: DrawingSheetTitleBlock.default(DEFAULT_DRAWING_SHEET_TITLE_BLOCK),
|
||||
}).describe(
|
||||
dedent`
|
||||
Drawing sheet node - persistent construction-document sheet metadata
|
||||
- sheetNumber/sheetTitle: sheet identity in the drawing set
|
||||
- paperSize/orientation/customPaperWidth/customPaperHeight: plotted sheet definition
|
||||
- placedViews: drawing views with numbers, titles, fixed scales, viewport regions, and annotation profiles
|
||||
- generalNoteSets/generalNoteSetIds/generalNotes: reusable project notes plus sheet-level numbered notes
|
||||
- keyedNoteDefinitions/keyedNoteInstances/keyedNoteLegend: stable keyed notes, repeated symbols, and legacy legend entries
|
||||
- documentMarkers: wall/glazing/assembly tags, callouts, detail references, deltas, and revision clouds
|
||||
- schedules/titleBlock: sheet-level documentation content and title-block metadata
|
||||
`,
|
||||
)
|
||||
|
||||
export type DrawingSheetPaperSize = z.infer<typeof DrawingSheetPaperSize>
|
||||
export type DrawingSheetOrientation = z.infer<typeof DrawingSheetOrientation>
|
||||
export type DrawingSheetScale = z.infer<typeof DrawingSheetScale>
|
||||
export type DrawingSheetAnnotationProfile = z.infer<typeof DrawingSheetAnnotationProfile>
|
||||
export type DrawingSheetRect = z.infer<typeof DrawingSheetRect>
|
||||
export type DrawingSheetPlacedView = z.infer<typeof DrawingSheetPlacedView>
|
||||
export type DrawingSheetGeneralNote = z.infer<typeof DrawingSheetGeneralNote>
|
||||
export type DrawingSheetGeneralNoteSet = z.infer<typeof DrawingSheetGeneralNoteSet>
|
||||
export type DrawingSheetKeyedNote = z.infer<typeof DrawingSheetKeyedNote>
|
||||
export type DrawingSheetKeyedNoteDefinition = z.infer<typeof DrawingSheetKeyedNoteDefinition>
|
||||
export type DrawingSheetKeyedNoteInstance = z.infer<typeof DrawingSheetKeyedNoteInstance>
|
||||
export type DrawingSheetDocumentMarker = z.infer<typeof DrawingSheetDocumentMarker>
|
||||
export type DrawingSheetDocumentMarkerKind = z.infer<typeof DrawingSheetDocumentMarkerKind>
|
||||
export type DrawingSheetSchedulePlacement = z.infer<typeof DrawingSheetSchedulePlacement>
|
||||
export type DrawingSheetTitleBlock = z.infer<typeof DrawingSheetTitleBlock>
|
||||
export type DrawingSheetNode = z.infer<typeof DrawingSheetNode>
|
||||
|
||||
/**
|
||||
* Rewrites every scene and sheet-local identity carried by a drawing sheet.
|
||||
* External scene references are preserved when they are not present in
|
||||
* `sceneIdMap`, which keeps a duplicated sheet attached to its existing level.
|
||||
*/
|
||||
export function remapDrawingSheetReferences(
|
||||
sheet: DrawingSheetNode,
|
||||
sceneIdMap: ReadonlyMap<string, string>,
|
||||
): DrawingSheetNode {
|
||||
const placedViewIds = new Map(
|
||||
sheet.placedViews.map((view) => [view.id, generateId('drawing-view')] as const),
|
||||
)
|
||||
const noteSetIds = new Map(
|
||||
sheet.generalNoteSets.map((set) => [set.id, generateId('sheet-note-set')] as const),
|
||||
)
|
||||
const noteIds = new Map(
|
||||
[...sheet.generalNotes, ...sheet.generalNoteSets.flatMap((set) => set.notes)].map(
|
||||
(note) => [note.id, generateId('sheet-note')] as const,
|
||||
),
|
||||
)
|
||||
const keyedDefinitionIds = new Map(
|
||||
sheet.keyedNoteDefinitions.map(
|
||||
(definition) => [definition.id, generateId('keyed-note')] as const,
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
...sheet,
|
||||
placedViews: sheet.placedViews.map((view) => ({
|
||||
...view,
|
||||
id: placedViewIds.get(view.id)!,
|
||||
levelId: view.levelId
|
||||
? ((sceneIdMap.get(view.levelId) ?? view.levelId) as typeof view.levelId)
|
||||
: null,
|
||||
})),
|
||||
generalNoteSetIds: sheet.generalNoteSetIds.map(
|
||||
(id) => (noteSetIds.get(id) ?? id) as DrawingSheetNode['generalNoteSetIds'][number],
|
||||
),
|
||||
generalNoteSets: sheet.generalNoteSets.map((set) => ({
|
||||
...set,
|
||||
id: noteSetIds.get(set.id)!,
|
||||
notes: set.notes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
|
||||
})),
|
||||
generalNotes: sheet.generalNotes.map((note) => ({ ...note, id: noteIds.get(note.id)! })),
|
||||
keyedNoteDefinitions: sheet.keyedNoteDefinitions.map((definition) => ({
|
||||
...definition,
|
||||
id: keyedDefinitionIds.get(definition.id)!,
|
||||
})),
|
||||
keyedNoteInstances: sheet.keyedNoteInstances.map((instance) => ({
|
||||
...instance,
|
||||
id: generateId('keyed-note-instance'),
|
||||
definitionId: (keyedDefinitionIds.get(instance.definitionId) ??
|
||||
instance.definitionId) as typeof instance.definitionId,
|
||||
placedViewId: instance.placedViewId
|
||||
? ((placedViewIds.get(instance.placedViewId) ??
|
||||
instance.placedViewId) as typeof instance.placedViewId)
|
||||
: null,
|
||||
})),
|
||||
documentMarkers: sheet.documentMarkers.map((marker) => ({
|
||||
...marker,
|
||||
id: generateId('sheet-marker'),
|
||||
placedViewId: marker.placedViewId
|
||||
? ((placedViewIds.get(marker.placedViewId) ??
|
||||
marker.placedViewId) as typeof marker.placedViewId)
|
||||
: null,
|
||||
})),
|
||||
schedules: sheet.schedules.map((schedule) => ({
|
||||
...schedule,
|
||||
id: generateId('sheet-schedule'),
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,7 @@ import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
buildEnabledWallFaceBandPatch,
|
||||
buildWallFaceBandCountPatch,
|
||||
getWallAssemblyDatumReferenceId,
|
||||
getWallAssemblyFaceOffsets,
|
||||
getWallAssemblyThickness,
|
||||
getWallDatumEligibleLayers,
|
||||
getWallFaceBandConfig,
|
||||
resolveWallAssemblyDatumReference,
|
||||
resolveWallAssemblyDatumReferences,
|
||||
WALL_CHAIR_RAIL_DEFAULT,
|
||||
WALL_CHAIR_RAIL_SLOT_DEFAULT,
|
||||
WALL_CROWN_DEFAULT,
|
||||
@@ -19,7 +13,6 @@ import {
|
||||
WALL_SKIRTING_SLOT_DEFAULT,
|
||||
WALL_SURFACE_SLOT_DEFAULTS,
|
||||
WallFaceBandConfig,
|
||||
WallNode,
|
||||
type WallNode as WallNodeType,
|
||||
WallTrimConfig,
|
||||
} from './wall'
|
||||
@@ -267,206 +260,3 @@ describe('wall trim profiles', () => {
|
||||
expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wall assembly layers', () => {
|
||||
test('defaults to legacy thickness when no assembly layers are modeled', () => {
|
||||
const wall = WallNode.parse({
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
thickness: 0.14,
|
||||
})
|
||||
|
||||
expect(wall.assemblyLayers).toEqual([])
|
||||
expect(getWallAssemblyThickness(wall)).toBe(0.14)
|
||||
})
|
||||
|
||||
test('stores role, side, thickness, material reference, and datum eligibility', () => {
|
||||
const wall = WallNode.parse({
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.09,
|
||||
materialRef: 'library:wood-framing',
|
||||
datumEligible: ['centerline', 'structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'interior-gwb',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.016,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'brick-veneer',
|
||||
role: 'masonry-veneer',
|
||||
side: 'exterior',
|
||||
thickness: 0.09,
|
||||
materialRef: 'library:brick',
|
||||
datumEligible: ['veneer-face', 'finish-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(getWallAssemblyThickness(wall)).toBeCloseTo(0.196)
|
||||
expect(getWallDatumEligibleLayers(wall, 'finish-face').map((layer) => layer.id)).toEqual([
|
||||
'interior-gwb',
|
||||
'brick-veneer',
|
||||
])
|
||||
expect(getWallDatumEligibleLayers(wall, 'structural-face')).toMatchObject([
|
||||
{ id: 'stud-core', role: 'structure', side: 'core' },
|
||||
])
|
||||
expect(getWallAssemblyFaceOffsets(wall)).toEqual({
|
||||
interior: -0.061,
|
||||
exterior: 0.135,
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves stable datum references for legacy single-thickness walls', () => {
|
||||
const wall = WallNode.parse({
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
thickness: 0.14,
|
||||
})
|
||||
|
||||
expect(resolveWallAssemblyDatumReferences(wall)).toEqual([
|
||||
{ id: 'wall:centerline:center', datum: 'centerline', side: 'center', offset: 0 },
|
||||
{
|
||||
id: 'wall:structural-face:interior',
|
||||
datum: 'structural-face',
|
||||
side: 'interior',
|
||||
offset: -0.07,
|
||||
},
|
||||
{
|
||||
id: 'wall:structural-face:exterior',
|
||||
datum: 'structural-face',
|
||||
side: 'exterior',
|
||||
offset: 0.07,
|
||||
},
|
||||
{
|
||||
id: 'wall:finish-face:interior',
|
||||
datum: 'finish-face',
|
||||
side: 'interior',
|
||||
offset: -0.07,
|
||||
},
|
||||
{
|
||||
id: 'wall:finish-face:exterior',
|
||||
datum: 'finish-face',
|
||||
side: 'exterior',
|
||||
offset: 0.07,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('resolves layer-owned centerline, structural, finish, and veneer datum references', () => {
|
||||
const wall = WallNode.parse({
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.09,
|
||||
materialRef: 'library:wood-framing',
|
||||
datumEligible: ['centerline', 'structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'interior-gwb',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.016,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'exterior-sheathing',
|
||||
role: 'exterior-sheathing',
|
||||
side: 'exterior',
|
||||
thickness: 0.012,
|
||||
materialRef: 'library:sheathing',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'brick-veneer',
|
||||
role: 'masonry-veneer',
|
||||
side: 'exterior',
|
||||
thickness: 0.09,
|
||||
materialRef: 'library:brick',
|
||||
datumEligible: ['veneer-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const references = resolveWallAssemblyDatumReferences(wall)
|
||||
|
||||
expect(references).toContainEqual({
|
||||
id: 'wall:centerline:center',
|
||||
datum: 'centerline',
|
||||
side: 'center',
|
||||
offset: 0,
|
||||
})
|
||||
expect(references).toContainEqual({
|
||||
id: 'wall:structural-face:interior:stud-core',
|
||||
datum: 'structural-face',
|
||||
side: 'interior',
|
||||
layerId: 'stud-core',
|
||||
offset: -0.045,
|
||||
})
|
||||
expect(references).toContainEqual({
|
||||
id: 'wall:structural-face:exterior:stud-core',
|
||||
datum: 'structural-face',
|
||||
side: 'exterior',
|
||||
layerId: 'stud-core',
|
||||
offset: 0.045,
|
||||
})
|
||||
expect(references).toContainEqual({
|
||||
id: 'wall:finish-face:interior:interior-gwb',
|
||||
datum: 'finish-face',
|
||||
side: 'interior',
|
||||
layerId: 'interior-gwb',
|
||||
offset: -0.061,
|
||||
})
|
||||
expect(
|
||||
references.find(
|
||||
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
|
||||
),
|
||||
).toMatchObject({
|
||||
datum: 'finish-face',
|
||||
side: 'exterior',
|
||||
layerId: 'exterior-sheathing',
|
||||
})
|
||||
expect(
|
||||
references.find(
|
||||
(reference) => reference.id === 'wall:finish-face:exterior:exterior-sheathing',
|
||||
)?.offset,
|
||||
).toBeCloseTo(0.057)
|
||||
|
||||
expect(
|
||||
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer'),
|
||||
).toMatchObject({
|
||||
datum: 'veneer-face',
|
||||
side: 'exterior',
|
||||
layerId: 'brick-veneer',
|
||||
})
|
||||
expect(
|
||||
references.find((reference) => reference.id === 'wall:veneer-face:exterior:brick-veneer')
|
||||
?.offset,
|
||||
).toBeCloseTo(0.147)
|
||||
expect(
|
||||
resolveWallAssemblyDatumReference(
|
||||
wall,
|
||||
getWallAssemblyDatumReferenceId('veneer-face', 'exterior', 'brick-veneer'),
|
||||
),
|
||||
).toMatchObject({
|
||||
datum: 'veneer-face',
|
||||
side: 'exterior',
|
||||
layerId: 'brick-veneer',
|
||||
offset: 0.147,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -127,48 +127,6 @@ export const WALL_SURFACE_SLOT_DEFAULTS = {
|
||||
|
||||
export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS
|
||||
|
||||
export const WallAssemblyLayerRole = z.enum([
|
||||
'structure',
|
||||
'interior-finish',
|
||||
'exterior-sheathing',
|
||||
'exterior-finish',
|
||||
'masonry-veneer',
|
||||
'air-space',
|
||||
'concrete-block',
|
||||
'structural-masonry',
|
||||
'solid-concrete',
|
||||
'furring',
|
||||
])
|
||||
export type WallAssemblyLayerRole = z.infer<typeof WallAssemblyLayerRole>
|
||||
|
||||
export const WallDimensionDatum = z.enum([
|
||||
'centerline',
|
||||
'structural-face',
|
||||
'finish-face',
|
||||
'veneer-face',
|
||||
])
|
||||
export type WallDimensionDatum = z.infer<typeof WallDimensionDatum>
|
||||
|
||||
export const WallAssemblyLayer = z.object({
|
||||
id: z.string().trim().min(1).max(80).default('structure'),
|
||||
role: WallAssemblyLayerRole.default('structure'),
|
||||
side: z.enum(['core', 'interior', 'exterior']).default('core'),
|
||||
thickness: z.number().finite().positive().default(0.1),
|
||||
materialRef: z.string().trim().max(120).default(''),
|
||||
datumEligible: z.array(WallDimensionDatum).max(8).default([]),
|
||||
})
|
||||
export type WallAssemblyLayer = z.infer<typeof WallAssemblyLayer>
|
||||
|
||||
export type WallAssemblyDatumSide = 'center' | 'interior' | 'exterior'
|
||||
|
||||
export type WallAssemblyDatumReference = {
|
||||
id: string
|
||||
datum: WallDimensionDatum
|
||||
side: WallAssemblyDatumSide
|
||||
layerId?: string
|
||||
offset: number
|
||||
}
|
||||
|
||||
export const WallNode = BaseNode.extend({
|
||||
id: objectId('wall'),
|
||||
type: nodeType('wall'),
|
||||
@@ -191,7 +149,6 @@ export const WallNode = BaseNode.extend({
|
||||
// in a follow-up once migrated scenes are the norm.
|
||||
slots: z.record(z.string(), z.string()).optional(),
|
||||
thickness: z.number().optional(),
|
||||
assemblyLayers: z.array(WallAssemblyLayer).max(32).default([]),
|
||||
height: z.number().optional(),
|
||||
curveOffset: z.number().optional(),
|
||||
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
|
||||
@@ -210,7 +167,6 @@ export const WallNode = BaseNode.extend({
|
||||
dedent`
|
||||
Wall node - used to represent a wall in the building
|
||||
- thickness: thickness in meters
|
||||
- assemblyLayers: construction layers with role, side, thickness, material reference, and datum eligibility
|
||||
- height: height in meters
|
||||
- curveOffset: midpoint sagitta offset used to bend the wall into an arc
|
||||
- start: start point of the wall in level coordinate system
|
||||
@@ -234,222 +190,6 @@ export type WallBandSurfaceSlotId =
|
||||
| 'upperExterior'
|
||||
| 'topExterior'
|
||||
|
||||
export function getWallAssemblyLayers(wall: Pick<WallNode, 'assemblyLayers'>): WallAssemblyLayer[] {
|
||||
return wall.assemblyLayers ?? []
|
||||
}
|
||||
|
||||
export function getWallAssemblyThickness(
|
||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
||||
): number {
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
if (layers.length === 0) return wall.thickness ?? 0.1
|
||||
return layers.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
}
|
||||
|
||||
export function getWallAssemblyFaceOffsets(wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>): {
|
||||
interior: number
|
||||
exterior: number
|
||||
} {
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
if (layers.length === 0) {
|
||||
const halfThickness = (wall.thickness ?? 0.1) / 2
|
||||
return { interior: -halfThickness, exterior: halfThickness }
|
||||
}
|
||||
|
||||
const coreLayers = layers.filter((layer) => layer.side === 'core')
|
||||
const coreThickness =
|
||||
coreLayers.length > 0
|
||||
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
: (wall.thickness ?? 0.1)
|
||||
const interiorFinishThickness = layers
|
||||
.filter((layer) => layer.side === 'interior')
|
||||
.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
const exteriorFinishThickness = layers
|
||||
.filter((layer) => layer.side === 'exterior')
|
||||
.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
|
||||
return {
|
||||
interior: -coreThickness / 2 - interiorFinishThickness,
|
||||
exterior: coreThickness / 2 + exteriorFinishThickness,
|
||||
}
|
||||
}
|
||||
|
||||
export function getWallDatumEligibleLayers(
|
||||
wall: Pick<WallNode, 'assemblyLayers'>,
|
||||
datum: WallDimensionDatum,
|
||||
): WallAssemblyLayer[] {
|
||||
return (wall.assemblyLayers ?? []).filter((layer) => layer.datumEligible.includes(datum))
|
||||
}
|
||||
|
||||
export function getWallAssemblyDatumReferenceId(
|
||||
datum: WallDimensionDatum,
|
||||
side: WallAssemblyDatumSide,
|
||||
layerId?: string,
|
||||
): string {
|
||||
return ['wall', datum, side, layerId].filter(Boolean).join(':')
|
||||
}
|
||||
|
||||
type WallAssemblyLayerSpan = {
|
||||
layer: WallAssemblyLayer
|
||||
interiorOffset: number
|
||||
exteriorOffset: number
|
||||
}
|
||||
|
||||
function getWallAssemblyLayerSpans(
|
||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
||||
): WallAssemblyLayerSpan[] {
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
if (layers.length === 0) return []
|
||||
|
||||
const coreLayers = layers.filter((layer) => layer.side === 'core')
|
||||
const coreThickness =
|
||||
coreLayers.length > 0
|
||||
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
: (wall.thickness ?? 0.1)
|
||||
const coreInteriorFace = -coreThickness / 2
|
||||
const coreExteriorFace = coreThickness / 2
|
||||
const spans: WallAssemblyLayerSpan[] = []
|
||||
|
||||
let coreOffset = coreInteriorFace
|
||||
for (const layer of coreLayers) {
|
||||
const interiorOffset = coreOffset
|
||||
const exteriorOffset = coreOffset + layer.thickness
|
||||
spans.push({ layer, interiorOffset, exteriorOffset })
|
||||
coreOffset = exteriorOffset
|
||||
}
|
||||
|
||||
let interiorOffset = coreInteriorFace
|
||||
for (const layer of layers.filter((candidate) => candidate.side === 'interior')) {
|
||||
const exteriorOffset = interiorOffset
|
||||
const nextInteriorOffset = exteriorOffset - layer.thickness
|
||||
spans.push({ layer, interiorOffset: nextInteriorOffset, exteriorOffset })
|
||||
interiorOffset = nextInteriorOffset
|
||||
}
|
||||
|
||||
let exteriorOffset = coreExteriorFace
|
||||
for (const layer of layers.filter((candidate) => candidate.side === 'exterior')) {
|
||||
const interiorFaceOffset = exteriorOffset
|
||||
const nextExteriorOffset = interiorFaceOffset + layer.thickness
|
||||
spans.push({ layer, interiorOffset: interiorFaceOffset, exteriorOffset: nextExteriorOffset })
|
||||
exteriorOffset = nextExteriorOffset
|
||||
}
|
||||
|
||||
return spans
|
||||
}
|
||||
|
||||
function createWallAssemblyDatumReference(
|
||||
datum: WallDimensionDatum,
|
||||
side: WallAssemblyDatumSide,
|
||||
offset: number,
|
||||
layerId?: string,
|
||||
): WallAssemblyDatumReference {
|
||||
return {
|
||||
id: getWallAssemblyDatumReferenceId(datum, side, layerId),
|
||||
datum,
|
||||
side,
|
||||
...(layerId ? { layerId } : {}),
|
||||
offset,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWallAssemblyDatumReferences(
|
||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
||||
): WallAssemblyDatumReference[] {
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
const references: WallAssemblyDatumReference[] = [
|
||||
createWallAssemblyDatumReference('centerline', 'center', 0),
|
||||
]
|
||||
|
||||
if (layers.length === 0) {
|
||||
const halfThickness = (wall.thickness ?? 0.1) / 2
|
||||
return [
|
||||
...references,
|
||||
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
|
||||
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
|
||||
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
|
||||
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
|
||||
]
|
||||
}
|
||||
|
||||
const spans = getWallAssemblyLayerSpans(wall)
|
||||
|
||||
for (const span of spans) {
|
||||
if (span.layer.datumEligible.includes('structural-face')) {
|
||||
if (span.layer.side === 'core') {
|
||||
references.push(
|
||||
createWallAssemblyDatumReference(
|
||||
'structural-face',
|
||||
'interior',
|
||||
span.interiorOffset,
|
||||
span.layer.id,
|
||||
),
|
||||
createWallAssemblyDatumReference(
|
||||
'structural-face',
|
||||
'exterior',
|
||||
span.exteriorOffset,
|
||||
span.layer.id,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
const side = span.layer.side
|
||||
references.push(
|
||||
createWallAssemblyDatumReference(
|
||||
'structural-face',
|
||||
side,
|
||||
side === 'interior' ? span.interiorOffset : span.exteriorOffset,
|
||||
span.layer.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (span.layer.datumEligible.includes('finish-face')) {
|
||||
const side = span.layer.side === 'core' ? 'center' : span.layer.side
|
||||
const offset =
|
||||
span.layer.side === 'interior'
|
||||
? span.interiorOffset
|
||||
: span.layer.side === 'exterior'
|
||||
? span.exteriorOffset
|
||||
: (span.interiorOffset + span.exteriorOffset) / 2
|
||||
references.push(createWallAssemblyDatumReference('finish-face', side, offset, span.layer.id))
|
||||
}
|
||||
|
||||
if (span.layer.datumEligible.includes('veneer-face')) {
|
||||
const side = span.layer.side === 'interior' ? 'interior' : 'exterior'
|
||||
const offset = side === 'interior' ? span.interiorOffset : span.exteriorOffset
|
||||
references.push(createWallAssemblyDatumReference('veneer-face', side, offset, span.layer.id))
|
||||
}
|
||||
}
|
||||
|
||||
if (!references.some((reference) => reference.datum === 'structural-face')) {
|
||||
const halfThickness = getWallAssemblyThickness(wall) / 2
|
||||
references.push(
|
||||
createWallAssemblyDatumReference('structural-face', 'interior', -halfThickness),
|
||||
createWallAssemblyDatumReference('structural-face', 'exterior', halfThickness),
|
||||
)
|
||||
}
|
||||
|
||||
if (!references.some((reference) => reference.datum === 'finish-face')) {
|
||||
const halfThickness = getWallAssemblyThickness(wall) / 2
|
||||
references.push(
|
||||
createWallAssemblyDatumReference('finish-face', 'interior', -halfThickness),
|
||||
createWallAssemblyDatumReference('finish-face', 'exterior', halfThickness),
|
||||
)
|
||||
}
|
||||
|
||||
return references
|
||||
}
|
||||
|
||||
export function resolveWallAssemblyDatumReference(
|
||||
wall: Pick<WallNode, 'assemblyLayers' | 'thickness'>,
|
||||
referenceId: string,
|
||||
): WallAssemblyDatumReference | null {
|
||||
return (
|
||||
resolveWallAssemblyDatumReferences(wall).find((reference) => reference.id === referenceId) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
// Declared default appearance for an unpainted wall face in colored mode —
|
||||
// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the
|
||||
// slot declaration (nodes) and the material resolver (viewer) share one value.
|
||||
|
||||
@@ -10,7 +10,6 @@ import { CupolaNode } from './nodes/cupola'
|
||||
import { DoorNode } from './nodes/door'
|
||||
import { DormerNode } from './nodes/dormer'
|
||||
import { DownspoutNode } from './nodes/downspout'
|
||||
import { DrawingSheetNode } from './nodes/drawing-sheet'
|
||||
import { DuctFittingNode } from './nodes/duct-fitting'
|
||||
import { DuctSegmentNode } from './nodes/duct-segment'
|
||||
import { DuctTerminalNode } from './nodes/duct-terminal'
|
||||
@@ -84,7 +83,6 @@ export const AnyNode = z.discriminatedUnion('type', [
|
||||
SkylightNode,
|
||||
DormerNode,
|
||||
DownspoutNode,
|
||||
DrawingSheetNode,
|
||||
DuctSegmentNode,
|
||||
DuctFittingNode,
|
||||
DuctTerminalNode,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, AnyNode as AnyNodeSchema } from '../schema'
|
||||
import useScene from './use-scene'
|
||||
|
||||
function resetScene() {
|
||||
useScene.setState({
|
||||
nodes: {},
|
||||
rootNodeIds: [],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
materials: {},
|
||||
} as never)
|
||||
useScene.temporal.getState().clear()
|
||||
}
|
||||
|
||||
function baseScene(levelChildren: string[]): Record<string, AnyNode> {
|
||||
return {
|
||||
site_test: {
|
||||
object: 'node',
|
||||
id: 'site_test',
|
||||
type: 'site',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: ['building_test'],
|
||||
},
|
||||
building_test: {
|
||||
object: 'node',
|
||||
id: 'building_test',
|
||||
type: 'building',
|
||||
parentId: 'site_test',
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: ['level_test'],
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
},
|
||||
level_test: {
|
||||
object: 'node',
|
||||
id: 'level_test',
|
||||
type: 'level',
|
||||
parentId: 'building_test',
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: levelChildren,
|
||||
level: 0,
|
||||
height: 2.5,
|
||||
},
|
||||
} as unknown as Record<string, AnyNode>
|
||||
}
|
||||
|
||||
describe('retired floor-plan data migration', () => {
|
||||
beforeEach(resetScene)
|
||||
|
||||
test('removes drawing-sheet nodes and their parent references', () => {
|
||||
const nodes = baseScene([])
|
||||
;(nodes.building_test as { children: string[] }).children.push('drawing-sheet_a101')
|
||||
;(nodes as Record<string, unknown>)['drawing-sheet_a101'] = {
|
||||
object: 'node',
|
||||
id: 'drawing-sheet_a101',
|
||||
type: 'drawing-sheet',
|
||||
parentId: 'building_test',
|
||||
visible: true,
|
||||
metadata: {},
|
||||
sheetNumber: 'A1.01',
|
||||
sheetTitle: 'Floor Plan',
|
||||
}
|
||||
|
||||
useScene.getState().setScene(nodes, ['site_test'] as never)
|
||||
|
||||
const migrated = useScene.getState().nodes
|
||||
expect(migrated['drawing-sheet_a101' as keyof typeof migrated]).toBeUndefined()
|
||||
expect((migrated.building_test as { children: string[] }).children).toEqual(['level_test'])
|
||||
expect(Object.values(migrated).every((node) => AnyNodeSchema.safeParse(node).success)).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('converts wall assembly width to plain thickness and removes the legacy field', () => {
|
||||
const nodes = baseScene(['wall_test'])
|
||||
;(nodes as Record<string, unknown>).wall_test = {
|
||||
object: 'node',
|
||||
id: 'wall_test',
|
||||
type: 'wall',
|
||||
parentId: 'level_test',
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
thickness: 0.1,
|
||||
assemblyLayers: [
|
||||
{ id: 'finish', role: 'interior-finish', side: 'interior', thickness: 0.0125 },
|
||||
{ id: 'stud', role: 'structure', side: 'core', thickness: 0.1 },
|
||||
{ id: 'sheathing', role: 'exterior-sheathing', side: 'exterior', thickness: 0.02 },
|
||||
],
|
||||
}
|
||||
|
||||
useScene.getState().setScene(nodes, ['site_test'] as never)
|
||||
|
||||
const wall = useScene.getState().nodes.wall_test as AnyNode & Record<string, unknown>
|
||||
expect(wall.thickness).toBeCloseTo(0.1325)
|
||||
expect(Object.hasOwn(wall, 'assemblyLayers')).toBe(false)
|
||||
expect(AnyNodeSchema.safeParse(wall).success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -591,6 +591,42 @@ function migrateConstructionDimension(node: Record<string, any>) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeRetiredDrawingSheets(nodes: Record<string, any>) {
|
||||
const retiredIds = new Set(
|
||||
Object.entries(nodes)
|
||||
.filter(([, node]) => node?.type === 'drawing-sheet')
|
||||
.map(([id]) => id),
|
||||
)
|
||||
if (retiredIds.size === 0) return
|
||||
|
||||
for (const id of retiredIds) delete nodes[id]
|
||||
for (const [id, node] of Object.entries(nodes)) {
|
||||
if (!Array.isArray(node?.children)) continue
|
||||
const children = getStringArray(node.children)
|
||||
if (!children.some((childId) => retiredIds.has(childId))) continue
|
||||
nodes[id] = {
|
||||
...node,
|
||||
children: children.filter((childId) => !retiredIds.has(childId)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function migrateWallAssembly(node: Record<string, any>) {
|
||||
if (!Object.hasOwn(node, 'assemblyLayers')) return node
|
||||
|
||||
const assemblyThickness = Array.isArray(node.assemblyLayers)
|
||||
? node.assemblyLayers.reduce((total: number, layer: unknown) => {
|
||||
if (!(layer && typeof layer === 'object')) return total
|
||||
const thickness = (layer as { thickness?: unknown }).thickness
|
||||
return typeof thickness === 'number' && Number.isFinite(thickness) && thickness > 0
|
||||
? total + thickness
|
||||
: total
|
||||
}, 0)
|
||||
: 0
|
||||
const { assemblyLayers: _assemblyLayers, ...wall } = node
|
||||
return assemblyThickness > 0 ? { ...wall, thickness: assemblyThickness } : wall
|
||||
}
|
||||
|
||||
// Walls whose top lands within this of the storey plane become plane-bound;
|
||||
// ceilings whose stored height lands within this of their clamp bound become
|
||||
// follows-mode (step 3f) — same census-backed threshold for both.
|
||||
@@ -608,6 +644,7 @@ function migrateNodes(nodes: Record<string, any>): {
|
||||
// any per-type migration runs, so already-saved scenes load cleanly.
|
||||
const { nodes: healed } = healSceneNodes(nodes)
|
||||
const patchedNodes = { ...healed } as Record<string, any>
|
||||
removeRetiredDrawingSheets(patchedNodes)
|
||||
|
||||
// Scene materials minted while moving legacy wall fields onto `node.slots`;
|
||||
// merged into the scene material map by the caller (`setScene`).
|
||||
@@ -728,7 +765,10 @@ function migrateNodes(nodes: Record<string, any>): {
|
||||
}
|
||||
|
||||
if (node.type === 'wall') {
|
||||
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id], mintedMaterials)
|
||||
patchedNodes[id] = migrateWallSurfaceMaterials(
|
||||
migrateWallAssembly(patchedNodes[id]),
|
||||
mintedMaterials,
|
||||
)
|
||||
}
|
||||
|
||||
// Cabinet v2→v3: node-level `doorStyle` was dead (geometry reads only the
|
||||
|
||||
@@ -10,7 +10,6 @@ function wall(id: string, start: [number, number], end: [number, number]): WallN
|
||||
visible: true,
|
||||
parentId: 'level_test',
|
||||
children: [],
|
||||
assemblyLayers: [],
|
||||
start,
|
||||
end,
|
||||
thickness: 0.1,
|
||||
|
||||
@@ -149,47 +149,6 @@ describe('construction-dimension clone references', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('drawing-sheet clone references', () => {
|
||||
test('remaps placed levels and nested sheet identities in whole-scene clones', () => {
|
||||
const level = makeNode('level_main', 'level')
|
||||
const sheet = makeNode('drawing-sheet_a101', 'drawing-sheet', {
|
||||
placedViews: [{ id: 'drawing-view_main', levelId: level.id }],
|
||||
generalNoteSetIds: [],
|
||||
generalNoteSets: [],
|
||||
generalNotes: [],
|
||||
keyedNoteDefinitions: [{ id: 'keyed-note_a', key: 'A', text: 'NOTE' }],
|
||||
keyedNoteInstances: [
|
||||
{
|
||||
id: 'keyed-note-instance_a',
|
||||
definitionId: 'keyed-note_a',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [1, 1],
|
||||
},
|
||||
],
|
||||
keyedNoteLegend: [],
|
||||
documentMarkers: [],
|
||||
schedules: [],
|
||||
})
|
||||
const cloned = cloneSceneGraph({
|
||||
nodes: { [level.id]: level, [sheet.id]: sheet },
|
||||
rootNodeIds: [level.id, sheet.id] as AnyNodeId[],
|
||||
})
|
||||
const clonedLevel = Object.values(cloned.nodes).find((node) => node.type === 'level')
|
||||
const clonedSheet = Object.values(cloned.nodes).find((node) => node.type === 'drawing-sheet')
|
||||
|
||||
expect(clonedLevel).toBeDefined()
|
||||
expect(clonedSheet?.type).toBe('drawing-sheet')
|
||||
if (clonedLevel && clonedSheet?.type === 'drawing-sheet') {
|
||||
expect(clonedSheet.placedViews[0]?.levelId).toBe(clonedLevel.id)
|
||||
expect(clonedSheet.placedViews[0]?.id).not.toBe('drawing-view_main')
|
||||
expect(clonedSheet.keyedNoteInstances[0]?.definitionId).toBe(
|
||||
clonedSheet.keyedNoteDefinitions[0]?.id,
|
||||
)
|
||||
expect(clonedSheet.keyedNoteInstances[0]?.placedViewId).toBe(clonedSheet.placedViews[0]?.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportSlabId remap', () => {
|
||||
test('cloneSceneGraph remaps supportSlabId to the cloned slab id', () => {
|
||||
const level = makeNode('level_1', 'level', { children: ['slab_1', 'item_1'] })
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import type { AnyNode, AnyNodeId } from '../schema'
|
||||
import { generateId } from '../schema/base'
|
||||
import type { Collection, CollectionId } from '../schema/collections'
|
||||
import { remapDrawingSheetReferences } from '../schema/nodes/drawing-sheet'
|
||||
|
||||
export type SceneGraph = {
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
@@ -114,10 +113,6 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
if (clonedNode.type === 'construction-dimension') {
|
||||
clonedNode = remapConstructionDimensionReferences(clonedNode, idMap)
|
||||
}
|
||||
if (clonedNode.type === 'drawing-sheet') {
|
||||
clonedNode = remapDrawingSheetReferences(clonedNode, idMap)
|
||||
}
|
||||
|
||||
clonedNodes[newId] = clonedNode
|
||||
}
|
||||
|
||||
@@ -287,10 +282,6 @@ export function cloneLevelSubtree(
|
||||
if (cloned.type === 'construction-dimension') {
|
||||
cloned = remapConstructionDimensionReferences(cloned, idMap)
|
||||
}
|
||||
if (cloned.type === 'drawing-sheet') {
|
||||
cloned = remapDrawingSheetReferences(cloned, idMap)
|
||||
}
|
||||
|
||||
clonedNodes.push(cloned)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { Icon } from '@iconify/react'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor'
|
||||
import { useFloorplanDraftPreview } from '../../store/use-floorplan-draft-preview'
|
||||
import { furnishTools } from '../ui/action-menu/furnish-tools'
|
||||
import { tools as structureTools } from '../ui/action-menu/structure-tools'
|
||||
|
||||
type SvgPoint = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
import {
|
||||
type FloorplanCursorPoint,
|
||||
projectFloorplanCursorPoint,
|
||||
resolveFloorplanCursorIndicatorPosition,
|
||||
} from './floorplan-cursor-indicator-position'
|
||||
|
||||
type FloorplanCursorIndicator =
|
||||
| {
|
||||
@@ -22,7 +23,7 @@ type FloorplanCursorIndicator =
|
||||
}
|
||||
|
||||
type FloorplanCursorIndicatorOverlayProps = {
|
||||
cursorPosition: SvgPoint | null
|
||||
cursorPosition: FloorplanCursorPoint | null
|
||||
floorplanSelectionTool: FloorplanSelectionTool
|
||||
movingOpeningType: 'door' | 'window' | null
|
||||
isPanning: boolean
|
||||
@@ -46,6 +47,10 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
|
||||
const tool = useEditor((state) => state.tool)
|
||||
const structureLayer = useEditor((state) => state.structureLayer)
|
||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
||||
const cursorPoint = useFloorplanDraftPreview((state) => state.cursorPoint)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const [projectedCursorPosition, setProjectedCursorPosition] =
|
||||
useState<FloorplanCursorPoint | null>(null)
|
||||
|
||||
const activeFloorplanToolConfig = useMemo(() => {
|
||||
if (movingOpeningType) {
|
||||
@@ -83,7 +88,32 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
|
||||
return null
|
||||
}, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
|
||||
|
||||
const position = cursorPosition
|
||||
useLayoutEffect(() => {
|
||||
const anchor = anchorRef.current
|
||||
const overlayHost = anchor?.parentElement
|
||||
const scene = overlayHost?.querySelector<SVGGElement>('[data-floorplan-scene]')
|
||||
const sceneToViewport = scene?.getScreenCTM()
|
||||
|
||||
if (!(cursorPoint && cursorPosition && overlayHost && sceneToViewport)) {
|
||||
setProjectedCursorPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
const overlayRect = overlayHost.getBoundingClientRect()
|
||||
const nextPosition = projectFloorplanCursorPoint(cursorPoint, sceneToViewport, {
|
||||
x: overlayRect.left,
|
||||
y: overlayRect.top,
|
||||
})
|
||||
setProjectedCursorPosition((currentPosition) =>
|
||||
currentPosition &&
|
||||
currentPosition.x === nextPosition.x &&
|
||||
currentPosition.y === nextPosition.y
|
||||
? currentPosition
|
||||
: nextPosition,
|
||||
)
|
||||
}, [cursorPoint, cursorPosition])
|
||||
|
||||
const position = resolveFloorplanCursorIndicatorPosition(cursorPosition, projectedCursorPosition)
|
||||
|
||||
if (!(indicator && position) || isPanning) {
|
||||
return null
|
||||
@@ -93,6 +123,7 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute z-20"
|
||||
ref={anchorRef}
|
||||
style={{ left: position.x, top: position.y }}
|
||||
>
|
||||
{mode === 'delete' ? (
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
projectFloorplanCursorPoint,
|
||||
resolveFloorplanCursorIndicatorPosition,
|
||||
} from './floorplan-cursor-indicator-position'
|
||||
|
||||
describe('projectFloorplanCursorPoint', () => {
|
||||
test('projects a snapped plan point into overlay-local screen coordinates', () => {
|
||||
expect(
|
||||
projectFloorplanCursorPoint(
|
||||
[3, 4],
|
||||
{
|
||||
a: 0,
|
||||
b: 10,
|
||||
c: -10,
|
||||
d: 0,
|
||||
e: 128,
|
||||
f: 40,
|
||||
},
|
||||
{ x: 30, y: 20 },
|
||||
),
|
||||
).toEqual({ x: 58, y: 50 })
|
||||
})
|
||||
|
||||
test('anchors the placement pin to the projected snap point instead of the raw pointer', () => {
|
||||
expect(resolveFloorplanCursorIndicatorPosition({ x: 88, y: 97 }, { x: 58, y: 70 })).toEqual({
|
||||
x: 58,
|
||||
y: 70,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
export type FloorplanCursorPoint = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
type Matrix2D = {
|
||||
a: number
|
||||
b: number
|
||||
c: number
|
||||
d: number
|
||||
e: number
|
||||
f: number
|
||||
}
|
||||
|
||||
export function projectFloorplanCursorPoint(
|
||||
point: readonly [number, number],
|
||||
sceneToViewport: Matrix2D,
|
||||
overlayOrigin: FloorplanCursorPoint,
|
||||
): FloorplanCursorPoint {
|
||||
return {
|
||||
x:
|
||||
sceneToViewport.a * point[0] +
|
||||
sceneToViewport.c * point[1] +
|
||||
sceneToViewport.e -
|
||||
overlayOrigin.x,
|
||||
y:
|
||||
sceneToViewport.b * point[0] +
|
||||
sceneToViewport.d * point[1] +
|
||||
sceneToViewport.f -
|
||||
overlayOrigin.y,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFloorplanCursorIndicatorPosition(
|
||||
cursorPosition: FloorplanCursorPoint | null,
|
||||
projectedCursorPosition: FloorplanCursorPoint | null,
|
||||
): FloorplanCursorPoint | null {
|
||||
return projectedCursorPosition ?? cursorPosition
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
bboxCornerAnchors,
|
||||
collectAlignmentAnchors,
|
||||
DEFAULT_ANGLE_STEP,
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPalette,
|
||||
pauseSceneHistory,
|
||||
pauseSpaceDetection,
|
||||
@@ -22,13 +23,16 @@ import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contex
|
||||
import { applyFloorplanAlignment } from '../../lib/floorplan/apply-alignment'
|
||||
import { clientToPlan } from '../../lib/floorplan/plan-coords'
|
||||
import { isHistoryShortcut } from '../../lib/history'
|
||||
import { formatLinearMeasurement } from '../../lib/measurements'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useAlignmentGuides from '../../store/use-alignment-guides'
|
||||
import useEditor, {
|
||||
isAlignmentGuideActive,
|
||||
isAngleSnapActive,
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
} from '../../store/use-editor'
|
||||
import useFloorplanMode from '../../store/use-floorplan-mode'
|
||||
import useInteractionScope, { useMovingNode } from '../../store/use-interaction-scope'
|
||||
import {
|
||||
classifyParticipant,
|
||||
@@ -44,6 +48,8 @@ import {
|
||||
} from '../editor/group-transform-shared'
|
||||
import { swallowNextClick } from '../editor/handles/use-handle-drag'
|
||||
import { useMeshSettleEpoch } from '../editor/use-mesh-settle-epoch'
|
||||
import { useFloorplanSceneRotation } from './floorplan-render-context'
|
||||
import { FloorplanDimensionRenderer } from './renderers/floorplan-dimension-renderer'
|
||||
|
||||
// 2D sibling of the 3D body-drag group move (`group-move-3d.ts`): dragging
|
||||
// any selected element of a multi-selection slides the whole selection
|
||||
@@ -441,9 +447,9 @@ export function startFloorplanGroupRotate(event: {
|
||||
let delta = angleOf([plan[0], plan[1]]) - initialAngle
|
||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||
// 15° increments by default; Shift rotates freely — the same contract
|
||||
// as the 3D group rotate gizmo (and the HUD hint its scope surfaces).
|
||||
if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
|
||||
if (isAngleSnapActive()) {
|
||||
delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
|
||||
}
|
||||
|
||||
const entries = rotateGroupPatches(starts, links, pivot, delta)
|
||||
const patchById = new Map(entries)
|
||||
@@ -586,11 +592,15 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
|
||||
}) {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const levelId = useViewer((s) => s.selection.levelId)
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const metricNotation = useViewer((s) => s.metricNotation)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const delta = useFloorplanGroupDrag((s) => s.delta)
|
||||
const liveRotation = useFloorplanGroupDrag((s) => s.rotation)
|
||||
const movingNode = useMovingNode()
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const floorplanMode = useFloorplanMode((s) => s.mode)
|
||||
const sceneRotationDeg = useFloorplanSceneRotation()
|
||||
|
||||
// While a selection modifier is held the box steps aside so clicks reach
|
||||
// the entries underneath (toggle membership) instead of starting a drag.
|
||||
@@ -638,6 +648,27 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
|
||||
const pad = 6 * unitsPerPixel
|
||||
const stroke = palette?.selectedStroke ?? '#3b82f6'
|
||||
const interactive = !modifierHeld && !!onPointerDown
|
||||
const dimensionOffset = pad + 0.28
|
||||
const widthDimension = {
|
||||
kind: 'dimension',
|
||||
start: [box.x, box.z + box.depth],
|
||||
end: [box.x + box.width, box.z + box.depth],
|
||||
offsetNormal: [0, 1],
|
||||
offsetDistance: dimensionOffset,
|
||||
extensionOvershoot: 0.08,
|
||||
text: formatLinearMeasurement(box.width, unit, metricNotation),
|
||||
stroke,
|
||||
} satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
|
||||
const depthDimension = {
|
||||
kind: 'dimension',
|
||||
start: [box.x + box.width, box.z],
|
||||
end: [box.x + box.width, box.z + box.depth],
|
||||
offsetNormal: [1, 0],
|
||||
offsetDistance: dimensionOffset,
|
||||
extensionOvershoot: 0.08,
|
||||
text: formatLinearMeasurement(box.depth, unit, metricNotation),
|
||||
stroke,
|
||||
} satisfies Extract<FloorplanGeometry, { kind: 'dimension' }>
|
||||
// Mid-gesture the box rides the live delta (group move) or spins around the
|
||||
// rotation pivot (corner rotate) — SVG rotate() is degrees around a plan
|
||||
// point, and positive matches the atan2 x→z sense on the y-down plan.
|
||||
@@ -664,6 +695,18 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
|
||||
x={box.x - pad}
|
||||
y={box.z - pad}
|
||||
/>
|
||||
{floorplanMode === 'default' ? (
|
||||
<g pointerEvents="none">
|
||||
<FloorplanDimensionRenderer
|
||||
geometry={widthDimension}
|
||||
sceneRotationDeg={sceneRotationDeg}
|
||||
/>
|
||||
<FloorplanDimensionRenderer
|
||||
geometry={depthDimension}
|
||||
sceneRotationDeg={sceneRotationDeg}
|
||||
/>
|
||||
</g>
|
||||
) : null}
|
||||
{/* Corner rotate handles — the 2D counterpart of the 3D rotate gizmo:
|
||||
drag a corner to spin the group (15° steps, Shift = free). */}
|
||||
{interactive && onRotatePointerDown
|
||||
|
||||
@@ -7,13 +7,23 @@ import {
|
||||
type FloorplanToolContext,
|
||||
getFloorplanNodeExtension,
|
||||
} from '../../lib/floorplan/floorplan-extension'
|
||||
import {
|
||||
type FloorplanMode,
|
||||
isFloorplanToolAvailableInMode,
|
||||
} from '../../lib/floorplan/floorplan-mode'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import useFloorplanMode from '../../store/use-floorplan-mode'
|
||||
|
||||
const lazyToolCache = new WeakMap<() => Promise<unknown>, ComponentType<FloorplanToolContext>>()
|
||||
|
||||
function registeredFloorplanTool(tool: string | null): ComponentType<FloorplanToolContext> | null {
|
||||
function registeredFloorplanTool(
|
||||
tool: string | null,
|
||||
mode: FloorplanMode,
|
||||
): ComponentType<FloorplanToolContext> | null {
|
||||
if (!tool) return null
|
||||
const loader = getFloorplanNodeExtension(nodeRegistry.get(tool))?.tool
|
||||
const extension = getFloorplanNodeExtension(nodeRegistry.get(tool))
|
||||
if (!isFloorplanToolAvailableInMode(extension?.availableModes, mode)) return null
|
||||
const loader = extension?.tool
|
||||
if (!loader) return null
|
||||
const cached = lazyToolCache.get(loader)
|
||||
if (cached) return cached
|
||||
@@ -25,6 +35,7 @@ function registeredFloorplanTool(tool: string | null): ComponentType<FloorplanTo
|
||||
export function FloorplanRegisteredToolLayer() {
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const tool = useEditor((state) => state.tool)
|
||||
const floorplanMode = useFloorplanMode((state) => state.mode)
|
||||
const gridSnapStep = useEditor((state) => state.gridSnapStep)
|
||||
const toolDefaults = useEditor((state) =>
|
||||
state.tool ? (state.toolDefaults[state.tool] ?? null) : null,
|
||||
@@ -43,7 +54,7 @@ export function FloorplanRegisteredToolLayer() {
|
||||
useEditor.getState().setMode('select')
|
||||
}, [])
|
||||
if (mode !== 'build') return null
|
||||
const Tool = registeredFloorplanTool(tool)
|
||||
const Tool = registeredFloorplanTool(tool, floorplanMode)
|
||||
return Tool ? (
|
||||
<Suspense fallback={null}>
|
||||
<Tool
|
||||
|
||||
+1
-62
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { floorplanGeometryMetadata } from '../../../lib/floorplan/floorplan-extension'
|
||||
import {
|
||||
collectAnnotationLayoutPreflightIssues,
|
||||
floorplanAnnotationObstacleMode,
|
||||
polylineObstacleRectangles,
|
||||
resolveAnnotationLabelRectangles,
|
||||
@@ -43,66 +42,6 @@ describe('floorplanAnnotationObstacleMode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectAnnotationLayoutPreflightIssues', () => {
|
||||
test('reports unresolved collisions, short labels, and plan geometry conflicts separately', () => {
|
||||
const issues = collectAnnotationLayoutPreflightIssues(
|
||||
[
|
||||
{
|
||||
id: 'short',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 40,
|
||||
height: 10,
|
||||
priority: 10,
|
||||
text: '1"',
|
||||
labelPlacement: 'outside-end',
|
||||
},
|
||||
{
|
||||
id: 'blocked',
|
||||
x: 100,
|
||||
y: 0,
|
||||
width: 40,
|
||||
height: 10,
|
||||
priority: 10,
|
||||
text: 'Blocked',
|
||||
},
|
||||
{
|
||||
id: 'overlap-a',
|
||||
x: 200,
|
||||
y: 0,
|
||||
width: 40,
|
||||
height: 10,
|
||||
priority: 10,
|
||||
text: 'A',
|
||||
},
|
||||
{
|
||||
id: 'overlap-b',
|
||||
x: 205,
|
||||
y: 0,
|
||||
width: 40,
|
||||
height: 10,
|
||||
priority: 10,
|
||||
text: 'B',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ id: 'short', dx: 0, dy: 0, resolved: true },
|
||||
{ id: 'blocked', dx: 0, dy: 0, resolved: true },
|
||||
{ id: 'overlap-a', dx: 0, dy: 0, resolved: false },
|
||||
{ id: 'overlap-b', dx: 0, dy: 0, resolved: true },
|
||||
],
|
||||
[{ x: 96, y: -2, width: 48, height: 14 }],
|
||||
)
|
||||
|
||||
expect(issues.map((issue) => issue.kind)).toEqual([
|
||||
'short-unreadable-segment',
|
||||
'plan-geometry-conflict',
|
||||
'unresolved-collision',
|
||||
])
|
||||
expect(issues.every((issue) => issue.severity === 'warning')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveAnnotationLabelRectangles', () => {
|
||||
test('keeps the higher-priority label and moves the conflicting label', () => {
|
||||
const shifts = resolveAnnotationLabelRectangles([
|
||||
@@ -341,6 +280,6 @@ describe('resolveSvgAnnotationCollisions', () => {
|
||||
},
|
||||
} as unknown as SVGSVGElement
|
||||
|
||||
expect(resolveSvgAnnotationCollisions(svg, { labels: [] })).toEqual([])
|
||||
expect(resolveSvgAnnotationCollisions(svg, { labels: [] })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,8 +8,6 @@ export type AnnotationLabelRectangle = {
|
||||
width: number
|
||||
height: number
|
||||
priority: number
|
||||
text?: string
|
||||
labelPlacement?: 'inside' | 'outside-end'
|
||||
pinnedShift?: { dx: number; dy: number }
|
||||
tangentX?: number
|
||||
tangentY?: number
|
||||
@@ -79,16 +77,6 @@ class AnnotationObstacleIndex {
|
||||
|
||||
export type AnnotationLayoutOverride = { dx: number; dy: number; pinned?: boolean }
|
||||
export type AnnotationLayoutOverrides = Readonly<Record<string, AnnotationLayoutOverride>>
|
||||
export type AnnotationPreflightIssueKind =
|
||||
| 'unresolved-collision'
|
||||
| 'short-unreadable-segment'
|
||||
| 'plan-geometry-conflict'
|
||||
export type AnnotationPreflightIssue = {
|
||||
id: string
|
||||
kind: AnnotationPreflightIssueKind
|
||||
severity: 'warning'
|
||||
message: string
|
||||
}
|
||||
|
||||
export function resolveAnnotationLabelRectangles(
|
||||
rectangles: readonly AnnotationLabelRectangle[],
|
||||
@@ -131,16 +119,15 @@ export function resolveSvgAnnotationCollisions(
|
||||
labels?: readonly SVGGElement[]
|
||||
layoutOverrides?: AnnotationLayoutOverrides
|
||||
} = {},
|
||||
): AnnotationPreflightIssue[] {
|
||||
): void {
|
||||
const labels =
|
||||
options.labels ??
|
||||
Array.from(svg.querySelectorAll<SVGGElement>('[data-floorplan-annotation-label]'))
|
||||
if (labels.length === 0) return []
|
||||
if (labels.length === 0) return
|
||||
|
||||
for (const label of labels) {
|
||||
const defaultTransform = label.dataset.floorplanAnnotationDefaultTransform
|
||||
if (defaultTransform !== undefined) label.setAttribute('transform', defaultTransform)
|
||||
label.removeAttribute('data-floorplan-layout-unresolved')
|
||||
delete label.dataset.floorplanAnnotationLayoutDx
|
||||
delete label.dataset.floorplanAnnotationLayoutDy
|
||||
}
|
||||
@@ -188,9 +175,6 @@ export function resolveSvgAnnotationCollisions(
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
priority: Number(label.dataset.floorplanAnnotationPriority ?? 0),
|
||||
text: label.textContent?.trim() ?? '',
|
||||
labelPlacement:
|
||||
label.dataset.floorplanDimensionLabelPlacement === 'outside-end' ? 'outside-end' : 'inside',
|
||||
pinnedShift,
|
||||
tangentX: tangentLength > 1e-9 && matrix ? matrix.a / tangentLength : undefined,
|
||||
tangentY: tangentLength > 1e-9 && matrix ? matrix.b / tangentLength : undefined,
|
||||
@@ -201,7 +185,6 @@ export function resolveSvgAnnotationCollisions(
|
||||
svg.querySelectorAll<SVGGraphicsElement>('[data-floorplan-annotation-obstacle]'),
|
||||
).flatMap(svgAnnotationObstacleRectangles)
|
||||
const shifts = resolveAnnotationLabelRectangles(rectangles, obstacles)
|
||||
const preflightIssues = collectAnnotationLayoutPreflightIssues(rectangles, shifts, obstacles)
|
||||
|
||||
labels.forEach((label, index) => {
|
||||
const rectangle = rectangles[index]
|
||||
@@ -209,7 +192,6 @@ export function resolveSvgAnnotationCollisions(
|
||||
if (!shift || (shift.dx === 0 && shift.dy === 0)) {
|
||||
label.dataset.floorplanAnnotationLayoutDx = '0'
|
||||
label.dataset.floorplanAnnotationLayoutDy = '0'
|
||||
if (shift && !shift.resolved) label.dataset.floorplanLayoutUnresolved = 'true'
|
||||
return
|
||||
}
|
||||
const matrix = label.getScreenCTM()
|
||||
@@ -227,78 +209,7 @@ export function resolveSvgAnnotationCollisions(
|
||||
else if (label.dataset.floorplanDimensionLabelPlacement === 'outside-end') {
|
||||
showDimensionLeader(label, matrix, shift.dx, shift.dy)
|
||||
}
|
||||
if (!shift.resolved) label.dataset.floorplanLayoutUnresolved = 'true'
|
||||
})
|
||||
return preflightIssues
|
||||
}
|
||||
|
||||
export function collectAnnotationLayoutPreflightIssues(
|
||||
rectangles: readonly AnnotationLabelRectangle[],
|
||||
shifts: readonly AnnotationLabelShift[],
|
||||
obstacles: readonly AnnotationObstacleRectangle[] = [],
|
||||
): AnnotationPreflightIssue[] {
|
||||
const shiftsById = new Map(shifts.map((shift) => [shift.id, shift]))
|
||||
const finalRectangles = rectangles.map((rectangle) => {
|
||||
const shift = shiftsById.get(rectangle.id) ?? {
|
||||
id: rectangle.id,
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
resolved: false,
|
||||
}
|
||||
return {
|
||||
source: rectangle,
|
||||
shift,
|
||||
bounds: {
|
||||
x: rectangle.x + shift.dx,
|
||||
y: rectangle.y + shift.dy,
|
||||
width: rectangle.width,
|
||||
height: rectangle.height,
|
||||
},
|
||||
}
|
||||
})
|
||||
const issues: AnnotationPreflightIssue[] = []
|
||||
const addIssue = (id: string, kind: AnnotationPreflightIssueKind, message: string): void => {
|
||||
if (issues.some((issue) => issue.id === id && issue.kind === kind)) return
|
||||
issues.push({ id, kind, severity: 'warning', message })
|
||||
}
|
||||
|
||||
for (const entry of finalRectangles) {
|
||||
const label = preflightLabel(entry.source)
|
||||
if (entry.source.labelPlacement === 'outside-end') {
|
||||
addIssue(
|
||||
entry.source.id,
|
||||
'short-unreadable-segment',
|
||||
`${label} is too short for inline text and uses an outside label or leader.`,
|
||||
)
|
||||
}
|
||||
if (obstacles.some((obstacle) => rectanglesOverlap(entry.bounds, obstacle))) {
|
||||
addIssue(
|
||||
entry.source.id,
|
||||
'plan-geometry-conflict',
|
||||
`${label} still conflicts with fixed plan geometry after automatic layout.`,
|
||||
)
|
||||
}
|
||||
if (!entry.shift.resolved) {
|
||||
const collidesWithLabel = finalRectangles.some(
|
||||
(candidate) =>
|
||||
candidate.source.id !== entry.source.id &&
|
||||
rectanglesOverlap(entry.bounds, candidate.bounds),
|
||||
)
|
||||
if (collidesWithLabel) {
|
||||
addIssue(
|
||||
entry.source.id,
|
||||
'unresolved-collision',
|
||||
`${label} still overlaps another annotation after automatic layout.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
function preflightLabel(rectangle: AnnotationLabelRectangle): string {
|
||||
const text = rectangle.text?.trim()
|
||||
return text ? `Annotation "${text}"` : `Annotation ${rectangle.id}`
|
||||
}
|
||||
|
||||
export function svgAnnotationLabelId(label: SVGGElement, index: number): string {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AnyNodeId,
|
||||
FloorplanAffordanceSession,
|
||||
FloorplanGeometry,
|
||||
FloorplanPalette,
|
||||
LiveNodeOverrides,
|
||||
} from '@pascal-app/core'
|
||||
import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
|
||||
@@ -23,10 +24,68 @@ import {
|
||||
floorplanHandleDoubleClickAffordance,
|
||||
InteractiveGeometry,
|
||||
isFloorplanOpeningPlacementState,
|
||||
resolveFloorplanHandleUnitsPerPixel,
|
||||
splitFloorplanOverlay,
|
||||
subscribeFloorplanAffordanceToolCancel,
|
||||
} from './floorplan-registry-layer'
|
||||
|
||||
describe('floorplan selection handle sizing', () => {
|
||||
test('caps visual handle growth at extreme zoom-out', () => {
|
||||
expect(resolveFloorplanHandleUnitsPerPixel(0.01)).toBe(0.01)
|
||||
expect(resolveFloorplanHandleUnitsPerPixel(0.1)).toBe(0.015)
|
||||
|
||||
const palette = {
|
||||
selectedStroke: '#111111',
|
||||
selectedFill: '#ffffff',
|
||||
selectedHatch: '#111111',
|
||||
wallHoverStroke: '#111111',
|
||||
endpointHandleFill: '#ffffff',
|
||||
endpointHandleStroke: '#111111',
|
||||
endpointHandleHoverStroke: '#222222',
|
||||
endpointHandleActiveFill: '#333333',
|
||||
endpointHandleActiveStroke: '#444444',
|
||||
curveHandleFill: '#ffffff',
|
||||
curveHandleStroke: '#008080',
|
||||
curveHandleHoverStroke: '#00aaaa',
|
||||
measurementStroke: '#111111',
|
||||
measurementLabelBackground: '#ffffff',
|
||||
measurementLabelText: '#111111',
|
||||
} satisfies FloorplanPalette
|
||||
const noop = () => {}
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(
|
||||
'svg',
|
||||
null,
|
||||
createElement(InteractiveGeometry, {
|
||||
activeDragId: null,
|
||||
activeRotateNodeId: null,
|
||||
geometry: {
|
||||
kind: 'endpoint-handle',
|
||||
point: [0, 0],
|
||||
state: 'idle',
|
||||
affordance: 'move-endpoint',
|
||||
payload: { endpoint: 'start' },
|
||||
},
|
||||
hatchPatternId: undefined,
|
||||
hoveredHandleId: null,
|
||||
isMarqueeSelectionActive: false,
|
||||
nodeId: 'wall_test' as AnyNodeId,
|
||||
onHandleDoubleClick: noop,
|
||||
onHandleHoverChange: noop,
|
||||
onHandlePointerDown: noop,
|
||||
onMoveHandlePointerDown: noop,
|
||||
palette,
|
||||
sceneRotationDeg: 0,
|
||||
unitsPerPixel: 0.1,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(markup).toContain('r="0.12"')
|
||||
expect(markup).not.toContain('r="0.8"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('floorplan affordance ownership', () => {
|
||||
test('keeps the wall center curve drag owned by the floorplan dispatcher', () => {
|
||||
expect(floorplanAffordanceReshapeScope('wall-curve', 'wall_1', undefined)).toEqual({
|
||||
@@ -262,6 +321,62 @@ describe('floorplan vertex double-click routing', () => {
|
||||
})
|
||||
|
||||
describe('floorplan annotation overlay routing', () => {
|
||||
test('keeps explicitly layered selection chrome above selected body fills', () => {
|
||||
const selectionHatch = {
|
||||
kind: 'line',
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 0.2,
|
||||
y2: 0.2,
|
||||
stroke: '#3b82f6',
|
||||
metadata: floorplanGeometryMetadata({ renderPass: 'overlay' }),
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
expect(splitFloorplanOverlay(selectionHatch)).toEqual({
|
||||
base: null,
|
||||
overlay: selectionHatch,
|
||||
})
|
||||
})
|
||||
|
||||
test('registers upright zone labels for rotation-only presentation updates', () => {
|
||||
const noop = () => {}
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(
|
||||
'svg',
|
||||
null,
|
||||
createElement(InteractiveGeometry, {
|
||||
activeDragId: null,
|
||||
activeRotateNodeId: null,
|
||||
geometry: {
|
||||
kind: 'text',
|
||||
x: 4,
|
||||
y: 6,
|
||||
text: 'Kitchen',
|
||||
fontSize: 0.2,
|
||||
upright: true,
|
||||
},
|
||||
hatchPatternId: undefined,
|
||||
hoveredHandleId: null,
|
||||
isMarqueeSelectionActive: false,
|
||||
nodeId: 'zone_test' as AnyNodeId,
|
||||
onHandleDoubleClick: noop,
|
||||
onHandleHoverChange: noop,
|
||||
onHandlePointerDown: noop,
|
||||
onMoveHandlePointerDown: noop,
|
||||
palette: undefined,
|
||||
sceneRotationDeg: 180,
|
||||
unitsPerPixel: 0.01,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(markup).not.toContain('data-floorplan-annotation-label=""')
|
||||
expect(markup).toContain('data-floorplan-annotation-angle-radians="0"')
|
||||
expect(markup).toContain('data-floorplan-annotation-screen-upright="true"')
|
||||
expect(markup).toContain('data-floorplan-annotation-transform-before-rotation="translate(4 6)"')
|
||||
expect(markup).toContain('transform="translate(4 6) rotate(-180)"')
|
||||
})
|
||||
|
||||
test('keeps automatic dimension strings left-to-right and top-to-bottom after rotation', () => {
|
||||
const noop = () => {}
|
||||
const renderAt180Degrees = (geometry: FloorplanGeometry) =>
|
||||
|
||||
@@ -56,9 +56,17 @@ import {
|
||||
import { resolveNodeForDrawingType } from '../../../lib/floorplan/drawing-coordination'
|
||||
import {
|
||||
createFloorplanContextExtensions,
|
||||
type FloorplanAnnotationRole,
|
||||
type FloorplanWallDimensionReference,
|
||||
getFloorplanNodeExtension,
|
||||
readFloorplanGeometryMetadata,
|
||||
withFloorplanGeometryMetadata,
|
||||
} from '../../../lib/floorplan/floorplan-extension'
|
||||
import {
|
||||
type FloorplanMode,
|
||||
resolveFloorplanAnnotationVisibility,
|
||||
resolveFloorplanWallDimensionReference,
|
||||
} from '../../../lib/floorplan/floorplan-mode'
|
||||
import { clientToPlan } from '../../../lib/floorplan/plan-coords'
|
||||
import {
|
||||
type ActiveInteractionScope,
|
||||
@@ -75,9 +83,9 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { clearSurfacePlanSnapFeedback } from '../../../lib/surface-plan-snap'
|
||||
import useDirectManipulationFeedback from '../../../store/use-direct-manipulation-feedback'
|
||||
import useDrawingView from '../../../store/use-drawing-view'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import useEditor, { isAngleSnapActive } from '../../../store/use-editor'
|
||||
import useFloorplanAnnotationVisibility from '../../../store/use-floorplan-annotation-visibility'
|
||||
import useFloorplanPreflight from '../../../store/use-floorplan-preflight'
|
||||
import useFloorplanMode from '../../../store/use-floorplan-mode'
|
||||
import useInteractionScope, {
|
||||
getMovingNode,
|
||||
useEndpointReshape,
|
||||
@@ -135,8 +143,8 @@ import {
|
||||
*/
|
||||
// Handle / hit-area sizes mirror the legacy `FLOORPLAN_ENDPOINT_HANDLE_*`
|
||||
// constants in floorplan-panel.tsx. Sizes are in screen pixels — the
|
||||
// dispatcher multiplies by `unitsPerPixel` so handles stay the same on-
|
||||
// screen size at any zoom.
|
||||
// dispatcher multiplies by `unitsPerPixel` so handles stay screen-sized
|
||||
// until the world-space ceiling takes over at extreme zoom-out.
|
||||
const ENDPOINT_HANDLE_SELECTED_RADIUS_PX = 8
|
||||
const ENDPOINT_HANDLE_ACTIVE_RADIUS_PX = 9
|
||||
const ENDPOINT_HANDLE_DOT_RADIUS_PX = 3
|
||||
@@ -148,6 +156,11 @@ const HOVER_TRANSITION = 'opacity 180ms cubic-bezier(0.2, 0, 0, 1)'
|
||||
const DIRECT_DRAG_THRESHOLD_PX = 4
|
||||
const DIRECT_ROTATE_EPSILON = 1e-6
|
||||
const DIRECT_ROTATE_RADIANS_PER_PIXEL = Math.PI / 180
|
||||
const MAX_HANDLE_UNITS_PER_PIXEL = 0.015
|
||||
|
||||
export function resolveFloorplanHandleUnitsPerPixel(unitsPerPixel: number): number {
|
||||
return Math.min(unitsPerPixel, MAX_HANDLE_UNITS_PER_PIXEL)
|
||||
}
|
||||
|
||||
const ScaleAwareFloorplanGroupSelectionBox = memo(function ScaleAwareFloorplanGroupSelectionBox(
|
||||
props: Omit<ComponentProps<typeof FloorplanGroupSelectionBox>, 'unitsPerPixel'>,
|
||||
@@ -313,6 +326,7 @@ type FloorplanEntryDescriptor = {
|
||||
}
|
||||
|
||||
type NodeDeps = {
|
||||
automaticDimensions: boolean
|
||||
node: AnyNode
|
||||
live: LiveTransform | undefined
|
||||
unit: 'metric' | 'imperial'
|
||||
@@ -415,11 +429,6 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const metricNotation = useViewer((s) => s.metricNotation)
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
||||
const hoveredId = useViewer((s) => s.hoveredId)
|
||||
const activeRotateNodeId = useDirectManipulationFeedback((s) => s.activeRotateNodeId)
|
||||
const setHoveredId = useViewer((s) => s.setHoveredId)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const installedPlugins = useScene((s) => s.installedPlugins)
|
||||
@@ -491,26 +500,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const drawingType = useDrawingView((s) => s.drawingType)
|
||||
const annotationVisibility = useFloorplanAnnotationVisibility((s) => s.visibility)
|
||||
const wallDimensionReference = useFloorplanAnnotationVisibility((s) => s.wallDimensionReference)
|
||||
const floorplanMode = useFloorplanMode((s) => s.mode)
|
||||
const effectiveWallDimensionReference = resolveFloorplanWallDimensionReference(
|
||||
floorplanMode,
|
||||
wallDimensionReference,
|
||||
)
|
||||
// Elevator builders read runtime state imperatively, so entries include this
|
||||
// rare-changing ref in their cache deps.
|
||||
const interactiveElevators = useInteractive((s) => s.elevators)
|
||||
|
||||
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
// Marquee preview selection — matches the legacy `highlightedIdSet` use
|
||||
// (filter-while-marquee), surfaces selection chrome without keyboard focus.
|
||||
const highlightedIdSet = useMemo(() => new Set(previewSelectedIds), [previewSelectedIds])
|
||||
// Multi-selection: members show highlight only (per-node edit chrome hidden)
|
||||
// and transformable members advertise the drag-to-move gesture.
|
||||
const isMultiSelect = selectedIds.length > 1
|
||||
const groupParticipantIdSet = useMemo(() => {
|
||||
if (selectedIds.length < 2 || !levelId) return null
|
||||
return new Set(
|
||||
selectedIds.filter(
|
||||
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
|
||||
),
|
||||
)
|
||||
}, [selectedIds, levelId, nodes])
|
||||
|
||||
// Interactive state lives in refs; only the visible feedback bits go
|
||||
// into React state to keep re-renders cheap during drag.
|
||||
const dragRef = useRef<ActiveDrag | null>(null)
|
||||
@@ -730,7 +728,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
startX,
|
||||
pointerEvent.clientX,
|
||||
DIRECT_ROTATE_RADIANS_PER_PIXEL,
|
||||
pointerEvent.shiftKey,
|
||||
!isAngleSnapActive(),
|
||||
)
|
||||
if (Math.abs(delta) < DIRECT_ROTATE_EPSILON) {
|
||||
lastPatch = null
|
||||
@@ -1196,9 +1194,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
let delta = current - rot.initialAngle
|
||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||
// Match the affordance's 15° angle step (Shift = free) so the wedge +
|
||||
// degree chip read the committed rotation, not the raw pointer bearing.
|
||||
delta = snapDirectRotationDelta(delta, event.shiftKey)
|
||||
delta = snapDirectRotationDelta(delta, !isAngleSnapActive())
|
||||
if (Math.abs(delta) < 0.0087) {
|
||||
setRotationOverlay(null)
|
||||
} else {
|
||||
@@ -1381,13 +1377,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
{entries.map((entry) => (
|
||||
<FloorplanRegistryEntry
|
||||
activeDragId={handleIdForNode(activeDragId, entry.id)}
|
||||
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
|
||||
annotationVisibility={annotationVisibility}
|
||||
floorplanMode={floorplanMode}
|
||||
floorplanVisible={floorplanVisible}
|
||||
geometryCacheRef={geometryCacheRef}
|
||||
hatchPatternId={renderCtx?.hatchPatternId}
|
||||
highlighted={highlightedIdSet.has(entry.id)}
|
||||
hovered={hoveredId === entry.id}
|
||||
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
||||
interactiveElevators={interactiveElevators}
|
||||
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
||||
@@ -1404,19 +1398,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
onHandleHoverChange={setHoveredHandleId}
|
||||
onHandleDoubleClick={commitAffordanceAction}
|
||||
onHandlePointerDown={startAffordanceDrag}
|
||||
onHoveredIdChange={setHoveredId}
|
||||
palette={palette}
|
||||
pass="base"
|
||||
sceneRotationDeg={sceneRotationDeg}
|
||||
selected={selectedIdSet.has(entry.id)}
|
||||
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
|
||||
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
|
||||
setMovingNode={setMovingNode}
|
||||
setMovingNodeOrigin={setMovingNodeOrigin}
|
||||
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
|
||||
unit={unit}
|
||||
metricNotation={metricNotation}
|
||||
wallDimensionReference={wallDimensionReference}
|
||||
wallDimensionReference={effectiveWallDimensionReference}
|
||||
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
||||
ctxOverrides={entry.ctxOverrides}
|
||||
/>
|
||||
@@ -1433,13 +1423,11 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
{entries.map((entry) => (
|
||||
<FloorplanRegistryEntry
|
||||
activeDragId={handleIdForNode(activeDragId, entry.id)}
|
||||
activeRotateNodeId={activeRotateNodeId === entry.id ? activeRotateNodeId : null}
|
||||
annotationVisibility={annotationVisibility}
|
||||
floorplanMode={floorplanMode}
|
||||
floorplanVisible={floorplanVisible}
|
||||
geometryCacheRef={geometryCacheRef}
|
||||
hatchPatternId={renderCtx?.hatchPatternId}
|
||||
highlighted={highlightedIdSet.has(entry.id)}
|
||||
hovered={hoveredId === entry.id}
|
||||
hoveredHandleId={handleIdForNode(hoveredHandleId, entry.id)}
|
||||
interactiveElevators={interactiveElevators}
|
||||
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
||||
@@ -1456,19 +1444,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
onHandleHoverChange={setHoveredHandleId}
|
||||
onHandleDoubleClick={commitAffordanceAction}
|
||||
onHandlePointerDown={startAffordanceDrag}
|
||||
onHoveredIdChange={setHoveredId}
|
||||
palette={palette}
|
||||
pass="overlay"
|
||||
sceneRotationDeg={sceneRotationDeg}
|
||||
selected={selectedIdSet.has(entry.id)}
|
||||
suppressHandles={isMultiSelect && selectedIdSet.has(entry.id)}
|
||||
groupMoveCursor={groupParticipantIdSet?.has(entry.id) ?? false}
|
||||
setMovingNode={setMovingNode}
|
||||
setMovingNodeOrigin={setMovingNodeOrigin}
|
||||
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
|
||||
unit={unit}
|
||||
metricNotation={metricNotation}
|
||||
wallDimensionReference={wallDimensionReference}
|
||||
wallDimensionReference={effectiveWallDimensionReference}
|
||||
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
||||
ctxOverrides={entry.ctxOverrides}
|
||||
/>
|
||||
@@ -1500,6 +1484,7 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
|
||||
const markerRef = useRef<SVGGElement>(null)
|
||||
const collisionLabelElementsRef = useRef<SVGGElement[]>([])
|
||||
const registryLabelElementsRef = useRef<SVGGElement[]>([])
|
||||
const registryOrientationElementsRef = useRef<SVGGElement[]>([])
|
||||
const appliedRotationDegRef = useRef<number | null>(null)
|
||||
const appliedLayoutInputsRef = useRef<object | null>(null)
|
||||
const interactionIdle = useInteractionScope((state) => isIdle(state.scope))
|
||||
@@ -1511,6 +1496,7 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
|
||||
const wallDimensionReference = useFloorplanAnnotationVisibility(
|
||||
(state) => state.wallDimensionReference,
|
||||
)
|
||||
const floorplanMode = useFloorplanMode((state) => state.mode)
|
||||
const layoutInputs = useMemo(
|
||||
() => ({
|
||||
annotationVisibility,
|
||||
@@ -1519,6 +1505,7 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
|
||||
interactionIdle,
|
||||
settledLayoutEpoch,
|
||||
wallDimensionReference,
|
||||
floorplanMode,
|
||||
}),
|
||||
[
|
||||
annotationVisibility,
|
||||
@@ -1527,11 +1514,10 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
|
||||
interactionIdle,
|
||||
settledLayoutEpoch,
|
||||
wallDimensionReference,
|
||||
floorplanMode,
|
||||
],
|
||||
)
|
||||
const setAnnotationLayoutOverride = useDrawingView((state) => state.setAnnotationLayoutOverride)
|
||||
const setPreflightIssues = useFloorplanPreflight((state) => state.setIssues)
|
||||
const resetPreflightIssues = useFloorplanPreflight((state) => state.reset)
|
||||
const layoutEnabled = active && interactionIdle
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1575,7 +1561,6 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
|
||||
// Explicit layout inputs replace the former whole-subtree MutationObserver.
|
||||
if (!active) {
|
||||
appliedLayoutInputsRef.current = null
|
||||
resetPreflightIssues()
|
||||
return
|
||||
}
|
||||
if (!interactionIdle) return
|
||||
@@ -1595,37 +1580,30 @@ function FloorplanAnnotationLayoutResolver({ active }: { active: boolean }) {
|
||||
registryLabelElementsRef.current = collisionLabelElementsRef.current.filter((label) =>
|
||||
registryLayer.contains(label),
|
||||
)
|
||||
registryOrientationElementsRef.current = Array.from(
|
||||
registryLayer.querySelectorAll<SVGGElement>('[data-floorplan-annotation-angle-radians]'),
|
||||
)
|
||||
}
|
||||
const labels = registryLabelElementsRef.current
|
||||
if (update.updateLabelPresentation) {
|
||||
updateSvgFloorplanLabelOrientations(labels, sceneRotationDeg)
|
||||
updateSvgFloorplanLabelOrientations(registryOrientationElementsRef.current, sceneRotationDeg)
|
||||
appliedRotationDegRef.current = sceneRotationDeg
|
||||
}
|
||||
if (!update.resolveCollisions) return
|
||||
const svg = markerRef.current?.ownerSVGElement
|
||||
if (!svg) return
|
||||
const preflightIssues = resolveSvgAnnotationCollisions(svg, {
|
||||
resolveSvgAnnotationCollisions(svg, {
|
||||
labels: collisionLabelElementsRef.current,
|
||||
layoutOverrides: annotationLayoutOverrides,
|
||||
})
|
||||
setPreflightIssues(preflightIssues)
|
||||
appliedLayoutInputsRef.current = layoutInputs
|
||||
|
||||
for (const [index, label] of labels.entries()) {
|
||||
for (const [index, label] of registryLabelElementsRef.current.entries()) {
|
||||
const id = svgAnnotationLabelId(label, index)
|
||||
label.dataset.floorplanAnnotationId = id
|
||||
label.style.pointerEvents = 'all'
|
||||
label.style.cursor = annotationLayoutOverrides[id]?.pinned ? 'grab' : 'move'
|
||||
}
|
||||
}, [
|
||||
active,
|
||||
annotationLayoutOverrides,
|
||||
interactionIdle,
|
||||
layoutInputs,
|
||||
resetPreflightIssues,
|
||||
sceneRotationDeg,
|
||||
setPreflightIssues,
|
||||
])
|
||||
}, [active, annotationLayoutOverrides, interactionIdle, layoutInputs, sceneRotationDeg])
|
||||
|
||||
useEffect(() => {
|
||||
if (!layoutEnabled) return
|
||||
@@ -1762,14 +1740,12 @@ function readFloorplanAnnotationLayoutOffset(label: SVGGElement) {
|
||||
|
||||
type FloorplanRegistryEntryProps = {
|
||||
activeDragId: string | null
|
||||
activeRotateNodeId: AnyNodeId | null
|
||||
annotationVisibility: FloorplanAnnotationVisibility
|
||||
floorplanMode: FloorplanMode
|
||||
ctxOverrides: FloorplanContextOverrides | undefined
|
||||
floorplanVisible: boolean
|
||||
geometryCacheRef: { current: Map<string, CacheEntry> }
|
||||
hatchPatternId: string | undefined
|
||||
highlighted: boolean
|
||||
hovered: boolean
|
||||
hoveredHandleId: string | null
|
||||
interactiveElevators: unknown
|
||||
isMarqueeSelectionActive: boolean
|
||||
@@ -1779,10 +1755,6 @@ type FloorplanRegistryEntryProps = {
|
||||
node: AnyNode
|
||||
nodeId: AnyNodeId
|
||||
nodes: Record<string, AnyNode>
|
||||
/** Selected member of a multi-selection: hide its per-node edit chrome. */
|
||||
suppressHandles: boolean
|
||||
/** Transformable member of a multi-selection: advertise drag-to-move. */
|
||||
groupMoveCursor: boolean
|
||||
onClickStop: (event: React.MouseEvent<SVGGElement>) => void
|
||||
onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => void
|
||||
onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => boolean
|
||||
@@ -1801,11 +1773,9 @@ type FloorplanRegistryEntryProps = {
|
||||
event: ReactPointerEvent<SVGGElement>,
|
||||
rotationPivot?: FloorplanPoint,
|
||||
) => void
|
||||
onHoveredIdChange: (id: AnyNodeId | null) => void
|
||||
palette: FloorplanPalette | undefined
|
||||
pass: FloorplanRenderPass
|
||||
sceneRotationDeg: number
|
||||
selected: boolean
|
||||
setMovingNode: ReturnType<typeof useEditor.getState>['setMovingNode']
|
||||
setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
|
||||
siblingEpoch: number
|
||||
@@ -1817,14 +1787,12 @@ type FloorplanRegistryEntryProps = {
|
||||
|
||||
const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
activeDragId,
|
||||
activeRotateNodeId,
|
||||
annotationVisibility,
|
||||
floorplanMode,
|
||||
ctxOverrides,
|
||||
floorplanVisible,
|
||||
geometryCacheRef,
|
||||
hatchPatternId,
|
||||
highlighted,
|
||||
hovered,
|
||||
hoveredHandleId,
|
||||
interactiveElevators,
|
||||
isMarqueeSelectionActive,
|
||||
@@ -1834,19 +1802,15 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
node,
|
||||
nodeId,
|
||||
nodes,
|
||||
suppressHandles,
|
||||
groupMoveCursor,
|
||||
onClickStop,
|
||||
onEntryPointerDown,
|
||||
onGroupMovePointerDown,
|
||||
onHandleHoverChange,
|
||||
onHandleDoubleClick,
|
||||
onHandlePointerDown,
|
||||
onHoveredIdChange,
|
||||
palette,
|
||||
pass,
|
||||
sceneRotationDeg,
|
||||
selected,
|
||||
setMovingNode,
|
||||
setMovingNodeOrigin,
|
||||
siblingEpoch,
|
||||
@@ -1855,6 +1819,27 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
wallDimensionReference,
|
||||
visibilityRootId,
|
||||
}: FloorplanRegistryEntryProps): React.ReactElement | null {
|
||||
const selected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
|
||||
const highlighted = useViewer((state) => state.previewSelectedIds.includes(nodeId))
|
||||
const suppressHandles = useViewer(
|
||||
(state) =>
|
||||
state.selection.selectedIds.length > 1 && state.selection.selectedIds.includes(nodeId),
|
||||
)
|
||||
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
||||
const selectionProxyId = resolveSelectionProxyId(
|
||||
node,
|
||||
nodes as Record<string, AnyNode | undefined>,
|
||||
)
|
||||
const hovered = useViewer((state) => state.hoveredId === selectionProxyId)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
const referencedAnnotationRole = useViewer((state) =>
|
||||
floorplanEntryReferencedAnnotationRole(node, new Set(state.selection.selectedIds)),
|
||||
)
|
||||
const activeRotateNodeId = useDirectManipulationFeedback((state) =>
|
||||
state.activeRotateNodeId === nodeId ? nodeId : null,
|
||||
)
|
||||
const groupMoveCursor =
|
||||
suppressHandles && classifyParticipant(node, selectedLevelId, nodes) !== null
|
||||
const live = useLiveTransforms((s) => (floorplanVisible ? s.transforms.get(nodeId) : undefined))
|
||||
const liveOverride = useLiveNodeOverrides((s) =>
|
||||
floorplanVisible ? s.overrides.get(nodeId) : undefined,
|
||||
@@ -1862,6 +1847,15 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
const liveOverrides = floorplanVisible
|
||||
? useLiveNodeOverrides.getState().overrides
|
||||
: EMPTY_LIVE_OVERRIDES
|
||||
const presentationVisibility = resolveFloorplanAnnotationVisibility(
|
||||
floorplanMode,
|
||||
annotationVisibility,
|
||||
{
|
||||
referencedAnnotationRole,
|
||||
selected,
|
||||
target: 'editor',
|
||||
},
|
||||
)
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<SVGGElement>) => onEntryPointerDown(nodeId, event),
|
||||
@@ -1871,16 +1865,16 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
// Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives
|
||||
// the highlight halo in 3D as well as registry floor-plan hover strokes.
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
const node = useScene.getState().nodes[nodeId]
|
||||
onHoveredIdChange(
|
||||
node
|
||||
const currentNode = useScene.getState().nodes[nodeId]
|
||||
setHoveredId(
|
||||
currentNode
|
||||
? resolveSelectionProxyId(
|
||||
node,
|
||||
currentNode,
|
||||
useScene.getState().nodes as Record<string, AnyNode | undefined>,
|
||||
)
|
||||
: nodeId,
|
||||
)
|
||||
}, [nodeId, onHoveredIdChange])
|
||||
}, [nodeId, setHoveredId])
|
||||
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
const node = useScene.getState().nodes[nodeId]
|
||||
@@ -1890,8 +1884,8 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
useScene.getState().nodes as Record<string, AnyNode | undefined>,
|
||||
)
|
||||
: nodeId
|
||||
if (useViewer.getState().hoveredId === targetId) onHoveredIdChange(null)
|
||||
}, [nodeId, onHoveredIdChange])
|
||||
if (useViewer.getState().hoveredId === targetId) setHoveredId(null)
|
||||
}, [nodeId, setHoveredId])
|
||||
|
||||
const handleHandlePointerDown = useCallback(
|
||||
(
|
||||
@@ -1940,6 +1934,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
)
|
||||
|
||||
const cacheEntry = buildFloorplanEntryGeometry({
|
||||
automaticDimensions: presentationVisibility.automaticDimensions,
|
||||
ctxOverrides,
|
||||
geometryCache: geometryCacheRef.current,
|
||||
highlighted,
|
||||
@@ -1964,7 +1959,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
})
|
||||
const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
|
||||
const visibleGeometry = rawGeometry
|
||||
? filterFloorplanAnnotationGeometry(rawGeometry, annotationVisibility)
|
||||
? filterFloorplanAnnotationGeometry(rawGeometry, presentationVisibility)
|
||||
: null
|
||||
// Multi-selection shows highlight only: strip this member's edit handles /
|
||||
// dimension chrome (all of which live in the overlay pass) while keeping
|
||||
@@ -2008,6 +2003,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
}, shallowPropsAreEqual)
|
||||
|
||||
type BuildFloorplanEntryGeometryArgs = {
|
||||
automaticDimensions: boolean
|
||||
ctxOverrides: FloorplanContextOverrides | undefined
|
||||
geometryCache: Map<string, CacheEntry>
|
||||
highlighted: boolean
|
||||
@@ -2052,7 +2048,20 @@ export function collectFloorplanDependencyNodes(
|
||||
})
|
||||
}
|
||||
|
||||
function floorplanEntryReferencedAnnotationRole(
|
||||
node: AnyNode,
|
||||
selectedIds: ReadonlySet<string>,
|
||||
): FloorplanAnnotationRole | undefined {
|
||||
if (selectedIds.size === 0) return undefined
|
||||
const definition = nodeRegistry.get(node.type)
|
||||
const role = getFloorplanNodeExtension(definition)?.referencedSelectionAnnotationRole
|
||||
if (!role) return undefined
|
||||
const dependencyIds = definition?.floorplanDependencies?.(node) ?? []
|
||||
return dependencyIds.some((id) => selectedIds.has(id)) ? role : undefined
|
||||
}
|
||||
|
||||
function buildFloorplanEntryGeometry({
|
||||
automaticDimensions,
|
||||
ctxOverrides,
|
||||
geometryCache,
|
||||
highlighted,
|
||||
@@ -2094,6 +2103,7 @@ function buildFloorplanEntryGeometry({
|
||||
)
|
||||
const dependencyNodes = collectFloorplanDependencyNodes(def, node, nodes, liveOverrides)
|
||||
const deps: NodeDeps = {
|
||||
automaticDimensions,
|
||||
node,
|
||||
live,
|
||||
unit,
|
||||
@@ -2174,6 +2184,7 @@ function buildFloorplanEntryGeometry({
|
||||
levelDataCache,
|
||||
)
|
||||
const viewState = {
|
||||
automaticDimensions,
|
||||
selected,
|
||||
unit,
|
||||
metricNotation,
|
||||
@@ -2197,6 +2208,7 @@ function buildFloorplanEntryGeometry({
|
||||
parent: ctxOverrides.parent,
|
||||
levelData,
|
||||
extensions: createFloorplanContextExtensions({
|
||||
automaticDimensions,
|
||||
metricNotation,
|
||||
purpose: 'edit',
|
||||
wallDimensionReference,
|
||||
@@ -2216,10 +2228,20 @@ function buildFloorplanEntryGeometry({
|
||||
...buildContext(effectiveNode, contextNodes, viewState, levelData),
|
||||
resolve: resolveContextNode,
|
||||
}
|
||||
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
||||
const modelGeometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
||||
effectiveNode,
|
||||
ctx,
|
||||
)
|
||||
const contextualGeometry = selected
|
||||
? (getFloorplanNodeExtension(def)?.contextualDimensions?.(effectiveNode, ctx) ?? null)
|
||||
: null
|
||||
const taggedContextualGeometry = withFloorplanGeometryMetadata(contextualGeometry, {
|
||||
annotationRole: 'contextual-dimension',
|
||||
})
|
||||
const geometry =
|
||||
modelGeometry && taggedContextualGeometry
|
||||
? { kind: 'group' as const, children: [modelGeometry, taggedContextualGeometry] }
|
||||
: (modelGeometry ?? taggedContextualGeometry)
|
||||
const { base, overlay } = geometry
|
||||
? splitFloorplanOverlay(geometry)
|
||||
: { base: null, overlay: null }
|
||||
@@ -2318,6 +2340,9 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
}: InteractiveGeometryProps): React.ReactElement {
|
||||
const liveUnitsPerPixel = useFloorplanStaticUnitsPerPixel()
|
||||
const unitsPerPixel = unitsPerPixelOverride ?? liveUnitsPerPixel
|
||||
// Keep handles pixel-sized through normal navigation, then let them shrink
|
||||
// with the plan once zoom compensation would make them dominate the geometry.
|
||||
const handleUnitsPerPixel = resolveFloorplanHandleUnitsPerPixel(unitsPerPixel)
|
||||
|
||||
return renderInteractive(geometry, 0)
|
||||
|
||||
@@ -2394,10 +2419,10 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
: palette.endpointHandleFill
|
||||
const outerRadius =
|
||||
(isActive ? ENDPOINT_HANDLE_ACTIVE_RADIUS_PX : ENDPOINT_HANDLE_SELECTED_RADIUS_PX) *
|
||||
unitsPerPixel
|
||||
handleUnitsPerPixel
|
||||
const dotRadius =
|
||||
(isActive ? ENDPOINT_HANDLE_ACTIVE_DOT_RADIUS_PX : ENDPOINT_HANDLE_DOT_RADIUS_PX) *
|
||||
unitsPerPixel
|
||||
handleUnitsPerPixel
|
||||
return (
|
||||
<g
|
||||
key={keyHint}
|
||||
@@ -2413,7 +2438,7 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
r={outerRadius}
|
||||
stroke={hoverStroke}
|
||||
strokeOpacity={isActive ? 0.24 : 0.16}
|
||||
strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * unitsPerPixel}
|
||||
strokeWidth={ENDPOINT_HOVER_GLOW_STROKE_WIDTH_PX * handleUnitsPerPixel}
|
||||
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
@@ -2425,7 +2450,7 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
r={outerRadius}
|
||||
stroke={hoverStroke}
|
||||
strokeOpacity={isActive ? 0.72 : 0.52}
|
||||
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
|
||||
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * handleUnitsPerPixel}
|
||||
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
@@ -2786,8 +2811,8 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
// Slightly smaller than endpoint dots; hover-expanded.
|
||||
const baseRadiusPx = 6
|
||||
const hoverRadiusPx = 8
|
||||
const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * unitsPerPixel
|
||||
const plusHalf = 3 * unitsPerPixel
|
||||
const radius = (isHovered || isActive ? hoverRadiusPx : baseRadiusPx) * handleUnitsPerPixel
|
||||
const plusHalf = 3 * handleUnitsPerPixel
|
||||
return (
|
||||
<g
|
||||
key={keyHint}
|
||||
@@ -2800,10 +2825,10 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
cy={g.point[1]}
|
||||
fill="none"
|
||||
pointerEvents="none"
|
||||
r={radius + 2 * unitsPerPixel}
|
||||
r={radius + 2 * handleUnitsPerPixel}
|
||||
stroke={hoverStroke}
|
||||
strokeOpacity={0.16}
|
||||
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * unitsPerPixel}
|
||||
strokeWidth={ENDPOINT_HOVER_RING_STROKE_WIDTH_PX * handleUnitsPerPixel}
|
||||
style={{ opacity: isHovered || isActive ? 1 : 0, transition: HOVER_TRANSITION }}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
@@ -3007,11 +3032,17 @@ export const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
// Counter-rotate by the scene rotation so the label reads
|
||||
// horizontally on screen even when the floor-plan view is
|
||||
// rotated (default `sceneRotationDeg` is 90°).
|
||||
const beforeRotation = `translate(${g.x} ${g.y})`
|
||||
const transform = `${beforeRotation} rotate(${-sceneRotationDeg})`
|
||||
return (
|
||||
<g
|
||||
data-floorplan-annotation-obstacle={floorplanAnnotationObstacleMode(g)}
|
||||
data-floorplan-annotation-angle-radians={0}
|
||||
data-floorplan-annotation-default-transform={transform}
|
||||
data-floorplan-annotation-screen-upright="true"
|
||||
data-floorplan-annotation-transform-before-rotation={beforeRotation}
|
||||
key={keyHint}
|
||||
transform={`translate(${g.x} ${g.y}) rotate(${-sceneRotationDeg})`}
|
||||
transform={transform}
|
||||
>
|
||||
<text
|
||||
dominantBaseline={g.dominantBaseline ?? 'middle'}
|
||||
@@ -3117,6 +3148,7 @@ export function buildContext(
|
||||
node: AnyNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
viewState: {
|
||||
automaticDimensions?: boolean
|
||||
selected: boolean
|
||||
unit: 'metric' | 'imperial'
|
||||
metricNotation?: 'meters' | 'millimeters'
|
||||
@@ -3162,6 +3194,7 @@ export function buildContext(
|
||||
parent,
|
||||
levelData,
|
||||
extensions: createFloorplanContextExtensions({
|
||||
automaticDimensions: viewState.automaticDimensions,
|
||||
metricNotation: viewState.metricNotation ?? 'meters',
|
||||
purpose: viewState.purpose ?? 'edit',
|
||||
wallDimensionReference: viewState.wallDimensionReference,
|
||||
@@ -3267,6 +3300,9 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
|
||||
base: FloorplanGeometry | null
|
||||
overlay: FloorplanGeometry | null
|
||||
} {
|
||||
if (readFloorplanGeometryMetadata(g).renderPass === 'overlay') {
|
||||
return { base: null, overlay: g }
|
||||
}
|
||||
if (isFloorplanAnnotationObstacleGeometry(g)) {
|
||||
return { base: null, overlay: g }
|
||||
}
|
||||
@@ -3282,12 +3318,10 @@ export function splitFloorplanOverlay(g: FloorplanGeometry): {
|
||||
if (split.overlay) overlayChildren.push(split.overlay)
|
||||
}
|
||||
const base: FloorplanGeometry | null =
|
||||
baseChildren.length > 0
|
||||
? { kind: 'group', children: baseChildren, transform: g.transform }
|
||||
: null
|
||||
baseChildren.length > 0 ? { ...g, children: baseChildren, transform: g.transform } : null
|
||||
const overlay: FloorplanGeometry | null =
|
||||
overlayChildren.length > 0
|
||||
? { kind: 'group', children: overlayChildren, transform: g.transform }
|
||||
? { ...g, children: overlayChildren, transform: g.transform }
|
||||
: null
|
||||
return { base, overlay }
|
||||
}
|
||||
@@ -3428,6 +3462,7 @@ export function computeAffectedSiblingIds(
|
||||
|
||||
function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
|
||||
const keys: Array<keyof NodeDeps> = [
|
||||
'automaticDimensions',
|
||||
'node',
|
||||
'live',
|
||||
'unit',
|
||||
|
||||
@@ -10,8 +10,6 @@ const baseArgs = {
|
||||
modifierKeys: { meta: false, ctrl: false, shift: false },
|
||||
planPoint: [0, 0] as [number, number],
|
||||
structureLayer: 'elements',
|
||||
toPoint2D: ([x, y]: [number, number]) => ({ x, y }),
|
||||
visibleZonePolygons: [],
|
||||
}
|
||||
|
||||
describe('resolveFloorplanBackgroundSelection', () => {
|
||||
@@ -55,4 +53,20 @@ describe('resolveFloorplanBackgroundSelection', () => {
|
||||
preserveSelection: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('uses the registry hit result for zone selection', () => {
|
||||
const result = resolveFloorplanBackgroundSelection({
|
||||
...baseArgs,
|
||||
canSelectElementFloorplanGeometry: false,
|
||||
canSelectFloorplanZones: true,
|
||||
getFloorplanHitIdAtPoint: () => 'zone_1',
|
||||
structureLayer: 'zones',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
handled: true,
|
||||
kind: 'select-zone',
|
||||
zoneId: 'zone_1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { Point2D, ZoneNode as ZoneNodeType } from '@pascal-app/core'
|
||||
import { isPointInsidePolygon } from '../../lib/floorplan'
|
||||
import type { ZoneNode as ZoneNodeType } from '@pascal-app/core'
|
||||
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
|
||||
|
||||
type ModifierKeys = {
|
||||
@@ -10,13 +9,6 @@ type ModifierKeys = {
|
||||
shift: boolean
|
||||
}
|
||||
|
||||
type ZoneHitEntry = {
|
||||
zone: {
|
||||
id: ZoneNodeType['id']
|
||||
}
|
||||
polygon: Point2D[]
|
||||
}
|
||||
|
||||
type ResolveFloorplanBackgroundSelectionArgs = {
|
||||
canSelectElementFloorplanGeometry: boolean
|
||||
canSelectFloorplanZones: boolean
|
||||
@@ -26,8 +18,6 @@ type ResolveFloorplanBackgroundSelectionArgs = {
|
||||
modifierKeys: ModifierKeys
|
||||
planPoint: WallPlanPoint
|
||||
structureLayer: string
|
||||
toPoint2D: (point: WallPlanPoint) => Point2D
|
||||
visibleZonePolygons: ZoneHitEntry[]
|
||||
}
|
||||
|
||||
export type FloorplanBackgroundSelectionResult =
|
||||
@@ -63,18 +53,14 @@ export function resolveFloorplanBackgroundSelection({
|
||||
modifierKeys,
|
||||
planPoint,
|
||||
structureLayer,
|
||||
toPoint2D,
|
||||
visibleZonePolygons,
|
||||
}: ResolveFloorplanBackgroundSelectionArgs): FloorplanBackgroundSelectionResult {
|
||||
if (canSelectFloorplanZones) {
|
||||
const zoneHit = visibleZonePolygons.find(({ polygon }) =>
|
||||
isPointInsidePolygon(toPoint2D(planPoint), polygon),
|
||||
)
|
||||
if (zoneHit) {
|
||||
const zoneId = getFloorplanHitIdAtPoint(planPoint)
|
||||
if (zoneId) {
|
||||
return {
|
||||
handled: true,
|
||||
kind: 'select-zone',
|
||||
zoneId: zoneHit.zone.id,
|
||||
zoneId: zoneId as ZoneNodeType['id'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, nodeRegistry } from '@pascal-app/core'
|
||||
import { X } from 'lucide-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { getFloorplanNodeExtension } from '../../lib/floorplan/floorplan-extension'
|
||||
import { isFloorplanToolAvailableInMode } from '../../lib/floorplan/floorplan-mode'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import useFloorplanMode from '../../store/use-floorplan-mode'
|
||||
|
||||
function getToolLabel(tool: string): string {
|
||||
return nodeRegistry.get(tool)?.presentation?.label ?? tool
|
||||
}
|
||||
|
||||
export function FloorplanModeCoordinator() {
|
||||
const editorMode = useEditor((state) => state.mode)
|
||||
const tool = useEditor((state) => state.tool)
|
||||
const floorplanMode = useFloorplanMode((state) => state.mode)
|
||||
const notice = useFloorplanMode((state) => state.notice)
|
||||
const dismissNotice = useFloorplanMode((state) => state.dismissNotice)
|
||||
const setFloorplanMode = useFloorplanMode((state) => state.setMode)
|
||||
const showExpertModeNotice = useFloorplanMode((state) => state.showExpertModeNotice)
|
||||
const showNotice = useFloorplanMode((state) => state.showNotice)
|
||||
const previousFloorplanMode = useRef(floorplanMode)
|
||||
|
||||
useEffect(() => {
|
||||
const priorFloorplanMode = previousFloorplanMode.current
|
||||
previousFloorplanMode.current = floorplanMode
|
||||
if (floorplanMode !== 'default' || editorMode !== 'build' || !tool) return
|
||||
const extension = getFloorplanNodeExtension(nodeRegistry.get(tool))
|
||||
if (isFloorplanToolAvailableInMode(extension?.availableModes, floorplanMode)) return
|
||||
|
||||
const toolLabel = getToolLabel(tool)
|
||||
emitter.emit('tool:cancel')
|
||||
useEditor.getState().setMode('select')
|
||||
if (priorFloorplanMode === 'expert') {
|
||||
showNotice(
|
||||
`Switched to Default. The unfinished ${toolLabel} draft was canceled; saved Expert annotations are hidden, not deleted.`,
|
||||
)
|
||||
} else {
|
||||
showExpertModeNotice(toolLabel)
|
||||
}
|
||||
}, [editorMode, floorplanMode, showExpertModeNotice, showNotice, tool])
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice || notice.kind === 'switch-to-expert') return
|
||||
const timer = window.setTimeout(dismissNotice, 6000)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [dismissNotice, notice])
|
||||
|
||||
if (!notice) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="fixed top-16 left-1/2 z-[100] flex max-w-md -translate-x-1/2 items-center gap-3 rounded-lg border border-border/60 bg-background/95 px-3 py-2 text-sm text-foreground shadow-elevation-3 backdrop-blur-xl"
|
||||
role="status"
|
||||
>
|
||||
<span>{notice.message}</span>
|
||||
{notice.kind === 'switch-to-expert' ? (
|
||||
<button
|
||||
className="shrink-0 rounded-md bg-cyan-500/15 px-2.5 py-1 font-medium text-cyan-400 hover:bg-cyan-500/25"
|
||||
onClick={() => {
|
||||
setFloorplanMode('expert')
|
||||
dismissNotice()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Switch to Expert
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
aria-label="Dismiss"
|
||||
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-white/10 hover:text-foreground"
|
||||
onClick={dismissNotice}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -35,10 +35,8 @@ import {
|
||||
type SiteNode,
|
||||
type SlabNode,
|
||||
SlabNode as SlabNodeSchema,
|
||||
type SpawnNode,
|
||||
type StairNode,
|
||||
StairNode as StairNodeSchema,
|
||||
type StairSegmentNode,
|
||||
StairSegmentNode as StairSegmentNodeSchema,
|
||||
sampleWallCenterline,
|
||||
sceneRegistry,
|
||||
@@ -464,21 +462,6 @@ type PendingFenceDragState = {
|
||||
startClientY: number
|
||||
}
|
||||
|
||||
type ElevatorResizeHandle =
|
||||
| 'width-negative'
|
||||
| 'width-positive'
|
||||
| 'depth-negative'
|
||||
| 'depth-positive'
|
||||
|
||||
type ElevatorResizeDragState = {
|
||||
center: Point2D
|
||||
elevatorId: ElevatorNode['id']
|
||||
handle: ElevatorResizeHandle
|
||||
pointerId: number
|
||||
rotation: number
|
||||
shaftWallThickness: number
|
||||
}
|
||||
|
||||
const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const
|
||||
|
||||
type GuideCorner = (typeof GUIDE_CORNERS)[number]
|
||||
@@ -643,22 +626,11 @@ type SitePolygonEntry = {
|
||||
points: string
|
||||
}
|
||||
|
||||
type ZonePolygonEntry = {
|
||||
zone: ZoneNodeType
|
||||
polygon: Point2D[]
|
||||
points: string
|
||||
}
|
||||
|
||||
type FloorplanLineSegment = {
|
||||
start: Point2D
|
||||
end: Point2D
|
||||
}
|
||||
|
||||
type FloorplanPolygonEntry = {
|
||||
points: string
|
||||
polygon: Point2D[]
|
||||
}
|
||||
|
||||
type FloorplanItemEntry = {
|
||||
dimensionPolygon: Point2D[]
|
||||
item: ItemNode
|
||||
@@ -674,52 +646,6 @@ type FloorplanItemEntry = {
|
||||
depth: number
|
||||
}
|
||||
|
||||
type FloorplanSpawnEntry = {
|
||||
spawn: SpawnNode
|
||||
position: Point2D
|
||||
rotation: number
|
||||
}
|
||||
|
||||
type FloorplanColumnEntry = {
|
||||
column: ColumnNode
|
||||
points: string
|
||||
polygon: Point2D[]
|
||||
}
|
||||
|
||||
type FloorplanElevatorServedLevel = {
|
||||
id: LevelNode['id']
|
||||
isCurrent: boolean
|
||||
isDisabled: boolean
|
||||
isQueued: boolean
|
||||
isServiceOnly: boolean
|
||||
isTarget: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
type FloorplanElevatorEntry = {
|
||||
cabCenterLocalY: number
|
||||
cabDepth: number
|
||||
cabWidth: number
|
||||
center: Point2D
|
||||
doorStyle: ElevatorNode['doorStyle']
|
||||
doorWidth: number
|
||||
elevator: ElevatorNode
|
||||
frontEdge: FloorplanLineSegment
|
||||
frontNormal: Point2D
|
||||
isCarOnLevel: boolean
|
||||
isQueuedLevel: boolean
|
||||
isTargetLevel: boolean
|
||||
outerHalfDepth: number
|
||||
outerHalfWidth: number
|
||||
points: string
|
||||
polygon: Point2D[]
|
||||
rotation: number
|
||||
servedLevels: FloorplanElevatorServedLevel[]
|
||||
shaftDepth: number
|
||||
shaftWallThickness: number
|
||||
shaftWidth: number
|
||||
}
|
||||
|
||||
type ReferenceFloorData = {
|
||||
ceilingPolygons: CeilingPolygonEntry[]
|
||||
columnEntries: ReferenceFloorColumnEntry[]
|
||||
@@ -764,42 +690,6 @@ const REFERENCE_REGISTRY_KINDS = new Set<AnyNode['type']>([
|
||||
'elevator',
|
||||
])
|
||||
|
||||
type FloorplanStairSegmentEntry = {
|
||||
centerLine: FloorplanLineSegment | null
|
||||
innerPoints: string
|
||||
innerPolygon: Point2D[]
|
||||
segment: StairSegmentNode
|
||||
points: string
|
||||
polygon: Point2D[]
|
||||
treadBars: FloorplanPolygonEntry[]
|
||||
treadThickness: number
|
||||
}
|
||||
|
||||
type FloorplanStairArrowEntry = {
|
||||
head: Point2D[]
|
||||
polyline: Point2D[]
|
||||
}
|
||||
|
||||
type FloorplanStairEntry = {
|
||||
arrow: FloorplanStairArrowEntry | null
|
||||
hitPolygons: Point2D[][]
|
||||
stair: StairNode
|
||||
segments: FloorplanStairSegmentEntry[]
|
||||
}
|
||||
|
||||
type FloorplanRoofSegmentEntry = {
|
||||
segment: RoofSegmentNode
|
||||
polygon: Point2D[]
|
||||
points: string
|
||||
ridgeLine: FloorplanLineSegment | null
|
||||
}
|
||||
|
||||
type FloorplanRoofEntry = {
|
||||
roof: RoofNode
|
||||
center: Point2D
|
||||
segments: FloorplanRoofSegmentEntry[]
|
||||
}
|
||||
|
||||
type FloorplanPalette = {
|
||||
surface: string
|
||||
minorGrid: string
|
||||
@@ -944,18 +834,6 @@ function resolveFloorplanViewWidth(
|
||||
return clamp(requestedWidth, Math.min(minWidth, currentWidth), Math.max(maxWidth, currentWidth))
|
||||
}
|
||||
|
||||
function roundPlanMeters(value: number) {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
function getElevatorResizeAxis(handle: ElevatorResizeHandle) {
|
||||
return handle.startsWith('width') ? 'width' : 'depth'
|
||||
}
|
||||
|
||||
function getElevatorResizeSign(handle: ElevatorResizeHandle) {
|
||||
return handle.endsWith('positive') ? 1 : -1
|
||||
}
|
||||
|
||||
function getSelectionModifierKeys(event?: {
|
||||
metaKey?: boolean
|
||||
ctrlKey?: boolean
|
||||
@@ -2465,7 +2343,6 @@ function buildDraftWall(levelId: string, start: WallPlanPoint, end: WallPlanPoin
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
assemblyLayers: [],
|
||||
start,
|
||||
end,
|
||||
frontSide: 'unknown',
|
||||
@@ -4165,262 +4042,6 @@ const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({
|
||||
)
|
||||
})
|
||||
|
||||
const FloorplanZoneLayer = memo(function FloorplanZoneLayer({
|
||||
canSelectZones,
|
||||
hoveredZoneId,
|
||||
isDeleteMode,
|
||||
onZoneHoverChange,
|
||||
onZoneSelect,
|
||||
palette,
|
||||
selectedZoneId,
|
||||
zonePolygons,
|
||||
}: {
|
||||
canSelectZones: boolean
|
||||
hoveredZoneId: ZoneNodeType['id'] | null
|
||||
isDeleteMode: boolean
|
||||
onZoneHoverChange: (zoneId: ZoneNodeType['id'] | null) => void
|
||||
onZoneSelect: (zoneId: ZoneNodeType['id'], event: ReactMouseEvent<SVGElement>) => void
|
||||
palette: FloorplanPalette
|
||||
selectedZoneId: ZoneNodeType['id'] | null
|
||||
zonePolygons: ZonePolygonEntry[]
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{zonePolygons.map(({ zone, points }) => {
|
||||
const isSelected = selectedZoneId === zone.id
|
||||
const isHovered = hoveredZoneId === zone.id
|
||||
const isDeleteHovered = isDeleteMode && isHovered
|
||||
|
||||
return (
|
||||
<g key={zone.id}>
|
||||
<polygon
|
||||
fill={isDeleteHovered ? palette.deleteFill : zone.color}
|
||||
fillOpacity={isDeleteHovered ? 0.22 : isSelected ? 0.28 : 0.16}
|
||||
pointerEvents="none"
|
||||
points={points}
|
||||
stroke={
|
||||
isDeleteHovered
|
||||
? palette.deleteStroke
|
||||
: isSelected
|
||||
? palette.selectedStroke
|
||||
: zone.color
|
||||
}
|
||||
strokeLinejoin="round"
|
||||
strokeOpacity={isDeleteHovered || isSelected ? 0.96 : 0.72}
|
||||
strokeWidth={isDeleteHovered || isSelected ? '0.08' : '0.05'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{canSelectZones && (
|
||||
<polygon
|
||||
fill="none"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onZoneSelect(zone.id, event)
|
||||
}}
|
||||
onPointerEnter={() => onZoneHoverChange(zone.id)}
|
||||
onPointerLeave={() => onZoneHoverChange(null)}
|
||||
pointerEvents="stroke"
|
||||
points={points}
|
||||
stroke="transparent"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={FLOORPLAN_WALL_HIT_STROKE_WIDTH}
|
||||
style={{ cursor: EDITOR_CURSOR }}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const FLOORPLAN_ZONE_LABEL_FONT_SIZE = 0.2
|
||||
|
||||
function FloorplanZoneLabelInput({
|
||||
centroid,
|
||||
svgRef,
|
||||
viewBox,
|
||||
zone,
|
||||
onDone,
|
||||
}: {
|
||||
centroid: { x: number; y: number }
|
||||
svgRef: React.RefObject<SVGSVGElement | null>
|
||||
viewBox: { minX: number; minY: number; width: number; height: number }
|
||||
zone: ZoneNodeType
|
||||
onDone: () => void
|
||||
}) {
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const [value, setValue] = useState(zone.name)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
})
|
||||
}, [])
|
||||
|
||||
const save = useCallback(() => {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed && trimmed !== zone.name) {
|
||||
updateNode(zone.id, { name: trimmed })
|
||||
}
|
||||
onDone()
|
||||
}, [value, zone.id, zone.name, updateNode, onDone])
|
||||
|
||||
// Convert SVG coordinates to screen pixel position
|
||||
const svgEl = svgRef.current
|
||||
if (!svgEl) return null
|
||||
const rect = svgEl.getBoundingClientRect()
|
||||
const screenX = ((centroid.x - viewBox.minX) / viewBox.width) * rect.width + rect.left
|
||||
const screenY = ((centroid.y - viewBox.minY) / viewBox.height) * rect.height + rect.top
|
||||
|
||||
return createPortal(
|
||||
<input
|
||||
onBlur={save}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
save()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onDone()
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
ref={inputRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: screenX,
|
||||
top: screenY,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
border: 'none',
|
||||
borderBottom: `1px solid ${zone.color}`,
|
||||
background: 'transparent',
|
||||
color: 'white',
|
||||
textShadow: `-1px -1px 0 ${zone.color}, 1px -1px 0 ${zone.color}, -1px 1px 0 ${zone.color}, 1px 1px 0 ${zone.color}`,
|
||||
outline: 'none',
|
||||
textAlign: 'center',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
padding: '2px 4px',
|
||||
margin: 0,
|
||||
zIndex: 100,
|
||||
width: `${Math.max((value || zone.name || '').length + 2, 6)}ch`,
|
||||
}}
|
||||
type="text"
|
||||
value={value}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
// Pencil icon as an SVG path (Lucide pencil simplified), rendered relative to the label
|
||||
const PENCIL_ICON_SIZE = FLOORPLAN_ZONE_LABEL_FONT_SIZE * 0.6
|
||||
|
||||
function FloorplanZoneLabel({
|
||||
centroid,
|
||||
onHoverChange,
|
||||
onLabelClick,
|
||||
zone,
|
||||
}: {
|
||||
centroid: { x: number; y: number }
|
||||
onHoverChange: (zoneId: ZoneNodeType['id'] | null) => void
|
||||
onLabelClick: (zoneId: ZoneNodeType['id'], event: ReactMouseEvent<SVGElement>) => void
|
||||
zone: ZoneNodeType
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const textRef = useRef<SVGTextElement>(null)
|
||||
const [textWidth, setTextWidth] = useState(0)
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
|
||||
useEffect(() => {
|
||||
if (textRef.current) {
|
||||
setTextWidth(textRef.current.getComputedTextLength())
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isDeleteMode = mode === 'delete'
|
||||
|
||||
return (
|
||||
<g
|
||||
cursor="pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isDeleteMode) {
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
deleteNode(zone.id as AnyNodeId)
|
||||
setSelection({ zoneId: null })
|
||||
return
|
||||
}
|
||||
onLabelClick(zone.id, e)
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
setHovered(true)
|
||||
onHoverChange(zone.id)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHovered(false)
|
||||
onHoverChange(null)
|
||||
}}
|
||||
pointerEvents="auto"
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<text
|
||||
dominantBaseline="central"
|
||||
fill={isDeleteMode && hovered ? '#fecaca' : 'white'}
|
||||
fontFamily="system-ui, -apple-system, sans-serif"
|
||||
fontSize={FLOORPLAN_ZONE_LABEL_FONT_SIZE}
|
||||
fontWeight="500"
|
||||
paintOrder="stroke"
|
||||
ref={textRef}
|
||||
stroke={isDeleteMode && hovered ? '#dc2626' : zone.color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={FLOORPLAN_ZONE_LABEL_FONT_SIZE * 0.35}
|
||||
textAnchor="middle"
|
||||
x={centroid.x}
|
||||
y={centroid.y}
|
||||
>
|
||||
{zone.name}
|
||||
</text>
|
||||
{/* Pencil icon — visible on hover */}
|
||||
{hovered && textWidth > 0 && (
|
||||
<g
|
||||
transform={`translate(${centroid.x + textWidth / 2 + PENCIL_ICON_SIZE * 0.5}, ${centroid.y - PENCIL_ICON_SIZE / 2})`}
|
||||
>
|
||||
<g transform={`scale(${PENCIL_ICON_SIZE / 24})`}>
|
||||
<path
|
||||
d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
|
||||
fill="none"
|
||||
paintOrder="stroke"
|
||||
stroke={zone.color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<path
|
||||
d="m15 5 4 4"
|
||||
fill="none"
|
||||
stroke={zone.color}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
edgeHandles = [],
|
||||
hoveredHandleId,
|
||||
@@ -5605,24 +5226,7 @@ export function FloorplanPanel({
|
||||
)
|
||||
const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null)
|
||||
const [wallCurveDraft, setWallCurveDraft] = useState<WallCurveDraft | null>(null)
|
||||
const [hoveredOpeningId, setHoveredOpeningId] = useState<OpeningNode['id'] | null>(null)
|
||||
const [hoveredWallId, setHoveredWallId] = useState<WallNode['id'] | null>(null)
|
||||
const [hoveredFenceId, setHoveredFenceId] = useState<FenceNode['id'] | null>(null)
|
||||
const [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null)
|
||||
const [hoveredCeilingId, setHoveredCeilingId] = useState<CeilingNode['id'] | null>(null)
|
||||
const [hoveredItemId, setHoveredItemId] = useState<ItemNode['id'] | null>(null)
|
||||
const [hoveredSpawnId, setHoveredSpawnId] = useState<SpawnNode['id'] | null>(null)
|
||||
const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null)
|
||||
const [hoveredElevatorId, setHoveredElevatorId] = useState<ElevatorNode['id'] | null>(null)
|
||||
const [elevatorResizeDragState, setElevatorResizeDragState] =
|
||||
useState<ElevatorResizeDragState | null>(null)
|
||||
const [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null)
|
||||
const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)
|
||||
const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState<string | null>(null)
|
||||
const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState<string | null>(null)
|
||||
const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState<string | null>(null)
|
||||
const [hoveredCeilingHandleId, setHoveredCeilingHandleId] = useState<string | null>(null)
|
||||
const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState<string | null>(null)
|
||||
const [hoveredGuideCorner, setHoveredGuideCorner] = useState<GuideCorner | null>(null)
|
||||
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
|
||||
const setFloorplanSelectionTool = useEditor((s) => s.setFloorplanSelectionTool)
|
||||
@@ -5717,11 +5321,9 @@ export function FloorplanPanel({
|
||||
if (!floorplanViewportInteractionInProgressRef.current) {
|
||||
latestViewportRef.current = viewport
|
||||
}
|
||||
// Tight bbox of the painted floor-plan scene (the rotation `<g>`'s
|
||||
// children), read via SVG `getBBox()` after content changes settle. The legacy
|
||||
// polygon arrays (`wallPolygons`, `displaySlabPolygons`, etc.) are now
|
||||
// empty stubs because rendering moved to the registry layer, so
|
||||
// measuring the DOM is how `fittedViewport` learns where content lives.
|
||||
// Tight bbox of the registry-rendered floor-plan content, read via SVG
|
||||
// `getBBox()` after geometry changes settle. `fittedViewport` combines it
|
||||
// with the editor-owned site boundary below.
|
||||
const [measuredSceneBBox, setMeasuredSceneBBox] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
@@ -5962,28 +5564,9 @@ export function FloorplanPanel({
|
||||
|
||||
return hasPreviewWalls ? nextFloorplanWallById : floorplanWallById
|
||||
}, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft])
|
||||
// Fence is fully registry-driven (`def.floorplan` + `buildFenceFloorplan`).
|
||||
// The legacy entry list is permanently empty; kept as a typed stable
|
||||
// reference so downstream prop sites stay typed without each having to
|
||||
// declare its own `[]`.
|
||||
const floorplanFenceEntries = useMemo<FloorplanFenceEntry[]>(() => [], [])
|
||||
// Wall is fully registry-driven. Empty stable arrays for the legacy
|
||||
// entry lists; consumers' map / iteration paths become no-ops.
|
||||
const wallPolygons = useMemo<WallPolygonEntry[]>(() => [], [])
|
||||
const displayWallPolygons = useMemo<WallPolygonEntry[]>(() => [], [])
|
||||
|
||||
// Doors + windows fully registry-driven via `def.floorplan`.
|
||||
const openingsPolygons = useMemo<OpeningPolygonEntry[]>(() => [], [])
|
||||
// Slab + ceiling fully registry-driven via `def.floorplan`. Same
|
||||
// empty-stable-array pattern.
|
||||
const slabPolygons = useMemo<SlabPolygonEntry[]>(() => [], [])
|
||||
const displaySlabPolygons = useMemo<SlabPolygonEntry[]>(() => [], [])
|
||||
const ceilingPolygons = useMemo<CeilingPolygonEntry[]>(() => [], [])
|
||||
const displayCeilingPolygons = useMemo<CeilingPolygonEntry[]>(() => [], [])
|
||||
// Ceilings on the active level, projected to 2D polygons for hit-testing
|
||||
// ceiling-item placement clicks/moves. The legacy `ceilingPolygons` above
|
||||
// is intentionally empty (ceilings render via the registry layer); this
|
||||
// memo is the placement-side counterpart, separate from rendering.
|
||||
// ceiling-item placement clicks/moves. Committed ceilings render through
|
||||
// the registry; this memo is placement data, not a rendering fallback.
|
||||
const ceilingHitEntries = useMemo(
|
||||
() =>
|
||||
ceilings.map((ceiling) => ({
|
||||
@@ -5995,11 +5578,6 @@ export function FloorplanPanel({
|
||||
})),
|
||||
[ceilings],
|
||||
)
|
||||
// Zone fully registry-driven via `def.floorplan`.
|
||||
const zonePolygons = useMemo<ZonePolygonEntry[]>(() => [], [])
|
||||
const displayZonePolygons = useMemo<ZonePolygonEntry[]>(() => [], [])
|
||||
// Column fully registry-driven via `def.floorplan`.
|
||||
const floorplanColumnEntries = useMemo<FloorplanColumnEntry[]>(() => [], [])
|
||||
const levelDescendantNodeById = useMemo(
|
||||
() => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)),
|
||||
[levelDescendantNodes],
|
||||
@@ -6022,11 +5600,6 @@ export function FloorplanPanel({
|
||||
),
|
||||
[levelDescendantNodes],
|
||||
)
|
||||
// Spawn + item fully registry-driven.
|
||||
const floorplanSpawnEntries = useMemo<FloorplanSpawnEntry[]>(() => [], [])
|
||||
const floorplanItemEntries = useMemo<FloorplanItemEntry[]>(() => [], [])
|
||||
// Elevator fully registry-driven via `def.floorplan`.
|
||||
const floorplanElevatorEntries = useMemo<FloorplanElevatorEntry[]>(() => [], [])
|
||||
const referenceFloorLevel = useMemo(() => {
|
||||
if (!(showReferenceFloor && levelNode)) {
|
||||
return null
|
||||
@@ -6269,26 +5842,6 @@ export function FloorplanPanel({
|
||||
wallPolygons,
|
||||
}
|
||||
}, [referenceFloorDescendants, referenceFloorLevel])
|
||||
// Pending-mesh check was a flag the legacy active-level item entries
|
||||
// raised when their polygon was the dimension fallback (waiting for
|
||||
// the GLB to load to produce a tighter convex hull). Items are now
|
||||
// registry-rendered, so the active-level entry list is always empty
|
||||
// and this flag is permanently false.
|
||||
const hasPendingItemMeshFootprints = false
|
||||
// Stair fully registry-driven via `def.floorplan` (the parent walks
|
||||
// its `stair-segment` children inside `buildStairFloorplan` to handle
|
||||
// the cumulative-transform chain). `FloorplanRegistryLayer` renders
|
||||
// the result; this legacy list stays empty.
|
||||
const floorplanStairEntries = useMemo<FloorplanStairEntry[]>(() => [], [])
|
||||
// Roof / roof-segment fully registry-driven via def.floorplan.
|
||||
const floorplanRoofEntries = useMemo<FloorplanRoofEntry[]>(() => [], [])
|
||||
// Slab / ceiling / zone are registry-driven; the polygon-handle, hole
|
||||
// editor, and boundary-edit affordances live on `def.floorplanAffordances`.
|
||||
// These legacy lookups stay as null stubs so the hole-editing fallbacks
|
||||
// that still reference them compile cleanly.
|
||||
const selectedSlabEntry = null as SlabPolygonEntry | null
|
||||
const selectedCeilingEntry = null as CeilingPolygonEntry | null
|
||||
const selectedZoneEntry = null as ZonePolygonEntry | null
|
||||
const slabById = useMemo(() => new Map(slabs.map((slab) => [slab.id, slab] as const)), [slabs])
|
||||
const zoneById = useMemo(() => new Map(zones.map((zone) => [zone.id, zone] as const)), [zones])
|
||||
const ceilingById = useMemo(
|
||||
@@ -6509,7 +6062,6 @@ export function FloorplanPanel({
|
||||
Boolean(visibleSitePolygon) &&
|
||||
activeHandleDrag?.nodeId === visibleSitePolygon?.site.id &&
|
||||
activeHandleDrag?.label === SITE_BOUNDARY_DRAG_LABEL
|
||||
const visibleZonePolygons = displayZonePolygons
|
||||
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
const highlightedFloorplanIdSet = useMemo(
|
||||
() => new Set([...selectedIds, ...previewSelectedIds]),
|
||||
@@ -6636,31 +6188,12 @@ export function FloorplanPanel({
|
||||
)
|
||||
|
||||
const fittedViewport = useMemo(() => {
|
||||
// Collect bounds from the legacy polygon arrays first. Most are empty
|
||||
// stubs (rendering moved to the registry layer), but we still honor
|
||||
// anything that does emit points so the fit is correct during the
|
||||
// brief window before `measuredSceneBBox` is populated.
|
||||
const legacyPoints = [
|
||||
...(visibleSitePolygon ? visibleSitePolygon.polygon : []),
|
||||
...displayCeilingPolygons.flatMap((entry) => entry.polygon),
|
||||
...displaySlabPolygons.flatMap((entry) => entry.polygon),
|
||||
...floorplanElevatorEntries.flatMap((entry) => entry.polygon),
|
||||
...floorplanFenceEntries.flatMap((entry) => entry.centerline),
|
||||
...floorplanItemEntries.flatMap((entry) => entry.polygon),
|
||||
...floorplanRoofEntries.flatMap((entry) =>
|
||||
entry.segments.flatMap((segmentEntry) => segmentEntry.polygon),
|
||||
),
|
||||
...floorplanStairEntries.flatMap((entry) => entry.hitPolygons.flat()),
|
||||
...visibleZonePolygons.flatMap((entry) => entry.polygon),
|
||||
...wallPolygons.flatMap((entry) => entry.polygon),
|
||||
]
|
||||
|
||||
let minX = Number.POSITIVE_INFINITY
|
||||
let maxX = Number.NEGATIVE_INFINITY
|
||||
let minY = Number.POSITIVE_INFINITY
|
||||
let maxY = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (const point of legacyPoints) {
|
||||
for (const point of visibleSitePolygon?.polygon ?? []) {
|
||||
const svgPoint = rotateSvgPoint(toSvgPoint(point), floorplanSceneRotationDeg)
|
||||
minX = Math.min(minX, svgPoint.x)
|
||||
maxX = Math.max(maxX, svgPoint.x)
|
||||
@@ -6708,21 +6241,7 @@ export function FloorplanPanel({
|
||||
centerY,
|
||||
width,
|
||||
}
|
||||
}, [
|
||||
displayCeilingPolygons,
|
||||
displaySlabPolygons,
|
||||
floorplanElevatorEntries,
|
||||
floorplanFenceEntries,
|
||||
floorplanItemEntries,
|
||||
floorplanRoofEntries,
|
||||
floorplanSceneRotationDeg,
|
||||
floorplanStairEntries,
|
||||
measuredSceneBBox,
|
||||
svgAspectRatio,
|
||||
visibleSitePolygon,
|
||||
visibleZonePolygons,
|
||||
wallPolygons,
|
||||
])
|
||||
}, [floorplanSceneRotationDeg, measuredSceneBBox, svgAspectRatio, visibleSitePolygon])
|
||||
latestFittedViewportRef.current = fittedViewport
|
||||
|
||||
// Measure the content-only subtree after its geometry settles. ViewBox-only
|
||||
@@ -7825,120 +7344,6 @@ export function FloorplanPanel({
|
||||
[getSvgPointFromClientPoint, buildingRotationY],
|
||||
)
|
||||
|
||||
const previewElevatorResize = useCallback(
|
||||
(dragState: ElevatorResizeDragState, planPoint: WallPlanPoint) => {
|
||||
const localDeltaX = planPoint[0] - dragState.center.x
|
||||
const localDeltaY = planPoint[1] - dragState.center.y
|
||||
const [localX, localY] = rotatePlanVector(localDeltaX, localDeltaY, -dragState.rotation)
|
||||
const axis = getElevatorResizeAxis(dragState.handle)
|
||||
const sign = getElevatorResizeSign(dragState.handle)
|
||||
const localDistance = sign * (axis === 'width' ? localX : localY)
|
||||
const nextOuterSize = Math.max(0.1, localDistance) * 2
|
||||
|
||||
if (axis === 'width') {
|
||||
const nextShaftWidth = roundPlanMeters(
|
||||
Math.max(0.8, nextOuterSize - dragState.shaftWallThickness * 2),
|
||||
)
|
||||
const nextCabWidth = nextShaftWidth
|
||||
useLiveNodeOverrides
|
||||
.getState()
|
||||
.set(dragState.elevatorId, { shaftWidth: nextShaftWidth, width: nextCabWidth })
|
||||
setCursorPoint(planPoint)
|
||||
return { shaftWidth: nextShaftWidth, width: nextCabWidth } satisfies Partial<ElevatorNode>
|
||||
}
|
||||
|
||||
const nextShaftDepth = roundPlanMeters(
|
||||
Math.max(0.8, nextOuterSize - dragState.shaftWallThickness * 2),
|
||||
)
|
||||
const nextCabDepth = nextShaftDepth
|
||||
useLiveNodeOverrides
|
||||
.getState()
|
||||
.set(dragState.elevatorId, { depth: nextCabDepth, shaftDepth: nextShaftDepth })
|
||||
setCursorPoint(planPoint)
|
||||
return { depth: nextCabDepth, shaftDepth: nextShaftDepth } satisfies Partial<ElevatorNode>
|
||||
},
|
||||
[setCursorPoint],
|
||||
)
|
||||
|
||||
const handleElevatorResizePointerDown = useCallback(
|
||||
(
|
||||
entry: FloorplanElevatorEntry,
|
||||
handle: ElevatorResizeHandle,
|
||||
event: ReactPointerEvent<SVGCircleElement>,
|
||||
) => {
|
||||
if (event.button !== 0 || mode !== 'select') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
setHoveredElevatorId(null)
|
||||
setSelection({ selectedIds: [entry.elevator.id] })
|
||||
|
||||
setElevatorResizeDragState({
|
||||
center: entry.center,
|
||||
elevatorId: entry.elevator.id,
|
||||
handle,
|
||||
pointerId: event.pointerId,
|
||||
rotation: entry.rotation,
|
||||
shaftWallThickness: entry.shaftWallThickness,
|
||||
})
|
||||
},
|
||||
[mode, setSelection],
|
||||
)
|
||||
|
||||
const handleElevatorResizePointerMove = useCallback(
|
||||
(event: ReactPointerEvent<SVGCircleElement>) => {
|
||||
const dragState = elevatorResizeDragState
|
||||
if (!dragState || dragState.pointerId !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
|
||||
if (!planPoint) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
previewElevatorResize(dragState, planPoint)
|
||||
},
|
||||
[elevatorResizeDragState, getPlanPointFromClientPoint, previewElevatorResize],
|
||||
)
|
||||
|
||||
const handleElevatorResizePointerUp = useCallback(
|
||||
(event: ReactPointerEvent<SVGCircleElement>) => {
|
||||
const dragState = elevatorResizeDragState
|
||||
if (!dragState || dragState.pointerId !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
|
||||
const updates = planPoint ? previewElevatorResize(dragState, planPoint) : {}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
useLiveNodeOverrides.getState().clear(dragState.elevatorId)
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updateNode(dragState.elevatorId as AnyNodeId, updates)
|
||||
}
|
||||
setElevatorResizeDragState(null)
|
||||
setCursorPoint(null)
|
||||
},
|
||||
[
|
||||
elevatorResizeDragState,
|
||||
getPlanPointFromClientPoint,
|
||||
previewElevatorResize,
|
||||
updateNode,
|
||||
setCursorPoint,
|
||||
],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
siteBoundaryDraftRef.current = siteBoundaryDraft
|
||||
}, [siteBoundaryDraft])
|
||||
@@ -8451,13 +7856,11 @@ export function FloorplanPanel({
|
||||
const clearWallEndpointDrag = useCallback(() => {
|
||||
wallEndpointDragRef.current = null
|
||||
setWallEndpointDraft(null)
|
||||
setHoveredEndpointId(null)
|
||||
useWallSnapIndicator.getState().clear()
|
||||
}, [])
|
||||
const clearWallCurveDrag = useCallback(() => {
|
||||
wallCurveDragRef.current = null
|
||||
setWallCurveDraft(null)
|
||||
setHoveredWallCurveHandleId(null)
|
||||
}, [])
|
||||
const clearSiteBoundaryInteraction = useCallback(() => {
|
||||
const draft = siteBoundaryDraftRef.current
|
||||
@@ -8665,14 +8068,6 @@ export function FloorplanPanel({
|
||||
}
|
||||
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasPendingItemMeshFootprints) {
|
||||
return
|
||||
}
|
||||
|
||||
scheduleMovingFloorplanNodeRefresh()
|
||||
}, [scheduleMovingFloorplanNodeRefresh])
|
||||
|
||||
// Subscribe to the live-transforms store so rotation/position changes that
|
||||
// *don't* go through pointer events still refresh the floorplan — e.g. R/T
|
||||
// keyboard rotation during placement updates `useLiveTransforms` but emits
|
||||
@@ -9779,10 +9174,6 @@ export function FloorplanPanel({
|
||||
return
|
||||
}
|
||||
|
||||
if (elevatorResizeDragState?.pointerId === event.pointerId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (wallEndpointDragRef.current?.pointerId === event.pointerId) {
|
||||
return
|
||||
}
|
||||
@@ -10135,7 +9526,6 @@ export function FloorplanPanel({
|
||||
publishFloorplanNavigationPose,
|
||||
referenceScaleDraft,
|
||||
roofDraftStart,
|
||||
elevatorResizeDragState,
|
||||
siteVertexDragState,
|
||||
surfaceSize.height,
|
||||
surfaceSize.width,
|
||||
@@ -10398,21 +9788,7 @@ export function FloorplanPanel({
|
||||
],
|
||||
)
|
||||
const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
|
||||
ceilingPolygons: displayCeilingPolygons,
|
||||
columnPolygons: floorplanColumnEntries,
|
||||
displaySlabPolygons,
|
||||
displayWallPolygons,
|
||||
floorplanElevatorEntries,
|
||||
floorplanItemEntries,
|
||||
floorplanRoofEntries,
|
||||
floorplanStairEntries,
|
||||
getFloorplanOpeningHitTolerance,
|
||||
getFloorplanWallHitTolerance,
|
||||
getOpeningCenterLine,
|
||||
isFloorplanItemContextActive,
|
||||
openingsPolygons,
|
||||
phase,
|
||||
toPoint2D,
|
||||
sceneRef: floorplanSceneRef,
|
||||
})
|
||||
// Wall-commit snap for the placement hook. Mirrors the move-preview branch:
|
||||
// it honours the Magnetic snap toggle so a click never snaps to geometry the
|
||||
@@ -10552,8 +9928,6 @@ export function FloorplanPanel({
|
||||
modifierKeys,
|
||||
planPoint,
|
||||
structureLayer,
|
||||
toPoint2D,
|
||||
visibleZonePolygons,
|
||||
})
|
||||
|
||||
if (backgroundSelection.handled) {
|
||||
@@ -10618,7 +9992,6 @@ export function FloorplanPanel({
|
||||
structureLayer,
|
||||
getFloorplanHitIdAtPoint,
|
||||
unit,
|
||||
visibleZonePolygons,
|
||||
emitFloorplanGridEvent,
|
||||
setCursorPoint,
|
||||
],
|
||||
@@ -11268,7 +10641,6 @@ export function FloorplanPanel({
|
||||
!panStateRef.current &&
|
||||
!floorplanRotationStateRef.current &&
|
||||
!guideInteractionRef.current &&
|
||||
!elevatorResizeDragState &&
|
||||
!wallEndpointDragRef.current &&
|
||||
!siteVertexDragState
|
||||
) {
|
||||
@@ -11296,7 +10668,6 @@ export function FloorplanPanel({
|
||||
handlePointerMove,
|
||||
hasFloorplanCursorIndicator,
|
||||
isSpacePanPressed,
|
||||
elevatorResizeDragState,
|
||||
siteVertexDragState,
|
||||
setFloorplanCursorPosition,
|
||||
],
|
||||
@@ -12013,18 +11384,10 @@ export function FloorplanPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Registry-driven floor-plan layer. Iterates kinds whose
|
||||
NodeDefinition supplies a `floorplan` builder and renders
|
||||
their SVG via <FloorplanGeometryRenderer>. Sits above the
|
||||
legacy inline content so newly-registered kinds (shelf
|
||||
today) overlay on top until their inline equivalent is
|
||||
removed in their Phase 5 migration PR.
|
||||
|
||||
Wrapped in <FloorplanRenderProvider> so registry-driven
|
||||
kinds receive the same themed palette / units-per-pixel
|
||||
the legacy layers compute. The hatch pattern id is the
|
||||
legacy wall hatch — kinds that opt into selection hatch
|
||||
fills reuse this <defs> pattern via fill="url(...)". */}
|
||||
{/* Registry-driven floor-plan layer. Every committed node kind
|
||||
supplies its geometry through `NodeDefinition.floorplan`.
|
||||
The provider carries editor presentation data—palette,
|
||||
scale, rotation, and the shared selection-hatch pattern. */}
|
||||
<FloorplanRenderProvider
|
||||
getSceneRotationDeg={getFloorplanSceneRotationDeg}
|
||||
hatchPatternId={wallSelectionHatchId}
|
||||
@@ -12165,13 +11528,6 @@ export function FloorplanPanel({
|
||||
walls={walls}
|
||||
/>
|
||||
|
||||
{/* Wall / fence endpoint, wall curve, slab / ceiling /
|
||||
zone vertex+midpoint+edge handles are all driven by the
|
||||
registry's `def.floorplanAffordances` and rendered as
|
||||
part of `FloorplanRegistryLayer`. The legacy handle
|
||||
layers that lived here received empty handle arrays
|
||||
post-migration and rendered nothing. */}
|
||||
|
||||
{selectedGuide && showGuides && (
|
||||
<FloorplanGuideSelectionOverlay
|
||||
guide={selectedGuide}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '../../lib/scene'
|
||||
import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import useFloorplanMode from '../../store/use-floorplan-mode'
|
||||
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
|
||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||
import { RoofEditSystem } from '../systems/roof/roof-edit-system'
|
||||
@@ -65,6 +66,7 @@ import { FenceTangentLines3D } from './fence-tangent-lines-3d'
|
||||
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
|
||||
import { FloatingActionMenu } from './floating-action-menu'
|
||||
import { FloatingBuildingActionMenu } from './floating-building-action-menu'
|
||||
import { FloorplanModeCoordinator } from './floorplan-mode-coordinator'
|
||||
import { FloorplanPanel } from './floorplan-panel'
|
||||
import { Grid } from './grid'
|
||||
import { GroupFloatingActionMenu } from './group-floating-action-menu'
|
||||
@@ -1176,9 +1178,11 @@ export default function Editor({
|
||||
|
||||
useEffect(() => {
|
||||
useViewer.getState().setProjectId(projectId ?? null)
|
||||
useFloorplanMode.getState().setProjectId(projectId ?? null)
|
||||
|
||||
return () => {
|
||||
useViewer.getState().setProjectId(null)
|
||||
useFloorplanMode.getState().setProjectId(null)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
@@ -1390,6 +1394,7 @@ export default function Editor({
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloorplanModeCoordinator />
|
||||
{showLoader && (
|
||||
<div className="fixed inset-0 z-60">
|
||||
<SceneLoader className="bg-background" />
|
||||
@@ -1465,6 +1470,7 @@ export default function Editor({
|
||||
|
||||
return (
|
||||
<div className="dark flex h-full w-full gap-3 bg-neutral-100 p-3 text-foreground">
|
||||
<FloorplanModeCoordinator />
|
||||
{showLoader && (
|
||||
<div className="fixed inset-0 z-60">
|
||||
<SceneLoader className="bg-background" />
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { collectRegistrySelectionIdsInBounds } from './use-floorplan-hit-testing'
|
||||
|
||||
const identityMatrix = {
|
||||
inverse() {
|
||||
return this
|
||||
},
|
||||
}
|
||||
|
||||
function pointFactory() {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
matrixTransform() {
|
||||
return pointFactoryWithPosition(this.x, this.y)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function pointFactoryWithPosition(x: number, y: number) {
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
matrixTransform() {
|
||||
return pointFactoryWithPosition(x, y)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function geometry(x: number, y: number, width: number, height: number) {
|
||||
return {
|
||||
getBBox: () => ({ x, y, width, height }),
|
||||
getScreenCTM: () => identityMatrix,
|
||||
} as unknown as SVGGraphicsElement
|
||||
}
|
||||
|
||||
function entry(nodeId: string, children: SVGGraphicsElement[]) {
|
||||
return {
|
||||
dataset: { nodeId },
|
||||
querySelectorAll: () => children,
|
||||
} as unknown as SVGGElement
|
||||
}
|
||||
|
||||
function scene(entries: SVGGElement[]) {
|
||||
return {
|
||||
getScreenCTM: () => identityMatrix,
|
||||
ownerSVGElement: {
|
||||
createSVGPoint: pointFactory,
|
||||
},
|
||||
querySelectorAll: () => entries,
|
||||
} as unknown as SVGGElement
|
||||
}
|
||||
|
||||
describe('collectRegistrySelectionIdsInBounds', () => {
|
||||
test('selects rendered registry geometry and preserves candidate order', () => {
|
||||
const renderedScene = scene([
|
||||
entry('wall_1', [geometry(0, 0, 4, 0.2)]),
|
||||
entry('door_1', [geometry(1, 1, 0.9, 0.9)]),
|
||||
entry('outside_1', [geometry(20, 20, 1, 1)]),
|
||||
])
|
||||
|
||||
expect(
|
||||
collectRegistrySelectionIdsInBounds(renderedScene, { minX: -1, minY: -1, maxX: 5, maxY: 5 }, [
|
||||
'door_1',
|
||||
'outside_1',
|
||||
'wall_1',
|
||||
]),
|
||||
).toEqual(['door_1', 'wall_1'])
|
||||
})
|
||||
|
||||
test('ignores rendered entries outside the selectable registry candidates', () => {
|
||||
const renderedScene = scene([entry('zone_1', [geometry(0, 0, 2, 2)])])
|
||||
|
||||
expect(
|
||||
collectRegistrySelectionIdsInBounds(
|
||||
renderedScene,
|
||||
{ minX: -1, minY: -1, maxX: 3, maxY: 3 },
|
||||
[],
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,195 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
AnyNode,
|
||||
CeilingNode,
|
||||
ColumnNode,
|
||||
DoorNode,
|
||||
ElevatorNode,
|
||||
ItemNode,
|
||||
Point2D,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
SlabNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
getFloorplanHitNodeId,
|
||||
getFloorplanSelectionIdsInBounds,
|
||||
} from '../../lib/floorplan/selection-tool'
|
||||
import { type RefObject, useCallback } from 'react'
|
||||
import type { FloorplanSelectionBounds } from '../../lib/floorplan/types'
|
||||
import {
|
||||
type Point2 as MarqueePoint2,
|
||||
polygonsIntersect,
|
||||
segmentIntersectsPolygon,
|
||||
} from '../tools/select/marquee-geometry'
|
||||
import { collectSelectableCandidateIds } from '../tools/select/select-candidates'
|
||||
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
|
||||
|
||||
type OpeningNode = WindowNode | DoorNode
|
||||
const REGISTRY_ENTRY_SELECTOR = '.floorplan-registry-base .floorplan-registry-entry[data-node-id]'
|
||||
const REGISTRY_GEOMETRY_SELECTOR = 'path, polygon, polyline, rect, circle, line, image'
|
||||
const POINT_HIT_EPSILON = 1e-4
|
||||
|
||||
type WallPolygonEntry = {
|
||||
wall: WallNode
|
||||
polygon: Point2D[]
|
||||
type FloorplanHitTestingOptions = {
|
||||
sceneRef: RefObject<SVGGElement | null>
|
||||
}
|
||||
|
||||
type OpeningPolygonEntry = {
|
||||
opening: OpeningNode
|
||||
polygon: Point2D[]
|
||||
function boundsPolygon(bounds: FloorplanSelectionBounds): MarqueePoint2[] {
|
||||
return [
|
||||
[bounds.minX, bounds.minY],
|
||||
[bounds.maxX, bounds.minY],
|
||||
[bounds.maxX, bounds.maxY],
|
||||
[bounds.minX, bounds.maxY],
|
||||
]
|
||||
}
|
||||
|
||||
type SlabPolygonEntry = {
|
||||
slab: SlabNode
|
||||
polygon: Point2D[]
|
||||
holes: Point2D[][]
|
||||
function transformElementBoxToScene(
|
||||
element: SVGGraphicsElement,
|
||||
scene: SVGGElement,
|
||||
): MarqueePoint2[] | null {
|
||||
const elementMatrix = element.getScreenCTM()
|
||||
const sceneMatrix = scene.getScreenCTM()
|
||||
const svg = scene.ownerSVGElement
|
||||
if (!(elementMatrix && sceneMatrix && svg)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let box: DOMRect
|
||||
try {
|
||||
box = element.getBBox()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const inverseSceneMatrix = sceneMatrix.inverse()
|
||||
const corners: MarqueePoint2[] = [
|
||||
[box.x, box.y],
|
||||
[box.x + box.width, box.y],
|
||||
[box.x + box.width, box.y + box.height],
|
||||
[box.x, box.y + box.height],
|
||||
]
|
||||
return corners.map(([x, y]) => {
|
||||
const point = svg.createSVGPoint()
|
||||
point.x = x
|
||||
point.y = y
|
||||
const screenPoint = point.matrixTransform(elementMatrix)
|
||||
const scenePoint = screenPoint.matrixTransform(inverseSceneMatrix)
|
||||
return [scenePoint.x, scenePoint.y] as MarqueePoint2
|
||||
})
|
||||
}
|
||||
|
||||
type CeilingPolygonEntry = {
|
||||
ceiling: CeilingNode
|
||||
polygon: Point2D[]
|
||||
holes: Point2D[][]
|
||||
function elementIntersectsPlanPolygon(
|
||||
element: SVGGraphicsElement,
|
||||
scene: SVGGElement,
|
||||
selectionPolygon: MarqueePoint2[],
|
||||
): boolean {
|
||||
const polygon = transformElementBoxToScene(element, scene)
|
||||
if (!polygon) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [first, second, third] = polygon
|
||||
if (!(first && second && third)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const width = Math.hypot(second[0] - first[0], second[1] - first[1])
|
||||
const height = Math.hypot(third[0] - second[0], third[1] - second[1])
|
||||
if (width <= POINT_HIT_EPSILON || height <= POINT_HIT_EPSILON) {
|
||||
const end = width >= height ? second : third
|
||||
return segmentIntersectsPolygon(first, end, selectionPolygon)
|
||||
}
|
||||
|
||||
return polygonsIntersect(polygon, selectionPolygon)
|
||||
}
|
||||
|
||||
type ColumnPolygonEntry = {
|
||||
column: ColumnNode
|
||||
polygon: Point2D[]
|
||||
export function collectRegistrySelectionIdsInBounds(
|
||||
scene: SVGGElement,
|
||||
bounds: FloorplanSelectionBounds,
|
||||
candidateIds = collectSelectableCandidateIds(),
|
||||
): string[] {
|
||||
const candidateIdSet = new Set(candidateIds)
|
||||
const selectionPolygon = boundsPolygon(bounds)
|
||||
const hitIds = new Set<string>()
|
||||
|
||||
for (const entry of scene.querySelectorAll<SVGGElement>(REGISTRY_ENTRY_SELECTOR)) {
|
||||
const nodeId = entry.dataset.nodeId
|
||||
if (!nodeId || !candidateIdSet.has(nodeId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const geometry = entry.querySelectorAll<SVGGraphicsElement>(REGISTRY_GEOMETRY_SELECTOR)
|
||||
const elements = geometry.length > 0 ? Array.from(geometry) : [entry]
|
||||
if (
|
||||
elements.some((element) => elementIntersectsPlanPolygon(element, scene, selectionPolygon))
|
||||
) {
|
||||
hitIds.add(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
return candidateIds.filter((id) => hitIds.has(id))
|
||||
}
|
||||
|
||||
type ElevatorPolygonEntry = {
|
||||
elevator: ElevatorNode
|
||||
polygon: Point2D[]
|
||||
export function getRegistryHitIdAtPlanPoint(
|
||||
scene: SVGGElement,
|
||||
planPoint: WallPlanPoint,
|
||||
candidateIds = collectSelectableCandidateIds(),
|
||||
): string | null {
|
||||
const sceneMatrix = scene.getScreenCTM()
|
||||
const svg = scene.ownerSVGElement
|
||||
if (!(sceneMatrix && svg)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const point = svg.createSVGPoint()
|
||||
point.x = planPoint[0]
|
||||
point.y = planPoint[1]
|
||||
const screenPoint = point.matrixTransform(sceneMatrix)
|
||||
const candidateIdSet = new Set(candidateIds)
|
||||
|
||||
if (typeof document !== 'undefined' && typeof document.elementsFromPoint === 'function') {
|
||||
for (const element of document.elementsFromPoint(screenPoint.x, screenPoint.y)) {
|
||||
const entry = element.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')
|
||||
const nodeId = entry?.dataset.nodeId
|
||||
if (entry && nodeId && candidateIdSet.has(nodeId) && scene.contains(entry)) {
|
||||
return nodeId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hits = collectRegistrySelectionIdsInBounds(
|
||||
scene,
|
||||
{
|
||||
minX: planPoint[0] - POINT_HIT_EPSILON,
|
||||
maxX: planPoint[0] + POINT_HIT_EPSILON,
|
||||
minY: planPoint[1] - POINT_HIT_EPSILON,
|
||||
maxY: planPoint[1] + POINT_HIT_EPSILON,
|
||||
},
|
||||
candidateIds,
|
||||
)
|
||||
return hits.at(-1) ?? null
|
||||
}
|
||||
|
||||
type FloorplanRoofEntry = {
|
||||
roof: RoofNode
|
||||
segments: Array<{
|
||||
polygon: Point2D[]
|
||||
segment: RoofSegmentNode
|
||||
}>
|
||||
}
|
||||
|
||||
type FloorplanItemEntry = {
|
||||
item: ItemNode
|
||||
polygon: Point2D[]
|
||||
}
|
||||
|
||||
type FloorplanStairSegmentEntry = {
|
||||
polygon: Point2D[]
|
||||
segment: StairSegmentNode | AnyNode
|
||||
}
|
||||
|
||||
type FloorplanStairEntry = {
|
||||
hitPolygons: Point2D[][]
|
||||
stair: StairNode
|
||||
segments: FloorplanStairSegmentEntry[]
|
||||
}
|
||||
|
||||
type UseFloorplanHitTestingArgs = {
|
||||
ceilingPolygons: CeilingPolygonEntry[]
|
||||
columnPolygons: ColumnPolygonEntry[]
|
||||
displaySlabPolygons: SlabPolygonEntry[]
|
||||
displayWallPolygons: WallPolygonEntry[]
|
||||
floorplanElevatorEntries: ElevatorPolygonEntry[]
|
||||
floorplanItemEntries: FloorplanItemEntry[]
|
||||
getFloorplanOpeningHitTolerance: () => number
|
||||
floorplanRoofEntries: FloorplanRoofEntry[]
|
||||
floorplanStairEntries: FloorplanStairEntry[]
|
||||
getFloorplanWallHitTolerance: () => number
|
||||
getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null
|
||||
isFloorplanItemContextActive: boolean
|
||||
openingsPolygons: OpeningPolygonEntry[]
|
||||
phase: 'site' | 'structure' | 'furnish'
|
||||
toPoint2D: (point: WallPlanPoint) => Point2D
|
||||
}
|
||||
|
||||
export function useFloorplanHitTesting({
|
||||
ceilingPolygons,
|
||||
columnPolygons,
|
||||
displaySlabPolygons,
|
||||
displayWallPolygons,
|
||||
floorplanElevatorEntries,
|
||||
floorplanItemEntries,
|
||||
getFloorplanOpeningHitTolerance,
|
||||
floorplanRoofEntries,
|
||||
floorplanStairEntries,
|
||||
getFloorplanWallHitTolerance,
|
||||
getOpeningCenterLine,
|
||||
isFloorplanItemContextActive,
|
||||
openingsPolygons,
|
||||
phase,
|
||||
toPoint2D,
|
||||
}: UseFloorplanHitTestingArgs) {
|
||||
export function useFloorplanHitTesting({ sceneRef }: FloorplanHitTestingOptions) {
|
||||
const getFloorplanHitIdAtPoint = useCallback(
|
||||
(planPoint: WallPlanPoint) => {
|
||||
const point = toPoint2D(planPoint)
|
||||
return getFloorplanHitNodeId({
|
||||
point,
|
||||
ceilings: ceilingPolygons,
|
||||
phase,
|
||||
isItemContextActive: isFloorplanItemContextActive,
|
||||
items: floorplanItemEntries,
|
||||
openings: openingsPolygons,
|
||||
roofs: floorplanRoofEntries,
|
||||
stairs: floorplanStairEntries,
|
||||
elevators: floorplanElevatorEntries,
|
||||
walls: displayWallPolygons,
|
||||
slabs: displaySlabPolygons,
|
||||
openingHitTolerance: getFloorplanOpeningHitTolerance(),
|
||||
wallHitTolerance: getFloorplanWallHitTolerance(),
|
||||
columns: columnPolygons,
|
||||
getOpeningCenterLine,
|
||||
})
|
||||
const scene = sceneRef.current
|
||||
return scene ? getRegistryHitIdAtPlanPoint(scene, planPoint) : null
|
||||
},
|
||||
[
|
||||
ceilingPolygons,
|
||||
columnPolygons,
|
||||
displaySlabPolygons,
|
||||
displayWallPolygons,
|
||||
floorplanItemEntries,
|
||||
floorplanElevatorEntries,
|
||||
floorplanRoofEntries,
|
||||
floorplanStairEntries,
|
||||
getFloorplanOpeningHitTolerance,
|
||||
getFloorplanWallHitTolerance,
|
||||
getOpeningCenterLine,
|
||||
isFloorplanItemContextActive,
|
||||
openingsPolygons,
|
||||
phase,
|
||||
toPoint2D,
|
||||
],
|
||||
[sceneRef],
|
||||
)
|
||||
|
||||
const getFloorplanSelectionIdsInBoundsForArea = useCallback(
|
||||
(bounds: FloorplanSelectionBounds) =>
|
||||
getFloorplanSelectionIdsInBounds({
|
||||
bounds,
|
||||
ceilings: ceilingPolygons,
|
||||
phase,
|
||||
isItemContextActive: isFloorplanItemContextActive,
|
||||
items: floorplanItemEntries,
|
||||
walls: displayWallPolygons,
|
||||
openings: openingsPolygons,
|
||||
roofs: floorplanRoofEntries,
|
||||
slabs: displaySlabPolygons,
|
||||
columns: columnPolygons,
|
||||
elevators: floorplanElevatorEntries,
|
||||
stairs: floorplanStairEntries,
|
||||
}),
|
||||
[
|
||||
ceilingPolygons,
|
||||
columnPolygons,
|
||||
displaySlabPolygons,
|
||||
displayWallPolygons,
|
||||
floorplanItemEntries,
|
||||
floorplanElevatorEntries,
|
||||
floorplanRoofEntries,
|
||||
floorplanStairEntries,
|
||||
isFloorplanItemContextActive,
|
||||
openingsPolygons,
|
||||
phase,
|
||||
],
|
||||
const getFloorplanSelectionIdsInBounds = useCallback(
|
||||
(bounds: FloorplanSelectionBounds) => {
|
||||
const scene = sceneRef.current
|
||||
return scene ? collectRegistrySelectionIdsInBounds(scene, bounds) : []
|
||||
},
|
||||
[sceneRef],
|
||||
)
|
||||
|
||||
return {
|
||||
getFloorplanHitIdAtPoint,
|
||||
getFloorplanSelectionIdsInBounds: getFloorplanSelectionIdsInBoundsForArea,
|
||||
getFloorplanSelectionIdsInBounds,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useState } from 'react'
|
||||
import type { CreatableMeasurementKind } from '../../../lib/measurement-kind'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import useFloorplanMode from '../../../store/use-floorplan-mode'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
@@ -63,6 +64,7 @@ export function MeasurementControl() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const tool = useEditor((state) => state.tool)
|
||||
const floorplanMode = useFloorplanMode((state) => state.mode)
|
||||
const selectedKind = useEditor((state) => state.lastMeasurementKind)
|
||||
const activeToolKind = useEditor((state) => state.toolDefaults.measurement?.kind)
|
||||
const constructionDimensionChainMode = useEditor(
|
||||
@@ -130,6 +132,10 @@ export function MeasurementControl() {
|
||||
dimensionMode: ConstructionDimensionMode,
|
||||
chainMode: ConstructionDimensionChainMode,
|
||||
) => {
|
||||
if (useFloorplanMode.getState().mode !== 'expert') {
|
||||
useFloorplanMode.getState().showExpertModeNotice('Construction Dimension')
|
||||
return
|
||||
}
|
||||
setPhase('structure')
|
||||
setStructureLayer('elements')
|
||||
setViewMode('2d')
|
||||
@@ -218,38 +224,42 @@ export function MeasurementControl() {
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="my-1.5 h-px bg-border/60" />
|
||||
<div className="px-2.5 pt-1 pb-0.5 font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
Floor plan
|
||||
</div>
|
||||
{floorplanMode === 'expert' ? (
|
||||
<>
|
||||
<div className="my-1.5 h-px bg-border/60" />
|
||||
<div className="px-2.5 pt-1 pb-0.5 font-semibold text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
Floor plan
|
||||
</div>
|
||||
|
||||
{constructionDimensionOptions.map((option) => {
|
||||
const OptionIcon = option.icon
|
||||
const isSelected =
|
||||
isConstructionDimensionActive && activeConstructionDimensionOption === option
|
||||
return (
|
||||
<button
|
||||
aria-checked={isSelected}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-sm transition-colors',
|
||||
isSelected
|
||||
? 'bg-white/10 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-white/8 hover:text-foreground',
|
||||
)}
|
||||
key={`${option.mode}-${option.chainMode}`}
|
||||
onClick={() => {
|
||||
activateConstructionDimension(option.mode, option.chainMode)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
role="menuitemradio"
|
||||
type="button"
|
||||
>
|
||||
<OptionIcon aria-hidden="true" className="h-4 w-4" />
|
||||
<span>{option.label}</span>
|
||||
{isSelected ? <Check aria-hidden="true" className="ml-auto h-4 w-4" /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{constructionDimensionOptions.map((option) => {
|
||||
const OptionIcon = option.icon
|
||||
const isSelected =
|
||||
isConstructionDimensionActive && activeConstructionDimensionOption === option
|
||||
return (
|
||||
<button
|
||||
aria-checked={isSelected}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-sm transition-colors',
|
||||
isSelected
|
||||
? 'bg-white/10 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-white/8 hover:text-foreground',
|
||||
)}
|
||||
key={`${option.mode}-${option.chainMode}`}
|
||||
onClick={() => {
|
||||
activateConstructionDimension(option.mode, option.chainMode)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
role="menuitemradio"
|
||||
type="button"
|
||||
>
|
||||
<OptionIcon aria-hidden="true" className="h-4 w-4" />
|
||||
<span>{option.label}</span>
|
||||
{isSelected ? <Check aria-hidden="true" className="ml-auto h-4 w-4" /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from './../../../../../components/ui/primitives/dialog'
|
||||
import { Switch } from './../../../../../components/ui/primitives/switch'
|
||||
import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor'
|
||||
import useFloorplanMode from './../../../../../store/use-floorplan-mode'
|
||||
import { AudioSettingsDialog } from './audio-settings-dialog'
|
||||
import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
|
||||
import { LoadBuildDialog, type PendingImport } from './load-build-dialog'
|
||||
@@ -192,6 +193,7 @@ export function SettingsPanel({
|
||||
const exportScene = useViewer((state) => state.exportScene)
|
||||
const shadows = useViewer((state) => state.shadows)
|
||||
const setPhase = useEditor((state) => state.setPhase)
|
||||
const floorplanMode = useFloorplanMode((state) => state.mode)
|
||||
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
|
||||
const [pendingImport, setPendingImport] = useState<PendingImport | null>(null)
|
||||
const sceneGraphValue = useMemo(
|
||||
@@ -399,7 +401,10 @@ export function SettingsPanel({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-muted-foreground text-xs">Floor plan</div>
|
||||
<div className="flex items-center justify-between font-medium text-muted-foreground text-xs">
|
||||
<span>Floor plan</span>
|
||||
<span>{floorplanMode === 'default' ? 'Default mode' : 'Expert mode'}</span>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => exportFloorplanPdf('full')}
|
||||
|
||||
@@ -351,6 +351,7 @@ export {
|
||||
type FloorplanRenderPurpose,
|
||||
type FloorplanSchedule,
|
||||
type FloorplanToolContext,
|
||||
type FloorplanToolMode,
|
||||
floorplanGeometryMetadata,
|
||||
getFloorplanNodeExtension,
|
||||
readFloorplanContext,
|
||||
@@ -358,6 +359,12 @@ export {
|
||||
readFloorplanMetricNotationOverride,
|
||||
withFloorplanGeometryMetadata,
|
||||
} from './lib/floorplan/floorplan-extension'
|
||||
export {
|
||||
DEFAULT_FLOORPLAN_MODE,
|
||||
FLOORPLAN_MODES,
|
||||
type FloorplanMode,
|
||||
isFloorplanToolAvailableInMode,
|
||||
} from './lib/floorplan/floorplan-mode'
|
||||
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
|
||||
export { exportSceneToGlb } from './lib/glb-export'
|
||||
export {
|
||||
@@ -517,11 +524,7 @@ export { default as useFenceCurveDraft } from './store/use-fence-curve-draft'
|
||||
export { type FirstPersonHudState, useFirstPersonHud } from './store/use-first-person-hud'
|
||||
export { default as useFloorplanAnnotationVisibility } from './store/use-floorplan-annotation-visibility'
|
||||
export { useFloorplanDraftPreview } from './store/use-floorplan-draft-preview'
|
||||
export {
|
||||
default as useFloorplanPreflight,
|
||||
type FloorplanPreflightIssue,
|
||||
type FloorplanPreflightIssueKind,
|
||||
} from './store/use-floorplan-preflight'
|
||||
export { default as useFloorplanMode } from './store/use-floorplan-mode'
|
||||
export {
|
||||
default as useInteractionScope,
|
||||
getEditingHole,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
filterFloorplanAnnotationGeometry,
|
||||
normalizeFloorplanAnnotationVisibility,
|
||||
} from './annotation-visibility'
|
||||
import { floorplanGeometryMetadata } from './floorplan-extension'
|
||||
import { floorplanGeometryMetadata, withFloorplanGeometryMetadata } from './floorplan-extension'
|
||||
|
||||
describe('floor-plan annotation visibility', () => {
|
||||
test('fills missing persisted categories with visible defaults', () => {
|
||||
@@ -136,6 +136,84 @@ describe('floor-plan annotation visibility', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
test('keeps a contextual dimension when automatic dimensions are hidden', () => {
|
||||
const contextualDimension = {
|
||||
kind: 'group',
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'contextual-dimension' }),
|
||||
children: [
|
||||
{
|
||||
kind: 'dimension',
|
||||
start: [0, 0],
|
||||
end: [2, 0],
|
||||
offsetNormal: [0, 1],
|
||||
offsetDistance: 0.3,
|
||||
extensionOvershoot: 0.08,
|
||||
text: '2m',
|
||||
},
|
||||
],
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
expect(
|
||||
filterFloorplanAnnotationGeometry(contextualDimension, {
|
||||
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
|
||||
automaticDimensions: false,
|
||||
contextualDimensions: true,
|
||||
}),
|
||||
).toEqual(contextualDimension)
|
||||
})
|
||||
|
||||
test('removes a nested automatic wall string beside a contextual dimension', () => {
|
||||
const wallBody = {
|
||||
kind: 'polygon',
|
||||
points: [
|
||||
[0, 0],
|
||||
[0.2, 0],
|
||||
[0.2, 6],
|
||||
[0, 6],
|
||||
],
|
||||
} satisfies FloorplanGeometry
|
||||
const automaticDimension = {
|
||||
kind: 'dimension-string',
|
||||
segments: [{ start: [0, 0], end: [0, 18], text: '18m' }],
|
||||
offsetNormal: [1, 0],
|
||||
offsetDistance: 0.55,
|
||||
extensionOvershoot: 0.12,
|
||||
} satisfies FloorplanGeometry
|
||||
const contextualDimension = withFloorplanGeometryMetadata(
|
||||
{
|
||||
kind: 'dimension',
|
||||
start: [0, 0],
|
||||
end: [0, 6],
|
||||
offsetNormal: [1, 0],
|
||||
offsetDistance: 0.34,
|
||||
extensionOvershoot: 0.08,
|
||||
text: '6m',
|
||||
} satisfies FloorplanGeometry,
|
||||
{ annotationRole: 'contextual-dimension' },
|
||||
)
|
||||
const geometry = {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'group',
|
||||
children: [wallBody, automaticDimension],
|
||||
},
|
||||
contextualDimension,
|
||||
],
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
expect(
|
||||
filterFloorplanAnnotationGeometry(geometry, {
|
||||
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
|
||||
automaticDimensions: false,
|
||||
contextualDimensions: true,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: 'group',
|
||||
children: [{ kind: 'group', children: [wallBody] }, contextualDimension],
|
||||
})
|
||||
})
|
||||
|
||||
test('hides structural grids and only the center marks within column geometry', () => {
|
||||
const centerMark = {
|
||||
kind: 'line',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type FloorplanAnnotationRole, readFloorplanGeometryMetadata } from './f
|
||||
|
||||
export type FloorplanAnnotationCategory =
|
||||
| 'automaticDimensions'
|
||||
| 'contextualDimensions'
|
||||
| 'manualDimensions'
|
||||
| 'measurements'
|
||||
| 'openingMarks'
|
||||
@@ -14,6 +15,7 @@ export type FloorplanAnnotationVisibility = Record<FloorplanAnnotationCategory,
|
||||
|
||||
export const DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY: FloorplanAnnotationVisibility = {
|
||||
automaticDimensions: true,
|
||||
contextualDimensions: false,
|
||||
manualDimensions: true,
|
||||
measurements: true,
|
||||
openingMarks: true,
|
||||
@@ -22,6 +24,14 @@ export const DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY: FloorplanAnnotationVisibil
|
||||
stairAnnotations: true,
|
||||
}
|
||||
|
||||
export function revealFloorplanAnnotationRole(
|
||||
visibility: FloorplanAnnotationVisibility,
|
||||
role: FloorplanAnnotationRole,
|
||||
): FloorplanAnnotationVisibility {
|
||||
const category = annotationCategoryForRole(role)
|
||||
return visibility[category] ? visibility : { ...visibility, [category]: true }
|
||||
}
|
||||
|
||||
export function normalizeFloorplanAnnotationVisibility(
|
||||
value: unknown,
|
||||
): FloorplanAnnotationVisibility {
|
||||
@@ -32,6 +42,10 @@ export function normalizeFloorplanAnnotationVisibility(
|
||||
typeof persisted.automaticDimensions === 'boolean'
|
||||
? persisted.automaticDimensions
|
||||
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.automaticDimensions,
|
||||
contextualDimensions:
|
||||
typeof persisted.contextualDimensions === 'boolean'
|
||||
? persisted.contextualDimensions
|
||||
: DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY.contextualDimensions,
|
||||
manualDimensions:
|
||||
typeof persisted.manualDimensions === 'boolean'
|
||||
? persisted.manualDimensions
|
||||
@@ -68,7 +82,7 @@ export function filterFloorplanAnnotationGeometry(
|
||||
if (role && !isAnnotationRoleVisible(role, visibility)) return null
|
||||
if (
|
||||
!visibility.automaticDimensions &&
|
||||
role !== 'manual-dimension' &&
|
||||
(role === undefined || role === 'automatic-dimension') &&
|
||||
(geometry.kind === 'dimension' ||
|
||||
geometry.kind === 'dimension-string' ||
|
||||
geometry.kind === 'dimension-label' ||
|
||||
@@ -82,7 +96,10 @@ export function filterFloorplanAnnotationGeometry(
|
||||
.map((child) => filterFloorplanAnnotationGeometry(child, visibility, role))
|
||||
.filter((child): child is FloorplanGeometry => child !== null)
|
||||
if (children.length === 0) return null
|
||||
if (children.length === geometry.children.length) return geometry
|
||||
const childrenUnchanged =
|
||||
children.length === geometry.children.length &&
|
||||
children.every((child, index) => child === geometry.children[index])
|
||||
if (childrenUnchanged) return geometry
|
||||
return { ...geometry, children }
|
||||
}
|
||||
|
||||
@@ -90,21 +107,27 @@ function isAnnotationRoleVisible(
|
||||
role: FloorplanAnnotationRole,
|
||||
visibility: FloorplanAnnotationVisibility,
|
||||
): boolean {
|
||||
return visibility[annotationCategoryForRole(role)]
|
||||
}
|
||||
|
||||
function annotationCategoryForRole(role: FloorplanAnnotationRole): FloorplanAnnotationCategory {
|
||||
switch (role) {
|
||||
case 'automatic-dimension':
|
||||
return visibility.automaticDimensions
|
||||
return 'automaticDimensions'
|
||||
case 'contextual-dimension':
|
||||
return 'contextualDimensions'
|
||||
case 'manual-dimension':
|
||||
return visibility.manualDimensions
|
||||
return 'manualDimensions'
|
||||
case 'measurement':
|
||||
return visibility.measurements
|
||||
return 'measurements'
|
||||
case 'opening-mark':
|
||||
return visibility.openingMarks
|
||||
return 'openingMarks'
|
||||
case 'structural-grid':
|
||||
case 'column-center':
|
||||
return visibility.structuralGrids
|
||||
return 'structuralGrids'
|
||||
case 'room-label':
|
||||
return visibility.roomLabels
|
||||
return 'roomLabels'
|
||||
case 'stair-annotation':
|
||||
return visibility.stairAnnotations
|
||||
return 'stairAnnotations'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { beforeAll, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
DrawingSheetNode,
|
||||
type FloorplanGeometry,
|
||||
nodeRegistry,
|
||||
registerNode,
|
||||
} from '@pascal-app/core'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { FloorplanGeometry } from '@pascal-app/core'
|
||||
import { splitFloorplanOverlay } from '../../components/editor-2d/renderers/floorplan-registry-layer'
|
||||
import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility'
|
||||
import {
|
||||
filterFloorplanExportOverlay,
|
||||
fitPlanToBox,
|
||||
isFloorplanExportAnnotationGeometry,
|
||||
partitionFloorplanExportOverlay,
|
||||
pointsPerMeterForDrawingScale,
|
||||
resolveDrawingSheetDocumentMarkers,
|
||||
resolveDrawingSheetGeneralNotes,
|
||||
resolveDrawingSheetKeyedNotes,
|
||||
resolveFloorplanExportAnnotationVisibility,
|
||||
resolveFloorplanExportNodeGeometry,
|
||||
resolveFloorplanExportPlacement,
|
||||
@@ -24,40 +16,31 @@ import {
|
||||
resolveFloorplanMeasurementSize,
|
||||
resolveFloorplanPageLayout,
|
||||
resolveFloorplanScreenUnitsPerPixel,
|
||||
resolveGraphicScaleLength,
|
||||
resolveSheetComposition,
|
||||
resolveSheetExportLayout,
|
||||
resolveSheetPageSetup,
|
||||
rotateFloorplanExportBounds,
|
||||
} from './floorplan-export'
|
||||
import { type FloorplanNodeExtension, floorplanGeometryMetadata } from './floorplan-extension'
|
||||
|
||||
const drawingSheetExtension: FloorplanNodeExtension<DrawingSheetNode> = {
|
||||
resolveDrawingSheet: ({ node, levelId, drawingType }) =>
|
||||
node.placedViews.some(
|
||||
(view) =>
|
||||
(view.levelId === null || view.levelId === levelId) && view.drawingType === drawingType,
|
||||
)
|
||||
? node
|
||||
: null,
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (nodeRegistry.has('drawing-sheet')) return
|
||||
registerNode({
|
||||
kind: 'drawing-sheet',
|
||||
schemaVersion: 1,
|
||||
schema: DrawingSheetNode,
|
||||
category: 'analysis',
|
||||
defaults: () => ({}) as never,
|
||||
capabilities: {},
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': drawingSheetExtension,
|
||||
},
|
||||
})
|
||||
})
|
||||
import { floorplanGeometryMetadata } from './floorplan-extension'
|
||||
|
||||
describe('filterFloorplanExportOverlay', () => {
|
||||
test('preserves annotation metadata while splitting geometry passes', () => {
|
||||
const contextualDimension = {
|
||||
kind: 'group',
|
||||
metadata: floorplanGeometryMetadata({ annotationRole: 'contextual-dimension' }),
|
||||
children: [
|
||||
{
|
||||
kind: 'dimension',
|
||||
start: [0, 0],
|
||||
end: [2, 0],
|
||||
offsetNormal: [0, 1],
|
||||
offsetDistance: 0.3,
|
||||
extensionOvershoot: 0.08,
|
||||
text: '2m',
|
||||
},
|
||||
],
|
||||
} satisfies FloorplanGeometry
|
||||
|
||||
expect(splitFloorplanOverlay(contextualDimension).overlay).toMatchObject(contextualDimension)
|
||||
})
|
||||
|
||||
test('preserves value labels and removes editing handles', () => {
|
||||
const label = {
|
||||
kind: 'dimension-label',
|
||||
@@ -242,6 +225,7 @@ describe('floor plan export policy', () => {
|
||||
test('exports the same annotation categories that are visible in the live view', () => {
|
||||
const liveVisibility = {
|
||||
automaticDimensions: true,
|
||||
contextualDimensions: false,
|
||||
manualDimensions: false,
|
||||
measurements: true,
|
||||
openingMarks: true,
|
||||
@@ -250,7 +234,27 @@ describe('floor plan export policy', () => {
|
||||
stairAnnotations: true,
|
||||
}
|
||||
|
||||
expect(resolveFloorplanExportAnnotationVisibility(liveVisibility)).toEqual(liveVisibility)
|
||||
expect(resolveFloorplanExportAnnotationVisibility('expert', liveVisibility)).toEqual(
|
||||
liveVisibility,
|
||||
)
|
||||
})
|
||||
|
||||
test('exports only model geometry and room labels in Default', () => {
|
||||
expect(
|
||||
resolveFloorplanExportAnnotationVisibility(
|
||||
'default',
|
||||
DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
|
||||
),
|
||||
).toEqual({
|
||||
automaticDimensions: false,
|
||||
contextualDimensions: false,
|
||||
manualDimensions: false,
|
||||
measurements: false,
|
||||
openingMarks: false,
|
||||
structuralGrids: false,
|
||||
roomLabels: true,
|
||||
stairAnnotations: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('matches live screen sizing to the fitted export viewport', () => {
|
||||
@@ -298,16 +302,6 @@ describe('floor plan export policy', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointsPerMeterForDrawingScale', () => {
|
||||
test('converts metric ratios to plotted points per metre', () => {
|
||||
expect(pointsPerMeterForDrawingScale('1:50')).toBeCloseTo(56.6929, 4)
|
||||
})
|
||||
|
||||
test('converts imperial architectural scales to plotted points per metre', () => {
|
||||
expect(pointsPerMeterForDrawingScale('1/4"=1\'-0"')).toBeCloseTo(59.0551, 4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveFloorplanMeasurementSize', () => {
|
||||
test('sizes the hidden SVG in screen pixels before resolving label collisions', () => {
|
||||
expect(
|
||||
@@ -316,276 +310,10 @@ describe('resolveFloorplanMeasurementSize', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSheetExportLayout', () => {
|
||||
test('reserves a plan viewport, side panel, and title block on one sheet page', () => {
|
||||
expect(resolveSheetExportLayout(842, 595)).toEqual({
|
||||
planBox: { x: 36, y: 36, width: 572, height: 463 },
|
||||
sidePanel: { x: 626, y: 36, width: 180, height: 463 },
|
||||
titleBlock: { x: 36, y: 517, width: 770, height: 42 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveFloorplanPageLayout', () => {
|
||||
test('uses the page for the plan without drawing-sheet sidebars or title blocks', () => {
|
||||
test('uses the available A4 page area for the fitted plan', () => {
|
||||
expect(resolveFloorplanPageLayout(842, 595)).toEqual({
|
||||
planBox: { x: 36, y: 64, width: 770, height: 495 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveGraphicScaleLength', () => {
|
||||
test('chooses a model length that fits the available paper width', () => {
|
||||
const scale = resolveGraphicScaleLength('1:50', 150)
|
||||
|
||||
expect(scale.modelMeters).toBe(2)
|
||||
expect(scale.widthPt).toBeCloseTo(113.39, 2)
|
||||
expect(scale.label).toBe('2 m')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSheetComposition', () => {
|
||||
test('uses drawing-sheet metadata for view titles, references, notes, and scale', () => {
|
||||
const sheet = DrawingSheetNode.parse({
|
||||
id: 'drawing-sheet_a101',
|
||||
sheetNumber: 'A1.1',
|
||||
sheetTitle: 'Plans',
|
||||
placedViews: [
|
||||
{
|
||||
id: 'drawing-view_main',
|
||||
levelId: 'level_main',
|
||||
drawingType: 'floor-plan',
|
||||
drawingNumber: '2',
|
||||
title: 'Main Floor Plan',
|
||||
scale: '1:50',
|
||||
},
|
||||
],
|
||||
generalNotes: [{ id: 'sheet-note_1', number: 1, text: 'Verify all dimensions.' }],
|
||||
keyedNoteLegend: [{ key: 'A', text: 'Patch existing slab.' }],
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveSheetComposition(
|
||||
{ [sheet.id]: sheet },
|
||||
'level_main',
|
||||
'Main Level',
|
||||
'floor-plan',
|
||||
'Floor plan',
|
||||
'1/4"=1\'-0"',
|
||||
),
|
||||
).toMatchObject({
|
||||
sheetNumber: 'A1.1',
|
||||
sheetTitle: 'Plans',
|
||||
paperSize: 'arch-b',
|
||||
orientation: 'landscape',
|
||||
drawingNumber: '2',
|
||||
viewTitle: 'Main Floor Plan',
|
||||
scale: '1:50',
|
||||
generalNotes: [{ number: 1, text: 'Verify all dimensions.' }],
|
||||
keyedNoteLegend: [{ key: 'A', text: 'Patch existing slab.' }],
|
||||
keyedNoteInstances: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves reusable general note sets before sheet-local notes', () => {
|
||||
const sheet = DrawingSheetNode.parse({
|
||||
id: 'drawing-sheet_a101',
|
||||
generalNoteSetIds: ['sheet-note-set_project'],
|
||||
generalNoteSets: [
|
||||
{
|
||||
id: 'sheet-note-set_project',
|
||||
name: 'Project Notes',
|
||||
notes: [{ id: 'sheet-note_project-1', number: 7, text: 'Coordinate with structural.' }],
|
||||
},
|
||||
],
|
||||
generalNotes: [{ id: 'sheet-note_sheet-1', number: 99, text: 'Verify dimensions.' }],
|
||||
})
|
||||
|
||||
expect(resolveDrawingSheetGeneralNotes(sheet).notes).toEqual([
|
||||
{ number: 1, text: 'Coordinate with structural.' },
|
||||
{ number: 2, text: 'Verify dimensions.' },
|
||||
])
|
||||
})
|
||||
|
||||
test('reports duplicate reusable and sheet-local general notes', () => {
|
||||
const sheet = DrawingSheetNode.parse({
|
||||
id: 'drawing-sheet_a101',
|
||||
generalNoteSets: [
|
||||
{
|
||||
id: 'sheet-note-set_project',
|
||||
name: 'Project Notes',
|
||||
notes: [{ id: 'sheet-note_project-1', number: 1, text: 'Verify all dimensions.' }],
|
||||
},
|
||||
],
|
||||
generalNotes: [{ id: 'sheet-note_sheet-1', number: 1, text: 'VERIFY ALL DIMENSIONS.' }],
|
||||
})
|
||||
|
||||
expect(resolveDrawingSheetGeneralNotes(sheet).duplicateWarnings).toEqual([
|
||||
{
|
||||
severity: 'warning',
|
||||
message:
|
||||
'Duplicate general note: "Verify all dimensions." appears in Project Notes and sheet.',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('derives keyed-note legends from repeated stable instances', () => {
|
||||
const sheet = DrawingSheetNode.parse({
|
||||
id: 'drawing-sheet_a101',
|
||||
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
|
||||
keyedNoteDefinitions: [
|
||||
{ id: 'keyed-note_patch', key: 'A', text: 'Patch existing slab.' },
|
||||
{ id: 'keyed-note_verify', key: 'B', text: 'Verify bearing.' },
|
||||
],
|
||||
keyedNoteInstances: [
|
||||
{
|
||||
id: 'keyed-note-instance_patch-1',
|
||||
definitionId: 'keyed-note_patch',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [2, 3],
|
||||
},
|
||||
{
|
||||
id: 'keyed-note-instance_patch-2',
|
||||
definitionId: 'keyed-note_patch',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [4, 3],
|
||||
},
|
||||
],
|
||||
keyedNoteLegend: [{ key: 'Z', text: 'Legacy unused note.' }],
|
||||
})
|
||||
|
||||
expect(resolveDrawingSheetKeyedNotes(sheet, 'drawing-view_main')).toEqual({
|
||||
legend: [{ key: 'A', text: 'Patch existing slab.' }],
|
||||
instances: [
|
||||
{ id: 'keyed-note-instance_patch-1', key: 'A', x: 2, y: 3 },
|
||||
{ id: 'keyed-note-instance_patch-2', key: 'A', x: 4, y: 3 },
|
||||
],
|
||||
warnings: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('reports keyed-note instances with missing definitions', () => {
|
||||
const sheet = DrawingSheetNode.parse({
|
||||
id: 'drawing-sheet_a101',
|
||||
keyedNoteInstances: [
|
||||
{
|
||||
id: 'keyed-note-instance_missing',
|
||||
definitionId: 'keyed-note_missing',
|
||||
position: [2, 3],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(resolveDrawingSheetKeyedNotes(sheet).warnings).toEqual([
|
||||
{
|
||||
severity: 'warning',
|
||||
message:
|
||||
'Keyed-note symbol keyed-note-instance_missing references missing definition keyed-note_missing.',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('resolves scoped drawing sheet document markers', () => {
|
||||
const sheet = DrawingSheetNode.parse({
|
||||
id: 'drawing-sheet_a101',
|
||||
placedViews: [{ id: 'drawing-view_main', levelId: 'level_main' }],
|
||||
documentMarkers: [
|
||||
{
|
||||
id: 'sheet-marker_wall-a',
|
||||
kind: 'wall-tag',
|
||||
label: 'W1',
|
||||
placedViewId: 'drawing-view_main',
|
||||
position: [2, 3],
|
||||
},
|
||||
{
|
||||
id: 'sheet-marker_revision-a',
|
||||
kind: 'revision-cloud',
|
||||
label: '1',
|
||||
revisionId: 'A',
|
||||
points: [
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
[2, 2],
|
||||
[1, 2],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sheet-marker_other-view',
|
||||
kind: 'detail-reference',
|
||||
label: '3',
|
||||
placedViewId: 'drawing-view_other',
|
||||
position: [5, 5],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(resolveDrawingSheetDocumentMarkers(sheet, 'drawing-view_main')).toEqual([
|
||||
{
|
||||
id: 'sheet-marker_wall-a',
|
||||
kind: 'wall-tag',
|
||||
label: 'W1',
|
||||
title: '',
|
||||
sheetReference: '',
|
||||
drawingReference: '',
|
||||
revisionId: '',
|
||||
x: 2,
|
||||
y: 3,
|
||||
endX: null,
|
||||
endY: null,
|
||||
points: [],
|
||||
},
|
||||
{
|
||||
id: 'sheet-marker_revision-a',
|
||||
kind: 'revision-cloud',
|
||||
label: '1',
|
||||
title: '',
|
||||
sheetReference: '',
|
||||
drawingReference: '',
|
||||
revisionId: 'A',
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
endX: null,
|
||||
endY: null,
|
||||
points: [
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 1 },
|
||||
{ x: 2, y: 2 },
|
||||
{ x: 1, y: 2 },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSheetPageSetup', () => {
|
||||
test('resolves supported paper sizes and orientation to page points', () => {
|
||||
expect(
|
||||
resolveSheetPageSetup({
|
||||
paperSize: 'arch-b',
|
||||
orientation: 'landscape',
|
||||
customPaperWidth: null,
|
||||
customPaperHeight: null,
|
||||
}),
|
||||
).toEqual({ width: 1296, height: 864, orientation: 'landscape' })
|
||||
|
||||
const a3 = resolveSheetPageSetup({
|
||||
paperSize: 'a3',
|
||||
orientation: 'portrait',
|
||||
customPaperWidth: null,
|
||||
customPaperHeight: null,
|
||||
})
|
||||
expect(a3.width).toBeCloseTo(841.89, 2)
|
||||
expect(a3.height).toBeCloseTo(1190.55, 2)
|
||||
})
|
||||
|
||||
test('uses custom paper dimensions in inches', () => {
|
||||
expect(
|
||||
resolveSheetPageSetup({
|
||||
paperSize: 'custom',
|
||||
orientation: 'portrait',
|
||||
customPaperWidth: 24,
|
||||
customPaperHeight: 36,
|
||||
}),
|
||||
).toEqual({ width: 1728, height: 2592, orientation: 'portrait' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,10 +4,6 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type ConstructionDrawingType,
|
||||
type DrawingSheetNode,
|
||||
type DrawingSheetOrientation,
|
||||
type DrawingSheetPaperSize,
|
||||
type DrawingSheetScale,
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPalette,
|
||||
type FloorplanPoint,
|
||||
@@ -33,6 +29,7 @@ import {
|
||||
import useDrawingView, { DRAWING_TYPE_OPTIONS } from '../../store/use-drawing-view'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import useFloorplanAnnotationVisibility from '../../store/use-floorplan-annotation-visibility'
|
||||
import useFloorplanMode from '../../store/use-floorplan-mode'
|
||||
import {
|
||||
type FloorplanAnnotationVisibility,
|
||||
filterFloorplanAnnotationGeometry,
|
||||
@@ -41,9 +38,15 @@ import { resolveNodeForDrawingType } from './drawing-coordination'
|
||||
import {
|
||||
type FloorplanMetricNotation,
|
||||
type FloorplanSchedule,
|
||||
type FloorplanWallDimensionReference,
|
||||
getFloorplanNodeExtension,
|
||||
readFloorplanGeometryMetadata,
|
||||
} from './floorplan-extension'
|
||||
import {
|
||||
type FloorplanMode,
|
||||
resolveFloorplanAnnotationVisibility,
|
||||
resolveFloorplanWallDimensionReference,
|
||||
} from './floorplan-mode'
|
||||
import { createFloorplanPdfDocument, type FloorplanPdfDocument } from './floorplan-pdfkit-document'
|
||||
import { renderFloorplanGeometryToPdfKit } from './floorplan-pdfkit-renderer'
|
||||
import { FLOORPLAN_VIEW_ROTATION_DEG } from './geometry'
|
||||
@@ -73,11 +76,8 @@ const PLAN_PADDING_RATIO = 0.2
|
||||
/** PDF page margin + title band, in pt. */
|
||||
const PAGE_MARGIN_PT = 36
|
||||
const TITLE_BAND_PT = 28
|
||||
const SHEET_GAP_PT = 18
|
||||
const SHEET_SIDE_PANEL_WIDTH_PT = 180
|
||||
const TITLE_BLOCK_HEIGHT_PT = 42
|
||||
const POINTS_PER_INCH = 72
|
||||
const METERS_PER_INCH = 0.0254
|
||||
const A4_LANDSCAPE_WIDTH_PT = (297 / 25.4) * 72
|
||||
const A4_LANDSCAPE_HEIGHT_PT = (210 / 25.4) * 72
|
||||
|
||||
const NEUTRAL_PALETTE: FloorplanPalette = {
|
||||
selectedStroke: '#334155',
|
||||
@@ -111,8 +111,16 @@ const NEUTRAL_VIEW_STATE = {
|
||||
export function resolveFloorplanExportViewState(
|
||||
unit: 'metric' | 'imperial',
|
||||
metricNotation: FloorplanMetricNotation,
|
||||
wallDimensionReference?: FloorplanWallDimensionReference,
|
||||
automaticDimensions = true,
|
||||
) {
|
||||
return { ...NEUTRAL_VIEW_STATE, unit, metricNotation }
|
||||
return {
|
||||
...NEUTRAL_VIEW_STATE,
|
||||
automaticDimensions,
|
||||
unit,
|
||||
metricNotation,
|
||||
wallDimensionReference,
|
||||
}
|
||||
}
|
||||
|
||||
type ExportLevel = { id: AnyNodeId; label: string }
|
||||
@@ -123,83 +131,24 @@ type ExportGeometry = {
|
||||
annotations: FloorplanGeometry | null
|
||||
}
|
||||
|
||||
type SheetComposition = {
|
||||
sheetNumber: string
|
||||
sheetTitle: string
|
||||
paperSize: DrawingSheetPaperSize
|
||||
orientation: DrawingSheetOrientation
|
||||
customPaperWidth: number | null
|
||||
customPaperHeight: number | null
|
||||
drawingNumber: string
|
||||
viewTitle: string
|
||||
drawingLabel: string
|
||||
scale: DrawingSheetScale
|
||||
generalNotes: { number: number; text: string }[]
|
||||
keyedNoteLegend: { key: string; text: string }[]
|
||||
keyedNoteInstances: { id: string; key: string; x: number; y: number }[]
|
||||
documentMarkers: ResolvedDocumentMarker[]
|
||||
preflightIssues: SheetPreflightIssue[]
|
||||
}
|
||||
|
||||
export type SheetExportLayout = {
|
||||
planBox: { x: number; y: number; width: number; height: number }
|
||||
sidePanel: { x: number; y: number; width: number; height: number }
|
||||
titleBlock: { x: number; y: number; width: number; height: number }
|
||||
}
|
||||
|
||||
export type FloorplanPageLayout = {
|
||||
planBox: { x: number; y: number; width: number; height: number }
|
||||
}
|
||||
|
||||
type ScheduleDrawResult = {
|
||||
drawnSchedules: number
|
||||
overflowSchedules: FloorplanSchedule[]
|
||||
}
|
||||
|
||||
export type SheetPageSetup = {
|
||||
width: number
|
||||
height: number
|
||||
orientation: DrawingSheetOrientation
|
||||
}
|
||||
|
||||
export type SheetPreflightIssue = {
|
||||
severity: 'warning'
|
||||
message: string
|
||||
}
|
||||
|
||||
type ResolvedGeneralNotes = {
|
||||
notes: { number: number; text: string }[]
|
||||
duplicateWarnings: SheetPreflightIssue[]
|
||||
}
|
||||
|
||||
type ResolvedKeyedNotes = {
|
||||
legend: { key: string; text: string }[]
|
||||
instances: { id: string; key: string; x: number; y: number }[]
|
||||
warnings: SheetPreflightIssue[]
|
||||
}
|
||||
|
||||
type ResolvedDocumentMarker = {
|
||||
id: string
|
||||
kind: string
|
||||
label: string
|
||||
title: string
|
||||
sheetReference: string
|
||||
drawingReference: string
|
||||
revisionId: string
|
||||
x: number
|
||||
y: number
|
||||
endX: number | null
|
||||
endY: number | null
|
||||
points: { x: number; y: number }[]
|
||||
}
|
||||
|
||||
export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<void> {
|
||||
const nodes = useScene.getState().nodes
|
||||
const viewer = useViewer.getState()
|
||||
const unit = viewer.unit
|
||||
const metricNotation = viewer.metricNotation
|
||||
const floorplanMode = useFloorplanMode.getState().mode
|
||||
const expertAnnotationState = useFloorplanAnnotationVisibility.getState()
|
||||
const annotationVisibility = resolveFloorplanExportAnnotationVisibility(
|
||||
useFloorplanAnnotationVisibility.getState().visibility,
|
||||
floorplanMode,
|
||||
expertAnnotationState.visibility,
|
||||
)
|
||||
const wallDimensionReference = resolveFloorplanWallDimensionReference(
|
||||
floorplanMode,
|
||||
expertAnnotationState.wallDimensionReference,
|
||||
)
|
||||
const navigationAzimuth = useEditor.getState().navigationSyncPose?.azimuth
|
||||
const drawingType = useDrawingView.getState().drawingType
|
||||
@@ -212,15 +161,9 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
|
||||
return
|
||||
}
|
||||
|
||||
const defaultPageSetup = resolveSheetPageSetup({
|
||||
paperSize: 'a4',
|
||||
orientation: 'landscape',
|
||||
customPaperWidth: null,
|
||||
customPaperHeight: null,
|
||||
})
|
||||
const { doc, save } = await createFloorplanPdfDocument([
|
||||
defaultPageSetup.width,
|
||||
defaultPageSetup.height,
|
||||
A4_LANDSCAPE_WIDTH_PT,
|
||||
A4_LANDSCAPE_HEIGHT_PT,
|
||||
])
|
||||
|
||||
const host = document.createElement('div')
|
||||
@@ -239,17 +182,11 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
|
||||
metricNotation,
|
||||
annotationVisibility,
|
||||
drawingType,
|
||||
wallDimensionReference,
|
||||
)
|
||||
const schedules = collectFloorplanSchedules(nodes, level.id, unit)
|
||||
if (geometries.length === 0 && schedules.length === 0) continue
|
||||
const pageSetup = resolveSheetPageSetup({
|
||||
paperSize: 'a4',
|
||||
orientation: 'landscape',
|
||||
customPaperWidth: null,
|
||||
customPaperHeight: null,
|
||||
})
|
||||
const layout = resolveFloorplanPageLayout(pageSetup.width, pageSetup.height)
|
||||
let scheduleOverflow: FloorplanSchedule[] = [...schedules]
|
||||
const layout = resolveFloorplanPageLayout(A4_LANDSCAPE_WIDTH_PT, A4_LANDSCAPE_HEIGHT_PT)
|
||||
|
||||
if (geometries.length > 0) {
|
||||
// Preserve the live floor-plan orientation rather than forcing north-up.
|
||||
@@ -266,7 +203,7 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
|
||||
)
|
||||
if (mounted) {
|
||||
try {
|
||||
doc.addPage([pageSetup.width, pageSetup.height], pageSetup.orientation)
|
||||
doc.addPage([A4_LANDSCAPE_WIDTH_PT, A4_LANDSCAPE_HEIGHT_PT], 'landscape')
|
||||
pageCount++
|
||||
|
||||
const screenUnitsPerPixel = resolveFloorplanScreenUnitsPerPixel(
|
||||
@@ -312,8 +249,8 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduleOverflow.length > 0) {
|
||||
pageCount = drawFloorplanSchedulePages(doc, level.label, scheduleOverflow, pageCount)
|
||||
if (schedules.length > 0) {
|
||||
pageCount = drawFloorplanSchedulePages(doc, level.label, schedules, pageCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,246 +296,6 @@ export function collectFloorplanSchedules(
|
||||
return schedules
|
||||
}
|
||||
|
||||
export function resolveSheetComposition(
|
||||
nodes: Record<string, AnyNode>,
|
||||
levelId: AnyNodeId,
|
||||
levelLabel: string,
|
||||
drawingType: ConstructionDrawingType,
|
||||
drawingLabel: string,
|
||||
fallbackScale: DrawingSheetScale,
|
||||
): SheetComposition {
|
||||
const sheet = findDrawingSheetForLevel(nodes, levelId, drawingType)
|
||||
const placedView = sheet?.placedViews.find(
|
||||
(view) =>
|
||||
(view.levelId === null || view.levelId === levelId) && view.drawingType === drawingType,
|
||||
)
|
||||
const generalNotes = sheet
|
||||
? resolveDrawingSheetGeneralNotes(sheet)
|
||||
: { notes: [], duplicateWarnings: [] }
|
||||
const keyedNotes = sheet
|
||||
? resolveDrawingSheetKeyedNotes(sheet, placedView?.id ?? null)
|
||||
: { legend: [], instances: [], warnings: [] }
|
||||
const documentMarkers = sheet
|
||||
? resolveDrawingSheetDocumentMarkers(sheet, placedView?.id ?? null)
|
||||
: []
|
||||
return {
|
||||
sheetNumber: sheet?.sheetNumber ?? 'A1.0',
|
||||
sheetTitle: sheet?.sheetTitle ?? drawingLabel,
|
||||
paperSize: sheet?.paperSize ?? 'a4',
|
||||
orientation: sheet?.orientation ?? 'landscape',
|
||||
customPaperWidth: sheet?.customPaperWidth ?? null,
|
||||
customPaperHeight: sheet?.customPaperHeight ?? null,
|
||||
drawingNumber: placedView?.drawingNumber ?? '1',
|
||||
viewTitle: placedView?.title ?? `${levelLabel} ${drawingLabel}`,
|
||||
drawingLabel,
|
||||
scale: placedView?.scale ?? fallbackScale,
|
||||
generalNotes: generalNotes.notes,
|
||||
keyedNoteLegend: keyedNotes.legend,
|
||||
keyedNoteInstances: keyedNotes.instances,
|
||||
documentMarkers,
|
||||
preflightIssues: [...generalNotes.duplicateWarnings, ...keyedNotes.warnings],
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDrawingSheetGeneralNotes(sheet: DrawingSheetNode): ResolvedGeneralNotes {
|
||||
const generalNoteSets = sheet.generalNoteSets ?? []
|
||||
const generalNoteSetIds = sheet.generalNoteSetIds ?? []
|
||||
const sheetNotes = sheet.generalNotes ?? []
|
||||
const selectedSetIds =
|
||||
generalNoteSetIds.length > 0
|
||||
? new Set(generalNoteSetIds)
|
||||
: new Set(generalNoteSets.map((set) => set.id))
|
||||
const noteSources = [
|
||||
...generalNoteSets
|
||||
.filter((set) => selectedSetIds.has(set.id))
|
||||
.flatMap((set) => set.notes.map((note) => ({ text: note.text, source: set.name }))),
|
||||
...sheetNotes.map((note) => ({ text: note.text, source: 'sheet' })),
|
||||
]
|
||||
const notes = noteSources.map((note, index) => ({ number: index + 1, text: note.text }))
|
||||
const duplicateWarnings: SheetPreflightIssue[] = []
|
||||
const seen = new Map<string, { text: string; count: number; sources: Set<string> }>()
|
||||
for (const note of noteSources) {
|
||||
const key = normalizeGeneralNoteText(note.text)
|
||||
const existing = seen.get(key)
|
||||
if (existing) {
|
||||
existing.count += 1
|
||||
existing.sources.add(note.source)
|
||||
continue
|
||||
}
|
||||
seen.set(key, { text: note.text, count: 1, sources: new Set([note.source]) })
|
||||
}
|
||||
for (const duplicate of seen.values()) {
|
||||
if (duplicate.count < 2) continue
|
||||
duplicateWarnings.push({
|
||||
severity: 'warning',
|
||||
message: `Duplicate general note: "${duplicate.text}" appears in ${[
|
||||
...duplicate.sources,
|
||||
].join(' and ')}.`,
|
||||
})
|
||||
}
|
||||
return { notes, duplicateWarnings }
|
||||
}
|
||||
|
||||
function normalizeGeneralNoteText(text: string): string {
|
||||
return text.trim().replace(/\s+/g, ' ').toLocaleLowerCase()
|
||||
}
|
||||
|
||||
export function resolveDrawingSheetKeyedNotes(
|
||||
sheet: DrawingSheetNode,
|
||||
placedViewId: string | null = null,
|
||||
): ResolvedKeyedNotes {
|
||||
const definitions = sheet.keyedNoteDefinitions ?? []
|
||||
const instances = sheet.keyedNoteInstances ?? []
|
||||
const definitionById = new Map(definitions.map((definition) => [definition.id, definition]))
|
||||
const scopedInstances = instances.filter(
|
||||
(instance) => instance.placedViewId === null || instance.placedViewId === placedViewId,
|
||||
)
|
||||
const warnings: SheetPreflightIssue[] = []
|
||||
const usedDefinitions = new Map<string, { key: string; text: string }>()
|
||||
const resolvedInstances: ResolvedKeyedNotes['instances'] = []
|
||||
|
||||
for (const instance of scopedInstances) {
|
||||
const definition = definitionById.get(instance.definitionId)
|
||||
if (!definition) {
|
||||
warnings.push({
|
||||
severity: 'warning',
|
||||
message: `Keyed-note symbol ${instance.id} references missing definition ${instance.definitionId}.`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
usedDefinitions.set(definition.id, { key: definition.key, text: definition.text })
|
||||
resolvedInstances.push({
|
||||
id: instance.id,
|
||||
key: definition.key,
|
||||
x: instance.position[0],
|
||||
y: instance.position[1],
|
||||
})
|
||||
}
|
||||
|
||||
const derivedLegend = [...usedDefinitions.values()].sort((left, right) =>
|
||||
left.key.localeCompare(right.key, undefined, { numeric: true }),
|
||||
)
|
||||
return {
|
||||
legend: derivedLegend.length > 0 ? derivedLegend : (sheet.keyedNoteLegend ?? []),
|
||||
instances: resolvedInstances,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDrawingSheetDocumentMarkers(
|
||||
sheet: DrawingSheetNode,
|
||||
placedViewId: string | null = null,
|
||||
): ResolvedDocumentMarker[] {
|
||||
return (sheet.documentMarkers ?? [])
|
||||
.filter((marker) => marker.placedViewId === null || marker.placedViewId === placedViewId)
|
||||
.map((marker) => ({
|
||||
id: marker.id,
|
||||
kind: marker.kind,
|
||||
label: marker.label,
|
||||
title: marker.title,
|
||||
sheetReference: marker.sheetReference,
|
||||
drawingReference: marker.drawingReference,
|
||||
revisionId: marker.revisionId,
|
||||
x: marker.position[0],
|
||||
y: marker.position[1],
|
||||
endX: marker.endPosition?.[0] ?? null,
|
||||
endY: marker.endPosition?.[1] ?? null,
|
||||
points: marker.points.map(([x, y]) => ({ x, y })),
|
||||
}))
|
||||
}
|
||||
|
||||
export function resolveSheetPageSetup(
|
||||
sheet: Pick<
|
||||
SheetComposition,
|
||||
'paperSize' | 'orientation' | 'customPaperWidth' | 'customPaperHeight'
|
||||
>,
|
||||
): SheetPageSetup {
|
||||
const base = paperSizePoints(sheet.paperSize, sheet.customPaperWidth, sheet.customPaperHeight)
|
||||
const [width, height] =
|
||||
sheet.orientation === 'landscape'
|
||||
? [Math.max(base.width, base.height), Math.min(base.width, base.height)]
|
||||
: [Math.min(base.width, base.height), Math.max(base.width, base.height)]
|
||||
return { width, height, orientation: sheet.orientation }
|
||||
}
|
||||
|
||||
function paperSizePoints(
|
||||
paperSize: DrawingSheetPaperSize,
|
||||
customPaperWidth: number | null,
|
||||
customPaperHeight: number | null,
|
||||
): { width: number; height: number } {
|
||||
switch (paperSize) {
|
||||
case 'letter':
|
||||
return inchesToPoints(8.5, 11)
|
||||
case 'tabloid':
|
||||
return inchesToPoints(11, 17)
|
||||
case 'arch-a':
|
||||
return inchesToPoints(9, 12)
|
||||
case 'arch-b':
|
||||
return inchesToPoints(12, 18)
|
||||
case 'arch-c':
|
||||
return inchesToPoints(18, 24)
|
||||
case 'a3':
|
||||
return millimetersToPoints(297, 420)
|
||||
case 'custom':
|
||||
return inchesToPoints(customPaperWidth ?? 18, customPaperHeight ?? 12)
|
||||
case 'a4':
|
||||
return millimetersToPoints(210, 297)
|
||||
}
|
||||
}
|
||||
|
||||
function inchesToPoints(width: number, height: number): { width: number; height: number } {
|
||||
return { width: width * POINTS_PER_INCH, height: height * POINTS_PER_INCH }
|
||||
}
|
||||
|
||||
function millimetersToPoints(width: number, height: number): { width: number; height: number } {
|
||||
return inchesToPoints(width / 25.4, height / 25.4)
|
||||
}
|
||||
|
||||
function findDrawingSheetForLevel(
|
||||
nodes: Record<string, AnyNode>,
|
||||
levelId: AnyNodeId,
|
||||
drawingType: ConstructionDrawingType,
|
||||
): DrawingSheetNode | null {
|
||||
for (const node of Object.values(nodes)) {
|
||||
const resolveDrawingSheet = getFloorplanNodeExtension(
|
||||
nodeRegistry.get(node.type),
|
||||
)?.resolveDrawingSheet
|
||||
const sheet = resolveDrawingSheet?.({ node: node as never, levelId, drawingType })
|
||||
if (sheet) return sheet
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveSheetExportLayout(pageWidth: number, pageHeight: number): SheetExportLayout {
|
||||
const contentX = PAGE_MARGIN_PT
|
||||
const contentY = PAGE_MARGIN_PT
|
||||
const contentWidth = pageWidth - PAGE_MARGIN_PT * 2
|
||||
const contentHeight = pageHeight - PAGE_MARGIN_PT * 2
|
||||
const titleBlock = {
|
||||
x: contentX,
|
||||
y: contentY + contentHeight - TITLE_BLOCK_HEIGHT_PT,
|
||||
width: contentWidth,
|
||||
height: TITLE_BLOCK_HEIGHT_PT,
|
||||
}
|
||||
const upperHeight = contentHeight - TITLE_BLOCK_HEIGHT_PT - SHEET_GAP_PT
|
||||
const sidePanel = {
|
||||
x: contentX + contentWidth - SHEET_SIDE_PANEL_WIDTH_PT,
|
||||
y: contentY,
|
||||
width: SHEET_SIDE_PANEL_WIDTH_PT,
|
||||
height: upperHeight,
|
||||
}
|
||||
return {
|
||||
planBox: {
|
||||
x: contentX,
|
||||
y: contentY,
|
||||
width: contentWidth - SHEET_SIDE_PANEL_WIDTH_PT - SHEET_GAP_PT,
|
||||
height: upperHeight,
|
||||
},
|
||||
sidePanel,
|
||||
titleBlock,
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFloorplanPageLayout(
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
@@ -748,398 +445,6 @@ function truncatePdfText(doc: FloorplanPdfDocument, value: string, maxWidth: num
|
||||
return `${truncated}...`
|
||||
}
|
||||
|
||||
function drawSheetChrome(
|
||||
doc: FloorplanPdfDocument,
|
||||
layout: SheetExportLayout,
|
||||
composition: SheetComposition,
|
||||
schedules: readonly FloorplanSchedule[],
|
||||
): ScheduleDrawResult {
|
||||
doc.setDrawColor('#0f172a')
|
||||
doc.setLineWidth(0.6)
|
||||
doc.rect(
|
||||
PAGE_MARGIN_PT,
|
||||
PAGE_MARGIN_PT,
|
||||
doc.internal.pageSize.getWidth() - PAGE_MARGIN_PT * 2,
|
||||
doc.internal.pageSize.getHeight() - PAGE_MARGIN_PT * 2,
|
||||
)
|
||||
doc.setDrawColor('#cbd5e1')
|
||||
doc.setLineWidth(0.4)
|
||||
doc.rect(layout.planBox.x, layout.planBox.y, layout.planBox.width, layout.planBox.height)
|
||||
doc.rect(layout.sidePanel.x, layout.sidePanel.y, layout.sidePanel.width, layout.sidePanel.height)
|
||||
doc.rect(
|
||||
layout.titleBlock.x,
|
||||
layout.titleBlock.y,
|
||||
layout.titleBlock.width,
|
||||
layout.titleBlock.height,
|
||||
)
|
||||
|
||||
drawSheetTitleBlock(doc, layout, composition)
|
||||
drawNorthArrow(doc, layout.planBox.x + layout.planBox.width - 26, layout.planBox.y + 38)
|
||||
drawGraphicScale(doc, layout.planBox.x + 18, layout.planBox.y + layout.planBox.height - 22, {
|
||||
scale: composition.scale,
|
||||
maxWidth: Math.min(150, layout.planBox.width * 0.3),
|
||||
})
|
||||
drawSheetDocumentMarkers(doc, composition)
|
||||
drawKeyedNoteSymbols(doc, composition)
|
||||
return drawSheetSidePanel(doc, layout.sidePanel, composition, schedules)
|
||||
}
|
||||
|
||||
function drawSheetDocumentMarkers(doc: FloorplanPdfDocument, composition: SheetComposition): void {
|
||||
if (composition.documentMarkers.length === 0) return
|
||||
doc.setDrawColor('#111827')
|
||||
doc.setTextColor('#111827')
|
||||
doc.setLineWidth(0.7)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(7)
|
||||
for (const marker of composition.documentMarkers) {
|
||||
const x = marker.x * 72
|
||||
const y = marker.y * 72
|
||||
const end =
|
||||
marker.endX !== null && marker.endY !== null
|
||||
? { x: marker.endX * 72, y: marker.endY * 72 }
|
||||
: null
|
||||
switch (marker.kind) {
|
||||
case 'wall-tag':
|
||||
case 'glazing-tag':
|
||||
case 'assembly-tag':
|
||||
drawTagMarker(doc, marker, x, y)
|
||||
break
|
||||
case 'section-callout':
|
||||
case 'elevation-callout':
|
||||
case 'detail-reference':
|
||||
drawCalloutMarker(doc, marker, x, y, end)
|
||||
break
|
||||
case 'delta-marker':
|
||||
drawDeltaMarker(doc, marker, x, y)
|
||||
break
|
||||
case 'revision-cloud':
|
||||
drawRevisionCloudMarker(doc, marker, x, y)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawTagMarker(
|
||||
doc: FloorplanPdfDocument,
|
||||
marker: ResolvedDocumentMarker,
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
const width = Math.max(20, marker.label.length * 5 + 10)
|
||||
const height = 14
|
||||
if (marker.kind === 'glazing-tag') {
|
||||
doc.roundedRect(x - width / 2, y - height / 2, width, height, 2, 2)
|
||||
} else if (marker.kind === 'assembly-tag') {
|
||||
doc.rect(x - width / 2, y - height / 2, width, height)
|
||||
} else {
|
||||
doc.circle(x, y, Math.max(7, width / 2))
|
||||
}
|
||||
doc.text(marker.label, x, y + 2.4, { align: 'center' })
|
||||
}
|
||||
|
||||
function drawCalloutMarker(
|
||||
doc: FloorplanPdfDocument,
|
||||
marker: ResolvedDocumentMarker,
|
||||
x: number,
|
||||
y: number,
|
||||
end: { x: number; y: number } | null,
|
||||
) {
|
||||
if (end) doc.line(x, y, end.x, end.y)
|
||||
doc.circle(x, y, 8)
|
||||
doc.line(x - 8, y, x + 8, y)
|
||||
doc.text(marker.label, x, y - 1.8, { align: 'center' })
|
||||
const reference = [marker.drawingReference, marker.sheetReference].filter(Boolean).join('/')
|
||||
if (reference) {
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.text(reference, x, y + 6, { align: 'center' })
|
||||
doc.setFont('helvetica', 'bold')
|
||||
}
|
||||
}
|
||||
|
||||
function drawDeltaMarker(
|
||||
doc: FloorplanPdfDocument,
|
||||
marker: ResolvedDocumentMarker,
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
const radius = 8
|
||||
const points = [
|
||||
[x, y - radius],
|
||||
[x + radius * 0.87, y + radius / 2],
|
||||
[x - radius * 0.87, y + radius / 2],
|
||||
] as const
|
||||
doc.triangle(points[0][0], points[0][1], points[1][0], points[1][1], points[2][0], points[2][1])
|
||||
doc.text(marker.revisionId || marker.label, x, y + 3, { align: 'center' })
|
||||
}
|
||||
|
||||
function drawRevisionCloudMarker(
|
||||
doc: FloorplanPdfDocument,
|
||||
marker: ResolvedDocumentMarker,
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
const points: [number, number][] | null =
|
||||
marker.points.length >= 3 ? marker.points.map((point) => [point.x * 72, point.y * 72]) : null
|
||||
if (points) {
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const current = points[index]!
|
||||
const next = points[(index + 1) % points.length]!
|
||||
const [x1, y1] = current
|
||||
const [x2, y2] = next
|
||||
doc.line(x1, y1, x2, y2)
|
||||
}
|
||||
} else {
|
||||
doc.roundedRect(x - 28, y - 16, 56, 32, 8, 8)
|
||||
}
|
||||
if (marker.revisionId) drawDeltaMarker(doc, marker, x, y)
|
||||
}
|
||||
|
||||
function drawKeyedNoteSymbols(doc: FloorplanPdfDocument, composition: SheetComposition): void {
|
||||
if (composition.keyedNoteInstances.length === 0) return
|
||||
doc.setDrawColor('#111827')
|
||||
doc.setTextColor('#111827')
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(7)
|
||||
for (const instance of composition.keyedNoteInstances) {
|
||||
const x = instance.x * 72
|
||||
const y = instance.y * 72
|
||||
doc.circle(x, y, 6)
|
||||
doc.text(instance.key, x, y + 2.4, { align: 'center' })
|
||||
}
|
||||
}
|
||||
|
||||
function drawSheetTitleBlock(
|
||||
doc: FloorplanPdfDocument,
|
||||
layout: SheetExportLayout,
|
||||
composition: SheetComposition,
|
||||
) {
|
||||
const title = layout.titleBlock
|
||||
const sheetNumberWidth = 86
|
||||
const drawingRefWidth = 72
|
||||
doc.setDrawColor('#cbd5e1')
|
||||
doc.line(
|
||||
title.x + title.width - sheetNumberWidth,
|
||||
title.y,
|
||||
title.x + title.width - sheetNumberWidth,
|
||||
title.y + title.height,
|
||||
)
|
||||
doc.line(
|
||||
title.x + title.width - sheetNumberWidth - drawingRefWidth,
|
||||
title.y,
|
||||
title.x + title.width - sheetNumberWidth - drawingRefWidth,
|
||||
title.y + title.height,
|
||||
)
|
||||
|
||||
doc.setTextColor('#111827')
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(12)
|
||||
doc.text(composition.viewTitle.toLocaleUpperCase(), title.x + 10, title.y + 16)
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(8)
|
||||
doc.text(`Scale: ${formatDrawingScaleLabel(composition.scale)}`, title.x + 10, title.y + 30)
|
||||
doc.text(
|
||||
`Drawing: ${composition.drawingNumber}`,
|
||||
title.x + title.width - sheetNumberWidth - drawingRefWidth + 10,
|
||||
title.y + 16,
|
||||
)
|
||||
doc.text(
|
||||
composition.drawingLabel,
|
||||
title.x + title.width - sheetNumberWidth - drawingRefWidth + 10,
|
||||
title.y + 30,
|
||||
)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(15)
|
||||
doc.text(composition.sheetNumber, title.x + title.width - sheetNumberWidth + 10, title.y + 25)
|
||||
doc.setFontSize(7)
|
||||
doc.text(
|
||||
composition.sheetTitle.toLocaleUpperCase(),
|
||||
title.x + title.width - sheetNumberWidth + 10,
|
||||
title.y + 36,
|
||||
{
|
||||
maxWidth: sheetNumberWidth - 20,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function drawNorthArrow(doc: FloorplanPdfDocument, x: number, y: number) {
|
||||
doc.setDrawColor('#111827')
|
||||
doc.setFillColor('#111827')
|
||||
doc.setLineWidth(0.6)
|
||||
doc.triangle(x, y - 24, x - 6, y - 5, x + 6, y - 5, 'F')
|
||||
doc.line(x, y - 5, x, y + 14)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(9)
|
||||
doc.text('N', x, y - 28, { align: 'center' })
|
||||
}
|
||||
|
||||
export function resolveGraphicScaleLength(
|
||||
scale: DrawingSheetScale,
|
||||
maxWidthPt: number,
|
||||
): { modelMeters: number; widthPt: number; label: string } {
|
||||
const pointsPerMeter = pointsPerMeterForDrawingScale(scale)
|
||||
const maxMeters = Math.max(0.1, maxWidthPt / pointsPerMeter)
|
||||
const candidates = [50, 20, 10, 5, 2, 1, 0.5, 0.25]
|
||||
const modelMeters = candidates.find((candidate) => candidate <= maxMeters) ?? 0.1
|
||||
return {
|
||||
modelMeters,
|
||||
widthPt: modelMeters * pointsPerMeter,
|
||||
label: `${modelMeters >= 1 ? modelMeters : modelMeters * 1000}${modelMeters >= 1 ? ' m' : ' mm'}`,
|
||||
}
|
||||
}
|
||||
|
||||
function drawGraphicScale(
|
||||
doc: FloorplanPdfDocument,
|
||||
x: number,
|
||||
y: number,
|
||||
options: { scale: DrawingSheetScale; maxWidth: number },
|
||||
) {
|
||||
const resolved = resolveGraphicScaleLength(options.scale, options.maxWidth)
|
||||
const half = resolved.widthPt / 2
|
||||
doc.setDrawColor('#111827')
|
||||
doc.setFillColor('#111827')
|
||||
doc.setLineWidth(0.6)
|
||||
doc.rect(x, y, half, 5, 'F')
|
||||
doc.rect(x + half, y, half, 5)
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(7)
|
||||
doc.text('0', x, y + 15, { align: 'center' })
|
||||
doc.text(resolved.label, x + resolved.widthPt, y + 15, { align: 'center' })
|
||||
doc.text(formatDrawingScaleLabel(options.scale), x + resolved.widthPt / 2, y - 4, {
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
function drawSheetSidePanel(
|
||||
doc: FloorplanPdfDocument,
|
||||
panel: SheetExportLayout['sidePanel'],
|
||||
composition: SheetComposition,
|
||||
schedules: readonly FloorplanSchedule[],
|
||||
): ScheduleDrawResult {
|
||||
let y = panel.y + 12
|
||||
const left = panel.x + 8
|
||||
const width = panel.width - 16
|
||||
const bottom = panel.y + panel.height - 8
|
||||
|
||||
y = drawSheetNotes(doc, 'GENERAL NOTES', composition.generalNotes, left, y, width, bottom)
|
||||
y = drawKeyedNoteLegend(doc, composition, left, y + 8, width, bottom)
|
||||
return drawInlineSchedules(doc, schedules, left, y + 8, width, bottom)
|
||||
}
|
||||
|
||||
function drawSheetNotes(
|
||||
doc: FloorplanPdfDocument,
|
||||
title: string,
|
||||
notes: readonly { number: number; text: string }[],
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
bottom: number,
|
||||
) {
|
||||
if (notes.length === 0) return y
|
||||
doc.setTextColor('#111827')
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(8)
|
||||
doc.text(title, x, y)
|
||||
y += 9
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(7)
|
||||
for (const note of notes) {
|
||||
const lines = doc.splitTextToSize(`${note.number}. ${note.text}`, width)
|
||||
if (y + lines.length * 8 > bottom) break
|
||||
doc.text(lines, x, y)
|
||||
y += lines.length * 8 + 3
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
function drawKeyedNoteLegend(
|
||||
doc: FloorplanPdfDocument,
|
||||
composition: SheetComposition,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
bottom: number,
|
||||
) {
|
||||
if (composition.keyedNoteLegend.length === 0) return y
|
||||
doc.setTextColor('#111827')
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(8)
|
||||
doc.text('KEYED NOTES', x, y)
|
||||
y += 9
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(7)
|
||||
for (const note of composition.keyedNoteLegend) {
|
||||
const lines = doc.splitTextToSize(`${note.key}. ${note.text}`, width)
|
||||
if (y + lines.length * 8 > bottom) break
|
||||
doc.text(lines, x, y)
|
||||
y += lines.length * 8 + 3
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
function drawInlineSchedules(
|
||||
doc: FloorplanPdfDocument,
|
||||
schedules: readonly FloorplanSchedule[],
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
bottom: number,
|
||||
): ScheduleDrawResult {
|
||||
const overflowSchedules: FloorplanSchedule[] = []
|
||||
let drawnSchedules = 0
|
||||
for (const schedule of schedules) {
|
||||
const rowHeight = 12
|
||||
const tableHeight = 18 + rowHeight * Math.min(schedule.rows.length, 6)
|
||||
if (y + tableHeight > bottom) {
|
||||
overflowSchedules.push(schedule)
|
||||
continue
|
||||
}
|
||||
drawnSchedules++
|
||||
doc.setTextColor('#111827')
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(8)
|
||||
doc.text(schedule.title.toLocaleUpperCase(), x, y)
|
||||
y += 10
|
||||
const widths = scheduleColumnWidths(schedule, width)
|
||||
doc.setFillColor('#334155')
|
||||
doc.rect(x, y, width, rowHeight, 'F')
|
||||
doc.setTextColor('#ffffff')
|
||||
doc.setFontSize(6)
|
||||
let colX = x
|
||||
schedule.columns.forEach((column, index) => {
|
||||
doc.text(column.label, colX + 2, y + 8, { maxWidth: Math.max(0, (widths[index] ?? 0) - 4) })
|
||||
colX += widths[index] ?? 0
|
||||
})
|
||||
y += rowHeight
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setTextColor('#111827')
|
||||
const inlineRows = schedule.rows.slice(0, 6)
|
||||
for (const row of inlineRows) {
|
||||
colX = x
|
||||
schedule.columns.forEach((column, index) => {
|
||||
const colWidth = widths[index] ?? 0
|
||||
doc.text(
|
||||
truncatePdfText(doc, row.cells[column.key] ?? '', Math.max(0, colWidth - 4)),
|
||||
colX + 2,
|
||||
y + 8,
|
||||
)
|
||||
colX += colWidth
|
||||
})
|
||||
doc.setDrawColor('#cbd5e1')
|
||||
doc.rect(x, y, width, rowHeight)
|
||||
y += rowHeight
|
||||
}
|
||||
if (schedule.rows.length > inlineRows.length) {
|
||||
overflowSchedules.push({
|
||||
...schedule,
|
||||
title: `${schedule.title} Continued`,
|
||||
rows: schedule.rows.slice(inlineRows.length),
|
||||
})
|
||||
}
|
||||
y += 12
|
||||
}
|
||||
return { drawnSchedules, overflowSchedules }
|
||||
}
|
||||
|
||||
type MountedFloorplan = {
|
||||
svg: SVGSVGElement
|
||||
annotationLabelShifts: readonly FloorplanPoint[]
|
||||
@@ -1241,9 +546,10 @@ export function resolveFloorplanScreenUnitsPerPixel(
|
||||
}
|
||||
|
||||
export function resolveFloorplanExportAnnotationVisibility(
|
||||
mode: FloorplanMode,
|
||||
liveVisibility: FloorplanAnnotationVisibility,
|
||||
): FloorplanAnnotationVisibility {
|
||||
return { ...liveVisibility }
|
||||
return resolveFloorplanAnnotationVisibility(mode, liveVisibility, { target: 'export' })
|
||||
}
|
||||
|
||||
export function resolveFloorplanExportRotationDeg(
|
||||
@@ -1257,39 +563,6 @@ export function resolveFloorplanExportRotationDeg(
|
||||
return FLOORPLAN_VIEW_ROTATION_DEG + userRotationDeg - (buildingRotationY * 180) / Math.PI
|
||||
}
|
||||
|
||||
export function pointsPerMeterForDrawingScale(scale: DrawingSheetScale): number {
|
||||
if (scale.startsWith('1:')) {
|
||||
const denominator = Number.parseFloat(scale.slice(2))
|
||||
if (Number.isFinite(denominator) && denominator > 0) {
|
||||
return POINTS_PER_INCH / METERS_PER_INCH / denominator
|
||||
}
|
||||
}
|
||||
|
||||
const imperial = scale.match(/^(.+)"=1'-0"$/)
|
||||
if (imperial) {
|
||||
const paperInchesPerFoot = parseImperialPaperInches(imperial[1] ?? '')
|
||||
if (paperInchesPerFoot > 0) {
|
||||
return (paperInchesPerFoot / 12) * (POINTS_PER_INCH / METERS_PER_INCH)
|
||||
}
|
||||
}
|
||||
|
||||
return pointsPerMeterForDrawingScale('1/4"=1\'-0"')
|
||||
}
|
||||
|
||||
function parseImperialPaperInches(value: string): number {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed.includes('/')) {
|
||||
const [numerator, denominator] = trimmed.split('/').map((part) => Number.parseFloat(part))
|
||||
return numerator && denominator ? numerator / denominator : 0
|
||||
}
|
||||
const parsed = Number.parseFloat(trimmed)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function formatDrawingScaleLabel(scale: DrawingSheetScale): string {
|
||||
return scale.replace('=', ' = ')
|
||||
}
|
||||
|
||||
async function mountFloorplanSvg(
|
||||
parent: HTMLElement,
|
||||
geometries: ExportGeometry[],
|
||||
@@ -1456,6 +729,7 @@ function collectFloorplanGeometry(
|
||||
metricNotation: FloorplanMetricNotation,
|
||||
annotationVisibility: FloorplanAnnotationVisibility,
|
||||
drawingType: ConstructionDrawingType,
|
||||
wallDimensionReference: FloorplanWallDimensionReference,
|
||||
): ExportGeometry[] {
|
||||
const noLiveOverrides = new Map<string, LiveNodeOverrides>()
|
||||
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
|
||||
@@ -1521,7 +795,12 @@ function collectFloorplanGeometry(
|
||||
const baseContext = buildContext(
|
||||
node,
|
||||
nodes,
|
||||
resolveFloorplanExportViewState(unit, metricNotation),
|
||||
resolveFloorplanExportViewState(
|
||||
unit,
|
||||
metricNotation,
|
||||
wallDimensionReference,
|
||||
annotationVisibility.automaticDimensions,
|
||||
),
|
||||
levelData,
|
||||
)
|
||||
const ctx = parentOverride ? { ...baseContext, parent: parentOverride } : baseContext
|
||||
|
||||
@@ -34,4 +34,10 @@ describe('floor-plan context extensions', () => {
|
||||
expect(normalizeFloorplanWallDimensionReference('unknown')).toBe('finished-faces')
|
||||
expect(normalizeFloorplanWallDimensionReference(null)).toBe('finished-faces')
|
||||
})
|
||||
|
||||
test('lets presentation code suppress automatic annotation construction', () => {
|
||||
expect(readFloorplanContext(context()).automaticDimensions).toBe(true)
|
||||
const extensions = createFloorplanContextExtensions({ automaticDimensions: false })
|
||||
expect(readFloorplanContext(context(extensions)).automaticDimensions).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
AnyNode,
|
||||
AnyNodeId,
|
||||
ConstructionDrawingType,
|
||||
DrawingSheetNode,
|
||||
FloorplanGeometry,
|
||||
GeometryContext,
|
||||
NodeDefinition,
|
||||
@@ -16,10 +15,12 @@ export const FLOORPLAN_CONTEXT_EXTENSION_KEY = 'pascal:editor/floorplan'
|
||||
|
||||
export type FloorplanRenderPurpose = 'edit' | 'document'
|
||||
export type FloorplanMetricNotation = 'meters' | 'millimeters'
|
||||
export type FloorplanToolMode = 'default' | 'expert'
|
||||
export type FloorplanWallDimensionReference = 'finished-faces' | 'centerline' | 'stud-faces'
|
||||
export const DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE = 'finished-faces'
|
||||
export type FloorplanAnnotationRole =
|
||||
| 'automatic-dimension'
|
||||
| 'contextual-dimension'
|
||||
| 'manual-dimension'
|
||||
| 'measurement'
|
||||
| 'opening-mark'
|
||||
@@ -56,15 +57,13 @@ export type FloorplanToolContext = {
|
||||
|
||||
export type FloorplanNodeExtension<N extends AnyNode = AnyNode> = {
|
||||
tool?: () => Promise<{ default: ComponentType<FloorplanToolContext> }>
|
||||
availableModes?: readonly FloorplanToolMode[]
|
||||
preferredView?: '2d' | '3d'
|
||||
referencedSelectionAnnotationRole?: FloorplanAnnotationRole
|
||||
contextualDimensions?: (node: N, ctx: GeometryContext) => FloorplanGeometry | null
|
||||
actionMenu?: {
|
||||
canCurve?: (args: { node: N; nodes: Readonly<Record<AnyNodeId, AnyNode>> }) => boolean
|
||||
}
|
||||
resolveDrawingSheet?: (args: {
|
||||
node: N
|
||||
levelId: AnyNodeId
|
||||
drawingType: ConstructionDrawingType
|
||||
}) => DrawingSheetNode | null
|
||||
schedule?: (args: {
|
||||
siblings: ReadonlyArray<N>
|
||||
nodes: Readonly<Record<string, AnyNode>>
|
||||
@@ -82,9 +81,11 @@ export type FloorplanNodeExtension<N extends AnyNode = AnyNode> = {
|
||||
type FloorplanGeometryMetadata = {
|
||||
annotationRole?: FloorplanAnnotationRole
|
||||
annotationObstacle?: 'bounds' | 'outline'
|
||||
renderPass?: 'overlay'
|
||||
}
|
||||
|
||||
type FloorplanContextExtension = {
|
||||
automaticDimensions: boolean
|
||||
purpose: FloorplanRenderPurpose
|
||||
metricNotation: FloorplanMetricNotation
|
||||
wallDimensionReference: FloorplanWallDimensionReference
|
||||
@@ -137,6 +138,7 @@ export function createFloorplanContextExtensions(
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return {
|
||||
[FLOORPLAN_CONTEXT_EXTENSION_KEY]: {
|
||||
automaticDimensions: values.automaticDimensions !== false,
|
||||
purpose: values.purpose === 'document' ? 'document' : 'edit',
|
||||
metricNotation: values.metricNotation === 'millimeters' ? 'millimeters' : 'meters',
|
||||
wallDimensionReference: normalizeFloorplanWallDimensionReference(
|
||||
@@ -151,6 +153,7 @@ export function readFloorplanContext(ctx: GeometryContext): FloorplanContextExte
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const extension = value as Partial<FloorplanContextExtension>
|
||||
return {
|
||||
automaticDimensions: extension.automaticDimensions !== false,
|
||||
purpose: extension.purpose === 'document' ? 'document' : 'edit',
|
||||
metricNotation: extension.metricNotation === 'millimeters' ? 'millimeters' : 'meters',
|
||||
wallDimensionReference: normalizeFloorplanWallDimensionReference(
|
||||
@@ -159,6 +162,7 @@ export function readFloorplanContext(ctx: GeometryContext): FloorplanContextExte
|
||||
}
|
||||
}
|
||||
return {
|
||||
automaticDimensions: true,
|
||||
purpose: 'edit',
|
||||
metricNotation: 'meters',
|
||||
wallDimensionReference: DEFAULT_FLOORPLAN_WALL_DIMENSION_REFERENCE,
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY } from './annotation-visibility'
|
||||
import {
|
||||
isFloorplanToolAvailableInMode,
|
||||
normalizeFloorplanMode,
|
||||
normalizeFloorplanModesByProject,
|
||||
resolveFloorplanAnnotationVisibility,
|
||||
resolveFloorplanWallDimensionReference,
|
||||
} from './floorplan-mode'
|
||||
|
||||
describe('floor-plan mode', () => {
|
||||
test('defaults unknown and missing values to Default', () => {
|
||||
expect(normalizeFloorplanMode(undefined)).toBe('default')
|
||||
expect(normalizeFloorplanMode('legacy')).toBe('default')
|
||||
expect(normalizeFloorplanMode('expert')).toBe('expert')
|
||||
})
|
||||
|
||||
test('keeps only valid per-project preferences', () => {
|
||||
expect(
|
||||
normalizeFloorplanModesByProject({
|
||||
alpha: 'expert',
|
||||
beta: 'default',
|
||||
invalid: 'legacy',
|
||||
empty: null,
|
||||
}),
|
||||
).toEqual({ alpha: 'expert', beta: 'default' })
|
||||
})
|
||||
|
||||
test('limits tools only when their registry extension declares available modes', () => {
|
||||
expect(isFloorplanToolAvailableInMode(undefined, 'default')).toBe(true)
|
||||
expect(isFloorplanToolAvailableInMode(['expert'], 'default')).toBe(false)
|
||||
expect(isFloorplanToolAvailableInMode(['expert'], 'expert')).toBe(true)
|
||||
expect(isFloorplanToolAvailableInMode(['default', 'expert'], 'default')).toBe(true)
|
||||
})
|
||||
|
||||
test('preserves the existing annotation profile in Expert', () => {
|
||||
const expertVisibility = {
|
||||
...DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY,
|
||||
openingMarks: false,
|
||||
roomLabels: false,
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveFloorplanAnnotationVisibility('expert', expertVisibility, {
|
||||
selected: false,
|
||||
target: 'editor',
|
||||
}),
|
||||
).toBe(expertVisibility)
|
||||
})
|
||||
|
||||
test('keeps Default clean until an item is selected', () => {
|
||||
expect(
|
||||
resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
|
||||
selected: false,
|
||||
target: 'editor',
|
||||
}),
|
||||
).toEqual({
|
||||
automaticDimensions: false,
|
||||
contextualDimensions: false,
|
||||
manualDimensions: false,
|
||||
measurements: false,
|
||||
openingMarks: false,
|
||||
structuralGrids: false,
|
||||
roomLabels: true,
|
||||
stairAnnotations: false,
|
||||
})
|
||||
|
||||
expect(
|
||||
resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
|
||||
selected: true,
|
||||
target: 'editor',
|
||||
}),
|
||||
).toMatchObject({
|
||||
automaticDimensions: false,
|
||||
contextualDimensions: true,
|
||||
manualDimensions: true,
|
||||
measurements: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('reveals a measurement when a referenced object is selected', () => {
|
||||
expect(
|
||||
resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
|
||||
referencedAnnotationRole: 'measurement',
|
||||
target: 'editor',
|
||||
}).measurements,
|
||||
).toBe(true)
|
||||
expect(
|
||||
resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
|
||||
referencedAnnotationRole: 'manual-dimension',
|
||||
target: 'editor',
|
||||
}).manualDimensions,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('exports Default without selection chrome or technical annotations', () => {
|
||||
expect(
|
||||
resolveFloorplanAnnotationVisibility('default', DEFAULT_FLOORPLAN_ANNOTATION_VISIBILITY, {
|
||||
selected: true,
|
||||
target: 'export',
|
||||
}),
|
||||
).toEqual({
|
||||
automaticDimensions: false,
|
||||
contextualDimensions: false,
|
||||
manualDimensions: false,
|
||||
measurements: false,
|
||||
openingMarks: false,
|
||||
structuralGrids: false,
|
||||
roomLabels: true,
|
||||
stairAnnotations: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('uses centerline dimensions only in Default', () => {
|
||||
expect(resolveFloorplanWallDimensionReference('default', 'finished-faces')).toBe('centerline')
|
||||
expect(resolveFloorplanWallDimensionReference('expert', 'finished-faces')).toBe(
|
||||
'finished-faces',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
type FloorplanAnnotationVisibility,
|
||||
revealFloorplanAnnotationRole,
|
||||
} from './annotation-visibility'
|
||||
import type {
|
||||
FloorplanAnnotationRole,
|
||||
FloorplanToolMode,
|
||||
FloorplanWallDimensionReference,
|
||||
} from './floorplan-extension'
|
||||
|
||||
export const FLOORPLAN_MODES = ['default', 'expert'] as const
|
||||
|
||||
export type FloorplanMode = (typeof FLOORPLAN_MODES)[number]
|
||||
|
||||
export const DEFAULT_FLOORPLAN_MODE: FloorplanMode = 'default'
|
||||
|
||||
export type FloorplanPresentationContext = {
|
||||
referencedAnnotationRole?: FloorplanAnnotationRole
|
||||
selected?: boolean
|
||||
target: 'editor' | 'export'
|
||||
}
|
||||
|
||||
export function normalizeFloorplanMode(value: unknown): FloorplanMode {
|
||||
return value === 'expert' ? 'expert' : DEFAULT_FLOORPLAN_MODE
|
||||
}
|
||||
|
||||
export function normalizeFloorplanModesByProject(value: unknown): Record<string, FloorplanMode> {
|
||||
if (!value || typeof value !== 'object') return {}
|
||||
const modes: Record<string, FloorplanMode> = {}
|
||||
for (const [projectId, mode] of Object.entries(value)) {
|
||||
if (!projectId || (mode !== 'default' && mode !== 'expert')) continue
|
||||
modes[projectId] = mode
|
||||
}
|
||||
return modes
|
||||
}
|
||||
|
||||
export function isFloorplanToolAvailableInMode(
|
||||
availableModes: readonly FloorplanToolMode[] | undefined,
|
||||
mode: FloorplanMode,
|
||||
): boolean {
|
||||
return availableModes === undefined || availableModes.includes(mode)
|
||||
}
|
||||
|
||||
export function resolveFloorplanAnnotationVisibility(
|
||||
mode: FloorplanMode,
|
||||
expertVisibility: FloorplanAnnotationVisibility,
|
||||
context: FloorplanPresentationContext,
|
||||
): FloorplanAnnotationVisibility {
|
||||
if (mode === 'expert') return expertVisibility
|
||||
|
||||
const interactive = context.target === 'editor'
|
||||
const selected = interactive && context.selected === true
|
||||
const visibility: FloorplanAnnotationVisibility = {
|
||||
automaticDimensions: false,
|
||||
contextualDimensions: selected,
|
||||
manualDimensions: selected,
|
||||
measurements: selected,
|
||||
openingMarks: false,
|
||||
structuralGrids: false,
|
||||
roomLabels: true,
|
||||
stairAnnotations: false,
|
||||
}
|
||||
return interactive && context.referencedAnnotationRole
|
||||
? revealFloorplanAnnotationRole(visibility, context.referencedAnnotationRole)
|
||||
: visibility
|
||||
}
|
||||
|
||||
export function resolveFloorplanWallDimensionReference(
|
||||
mode: FloorplanMode,
|
||||
expertReference: FloorplanWallDimensionReference,
|
||||
): FloorplanWallDimensionReference {
|
||||
return mode === 'default' ? 'centerline' : expertReference
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import type {
|
||||
CeilingNode,
|
||||
ColumnNode,
|
||||
DoorNode,
|
||||
ElevatorNode,
|
||||
ItemNode,
|
||||
Point2D,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
SlabNode,
|
||||
StairNode,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
doesPolygonIntersectSelectionBounds,
|
||||
getDistanceToWallSegment,
|
||||
isPointInsidePolygon,
|
||||
isPointInsidePolygonWithHoles,
|
||||
} from './geometry'
|
||||
import type { FloorplanSelectionBounds } from './types'
|
||||
|
||||
type OpeningNode = WindowNode | DoorNode
|
||||
|
||||
type OpeningPolygonEntry = {
|
||||
opening: OpeningNode
|
||||
polygon: Point2D[]
|
||||
}
|
||||
|
||||
type ItemEntry = {
|
||||
item: ItemNode
|
||||
polygon: Point2D[]
|
||||
}
|
||||
|
||||
type StairEntry = {
|
||||
hitPolygons: Point2D[][]
|
||||
stair: StairNode
|
||||
segments: Array<{ polygon: Point2D[] }>
|
||||
}
|
||||
|
||||
type WallEntry = {
|
||||
wall: WallNode
|
||||
polygon: Point2D[]
|
||||
}
|
||||
|
||||
type SlabEntry = {
|
||||
slab: SlabNode
|
||||
polygon: Point2D[]
|
||||
holes: Point2D[][]
|
||||
}
|
||||
|
||||
type CeilingEntry = {
|
||||
ceiling: CeilingNode
|
||||
polygon: Point2D[]
|
||||
holes: Point2D[][]
|
||||
}
|
||||
|
||||
type ColumnEntry = {
|
||||
column: ColumnNode
|
||||
polygon: Point2D[]
|
||||
}
|
||||
|
||||
type ElevatorEntry = {
|
||||
elevator: ElevatorNode
|
||||
polygon: Point2D[]
|
||||
}
|
||||
|
||||
type RoofEntry = {
|
||||
roof: RoofNode
|
||||
segments: Array<{
|
||||
polygon: Point2D[]
|
||||
segment: RoofSegmentNode
|
||||
}>
|
||||
}
|
||||
|
||||
type FloorplanSelectionToolContext = {
|
||||
point: Point2D
|
||||
phase: 'site' | 'structure' | 'furnish'
|
||||
isItemContextActive: boolean
|
||||
items: ItemEntry[]
|
||||
openings: OpeningPolygonEntry[]
|
||||
stairs: StairEntry[]
|
||||
walls: WallEntry[]
|
||||
slabs: SlabEntry[]
|
||||
ceilings: CeilingEntry[]
|
||||
columns: ColumnEntry[]
|
||||
elevators: ElevatorEntry[]
|
||||
roofs: RoofEntry[]
|
||||
openingHitTolerance: number
|
||||
wallHitTolerance: number
|
||||
getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null
|
||||
}
|
||||
|
||||
function getItemHitId(context: FloorplanSelectionToolContext) {
|
||||
if (!context.isItemContextActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
const itemHit = context.items.find(({ polygon }) => isPointInsidePolygon(context.point, polygon))
|
||||
return itemHit?.item.id ?? null
|
||||
}
|
||||
|
||||
function getStairHitPolygons(stair: StairEntry) {
|
||||
return stair.hitPolygons.length > 0
|
||||
? stair.hitPolygons
|
||||
: stair.segments.map(({ polygon }) => polygon)
|
||||
}
|
||||
|
||||
export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
|
||||
if (context.phase === 'structure') {
|
||||
const openingHit = context.openings.find(({ polygon }) => {
|
||||
if (isPointInsidePolygon(context.point, polygon)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const centerLine = context.getOpeningCenterLine(polygon)
|
||||
if (!centerLine) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
getDistanceToWallSegment(
|
||||
context.point,
|
||||
[centerLine.start.x, centerLine.start.y],
|
||||
[centerLine.end.x, centerLine.end.y],
|
||||
) <= context.openingHitTolerance
|
||||
)
|
||||
})
|
||||
if (openingHit) {
|
||||
return openingHit.opening.id
|
||||
}
|
||||
|
||||
const stairHit = context.stairs.find((stair) =>
|
||||
getStairHitPolygons(stair).some((polygon) => isPointInsidePolygon(context.point, polygon)),
|
||||
)
|
||||
if (stairHit) {
|
||||
return stairHit.stair.id
|
||||
}
|
||||
|
||||
const elevatorHit = context.elevators.find(({ polygon }) =>
|
||||
isPointInsidePolygon(context.point, polygon),
|
||||
)
|
||||
if (elevatorHit) {
|
||||
return elevatorHit.elevator.id
|
||||
}
|
||||
|
||||
const columnHit = context.columns.find(({ polygon }) =>
|
||||
isPointInsidePolygon(context.point, polygon),
|
||||
)
|
||||
if (columnHit) {
|
||||
return columnHit.column.id
|
||||
}
|
||||
|
||||
const wallHit = context.walls.find(
|
||||
({ wall, polygon }) =>
|
||||
isPointInsidePolygon(context.point, polygon) ||
|
||||
getDistanceToWallSegment(context.point, wall.start, wall.end) <= context.wallHitTolerance,
|
||||
)
|
||||
if (wallHit) {
|
||||
return wallHit.wall.id
|
||||
}
|
||||
|
||||
const roofHit = context.roofs.find(({ segments }) =>
|
||||
segments.some(({ polygon }) => isPointInsidePolygon(context.point, polygon)),
|
||||
)
|
||||
if (roofHit) {
|
||||
return roofHit.roof.id
|
||||
}
|
||||
|
||||
const ceilingHit = context.ceilings.find(({ polygon, holes }) =>
|
||||
isPointInsidePolygonWithHoles(context.point, polygon, holes),
|
||||
)
|
||||
if (ceilingHit) {
|
||||
return ceilingHit.ceiling.id
|
||||
}
|
||||
|
||||
const slabHit = context.slabs.find(({ polygon, holes }) =>
|
||||
isPointInsidePolygonWithHoles(context.point, polygon, holes),
|
||||
)
|
||||
if (slabHit) {
|
||||
return slabHit.slab.id
|
||||
}
|
||||
}
|
||||
|
||||
return getItemHitId(context)
|
||||
}
|
||||
|
||||
type FloorplanSelectionBoundsContext = {
|
||||
bounds: FloorplanSelectionBounds
|
||||
phase: 'site' | 'structure' | 'furnish'
|
||||
isItemContextActive: boolean
|
||||
items: ItemEntry[]
|
||||
walls: WallEntry[]
|
||||
openings: OpeningPolygonEntry[]
|
||||
slabs: SlabEntry[]
|
||||
ceilings: CeilingEntry[]
|
||||
columns: ColumnEntry[]
|
||||
elevators: ElevatorEntry[]
|
||||
stairs: StairEntry[]
|
||||
roofs: RoofEntry[]
|
||||
}
|
||||
|
||||
export function getFloorplanSelectionIdsInBounds({
|
||||
bounds,
|
||||
phase,
|
||||
isItemContextActive,
|
||||
items,
|
||||
walls,
|
||||
openings,
|
||||
slabs,
|
||||
ceilings,
|
||||
columns,
|
||||
elevators,
|
||||
stairs,
|
||||
roofs,
|
||||
}: FloorplanSelectionBoundsContext) {
|
||||
const itemIds = isItemContextActive
|
||||
? items
|
||||
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
|
||||
.map(({ item }) => item.id)
|
||||
: []
|
||||
|
||||
if (phase !== 'structure') {
|
||||
return itemIds
|
||||
}
|
||||
|
||||
const wallIds = walls
|
||||
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
|
||||
.map(({ wall }) => wall.id)
|
||||
const openingIds = openings
|
||||
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
|
||||
.map(({ opening }) => opening.id)
|
||||
const slabIds = slabs
|
||||
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
|
||||
.map(({ slab }) => slab.id)
|
||||
const ceilingIds = ceilings
|
||||
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
|
||||
.map(({ ceiling }) => ceiling.id)
|
||||
const columnIds = columns
|
||||
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
|
||||
.map(({ column }) => column.id)
|
||||
const elevatorIds = elevators
|
||||
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
|
||||
.map(({ elevator }) => elevator.id)
|
||||
const stairIds = stairs
|
||||
.filter((stair) =>
|
||||
getStairHitPolygons(stair).some((polygon) =>
|
||||
doesPolygonIntersectSelectionBounds(polygon, bounds),
|
||||
),
|
||||
)
|
||||
.map(({ stair }) => stair.id)
|
||||
const roofIds = roofs
|
||||
.filter(({ segments }) =>
|
||||
segments.some(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)),
|
||||
)
|
||||
.map(({ roof }) => roof.id)
|
||||
|
||||
return Array.from(
|
||||
new Set([
|
||||
...itemIds,
|
||||
...wallIds,
|
||||
...openingIds,
|
||||
...slabIds,
|
||||
...ceilingIds,
|
||||
...columnIds,
|
||||
...elevatorIds,
|
||||
...stairIds,
|
||||
...roofIds,
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,10 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { normalizeAnnotationLayoutOverrides, normalizeDrawingType } from './use-drawing-view'
|
||||
import useDrawingView, { normalizeAnnotationLayoutOverrides } from './use-drawing-view'
|
||||
|
||||
describe('normalizeDrawingType', () => {
|
||||
test('restores every persistent construction drawing type', () => {
|
||||
expect(normalizeDrawingType('floor-plan')).toBe('floor-plan')
|
||||
expect(normalizeDrawingType('foundation-plan')).toBe('foundation-plan')
|
||||
expect(normalizeDrawingType('reflected-ceiling-plan')).toBe('reflected-ceiling-plan')
|
||||
expect(normalizeDrawingType('roof-plan')).toBe('roof-plan')
|
||||
expect(normalizeDrawingType('site-plan')).toBe('site-plan')
|
||||
})
|
||||
|
||||
test('falls back to the floor plan for stale persisted values', () => {
|
||||
expect(normalizeDrawingType('unknown')).toBe('floor-plan')
|
||||
expect(normalizeDrawingType(null)).toBe('floor-plan')
|
||||
describe('drawing type', () => {
|
||||
test('keeps the workspace on the floor plan', () => {
|
||||
expect(useDrawingView.getState().drawingType).toBe('floor-plan')
|
||||
expect('setDrawingType' in useDrawingView.getState()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
CONSTRUCTION_DRAWING_TYPES,
|
||||
type ConstructionDrawingType,
|
||||
type DrawingSheetScale,
|
||||
} from '@pascal-app/core'
|
||||
import type { ConstructionDrawingType } from '@pascal-app/core'
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
@@ -16,18 +12,6 @@ export const DRAWING_TYPE_OPTIONS = [
|
||||
{ id: 'site-plan', label: 'Site plan' },
|
||||
] as const satisfies readonly { id: ConstructionDrawingType; label: string }[]
|
||||
|
||||
export const DRAWING_SCALE_OPTIONS = [
|
||||
{ id: '1:20', label: '1:20' },
|
||||
{ id: '1:25', label: '1:25' },
|
||||
{ id: '1:50', label: '1:50' },
|
||||
{ id: '1:75', label: '1:75' },
|
||||
{ id: '1:100', label: '1:100' },
|
||||
{ id: '1/8"=1\'-0"', label: '1/8" = 1\'-0"' },
|
||||
{ id: '1/4"=1\'-0"', label: '1/4" = 1\'-0"' },
|
||||
{ id: '1/2"=1\'-0"', label: '1/2" = 1\'-0"' },
|
||||
{ id: '1"=1\'-0"', label: '1" = 1\'-0"' },
|
||||
] as const satisfies readonly { id: DrawingSheetScale; label: string }[]
|
||||
|
||||
export type DrawingAnnotationLayoutOverride = {
|
||||
dx: number
|
||||
dy: number
|
||||
@@ -37,33 +21,14 @@ export type DrawingAnnotationLayoutOverride = {
|
||||
export type DrawingAnnotationLayoutOverrides = Record<string, DrawingAnnotationLayoutOverride>
|
||||
|
||||
type DrawingViewState = {
|
||||
drawingType: ConstructionDrawingType
|
||||
drawingScale: DrawingSheetScale
|
||||
drawingType: Extract<ConstructionDrawingType, 'floor-plan'>
|
||||
annotationLayoutOverrides: DrawingAnnotationLayoutOverrides
|
||||
setDrawingType: (drawingType: ConstructionDrawingType) => void
|
||||
setDrawingScale: (drawingScale: DrawingSheetScale) => void
|
||||
setAnnotationLayoutOverride: (
|
||||
id: string,
|
||||
override: DrawingAnnotationLayoutOverride | null,
|
||||
) => void
|
||||
}
|
||||
|
||||
export function normalizeDrawingType(value: unknown): ConstructionDrawingType {
|
||||
if (typeof value !== 'string') return 'floor-plan'
|
||||
for (const drawingType of CONSTRUCTION_DRAWING_TYPES) {
|
||||
if (drawingType === value) return drawingType
|
||||
}
|
||||
return 'floor-plan'
|
||||
}
|
||||
|
||||
export function normalizeDrawingScale(value: unknown): DrawingSheetScale {
|
||||
if (typeof value !== 'string') return '1/4"=1\'-0"'
|
||||
for (const option of DRAWING_SCALE_OPTIONS) {
|
||||
if (option.id === value) return option.id
|
||||
}
|
||||
return '1/4"=1\'-0"'
|
||||
}
|
||||
|
||||
export function normalizeAnnotationLayoutOverrides(
|
||||
value: unknown,
|
||||
): DrawingAnnotationLayoutOverrides {
|
||||
@@ -91,10 +56,7 @@ const useDrawingView = create<DrawingViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
drawingType: 'floor-plan',
|
||||
drawingScale: '1/4"=1\'-0"',
|
||||
annotationLayoutOverrides: {},
|
||||
setDrawingType: (drawingType) => set({ drawingType }),
|
||||
setDrawingScale: (drawingScale) => set({ drawingScale }),
|
||||
setAnnotationLayoutOverride: (id, override) =>
|
||||
set((state) => {
|
||||
const next = { ...state.annotationLayoutOverrides }
|
||||
@@ -107,20 +69,12 @@ const useDrawingView = create<DrawingViewState>()(
|
||||
name: 'pascal-floorplan-drawing-view',
|
||||
merge: (persistedState, currentState) => ({
|
||||
...currentState,
|
||||
drawingType: normalizeDrawingType(
|
||||
(persistedState as { drawingType?: unknown } | undefined)?.drawingType,
|
||||
),
|
||||
drawingScale: normalizeDrawingScale(
|
||||
(persistedState as { drawingScale?: unknown } | undefined)?.drawingScale,
|
||||
),
|
||||
annotationLayoutOverrides: normalizeAnnotationLayoutOverrides(
|
||||
(persistedState as { annotationLayoutOverrides?: unknown } | undefined)
|
||||
?.annotationLayoutOverrides,
|
||||
),
|
||||
}),
|
||||
partialize: (state) => ({
|
||||
drawingType: state.drawingType,
|
||||
drawingScale: state.drawingScale,
|
||||
annotationLayoutOverrides: state.annotationLayoutOverrides,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import {
|
||||
DEFAULT_FLOORPLAN_MODE,
|
||||
type FloorplanMode,
|
||||
normalizeFloorplanModesByProject,
|
||||
} from '../lib/floorplan/floorplan-mode'
|
||||
|
||||
export type FloorplanModeNotice = {
|
||||
id: number
|
||||
kind: 'info' | 'switch-to-expert'
|
||||
message: string
|
||||
}
|
||||
|
||||
type FloorplanModeState = {
|
||||
mode: FloorplanMode
|
||||
projectId: string | null
|
||||
modesByProject: Record<string, FloorplanMode>
|
||||
hasShownDefaultReassurance: boolean
|
||||
notice: FloorplanModeNotice | null
|
||||
dismissNotice: () => void
|
||||
setMode: (mode: FloorplanMode) => void
|
||||
setProjectId: (projectId: string | null) => void
|
||||
showExpertModeNotice: (toolLabel: string) => void
|
||||
showNotice: (message: string) => void
|
||||
}
|
||||
|
||||
let nextNoticeId = 0
|
||||
|
||||
const useFloorplanMode = create<FloorplanModeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
mode: DEFAULT_FLOORPLAN_MODE,
|
||||
projectId: null,
|
||||
modesByProject: {},
|
||||
hasShownDefaultReassurance: false,
|
||||
notice: null,
|
||||
dismissNotice: () => set({ notice: null }),
|
||||
setMode: (mode) =>
|
||||
set((state) => {
|
||||
const shouldReassure =
|
||||
state.mode === 'expert' && mode === 'default' && !state.hasShownDefaultReassurance
|
||||
return {
|
||||
mode,
|
||||
modesByProject: state.projectId
|
||||
? { ...state.modesByProject, [state.projectId]: mode }
|
||||
: state.modesByProject,
|
||||
hasShownDefaultReassurance: state.hasShownDefaultReassurance || shouldReassure,
|
||||
notice: shouldReassure
|
||||
? {
|
||||
id: ++nextNoticeId,
|
||||
kind: 'info',
|
||||
message:
|
||||
'Default mode hides Expert annotations. Your saved work was not deleted.',
|
||||
}
|
||||
: state.notice,
|
||||
}
|
||||
}),
|
||||
setProjectId: (projectId) =>
|
||||
set((state) => ({
|
||||
projectId,
|
||||
mode: projectId
|
||||
? (state.modesByProject[projectId] ?? DEFAULT_FLOORPLAN_MODE)
|
||||
: DEFAULT_FLOORPLAN_MODE,
|
||||
notice: null,
|
||||
})),
|
||||
showExpertModeNotice: (toolLabel) =>
|
||||
set({
|
||||
notice: {
|
||||
id: ++nextNoticeId,
|
||||
kind: 'switch-to-expert',
|
||||
message: `${toolLabel} is available in Expert mode.`,
|
||||
},
|
||||
}),
|
||||
showNotice: (message) =>
|
||||
set({
|
||||
notice: {
|
||||
id: ++nextNoticeId,
|
||||
kind: 'info',
|
||||
message,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'pascal-floorplan-mode-by-project',
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as
|
||||
| { hasShownDefaultReassurance?: unknown; modesByProject?: unknown }
|
||||
| undefined
|
||||
const modesByProject = normalizeFloorplanModesByProject(persisted?.modesByProject)
|
||||
return {
|
||||
...currentState,
|
||||
modesByProject,
|
||||
hasShownDefaultReassurance: persisted?.hasShownDefaultReassurance === true,
|
||||
mode: currentState.projectId
|
||||
? (modesByProject[currentState.projectId] ?? DEFAULT_FLOORPLAN_MODE)
|
||||
: DEFAULT_FLOORPLAN_MODE,
|
||||
}
|
||||
},
|
||||
partialize: (state) => ({
|
||||
hasShownDefaultReassurance: state.hasShownDefaultReassurance,
|
||||
modesByProject: state.modesByProject,
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export default useFloorplanMode
|
||||
@@ -1,49 +0,0 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import useFloorplanPreflight, { type FloorplanPreflightIssue } from './use-floorplan-preflight'
|
||||
|
||||
const COLLISION_ISSUE: FloorplanPreflightIssue = {
|
||||
id: 'dimension-1',
|
||||
kind: 'unresolved-collision',
|
||||
severity: 'warning',
|
||||
message: 'The same collision remains unresolved.',
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useFloorplanPreflight.getState().setIssues([])
|
||||
useFloorplanPreflight.getState().setAuditIssues([])
|
||||
})
|
||||
|
||||
describe('useFloorplanPreflight', () => {
|
||||
test('does not notify subscribers when layout issues are unchanged', () => {
|
||||
const state = useFloorplanPreflight.getState()
|
||||
state.setIssues([COLLISION_ISSUE])
|
||||
let notifications = 0
|
||||
const unsubscribe = useFloorplanPreflight.subscribe(() => {
|
||||
notifications += 1
|
||||
})
|
||||
|
||||
useFloorplanPreflight.getState().setIssues([{ ...COLLISION_ISSUE }])
|
||||
|
||||
unsubscribe()
|
||||
expect(notifications).toBe(0)
|
||||
})
|
||||
|
||||
test('still publishes changed layout issues alongside audit issues', () => {
|
||||
const state = useFloorplanPreflight.getState()
|
||||
state.setAuditIssues([
|
||||
{
|
||||
id: 'audit-1',
|
||||
kind: 'dimension-completeness',
|
||||
severity: 'info',
|
||||
message: 'Audit issue',
|
||||
},
|
||||
])
|
||||
|
||||
useFloorplanPreflight.getState().setIssues([COLLISION_ISSUE])
|
||||
|
||||
expect(useFloorplanPreflight.getState().issues).toEqual([
|
||||
COLLISION_ISSUE,
|
||||
expect.objectContaining({ id: 'audit-1' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type FloorplanPreflightIssueKind =
|
||||
| 'unresolved-collision'
|
||||
| 'short-unreadable-segment'
|
||||
| 'plan-geometry-conflict'
|
||||
| 'dimension-completeness'
|
||||
| 'clearance-advisory'
|
||||
| 'module-advisory'
|
||||
| 'sheet-content'
|
||||
|
||||
export type FloorplanPreflightIssue = {
|
||||
id: string
|
||||
kind: FloorplanPreflightIssueKind
|
||||
severity: 'info' | 'warning'
|
||||
message: string
|
||||
}
|
||||
|
||||
function preflightIssuesEqual(
|
||||
left: readonly FloorplanPreflightIssue[],
|
||||
right: readonly FloorplanPreflightIssue[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((issue, index) => {
|
||||
const candidate = right[index]
|
||||
return (
|
||||
candidate !== undefined &&
|
||||
issue.id === candidate.id &&
|
||||
issue.kind === candidate.kind &&
|
||||
issue.severity === candidate.severity &&
|
||||
issue.message === candidate.message
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
type FloorplanPreflightState = {
|
||||
issues: FloorplanPreflightIssue[]
|
||||
layoutIssues: FloorplanPreflightIssue[]
|
||||
auditIssues: FloorplanPreflightIssue[]
|
||||
clearanceChecksEnabled: boolean
|
||||
moduleChecksEnabled: boolean
|
||||
setIssues: (issues: readonly FloorplanPreflightIssue[]) => void
|
||||
setAuditIssues: (issues: readonly FloorplanPreflightIssue[]) => void
|
||||
setClearanceChecksEnabled: (enabled: boolean) => void
|
||||
setModuleChecksEnabled: (enabled: boolean) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useFloorplanPreflight = create<FloorplanPreflightState>((set) => ({
|
||||
issues: [],
|
||||
layoutIssues: [],
|
||||
auditIssues: [],
|
||||
clearanceChecksEnabled: false,
|
||||
moduleChecksEnabled: false,
|
||||
setIssues: (issues) =>
|
||||
set((state) =>
|
||||
preflightIssuesEqual(state.layoutIssues, issues)
|
||||
? state
|
||||
: { layoutIssues: [...issues], issues: [...issues, ...state.auditIssues] },
|
||||
),
|
||||
setAuditIssues: (issues) =>
|
||||
set((state) =>
|
||||
preflightIssuesEqual(state.auditIssues, issues)
|
||||
? state
|
||||
: { auditIssues: [...issues], issues: [...state.layoutIssues, ...issues] },
|
||||
),
|
||||
setClearanceChecksEnabled: (clearanceChecksEnabled) => set({ clearanceChecksEnabled }),
|
||||
setModuleChecksEnabled: (moduleChecksEnabled) => set({ moduleChecksEnabled }),
|
||||
reset: () =>
|
||||
set((state) =>
|
||||
state.layoutIssues.length === 0
|
||||
? state
|
||||
: { layoutIssues: [], issues: [...state.auditIssues] },
|
||||
),
|
||||
}))
|
||||
|
||||
export default useFloorplanPreflight
|
||||
@@ -2,15 +2,15 @@ import { describe, expect, test } from 'bun:test'
|
||||
import { buildingDefinition } from './definition'
|
||||
|
||||
describe('buildingDefinition', () => {
|
||||
test('tracks drawing-sheet child support in the schema version', () => {
|
||||
test('accepts level and elevator children', () => {
|
||||
expect(buildingDefinition.kind).toBe('building')
|
||||
expect(buildingDefinition.schemaVersion).toBe(2)
|
||||
expect(buildingDefinition.schemaVersion).toBe(3)
|
||||
expect(
|
||||
buildingDefinition.schema.safeParse({
|
||||
id: 'building_default',
|
||||
type: 'building',
|
||||
...buildingDefinition.defaults(),
|
||||
children: ['level_main', 'drawing-sheet_a101'],
|
||||
children: ['level_main', 'elevator_main'],
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import { BuildingNode } from './schema'
|
||||
*/
|
||||
export const buildingDefinition: NodeDefinition<typeof BuildingNode> = {
|
||||
kind: 'building',
|
||||
schemaVersion: 2,
|
||||
schemaVersion: 3,
|
||||
schema: BuildingNode,
|
||||
category: 'site',
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
type AnyNodeId,
|
||||
type ColumnNode,
|
||||
type FloorplanAffordance,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { isAngleSnapActive } from '@pascal-app/editor'
|
||||
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||
|
||||
// Floor minimums — mirror the 3D handles in `column/definition.ts` so a
|
||||
@@ -59,9 +61,10 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
|
||||
|
||||
let lastPatch: Partial<ColumnNode> = {}
|
||||
|
||||
const commitPatch = (patch: Partial<ColumnNode>) => {
|
||||
const previewPatch = (patch: Partial<ColumnNode>) => {
|
||||
lastPatch = patch
|
||||
useScene.getState().updateNode(columnId, patch)
|
||||
useLiveNodeOverrides.getState().set(columnId, patch)
|
||||
useScene.getState().markDirty(columnId)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -71,37 +74,37 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
|
||||
const projDelta = currentProj - initialProj
|
||||
switch (dim) {
|
||||
case 'width':
|
||||
commitPatch({
|
||||
previewPatch({
|
||||
width: Math.max(MIN_COLUMN_WIDTH, initialWidth + 2 * projDelta),
|
||||
})
|
||||
return
|
||||
case 'depth':
|
||||
commitPatch({
|
||||
previewPatch({
|
||||
depth: Math.max(MIN_COLUMN_DEPTH, initialDepth + 2 * projDelta),
|
||||
})
|
||||
return
|
||||
case 'uniform': {
|
||||
const next = Math.max(MIN_COLUMN_WIDTH, initialWidth + 2 * projDelta)
|
||||
commitPatch({ width: next, depth: next })
|
||||
previewPatch({ width: next, depth: next })
|
||||
return
|
||||
}
|
||||
case 'radius':
|
||||
commitPatch({
|
||||
previewPatch({
|
||||
radius: Math.max(MIN_COLUMN_RADIUS, initialRadius + projDelta),
|
||||
})
|
||||
return
|
||||
case 'brace-width':
|
||||
commitPatch({
|
||||
previewPatch({
|
||||
braceWidth: Math.max(MIN_BRACE_DIMENSION, initialBraceWidth + 2 * projDelta),
|
||||
})
|
||||
return
|
||||
case 'brace-depth':
|
||||
commitPatch({
|
||||
previewPatch({
|
||||
braceDepth: Math.max(MIN_BRACE_DIMENSION, initialBraceDepth + 2 * projDelta),
|
||||
})
|
||||
return
|
||||
case 'brace-bottom-spread':
|
||||
commitPatch({
|
||||
previewPatch({
|
||||
braceBottomSpread: Math.max(
|
||||
MIN_BRACE_BOTTOM_SPREAD,
|
||||
initialBraceBottomSpread + 2 * projDelta,
|
||||
@@ -109,7 +112,7 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
|
||||
})
|
||||
return
|
||||
case 'brace-top-spread':
|
||||
commitPatch({
|
||||
previewPatch({
|
||||
braceTopSpread: Math.max(MIN_BRACE_TOP_SPREAD, initialBraceTopSpread + 2 * projDelta),
|
||||
})
|
||||
return
|
||||
@@ -120,6 +123,7 @@ export const columnResizeAffordance: FloorplanAffordance<ColumnNode> = {
|
||||
},
|
||||
commit() {
|
||||
if (Object.keys(lastPatch).length > 0) {
|
||||
useLiveNodeOverrides.getState().clear(columnId)
|
||||
useScene.getState().updateNode(columnId, lastPatch)
|
||||
}
|
||||
},
|
||||
@@ -147,21 +151,23 @@ export const columnRotateAffordance: FloorplanAffordance<ColumnNode> = {
|
||||
|
||||
return {
|
||||
affectedIds: [columnId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
apply({ planPoint }) {
|
||||
const delta = rotateAffordanceDelta({
|
||||
center: [cx, cz],
|
||||
initialAngle,
|
||||
planPoint,
|
||||
free: modifiers.shiftKey,
|
||||
free: !isAngleSnapActive(),
|
||||
})
|
||||
const newRotation = initialRotation - delta
|
||||
lastRotation = newRotation
|
||||
useScene.getState().updateNode(columnId, { rotation: newRotation })
|
||||
useLiveNodeOverrides.getState().set(columnId, { rotation: newRotation })
|
||||
useScene.getState().markDirty(columnId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(columnId)
|
||||
useScene.getState().updateNode(columnId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export const constructionDimensionDefinition: NodeDefinition<typeof Construction
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
tool: () => import('./floorplan-tool'),
|
||||
availableModes: ['expert'],
|
||||
resolveForDrawing: resolveConstructionDimensionForDrawing,
|
||||
} satisfies FloorplanNodeExtension<ConstructionDimensionNode>,
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
normalizeConstructionDimensionChainMode,
|
||||
normalizeConstructionDimensionMode,
|
||||
resolveConstructionDimensionDraftDirection,
|
||||
shouldConsumeConstructionDimensionPointerEvent,
|
||||
} from './floorplan-tool'
|
||||
|
||||
describe('continuous construction-dimension drafting', () => {
|
||||
@@ -21,6 +22,41 @@ describe('continuous construction-dimension drafting', () => {
|
||||
expect(resolveConstructionDimensionDraftDirection([[1, 0, 2]])).toBeNull()
|
||||
})
|
||||
|
||||
test('keeps an endpoint-to-face dimension aligned with the same straight wall', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_diagonal',
|
||||
start: [0, 0],
|
||||
end: [4, 4],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const direction = resolveConstructionDimensionDraftDirection(
|
||||
[
|
||||
[4, 0, 4],
|
||||
[0.9292893219, 0, 1.0707106781],
|
||||
],
|
||||
[
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: wall.id, featureId: 'wall:end' },
|
||||
fallback: [4, 0, 4],
|
||||
},
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: {
|
||||
nodeId: wall.id,
|
||||
featureId: 'wall:face:left',
|
||||
parameters: { t: 0.25 },
|
||||
},
|
||||
fallback: [0.9292893219, 0, 1.0707106781],
|
||||
},
|
||||
],
|
||||
{ [wall.id]: wall },
|
||||
)
|
||||
|
||||
expect(direction?.[0]).toBeCloseTo(-Math.SQRT1_2)
|
||||
expect(direction?.[1]).toBeCloseTo(-Math.SQRT1_2)
|
||||
})
|
||||
|
||||
test('previews one adjacent dimension for every witness interval', () => {
|
||||
const geometry = buildConstructionDimensionPreviewGeometries(
|
||||
[
|
||||
@@ -61,7 +97,7 @@ describe('continuous construction-dimension drafting', () => {
|
||||
]
|
||||
expect(
|
||||
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'radius')[0],
|
||||
).toMatchObject({ text: 'R 2m' })
|
||||
).toMatchObject({ text: 'R 1m' })
|
||||
expect(
|
||||
buildConstructionDimensionPreviewGeometries(points, [0, 0, 1], 'metric', 'diameter')[0],
|
||||
).toMatchObject({ text: 'Ø 2m' })
|
||||
@@ -123,14 +159,14 @@ describe('continuous construction-dimension drafting', () => {
|
||||
|
||||
test('only requests a label baseline for modes that use one', () => {
|
||||
expect(constructionDimensionUsesBaseline('linear')).toBe(true)
|
||||
expect(constructionDimensionUsesBaseline('radius')).toBe(true)
|
||||
expect(constructionDimensionUsesBaseline('radius')).toBe(false)
|
||||
expect(constructionDimensionUsesBaseline('angular')).toBe(true)
|
||||
expect(constructionDimensionUsesBaseline('diameter')).toBe(false)
|
||||
expect(constructionDimensionUsesBaseline('center-mark')).toBe(false)
|
||||
expect(constructionDimensionUsesBaseline('coordinate')).toBe(false)
|
||||
})
|
||||
|
||||
test('derives associative radius, chord, and center drafts from one curved wall', () => {
|
||||
test('keeps radius manual while deriving chord and center drafts from one curved wall', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_curve',
|
||||
start: [0, 0],
|
||||
@@ -138,16 +174,7 @@ describe('continuous construction-dimension drafting', () => {
|
||||
curveOffset: 1,
|
||||
})
|
||||
|
||||
expect(buildCurvedWallConstructionDimensionDraft(wall, 'radius')).toMatchObject({
|
||||
anchors: [
|
||||
{ reference: { nodeId: wall.id, featureId: 'wall:curve:center' } },
|
||||
{ reference: { nodeId: wall.id, featureId: 'wall:midpoint' } },
|
||||
],
|
||||
points: [
|
||||
[2, 0, 1.5],
|
||||
[2, 0, -1],
|
||||
],
|
||||
})
|
||||
expect(buildCurvedWallConstructionDimensionDraft(wall, 'radius')).toBeNull()
|
||||
expect(buildCurvedWallConstructionDimensionDraft(wall, 'chord')?.anchors).toMatchObject([
|
||||
{ reference: { featureId: 'wall:start' } },
|
||||
{ reference: { featureId: 'wall:end' } },
|
||||
@@ -177,4 +204,14 @@ describe('continuous construction-dimension drafting', () => {
|
||||
expect(buildCurvedWallConstructionDimensionDraft(curved, 'diameter')).toBeNull()
|
||||
expect(buildCurvedWallConstructionDimensionDraft(curved, 'linear')).toBeNull()
|
||||
})
|
||||
|
||||
test('leaves middle-button drag moves available for floor-plan panning', () => {
|
||||
expect(
|
||||
shouldConsumeConstructionDimensionPointerEvent({
|
||||
type: 'pointermove',
|
||||
button: -1,
|
||||
buttons: 4,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,7 +35,10 @@ import {
|
||||
useInteractionScope,
|
||||
} from '@pascal-app/editor'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { resolveCircularConstructionDimensionLayout } from './geometry'
|
||||
import {
|
||||
alignConstructionDimensionDirectionToSharedWall,
|
||||
resolveCircularConstructionDimensionLayout,
|
||||
} from './geometry'
|
||||
|
||||
const SEMANTIC_SNAP_DISTANCE = 0.2
|
||||
const SEMANTIC_BYPASS_DISTANCE = 0.012
|
||||
@@ -123,12 +126,18 @@ function registryTargetNodeId(target: EventTarget | null): string | null {
|
||||
|
||||
export function resolveConstructionDimensionDraftDirection(
|
||||
points: readonly MeasurementPoint[],
|
||||
anchors?: readonly MeasurementAnchor[],
|
||||
nodes?: Readonly<Record<AnyNodeId, AnyNode>>,
|
||||
): [number, number] | null {
|
||||
if (points.length < 2) return null
|
||||
const dx = points[1]![0] - points[0]![0]
|
||||
const dz = points[1]![2] - points[0]![2]
|
||||
const magnitude = Math.hypot(dx, dz)
|
||||
return magnitude <= MIN_DIMENSION_LENGTH ? null : [dx / magnitude, dz / magnitude]
|
||||
if (magnitude <= MIN_DIMENSION_LENGTH) return null
|
||||
const measuredDirection: [number, number] = [dx / magnitude, dz / magnitude]
|
||||
return anchors && nodes
|
||||
? alignConstructionDimensionDirectionToSharedWall(measuredDirection, anchors, (id) => nodes[id])
|
||||
: measuredDirection
|
||||
}
|
||||
|
||||
export function buildConstructionDimensionPreviewGeometries(
|
||||
@@ -137,6 +146,7 @@ export function buildConstructionDimensionPreviewGeometries(
|
||||
unit: 'metric' | 'imperial',
|
||||
mode: ConstructionDimensionMode = 'linear',
|
||||
metricNotation: 'meters' | 'millimeters' = 'meters',
|
||||
directionOverride?: readonly [number, number] | null,
|
||||
): FloorplanGeometry[] {
|
||||
if (mode === 'arc-length' || mode === 'angular') {
|
||||
const layout = resolveCircularConstructionDimensionLayout(mode, points)
|
||||
@@ -278,7 +288,7 @@ export function buildConstructionDimensionPreviewGeometries(
|
||||
]
|
||||
}
|
||||
if (!['linear', 'chord', 'radius', 'diameter'].includes(mode)) return []
|
||||
const direction = resolveConstructionDimensionDraftDirection(points)
|
||||
const direction = directionOverride ?? resolveConstructionDimensionDraftDirection(points)
|
||||
if (!direction) return []
|
||||
const normal: [number, number] = [-direction[1], direction[0]]
|
||||
const project = (point: MeasurementPoint): [number, number] => {
|
||||
@@ -292,7 +302,11 @@ export function buildConstructionDimensionPreviewGeometries(
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[2] - start[2]
|
||||
const value = Math.abs(dx * direction[0] + dz * direction[1])
|
||||
const rawText = formatLinearMeasurement(value, unit, metricNotation)
|
||||
const rawText = formatLinearMeasurement(
|
||||
mode === 'radius' ? value / 2 : value,
|
||||
unit,
|
||||
metricNotation,
|
||||
)
|
||||
const text =
|
||||
mode === 'radius'
|
||||
? `R ${rawText}`
|
||||
@@ -337,7 +351,16 @@ export function normalizeConstructionDimensionMode(value: unknown): Construction
|
||||
}
|
||||
|
||||
export function constructionDimensionUsesBaseline(mode: ConstructionDimensionMode): boolean {
|
||||
return ['linear', 'radius', 'chord', 'arc-length', 'angular'].includes(mode)
|
||||
return ['linear', 'chord', 'arc-length', 'angular'].includes(mode)
|
||||
}
|
||||
|
||||
export function shouldConsumeConstructionDimensionPointerEvent(event: {
|
||||
type: string
|
||||
button: number
|
||||
buttons: number
|
||||
}): boolean {
|
||||
if (event.type === 'pointerdown') return event.button === 0
|
||||
return (event.buttons & 0b110) === 0
|
||||
}
|
||||
|
||||
function wallFeatureAnchor(
|
||||
@@ -368,7 +391,6 @@ export function buildCurvedWallConstructionDimensionDraft(
|
||||
wallFeatureAnchor(wall, featureId, fallback)
|
||||
|
||||
switch (mode) {
|
||||
case 'radius':
|
||||
case 'center-mark':
|
||||
return {
|
||||
anchors: [feature('wall:curve:center', center), feature('wall:midpoint', midpoint)],
|
||||
@@ -461,7 +483,11 @@ export function FloorplanConstructionDimensionToolLayer({
|
||||
)
|
||||
}
|
||||
const commitDraft = (current: Draft, baselinePoint?: MeasurementPoint) => {
|
||||
const direction = resolveConstructionDimensionDraftDirection(current.points)
|
||||
const direction = resolveConstructionDimensionDraftDirection(
|
||||
current.points,
|
||||
current.anchors,
|
||||
sceneApi.nodes(),
|
||||
)
|
||||
const originPoint = baselinePoint ?? current.points.at(-1)
|
||||
if (!(direction && originPoint)) return false
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
@@ -510,10 +536,10 @@ export function FloorplanConstructionDimensionToolLayer({
|
||||
if (current.stage === 'baseline') commitDraft(current, associated.point)
|
||||
}
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (event.button === 0) consume(event)
|
||||
if (shouldConsumeConstructionDimensionPointerEvent(event)) consume(event)
|
||||
}
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
consume(event)
|
||||
if (shouldConsumeConstructionDimensionPointerEvent(event)) consume(event)
|
||||
setHover(resolveEvent(event))
|
||||
}
|
||||
const onPointerLeave = () => {
|
||||
@@ -633,19 +659,31 @@ export function FloorplanConstructionDimensionToolLayer({
|
||||
usesBaseline,
|
||||
])
|
||||
|
||||
const preview = useMemo(
|
||||
() =>
|
||||
draft.stage === 'baseline' && hover
|
||||
? buildConstructionDimensionPreviewGeometries(
|
||||
draft.points,
|
||||
hover.point,
|
||||
unit,
|
||||
dimensionMode,
|
||||
metricNotation,
|
||||
)
|
||||
: [],
|
||||
[dimensionMode, draft.points, draft.stage, hover, metricNotation, unit],
|
||||
)
|
||||
const preview = useMemo(() => {
|
||||
if (draft.stage !== 'baseline' || !hover) return []
|
||||
const direction = resolveConstructionDimensionDraftDirection(
|
||||
draft.points,
|
||||
draft.anchors,
|
||||
sceneApi.nodes(),
|
||||
)
|
||||
return buildConstructionDimensionPreviewGeometries(
|
||||
draft.points,
|
||||
hover.point,
|
||||
unit,
|
||||
dimensionMode,
|
||||
metricNotation,
|
||||
direction,
|
||||
)
|
||||
}, [
|
||||
dimensionMode,
|
||||
draft.anchors,
|
||||
draft.points,
|
||||
draft.stage,
|
||||
hover,
|
||||
metricNotation,
|
||||
sceneApi,
|
||||
unit,
|
||||
])
|
||||
const witnessDraftPoints =
|
||||
draft.stage === 'witnesses' && hover ? [...draft.points, hover.point] : draft.points
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ConstructionDimensionNode,
|
||||
type FloorplanGeometry,
|
||||
type GeometryContext,
|
||||
LevelNode,
|
||||
nodeRegistry,
|
||||
registerNode,
|
||||
WallNode,
|
||||
@@ -134,47 +135,92 @@ describe('buildConstructionDimensionFloorplan', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves wall anchors against the selected assembly datum', () => {
|
||||
test('straightens an existing endpoint-to-face baseline onto its referenced wall', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_assembly',
|
||||
id: 'wall_existing_diagonal',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
assemblyLayers: [
|
||||
end: [4, 4],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.1,
|
||||
datumEligible: ['structural-face'],
|
||||
kind: 'feature',
|
||||
reference: { nodeId: wall.id, featureId: 'wall:end' },
|
||||
fallback: [4, 0, 4],
|
||||
},
|
||||
{
|
||||
id: 'exterior-finish',
|
||||
role: 'exterior-finish',
|
||||
side: 'exterior',
|
||||
thickness: 0.03,
|
||||
datumEligible: ['finish-face'],
|
||||
kind: 'feature',
|
||||
reference: {
|
||||
nodeId: wall.id,
|
||||
featureId: 'wall:face:left',
|
||||
parameters: { t: 0.25 },
|
||||
},
|
||||
fallback: [0.9292893219, 0, 1.0707106781],
|
||||
},
|
||||
],
|
||||
baseline: {
|
||||
origin: [5, 5],
|
||||
direction: [-0.7235724834, -0.6902484349],
|
||||
},
|
||||
datumPolicy: 'structural-face',
|
||||
})
|
||||
const anchor = {
|
||||
kind: 'feature' as const,
|
||||
reference: { nodeId: wall.id, featureId: 'wall:centerline', parameters: { t: 0.25 } },
|
||||
fallback: [1, 0, 0] as [number, number, number],
|
||||
}
|
||||
const build = (datumPolicy: 'centerline' | 'wall-face' | 'structural-face' | 'finish-face') =>
|
||||
buildConstructionDimensionFloorplan(
|
||||
ConstructionDimensionNode.parse({
|
||||
anchors: [anchor, [3, 0, 0]],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
datumPolicy,
|
||||
}),
|
||||
context({ [wall.id]: wall }),
|
||||
)
|
||||
const segment = dimensionSegments(
|
||||
buildConstructionDimensionFloorplan(node, context({ [wall.id]: wall })),
|
||||
)[0]
|
||||
const dx = (segment?.dimensionEnd?.[0] ?? 0) - (segment?.dimensionStart?.[0] ?? 0)
|
||||
const dy = (segment?.dimensionEnd?.[1] ?? 0) - (segment?.dimensionStart?.[1] ?? 0)
|
||||
const length = Math.hypot(dx, dy)
|
||||
|
||||
expect(dimensionSegments(build('centerline'))[0]?.start).toEqual([1, 0])
|
||||
expect(dimensionSegments(build('structural-face'))[0]?.start[1]).toBeCloseTo(0.05)
|
||||
expect(dimensionSegments(build('finish-face'))[0]?.start[1]).toBeCloseTo(0.08)
|
||||
expect(dimensionSegments(build('wall-face'))[0]?.start[1]).toBeCloseTo(0.08)
|
||||
expect(dx / length).toBeCloseTo(-Math.SQRT1_2)
|
||||
expect(dy / length).toBeCloseTo(-Math.SQRT1_2)
|
||||
})
|
||||
|
||||
test('extends a wall-face dimension to the connected wall edge', () => {
|
||||
const measuredWall = WallNode.parse({
|
||||
id: 'wall_measured',
|
||||
parentId: 'level_main',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const connectedWall = WallNode.parse({
|
||||
id: 'wall_connected',
|
||||
parentId: 'level_main',
|
||||
start: [4, 0],
|
||||
end: [4, 3],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const level = LevelNode.parse({
|
||||
id: 'level_main',
|
||||
children: [measuredWall.id, connectedWall.id],
|
||||
})
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
anchors: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: measuredWall.id, featureId: 'wall:start' },
|
||||
fallback: [0, 0, 0],
|
||||
},
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: measuredWall.id, featureId: 'wall:end' },
|
||||
fallback: [4, 0, 0],
|
||||
},
|
||||
],
|
||||
baseline: { origin: [0, 1], direction: [1, 0] },
|
||||
datumPolicy: 'wall-face',
|
||||
})
|
||||
const geometry = buildConstructionDimensionFloorplan(
|
||||
node,
|
||||
context({
|
||||
[level.id]: level,
|
||||
[measuredWall.id]: measuredWall,
|
||||
[connectedWall.id]: connectedWall,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(dimensionSegments(geometry)[0]?.end).toEqual([4.1, 0.1])
|
||||
})
|
||||
|
||||
test('uses millimetre notation in document output', () => {
|
||||
@@ -349,59 +395,23 @@ describe('buildConstructionDimensionFloorplan', () => {
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('renders radius notation with a leader and center mark', () => {
|
||||
test('renders radius like diameter while showing half the picked span', () => {
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
mode: 'radius',
|
||||
anchors: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
],
|
||||
baseline: { origin: [3, 1], direction: [1, 0] },
|
||||
})
|
||||
const geometry = buildConstructionDimensionFloorplan(node, context())
|
||||
const entries = geometry ? flatten(geometry) : []
|
||||
|
||||
expect(entries.find((entry) => entry.kind === 'dimension-label')).toMatchObject({
|
||||
text: 'R 2m',
|
||||
cx: 3,
|
||||
cy: 1,
|
||||
})
|
||||
expect(entries.filter((entry) => entry.kind === 'line').length).toBeGreaterThanOrEqual(6)
|
||||
})
|
||||
|
||||
test('updates an associative curved-wall radius when the host curve changes', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_curve',
|
||||
expect(dimensionSegments(geometry)[0]).toMatchObject({
|
||||
text: 'R 1m',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
curveOffset: 1,
|
||||
end: [2, 0],
|
||||
})
|
||||
const node = ConstructionDimensionNode.parse({
|
||||
mode: 'radius',
|
||||
anchors: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: wall.id, featureId: 'wall:curve:center' },
|
||||
fallback: [2, 0, 1.5],
|
||||
},
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: wall.id, featureId: 'wall:midpoint' },
|
||||
fallback: [2, 0, -1],
|
||||
},
|
||||
],
|
||||
baseline: { origin: [2, -1.5], direction: [0, -1] },
|
||||
})
|
||||
const reshapedWall = WallNode.parse({ ...wall, curveOffset: 0.5 })
|
||||
const original = buildConstructionDimensionFloorplan(node, context({ [wall.id]: wall }))
|
||||
const reshaped = buildConstructionDimensionFloorplan(node, context({ [wall.id]: reshapedWall }))
|
||||
const originalLabel =
|
||||
original && flatten(original).find((entry) => entry.kind === 'dimension-label')
|
||||
const reshapedLabel =
|
||||
reshaped && flatten(reshaped).find((entry) => entry.kind === 'dimension-label')
|
||||
|
||||
expect(originalLabel).toMatchObject({ text: 'R 2.5m' })
|
||||
expect(reshapedLabel).toMatchObject({ text: 'R 4.25m' })
|
||||
expect(entries.some((entry) => entry.kind === 'dimension-label')).toBe(false)
|
||||
})
|
||||
|
||||
test('renders diameter and repeated-feature notation', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AnyNode,
|
||||
AnyNodeId,
|
||||
ConstructionDimensionNode,
|
||||
FloorplanGeometry,
|
||||
@@ -11,10 +12,9 @@ import type {
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
constructionDimensionRequiredAnchorCount,
|
||||
getWallAssemblyFaceOffsets,
|
||||
getWallAssemblyThickness,
|
||||
getWallArcData,
|
||||
getWallCurveFrameAt,
|
||||
resolveWallAssemblyDatumReferences,
|
||||
getWallThickness,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
readFloorplanContext,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '../shared/construction-length'
|
||||
import { buildDimensionStringGeometry } from '../shared/dimension-string'
|
||||
import {
|
||||
alignConstructionDimensionDirectionToSharedWall,
|
||||
resolveCircularConstructionDimensionLayout,
|
||||
resolveConstructionDimensionLayout,
|
||||
} from './geometry'
|
||||
@@ -73,11 +74,23 @@ export function buildConstructionDimensionFloorplan(
|
||||
|
||||
switch (node.mode) {
|
||||
case 'linear':
|
||||
case 'chord':
|
||||
case 'chord': {
|
||||
const alignedNode = {
|
||||
...displayNode,
|
||||
baseline: {
|
||||
...displayNode.baseline,
|
||||
direction: alignConstructionDimensionDirectionToSharedWall(
|
||||
displayNode.baseline.direction,
|
||||
displayNode.anchors,
|
||||
(id) => ctx.resolve(id),
|
||||
),
|
||||
},
|
||||
}
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildLinearOrChord(displayNode, points, stroke, dangling, unit, profile, editable),
|
||||
buildLinearOrChord(alignedNode, points, stroke, dangling, unit, profile, editable),
|
||||
{ annotationRole: 'manual-dimension' },
|
||||
)
|
||||
}
|
||||
case 'radius':
|
||||
return withFloorplanGeometryMetadata(
|
||||
buildRadius(displayNode, points, stroke, dangling, unit, profile, editable),
|
||||
@@ -127,13 +140,19 @@ function resolveDimensionAnchor(
|
||||
const frame = getWallCurveFrameAt(referenced, t)
|
||||
const side = wallDatumSide(node, anchor.reference.featureId, resolved, frame)
|
||||
const offset = wallDatumOffset(referenced, node.datumPolicy, side)
|
||||
const endpointExtension = wallEndpointDatumExtension(
|
||||
referenced,
|
||||
anchor.reference.featureId,
|
||||
node.datumPolicy,
|
||||
ctx,
|
||||
)
|
||||
|
||||
return {
|
||||
...resolved,
|
||||
point: [
|
||||
frame.point.x + frame.normal.x * offset,
|
||||
frame.point.x + frame.normal.x * offset + frame.tangent.x * endpointExtension,
|
||||
resolved.point[1],
|
||||
frame.point.y + frame.normal.y * offset,
|
||||
frame.point.y + frame.normal.y * offset + frame.tangent.y * endpointExtension,
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -184,17 +203,61 @@ function wallDatumOffset(
|
||||
side: 1 | -1,
|
||||
): number {
|
||||
if (policy === 'centerline') return 0
|
||||
if (policy === 'wall-face') {
|
||||
const faces = getWallAssemblyFaceOffsets(wall)
|
||||
return side > 0 ? faces.exterior : faces.interior
|
||||
return (getWallThickness(wall) / 2) * side
|
||||
}
|
||||
|
||||
function wallEndpointDatumExtension(
|
||||
wall: WallNode,
|
||||
featureId: string,
|
||||
policy: ConstructionDimensionNode['datumPolicy'],
|
||||
ctx: GeometryContext,
|
||||
): number {
|
||||
if (policy === 'centerline' || (featureId !== 'wall:start' && featureId !== 'wall:end')) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const datum = policy === 'finish-face' ? 'finish-face' : 'structural-face'
|
||||
const candidates = resolveWallAssemblyDatumReferences(wall)
|
||||
.filter((reference) => reference.datum === datum && Math.sign(reference.offset) === side)
|
||||
.map((reference) => reference.offset)
|
||||
if (candidates.length === 0) return (getWallAssemblyThickness(wall) / 2) * side
|
||||
return side > 0 ? Math.max(...candidates) : Math.min(...candidates)
|
||||
const parent = wall.parentId ? ctx.resolve<AnyNode>(wall.parentId as AnyNodeId) : undefined
|
||||
const childIds =
|
||||
parent && 'children' in parent && Array.isArray(parent.children)
|
||||
? (parent.children as AnyNodeId[])
|
||||
: []
|
||||
const endpoint = featureId === 'wall:start' ? wall.start : wall.end
|
||||
const frame = getWallCurveFrameAt(wall, featureId === 'wall:start' ? 0 : 1)
|
||||
const projections = [0]
|
||||
|
||||
for (const childId of childIds) {
|
||||
const candidate = ctx.resolve<WallNode>(childId)
|
||||
if (
|
||||
candidate?.type !== 'wall' ||
|
||||
candidate.id === wall.id ||
|
||||
getWallArcData(candidate) ||
|
||||
(!pointsCoincide(endpoint, candidate.start) && !pointsCoincide(endpoint, candidate.end))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const dx = candidate.end[0] - candidate.start[0]
|
||||
const dz = candidate.end[1] - candidate.start[1]
|
||||
const length = Math.hypot(dx, dz)
|
||||
if (length <= EPSILON) continue
|
||||
const normal: FloorplanPoint = [-dz / length, dx / length]
|
||||
for (const side of [-1, 1] as const) {
|
||||
const candidateOffset = wallDatumOffset(candidate, policy, side)
|
||||
projections.push(
|
||||
normal[0] * candidateOffset * frame.tangent.x +
|
||||
normal[1] * candidateOffset * frame.tangent.y,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return featureId === 'wall:start' ? Math.min(...projections) : Math.max(...projections)
|
||||
}
|
||||
|
||||
function pointsCoincide(
|
||||
left: readonly [number, number],
|
||||
right: readonly [number, number],
|
||||
): boolean {
|
||||
return Math.hypot(left[0] - right[0], left[1] - right[1]) <= 0.03
|
||||
}
|
||||
|
||||
function buildLinearOrChord(
|
||||
@@ -252,23 +315,29 @@ function buildRadius(
|
||||
editable: boolean,
|
||||
): FloorplanGeometry | null {
|
||||
const layout = resolveCircularConstructionDimensionLayout('radius', points)
|
||||
if (!layout) return null
|
||||
const labelPoint: FloorplanPoint = node.baseline.origin
|
||||
if (!layout?.end) return null
|
||||
const direction = normalized(layout.start, layout.end)
|
||||
if (!direction) return null
|
||||
const normal: FloorplanPoint = [-direction[1], direction[0]]
|
||||
const children: FloorplanGeometry[] = [
|
||||
styledPolyline([layout.center, layout.start, labelPoint], stroke),
|
||||
...openArrow(layout.start, layout.center, stroke),
|
||||
labelGeometry(
|
||||
labelPoint,
|
||||
dimensionGeometry(
|
||||
node,
|
||||
layout.start,
|
||||
layout.end,
|
||||
layout.start,
|
||||
layout.end,
|
||||
normal,
|
||||
notation(
|
||||
node,
|
||||
`R ${formatConstructionLength(layout.radius, unit, profile, lengthFormatOptions(node))}`,
|
||||
dangling,
|
||||
),
|
||||
angle(layout.start, labelPoint),
|
||||
stroke,
|
||||
),
|
||||
hitLine(layout.start, layout.end),
|
||||
]
|
||||
if (node.showCenterMark) children.push(...centerMark(layout.center, layout.radius, stroke))
|
||||
if (editable) children.push(...anchorHandles(points), baselineHandle(labelPoint))
|
||||
if (editable) children.push(...anchorHandles(points))
|
||||
return dimensionGroup(children)
|
||||
}
|
||||
|
||||
@@ -531,10 +600,6 @@ function styledLine(
|
||||
}
|
||||
}
|
||||
|
||||
function styledPolyline(points: FloorplanPoint[], stroke: string): FloorplanGeometry {
|
||||
return { kind: 'polyline', points, fill: 'none', ...lineStyle(stroke) }
|
||||
}
|
||||
|
||||
function lineStyle(stroke: string, strokeDasharray?: string): FloorplanStyle {
|
||||
return {
|
||||
fill: 'none',
|
||||
@@ -651,10 +716,6 @@ function distance(first: FloorplanPoint, second: FloorplanPoint): number {
|
||||
return Math.hypot(second[0] - first[0], second[1] - first[1])
|
||||
}
|
||||
|
||||
function angle(first: FloorplanPoint, second: FloorplanPoint): number {
|
||||
return Math.atan2(second[1] - first[1], second[0] - first[0])
|
||||
}
|
||||
|
||||
function formatDegrees(value: number): string {
|
||||
return `${Number.parseFloat(value.toFixed(value < 10 ? 1 : 0))}°`
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type {
|
||||
ConstructionDimensionMode,
|
||||
ConstructionDimensionNode,
|
||||
FloorplanPoint,
|
||||
MeasurementPoint,
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type ConstructionDimensionMode,
|
||||
type ConstructionDimensionNode,
|
||||
type FloorplanPoint,
|
||||
getWallArcData,
|
||||
type MeasurementAnchor,
|
||||
type MeasurementPoint,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export type ConstructionDimensionSegmentLayout = {
|
||||
@@ -24,6 +28,55 @@ export type ConstructionDimensionLayout = {
|
||||
|
||||
const project = (point: MeasurementPoint): FloorplanPoint => [point[0], point[2]]
|
||||
|
||||
export function alignConstructionDimensionDirectionToSharedWall(
|
||||
direction: readonly [number, number],
|
||||
anchors: readonly MeasurementAnchor[],
|
||||
resolve: (id: AnyNodeId) => AnyNode | undefined,
|
||||
): [number, number] {
|
||||
const firstAnchor = anchors[0]
|
||||
const secondAnchor = anchors[1]
|
||||
if (
|
||||
!firstAnchor ||
|
||||
!secondAnchor ||
|
||||
Array.isArray(firstAnchor) ||
|
||||
Array.isArray(secondAnchor) ||
|
||||
firstAnchor.reference.nodeId !== secondAnchor.reference.nodeId
|
||||
) {
|
||||
return [direction[0], direction[1]]
|
||||
}
|
||||
|
||||
const wall = resolve(firstAnchor.reference.nodeId as AnyNodeId)
|
||||
if (
|
||||
wall?.type !== 'wall' ||
|
||||
getWallArcData(wall) ||
|
||||
!supportsStraightWallDirection(firstAnchor.reference.featureId) ||
|
||||
!supportsStraightWallDirection(secondAnchor.reference.featureId)
|
||||
) {
|
||||
return [direction[0], direction[1]]
|
||||
}
|
||||
|
||||
const wallDx = wall.end[0] - wall.start[0]
|
||||
const wallDz = wall.end[1] - wall.start[1]
|
||||
const wallLength = Math.hypot(wallDx, wallDz)
|
||||
if (wallLength <= 1e-9) return [direction[0], direction[1]]
|
||||
const wallDirection: [number, number] = [wallDx / wallLength, wallDz / wallLength]
|
||||
return direction[0] * wallDirection[0] + direction[1] * wallDirection[1] < 0
|
||||
? [-wallDirection[0], -wallDirection[1]]
|
||||
: wallDirection
|
||||
}
|
||||
|
||||
function supportsStraightWallDirection(featureId: string): boolean {
|
||||
return [
|
||||
'wall:start',
|
||||
'wall:end',
|
||||
'wall:centerline',
|
||||
'wall:midpoint',
|
||||
'wall:face:left',
|
||||
'wall:face:right',
|
||||
'wall:top-centerline',
|
||||
].includes(featureId)
|
||||
}
|
||||
|
||||
export type CircularConstructionDimensionLayout = {
|
||||
center: FloorplanPoint
|
||||
start: FloorplanPoint
|
||||
@@ -44,7 +97,7 @@ export function resolveCircularConstructionDimensionLayout(
|
||||
const first = project(anchors[0]!)
|
||||
const second = project(anchors[1]!)
|
||||
|
||||
if (mode === 'diameter') {
|
||||
if (mode === 'diameter' || mode === 'radius') {
|
||||
const center: FloorplanPoint = [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2]
|
||||
const radius = distance(first, second) / 2
|
||||
if (radius <= 1e-9) return null
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { getFloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { drawingSheetDefinition } from './definition'
|
||||
|
||||
describe('drawingSheetDefinition', () => {
|
||||
test('registers persistent drawing sheets as non-geometric document nodes', () => {
|
||||
expect(drawingSheetDefinition.kind).toBe('drawing-sheet')
|
||||
expect(drawingSheetDefinition.bake).toBe('strip')
|
||||
expect(drawingSheetDefinition.schemaVersion).toBe(4)
|
||||
expect(drawingSheetDefinition.dirtyTracking).toBe(false)
|
||||
expect(drawingSheetDefinition.capabilities).toMatchObject({
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
presettable: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('produces schema-valid defaults', () => {
|
||||
expect(
|
||||
drawingSheetDefinition.schema.safeParse({
|
||||
id: 'drawing-sheet_default',
|
||||
type: 'drawing-sheet',
|
||||
...drawingSheetDefinition.defaults(),
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('contributes drawing-sheet matching through the editor extension', () => {
|
||||
const sheet = drawingSheetDefinition.schema.parse({
|
||||
id: 'drawing-sheet_a101',
|
||||
placedViews: [
|
||||
{
|
||||
id: 'drawing-view_floor',
|
||||
levelId: 'level_main',
|
||||
drawingType: 'floor-plan',
|
||||
drawingNumber: '1',
|
||||
title: 'Main floor',
|
||||
scale: '1:50',
|
||||
},
|
||||
],
|
||||
})
|
||||
const resolveDrawingSheet =
|
||||
getFloorplanNodeExtension(drawingSheetDefinition)?.resolveDrawingSheet
|
||||
|
||||
expect(
|
||||
resolveDrawingSheet?.({ node: sheet, levelId: 'level_main', drawingType: 'floor-plan' }),
|
||||
).toBe(sheet)
|
||||
expect(
|
||||
resolveDrawingSheet?.({ node: sheet, levelId: 'level_upper', drawingType: 'floor-plan' }),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import { DrawingSheetNode as DrawingSheetNodeSchema, type NodeDefinition } from '@pascal-app/core'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { DrawingSheetNode } from './schema'
|
||||
|
||||
export const drawingSheetDefinition: NodeDefinition<typeof DrawingSheetNode> = {
|
||||
kind: 'drawing-sheet',
|
||||
bake: 'strip',
|
||||
schemaVersion: 4,
|
||||
schema: DrawingSheetNode,
|
||||
category: 'analysis',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
resolveDrawingSheet: ({ node, levelId, drawingType }) =>
|
||||
node.placedViews.some(
|
||||
(view) =>
|
||||
(view.levelId === null || view.levelId === levelId) && view.drawingType === drawingType,
|
||||
)
|
||||
? node
|
||||
: null,
|
||||
} satisfies FloorplanNodeExtension<DrawingSheetNodeSchema>,
|
||||
},
|
||||
|
||||
defaults: () => {
|
||||
const stub = DrawingSheetNodeSchema.parse({
|
||||
id: 'drawing-sheet_default' as never,
|
||||
type: 'drawing-sheet',
|
||||
})
|
||||
const { id: _id, type: _type, ...rest } = stub
|
||||
return rest
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
presettable: false,
|
||||
},
|
||||
|
||||
dirtyTracking: false,
|
||||
|
||||
presentation: {
|
||||
label: 'Drawing Sheet',
|
||||
description: 'A persistent construction-document sheet with placed views and title-block data.',
|
||||
icon: { kind: 'iconify', name: 'lucide:file-text' },
|
||||
hidden: true,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A persistent construction-document sheet containing paper setup, placed drawing views, notes, schedules, and title-block metadata.',
|
||||
},
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { drawingSheetDefinition } from './definition'
|
||||
@@ -1,18 +0,0 @@
|
||||
export {
|
||||
DrawingSheetAnnotationProfile,
|
||||
DrawingSheetDocumentMarker,
|
||||
DrawingSheetDocumentMarkerKind,
|
||||
DrawingSheetGeneralNote,
|
||||
DrawingSheetGeneralNoteSet,
|
||||
DrawingSheetKeyedNote,
|
||||
DrawingSheetKeyedNoteDefinition,
|
||||
DrawingSheetKeyedNoteInstance,
|
||||
DrawingSheetNode,
|
||||
DrawingSheetOrientation,
|
||||
DrawingSheetPaperSize,
|
||||
DrawingSheetPlacedView,
|
||||
DrawingSheetRect,
|
||||
DrawingSheetScale,
|
||||
DrawingSheetSchedulePlacement,
|
||||
DrawingSheetTitleBlock,
|
||||
} from '@pascal-app/core'
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
type AnyNodeId,
|
||||
type ElevatorNode,
|
||||
type FloorplanAffordance,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { isAngleSnapActive } from '@pascal-app/editor'
|
||||
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||
|
||||
const MIN_ELEVATOR_DIM = 0.6
|
||||
@@ -15,9 +17,7 @@ type ElevatorResizePayload = { axis: 'x' | 'z'; side: 1 | -1 }
|
||||
* `linear-resize` handles declared in `definition.ts` — `anchor: 'center'`
|
||||
* means dragging outward on either +X or -X edge grows `width` by 2×
|
||||
* the elevator-local cursor offset while `position` stays put. Same for
|
||||
* +Z / -Z and `depth`. Writes directly to scene each tick (door pattern);
|
||||
* the registry dispatcher snapshots / pauses history at start so the
|
||||
* per-tick writes collapse into one undoable entry on commit.
|
||||
* +Z / -Z and `depth`.
|
||||
*/
|
||||
export const elevatorResizeAffordance: FloorplanAffordance<ElevatorNode> = {
|
||||
start({ node, payload, initialPlanPoint }) {
|
||||
@@ -49,14 +49,16 @@ export const elevatorResizeAffordance: FloorplanAffordance<ElevatorNode> = {
|
||||
const delta = (currentLocal - initialLocal) * side
|
||||
const newValue = Math.max(MIN_ELEVATOR_DIM, initialValue + 2 * delta)
|
||||
lastValue = newValue
|
||||
useScene
|
||||
useLiveNodeOverrides
|
||||
.getState()
|
||||
.updateNode(elevatorId, axis === 'x' ? { width: newValue } : { depth: newValue })
|
||||
.set(elevatorId, axis === 'x' ? { width: newValue } : { depth: newValue })
|
||||
useScene.getState().markDirty(elevatorId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(elevatorId)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(elevatorId, axis === 'x' ? { width: lastValue } : { depth: lastValue })
|
||||
@@ -69,9 +71,7 @@ export const elevatorResizeAffordance: FloorplanAffordance<ElevatorNode> = {
|
||||
* Elevator rotation drag (floor-plan). Sister to the 3D `arc-resize`
|
||||
* handle declared in `definition.ts`. Same `- delta` sign convention as
|
||||
* the 3D path so dragging the cursor in the same direction in both views
|
||||
* produces the same rotation. Writes directly to scene during the drag;
|
||||
* the registry dispatcher captures a snapshot first and re-applies the
|
||||
* single tracked update on pointer-up.
|
||||
* produces the same rotation.
|
||||
*/
|
||||
export const elevatorRotateAffordance: FloorplanAffordance<ElevatorNode> = {
|
||||
start({ node, initialPlanPoint }) {
|
||||
@@ -84,21 +84,23 @@ export const elevatorRotateAffordance: FloorplanAffordance<ElevatorNode> = {
|
||||
|
||||
return {
|
||||
affectedIds: [elevatorId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
apply({ planPoint }) {
|
||||
const delta = rotateAffordanceDelta({
|
||||
center: [cx, cz],
|
||||
initialAngle,
|
||||
planPoint,
|
||||
free: modifiers.shiftKey,
|
||||
free: !isAngleSnapActive(),
|
||||
})
|
||||
const newRotation = initialRotation - delta
|
||||
lastRotation = newRotation
|
||||
useScene.getState().updateNode(elevatorId, { rotation: newRotation })
|
||||
useLiveNodeOverrides.getState().set(elevatorId, { rotation: newRotation })
|
||||
useScene.getState().markDirty(elevatorId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(elevatorId)
|
||||
useScene.getState().updateNode(elevatorId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -266,6 +266,9 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
||||
const linkedOriginals = collectLinkedFences(fences, node.id, originalMovingPoint)
|
||||
|
||||
const affectedIds: AnyNodeId[] = [node.id, ...linkedOriginals.map((l) => l.id)]
|
||||
let lastPatches = new Map<AnyNodeId, Partial<FenceNode>>()
|
||||
let lastStart = originalStart
|
||||
let lastEnd = originalEnd
|
||||
|
||||
return {
|
||||
affectedIds,
|
||||
@@ -314,39 +317,63 @@ export const fenceMoveEndpointAffordance: FloorplanAffordance<FenceNode> = {
|
||||
end: pointsNearlyEqual(l.end, originalMovingPoint) ? aligned : l.end,
|
||||
}))
|
||||
|
||||
useScene.getState().updateNodes([
|
||||
{ id: node.id, data: { start: nextStart, end: nextEnd } },
|
||||
...linkedUpdates.map((u) => ({
|
||||
id: u.id,
|
||||
data: { start: u.start, end: u.end },
|
||||
})),
|
||||
lastStart = nextStart
|
||||
lastEnd = nextEnd
|
||||
const nextPatches = new Map<AnyNodeId, Partial<FenceNode>>([
|
||||
[node.id, { start: nextStart, end: nextEnd }],
|
||||
...linkedUpdates.map(
|
||||
(update) =>
|
||||
[update.id, { start: update.start, end: update.end }] as [
|
||||
AnyNodeId,
|
||||
Partial<FenceNode>,
|
||||
],
|
||||
),
|
||||
])
|
||||
// Re-elect the slab lift host as the endpoint drags (uncapped max
|
||||
// election — 2D has no camera ray). This legacy write path commits
|
||||
// via the dispatcher's snapshot diff, so patching per tick both
|
||||
// previews the lift and lands it in the committed diff. Fences run
|
||||
// no per-frame election: `supportSlabId` IS the lift.
|
||||
const patchedNodes = useScene.getState().nodes
|
||||
const supportPatches = [node.id, ...linkedUpdates.map((u) => u.id)].flatMap((id) => {
|
||||
// election — 2D has no camera ray). Fences run no per-frame
|
||||
// election: `supportSlabId` IS the lift.
|
||||
const patchedNodes = { ...sceneNodes }
|
||||
for (const [id, patch] of nextPatches) {
|
||||
patchedNodes[id] = { ...patchedNodes[id], ...patch } as AnyNode
|
||||
}
|
||||
for (const id of nextPatches.keys()) {
|
||||
const fence = patchedNodes[id]
|
||||
if (fence?.type !== 'fence') return []
|
||||
if (fence?.type !== 'fence') continue
|
||||
const patch = resolveFenceSupportSlabPatch(fence as FenceNode, patchedNodes)
|
||||
return patch.supportSlabId === (fence as FenceNode).supportSlabId
|
||||
? []
|
||||
: [{ id, data: patch }]
|
||||
})
|
||||
if (supportPatches.length > 0) useScene.getState().updateNodes(supportPatches)
|
||||
if (patch.supportSlabId !== (fence as FenceNode).supportSlabId) {
|
||||
nextPatches.set(id, { ...nextPatches.get(id), ...patch })
|
||||
}
|
||||
}
|
||||
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
const scene = useScene.getState()
|
||||
for (const linked of linkedOriginals) {
|
||||
if (!nextPatches.has(linked.id)) {
|
||||
overrides.clear(linked.id)
|
||||
scene.markDirty(linked.id)
|
||||
}
|
||||
}
|
||||
for (const [id, patch] of nextPatches) {
|
||||
overrides.set(id, patch)
|
||||
scene.markDirty(id)
|
||||
}
|
||||
lastPatches = nextPatches
|
||||
},
|
||||
canCommit() {
|
||||
// Pointer-up always runs canCommit — drop the alignment guide here
|
||||
// so it doesn't linger after a commit / reject.
|
||||
useAlignmentGuides.getState().clear()
|
||||
const finalFence = useScene.getState().nodes[node.id] as FenceNode | undefined
|
||||
return (
|
||||
!!finalFence &&
|
||||
finalFence.type === 'fence' &&
|
||||
isSegmentLongEnough(finalFence.start, finalFence.end)
|
||||
return isSegmentLongEnough(lastStart, lastEnd)
|
||||
},
|
||||
commit() {
|
||||
useScene.getState().updateNodes(
|
||||
Array.from(lastPatches, ([id, data]) => ({
|
||||
id,
|
||||
data,
|
||||
})),
|
||||
)
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
for (const id of affectedIds) overrides.clear(id)
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -155,7 +155,6 @@ function toMiterWall(segment: SegmentLike): WallNode {
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
assemblyLayers: [],
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
thickness: segment.thickness,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { cupolaDefinition } from './cupola'
|
||||
import { doorDefinition } from './door'
|
||||
import { dormerDefinition } from './dormer'
|
||||
import { downspoutDefinition } from './downspout'
|
||||
import { drawingSheetDefinition } from './drawing-sheet'
|
||||
import { ductFittingDefinition } from './duct-fitting'
|
||||
import { ductSegmentDefinition } from './duct-segment'
|
||||
import { ductTerminalDefinition } from './duct-terminal'
|
||||
@@ -94,7 +93,6 @@ export const builtinPlugin: Plugin = {
|
||||
scanDefinition as unknown as AnyNodeDefinition,
|
||||
measurementDefinition as unknown as AnyNodeDefinition,
|
||||
constructionDimensionDefinition as unknown as AnyNodeDefinition,
|
||||
drawingSheetDefinition as unknown as AnyNodeDefinition,
|
||||
structuralGridDefinition as unknown as AnyNodeDefinition,
|
||||
// Roof-mounted accessories (custom renderer + bespoke roof-event tool).
|
||||
boxVentDefinition as unknown as AnyNodeDefinition,
|
||||
@@ -141,7 +139,6 @@ export { cupolaDefinition } from './cupola'
|
||||
export { doorDefinition } from './door'
|
||||
export { dormerDefinition } from './dormer'
|
||||
export { downspoutDefinition } from './downspout'
|
||||
export { drawingSheetDefinition } from './drawing-sheet'
|
||||
export { ductFittingDefinition } from './duct-fitting'
|
||||
export { ductSegmentDefinition } from './duct-segment'
|
||||
export { ductTerminalDefinition } from './duct-terminal'
|
||||
@@ -163,35 +160,6 @@ export { ridgeVentDefinition } from './ridge-vent'
|
||||
export { roofDefinition } from './roof'
|
||||
export { roofSegmentDefinition } from './roof-segment'
|
||||
export { scanDefinition } from './scan'
|
||||
export {
|
||||
type BuildClearanceAdvisoriesOptions,
|
||||
buildClearanceAdvisories,
|
||||
type ClearanceAdvisory,
|
||||
type ClearanceAdvisoryCategory,
|
||||
type ClearanceAdvisorySeverity,
|
||||
type ClearanceEvidence,
|
||||
type ClearanceProfile,
|
||||
type ClearanceRule,
|
||||
type ClearanceRuleSource,
|
||||
DEFAULT_CLEARANCE_PROFILES,
|
||||
} from './shared/clearance-advisories'
|
||||
export {
|
||||
type BuildConstructionModuleAdvisoriesOptions,
|
||||
buildConstructionModuleAdvisories,
|
||||
type ConstructionModuleAdvisory,
|
||||
type ConstructionModuleAdvisorySeverity,
|
||||
type ConstructionModuleMeasurementKind,
|
||||
type ConstructionModuleProfile,
|
||||
type ConstructionModuleSystem,
|
||||
DEFAULT_CONSTRUCTION_MODULE_PROFILES,
|
||||
} from './shared/construction-module-advisories'
|
||||
export {
|
||||
type BuildDimensionCompletenessAuditOptions,
|
||||
buildDimensionCompletenessAudit,
|
||||
type DimensionCompletenessIssue,
|
||||
type DimensionCompletenessIssueKind,
|
||||
type DimensionCompletenessIssueSeverity,
|
||||
} from './shared/dimension-completeness-audit'
|
||||
export { shelfDefinition } from './shelf'
|
||||
export { siteDefinition } from './site'
|
||||
export { skylightDefinition } from './skylight'
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
type ItemNode as ItemNodeType,
|
||||
type NodeDefinition,
|
||||
} from '@pascal-app/core'
|
||||
import { buildItemFloorplan } from './floorplan'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { buildItemContextualDimensions, buildItemFloorplan } from './floorplan'
|
||||
import { itemFloorplanMoveTarget } from './floorplan-move'
|
||||
import { itemPaint } from './paint'
|
||||
import { itemParametrics } from './parametrics'
|
||||
@@ -172,6 +173,11 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
|
||||
schema: ItemNode,
|
||||
category: 'furnish',
|
||||
surfaceRole: 'furnishing',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
contextualDimensions: buildItemContextualDimensions,
|
||||
} satisfies FloorplanNodeExtension<ItemNodeType>,
|
||||
},
|
||||
|
||||
// Defaults shape is cast: the schema requires a fully-typed `asset`
|
||||
// field, but in practice items are always created from the catalog
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
movingFootprintAnchors,
|
||||
type RoofSegmentNode,
|
||||
roofFacePointToSegment,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
@@ -203,6 +204,7 @@ function buildWallItemSession(
|
||||
original: resolveItemPlanPoint(node, useScene.getState().nodes),
|
||||
metadata: node.metadata,
|
||||
})
|
||||
let lastPatch: Partial<ItemNode> | null = null
|
||||
|
||||
return {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
@@ -234,25 +236,24 @@ function buildWallItemSession(
|
||||
const halfW = width / 2
|
||||
const clampedX = Math.max(halfW, Math.min(hit.wallLength - halfW, snappedLocalX))
|
||||
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: node.id as AnyNodeId,
|
||||
data: {
|
||||
position: [clampedX, startLocalY, 0],
|
||||
rotation: [0, hit.itemRotation, 0],
|
||||
side: hit.side,
|
||||
parentId: hit.wall.id,
|
||||
// Re-anchoring to a wall ends any roof-segment hosting; the
|
||||
// overlay's snapshot restores it if the move is reverted.
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
},
|
||||
},
|
||||
])
|
||||
lastPatch = {
|
||||
position: [clampedX, startLocalY, 0],
|
||||
rotation: [0, hit.itemRotation, 0],
|
||||
side: hit.side,
|
||||
parentId: hit.wall.id,
|
||||
roofSegmentId: undefined,
|
||||
roofFace: undefined,
|
||||
}
|
||||
useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastPatch)
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[node.id as AnyNodeId] as ItemNode | undefined
|
||||
return !!live && live.type === 'item' && !!live.parentId
|
||||
return !!lastPatch?.parentId
|
||||
},
|
||||
commit() {
|
||||
if (!lastPatch) return
|
||||
useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
|
||||
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastPatch }])
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -278,6 +279,7 @@ function buildFloorItemSession(
|
||||
const resolvePlanPoint = createPlanarMovePointResolver(resolveItemPlanPoint(node, nodes), node)
|
||||
// Alignment candidates gathered once — scene is stable during the drag.
|
||||
const candidates = collectAlignmentAnchors(nodes, node.id)
|
||||
let lastPatch: Partial<ItemNode> | null = null
|
||||
return {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
apply({ planPoint }) {
|
||||
@@ -300,22 +302,23 @@ function buildFloorItemSession(
|
||||
const sourceY = node.position[1]
|
||||
const nextPosition: [number, number, number] = [snapped[0], sourceY, snapped[1]]
|
||||
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: node.id as AnyNodeId,
|
||||
data: {
|
||||
position: nextPosition,
|
||||
// Keep parent as the level we resolved at session-start. If
|
||||
// somehow it's null (e.g. orphaned item), fall back to the
|
||||
// existing parent so we don't write `null` and detach.
|
||||
parentId: startLevelId ?? node.parentId,
|
||||
},
|
||||
},
|
||||
])
|
||||
lastPatch = {
|
||||
position: nextPosition,
|
||||
// Keep parent as the level we resolved at session-start. If
|
||||
// somehow it's null (e.g. orphaned item), fall back to the
|
||||
// existing parent so we don't write `null` and detach.
|
||||
parentId: startLevelId ?? node.parentId,
|
||||
}
|
||||
useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastPatch)
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[node.id as AnyNodeId] as ItemNode | undefined
|
||||
return !!live && live.type === 'item'
|
||||
return lastPatch !== null
|
||||
},
|
||||
commit() {
|
||||
if (!lastPatch) return
|
||||
useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
|
||||
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastPatch }])
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -337,6 +340,7 @@ function buildSurfaceItemSession(
|
||||
resolveItemPlanPoint(node, useScene.getState().nodes),
|
||||
node,
|
||||
)
|
||||
let lastPatch: Partial<ItemNode> | null = null
|
||||
return {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
apply({ planPoint }) {
|
||||
@@ -348,19 +352,20 @@ function buildSurfaceItemSession(
|
||||
const sourceY = node.position[1]
|
||||
const nextPosition: [number, number, number] = [snapped[0], sourceY, snapped[1]]
|
||||
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: node.id as AnyNodeId,
|
||||
data: {
|
||||
position: nextPosition,
|
||||
parentId: surface ? surface.id : node.parentId,
|
||||
},
|
||||
},
|
||||
])
|
||||
lastPatch = {
|
||||
position: nextPosition,
|
||||
parentId: surface ? surface.id : node.parentId,
|
||||
}
|
||||
useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastPatch)
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[node.id as AnyNodeId] as ItemNode | undefined
|
||||
return !!live && live.type === 'item'
|
||||
return lastPatch !== null
|
||||
},
|
||||
commit() {
|
||||
if (!lastPatch) return
|
||||
useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
|
||||
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastPatch }])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
roofFacePointToSegment,
|
||||
useLiveTransforms,
|
||||
} from '@pascal-app/core'
|
||||
import { formatLinearMeasurement, readFloorplanMetricNotationOverride } from '@pascal-app/editor'
|
||||
|
||||
/**
|
||||
* Stage C floor-plan builder for item.
|
||||
@@ -153,6 +154,58 @@ function resolveItemTransform(
|
||||
return result
|
||||
}
|
||||
|
||||
export function buildItemContextualDimensions(
|
||||
node: ItemNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const transform = resolveItemTransform(node, ctx)
|
||||
if (!transform) return null
|
||||
const [width, , depth] = getScaledDimensions(node)
|
||||
if (width <= 1e-6 || depth <= 1e-6) return null
|
||||
|
||||
const centerLocalZ = node.asset.attachTo === 'wall-side' ? depth / 2 : 0
|
||||
const [centerOffsetX, centerOffsetY] = rotateVec(0, centerLocalZ, transform.rotation)
|
||||
const cx = transform.x + centerOffsetX
|
||||
const cy = transform.y + centerOffsetY
|
||||
const halfWidth = width / 2
|
||||
const halfDepth = depth / 2
|
||||
const point = (x: number, y: number): FloorplanPoint => {
|
||||
const [rx, ry] = rotateVec(x, y, transform.rotation)
|
||||
return [cx + rx, cy + ry]
|
||||
}
|
||||
const widthNormal = rotateVec(0, -1, transform.rotation)
|
||||
const depthNormal = rotateVec(1, 0, transform.rotation)
|
||||
const unit = ctx.viewState?.unit ?? 'metric'
|
||||
const metricNotation = readFloorplanMetricNotationOverride(ctx) ?? 'meters'
|
||||
const stroke = ctx.viewState?.palette?.selectedStroke ?? '#2563eb'
|
||||
|
||||
return {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'dimension',
|
||||
start: point(-halfWidth, -halfDepth),
|
||||
end: point(halfWidth, -halfDepth),
|
||||
offsetNormal: widthNormal,
|
||||
offsetDistance: 0.28,
|
||||
extensionOvershoot: 0.08,
|
||||
text: formatLinearMeasurement(width, unit, metricNotation),
|
||||
stroke,
|
||||
},
|
||||
{
|
||||
kind: 'dimension',
|
||||
start: point(halfWidth, -halfDepth),
|
||||
end: point(halfWidth, halfDepth),
|
||||
offsetNormal: depthNormal,
|
||||
offsetDistance: 0.28,
|
||||
extensionOvershoot: 0.08,
|
||||
text: formatLinearMeasurement(depth, unit, metricNotation),
|
||||
stroke,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function buildItemFloorplan(node: ItemNode, ctx: GeometryContext): FloorplanGeometry | null {
|
||||
const transform = resolveItemTransform(node, ctx)
|
||||
if (!transform) return null
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { getFloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { measurementDefinition } from './definition'
|
||||
|
||||
describe('measurementDefinition', () => {
|
||||
@@ -21,6 +22,9 @@ describe('measurementDefinition', () => {
|
||||
)
|
||||
expect(measurementDefinition.presentation?.actionMenu).toBe(false)
|
||||
expect(measurementDefinition.parametrics).toBeUndefined()
|
||||
expect(
|
||||
getFloorplanNodeExtension(measurementDefinition)?.referencedSelectionAnnotationRole,
|
||||
).toBe('measurement')
|
||||
expect(measurementDefinition.toolHints?.map((hint) => hint.key)).toEqual([
|
||||
'Left click',
|
||||
'Enter',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { measurementReferenceNodeIds, type NodeDefinition } from '@pascal-app/core'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { buildMeasurementFloorplan } from './floorplan'
|
||||
import { measurementMoveVertexAffordance } from './floorplan-affordance'
|
||||
import { MeasurementNode } from './schema'
|
||||
@@ -40,6 +41,11 @@ export const measurementDefinition: NodeDefinition<typeof MeasurementNode> = {
|
||||
},
|
||||
floorplan: buildMeasurementFloorplan,
|
||||
floorplanDependencies: (node) => measurementReferenceNodeIds(node.measurement),
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
referencedSelectionAnnotationRole: 'measurement',
|
||||
} satisfies FloorplanNodeExtension,
|
||||
},
|
||||
floorplanAffordances: {
|
||||
'move-measurement-vertex': measurementMoveVertexAffordance,
|
||||
},
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
snapScalar,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { getSegmentGridStep } from '@pascal-app/editor'
|
||||
import { getSegmentGridStep, isAngleSnapActive } from '@pascal-app/editor'
|
||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||
|
||||
@@ -91,14 +92,16 @@ export const roofSegmentResizeAffordance: FloorplanAffordance<RoofSegmentNode> =
|
||||
const snappedValue = step > 0 ? snapScalar(rawValue, step) : rawValue
|
||||
const newValue = Math.max(MIN_ROOF_DIM, snappedValue)
|
||||
lastValue = newValue
|
||||
useScene
|
||||
useLiveNodeOverrides
|
||||
.getState()
|
||||
.updateNode(segmentId, axis === 'x' ? { width: newValue } : { depth: newValue })
|
||||
.set(segmentId, axis === 'x' ? { width: newValue } : { depth: newValue })
|
||||
useScene.getState().markDirty(segmentId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(segmentId)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(segmentId, axis === 'x' ? { width: lastValue } : { depth: lastValue })
|
||||
@@ -125,20 +128,22 @@ export const roofSegmentRotateAffordance: FloorplanAffordance<RoofSegmentNode> =
|
||||
|
||||
return {
|
||||
affectedIds: [segmentId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
apply({ planPoint }) {
|
||||
const delta = rotateAffordanceDelta({
|
||||
center: [cx, cz],
|
||||
initialAngle,
|
||||
planPoint,
|
||||
free: modifiers.shiftKey,
|
||||
free: !isAngleSnapActive(),
|
||||
})
|
||||
lastRotation = initialRotation - delta
|
||||
useScene.getState().updateNode(segmentId, { rotation: lastRotation })
|
||||
useLiveNodeOverrides.getState().set(segmentId, { rotation: lastRotation })
|
||||
useScene.getState().markDirty(segmentId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(segmentId)
|
||||
useScene.getState().updateNode(segmentId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
@@ -186,12 +191,14 @@ export const roofSegmentMoveTarget: FloorplanMoveTarget<RoofSegmentNode> = ({ no
|
||||
let localX = dx * cosRoof + dz * sinRoof
|
||||
let localZ = -dx * sinRoof + dz * cosRoof
|
||||
lastLocal = [localX, initialY, localZ]
|
||||
useScene.getState().updateNode(segmentId, { position: lastLocal })
|
||||
useLiveNodeOverrides.getState().set(segmentId, { position: lastLocal })
|
||||
useScene.getState().markDirty(segmentId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(segmentId)
|
||||
useScene.getState().updateNode(segmentId, { position: lastLocal })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
CabinetNode,
|
||||
DoorNode,
|
||||
ItemNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
buildClearanceAdvisories,
|
||||
type ClearanceProfile,
|
||||
DEFAULT_CLEARANCE_PROFILES,
|
||||
} from './clearance-advisories'
|
||||
|
||||
const adaProfile: ClearanceProfile = {
|
||||
...DEFAULT_CLEARANCE_PROFILES.find((profile) => profile.id === 'us-ada-2010-advisory')!,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const officeProfile: ClearanceProfile = {
|
||||
...DEFAULT_CLEARANCE_PROFILES.find((profile) => profile.id === 'office-residential-advisory')!,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
function nodes(...items: AnyNode[]): Record<string, AnyNode> {
|
||||
return Object.fromEntries(items.map((item) => [item.id, item])) as Record<string, AnyNode>
|
||||
}
|
||||
|
||||
describe('clearance advisories', () => {
|
||||
test('keeps default clearance profiles optional and quiet', () => {
|
||||
const narrowHall = ZoneNode.parse({
|
||||
id: 'zone_hall',
|
||||
name: 'Hallway',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[0.8, 0],
|
||||
[0.8, 4],
|
||||
[0, 4],
|
||||
],
|
||||
})
|
||||
|
||||
expect(buildClearanceAdvisories(nodes(narrowHall))).toEqual([])
|
||||
})
|
||||
|
||||
test('checks circulation, entry, and door clear widths with ADA provenance', () => {
|
||||
const hall = ZoneNode.parse({
|
||||
id: 'zone_hall',
|
||||
name: 'North Corridor',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[0.8, 0],
|
||||
[0.8, 5],
|
||||
[0, 5],
|
||||
],
|
||||
})
|
||||
const entry = ZoneNode.parse({
|
||||
id: 'zone_entry',
|
||||
name: 'Entry vestibule',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[0.86, 0],
|
||||
[0.86, 2],
|
||||
[0, 2],
|
||||
],
|
||||
})
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_narrow',
|
||||
width: 0.78,
|
||||
})
|
||||
|
||||
const advisories = buildClearanceAdvisories(nodes(hall, entry, door), {
|
||||
profiles: [adaProfile],
|
||||
})
|
||||
|
||||
expect(advisories.map((advisory) => advisory.ruleId)).toEqual([
|
||||
'ada-door-clear-opening',
|
||||
'ada-entry-clear-width',
|
||||
'ada-accessible-route-clear-width',
|
||||
])
|
||||
expect(advisories.every((advisory) => advisory.source.edition === '2010')).toBe(true)
|
||||
expect(advisories.every((advisory) => advisory.severity === 'warning')).toBe(true)
|
||||
})
|
||||
|
||||
test('reports missing fixture, cabinet, and appliance clearance evidence', () => {
|
||||
const toilet = ItemNode.parse({
|
||||
id: 'item_toilet',
|
||||
asset: {
|
||||
id: 'asset_toilet',
|
||||
category: 'plumbing',
|
||||
name: 'Accessible Toilet',
|
||||
thumbnail: '',
|
||||
src: 'asset://toilet.glb',
|
||||
tags: ['fixture'],
|
||||
},
|
||||
})
|
||||
const sinkCabinet = CabinetNode.parse({
|
||||
id: 'cabinet_sink',
|
||||
stack: [{ id: 'sink', type: 'sink' }],
|
||||
})
|
||||
const applianceCabinet = CabinetNode.parse({
|
||||
id: 'cabinet_dishwasher',
|
||||
stack: [{ id: 'dishwasher', type: 'dishwasher' }],
|
||||
})
|
||||
|
||||
const advisories = buildClearanceAdvisories(nodes(toilet, sinkCabinet, applianceCabinet), {
|
||||
profiles: [adaProfile, officeProfile],
|
||||
})
|
||||
|
||||
expect(advisories.map((advisory) => advisory.id)).toEqual([
|
||||
'clearance:office-residential-advisory:cabinet_dishwasher:office-appliance-front-clearance',
|
||||
'clearance:office-residential-advisory:cabinet_dishwasher:office-cabinet-front-clearance',
|
||||
'clearance:office-residential-advisory:cabinet_sink:office-cabinet-front-clearance',
|
||||
'clearance:us-ada-2010-advisory:cabinet_sink:ada-fixture-clear-floor-depth',
|
||||
'clearance:us-ada-2010-advisory:cabinet_sink:ada-fixture-clear-floor-width',
|
||||
'clearance:us-ada-2010-advisory:item_toilet:ada-fixture-clear-floor-depth',
|
||||
'clearance:us-ada-2010-advisory:item_toilet:ada-fixture-clear-floor-width',
|
||||
])
|
||||
expect(advisories.every((advisory) => advisory.measured === null)).toBe(true)
|
||||
expect(advisories.every((advisory) => advisory.severity === 'info')).toBe(true)
|
||||
})
|
||||
|
||||
test('accepts explicit clearance evidence for surrounding cabinet and fixture checks', () => {
|
||||
const toilet = ItemNode.parse({
|
||||
id: 'item_toilet',
|
||||
asset: {
|
||||
id: 'asset_toilet',
|
||||
category: 'plumbing',
|
||||
name: 'Accessible Toilet',
|
||||
thumbnail: '',
|
||||
src: 'asset://toilet.glb',
|
||||
tags: ['fixture'],
|
||||
},
|
||||
})
|
||||
const cabinet = CabinetNode.parse({
|
||||
id: 'cabinet_base',
|
||||
})
|
||||
|
||||
const advisories = buildClearanceAdvisories(nodes(toilet, cabinet), {
|
||||
profiles: [adaProfile, officeProfile],
|
||||
evidence: {
|
||||
item_toilet: {
|
||||
'ada-fixture-clear-floor-width': 0.9,
|
||||
'ada-fixture-clear-floor-depth': 1.0,
|
||||
},
|
||||
cabinet_base: {
|
||||
'office-cabinet-front-clearance': 1.0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(advisories.map((advisory) => advisory.ruleId)).toEqual(['ada-fixture-clear-floor-depth'])
|
||||
expect(advisories[0]?.measured).toBe(1)
|
||||
expect(advisories[0]?.severity).toBe('warning')
|
||||
})
|
||||
|
||||
test('checks closet depth and stair geometry from modeled dimensions', () => {
|
||||
const closet = ZoneNode.parse({
|
||||
id: 'zone_closet',
|
||||
name: 'Bedroom Closet',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[0.55, 0],
|
||||
[0.55, 2],
|
||||
[0, 2],
|
||||
],
|
||||
})
|
||||
const stair = StairNode.parse({
|
||||
id: 'stair_tall_riser',
|
||||
width: 0.82,
|
||||
totalRise: 2.8,
|
||||
stepCount: 12,
|
||||
})
|
||||
const segment = StairSegmentNode.parse({
|
||||
id: 'sseg_shallow_treads',
|
||||
width: 1,
|
||||
length: 2.2,
|
||||
height: 2,
|
||||
stepCount: 10,
|
||||
})
|
||||
|
||||
const advisories = buildClearanceAdvisories(nodes(closet, stair, segment), {
|
||||
profiles: [officeProfile],
|
||||
})
|
||||
|
||||
expect(advisories.map((advisory) => advisory.ruleId)).toEqual([
|
||||
'office-stair-tread-depth',
|
||||
'office-stair-riser-height',
|
||||
'office-stair-tread-depth',
|
||||
'office-stair-width',
|
||||
'office-closet-depth',
|
||||
])
|
||||
expect(advisories.every((advisory) => advisory.source.title.includes('Pascal'))).toBe(true)
|
||||
})
|
||||
|
||||
test('can include disabled profiles for profile preview UIs', () => {
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_preview',
|
||||
width: 0.78,
|
||||
})
|
||||
|
||||
const advisories = buildClearanceAdvisories(nodes(door), {
|
||||
includeDisabled: true,
|
||||
})
|
||||
|
||||
expect(advisories.map((advisory) => advisory.profileId)).toEqual(['us-ada-2010-advisory'])
|
||||
})
|
||||
})
|
||||
@@ -1,513 +0,0 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type CabinetModuleNode,
|
||||
type CabinetNode,
|
||||
type DoorNode,
|
||||
type ItemNode,
|
||||
resolveStairTotalRise,
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { formatConstructionLength } from './construction-length'
|
||||
|
||||
export type ClearanceAdvisoryCategory =
|
||||
| 'circulation'
|
||||
| 'entry'
|
||||
| 'door-approach'
|
||||
| 'fixture'
|
||||
| 'cabinet'
|
||||
| 'appliance'
|
||||
| 'closet'
|
||||
| 'stair'
|
||||
|
||||
export type ClearanceAdvisorySeverity = 'info' | 'warning'
|
||||
|
||||
export type ClearanceRuleSource = {
|
||||
title: string
|
||||
edition: string
|
||||
section: string
|
||||
url?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
export type ClearanceRule = {
|
||||
id: string
|
||||
category: ClearanceAdvisoryCategory
|
||||
label: string
|
||||
measurement:
|
||||
| 'clear-width'
|
||||
| 'clear-depth'
|
||||
| 'clear-floor-width'
|
||||
| 'clear-floor-depth'
|
||||
| 'front-clearance'
|
||||
| 'stair-width'
|
||||
| 'tread-depth'
|
||||
| 'riser-height'
|
||||
minValue: number
|
||||
source: ClearanceRuleSource
|
||||
}
|
||||
|
||||
export type ClearanceProfile = {
|
||||
id: string
|
||||
label: string
|
||||
jurisdiction?: string
|
||||
enabled: boolean
|
||||
rules: readonly ClearanceRule[]
|
||||
}
|
||||
|
||||
export type ClearanceEvidence = Readonly<
|
||||
Record<string, Partial<Record<ClearanceRule['id'], number>>>
|
||||
>
|
||||
|
||||
export type BuildClearanceAdvisoriesOptions = {
|
||||
profiles?: readonly ClearanceProfile[]
|
||||
includeDisabled?: boolean
|
||||
evidence?: ClearanceEvidence
|
||||
}
|
||||
|
||||
export type ClearanceAdvisory = {
|
||||
id: string
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
profileId: string
|
||||
profileLabel: string
|
||||
category: ClearanceAdvisoryCategory
|
||||
ruleId: string
|
||||
label: string
|
||||
measured: number | null
|
||||
required: number
|
||||
severity: ClearanceAdvisorySeverity
|
||||
source: ClearanceRuleSource
|
||||
message: string
|
||||
}
|
||||
|
||||
type ClearanceTarget = {
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
category: ClearanceAdvisoryCategory
|
||||
measurements: Partial<Record<ClearanceRule['measurement'], number>>
|
||||
}
|
||||
|
||||
const ADA_2010: Pick<ClearanceRuleSource, 'title' | 'edition' | 'url'> = {
|
||||
title: '2010 ADA Standards for Accessible Design',
|
||||
edition: '2010',
|
||||
url: 'https://www.access-board.gov/ada/',
|
||||
}
|
||||
|
||||
const OFFICE_STANDARD: Pick<ClearanceRuleSource, 'title' | 'edition'> = {
|
||||
title: 'Pascal construction-document advisory profile',
|
||||
edition: '2026-07-21',
|
||||
}
|
||||
|
||||
export const DEFAULT_CLEARANCE_PROFILES: readonly ClearanceProfile[] = [
|
||||
{
|
||||
id: 'us-ada-2010-advisory',
|
||||
label: 'U.S. ADA 2010 advisory checks',
|
||||
jurisdiction: 'US',
|
||||
enabled: false,
|
||||
rules: [
|
||||
{
|
||||
id: 'ada-accessible-route-clear-width',
|
||||
category: 'circulation',
|
||||
label: 'accessible route clear width',
|
||||
measurement: 'clear-width',
|
||||
minValue: 36 * 0.0254,
|
||||
source: {
|
||||
...ADA_2010,
|
||||
section: '403.5.1',
|
||||
note: 'Accessible routes generally require 36 inches minimum clear width.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ada-entry-clear-width',
|
||||
category: 'entry',
|
||||
label: 'entry clear width',
|
||||
measurement: 'clear-width',
|
||||
minValue: 36 * 0.0254,
|
||||
source: {
|
||||
...ADA_2010,
|
||||
section: '403.5.1',
|
||||
note: 'Entries serving an accessible route are checked against the route clear width.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ada-door-clear-opening',
|
||||
category: 'door-approach',
|
||||
label: 'door clear opening',
|
||||
measurement: 'clear-width',
|
||||
minValue: 32 * 0.0254,
|
||||
source: {
|
||||
...ADA_2010,
|
||||
section: '404.2.3',
|
||||
note: 'Door openings on accessible routes require 32 inches minimum clear width.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ada-fixture-clear-floor-width',
|
||||
category: 'fixture',
|
||||
label: 'fixture clear floor space width',
|
||||
measurement: 'clear-floor-width',
|
||||
minValue: 30 * 0.0254,
|
||||
source: {
|
||||
...ADA_2010,
|
||||
section: '305.3',
|
||||
note: 'Clear floor or ground space is 30 inches minimum by 48 inches minimum.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ada-fixture-clear-floor-depth',
|
||||
category: 'fixture',
|
||||
label: 'fixture clear floor space depth',
|
||||
measurement: 'clear-floor-depth',
|
||||
minValue: 48 * 0.0254,
|
||||
source: {
|
||||
...ADA_2010,
|
||||
section: '305.3',
|
||||
note: 'Clear floor or ground space is 30 inches minimum by 48 inches minimum.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'office-residential-advisory',
|
||||
label: 'Office residential advisory checks',
|
||||
enabled: false,
|
||||
rules: [
|
||||
{
|
||||
id: 'office-cabinet-front-clearance',
|
||||
category: 'cabinet',
|
||||
label: 'cabinet front working clearance',
|
||||
measurement: 'front-clearance',
|
||||
minValue: 0.9,
|
||||
source: {
|
||||
...OFFICE_STANDARD,
|
||||
section: 'Kitchen working clearances',
|
||||
note: 'Office drafting convention for cabinet and drawer operation clearance.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'office-appliance-front-clearance',
|
||||
category: 'appliance',
|
||||
label: 'appliance front working clearance',
|
||||
measurement: 'front-clearance',
|
||||
minValue: 0.9,
|
||||
source: {
|
||||
...OFFICE_STANDARD,
|
||||
section: 'Kitchen appliance clearances',
|
||||
note: 'Office drafting convention for appliance door and working clearance.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'office-closet-depth',
|
||||
category: 'closet',
|
||||
label: 'closet clear depth',
|
||||
measurement: 'clear-depth',
|
||||
minValue: 0.6,
|
||||
source: {
|
||||
...OFFICE_STANDARD,
|
||||
section: 'Storage clearances',
|
||||
note: 'Office drafting convention for reach-in closet depth.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'office-stair-width',
|
||||
category: 'stair',
|
||||
label: 'stair clear width',
|
||||
measurement: 'stair-width',
|
||||
minValue: 0.9,
|
||||
source: {
|
||||
...OFFICE_STANDARD,
|
||||
section: 'Residential stair geometry',
|
||||
note: 'Office drafting convention; verify against local stair code before permit use.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'office-stair-tread-depth',
|
||||
category: 'stair',
|
||||
label: 'stair tread depth',
|
||||
measurement: 'tread-depth',
|
||||
minValue: 0.25,
|
||||
source: {
|
||||
...OFFICE_STANDARD,
|
||||
section: 'Residential stair geometry',
|
||||
note: 'Office drafting convention; verify against local stair code before permit use.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'office-stair-riser-height',
|
||||
category: 'stair',
|
||||
label: 'stair riser height',
|
||||
measurement: 'riser-height',
|
||||
minValue: -0.2,
|
||||
source: {
|
||||
...OFFICE_STANDARD,
|
||||
section: 'Residential stair geometry',
|
||||
note: 'Negative minValue means measured riser height must be less than or equal to the absolute value.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
export function buildClearanceAdvisories(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
options: BuildClearanceAdvisoriesOptions = {},
|
||||
): ClearanceAdvisory[] {
|
||||
const profiles = (options.profiles ?? DEFAULT_CLEARANCE_PROFILES).filter(
|
||||
(profile) => options.includeDisabled === true || profile.enabled,
|
||||
)
|
||||
if (profiles.length === 0) return []
|
||||
|
||||
const targets = Object.values(nodes).flatMap((node) => clearanceTargets(node, nodes))
|
||||
const advisories: ClearanceAdvisory[] = []
|
||||
|
||||
for (const target of targets) {
|
||||
for (const profile of profiles) {
|
||||
for (const rule of profile.rules) {
|
||||
if (rule.category !== target.category) continue
|
||||
const measured =
|
||||
target.measurements[rule.measurement] ?? options.evidence?.[target.nodeId]?.[rule.id]
|
||||
if (measured === undefined) {
|
||||
advisories.push(clearanceAdvisory({ target, profile, rule, measured: null }))
|
||||
continue
|
||||
}
|
||||
if (violatesClearanceRule(measured, rule)) {
|
||||
advisories.push(clearanceAdvisory({ target, profile, rule, measured }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return advisories.sort((left, right) => left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
function clearanceTargets(
|
||||
node: AnyNode,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): ClearanceTarget[] {
|
||||
if (node.type === 'zone') return zoneTargets(node)
|
||||
if (node.type === 'door') return doorTargets(node)
|
||||
if (node.type === 'item') return itemTargets(node)
|
||||
if (node.type === 'cabinet' || node.type === 'cabinet-module') return cabinetTargets(node)
|
||||
if (node.type === 'stair') return stairTargets(node, nodes)
|
||||
if (node.type === 'stair-segment') return stairSegmentTargets(node)
|
||||
return []
|
||||
}
|
||||
|
||||
function zoneTargets(zone: ZoneNode): ClearanceTarget[] {
|
||||
const role = normalizedText([zone.name, zone.occupancy, String(zone.metadata ?? '')])
|
||||
const dimensions = zoneClearDimensions(zone)
|
||||
const targets: ClearanceTarget[] = []
|
||||
|
||||
if (containsAny(role, ['hall', 'hallway', 'corridor', 'passage', 'circulation'])) {
|
||||
targets.push({
|
||||
nodeId: zone.id,
|
||||
nodeType: zone.type,
|
||||
category: 'circulation',
|
||||
measurements: { 'clear-width': dimensions.minSpan },
|
||||
})
|
||||
}
|
||||
|
||||
if (containsAny(role, ['entry', 'entrance', 'vestibule', 'foyer'])) {
|
||||
targets.push({
|
||||
nodeId: zone.id,
|
||||
nodeType: zone.type,
|
||||
category: 'entry',
|
||||
measurements: { 'clear-width': dimensions.minSpan },
|
||||
})
|
||||
}
|
||||
|
||||
if (containsAny(role, ['closet', 'wardrobe'])) {
|
||||
targets.push({
|
||||
nodeId: zone.id,
|
||||
nodeType: zone.type,
|
||||
category: 'closet',
|
||||
measurements: { 'clear-depth': dimensions.minSpan },
|
||||
})
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
function doorTargets(door: DoorNode): ClearanceTarget[] {
|
||||
return [
|
||||
{
|
||||
nodeId: door.id,
|
||||
nodeType: door.type,
|
||||
category: 'door-approach',
|
||||
measurements: { 'clear-width': door.width },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function itemTargets(item: ItemNode): ClearanceTarget[] {
|
||||
const text = normalizedText([
|
||||
item.asset.name,
|
||||
item.asset.category,
|
||||
...(item.asset.tags ?? []),
|
||||
...(item.asset.functionTags ?? []),
|
||||
])
|
||||
const targets: ClearanceTarget[] = []
|
||||
|
||||
if (containsAny(text, ['toilet', 'lavatory', 'sink', 'fixture', 'tub', 'shower', 'wc'])) {
|
||||
targets.push({
|
||||
nodeId: item.id,
|
||||
nodeType: item.type,
|
||||
category: 'fixture',
|
||||
measurements: {},
|
||||
})
|
||||
}
|
||||
|
||||
if (containsAny(text, ['appliance', 'fridge', 'refrigerator', 'oven', 'range', 'dishwasher'])) {
|
||||
targets.push({
|
||||
nodeId: item.id,
|
||||
nodeType: item.type,
|
||||
category: 'appliance',
|
||||
measurements: {},
|
||||
})
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
function cabinetTargets(cabinet: CabinetNode | CabinetModuleNode): ClearanceTarget[] {
|
||||
const targets: ClearanceTarget[] = [
|
||||
{
|
||||
nodeId: cabinet.id,
|
||||
nodeType: cabinet.type,
|
||||
category: 'cabinet',
|
||||
measurements: {},
|
||||
},
|
||||
]
|
||||
|
||||
if ((cabinet.stack ?? []).some((compartment) => isApplianceCompartment(compartment.type))) {
|
||||
targets.push({
|
||||
nodeId: cabinet.id,
|
||||
nodeType: cabinet.type,
|
||||
category: 'appliance',
|
||||
measurements: {},
|
||||
})
|
||||
}
|
||||
|
||||
if ((cabinet.stack ?? []).some((compartment) => compartment.type === 'sink')) {
|
||||
targets.push({
|
||||
nodeId: cabinet.id,
|
||||
nodeType: cabinet.type,
|
||||
category: 'fixture',
|
||||
measurements: {},
|
||||
})
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
function stairTargets(
|
||||
stair: StairNode,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): ClearanceTarget[] {
|
||||
const measurements: ClearanceTarget['measurements'] = { 'stair-width': stair.width }
|
||||
const totalRise = resolveStairTotalRise(stair, nodes as Record<string, AnyNode>)
|
||||
if (stair.stepCount > 0 && totalRise > 0) {
|
||||
measurements['riser-height'] = totalRise / stair.stepCount
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
nodeId: stair.id,
|
||||
nodeType: stair.type,
|
||||
category: 'stair',
|
||||
measurements,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function stairSegmentTargets(segment: StairSegmentNode): ClearanceTarget[] {
|
||||
const measurements: ClearanceTarget['measurements'] = { 'stair-width': segment.width }
|
||||
if (segment.segmentType === 'stair' && segment.stepCount > 0) {
|
||||
measurements['tread-depth'] = segment.length / segment.stepCount
|
||||
if (segment.height > 0) measurements['riser-height'] = segment.height / segment.stepCount
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
nodeId: segment.id,
|
||||
nodeType: segment.type,
|
||||
category: 'stair',
|
||||
measurements,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function clearanceAdvisory(args: {
|
||||
target: ClearanceTarget
|
||||
profile: ClearanceProfile
|
||||
rule: ClearanceRule
|
||||
measured: number | null
|
||||
}): ClearanceAdvisory {
|
||||
const { target, profile, rule, measured } = args
|
||||
const measuredLabel =
|
||||
measured === null ? 'not verified' : formatConstructionLength(measured, 'metric')
|
||||
const requiredLabel = formatConstructionLength(Math.abs(rule.minValue), 'metric')
|
||||
const comparator = rule.minValue < 0 ? 'at most' : 'at least'
|
||||
|
||||
return {
|
||||
id: ['clearance', profile.id, target.nodeId, rule.id].join(':'),
|
||||
nodeId: target.nodeId,
|
||||
nodeType: target.nodeType,
|
||||
profileId: profile.id,
|
||||
profileLabel: profile.label,
|
||||
category: rule.category,
|
||||
ruleId: rule.id,
|
||||
label: rule.label,
|
||||
measured,
|
||||
required: Math.abs(rule.minValue),
|
||||
severity: measured === null ? 'info' : 'warning',
|
||||
source: rule.source,
|
||||
message:
|
||||
measured === null
|
||||
? `${titleCase(target.nodeType)} ${target.nodeId} requires ${rule.label} verification (${comparator} ${requiredLabel}) per ${rule.source.title} ${rule.source.edition} ${rule.source.section}.`
|
||||
: `${titleCase(target.nodeType)} ${target.nodeId} ${rule.label} ${measuredLabel} is below ${requiredLabel} per ${rule.source.title} ${rule.source.edition} ${rule.source.section}.`,
|
||||
}
|
||||
}
|
||||
|
||||
function violatesClearanceRule(measured: number, rule: ClearanceRule): boolean {
|
||||
if (!Number.isFinite(measured)) return true
|
||||
if (rule.minValue < 0) return measured > Math.abs(rule.minValue)
|
||||
return measured < rule.minValue
|
||||
}
|
||||
|
||||
function zoneClearDimensions(zone: ZoneNode): { minSpan: number } {
|
||||
const xs = zone.polygon.map((point) => point[0])
|
||||
const zs = zone.polygon.map((point) => point[1])
|
||||
if (xs.length === 0 || zs.length === 0) return { minSpan: 0 }
|
||||
return {
|
||||
minSpan: Math.min(Math.max(...xs) - Math.min(...xs), Math.max(...zs) - Math.min(...zs)),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedText(parts: readonly string[]): string {
|
||||
return parts.join(' ').toLowerCase()
|
||||
}
|
||||
|
||||
function containsAny(text: string, needles: readonly string[]): boolean {
|
||||
return needles.some((needle) => text.includes(needle))
|
||||
}
|
||||
|
||||
function isApplianceCompartment(type: string): boolean {
|
||||
return [
|
||||
'oven',
|
||||
'microwave',
|
||||
'dishwasher',
|
||||
'cooktop-gas',
|
||||
'cooktop-induction',
|
||||
'fridge-single',
|
||||
'fridge-double',
|
||||
'fridge-top-freezer',
|
||||
'fridge-bottom-freezer',
|
||||
].includes(type)
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, DoorNode, WallNode, WindowNode } from '@pascal-app/core'
|
||||
import {
|
||||
buildConstructionModuleAdvisories,
|
||||
type ConstructionModuleProfile,
|
||||
DEFAULT_CONSTRUCTION_MODULE_PROFILES,
|
||||
} from './construction-module-advisories'
|
||||
|
||||
const FOOT = 0.3048
|
||||
|
||||
const metricProfile: ConstructionModuleProfile = {
|
||||
...DEFAULT_CONSTRUCTION_MODULE_PROFILES.find((profile) => profile.id === 'metric-common')!,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const imperialProfile: ConstructionModuleProfile = {
|
||||
...DEFAULT_CONSTRUCTION_MODULE_PROFILES.find((profile) => profile.id === 'imperial-common')!,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
function nodes(...items: AnyNode[]): Record<string, AnyNode> {
|
||||
return Object.fromEntries(items.map((item) => [item.id, item])) as Record<string, AnyNode>
|
||||
}
|
||||
|
||||
describe('construction module advisories', () => {
|
||||
test('keeps default construction module profiles optional and quiet', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_off_module',
|
||||
start: [0, 0],
|
||||
end: [3.97, 0],
|
||||
})
|
||||
|
||||
expect(buildConstructionModuleAdvisories(nodes(wall))).toEqual([])
|
||||
})
|
||||
|
||||
test('reports metric wall lengths that miss the configured construction module', () => {
|
||||
const compliantWall = WallNode.parse({
|
||||
id: 'wall_metric_ok',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
})
|
||||
const offModuleWall = WallNode.parse({
|
||||
id: 'wall_metric_off',
|
||||
start: [0, 0],
|
||||
end: [3.97, 0],
|
||||
})
|
||||
|
||||
const advisories = buildConstructionModuleAdvisories(nodes(compliantWall, offModuleWall), {
|
||||
profiles: [metricProfile],
|
||||
})
|
||||
|
||||
expect(advisories).toHaveLength(1)
|
||||
expect(advisories[0]).toMatchObject({
|
||||
id: 'construction-module:metric-common:wall_metric_off:wall-length',
|
||||
nodeId: 'wall_metric_off',
|
||||
profileId: 'metric-common',
|
||||
kind: 'wall-length',
|
||||
module: 0.1,
|
||||
measured: 3.97,
|
||||
nearestMultiple: 4,
|
||||
severity: 'info',
|
||||
})
|
||||
expect(advisories[0]?.deviation).toBeCloseTo(0.03)
|
||||
expect(advisories[0]?.message).toContain('100 mm construction module')
|
||||
})
|
||||
|
||||
test('checks overall level extents at exterior finish faces', () => {
|
||||
const walls = [
|
||||
WallNode.parse({
|
||||
id: 'wall_bottom',
|
||||
parentId: 'level_main',
|
||||
start: [0, 0],
|
||||
end: [4.03, 0],
|
||||
thickness: 0.2,
|
||||
}),
|
||||
WallNode.parse({
|
||||
id: 'wall_right',
|
||||
parentId: 'level_main',
|
||||
start: [4.03, 0],
|
||||
end: [4.03, 3],
|
||||
thickness: 0.2,
|
||||
}),
|
||||
WallNode.parse({
|
||||
id: 'wall_top',
|
||||
parentId: 'level_main',
|
||||
start: [4.03, 3],
|
||||
end: [0, 3],
|
||||
thickness: 0.2,
|
||||
}),
|
||||
WallNode.parse({
|
||||
id: 'wall_left',
|
||||
parentId: 'level_main',
|
||||
start: [0, 3],
|
||||
end: [0, 0],
|
||||
thickness: 0.2,
|
||||
}),
|
||||
]
|
||||
|
||||
const advisories = buildConstructionModuleAdvisories(nodes(...walls), {
|
||||
profiles: [metricProfile],
|
||||
})
|
||||
|
||||
expect(advisories).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'construction-module:metric-common:level_main:level-overall-width',
|
||||
nodeId: 'level_main',
|
||||
nodeType: 'level',
|
||||
kind: 'level-overall-width',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
advisories.find((advisory) => advisory.kind === 'level-overall-width')?.measured,
|
||||
).toBeCloseTo(4.23)
|
||||
expect(advisories).not.toContainEqual(expect.objectContaining({ kind: 'level-overall-depth' }))
|
||||
})
|
||||
|
||||
test('reports imperial opening widths that miss common inch modules', () => {
|
||||
const compliantDoor = DoorNode.parse({
|
||||
id: 'door_imperial_ok',
|
||||
width: 3 * FOOT,
|
||||
})
|
||||
const offModuleDoor = DoorNode.parse({
|
||||
id: 'door_imperial_off',
|
||||
width: 0.95,
|
||||
})
|
||||
|
||||
const advisories = buildConstructionModuleAdvisories(nodes(compliantDoor, offModuleDoor), {
|
||||
profiles: [imperialProfile],
|
||||
})
|
||||
|
||||
expect(advisories).toHaveLength(1)
|
||||
expect(advisories[0]).toMatchObject({
|
||||
id: 'construction-module:imperial-common:door_imperial_off:opening-width',
|
||||
nodeId: 'door_imperial_off',
|
||||
profileId: 'imperial-common',
|
||||
kind: 'opening-width',
|
||||
})
|
||||
expect(advisories[0]?.module).toBeCloseTo(12 * 0.0254)
|
||||
expect(advisories[0]?.message).toContain('1\'-0" construction module')
|
||||
})
|
||||
|
||||
test('checks verified rough, masonry, and finish opening widths without inventing them', () => {
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_verified_widths',
|
||||
width: 1.2,
|
||||
roughOpeningWidth: 1.23,
|
||||
masonryOpeningWidth: 1.4,
|
||||
})
|
||||
const window = WindowNode.parse({
|
||||
id: 'window_verified_widths',
|
||||
width: 1.2,
|
||||
finishOpeningWidth: 1.27,
|
||||
})
|
||||
|
||||
const advisories = buildConstructionModuleAdvisories(nodes(door, window), {
|
||||
profiles: [metricProfile],
|
||||
})
|
||||
|
||||
expect(advisories.map((advisory) => advisory.id)).toEqual([
|
||||
'construction-module:metric-common:door_verified_widths:rough-opening-width',
|
||||
'construction-module:metric-common:window_verified_widths:finish-opening-width',
|
||||
])
|
||||
})
|
||||
|
||||
test('can explicitly include disabled profiles for preflight previews', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_preview',
|
||||
start: [0, 0],
|
||||
end: [3.97, 0],
|
||||
})
|
||||
|
||||
const advisories = buildConstructionModuleAdvisories(nodes(wall), {
|
||||
includeDisabled: true,
|
||||
})
|
||||
|
||||
expect(advisories.map((advisory) => advisory.profileId).sort()).toEqual([
|
||||
'imperial-common',
|
||||
'metric-common',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,326 +0,0 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type DoorNode,
|
||||
getWallAssemblyFaceOffsets,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { formatConstructionLength } from './construction-length'
|
||||
|
||||
const INCH = 0.0254
|
||||
|
||||
export type ConstructionModuleSystem = 'imperial' | 'metric'
|
||||
export type ConstructionModuleAdvisorySeverity = 'info' | 'warning'
|
||||
|
||||
export type ConstructionModuleProfile = {
|
||||
id: string
|
||||
label: string
|
||||
system: ConstructionModuleSystem
|
||||
modules: readonly number[]
|
||||
tolerance: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ConstructionModuleMeasurementKind =
|
||||
| 'wall-length'
|
||||
| 'level-overall-width'
|
||||
| 'level-overall-depth'
|
||||
| 'opening-width'
|
||||
| 'rough-opening-width'
|
||||
| 'masonry-opening-width'
|
||||
| 'finish-opening-width'
|
||||
|
||||
export type ConstructionModuleAdvisory = {
|
||||
id: string
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
profileId: string
|
||||
profileLabel: string
|
||||
system: ConstructionModuleSystem
|
||||
kind: ConstructionModuleMeasurementKind
|
||||
label: string
|
||||
module: number
|
||||
measured: number
|
||||
deviation: number
|
||||
nearestMultiple: number
|
||||
severity: ConstructionModuleAdvisorySeverity
|
||||
message: string
|
||||
}
|
||||
|
||||
export type BuildConstructionModuleAdvisoriesOptions = {
|
||||
profiles?: readonly ConstructionModuleProfile[]
|
||||
includeDisabled?: boolean
|
||||
}
|
||||
|
||||
type ConstructionModuleMeasurement = {
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
kind: ConstructionModuleMeasurementKind
|
||||
label: string
|
||||
measured: number
|
||||
}
|
||||
|
||||
type ModuleFit = {
|
||||
module: number
|
||||
nearestMultiple: number
|
||||
deviation: number
|
||||
}
|
||||
|
||||
export const DEFAULT_CONSTRUCTION_MODULE_PROFILES: readonly ConstructionModuleProfile[] = [
|
||||
{
|
||||
id: 'imperial-common',
|
||||
label: 'Imperial common modules',
|
||||
system: 'imperial',
|
||||
modules: [12 * INCH, 16 * INCH, 24 * INCH],
|
||||
tolerance: 0.25 * INCH,
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'metric-common',
|
||||
label: 'Metric common modules',
|
||||
system: 'metric',
|
||||
modules: [0.1, 0.2, 0.4, 0.6],
|
||||
tolerance: 0.005,
|
||||
enabled: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
export function buildConstructionModuleAdvisories(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
options: BuildConstructionModuleAdvisoriesOptions = {},
|
||||
): ConstructionModuleAdvisory[] {
|
||||
const profiles = (options.profiles ?? DEFAULT_CONSTRUCTION_MODULE_PROFILES).filter(
|
||||
(profile) => options.includeDisabled === true || profile.enabled,
|
||||
)
|
||||
if (profiles.length === 0) return []
|
||||
|
||||
const measurements = [
|
||||
...Object.values(nodes).flatMap((node) => constructionModuleMeasurements(node)),
|
||||
...levelOverallMeasurements(nodes),
|
||||
]
|
||||
const advisories: ConstructionModuleAdvisory[] = []
|
||||
|
||||
for (const measurement of measurements) {
|
||||
for (const profile of profiles) {
|
||||
const fit = bestModuleFit(measurement.measured, profile.modules)
|
||||
if (!fit || fit.deviation <= profile.tolerance) continue
|
||||
|
||||
advisories.push({
|
||||
id: ['construction-module', profile.id, measurement.nodeId, measurement.kind].join(':'),
|
||||
nodeId: measurement.nodeId,
|
||||
nodeType: measurement.nodeType,
|
||||
profileId: profile.id,
|
||||
profileLabel: profile.label,
|
||||
system: profile.system,
|
||||
kind: measurement.kind,
|
||||
label: measurement.label,
|
||||
module: fit.module,
|
||||
measured: measurement.measured,
|
||||
deviation: fit.deviation,
|
||||
nearestMultiple: fit.nearestMultiple,
|
||||
severity: 'info',
|
||||
message: moduleAdvisoryMessage(measurement, profile, fit),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return advisories.sort((left, right) => left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
function levelOverallMeasurements(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): ConstructionModuleMeasurement[] {
|
||||
const wallsByLevel = new Map<string, WallNode[]>()
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type !== 'wall' || !node.parentId) continue
|
||||
if (node.curveOffset !== undefined && Math.abs(node.curveOffset) > 1e-6) continue
|
||||
const levelWalls = wallsByLevel.get(node.parentId) ?? []
|
||||
levelWalls.push(node)
|
||||
wallsByLevel.set(node.parentId, levelWalls)
|
||||
}
|
||||
|
||||
const measurements: ConstructionModuleMeasurement[] = []
|
||||
for (const [levelId, walls] of wallsByLevel) {
|
||||
const primaryWall = walls.reduce((longest, wall) =>
|
||||
wallLength(wall) > wallLength(longest) ? wall : longest,
|
||||
)
|
||||
const primaryLength = wallLength(primaryWall)
|
||||
if (
|
||||
walls.length < 2 ||
|
||||
!isUsefulLength(primaryLength) ||
|
||||
!walls.some((wall) => !wallsAreParallel(primaryWall, wall))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const footprintPoints = walls.flatMap(wallFootprintPoints)
|
||||
const direction: [number, number] = [
|
||||
(primaryWall.end[0] - primaryWall.start[0]) / primaryLength,
|
||||
(primaryWall.end[1] - primaryWall.start[1]) / primaryLength,
|
||||
]
|
||||
const normal: [number, number] = [-direction[1], direction[0]]
|
||||
const along = footprintPoints.map(([x, y]) => x * direction[0] + y * direction[1])
|
||||
const across = footprintPoints.map(([x, y]) => x * normal[0] + y * normal[1])
|
||||
const width = Math.max(...along) - Math.min(...along)
|
||||
const depth = Math.max(...across) - Math.min(...across)
|
||||
|
||||
if (isUsefulLength(width)) {
|
||||
measurements.push({
|
||||
nodeId: levelId,
|
||||
nodeType: 'level',
|
||||
kind: 'level-overall-width',
|
||||
label: 'overall plan width',
|
||||
measured: width,
|
||||
})
|
||||
}
|
||||
if (isUsefulLength(depth)) {
|
||||
measurements.push({
|
||||
nodeId: levelId,
|
||||
nodeType: 'level',
|
||||
kind: 'level-overall-depth',
|
||||
label: 'overall plan depth',
|
||||
measured: depth,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return measurements
|
||||
}
|
||||
|
||||
function wallLength(wall: WallNode): number {
|
||||
return Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
||||
}
|
||||
|
||||
function wallsAreParallel(left: WallNode, right: WallNode): boolean {
|
||||
const leftLength = wallLength(left)
|
||||
const rightLength = wallLength(right)
|
||||
if (!(isUsefulLength(leftLength) && isUsefulLength(rightLength))) return true
|
||||
const leftDirection = [
|
||||
(left.end[0] - left.start[0]) / leftLength,
|
||||
(left.end[1] - left.start[1]) / leftLength,
|
||||
]
|
||||
const rightDirection = [
|
||||
(right.end[0] - right.start[0]) / rightLength,
|
||||
(right.end[1] - right.start[1]) / rightLength,
|
||||
]
|
||||
return (
|
||||
Math.abs(leftDirection[0]! * rightDirection[1]! - leftDirection[1]! * rightDirection[0]!) < 1e-4
|
||||
)
|
||||
}
|
||||
|
||||
function wallFootprintPoints(wall: WallNode): [number, number][] {
|
||||
const dx = wall.end[0] - wall.start[0]
|
||||
const dy = wall.end[1] - wall.start[1]
|
||||
const length = wallLength(wall)
|
||||
if (!isUsefulLength(length)) return []
|
||||
|
||||
const normal: [number, number] = [-dy / length, dx / length]
|
||||
const offsets = getWallAssemblyFaceOffsets(wall)
|
||||
return [offsets.interior, offsets.exterior].flatMap((offset) => [
|
||||
[wall.start[0] + normal[0] * offset, wall.start[1] + normal[1] * offset],
|
||||
[wall.end[0] + normal[0] * offset, wall.end[1] + normal[1] * offset],
|
||||
])
|
||||
}
|
||||
|
||||
function constructionModuleMeasurements(node: AnyNode): ConstructionModuleMeasurement[] {
|
||||
if (node.type === 'wall') return wallMeasurements(node)
|
||||
if (node.type === 'door' || node.type === 'window') return openingMeasurements(node)
|
||||
return []
|
||||
}
|
||||
|
||||
function wallMeasurements(wall: WallNode): ConstructionModuleMeasurement[] {
|
||||
if (wall.curveOffset !== undefined && Math.abs(wall.curveOffset) > 1e-6) return []
|
||||
|
||||
const length = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
||||
if (!isUsefulLength(length)) return []
|
||||
|
||||
return [
|
||||
{
|
||||
nodeId: wall.id,
|
||||
nodeType: wall.type,
|
||||
kind: 'wall-length',
|
||||
label: 'wall length',
|
||||
measured: length,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function openingMeasurements(opening: DoorNode | WindowNode): ConstructionModuleMeasurement[] {
|
||||
return [
|
||||
widthMeasurement(opening, 'opening-width', 'nominal width', opening.width),
|
||||
widthMeasurement(
|
||||
opening,
|
||||
'rough-opening-width',
|
||||
'rough opening width',
|
||||
opening.roughOpeningWidth,
|
||||
),
|
||||
widthMeasurement(
|
||||
opening,
|
||||
'masonry-opening-width',
|
||||
'masonry opening width',
|
||||
opening.masonryOpeningWidth,
|
||||
),
|
||||
widthMeasurement(
|
||||
opening,
|
||||
'finish-opening-width',
|
||||
'finish opening width',
|
||||
opening.finishOpeningWidth,
|
||||
),
|
||||
].filter((measurement): measurement is ConstructionModuleMeasurement => measurement !== null)
|
||||
}
|
||||
|
||||
function widthMeasurement(
|
||||
opening: DoorNode | WindowNode,
|
||||
kind: ConstructionModuleMeasurementKind,
|
||||
label: string,
|
||||
measured: number | undefined,
|
||||
): ConstructionModuleMeasurement | null {
|
||||
if (!isUsefulLength(measured)) return null
|
||||
return {
|
||||
nodeId: opening.id,
|
||||
nodeType: opening.type,
|
||||
kind,
|
||||
label,
|
||||
measured,
|
||||
}
|
||||
}
|
||||
|
||||
function bestModuleFit(measured: number, modules: readonly number[]): ModuleFit | null {
|
||||
let best: ModuleFit | null = null
|
||||
for (const module of modules) {
|
||||
if (!isUsefulLength(module)) continue
|
||||
const multiple = Math.max(1, Math.round(measured / module))
|
||||
const nearestMultiple = multiple * module
|
||||
const deviation = Math.abs(measured - nearestMultiple)
|
||||
if (!best || deviation < best.deviation) {
|
||||
best = { module, nearestMultiple, deviation }
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function moduleAdvisoryMessage(
|
||||
measurement: ConstructionModuleMeasurement,
|
||||
profile: ConstructionModuleProfile,
|
||||
fit: ModuleFit,
|
||||
): string {
|
||||
const unit = profile.system === 'imperial' ? 'imperial' : 'metric'
|
||||
const measured = formatConstructionLength(measurement.measured, unit)
|
||||
const module = formatModuleLength(fit.module, profile.system)
|
||||
const deviation = formatConstructionLength(fit.deviation, unit)
|
||||
|
||||
return `${titleCase(measurement.nodeType)} ${measurement.nodeId} ${measurement.label} ${measured} is ${deviation} off the ${module} construction module.`
|
||||
}
|
||||
|
||||
function formatModuleLength(module: number, system: ConstructionModuleSystem): string {
|
||||
if (system === 'metric') return `${Math.round(module * 1000)} mm`
|
||||
return formatConstructionLength(module, 'imperial')
|
||||
}
|
||||
|
||||
function isUsefulLength(value: number | undefined): value is number {
|
||||
return value !== undefined && Number.isFinite(value) && value > 1e-6
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
DoorNode,
|
||||
type GeometryContext,
|
||||
ItemNode,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { createFloorplanContextExtensions } from '@pascal-app/editor'
|
||||
import { buildDoorContextualDimensions } from '../door/contextual-dimensions'
|
||||
import { buildItemContextualDimensions } from '../item/floorplan'
|
||||
import { buildWallContextualDimensions } from '../wall/contextual-dimensions'
|
||||
import { buildWindowContextualDimensions } from '../window/contextual-dimensions'
|
||||
import { buildZoneContextualDimensions } from '../zone/contextual-dimensions'
|
||||
|
||||
function context(
|
||||
parent: GeometryContext['parent'] = null,
|
||||
siblings: GeometryContext['siblings'] = [],
|
||||
moving = false,
|
||||
): GeometryContext {
|
||||
return {
|
||||
resolve: () => undefined,
|
||||
children: [],
|
||||
siblings,
|
||||
parent,
|
||||
extensions: createFloorplanContextExtensions({
|
||||
metricNotation: 'meters',
|
||||
purpose: 'edit',
|
||||
wallDimensionReference: 'centerline',
|
||||
}),
|
||||
viewState: moving
|
||||
? {
|
||||
selected: true,
|
||||
unit: 'metric',
|
||||
highlighted: false,
|
||||
hovered: false,
|
||||
moving: true,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
describe('contextual floor-plan dimensions', () => {
|
||||
test('uses modeled centerline length for a straight wall', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_primary',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
})
|
||||
|
||||
expect(buildWallContextualDimensions(wall, context())).toMatchObject({
|
||||
kind: 'dimension',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
text: '4m',
|
||||
})
|
||||
})
|
||||
|
||||
test('places a selected corner-wall dimension on its exterior side', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_corner',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
frontSide: 'interior',
|
||||
backSide: 'exterior',
|
||||
})
|
||||
|
||||
expect(buildWallContextualDimensions(wall, context())).toMatchObject({
|
||||
kind: 'dimension',
|
||||
offsetNormal: [0, -1],
|
||||
})
|
||||
})
|
||||
|
||||
test('infers the outside of an unclassified perimeter wall from its connected plan', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_right',
|
||||
start: [4, 0],
|
||||
end: [4, 6],
|
||||
})
|
||||
const siblings = [
|
||||
WallNode.parse({ id: 'wall_top', start: [0, 0], end: [4, 0] }),
|
||||
WallNode.parse({ id: 'wall_bottom', start: [4, 6], end: [0, 6] }),
|
||||
WallNode.parse({ id: 'wall_left', start: [0, 6], end: [0, 0] }),
|
||||
]
|
||||
|
||||
expect(buildWallContextualDimensions(wall, context(null, siblings))).toMatchObject({
|
||||
kind: 'dimension',
|
||||
offsetNormal: [1, 0],
|
||||
})
|
||||
})
|
||||
|
||||
test('shows one internal-wall dimension between the connected stud faces', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_internal',
|
||||
start: [0, 0],
|
||||
end: [0, 4],
|
||||
thickness: 0.1,
|
||||
frontSide: 'interior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
const startWall = WallNode.parse({
|
||||
id: 'wall_start',
|
||||
start: [-2, 0],
|
||||
end: [2, 0],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const endWall = WallNode.parse({
|
||||
id: 'wall_end',
|
||||
start: [-2, 4],
|
||||
end: [2, 4],
|
||||
thickness: 0.2,
|
||||
})
|
||||
|
||||
expect(buildWallContextualDimensions(wall, context(null, [startWall, endWall]))).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'dimension',
|
||||
start: [0, 0.1],
|
||||
end: [0, 3.9],
|
||||
offsetNormal: [-1, 0],
|
||||
text: '3.8m',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('measures a selected perimeter wall between connected stud centerlines', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_perimeter',
|
||||
start: [0, 0],
|
||||
end: [0, 4],
|
||||
frontSide: 'interior',
|
||||
backSide: 'exterior',
|
||||
})
|
||||
const startWall = WallNode.parse({
|
||||
id: 'wall_start',
|
||||
start: [-2, 0],
|
||||
end: [2, 0],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const endWall = WallNode.parse({
|
||||
id: 'wall_end',
|
||||
start: [-2, 4],
|
||||
end: [2, 4],
|
||||
thickness: 0.2,
|
||||
})
|
||||
|
||||
expect(buildWallContextualDimensions(wall, context(null, [startWall, endWall]))).toMatchObject({
|
||||
kind: 'dimension',
|
||||
start: [0, 0],
|
||||
end: [0, 4],
|
||||
offsetNormal: [1, 0],
|
||||
text: '4m',
|
||||
})
|
||||
})
|
||||
|
||||
test('uses arc length for a curved wall', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_curved',
|
||||
start: [0, 0],
|
||||
end: [4, 0],
|
||||
curveOffset: 1,
|
||||
})
|
||||
const geometry = buildWallContextualDimensions(wall, context())
|
||||
|
||||
expect(geometry).toMatchObject({ kind: 'dimension-label' })
|
||||
expect(geometry && 'text' in geometry ? Number.parseFloat(geometry.text) : 0).toBeGreaterThan(4)
|
||||
})
|
||||
|
||||
test('shows only an opening width along its host wall', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_host',
|
||||
start: [0, 0],
|
||||
end: [6, 0],
|
||||
})
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_primary',
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
position: [2, 1.05, 0],
|
||||
width: 0.9,
|
||||
})
|
||||
|
||||
expect(buildDoorContextualDimensions(door, context(wall))).toMatchObject({
|
||||
kind: 'dimension',
|
||||
start: [1.55, 0],
|
||||
end: [2.45, 0],
|
||||
text: '0.9m',
|
||||
})
|
||||
})
|
||||
|
||||
test('shows a selected window width on the exterior side', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_host',
|
||||
start: [0, 0],
|
||||
end: [6, 0],
|
||||
frontSide: 'interior',
|
||||
backSide: 'exterior',
|
||||
})
|
||||
const startWall = WallNode.parse({
|
||||
id: 'wall_start',
|
||||
start: [0, -2],
|
||||
end: [0, 2],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const endWall = WallNode.parse({
|
||||
id: 'wall_end',
|
||||
start: [6, -2],
|
||||
end: [6, 2],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const window = WindowNode.parse({
|
||||
id: 'window_primary',
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
position: [2, 1.05, 0],
|
||||
width: 1,
|
||||
})
|
||||
|
||||
expect(
|
||||
buildWindowContextualDimensions(window, context(wall, [startWall, endWall])),
|
||||
).toMatchObject({
|
||||
kind: 'dimension',
|
||||
offsetNormal: [0, -1],
|
||||
start: [1.5, 0],
|
||||
end: [2.5, 0],
|
||||
text: '1m',
|
||||
})
|
||||
})
|
||||
|
||||
test('updates both window clearances from its live wall-local position', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_host',
|
||||
start: [0, 0],
|
||||
end: [6, 0],
|
||||
frontSide: 'interior',
|
||||
backSide: 'exterior',
|
||||
})
|
||||
const startWall = WallNode.parse({
|
||||
id: 'wall_start',
|
||||
start: [0, -2],
|
||||
end: [0, 2],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const endWall = WallNode.parse({
|
||||
id: 'wall_end',
|
||||
start: [6, -2],
|
||||
end: [6, 2],
|
||||
thickness: 0.2,
|
||||
})
|
||||
const draggedWindow = WindowNode.parse({
|
||||
id: 'window_primary',
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
position: [3, 1.05, 0],
|
||||
width: 1,
|
||||
})
|
||||
const geometry = buildWindowContextualDimensions(
|
||||
draggedWindow,
|
||||
context(wall, [startWall, endWall], true),
|
||||
)
|
||||
|
||||
expect(
|
||||
geometry?.kind === 'dimension-string' ? geometry.segments.map((segment) => segment.text) : [],
|
||||
).toEqual(['2.4m', '1m', '2.4m'])
|
||||
})
|
||||
|
||||
test('shows room area at the polygon centroid', () => {
|
||||
const room = ZoneNode.parse({
|
||||
id: 'zone_room',
|
||||
name: 'Office',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
],
|
||||
spaceRole: 'room',
|
||||
})
|
||||
|
||||
expect(buildZoneContextualDimensions(room, context())).toMatchObject({
|
||||
kind: 'dimension-label',
|
||||
cx: 2,
|
||||
cy: 1.5,
|
||||
text: '12.0m²',
|
||||
})
|
||||
})
|
||||
|
||||
test('shows item width and depth without placement chains', () => {
|
||||
const item = ItemNode.parse({
|
||||
id: 'item_primary',
|
||||
position: [2, 0, 3],
|
||||
scale: [2, 1, 1],
|
||||
asset: {
|
||||
id: 'table',
|
||||
category: 'furniture',
|
||||
name: 'Table',
|
||||
thumbnail: '',
|
||||
src: 'asset://table',
|
||||
dimensions: [1.2, 0.8, 0.6],
|
||||
},
|
||||
})
|
||||
const geometry = buildItemContextualDimensions(item, context())
|
||||
|
||||
expect(geometry?.kind).toBe('group')
|
||||
expect(
|
||||
geometry?.kind === 'group'
|
||||
? geometry.children.map((child) => ('text' in child ? child.text : null))
|
||||
: [],
|
||||
).toEqual(['2.4m', '0.6m'])
|
||||
})
|
||||
})
|
||||
@@ -1,340 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
CabinetNode,
|
||||
ConstructionDimensionNode,
|
||||
DoorNode,
|
||||
StairNode,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { buildDimensionCompletenessAudit } from './dimension-completeness-audit'
|
||||
|
||||
function nodes(...items: AnyNode[]): Record<string, AnyNode> {
|
||||
return Object.fromEntries(items.map((item) => [item.id, item])) as Record<string, AnyNode>
|
||||
}
|
||||
|
||||
function featureAnchor(nodeId: string, fallback: [number, number, number] = [0, 0, 0]) {
|
||||
return {
|
||||
kind: 'feature' as const,
|
||||
reference: { nodeId, featureId: 'center' },
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
|
||||
describe('dimension completeness audit', () => {
|
||||
test('reports missing overall exterior wall dimensions and partition references', () => {
|
||||
const exteriorWall = WallNode.parse({
|
||||
id: 'wall_exterior',
|
||||
start: [0, 0],
|
||||
end: [5, 0],
|
||||
frontSide: 'exterior',
|
||||
})
|
||||
const partitionWall = WallNode.parse({
|
||||
id: 'wall_partition',
|
||||
start: [1, 0],
|
||||
end: [1, 3],
|
||||
frontSide: 'interior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
|
||||
const issues = buildDimensionCompletenessAudit(nodes(exteriorWall, partitionWall))
|
||||
|
||||
expect(issues.map((auditIssue) => auditIssue.kind)).toEqual([
|
||||
'missing-overall-dimension',
|
||||
'missing-partition-reference',
|
||||
'undocumented-critical-node',
|
||||
'undocumented-critical-node',
|
||||
])
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'missing-overall-dimension',
|
||||
nodeId: 'wall_exterior',
|
||||
severity: 'warning',
|
||||
}),
|
||||
)
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'missing-partition-reference',
|
||||
nodeId: 'wall_partition',
|
||||
severity: 'info',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('uses associative construction-dimension anchors as dimension coverage', () => {
|
||||
const exteriorWall = WallNode.parse({
|
||||
id: 'wall_exterior',
|
||||
start: [0, 0],
|
||||
end: [5, 0],
|
||||
frontSide: 'exterior',
|
||||
})
|
||||
const partitionWall = WallNode.parse({
|
||||
id: 'wall_partition',
|
||||
start: [1, 0],
|
||||
end: [1, 3],
|
||||
frontSide: 'interior',
|
||||
backSide: 'interior',
|
||||
})
|
||||
const dimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_wall_refs',
|
||||
anchors: [
|
||||
featureAnchor(exteriorWall.id, [0, 0, 0]),
|
||||
featureAnchor(partitionWall.id, [1, 0, 0]),
|
||||
],
|
||||
})
|
||||
|
||||
expect(buildDimensionCompletenessAudit(nodes(exteriorWall, partitionWall, dimension))).toEqual(
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('can count the automatic wall and opening dimension plan as coverage', () => {
|
||||
const exteriorWall = WallNode.parse({
|
||||
id: 'wall_exterior',
|
||||
children: ['door_entry'],
|
||||
start: [0, 0],
|
||||
end: [5, 0],
|
||||
frontSide: 'exterior',
|
||||
})
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_entry',
|
||||
parentId: exteriorWall.id,
|
||||
wallId: exteriorWall.id,
|
||||
roughOpeningWidth: 0.96,
|
||||
})
|
||||
|
||||
expect(
|
||||
buildDimensionCompletenessAudit(nodes(exteriorWall, door), {
|
||||
includeAutomaticDimensions: true,
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('reports undimensioned exterior openings and missing verified rough openings', () => {
|
||||
const exteriorWall = WallNode.parse({
|
||||
id: 'wall_exterior',
|
||||
children: ['door_entry', 'window_front'],
|
||||
start: [0, 0],
|
||||
end: [5, 0],
|
||||
frontSide: 'exterior',
|
||||
})
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_entry',
|
||||
parentId: exteriorWall.id,
|
||||
wallId: exteriorWall.id,
|
||||
width: 0.9,
|
||||
})
|
||||
const window = WindowNode.parse({
|
||||
id: 'window_front',
|
||||
parentId: exteriorWall.id,
|
||||
wallId: exteriorWall.id,
|
||||
roughOpeningWidth: 1.22,
|
||||
})
|
||||
|
||||
const issues = buildDimensionCompletenessAudit(nodes(exteriorWall, door, window))
|
||||
|
||||
expect(issues.map((auditIssue) => auditIssue.kind)).toEqual([
|
||||
'missing-overall-dimension',
|
||||
'missing-verified-rough-opening',
|
||||
'undimensioned-exterior-opening',
|
||||
'undimensioned-exterior-opening',
|
||||
'undocumented-critical-node',
|
||||
])
|
||||
expect(issues.filter((auditIssue) => auditIssue.nodeId === 'window_front')).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('suppresses exterior opening and rough-opening issues when evidence exists', () => {
|
||||
const exteriorWall = WallNode.parse({
|
||||
id: 'wall_exterior',
|
||||
children: ['door_entry'],
|
||||
start: [0, 0],
|
||||
end: [5, 0],
|
||||
frontSide: 'exterior',
|
||||
})
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_entry',
|
||||
parentId: exteriorWall.id,
|
||||
wallId: exteriorWall.id,
|
||||
width: 0.9,
|
||||
roughOpeningWidth: 0.96,
|
||||
})
|
||||
const openingDimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_door',
|
||||
anchors: [featureAnchor(door.id, [2, 0, 0]), featureAnchor(door.id, [3, 0, 0])],
|
||||
})
|
||||
|
||||
const issues = buildDimensionCompletenessAudit(nodes(exteriorWall, door, openingDimension))
|
||||
|
||||
expect(issues.map((auditIssue) => auditIssue.kind)).toEqual([
|
||||
'missing-overall-dimension',
|
||||
'undocumented-critical-node',
|
||||
])
|
||||
})
|
||||
|
||||
test('can require rough-opening height verification as a stricter profile', () => {
|
||||
const door = DoorNode.parse({
|
||||
id: 'door_entry',
|
||||
roughOpeningWidth: 0.96,
|
||||
})
|
||||
|
||||
expect(
|
||||
buildDimensionCompletenessAudit(nodes(door), { requireRoughOpeningHeights: true }),
|
||||
).toMatchObject([
|
||||
{
|
||||
kind: 'missing-verified-rough-opening',
|
||||
nodeId: 'door_entry',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('does not require rough openings for masonry openings or frameless openings', () => {
|
||||
const masonryWindow = WindowNode.parse({
|
||||
id: 'window_masonry',
|
||||
constructionType: 'masonry',
|
||||
})
|
||||
const framelessOpening = DoorNode.parse({
|
||||
id: 'door_opening',
|
||||
openingKind: 'opening',
|
||||
})
|
||||
|
||||
expect(buildDimensionCompletenessAudit(nodes(masonryWindow, framelessOpening))).toEqual([])
|
||||
})
|
||||
|
||||
test('detects duplicate and contradictory dimension string overrides', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_exterior',
|
||||
start: [0, 0],
|
||||
end: [5, 0],
|
||||
frontSide: 'exterior',
|
||||
})
|
||||
const firstDimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_first',
|
||||
textOverride: '5.00m',
|
||||
anchors: [featureAnchor(wall.id, [0, 0, 0]), featureAnchor(wall.id, [5, 0, 0])],
|
||||
})
|
||||
const duplicateDimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_duplicate',
|
||||
textOverride: '5.00 m',
|
||||
anchors: [featureAnchor('wall_other', [0, 0, 0]), featureAnchor('wall_other', [5, 0, 0])],
|
||||
})
|
||||
const conflictingDimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_conflict',
|
||||
textOverride: '4.80m',
|
||||
anchors: [featureAnchor(wall.id, [0, 0, 0]), featureAnchor(wall.id, [4.8, 0, 0])],
|
||||
})
|
||||
|
||||
const issues = buildDimensionCompletenessAudit(
|
||||
nodes(wall, firstDimension, duplicateDimension, conflictingDimension),
|
||||
)
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'duplicate-dimension-string',
|
||||
nodeId: 'construction-dimension_first',
|
||||
}),
|
||||
)
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: 'contradictory-dimension-string',
|
||||
nodeId: wall.id,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('detects continuous dimension segment totals that disagree with the overall string', () => {
|
||||
const dimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_chain',
|
||||
chainMode: 'continuous',
|
||||
textOverride: '3.00m',
|
||||
anchors: [
|
||||
featureAnchor('wall_a', [0, 0, 0]),
|
||||
featureAnchor('wall_b', [1, 0, 0]),
|
||||
featureAnchor('wall_c', [2, 0, 0]),
|
||||
],
|
||||
})
|
||||
|
||||
const issues = buildDimensionCompletenessAudit(nodes(dimension))
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'dimension-segment-total-mismatch',
|
||||
nodeId: 'construction-dimension_chain',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test('reports construction-critical nodes without dimensions or schedules', () => {
|
||||
const undocumentedCabinet = CabinetNode.parse({
|
||||
id: 'cabinet_undocumented',
|
||||
})
|
||||
const stair = StairNode.parse({
|
||||
id: 'stair_documented',
|
||||
})
|
||||
const stairDimension = ConstructionDimensionNode.parse({
|
||||
id: 'construction-dimension_stair',
|
||||
anchors: [featureAnchor(stair.id, [0, 0, 0]), featureAnchor(stair.id, [1, 0, 0])],
|
||||
})
|
||||
|
||||
const issues = buildDimensionCompletenessAudit(
|
||||
nodes(undocumentedCabinet, stair, stairDimension),
|
||||
)
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: 'undocumented-critical-node',
|
||||
nodeId: undocumentedCabinet.id,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test('includes unresolved annotation collisions from preflight evidence', () => {
|
||||
const issues = buildDimensionCompletenessAudit(nodes(), {
|
||||
preflightIssues: [
|
||||
{
|
||||
id: 'dimension-label_wall_a',
|
||||
kind: 'unresolved-collision',
|
||||
severity: 'warning',
|
||||
message:
|
||||
'Wall A dimension label still overlaps another annotation after automatic layout.',
|
||||
},
|
||||
{
|
||||
id: 'dimension-label_wall_b',
|
||||
kind: 'short-unreadable-segment',
|
||||
severity: 'warning',
|
||||
message: 'Wall B uses an outside label.',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'dimension-completeness:unresolved-annotation-collision:dimension-label_wall_a',
|
||||
kind: 'unresolved-annotation-collision',
|
||||
nodeId: 'dimension-label_wall_a',
|
||||
nodeType: 'annotation',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test('includes clipped sheet content from sheet preflight evidence', () => {
|
||||
const issues = buildDimensionCompletenessAudit(nodes(), {
|
||||
preflightIssues: [
|
||||
{
|
||||
message:
|
||||
'Scaled plan exceeds the sheet viewport. Review clipped view or annotation content.',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'dimension-completeness:clipped-sheet-content:sheet',
|
||||
kind: 'clipped-sheet-content',
|
||||
nodeId: 'sheet',
|
||||
nodeType: 'sheet',
|
||||
severity: 'warning',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,495 +0,0 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type ConstructionDimensionNode,
|
||||
type DoorNode,
|
||||
measurementAnchorReferenceNodeIds,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export type DimensionCompletenessIssueKind =
|
||||
| 'missing-overall-dimension'
|
||||
| 'undimensioned-exterior-opening'
|
||||
| 'missing-partition-reference'
|
||||
| 'missing-verified-rough-opening'
|
||||
| 'duplicate-dimension-string'
|
||||
| 'contradictory-dimension-string'
|
||||
| 'dimension-segment-total-mismatch'
|
||||
| 'undocumented-critical-node'
|
||||
| 'unresolved-annotation-collision'
|
||||
| 'clipped-sheet-content'
|
||||
|
||||
export type DimensionCompletenessIssueSeverity = 'info' | 'warning'
|
||||
|
||||
export type DimensionCompletenessIssue = {
|
||||
id: string
|
||||
kind: DimensionCompletenessIssueKind
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
severity: DimensionCompletenessIssueSeverity
|
||||
message: string
|
||||
}
|
||||
|
||||
export type BuildDimensionCompletenessAuditOptions = {
|
||||
includeAutomaticDimensions?: boolean
|
||||
requireRoughOpeningHeights?: boolean
|
||||
dimensionValueTolerance?: number
|
||||
preflightIssues?: readonly DimensionCompletenessPreflightIssue[]
|
||||
}
|
||||
|
||||
export type DimensionCompletenessPreflightIssue = {
|
||||
id?: string
|
||||
kind?: string
|
||||
severity?: DimensionCompletenessIssueSeverity
|
||||
message: string
|
||||
}
|
||||
|
||||
type DimensionCoverage = ReadonlySet<string>
|
||||
type DocumentationCoverage = {
|
||||
dimensioned: ReadonlySet<string>
|
||||
scheduled: ReadonlySet<string>
|
||||
}
|
||||
type OpeningNode = DoorNode | WindowNode
|
||||
type DimensionRecord = {
|
||||
dimension: ConstructionDimensionNode
|
||||
referencedNodeIds: readonly string[]
|
||||
normalizedText: string | null
|
||||
parsedTextValue: number | null
|
||||
segmentTotal: number | null
|
||||
}
|
||||
|
||||
export function buildDimensionCompletenessAudit(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
options: BuildDimensionCompletenessAuditOptions = {},
|
||||
): DimensionCompletenessIssue[] {
|
||||
const coverage = dimensionCoverage(nodes, options)
|
||||
const documentation = documentationCoverage(nodes, coverage)
|
||||
const issues: DimensionCompletenessIssue[] = []
|
||||
|
||||
issues.push(...dimensionStringIssues(nodes, options))
|
||||
issues.push(...preflightCompletenessIssues(options.preflightIssues ?? []))
|
||||
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type === 'wall') {
|
||||
issues.push(...wallDimensionIssues(node, coverage))
|
||||
} else if (node.type === 'door' || node.type === 'window') {
|
||||
issues.push(...openingDimensionIssues(node, nodes, coverage, options))
|
||||
}
|
||||
|
||||
if (isConstructionCriticalNode(node, nodes) && !hasDocumentationCoverage(node, documentation)) {
|
||||
issues.push(
|
||||
issue(
|
||||
'undocumented-critical-node',
|
||||
node,
|
||||
'warning',
|
||||
`${titleCase(node.type)} ${node.id} has no construction dimension or schedule entry.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return issues.sort((left, right) => left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
function dimensionCoverage(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
options: BuildDimensionCompletenessAuditOptions,
|
||||
): DimensionCoverage {
|
||||
const covered = new Set<string>()
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type !== 'construction-dimension') continue
|
||||
|
||||
for (const nodeId of measurementAnchorReferenceNodeIds(
|
||||
(node as ConstructionDimensionNode).anchors,
|
||||
)) {
|
||||
covered.add(nodeId)
|
||||
}
|
||||
}
|
||||
if (options.includeAutomaticDimensions === true) {
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (
|
||||
node.type === 'wall' &&
|
||||
node.visible !== false &&
|
||||
Math.abs(node.curveOffset ?? 0) <= 1e-6 &&
|
||||
(isExteriorWall(node) || isPartitionWall(node))
|
||||
) {
|
||||
covered.add(node.id)
|
||||
}
|
||||
}
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type !== 'door' && node.type !== 'window') continue
|
||||
const host = openingHostWall(node, nodes)
|
||||
if (host && covered.has(host.id)) covered.add(node.id)
|
||||
}
|
||||
}
|
||||
return covered
|
||||
}
|
||||
|
||||
function documentationCoverage(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
dimensioned: DimensionCoverage,
|
||||
): DocumentationCoverage {
|
||||
const scheduled = new Set<string>()
|
||||
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (hasGeneratedScheduleEntry(node)) scheduled.add(node.id)
|
||||
}
|
||||
|
||||
return { dimensioned, scheduled }
|
||||
}
|
||||
|
||||
function dimensionStringIssues(
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
options: BuildDimensionCompletenessAuditOptions,
|
||||
): DimensionCompletenessIssue[] {
|
||||
const records = Object.values(nodes)
|
||||
.filter((node): node is ConstructionDimensionNode => node.type === 'construction-dimension')
|
||||
.map((dimension) => dimensionRecord(dimension))
|
||||
const issues: DimensionCompletenessIssue[] = []
|
||||
|
||||
issues.push(...duplicateDimensionStringIssues(records))
|
||||
issues.push(...contradictoryDimensionStringIssues(records))
|
||||
issues.push(...segmentTotalMismatchIssues(records, options.dimensionValueTolerance ?? 0.005))
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function dimensionRecord(dimension: ConstructionDimensionNode): DimensionRecord {
|
||||
const normalizedText = normalizedDimensionText(dimension.textOverride)
|
||||
return {
|
||||
dimension,
|
||||
referencedNodeIds: measurementAnchorReferenceNodeIds(dimension.anchors),
|
||||
normalizedText,
|
||||
parsedTextValue: normalizedText ? parseDimensionTextValue(normalizedText) : null,
|
||||
segmentTotal: continuousSegmentTotal(dimension),
|
||||
}
|
||||
}
|
||||
|
||||
function duplicateDimensionStringIssues(
|
||||
records: readonly DimensionRecord[],
|
||||
): DimensionCompletenessIssue[] {
|
||||
const byText = new Map<string, DimensionRecord[]>()
|
||||
for (const record of records) {
|
||||
if (!record.normalizedText) continue
|
||||
const existing = byText.get(record.normalizedText)
|
||||
if (existing) existing.push(record)
|
||||
else byText.set(record.normalizedText, [record])
|
||||
}
|
||||
|
||||
const issues: DimensionCompletenessIssue[] = []
|
||||
for (const [text, duplicates] of byText) {
|
||||
if (duplicates.length < 2) continue
|
||||
const dimension = duplicates[0]?.dimension
|
||||
if (!dimension) continue
|
||||
issues.push(
|
||||
issue(
|
||||
'duplicate-dimension-string',
|
||||
dimension,
|
||||
'info',
|
||||
`Dimension string "${text}" is used by ${duplicates.length} construction dimensions.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
function contradictoryDimensionStringIssues(
|
||||
records: readonly DimensionRecord[],
|
||||
): DimensionCompletenessIssue[] {
|
||||
const byNode = new Map<string, Map<string, DimensionRecord[]>>()
|
||||
for (const record of records) {
|
||||
if (!record.normalizedText) continue
|
||||
for (const nodeId of record.referencedNodeIds) {
|
||||
const byText = byNode.get(nodeId) ?? new Map<string, DimensionRecord[]>()
|
||||
const matchingText = byText.get(record.normalizedText)
|
||||
if (matchingText) matchingText.push(record)
|
||||
else byText.set(record.normalizedText, [record])
|
||||
byNode.set(nodeId, byText)
|
||||
}
|
||||
}
|
||||
|
||||
const issues: DimensionCompletenessIssue[] = []
|
||||
for (const [nodeId, byText] of byNode) {
|
||||
if (byText.size < 2) continue
|
||||
const firstRecord = [...byText.values()][0]?.[0]
|
||||
if (!firstRecord) continue
|
||||
issues.push({
|
||||
id: ['dimension-completeness', 'contradictory-dimension-string', nodeId].join(':'),
|
||||
kind: 'contradictory-dimension-string',
|
||||
nodeId,
|
||||
nodeType: 'unknown',
|
||||
severity: 'warning',
|
||||
message: `Referenced node ${nodeId} has contradictory construction dimension strings: ${[
|
||||
...byText.keys(),
|
||||
].join(', ')}.`,
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
function segmentTotalMismatchIssues(
|
||||
records: readonly DimensionRecord[],
|
||||
tolerance: number,
|
||||
): DimensionCompletenessIssue[] {
|
||||
return records.flatMap((record) => {
|
||||
if (record.dimension.chainMode !== 'continuous') return []
|
||||
if (record.parsedTextValue === null || record.segmentTotal === null) return []
|
||||
if (Math.abs(record.parsedTextValue - record.segmentTotal) <= tolerance) return []
|
||||
|
||||
return [
|
||||
issue(
|
||||
'dimension-segment-total-mismatch',
|
||||
record.dimension,
|
||||
'warning',
|
||||
`Continuous dimension ${record.dimension.id} text ${record.normalizedText} does not match its segment total ${record.segmentTotal.toFixed(3)}m.`,
|
||||
),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function preflightCompletenessIssues(
|
||||
preflightIssues: readonly DimensionCompletenessPreflightIssue[],
|
||||
): DimensionCompletenessIssue[] {
|
||||
const issues: DimensionCompletenessIssue[] = []
|
||||
for (const preflightIssue of preflightIssues) {
|
||||
const normalizedKind = preflightIssue.kind?.trim().toLowerCase()
|
||||
const normalizedMessage = preflightIssue.message.trim().toLowerCase()
|
||||
|
||||
if (normalizedKind === 'unresolved-collision') {
|
||||
issues.push(
|
||||
preflightIssueCompletenessIssue(
|
||||
'unresolved-annotation-collision',
|
||||
preflightIssue,
|
||||
'annotation',
|
||||
),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedKind === 'clipped-content' ||
|
||||
normalizedKind === 'clipped-sheet-content' ||
|
||||
normalizedMessage.includes('clipped') ||
|
||||
normalizedMessage.includes('exceeds the sheet viewport')
|
||||
) {
|
||||
issues.push(preflightIssueCompletenessIssue('clipped-sheet-content', preflightIssue, 'sheet'))
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
function preflightIssueCompletenessIssue(
|
||||
kind: Extract<
|
||||
DimensionCompletenessIssueKind,
|
||||
'unresolved-annotation-collision' | 'clipped-sheet-content'
|
||||
>,
|
||||
preflightIssue: DimensionCompletenessPreflightIssue,
|
||||
fallbackNodeId: string,
|
||||
): DimensionCompletenessIssue {
|
||||
const nodeId = preflightIssue.id?.trim() || fallbackNodeId
|
||||
return {
|
||||
id: ['dimension-completeness', kind, nodeId].join(':'),
|
||||
kind,
|
||||
nodeId,
|
||||
nodeType: fallbackNodeId,
|
||||
severity: preflightIssue.severity ?? 'warning',
|
||||
message: preflightIssue.message,
|
||||
}
|
||||
}
|
||||
|
||||
function wallDimensionIssues(
|
||||
wall: WallNode,
|
||||
coverage: DimensionCoverage,
|
||||
): DimensionCompletenessIssue[] {
|
||||
if (coverage.has(wall.id)) return []
|
||||
|
||||
if (isExteriorWall(wall)) {
|
||||
return [
|
||||
issue(
|
||||
'missing-overall-dimension',
|
||||
wall,
|
||||
'warning',
|
||||
`Exterior wall ${wall.id} has no associative overall construction dimension.`,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
if (isPartitionWall(wall)) {
|
||||
return [
|
||||
issue(
|
||||
'missing-partition-reference',
|
||||
wall,
|
||||
'info',
|
||||
`Partition wall ${wall.id} has no associative partition reference dimension.`,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function openingDimensionIssues(
|
||||
opening: OpeningNode,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
coverage: DimensionCoverage,
|
||||
options: BuildDimensionCompletenessAuditOptions,
|
||||
): DimensionCompletenessIssue[] {
|
||||
const issues: DimensionCompletenessIssue[] = []
|
||||
const hostWall = openingHostWall(opening, nodes)
|
||||
|
||||
if (hostWall && isExteriorWall(hostWall) && !coverage.has(opening.id)) {
|
||||
issues.push(
|
||||
issue(
|
||||
'undimensioned-exterior-opening',
|
||||
opening,
|
||||
'warning',
|
||||
`${titleCase(opening.type)} ${opening.id} is on exterior wall ${hostWall.id} but has no associative opening dimension.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (missingVerifiedRoughOpening(opening, options)) {
|
||||
issues.push(
|
||||
issue(
|
||||
'missing-verified-rough-opening',
|
||||
opening,
|
||||
'info',
|
||||
`${titleCase(opening.type)} ${opening.id} has no verified rough-opening ${options.requireRoughOpeningHeights === true ? 'width and height' : 'width'} recorded.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
function openingHostWall(
|
||||
opening: OpeningNode,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): WallNode | null {
|
||||
const hostId = opening.wallId ?? opening.parentId ?? null
|
||||
if (!hostId) return null
|
||||
const host = nodes[hostId]
|
||||
return host?.type === 'wall' ? host : null
|
||||
}
|
||||
|
||||
function missingVerifiedRoughOpening(
|
||||
opening: OpeningNode,
|
||||
options: BuildDimensionCompletenessAuditOptions,
|
||||
): boolean {
|
||||
if (opening.openingKind === 'opening') return false
|
||||
if (opening.constructionType === 'masonry') return false
|
||||
if (opening.roughOpeningWidth === undefined) return true
|
||||
return options.requireRoughOpeningHeights === true && opening.roughOpeningHeight === undefined
|
||||
}
|
||||
|
||||
function isExteriorWall(wall: WallNode): boolean {
|
||||
return wall.frontSide === 'exterior' || wall.backSide === 'exterior'
|
||||
}
|
||||
|
||||
function isPartitionWall(wall: WallNode): boolean {
|
||||
return wall.frontSide === 'interior' || wall.backSide === 'interior'
|
||||
}
|
||||
|
||||
function hasDocumentationCoverage(node: AnyNode, coverage: DocumentationCoverage): boolean {
|
||||
return coverage.dimensioned.has(node.id) || coverage.scheduled.has(node.id)
|
||||
}
|
||||
|
||||
function hasGeneratedScheduleEntry(node: AnyNode): boolean {
|
||||
if (node.type === 'door' || node.type === 'window') return node.openingKind !== 'opening'
|
||||
return node.type === 'zone' && node.spaceRole === 'room'
|
||||
}
|
||||
|
||||
function isConstructionCriticalNode(
|
||||
node: AnyNode,
|
||||
nodes: Readonly<Record<string, AnyNode>>,
|
||||
): boolean {
|
||||
if (node.type === 'wall') return isExteriorWall(node) || isPartitionWall(node)
|
||||
if (node.type === 'door' || node.type === 'window') {
|
||||
const hostWall = openingHostWall(node, nodes)
|
||||
return hostWall ? isExteriorWall(hostWall) : false
|
||||
}
|
||||
if (node.type === 'zone') return node.spaceRole === 'room'
|
||||
return (
|
||||
node.type === 'cabinet' ||
|
||||
node.type === 'cabinet-module' ||
|
||||
node.type === 'stair' ||
|
||||
node.type === 'stair-segment'
|
||||
)
|
||||
}
|
||||
|
||||
function normalizedDimensionText(text: string | null): string | null {
|
||||
const normalized = text
|
||||
?.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/(\d)\s+(MM|M|")/gi, '$1$2')
|
||||
.toUpperCase()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function parseDimensionTextValue(text: string): number | null {
|
||||
const metricMatch = text.match(/^([0-9]+(?:\.[0-9]+)?)\s*(MM|M)?$/)
|
||||
if (metricMatch) {
|
||||
const value = Number.parseFloat(metricMatch[1] ?? '')
|
||||
if (!Number.isFinite(value)) return null
|
||||
return metricMatch[2] === 'MM' ? value / 1000 : value
|
||||
}
|
||||
|
||||
const imperialMatch = text.match(/^(?:(\d+(?:\.\d+)?)')?(?:-)?(?:(\d+(?:\.\d+)?)")?$/)
|
||||
if (imperialMatch) {
|
||||
const feet = Number.parseFloat(imperialMatch[1] ?? '0')
|
||||
const inches = Number.parseFloat(imperialMatch[2] ?? '0')
|
||||
const totalInches = feet * 12 + inches
|
||||
return totalInches > 0 ? totalInches * 0.0254 : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function continuousSegmentTotal(dimension: ConstructionDimensionNode): number | null {
|
||||
if (dimension.chainMode !== 'continuous' || dimension.anchors.length < 3) return null
|
||||
|
||||
const directionLength = Math.hypot(
|
||||
dimension.baseline.direction[0],
|
||||
dimension.baseline.direction[1],
|
||||
)
|
||||
if (directionLength <= 1e-9) return null
|
||||
const dirX = dimension.baseline.direction[0] / directionLength
|
||||
const dirZ = dimension.baseline.direction[1] / directionLength
|
||||
|
||||
let total = 0
|
||||
for (let index = 1; index < dimension.anchors.length; index += 1) {
|
||||
const previousAnchor = dimension.anchors[index - 1]
|
||||
const currentAnchor = dimension.anchors[index]
|
||||
if (!previousAnchor || !currentAnchor) return null
|
||||
const previous = anchorFallbackPoint(previousAnchor)
|
||||
const current = anchorFallbackPoint(currentAnchor)
|
||||
total += Math.abs((current[0] - previous[0]) * dirX + (current[2] - previous[2]) * dirZ)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
function anchorFallbackPoint(
|
||||
anchor: ConstructionDimensionNode['anchors'][number],
|
||||
): [number, number, number] {
|
||||
return Array.isArray(anchor) ? anchor : anchor.fallback
|
||||
}
|
||||
|
||||
function issue(
|
||||
kind: DimensionCompletenessIssueKind,
|
||||
node: Pick<AnyNode, 'id' | 'type'>,
|
||||
severity: DimensionCompletenessIssueSeverity,
|
||||
message: string,
|
||||
): DimensionCompletenessIssue {
|
||||
return {
|
||||
id: ['dimension-completeness', kind, node.id].join(':'),
|
||||
kind,
|
||||
nodeId: node.id,
|
||||
nodeType: node.type,
|
||||
severity,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
function titleCase(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
DoorNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { columnResizeAffordance } from '../column/floorplan-affordances'
|
||||
import { doorWidthAffordance } from '../door/floorplan-affordances'
|
||||
import { spawnRotateAffordance } from '../spawn/floorplan-affordances'
|
||||
import { windowWidthAffordance } from '../window/floorplan-affordances'
|
||||
|
||||
globalThis.requestAnimationFrame ??= (callback) => {
|
||||
callback(0)
|
||||
return 0
|
||||
}
|
||||
globalThis.cancelAnimationFrame ??= () => {}
|
||||
|
||||
const modifiers = { shiftKey: false, altKey: false, ctrlKey: false, metaKey: false }
|
||||
|
||||
afterEach(() => {
|
||||
useLiveNodeOverrides.getState().clearAll()
|
||||
useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
|
||||
})
|
||||
|
||||
describe('opening width floor-plan affordances', () => {
|
||||
for (const kind of ['door', 'window'] as const) {
|
||||
test(`${kind} previews through a live override and writes the scene only on commit`, () => {
|
||||
const wall = WallNode.parse({
|
||||
id: `wall_${kind}`,
|
||||
start: [0, 0],
|
||||
end: [6, 0],
|
||||
})
|
||||
const opening =
|
||||
kind === 'door'
|
||||
? DoorNode.parse({
|
||||
id: 'door_width-live',
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
position: [2, 1.05, 0],
|
||||
width: 1,
|
||||
})
|
||||
: WindowNode.parse({
|
||||
id: 'window_width-live',
|
||||
parentId: wall.id,
|
||||
wallId: wall.id,
|
||||
position: [2, 1.05, 0],
|
||||
width: 1,
|
||||
})
|
||||
const nodes = { [wall.id]: wall, [opening.id]: opening }
|
||||
useScene.setState({ nodes } as never)
|
||||
const affordance = kind === 'door' ? doorWidthAffordance : windowWidthAffordance
|
||||
const session = affordance.start({
|
||||
node: opening as never,
|
||||
payload: { side: 'end' },
|
||||
nodes: useScene.getState().nodes,
|
||||
initialPlanPoint: [2.5, 0],
|
||||
gridSnapStep: 0.1,
|
||||
})
|
||||
|
||||
session.apply({ planPoint: [3, 0], modifiers })
|
||||
|
||||
expect(useScene.getState().nodes[opening.id]).toBe(opening)
|
||||
expect(useLiveNodeOverrides.getState().get(opening.id as AnyNodeId)).toMatchObject({
|
||||
width: 1.5,
|
||||
position: [2.25, 1.05, 0],
|
||||
})
|
||||
|
||||
session.commit?.()
|
||||
|
||||
expect(useLiveNodeOverrides.getState().get(opening.id as AnyNodeId)).toBeUndefined()
|
||||
expect(useScene.getState().nodes[opening.id]).toMatchObject({
|
||||
width: 1.5,
|
||||
position: [2.25, 1.05, 0],
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('floor-plan affordance preview policy', () => {
|
||||
test('a parametric resize keeps scene data stable until commit', () => {
|
||||
const column = {
|
||||
id: 'column_live-resize',
|
||||
type: 'column',
|
||||
position: [0, 0, 0],
|
||||
width: 1,
|
||||
depth: 1,
|
||||
radius: 0.5,
|
||||
}
|
||||
useScene.setState({ nodes: { [column.id]: column } } as never)
|
||||
const session = columnResizeAffordance.start({
|
||||
node: column as never,
|
||||
payload: { dim: 'width', planAxis: [1, 0] },
|
||||
nodes: useScene.getState().nodes,
|
||||
initialPlanPoint: [0.5, 0],
|
||||
gridSnapStep: 0.1,
|
||||
})
|
||||
|
||||
session.apply({ planPoint: [0.75, 0], modifiers })
|
||||
|
||||
expect(useScene.getState().nodes[column.id]).toBe(column)
|
||||
expect(useLiveNodeOverrides.getState().get(column.id as AnyNodeId)).toMatchObject({
|
||||
width: 1.5,
|
||||
})
|
||||
|
||||
session.commit?.()
|
||||
|
||||
expect(useLiveNodeOverrides.getState().get(column.id as AnyNodeId)).toBeUndefined()
|
||||
expect(useScene.getState().nodes[column.id]).toMatchObject({ width: 1.5 })
|
||||
})
|
||||
|
||||
test('a rotation keeps scene data stable until commit', () => {
|
||||
const spawn = {
|
||||
id: 'spawn_live-rotate',
|
||||
type: 'spawn',
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
}
|
||||
useScene.setState({ nodes: { [spawn.id]: spawn } } as never)
|
||||
const session = spawnRotateAffordance.start({
|
||||
node: spawn as never,
|
||||
payload: undefined,
|
||||
nodes: useScene.getState().nodes,
|
||||
initialPlanPoint: [1, 0],
|
||||
gridSnapStep: 0.1,
|
||||
})
|
||||
|
||||
session.apply({ planPoint: [0, 1], modifiers })
|
||||
|
||||
expect(useScene.getState().nodes[spawn.id]).toBe(spawn)
|
||||
expect(useLiveNodeOverrides.getState().get(spawn.id as AnyNodeId)?.rotation).toBeCloseTo(
|
||||
-Math.PI / 2,
|
||||
)
|
||||
|
||||
session.commit?.()
|
||||
|
||||
expect(useLiveNodeOverrides.getState().get(spawn.id as AnyNodeId)).toBeUndefined()
|
||||
expect((useScene.getState().nodes[spawn.id] as { rotation: number }).rotation).toBeCloseTo(
|
||||
-Math.PI / 2,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
type AnyNodeId,
|
||||
type FloorplanAffordance,
|
||||
type ShelfNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { isAngleSnapActive } from '@pascal-app/editor'
|
||||
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||
|
||||
// Mirror the 3D handles in `shelf/definition.ts` so a drag can't push a
|
||||
@@ -45,13 +47,15 @@ export const shelfResizeAffordance: FloorplanAffordance<ShelfNode> = {
|
||||
} else {
|
||||
lastPatch = { depth: Math.max(MIN_SHELF_DEPTH, initialDepth + 2 * projDelta) }
|
||||
}
|
||||
useScene.getState().updateNode(shelfId, lastPatch)
|
||||
useLiveNodeOverrides.getState().set(shelfId, lastPatch)
|
||||
useScene.getState().markDirty(shelfId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
if (Object.keys(lastPatch).length > 0) {
|
||||
useLiveNodeOverrides.getState().clear(shelfId)
|
||||
useScene.getState().updateNode(shelfId, lastPatch)
|
||||
}
|
||||
},
|
||||
@@ -83,21 +87,23 @@ export const shelfRotateAffordance: FloorplanAffordance<ShelfNode> = {
|
||||
|
||||
return {
|
||||
affectedIds: [shelfId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
apply({ planPoint }) {
|
||||
const delta = rotateAffordanceDelta({
|
||||
center: [cx, cz],
|
||||
initialAngle,
|
||||
planPoint,
|
||||
free: modifiers.shiftKey,
|
||||
free: !isAngleSnapActive(),
|
||||
})
|
||||
const newRotationY = initialRotationY - delta
|
||||
lastRotation = [r[0], newRotationY, r[2]]
|
||||
useScene.getState().updateNode(shelfId, { rotation: lastRotation })
|
||||
useLiveNodeOverrides.getState().set(shelfId, { rotation: lastRotation })
|
||||
useScene.getState().markDirty(shelfId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(shelfId)
|
||||
useScene.getState().updateNode(shelfId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,14 +6,16 @@ import {
|
||||
type FloorplanMoveTargetSession,
|
||||
movingFootprintAnchors,
|
||||
type ShelfNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
applyFloorplanAlignment,
|
||||
getFloorStackPreviewPosition,
|
||||
getSegmentGridStep,
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
type WallPlanPoint,
|
||||
} from '@pascal-app/editor'
|
||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
@@ -23,22 +25,8 @@ import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
* because shelf is a `position`-field kind (it carries its location in
|
||||
* `node.position`, not in polygon vertices):
|
||||
*
|
||||
* - Each pointermove writes the absolute world-plan position straight
|
||||
* to `useScene` (history is paused by the overlay). This is the single
|
||||
* source of truth: the 2D `FloorplanRegistryLayer` and the 3D
|
||||
* `ParametricNodeRenderer` group transform both follow it reactively,
|
||||
* so 2D and 3D can never diverge.
|
||||
* - On commit, the overlay's snapshot-diff reverts to baseline, resumes
|
||||
* history, and re-applies the final position as one undoable step.
|
||||
* `canCommit` only validates.
|
||||
*
|
||||
* Earlier this used the `useLiveTransforms` + imperative-mesh pattern that
|
||||
* `slab` / `ceiling` use. That works for polygon kinds because their commit
|
||||
* rebuilds geometry (the vertices change), which forces the 3D group to
|
||||
* reconcile. Shelf's `geometryKey` excludes `position`, so its commit
|
||||
* `markDirty` is a no-op and nothing reconciled the 3D group off the cleared
|
||||
* live transform — the 2D SVG moved but the 3D mesh stayed put. Writing the
|
||||
* scene directly removes that second source of truth entirely.
|
||||
* - Each pointermove previews through `useLiveNodeOverrides`.
|
||||
* - On commit, the final position is written once as one undoable step.
|
||||
*/
|
||||
export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node, nodes }) => {
|
||||
const shelfId = node.id as AnyNodeId
|
||||
@@ -49,6 +37,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
metadata: node.metadata,
|
||||
})
|
||||
let lastPosition: [number, number, number] = originalPosition
|
||||
let lastVisualPosition: [number, number, number] = originalPosition
|
||||
let lastSnapKey: string | null = null
|
||||
|
||||
// Alignment candidates — corner/edge/segment anchors of every OTHER node
|
||||
@@ -58,16 +47,13 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [shelfId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const snap = (value: number) => {
|
||||
if (modifiers.shiftKey) return value
|
||||
const step = useEditor.getState().gridSnapStep
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
apply({ planPoint }) {
|
||||
const gridSnapActive = isGridSnapActive()
|
||||
const step = gridSnapActive ? getSegmentGridStep() : 0
|
||||
const snap = (value: number) => (step > 0 ? Math.round(value / step) * step : value)
|
||||
const gridSnapped = resolveCursor(planPoint, { snap }) as WallPlanPoint
|
||||
// Figma-style alignment layered on the grid snap — the shelf footprint
|
||||
// edges snap to neighbours / wall faces and a guide is published. Alt
|
||||
// bypasses alignment; Shift bypasses all snap.
|
||||
// edges snap to neighbours / wall faces and a guide is published.
|
||||
const { point: snapped } = applyFloorplanAlignment(
|
||||
gridSnapped,
|
||||
movingFootprintAnchors(
|
||||
@@ -77,7 +63,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
originalRotationY,
|
||||
),
|
||||
candidates,
|
||||
{ applySnap: isMagneticSnapActive(), bypass: modifiers.altKey || modifiers.shiftKey },
|
||||
{ applySnap: isMagneticSnapActive() },
|
||||
)
|
||||
const next: [number, number, number] = [snapped[0], originalPosition[1], snapped[1]]
|
||||
lastPosition = next
|
||||
@@ -86,7 +72,7 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
// and the placement coordinators. Item / slab / wall flows fire
|
||||
// the same cue, so the shelf following along is the expected UX.
|
||||
const snapKey = `${snapped[0]},${snapped[1]}`
|
||||
if (!modifiers.shiftKey && snapKey !== lastSnapKey) {
|
||||
if (gridSnapActive && snapKey !== lastSnapKey) {
|
||||
triggerSFX('sfx:grid-snap')
|
||||
lastSnapKey = snapKey
|
||||
}
|
||||
@@ -96,23 +82,19 @@ export const shelfFloorplanMoveTarget: FloorplanMoveTarget<ShelfNode> = ({ node,
|
||||
rotation: node.rotation,
|
||||
levelId: node.parentId ?? null,
|
||||
})
|
||||
// Single source of truth — write the absolute position straight to
|
||||
// the scene (history is paused by the overlay). Both the 2D SVG and
|
||||
// the 3D group transform read `node.position` reactively, so they
|
||||
// stay in lockstep. The overlay's snapshot-diff turns the whole drag
|
||||
// into one undoable step on commit.
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: shelfId,
|
||||
data: { position: visualPosition },
|
||||
},
|
||||
])
|
||||
lastVisualPosition = visualPosition
|
||||
useLiveNodeOverrides.getState().set(shelfId, { position: visualPosition })
|
||||
useScene.getState().markDirty(shelfId)
|
||||
},
|
||||
canCommit() {
|
||||
const live = useScene.getState().nodes[shelfId] as ShelfNode | undefined
|
||||
if (live?.type !== 'shelf') return false
|
||||
return !(lastPosition[0] === originalPosition[0] && lastPosition[2] === originalPosition[2])
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(shelfId)
|
||||
useScene.getState().updateNodes([{ id: shelfId, data: { position: lastVisualPosition } }])
|
||||
},
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
type AnyNodeId,
|
||||
type FloorplanAffordance,
|
||||
type SpawnNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { isAngleSnapActive } from '@pascal-app/editor'
|
||||
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||
|
||||
export const spawnRotateAffordance: FloorplanAffordance<SpawnNode> = {
|
||||
@@ -17,20 +19,22 @@ export const spawnRotateAffordance: FloorplanAffordance<SpawnNode> = {
|
||||
|
||||
return {
|
||||
affectedIds: [spawnId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
apply({ planPoint }) {
|
||||
const delta = rotateAffordanceDelta({
|
||||
center: [cx, cz],
|
||||
initialAngle,
|
||||
planPoint,
|
||||
free: modifiers.shiftKey,
|
||||
free: !isAngleSnapActive(),
|
||||
})
|
||||
lastRotation = initialRotation - delta
|
||||
useScene.getState().updateNode(spawnId, { rotation: lastRotation })
|
||||
useLiveNodeOverrides.getState().set(spawnId, { rotation: lastRotation })
|
||||
useScene.getState().markDirty(spawnId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(spawnId)
|
||||
useScene.getState().updateNode(spawnId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import {
|
||||
type FloorplanMoveTargetSession,
|
||||
type SpawnNode,
|
||||
snapScalar,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { getSegmentGridStep } from '@pascal-app/editor'
|
||||
import { getSegmentGridStep, isGridSnapActive } from '@pascal-app/editor'
|
||||
|
||||
export const spawnFloorplanMoveTarget: FloorplanMoveTarget<SpawnNode> = ({ node }) => {
|
||||
const spawnId = node.id as AnyNodeId
|
||||
@@ -16,14 +17,15 @@ export const spawnFloorplanMoveTarget: FloorplanMoveTarget<SpawnNode> = ({ node
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [spawnId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
const step = getSegmentGridStep()
|
||||
const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step))
|
||||
apply({ planPoint }) {
|
||||
const step = isGridSnapActive() ? getSegmentGridStep() : 0
|
||||
const snap = (value: number) => (step > 0 ? snapScalar(value, step) : value)
|
||||
const next: [number, number, number] = [snap(planPoint[0]), startY, snap(planPoint[1])]
|
||||
|
||||
if (lastPosition && lastPosition[0] === next[0] && lastPosition[2] === next[2]) return
|
||||
lastPosition = next
|
||||
useScene.getState().updateNodes([{ id: spawnId, data: { position: next } }])
|
||||
useLiveNodeOverrides.getState().set(spawnId, { position: next })
|
||||
useScene.getState().markDirty(spawnId)
|
||||
},
|
||||
canCommit() {
|
||||
if (!lastPosition) return false
|
||||
@@ -31,6 +33,7 @@ export const spawnFloorplanMoveTarget: FloorplanMoveTarget<SpawnNode> = ({ node
|
||||
},
|
||||
commit() {
|
||||
if (!lastPosition) return
|
||||
useLiveNodeOverrides.getState().clear(spawnId)
|
||||
useScene.getState().updateNodes([{ id: spawnId, data: { position: lastPosition } }])
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
type FloorplanAffordanceSession,
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { isAngleSnapActive } from '@pascal-app/editor'
|
||||
import { rotateAffordanceDelta } from '../shared/rotate-affordance'
|
||||
|
||||
// Minimums + max sweep mirror the 3D handles in
|
||||
@@ -73,12 +75,14 @@ export const segmentWidthAffordance: FloorplanAffordance<StairNode> = {
|
||||
const delta = sign * (currentProj - initialProj)
|
||||
const newWidth = Math.max(MIN_SEGMENT_WIDTH, initialWidth + delta)
|
||||
lastWidth = newWidth
|
||||
useScene.getState().updateNode(segmentNodeId, { width: newWidth })
|
||||
useLiveNodeOverrides.getState().set(segmentNodeId, { width: newWidth })
|
||||
useScene.getState().markDirty(segmentNodeId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(segmentNodeId)
|
||||
useScene.getState().updateNode(segmentNodeId, { width: lastWidth })
|
||||
},
|
||||
}
|
||||
@@ -111,12 +115,14 @@ export const segmentLengthAffordance: FloorplanAffordance<StairNode> = {
|
||||
const delta = currentProj - initialProj
|
||||
const newLength = Math.max(MIN_SEGMENT_LENGTH, initialLength + delta)
|
||||
lastLength = newLength
|
||||
useScene.getState().updateNode(segmentNodeId, { length: newLength })
|
||||
useLiveNodeOverrides.getState().set(segmentNodeId, { length: newLength })
|
||||
useScene.getState().markDirty(segmentNodeId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(segmentNodeId)
|
||||
useScene.getState().updateNode(segmentNodeId, { length: lastLength })
|
||||
},
|
||||
}
|
||||
@@ -149,12 +155,14 @@ export const curvedStairWidthAffordance: FloorplanAffordance<StairNode> = {
|
||||
const currentRadial = (planPoint[0] - cx) * radialX + (planPoint[1] - cz) * radialZ
|
||||
const newWidth = Math.max(MIN_CURVED_WIDTH, initialWidth + (currentRadial - initialRadial))
|
||||
lastWidth = newWidth
|
||||
useScene.getState().updateNode(stairId, { width: newWidth })
|
||||
useLiveNodeOverrides.getState().set(stairId, { width: newWidth })
|
||||
useScene.getState().markDirty(stairId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(stairId)
|
||||
useScene.getState().updateNode(stairId, { width: lastWidth })
|
||||
},
|
||||
}
|
||||
@@ -200,12 +208,17 @@ export const curvedStairInnerRadiusAffordance: FloorplanAffordance<StairNode> =
|
||||
const newWidth = initialOuterRadius - newInner
|
||||
lastInner = newInner
|
||||
lastWidth = newWidth
|
||||
useScene.getState().updateNode(stairId, { innerRadius: newInner, width: newWidth })
|
||||
useLiveNodeOverrides.getState().set(stairId, {
|
||||
innerRadius: newInner,
|
||||
width: newWidth,
|
||||
})
|
||||
useScene.getState().markDirty(stairId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(stairId)
|
||||
useScene.getState().updateNode(stairId, { innerRadius: lastInner, width: lastWidth })
|
||||
},
|
||||
}
|
||||
@@ -233,21 +246,23 @@ export const stairRotateAffordance: FloorplanAffordance<StairNode> = {
|
||||
|
||||
return {
|
||||
affectedIds: [stairId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
apply({ planPoint }) {
|
||||
const delta = rotateAffordanceDelta({
|
||||
center: [cx, cz],
|
||||
initialAngle,
|
||||
planPoint,
|
||||
free: modifiers.shiftKey,
|
||||
free: !isAngleSnapActive(),
|
||||
})
|
||||
const newRotation = initialRotation - delta
|
||||
lastRotation = newRotation
|
||||
useScene.getState().updateNode(stairId, { rotation: newRotation })
|
||||
useLiveNodeOverrides.getState().set(stairId, { rotation: newRotation })
|
||||
useScene.getState().markDirty(stairId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(stairId)
|
||||
useScene.getState().updateNode(stairId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
@@ -298,12 +313,17 @@ export const curvedStairSweepAffordance: FloorplanAffordance<StairNode> = {
|
||||
const newRotation = initialRotation + rotationShift
|
||||
lastSweep = newSweep
|
||||
lastRotation = newRotation
|
||||
useScene.getState().updateNode(stairId, { sweepAngle: newSweep, rotation: newRotation })
|
||||
useLiveNodeOverrides.getState().set(stairId, {
|
||||
sweepAngle: newSweep,
|
||||
rotation: newRotation,
|
||||
})
|
||||
useScene.getState().markDirty(stairId)
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useLiveNodeOverrides.getState().clear(stairId)
|
||||
useScene.getState().updateNode(stairId, { sweepAngle: lastSweep, rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,9 +6,15 @@ import {
|
||||
movingAlignmentAnchors,
|
||||
type StairNode,
|
||||
snapScalar,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { applyFloorplanAlignment, getSegmentGridStep } from '@pascal-app/editor'
|
||||
import {
|
||||
applyFloorplanAlignment,
|
||||
getSegmentGridStep,
|
||||
isGridSnapActive,
|
||||
isMagneticSnapActive,
|
||||
} from '@pascal-app/editor'
|
||||
import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
|
||||
/**
|
||||
@@ -17,13 +23,11 @@ import { createFloorplanCursorResolver } from '../shared/floorplan-cursor'
|
||||
* Existing stairs preserve the cursor grab offset, matching the 3D move
|
||||
* tools; fresh catalog placement follows the cursor absolutely.
|
||||
*
|
||||
* Figma alignment is layered on the stair footprint edges; Alt bypasses.
|
||||
* Figma alignment is layered on the stair footprint edges.
|
||||
* Guides are cleared by `FloorplanRegistryMoveOverlay`'s Path 1 teardown.
|
||||
*
|
||||
* The position is written straight to scene each tick (the stair has a real
|
||||
* `position` field, unlike polygon kinds) and re-applied atomically via
|
||||
* `commit()` so the overlay's deterministic revert → resume → commit path
|
||||
* records a single undo step (same pattern door / window use).
|
||||
* The position previews through the live override store and is written to
|
||||
* scene once via `commit()`.
|
||||
*/
|
||||
export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node, nodes }) => {
|
||||
const startY = node.position[1]
|
||||
@@ -37,14 +41,12 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node,
|
||||
|
||||
const session: FloorplanMoveTargetSession = {
|
||||
affectedIds: [node.id as AnyNodeId],
|
||||
apply({ planPoint, modifiers }) {
|
||||
// Snap the origin to the editor's current grid step (driven by
|
||||
// `useEditor.gridSnapStep`). Shift bypasses the grid snap.
|
||||
const step = getSegmentGridStep()
|
||||
const snap = (value: number) => (modifiers.shiftKey ? value : snapScalar(value, step))
|
||||
apply({ planPoint }) {
|
||||
const step = isGridSnapActive() ? getSegmentGridStep() : 0
|
||||
const snap = (value: number) => (step > 0 ? snapScalar(value, step) : value)
|
||||
const [gx, gz] = resolveCursor(planPoint, { snap })
|
||||
// Figma alignment on the actual stair footprint (Alt bypasses alignment; Shift all snap),
|
||||
// matching the 3D move tool. Publishes guides via `useAlignmentGuides`.
|
||||
// Figma alignment on the actual stair footprint, matching the 3D move
|
||||
// tool. Publishes guides via `useAlignmentGuides`.
|
||||
const movingAnchors = movingAlignmentAnchors(node, nodes, gx, gz, node.rotation ?? 0)
|
||||
const { point: aligned } = applyFloorplanAlignment(
|
||||
[gx, gz],
|
||||
@@ -52,14 +54,15 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node,
|
||||
? movingAnchors
|
||||
: [{ nodeId: node.id, kind: 'corner', x: gx, z: gz }],
|
||||
candidates,
|
||||
{ bypass: modifiers.altKey || modifiers.shiftKey },
|
||||
{ applySnap: isMagneticSnapActive() },
|
||||
)
|
||||
const sx = aligned[0]
|
||||
const sz = aligned[1]
|
||||
|
||||
if (lastValid && lastValid.position[0] === sx && lastValid.position[2] === sz) return
|
||||
lastValid = { position: [sx, startY, sz] }
|
||||
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastValid }])
|
||||
useLiveNodeOverrides.getState().set(node.id as AnyNodeId, lastValid)
|
||||
useScene.getState().markDirty(node.id as AnyNodeId)
|
||||
},
|
||||
canCommit() {
|
||||
// No overlap / placement rules for stairs in 2D — any pointer-up
|
||||
@@ -72,6 +75,7 @@ export const stairFloorplanMoveTarget: FloorplanMoveTarget<StairNode> = ({ node,
|
||||
// commit-path (revert → resume → session.commit()). Same pattern
|
||||
// door / window use.
|
||||
if (!lastValid) return
|
||||
useLiveNodeOverrides.getState().clear(node.id as AnyNodeId)
|
||||
useScene.getState().updateNodes([{ id: node.id as AnyNodeId, data: lastValid }])
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export const structuralGridDefinition: NodeDefinition<typeof StructuralGridNode>
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
tool: () => import('./floorplan-tool'),
|
||||
availableModes: ['expert'],
|
||||
preferredView: '2d',
|
||||
} satisfies FloorplanNodeExtension<StructuralGridNode>,
|
||||
},
|
||||
|
||||
@@ -14,34 +14,9 @@ function wall(overrides: Partial<WallNodeType>): WallNodeType {
|
||||
parentId: 'level_main',
|
||||
start: [0, 0],
|
||||
end: [1, 0],
|
||||
thickness: 0.2,
|
||||
frontSide: 'interior',
|
||||
backSide: 'interior',
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.2,
|
||||
materialRef: 'library:stud',
|
||||
datumEligible: ['structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'interior-finish',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.02,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'exterior-finish',
|
||||
role: 'exterior-finish',
|
||||
side: 'exterior',
|
||||
thickness: 0.04,
|
||||
materialRef: 'library:cladding',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
@@ -88,42 +63,9 @@ function topFacadeFixture(splitAtPartition = false, partitionSpansPlan = false)
|
||||
id: 'wall_partition',
|
||||
start: [4, partitionSpansPlan ? -6 : -4],
|
||||
end: [4, 0],
|
||||
thickness: 0.12,
|
||||
frontSide: 'interior',
|
||||
backSide: 'interior',
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'partition-stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.12,
|
||||
materialRef: 'library:stud',
|
||||
datumEligible: ['structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'partition-finish-left',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.02,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'partition-finish-right',
|
||||
role: 'interior-finish',
|
||||
side: 'exterior',
|
||||
thickness: 0.02,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'partition-veneer',
|
||||
role: 'masonry-veneer',
|
||||
side: 'exterior',
|
||||
thickness: 0.1,
|
||||
materialRef: 'library:brick',
|
||||
datumEligible: ['veneer-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
const walls = [top, ...(topContinuation ? [topContinuation] : []), right, bottom, left, partition]
|
||||
const nodes = Object.fromEntries(walls.map((candidate) => [candidate.id, candidate])) as Record<
|
||||
@@ -172,12 +114,12 @@ describe('automatic wall dimension reference policy', () => {
|
||||
return entries[0]?.end[0]
|
||||
}
|
||||
|
||||
expect(intersection('finish-face')).toBeCloseTo(3.92)
|
||||
expect(intersection('finish-face')).toBeCloseTo(3.94)
|
||||
expect(intersection('centerline')).toBeCloseTo(4)
|
||||
expect(intersection('structural-face')).toBeCloseTo(3.94)
|
||||
})
|
||||
|
||||
test('keeps finished faces, centerline, and face of stud as distinct display modes', () => {
|
||||
test('keeps centerline distinct while all face modes use the wall face', () => {
|
||||
const { nodes, top, walls } = topFacadeFixture(true)
|
||||
const levelData = computeWallFloorplanLevelData({ siblings: walls, nodes })
|
||||
const renderedSegments = (reference: 'finished-faces' | 'centerline' | 'stud-faces') => {
|
||||
@@ -191,9 +133,9 @@ describe('automatic wall dimension reference policy', () => {
|
||||
}
|
||||
|
||||
expect(renderedSegments('finished-faces').map((segment) => segment.text)).toEqual([
|
||||
'3.92m',
|
||||
'0.26m',
|
||||
'6.02m',
|
||||
'4.04m',
|
||||
'0.12m',
|
||||
'6.04m',
|
||||
])
|
||||
expect(renderedSegments('centerline').map((segment) => segment.text)).toEqual(['4.1m', '6.1m'])
|
||||
expect(renderedSegments('stud-faces').map((segment) => segment.text)).toEqual([
|
||||
@@ -306,16 +248,7 @@ describe('automatic wall dimension reference policy', () => {
|
||||
id: 'wall_horizontal_partition',
|
||||
start: [0, -3],
|
||||
end: [10, -3],
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'horizontal-stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.12,
|
||||
materialRef: 'library:stud',
|
||||
datumEligible: ['structural-face'],
|
||||
},
|
||||
],
|
||||
thickness: 0.12,
|
||||
})
|
||||
const horizontalWalls = [top, right, bottom, left, partition]
|
||||
const horizontalNodes = Object.fromEntries(
|
||||
|
||||
@@ -184,36 +184,12 @@ describe('buildWallConstructionDimensions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('places witness origins on centerline, structural, finish, or assembly faces', () => {
|
||||
const assemblyWall = wall({
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.1,
|
||||
datumEligible: ['structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'interior-finish',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.02,
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'exterior-finish',
|
||||
role: 'exterior-finish',
|
||||
side: 'exterior',
|
||||
thickness: 0.03,
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
test('places witness origins on the centerline or wall faces', () => {
|
||||
const plainWall = wall({ thickness: 0.2 })
|
||||
const witnessY = (
|
||||
datumPolicy: 'centerline' | 'wall-face' | 'structural-face' | 'finish-face',
|
||||
) => {
|
||||
const entry = buildWallConstructionDimensions(assemblyWall, context(), {
|
||||
const entry = buildWallConstructionDimensions(plainWall, context(), {
|
||||
unit: 'metric',
|
||||
standard: constructionDimensionStandard({ datumPolicy }),
|
||||
})[0]
|
||||
@@ -221,9 +197,9 @@ describe('buildWallConstructionDimensions', () => {
|
||||
}
|
||||
|
||||
expect(witnessY('centerline')).toBe(0)
|
||||
expect(witnessY('structural-face')).toBeCloseTo(0.05)
|
||||
expect(witnessY('finish-face')).toBeCloseTo(0.08)
|
||||
expect(witnessY('wall-face')).toBeCloseTo(0.08)
|
||||
expect(witnessY('structural-face')).toBeCloseTo(0.1)
|
||||
expect(witnessY('finish-face')).toBeCloseTo(0.1)
|
||||
expect(witnessY('wall-face')).toBeCloseTo(0.1)
|
||||
})
|
||||
|
||||
test('never dimensions a classified interior wall', () => {
|
||||
|
||||
@@ -6,11 +6,10 @@ import {
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
getWallArcData,
|
||||
getWallAssemblyFaceOffsets,
|
||||
getWallChordFrame,
|
||||
getWallMidpointHandlePoint,
|
||||
getWallThickness,
|
||||
isCurvedWall,
|
||||
resolveWallAssemblyDatumReferences,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -1512,16 +1511,8 @@ function wallDatumOffsetOnSide(
|
||||
policy: ConstructionDimensionDrawingStandard['datumPolicy'],
|
||||
side: 1 | -1,
|
||||
): number {
|
||||
const faces = getWallAssemblyFaceOffsets(wall)
|
||||
if (policy === 'wall-face') return side > 0 ? faces.exterior : faces.interior
|
||||
if (policy === 'centerline') return 0
|
||||
|
||||
const datum = policy === 'finish-face' ? 'finish-face' : 'structural-face'
|
||||
const candidates = resolveWallAssemblyDatumReferences(wall)
|
||||
.filter((reference) => reference.datum === datum && Math.sign(reference.offset) === side)
|
||||
.map((reference) => reference.offset)
|
||||
if (candidates.length === 0) return side > 0 ? faces.exterior : faces.interior
|
||||
return side > 0 ? Math.max(...candidates) : Math.min(...candidates)
|
||||
return (getWallThickness(wall) / 2) * side
|
||||
}
|
||||
|
||||
function wallDatumDistanceToward(
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
getWallThickness,
|
||||
isCurvedWall,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { formatLinearMeasurement, readFloorplanMetricNotationOverride } from '@pascal-app/editor'
|
||||
|
||||
type WallHostedOpening = {
|
||||
position: readonly [number, number, number]
|
||||
width: number
|
||||
}
|
||||
|
||||
type OpeningDimensionOptions = {
|
||||
showClearancesWhileMoving: boolean
|
||||
useExteriorNormal: boolean
|
||||
}
|
||||
|
||||
const WALL_CONNECTION_TOLERANCE = 0.03
|
||||
|
||||
function formatLength(value: number, ctx: GeometryContext): string {
|
||||
return formatLinearMeasurement(
|
||||
value,
|
||||
ctx.viewState?.unit ?? 'metric',
|
||||
readFloorplanMetricNotationOverride(ctx) ?? 'meters',
|
||||
)
|
||||
}
|
||||
|
||||
function selectedStroke(ctx: GeometryContext): string {
|
||||
return ctx.viewState?.palette?.selectedStroke ?? '#2563eb'
|
||||
}
|
||||
|
||||
function contextualWallDimensionNormal(
|
||||
wall: WallNode,
|
||||
frontNormal: FloorplanPoint,
|
||||
siblings: GeometryContext['siblings'],
|
||||
): FloorplanPoint {
|
||||
const front: FloorplanPoint = [cleanZero(frontNormal[0]), cleanZero(frontNormal[1])]
|
||||
const back: FloorplanPoint = [cleanZero(-front[0]), cleanZero(-front[1])]
|
||||
if (wall.frontSide === 'exterior' && wall.backSide !== 'exterior') return front
|
||||
if (wall.backSide === 'exterior' && wall.frontSide !== 'exterior') return back
|
||||
if (wall.frontSide === 'interior' && wall.backSide === 'interior') return front
|
||||
|
||||
const walls = [
|
||||
wall,
|
||||
...siblings.filter((sibling): sibling is WallNode => sibling.type === 'wall'),
|
||||
]
|
||||
let centroidX = 0
|
||||
let centroidY = 0
|
||||
for (const candidate of walls) {
|
||||
centroidX += candidate.start[0] + candidate.end[0]
|
||||
centroidY += candidate.start[1] + candidate.end[1]
|
||||
}
|
||||
const centroid: FloorplanPoint = [centroidX / (walls.length * 2), centroidY / (walls.length * 2)]
|
||||
const midpoint: FloorplanPoint = [
|
||||
(wall.start[0] + wall.end[0]) / 2,
|
||||
(wall.start[1] + wall.end[1]) / 2,
|
||||
]
|
||||
const towardFront =
|
||||
(midpoint[0] - centroid[0]) * front[0] + (midpoint[1] - centroid[1]) * front[1]
|
||||
return towardFront >= 0 ? front : back
|
||||
}
|
||||
|
||||
function cleanZero(value: number): number {
|
||||
return Math.abs(value) <= Number.EPSILON ? 0 : value
|
||||
}
|
||||
|
||||
function cross(left: FloorplanPoint, right: FloorplanPoint): number {
|
||||
return left[0] * right[1] - left[1] * right[0]
|
||||
}
|
||||
|
||||
function pointSegmentDistance(
|
||||
point: FloorplanPoint,
|
||||
start: FloorplanPoint,
|
||||
end: FloorplanPoint,
|
||||
): number {
|
||||
const dx = end[0] - start[0]
|
||||
const dy = end[1] - start[1]
|
||||
const lengthSquared = dx * dx + dy * dy
|
||||
if (lengthSquared <= 1e-12) return Math.hypot(point[0] - start[0], point[1] - start[1])
|
||||
const t = Math.max(
|
||||
0,
|
||||
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / lengthSquared),
|
||||
)
|
||||
return Math.hypot(point[0] - (start[0] + dx * t), point[1] - (start[1] + dy * t))
|
||||
}
|
||||
|
||||
function structuralFaceProjections(
|
||||
wallStart: FloorplanPoint,
|
||||
wallTangent: FloorplanPoint,
|
||||
connectedWall: WallNode,
|
||||
): number[] {
|
||||
if (isCurvedWall(connectedWall)) return []
|
||||
const dx = connectedWall.end[0] - connectedWall.start[0]
|
||||
const dy = connectedWall.end[1] - connectedWall.start[1]
|
||||
const length = Math.hypot(dx, dy)
|
||||
if (length <= 1e-6) return []
|
||||
const direction: FloorplanPoint = [dx / length, dy / length]
|
||||
const denominator = cross(wallTangent, direction)
|
||||
if (Math.abs(denominator) <= 1e-6) return []
|
||||
const normal: FloorplanPoint = [-direction[1], direction[0]]
|
||||
|
||||
const halfThickness = getWallThickness(connectedWall) / 2
|
||||
return [-halfThickness, halfThickness].map((offset) => {
|
||||
const facePoint: FloorplanPoint = [
|
||||
connectedWall.start[0] + normal[0] * offset,
|
||||
connectedWall.start[1] + normal[1] * offset,
|
||||
]
|
||||
return (
|
||||
cross([facePoint[0] - wallStart[0], facePoint[1] - wallStart[1]], direction) / denominator
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function wallStudFaceSpan(
|
||||
wall: WallNode,
|
||||
siblings: GeometryContext['siblings'],
|
||||
): { end: FloorplanPoint; length: number; start: FloorplanPoint } {
|
||||
const dx = wall.end[0] - wall.start[0]
|
||||
const dy = wall.end[1] - wall.start[1]
|
||||
const centerlineLength = Math.hypot(dx, dy)
|
||||
if (centerlineLength <= 1e-6) {
|
||||
return { start: wall.start, end: wall.end, length: centerlineLength }
|
||||
}
|
||||
|
||||
const tangent: FloorplanPoint = [dx / centerlineLength, dy / centerlineLength]
|
||||
let startProjection = 0
|
||||
let endProjection = centerlineLength
|
||||
|
||||
for (const sibling of siblings) {
|
||||
if (sibling.type !== 'wall' || sibling.id === wall.id) continue
|
||||
const projections = structuralFaceProjections(wall.start, tangent, sibling)
|
||||
if (pointSegmentDistance(wall.start, sibling.start, sibling.end) <= WALL_CONNECTION_TOLERANCE) {
|
||||
for (const projection of projections) {
|
||||
if (projection > startProjection && projection < endProjection) {
|
||||
startProjection = projection
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pointSegmentDistance(wall.end, sibling.start, sibling.end) <= WALL_CONNECTION_TOLERANCE) {
|
||||
for (const projection of projections) {
|
||||
if (projection > startProjection && projection < endProjection) {
|
||||
endProjection = projection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const start: FloorplanPoint = [
|
||||
wall.start[0] + tangent[0] * startProjection,
|
||||
wall.start[1] + tangent[1] * startProjection,
|
||||
]
|
||||
const end: FloorplanPoint = [
|
||||
wall.start[0] + tangent[0] * endProjection,
|
||||
wall.start[1] + tangent[1] * endProjection,
|
||||
]
|
||||
return { start, end, length: Math.max(0, endProjection - startProjection) }
|
||||
}
|
||||
|
||||
export function buildWallContextualDimensions(
|
||||
node: WallNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const length = getWallCurveLength(node)
|
||||
if (!Number.isFinite(length) || length <= 1e-6) return null
|
||||
|
||||
if (isCurvedWall(node)) {
|
||||
const frame = getWallCurveFrameAt(node, 0.5)
|
||||
const offsetNormal = contextualWallDimensionNormal(
|
||||
node,
|
||||
[frame.normal.x, frame.normal.y],
|
||||
ctx.siblings,
|
||||
)
|
||||
return {
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: frame.point.x + offsetNormal[0] * 0.38,
|
||||
cy: frame.point.y + offsetNormal[1] * 0.38,
|
||||
text: formatLength(length, ctx),
|
||||
angle: Math.atan2(frame.tangent.y, frame.tangent.x),
|
||||
}
|
||||
}
|
||||
|
||||
const dx = node.end[0] - node.start[0]
|
||||
const dy = node.end[1] - node.start[1]
|
||||
const chordLength = Math.hypot(dx, dy)
|
||||
if (chordLength <= 1e-6) return null
|
||||
const offsetNormal = contextualWallDimensionNormal(
|
||||
node,
|
||||
[-dy / chordLength, dx / chordLength],
|
||||
ctx.siblings,
|
||||
)
|
||||
const span =
|
||||
node.frontSide === 'interior' && node.backSide === 'interior'
|
||||
? wallStudFaceSpan(node, ctx.siblings)
|
||||
: { start: node.start, end: node.end, length: chordLength }
|
||||
if (span.length <= 1e-6) return null
|
||||
|
||||
return {
|
||||
kind: 'dimension',
|
||||
start: span.start,
|
||||
end: span.end,
|
||||
offsetNormal,
|
||||
offsetDistance: 0.34,
|
||||
extensionOvershoot: 0.08,
|
||||
text: formatLength(span.length, ctx),
|
||||
stroke: selectedStroke(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWallHostedOpeningContextualDimensions(
|
||||
node: WallHostedOpening,
|
||||
ctx: GeometryContext,
|
||||
options: OpeningDimensionOptions,
|
||||
): FloorplanGeometry | null {
|
||||
const wall = ctx.parent as WallNode | null
|
||||
if (wall?.type !== 'wall' || node.width <= 1e-6) return null
|
||||
const dx = wall.end[0] - wall.start[0]
|
||||
const dy = wall.end[1] - wall.start[1]
|
||||
const wallLength = Math.hypot(dx, dy)
|
||||
if (wallLength <= 1e-6) return null
|
||||
|
||||
const dirX = dx / wallLength
|
||||
const dirY = dy / wallLength
|
||||
const cx = wall.start[0] + dirX * node.position[0]
|
||||
const cy = wall.start[1] + dirY * node.position[0]
|
||||
const halfWidth = node.width / 2
|
||||
const openingStart: FloorplanPoint = [cx - dirX * halfWidth, cy - dirY * halfWidth]
|
||||
const openingEnd: FloorplanPoint = [cx + dirX * halfWidth, cy + dirY * halfWidth]
|
||||
|
||||
if (!options.useExteriorNormal || isCurvedWall(wall)) {
|
||||
return {
|
||||
kind: 'dimension',
|
||||
start: openingStart,
|
||||
end: openingEnd,
|
||||
offsetNormal: [-dirY, dirX],
|
||||
offsetDistance: 0.34,
|
||||
extensionOvershoot: 0.08,
|
||||
text: formatLength(node.width, ctx),
|
||||
stroke: selectedStroke(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
const offsetNormal = contextualWallDimensionNormal(wall, [-dirY, dirX], ctx.siblings)
|
||||
if (!options.showClearancesWhileMoving || !ctx.viewState?.moving) {
|
||||
return {
|
||||
kind: 'dimension',
|
||||
start: openingStart,
|
||||
end: openingEnd,
|
||||
offsetNormal,
|
||||
offsetDistance: 0.34,
|
||||
extensionOvershoot: 0.08,
|
||||
text: formatLength(node.width, ctx),
|
||||
stroke: selectedStroke(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
const span = wallStudFaceSpan(wall, ctx.siblings)
|
||||
const spanStart = (span.start[0] - wall.start[0]) * dirX + (span.start[1] - wall.start[1]) * dirY
|
||||
const spanEnd = (span.end[0] - wall.start[0]) * dirX + (span.end[1] - wall.start[1]) * dirY
|
||||
const openingStartAlong = node.position[0] - halfWidth
|
||||
const openingEndAlong = node.position[0] + halfWidth
|
||||
|
||||
return {
|
||||
kind: 'dimension-string',
|
||||
segments: [
|
||||
{
|
||||
start: span.start,
|
||||
end: openingStart,
|
||||
text: formatLength(Math.max(0, openingStartAlong - spanStart), ctx),
|
||||
},
|
||||
{
|
||||
start: openingStart,
|
||||
end: openingEnd,
|
||||
text: formatLength(node.width, ctx),
|
||||
},
|
||||
{
|
||||
start: openingEnd,
|
||||
end: span.end,
|
||||
text: formatLength(Math.max(0, spanEnd - openingEndAlong), ctx),
|
||||
},
|
||||
],
|
||||
offsetNormal,
|
||||
offsetDistance: 0.34,
|
||||
extensionOvershoot: 0.08,
|
||||
stroke: selectedStroke(ctx),
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
||||
import { getFloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { wallDefinition } from './definition'
|
||||
|
||||
test('wallDefinition records the retired assembly field migration', () => {
|
||||
expect(wallDefinition.schemaVersion).toBe(7)
|
||||
})
|
||||
|
||||
describe('wallDefinition floor-plan extension', () => {
|
||||
test('owns curve eligibility for hosted openings', () => {
|
||||
const wall = wallDefinition.schema.parse({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AnyNodeId, NodeDefinition } from '@pascal-app/core'
|
||||
import type { FloorplanNodeExtension } from '@pascal-app/editor'
|
||||
import { buildWallContextualDimensions } from './contextual-dimensions'
|
||||
import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan'
|
||||
import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances'
|
||||
import { wallFloorplanMoveTarget } from './floorplan-move'
|
||||
@@ -33,12 +34,13 @@ import { wallSlots } from './slots'
|
||||
export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
kind: 'wall',
|
||||
snapProfile: 'structural',
|
||||
schemaVersion: 6,
|
||||
schemaVersion: 7,
|
||||
schema: WallNode,
|
||||
category: 'structure',
|
||||
surfaceRole: 'wall',
|
||||
extensions: {
|
||||
'pascal:editor/floorplan': {
|
||||
contextualDimensions: buildWallContextualDimensions,
|
||||
actionMenu: {
|
||||
canCurve: ({ node, nodes }) =>
|
||||
!node.children.some((childId) => {
|
||||
@@ -58,7 +60,6 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
visible: true,
|
||||
metadata: {},
|
||||
children: [],
|
||||
assemblyLayers: [],
|
||||
start: [0, 0],
|
||||
end: [3, 0],
|
||||
frontSide: 'unknown',
|
||||
|
||||
@@ -31,6 +31,7 @@ function context(
|
||||
selected = false,
|
||||
metricNotation: 'meters' | 'millimeters' = 'meters',
|
||||
wallDimensionReference: 'finished-faces' | 'centerline' | 'stud-faces' = 'finished-faces',
|
||||
automaticDimensions = true,
|
||||
): GeometryContext {
|
||||
return {
|
||||
resolve: () => undefined,
|
||||
@@ -46,6 +47,7 @@ function context(
|
||||
palette,
|
||||
},
|
||||
extensions: createFloorplanContextExtensions({
|
||||
automaticDimensions,
|
||||
metricNotation,
|
||||
purpose,
|
||||
wallDimensionReference,
|
||||
@@ -90,6 +92,53 @@ describe('buildWallFloorplan render purpose', () => {
|
||||
expect(readFloorplanGeometryMetadata(documentPolygon).annotationObstacle).toBe('outline')
|
||||
})
|
||||
|
||||
test('draws crisp diagonal hatch strokes inside a selected wall', () => {
|
||||
const diagonalWall = WallNode.parse({
|
||||
...wall,
|
||||
end: [4, 4],
|
||||
})
|
||||
const selected = buildWallFloorplan(diagonalWall, context('edit', true))
|
||||
const selectedOutline = selected
|
||||
? flatten(selected).find((entry) => entry.kind === 'polygon')
|
||||
: undefined
|
||||
const hatchLines = selected
|
||||
? flatten(selected).filter(
|
||||
(entry) => entry.kind === 'line' && entry.stroke === palette.selectedHatch,
|
||||
)
|
||||
: []
|
||||
|
||||
expect(selectedOutline?.kind).toBe('polygon')
|
||||
expect(hatchLines.length).toBeGreaterThan(8)
|
||||
expect(
|
||||
hatchLines.every(
|
||||
(entry) =>
|
||||
entry.kind === 'line' &&
|
||||
entry.strokeWidth === 0.02 &&
|
||||
entry.strokeWidth < (selectedOutline?.strokeWidth ?? 0) &&
|
||||
entry.vectorEffect === undefined &&
|
||||
entry.pointerEvents === 'none' &&
|
||||
readFloorplanGeometryMetadata(entry).renderPass === 'overlay',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('extends selected-wall hatch strokes to both wall faces', () => {
|
||||
const selected = buildWallFloorplan(wall, context('edit', true))
|
||||
const entries = selected ? flatten(selected) : []
|
||||
const outline = entries.find((entry) => entry.kind === 'polygon')
|
||||
const hatch = entries.find(
|
||||
(entry) => entry.kind === 'line' && entry.stroke === palette.selectedHatch,
|
||||
)
|
||||
|
||||
expect(outline?.kind).toBe('polygon')
|
||||
expect(hatch?.kind).toBe('line')
|
||||
if (outline?.kind !== 'polygon' || hatch?.kind !== 'line') return
|
||||
|
||||
const wallFaces = outline.points.map((point) => point[1])
|
||||
expect(Math.min(hatch.y1, hatch.y2)).toBeCloseTo(Math.min(...wallFaces))
|
||||
expect(Math.max(hatch.y1, hatch.y2)).toBeCloseTo(Math.max(...wallFaces))
|
||||
})
|
||||
|
||||
test('uses document metric notation only for document output', () => {
|
||||
const edit = buildWallFloorplan(wall, context('edit'))
|
||||
const document = buildWallFloorplan(wall, context('document'))
|
||||
@@ -115,144 +164,40 @@ describe('buildWallFloorplan render purpose', () => {
|
||||
expect(texts).toContain('4000')
|
||||
})
|
||||
|
||||
test('keeps standalone wall witnesses on the stud face in every intersection mode', () => {
|
||||
const assemblyWall = WallNode.parse({
|
||||
test('does not construct automatic wall dimensions when presentation disables them', () => {
|
||||
const geometry = buildWallFloorplan(
|
||||
wall,
|
||||
context('edit', false, 'meters', 'finished-faces', false),
|
||||
)
|
||||
const entries = geometry ? flatten(geometry) : []
|
||||
|
||||
expect(
|
||||
entries.some(
|
||||
(entry) =>
|
||||
entry.kind === 'dimension' ||
|
||||
entry.kind === 'dimension-string' ||
|
||||
entry.kind === 'dimension-label',
|
||||
),
|
||||
).toBe(false)
|
||||
expect(entries.some((entry) => entry.kind === 'polygon')).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps standalone wall witnesses on the wall face in every intersection mode', () => {
|
||||
const plainWall = WallNode.parse({
|
||||
...wall,
|
||||
thickness: undefined,
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'stud-core',
|
||||
role: 'structure',
|
||||
side: 'core',
|
||||
thickness: 0.1,
|
||||
materialRef: 'library:stud',
|
||||
datumEligible: ['structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'interior-finish',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.02,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'exterior-finish',
|
||||
role: 'exterior-finish',
|
||||
side: 'exterior',
|
||||
thickness: 0.03,
|
||||
materialRef: 'library:cladding',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
],
|
||||
thickness: 0.1,
|
||||
})
|
||||
const witnessY = (reference: 'finished-faces' | 'centerline' | 'stud-faces') => {
|
||||
const geometry = buildWallFloorplan(assemblyWall, context('edit', false, 'meters', reference))
|
||||
const geometry = buildWallFloorplan(plainWall, context('edit', false, 'meters', reference))
|
||||
const dimension = geometry
|
||||
? flatten(geometry).find((entry) => entry.kind === 'dimension-string')
|
||||
: undefined
|
||||
return dimension?.kind === 'dimension-string' ? dimension.segments[0]?.start[1] : undefined
|
||||
}
|
||||
|
||||
expect(witnessY('finished-faces')).toBeCloseTo(0.05)
|
||||
expect(witnessY('centerline')).toBeCloseTo(0.05)
|
||||
expect(witnessY('stud-faces')).toBeCloseTo(0.05)
|
||||
})
|
||||
|
||||
test('uses total assembly thickness and emits construction graphics for modeled layers', () => {
|
||||
const assemblyWall = WallNode.parse({
|
||||
...wall,
|
||||
thickness: undefined,
|
||||
assemblyLayers: [
|
||||
{
|
||||
id: 'block-core',
|
||||
role: 'concrete-block',
|
||||
side: 'core',
|
||||
thickness: 0.19,
|
||||
materialRef: 'library:cmu',
|
||||
datumEligible: ['structural-face'],
|
||||
},
|
||||
{
|
||||
id: 'interior-furring',
|
||||
role: 'furring',
|
||||
side: 'interior',
|
||||
thickness: 0.025,
|
||||
materialRef: 'library:furring',
|
||||
datumEligible: [],
|
||||
},
|
||||
{
|
||||
id: 'interior-gwb',
|
||||
role: 'interior-finish',
|
||||
side: 'interior',
|
||||
thickness: 0.016,
|
||||
materialRef: 'library:gypsum-board',
|
||||
datumEligible: ['finish-face'],
|
||||
},
|
||||
{
|
||||
id: 'exterior-air-space',
|
||||
role: 'air-space',
|
||||
side: 'exterior',
|
||||
thickness: 0.025,
|
||||
materialRef: '',
|
||||
datumEligible: [],
|
||||
},
|
||||
{
|
||||
id: 'brick-veneer',
|
||||
role: 'masonry-veneer',
|
||||
side: 'exterior',
|
||||
thickness: 0.09,
|
||||
materialRef: 'library:brick',
|
||||
datumEligible: ['veneer-face'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const document = buildWallFloorplan(assemblyWall, context('document'))
|
||||
const entries = document ? flatten(document) : []
|
||||
const polygons = entries.filter((entry) => entry.kind === 'polygon')
|
||||
const mainPolygon = polygons[0]
|
||||
|
||||
expect(mainPolygon?.kind).toBe('polygon')
|
||||
if (mainPolygon?.kind !== 'polygon') return
|
||||
|
||||
const documentThickness =
|
||||
Math.max(...mainPolygon.points.map((point) => point[1])) -
|
||||
Math.min(...mainPolygon.points.map((point) => point[1]))
|
||||
expect(documentThickness).toBeCloseTo(0.346)
|
||||
|
||||
const layerPolygons = polygons.slice(1)
|
||||
expect(layerPolygons).toHaveLength(5)
|
||||
expect(
|
||||
layerPolygons.every((entry) => entry.kind === 'polygon' && entry.pointerEvents === 'none'),
|
||||
).toBe(true)
|
||||
expect(
|
||||
layerPolygons.map((entry) => (entry.kind === 'polygon' ? entry.fill : undefined)),
|
||||
).toEqual(['#cbd5e1', '#fde68a', '#f8fafc', '#ffffff', '#fca5a5'])
|
||||
|
||||
const lines = entries.filter((entry) => entry.kind === 'line')
|
||||
expect(lines.some((entry) => entry.kind === 'line' && entry.stroke === '#991b1b')).toBe(true)
|
||||
expect(
|
||||
lines.some(
|
||||
(entry) =>
|
||||
entry.kind === 'line' &&
|
||||
entry.stroke === '#64748b' &&
|
||||
entry.strokeDasharray === '0.035 0.025',
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
lines.some(
|
||||
(entry) =>
|
||||
entry.kind === 'line' &&
|
||||
entry.stroke === '#92400e' &&
|
||||
entry.strokeDasharray === '0.04 0.02',
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
lines.filter(
|
||||
(entry) =>
|
||||
entry.kind === 'line' && entry.stroke === '#111827' && entry.strokeWidth === 0.85,
|
||||
),
|
||||
).toHaveLength(2)
|
||||
expect(witnessY('finished-faces')).toBeCloseTo(0.065)
|
||||
expect(witnessY('centerline')).toBeCloseTo(0.065)
|
||||
expect(witnessY('stud-faces')).toBeCloseTo(0.065)
|
||||
})
|
||||
|
||||
test('shows an orthogonal depth dimension for a curved wall without a radius leader', () => {
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
getWallAssemblyThickness,
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
getWallMidpointHandlePoint,
|
||||
getWallPlanFootprint,
|
||||
getWallThickness,
|
||||
isCurvedWall,
|
||||
type WallAssemblyLayer,
|
||||
type WallMiterData,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -28,13 +29,15 @@ import {
|
||||
const FLOORPLAN_WALL_THICKNESS_SCALE = 1.18
|
||||
const FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS = 0.13
|
||||
const FLOORPLAN_MAX_EXTRA_THICKNESS = 0.035
|
||||
const FLOORPLAN_ASSEMBLY_GRAPHIC_MIN_SPACING = 0.06
|
||||
const FLOORPLAN_SELECTION_HATCH_SPACING = 0.12
|
||||
const FLOORPLAN_SELECTED_WALL_STROKE_WIDTH = 0.03
|
||||
const FLOORPLAN_SELECTION_HATCH_STROKE_WIDTH = 0.02
|
||||
const WALL_DIMENSION_REFERENCES = ['finished-faces', 'centerline', 'stud-faces'] as const
|
||||
|
||||
type WallDimensionReference = (typeof WALL_DIMENSION_REFERENCES)[number]
|
||||
|
||||
function floorplanWallThickness(wall: WallNode): number {
|
||||
const baseThickness = getWallAssemblyThickness(wall)
|
||||
const baseThickness = getWallThickness(wall)
|
||||
const scaledThickness = baseThickness * FLOORPLAN_WALL_THICKNESS_SCALE
|
||||
return Math.min(
|
||||
baseThickness + FLOORPLAN_MAX_EXTRA_THICKNESS,
|
||||
@@ -46,10 +49,6 @@ function exaggerateWallThickness(wall: WallNode): WallNode {
|
||||
return { ...wall, thickness: floorplanWallThickness(wall) }
|
||||
}
|
||||
|
||||
function wallWithModeledAssemblyThickness(wall: WallNode): WallNode {
|
||||
return { ...wall, thickness: getWallAssemblyThickness(wall) }
|
||||
}
|
||||
|
||||
export type WallFloorplanLevelData = {
|
||||
miters: WallMiterData
|
||||
documentMiters: WallMiterData
|
||||
@@ -64,29 +63,40 @@ export function computeWallFloorplanLevelData({
|
||||
nodes: Record<string, AnyNode>
|
||||
}): WallFloorplanLevelData {
|
||||
const walls = siblings.map(exaggerateWallThickness)
|
||||
const constructionDimensionsByReference = {} as Record<
|
||||
WallDimensionReference,
|
||||
WallConstructionDimensionPlan
|
||||
>
|
||||
for (const reference of WALL_DIMENSION_REFERENCES) {
|
||||
let cached: WallConstructionDimensionPlan | undefined
|
||||
Object.defineProperty(constructionDimensionsByReference, reference, {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
if (cached) return cached
|
||||
const datumPolicy =
|
||||
reference === 'finished-faces'
|
||||
? 'wall-face'
|
||||
: reference === 'stud-faces'
|
||||
? 'structural-face'
|
||||
: 'centerline'
|
||||
cached = buildLevelWallConstructionDimensionPlan(
|
||||
siblings,
|
||||
nodes,
|
||||
constructionDimensionStandard({
|
||||
datumPolicy,
|
||||
...(reference === 'finished-faces'
|
||||
? { intersectionReferencePolicy: 'both-faces' as const }
|
||||
: {}),
|
||||
}),
|
||||
)
|
||||
return cached
|
||||
},
|
||||
})
|
||||
}
|
||||
return {
|
||||
miters: calculateLevelMiters(walls),
|
||||
documentMiters: calculateLevelMiters([...siblings]),
|
||||
constructionDimensionsByReference: {
|
||||
'finished-faces': buildLevelWallConstructionDimensionPlan(
|
||||
siblings,
|
||||
nodes,
|
||||
constructionDimensionStandard({
|
||||
datumPolicy: 'wall-face',
|
||||
intersectionReferencePolicy: 'both-faces',
|
||||
}),
|
||||
),
|
||||
centerline: buildLevelWallConstructionDimensionPlan(
|
||||
siblings,
|
||||
nodes,
|
||||
constructionDimensionStandard({ datumPolicy: 'centerline' }),
|
||||
),
|
||||
'stud-faces': buildLevelWallConstructionDimensionPlan(
|
||||
siblings,
|
||||
nodes,
|
||||
constructionDimensionStandard({ datumPolicy: 'structural-face' }),
|
||||
),
|
||||
},
|
||||
constructionDimensionsByReference,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,10 +117,10 @@ export function computeWallFloorplanLevelData({
|
||||
* direct builder callers.
|
||||
*/
|
||||
export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): FloorplanGeometry | null {
|
||||
const { metricNotation, purpose, wallDimensionReference } = readFloorplanContext(ctx)
|
||||
const { automaticDimensions, metricNotation, purpose, wallDimensionReference } =
|
||||
readFloorplanContext(ctx)
|
||||
const documentMode = purpose === 'document'
|
||||
const wallForPurpose = (wall: WallNode) =>
|
||||
documentMode ? wallWithModeledAssemblyThickness(wall) : exaggerateWallThickness(wall)
|
||||
const wallForPurpose = (wall: WallNode) => (documentMode ? wall : exaggerateWallThickness(wall))
|
||||
const self = wallForPurpose(node)
|
||||
// Prefer the level-batch miter graph the floor-plan dispatcher precomputes
|
||||
// once per pass (`computeWallFloorplanLevelData`). Only the fallback path —
|
||||
@@ -155,7 +165,7 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
|
||||
points,
|
||||
fill,
|
||||
stroke,
|
||||
strokeWidth: showSelectedChrome ? 0.03 : 0.02,
|
||||
strokeWidth: showSelectedChrome ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH : 0.02,
|
||||
opacity: 0.92,
|
||||
metadata: floorplanGeometryMetadata({ annotationObstacle: 'outline' }),
|
||||
// Once the wall is selected, the body keeps catching the pointer
|
||||
@@ -167,65 +177,60 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
|
||||
},
|
||||
]
|
||||
|
||||
children.push(...buildWallAssemblyFloorplanGraphics(self))
|
||||
|
||||
const dimensionStroke =
|
||||
isSelected && palette ? palette.selectedStroke : (palette?.measurementStroke ?? '#334155')
|
||||
const dimensionStandard = constructionDimensionStandard({
|
||||
datumPolicy: wallDimensionDatumPolicy(wallDimensionReference),
|
||||
metricNotation,
|
||||
})
|
||||
const exteriorCornerDimensionStandard = constructionDimensionStandard({
|
||||
datumPolicy: 'structural-face',
|
||||
metricNotation,
|
||||
})
|
||||
if (isCurvedWall(node)) {
|
||||
children.push(
|
||||
...buildCurvedWallConstructionDimensions(self, {
|
||||
unit: view?.unit ?? 'metric',
|
||||
stroke: dimensionStroke,
|
||||
profile: documentMode ? 'document' : 'editor',
|
||||
standard: exteriorCornerDimensionStandard,
|
||||
siblings: ctx.siblings.filter(
|
||||
(sibling): sibling is AnyNode & WallNode => sibling.type === 'wall',
|
||||
),
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
const planned = levelData?.constructionDimensionsByReference[wallDimensionReference].get(
|
||||
node.id,
|
||||
)
|
||||
if (planned) {
|
||||
if (automaticDimensions) {
|
||||
const dimensionStroke =
|
||||
isSelected && palette ? palette.selectedStroke : (palette?.measurementStroke ?? '#334155')
|
||||
const dimensionStandard = constructionDimensionStandard({
|
||||
datumPolicy: wallDimensionDatumPolicy(wallDimensionReference),
|
||||
metricNotation,
|
||||
})
|
||||
const exteriorCornerDimensionStandard = constructionDimensionStandard({
|
||||
datumPolicy: 'structural-face',
|
||||
metricNotation,
|
||||
})
|
||||
if (isCurvedWall(node)) {
|
||||
children.push(
|
||||
...renderPlannedConstructionDimensions(
|
||||
planned,
|
||||
view?.unit ?? 'metric',
|
||||
dimensionStroke,
|
||||
documentMode ? 'document' : 'editor',
|
||||
dimensionStandard,
|
||||
),
|
||||
)
|
||||
} else if (!levelData) {
|
||||
children.push(
|
||||
...buildWallConstructionDimensions(self, ctx, {
|
||||
...buildCurvedWallConstructionDimensions(self, {
|
||||
unit: view?.unit ?? 'metric',
|
||||
stroke: dimensionStroke,
|
||||
profile: documentMode ? 'document' : 'editor',
|
||||
standard: exteriorCornerDimensionStandard,
|
||||
siblings: ctx.siblings.filter(
|
||||
(sibling): sibling is AnyNode & WallNode => sibling.type === 'wall',
|
||||
),
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
const planned = levelData?.constructionDimensionsByReference[wallDimensionReference].get(
|
||||
node.id,
|
||||
)
|
||||
if (planned) {
|
||||
children.push(
|
||||
...renderPlannedConstructionDimensions(
|
||||
planned,
|
||||
view?.unit ?? 'metric',
|
||||
dimensionStroke,
|
||||
documentMode ? 'document' : 'editor',
|
||||
dimensionStandard,
|
||||
),
|
||||
)
|
||||
} else if (!levelData) {
|
||||
children.push(
|
||||
...buildWallConstructionDimensions(self, ctx, {
|
||||
unit: view?.unit ?? 'metric',
|
||||
stroke: dimensionStroke,
|
||||
profile: documentMode ? 'document' : 'editor',
|
||||
standard: exteriorCornerDimensionStandard,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Selection hatch overlay — only when the wall is *the* selected item
|
||||
// (not when it's just marquee-highlighted), matching the legacy.
|
||||
if (isSelected && palette) {
|
||||
children.push({
|
||||
kind: 'hatch',
|
||||
points,
|
||||
color: palette.selectedHatch,
|
||||
opacity: 1,
|
||||
})
|
||||
children.push(...buildSelectedWallHatchLines(self, palette.selectedHatch))
|
||||
}
|
||||
|
||||
// Hit-line on the centerline. Stroke width is in screen pixels so it
|
||||
@@ -309,6 +314,35 @@ export function buildWallFloorplan(node: WallNode, ctx: GeometryContext): Floorp
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
function buildSelectedWallHatchLines(wall: WallNode, stroke: string): FloorplanGeometry[] {
|
||||
const length = getWallCurveLength(wall)
|
||||
if (length <= 1e-6) return []
|
||||
|
||||
const halfAcross = getWallThickness(wall) / 2
|
||||
const halfAlong = halfAcross
|
||||
const count = Math.max(1, Math.floor(length / FLOORPLAN_SELECTION_HATCH_SPACING))
|
||||
const spacing = length / count
|
||||
const lines: FloorplanGeometry[] = []
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const along = (index + 0.5) * spacing
|
||||
const frame = getWallCurveFrameAt(wall, along / length)
|
||||
lines.push({
|
||||
kind: 'line',
|
||||
x1: frame.point.x - frame.tangent.x * halfAlong - frame.normal.x * halfAcross,
|
||||
y1: frame.point.y - frame.tangent.y * halfAlong - frame.normal.y * halfAcross,
|
||||
x2: frame.point.x + frame.tangent.x * halfAlong + frame.normal.x * halfAcross,
|
||||
y2: frame.point.y + frame.tangent.y * halfAlong + frame.normal.y * halfAcross,
|
||||
stroke,
|
||||
strokeWidth: FLOORPLAN_SELECTION_HATCH_STROKE_WIDTH,
|
||||
pointerEvents: 'none',
|
||||
metadata: floorplanGeometryMetadata({ renderPass: 'overlay' }),
|
||||
})
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function wallDimensionDatumPolicy(reference: WallDimensionReference) {
|
||||
switch (reference) {
|
||||
case 'centerline':
|
||||
@@ -320,424 +354,6 @@ function wallDimensionDatumPolicy(reference: WallDimensionReference) {
|
||||
}
|
||||
}
|
||||
|
||||
type WallAssemblyLayerSpan = {
|
||||
layer: WallAssemblyLayer
|
||||
interiorOffset: number
|
||||
exteriorOffset: number
|
||||
}
|
||||
|
||||
function buildWallAssemblyFloorplanGraphics(wall: WallNode): FloorplanGeometry[] {
|
||||
if (isCurvedWall(wall)) return []
|
||||
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
if (layers.length === 0) return []
|
||||
|
||||
const spans = getWallAssemblyLayerSpans(wall)
|
||||
if (spans.length === 0) return []
|
||||
|
||||
const dx = wall.end[0] - wall.start[0]
|
||||
const dy = wall.end[1] - wall.start[1]
|
||||
const length = Math.hypot(dx, dy)
|
||||
if (length <= 1e-6) return []
|
||||
|
||||
const tx = dx / length
|
||||
const ty = dy / length
|
||||
const nx = -ty
|
||||
const ny = tx
|
||||
const startX = wall.start[0]
|
||||
const startY = wall.start[1]
|
||||
const endX = wall.end[0]
|
||||
const endY = wall.end[1]
|
||||
|
||||
const graphics: FloorplanGeometry[] = []
|
||||
for (const span of spans) {
|
||||
const style = wallAssemblyLayerGraphicStyle(span.layer)
|
||||
const points = wallLayerPolygon(startX, startY, endX, endY, nx, ny, span)
|
||||
graphics.push({
|
||||
kind: 'polygon',
|
||||
points,
|
||||
fill: style.fill,
|
||||
stroke: style.stroke,
|
||||
strokeWidth: style.strokeWidth,
|
||||
fillOpacity: style.fillOpacity,
|
||||
opacity: style.opacity,
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
graphics.push(
|
||||
...buildWallAssemblyLayerHatchLines({
|
||||
span,
|
||||
style,
|
||||
startX,
|
||||
startY,
|
||||
endX,
|
||||
endY,
|
||||
tx,
|
||||
ty,
|
||||
nx,
|
||||
ny,
|
||||
length,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
graphics.push(...buildWallAssemblyFaceLines(startX, startY, endX, endY, nx, ny, spans))
|
||||
return graphics
|
||||
}
|
||||
|
||||
function getWallAssemblyLayerSpans(wall: WallNode): WallAssemblyLayerSpan[] {
|
||||
const layers = wall.assemblyLayers ?? []
|
||||
if (layers.length === 0) return []
|
||||
|
||||
const coreLayers = layers.filter((layer) => layer.side === 'core')
|
||||
const coreThickness =
|
||||
coreLayers.length > 0
|
||||
? coreLayers.reduce((sum, layer) => sum + layer.thickness, 0)
|
||||
: (wall.thickness ?? 0.1)
|
||||
const coreInteriorFace = -coreThickness / 2
|
||||
const coreExteriorFace = coreThickness / 2
|
||||
const spans: WallAssemblyLayerSpan[] = []
|
||||
|
||||
let coreOffset = coreInteriorFace
|
||||
for (const layer of coreLayers) {
|
||||
const interiorOffset = coreOffset
|
||||
const exteriorOffset = coreOffset + layer.thickness
|
||||
spans.push({ layer, interiorOffset, exteriorOffset })
|
||||
coreOffset = exteriorOffset
|
||||
}
|
||||
|
||||
let interiorOffset = coreInteriorFace
|
||||
for (const layer of layers.filter((candidate) => candidate.side === 'interior')) {
|
||||
const exteriorOffset = interiorOffset
|
||||
const nextInteriorOffset = exteriorOffset - layer.thickness
|
||||
spans.push({ layer, interiorOffset: nextInteriorOffset, exteriorOffset })
|
||||
interiorOffset = nextInteriorOffset
|
||||
}
|
||||
|
||||
let exteriorOffset = coreExteriorFace
|
||||
for (const layer of layers.filter((candidate) => candidate.side === 'exterior')) {
|
||||
const interiorFaceOffset = exteriorOffset
|
||||
const nextExteriorOffset = interiorFaceOffset + layer.thickness
|
||||
spans.push({ layer, interiorOffset: interiorFaceOffset, exteriorOffset: nextExteriorOffset })
|
||||
exteriorOffset = nextExteriorOffset
|
||||
}
|
||||
|
||||
return spans
|
||||
}
|
||||
|
||||
type WallAssemblyLayerGraphicStyle = {
|
||||
fill: string
|
||||
stroke: string
|
||||
strokeWidth: number
|
||||
fillOpacity: number
|
||||
opacity?: number
|
||||
hatch?: 'diagonal' | 'cross' | 'brick' | 'air' | 'furring'
|
||||
hatchStroke: string
|
||||
hatchDasharray?: string
|
||||
}
|
||||
|
||||
function wallAssemblyLayerGraphicStyle(layer: WallAssemblyLayer): WallAssemblyLayerGraphicStyle {
|
||||
switch (layer.role) {
|
||||
case 'structure':
|
||||
return {
|
||||
fill: '#475569',
|
||||
stroke: '#111827',
|
||||
strokeWidth: 0.006,
|
||||
fillOpacity: 0.34,
|
||||
hatch: 'diagonal',
|
||||
hatchStroke: '#0f172a',
|
||||
}
|
||||
case 'concrete-block':
|
||||
case 'structural-masonry':
|
||||
return {
|
||||
fill: '#cbd5e1',
|
||||
stroke: '#334155',
|
||||
strokeWidth: 0.006,
|
||||
fillOpacity: 0.82,
|
||||
hatch: 'cross',
|
||||
hatchStroke: '#475569',
|
||||
}
|
||||
case 'solid-concrete':
|
||||
return {
|
||||
fill: '#94a3b8',
|
||||
stroke: '#334155',
|
||||
strokeWidth: 0.006,
|
||||
fillOpacity: 0.78,
|
||||
hatch: 'diagonal',
|
||||
hatchStroke: '#64748b',
|
||||
}
|
||||
case 'masonry-veneer':
|
||||
return {
|
||||
fill: '#fca5a5',
|
||||
stroke: '#7f1d1d',
|
||||
strokeWidth: 0.004,
|
||||
fillOpacity: 0.45,
|
||||
hatch: 'brick',
|
||||
hatchStroke: '#991b1b',
|
||||
}
|
||||
case 'air-space':
|
||||
return {
|
||||
fill: '#ffffff',
|
||||
stroke: '#94a3b8',
|
||||
strokeWidth: 0.004,
|
||||
fillOpacity: 0.15,
|
||||
hatch: 'air',
|
||||
hatchStroke: '#64748b',
|
||||
hatchDasharray: '0.035 0.025',
|
||||
}
|
||||
case 'furring':
|
||||
return {
|
||||
fill: '#fde68a',
|
||||
stroke: '#92400e',
|
||||
strokeWidth: 0.004,
|
||||
fillOpacity: 0.42,
|
||||
hatch: 'furring',
|
||||
hatchStroke: '#92400e',
|
||||
hatchDasharray: '0.04 0.02',
|
||||
}
|
||||
case 'interior-finish':
|
||||
case 'exterior-finish':
|
||||
case 'exterior-sheathing':
|
||||
return {
|
||||
fill: '#f8fafc',
|
||||
stroke: '#94a3b8',
|
||||
strokeWidth: 0.003,
|
||||
fillOpacity: 0.72,
|
||||
hatch: layer.role === 'exterior-sheathing' ? 'diagonal' : undefined,
|
||||
hatchStroke: '#94a3b8',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wallLayerPolygon(
|
||||
startX: number,
|
||||
startY: number,
|
||||
endX: number,
|
||||
endY: number,
|
||||
nx: number,
|
||||
ny: number,
|
||||
span: WallAssemblyLayerSpan,
|
||||
): FloorplanPoint[] {
|
||||
return [
|
||||
[startX + nx * span.interiorOffset, startY + ny * span.interiorOffset],
|
||||
[endX + nx * span.interiorOffset, endY + ny * span.interiorOffset],
|
||||
[endX + nx * span.exteriorOffset, endY + ny * span.exteriorOffset],
|
||||
[startX + nx * span.exteriorOffset, startY + ny * span.exteriorOffset],
|
||||
]
|
||||
}
|
||||
|
||||
function buildWallAssemblyLayerHatchLines({
|
||||
span,
|
||||
style,
|
||||
startX,
|
||||
startY,
|
||||
tx,
|
||||
ty,
|
||||
nx,
|
||||
ny,
|
||||
length,
|
||||
}: {
|
||||
span: WallAssemblyLayerSpan
|
||||
style: WallAssemblyLayerGraphicStyle
|
||||
startX: number
|
||||
startY: number
|
||||
endX: number
|
||||
endY: number
|
||||
tx: number
|
||||
ty: number
|
||||
nx: number
|
||||
ny: number
|
||||
length: number
|
||||
}): FloorplanGeometry[] {
|
||||
if (!style.hatch) return []
|
||||
|
||||
const layerWidth = span.exteriorOffset - span.interiorOffset
|
||||
if (layerWidth <= 1e-6) return []
|
||||
|
||||
const interval = Math.max(FLOORPLAN_ASSEMBLY_GRAPHIC_MIN_SPACING, layerWidth * 1.8)
|
||||
const insetAlong = Math.min(0.035, length * 0.08)
|
||||
const lines: FloorplanGeometry[] = []
|
||||
|
||||
if (style.hatch === 'air') {
|
||||
const midOffset = (span.interiorOffset + span.exteriorOffset) / 2
|
||||
lines.push(
|
||||
wallAssemblyLine(
|
||||
startX + tx * insetAlong,
|
||||
startY + ty * insetAlong,
|
||||
startX + tx * (length - insetAlong),
|
||||
startY + ty * (length - insetAlong),
|
||||
nx,
|
||||
ny,
|
||||
midOffset,
|
||||
style.hatchStroke,
|
||||
style.hatchDasharray,
|
||||
),
|
||||
)
|
||||
return lines
|
||||
}
|
||||
|
||||
if (style.hatch === 'brick') {
|
||||
for (let along = interval; along < length; along += interval) {
|
||||
lines.push(
|
||||
wallCrossLine(startX, startY, tx, ty, nx, ny, along, span, style.hatchStroke, undefined),
|
||||
)
|
||||
}
|
||||
const thirds = [
|
||||
span.interiorOffset + layerWidth / 3,
|
||||
span.interiorOffset + (layerWidth * 2) / 3,
|
||||
]
|
||||
for (const offset of thirds) {
|
||||
lines.push(
|
||||
wallAssemblyLine(
|
||||
startX + tx * insetAlong,
|
||||
startY + ty * insetAlong,
|
||||
startX + tx * (length - insetAlong),
|
||||
startY + ty * (length - insetAlong),
|
||||
nx,
|
||||
ny,
|
||||
offset,
|
||||
style.hatchStroke,
|
||||
style.hatchDasharray,
|
||||
),
|
||||
)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
if (style.hatch === 'furring') {
|
||||
for (let along = interval; along < length; along += interval) {
|
||||
lines.push(
|
||||
wallCrossLine(
|
||||
startX,
|
||||
startY,
|
||||
tx,
|
||||
ty,
|
||||
nx,
|
||||
ny,
|
||||
along,
|
||||
span,
|
||||
style.hatchStroke,
|
||||
style.hatchDasharray,
|
||||
),
|
||||
)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
const emitDiagonal = (flip: boolean) => {
|
||||
for (let along = interval / 2; along < length; along += interval) {
|
||||
const centerOffset = (span.interiorOffset + span.exteriorOffset) / 2
|
||||
const halfAlong = Math.min(interval * 0.35, length * 0.08)
|
||||
const halfAcross = layerWidth * 0.42
|
||||
const sign = flip ? -1 : 1
|
||||
lines.push({
|
||||
kind: 'line',
|
||||
x1: startX + tx * Math.max(0, along - halfAlong) + nx * (centerOffset - sign * halfAcross),
|
||||
y1: startY + ty * Math.max(0, along - halfAlong) + ny * (centerOffset - sign * halfAcross),
|
||||
x2:
|
||||
startX +
|
||||
tx * Math.min(length, along + halfAlong) +
|
||||
nx * (centerOffset + sign * halfAcross),
|
||||
y2:
|
||||
startY +
|
||||
ty * Math.min(length, along + halfAlong) +
|
||||
ny * (centerOffset + sign * halfAcross),
|
||||
stroke: style.hatchStroke,
|
||||
strokeWidth: 0.55,
|
||||
strokeDasharray: style.hatchDasharray,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emitDiagonal(false)
|
||||
if (style.hatch === 'cross') emitDiagonal(true)
|
||||
return lines
|
||||
}
|
||||
|
||||
function wallAssemblyLine(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
nx: number,
|
||||
ny: number,
|
||||
offset: number,
|
||||
stroke: string,
|
||||
strokeDasharray: string | undefined,
|
||||
): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'line',
|
||||
x1: x1 + nx * offset,
|
||||
y1: y1 + ny * offset,
|
||||
x2: x2 + nx * offset,
|
||||
y2: y2 + ny * offset,
|
||||
stroke,
|
||||
strokeWidth: 0.5,
|
||||
strokeDasharray,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
function wallCrossLine(
|
||||
startX: number,
|
||||
startY: number,
|
||||
tx: number,
|
||||
ty: number,
|
||||
nx: number,
|
||||
ny: number,
|
||||
along: number,
|
||||
span: WallAssemblyLayerSpan,
|
||||
stroke: string,
|
||||
strokeDasharray: string | undefined,
|
||||
): FloorplanGeometry {
|
||||
return {
|
||||
kind: 'line',
|
||||
x1: startX + tx * along + nx * span.interiorOffset,
|
||||
y1: startY + ty * along + ny * span.interiorOffset,
|
||||
x2: startX + tx * along + nx * span.exteriorOffset,
|
||||
y2: startY + ty * along + ny * span.exteriorOffset,
|
||||
stroke,
|
||||
strokeWidth: 0.5,
|
||||
strokeDasharray,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
}
|
||||
|
||||
function buildWallAssemblyFaceLines(
|
||||
startX: number,
|
||||
startY: number,
|
||||
endX: number,
|
||||
endY: number,
|
||||
nx: number,
|
||||
ny: number,
|
||||
spans: WallAssemblyLayerSpan[],
|
||||
): FloorplanGeometry[] {
|
||||
const offsets = new Set<number>()
|
||||
for (const span of spans) {
|
||||
offsets.add(span.interiorOffset)
|
||||
offsets.add(span.exteriorOffset)
|
||||
}
|
||||
|
||||
const sortedOffsets = [...offsets].sort((a, b) => a - b)
|
||||
const minOffset = sortedOffsets[0]
|
||||
const maxOffset = sortedOffsets.at(-1)
|
||||
|
||||
return sortedOffsets.map((offset) => ({
|
||||
kind: 'line',
|
||||
x1: startX + nx * offset,
|
||||
y1: startY + ny * offset,
|
||||
x2: endX + nx * offset,
|
||||
y2: endY + ny * offset,
|
||||
stroke: offset === minOffset || offset === maxOffset ? '#111827' : '#64748b',
|
||||
strokeWidth: offset === minOffset || offset === maxOffset ? 0.85 : 0.45,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
pointerEvents: 'none',
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Doors, windows, and wall-attached items would tear if the wall bent
|
||||
* around them, so the curve sagitta handle hides when any of those
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user