From a3761c4594d14bd0b58f68034277229aaa30c8b7 Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Thu, 19 Mar 2026 11:08:30 +0100 Subject: [PATCH 01/21] Updated model types --- src/building/model/ids.ts | 9 ++++-- src/building/model/rooms.ts | 61 +++++++++++++++++++++++-------------- 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/src/building/model/ids.ts b/src/building/model/ids.ts index 77ef26b50..8df9e51d1 100644 --- a/src/building/model/ids.ts +++ b/src/building/model/ids.ts @@ -8,6 +8,7 @@ const OPENING_ID_PREFIX = 'opening_' const POST_ID_PREFIX = 'post_' const RING_BEAM_ID_PREFIX = 'ringbeam_' const WALL_ASSEMBLY_ID_PREFIX = 'wa_' +const INTERIOR_WALL_ASSEMBLY_ID_PREFIX = 'iwa_' const FLOOR_ASSEMBLY_ID_PREFIX = 'fa_' const ROOF_ASSEMBLY_ID_PREFIX = 'ra_' const OPENING_ASSEMBLY_ID_PREFIX = 'oa_' @@ -56,6 +57,7 @@ export type LayerSetId = `${typeof LAYER_SET_ID_PREFIX}${string}` export type AssemblyId = | RingBeamAssemblyId | WallAssemblyId + | InteriorWallAssemblyId | FloorAssemblyId | RoofAssemblyId | OpeningAssemblyId @@ -82,7 +84,7 @@ export type WallAssemblyId = `${typeof WALL_ASSEMBLY_ID_PREFIX}${string}` export type FloorAssemblyId = `${typeof FLOOR_ASSEMBLY_ID_PREFIX}${string}` export type RoofAssemblyId = `${typeof ROOF_ASSEMBLY_ID_PREFIX}${string}` export type OpeningAssemblyId = `${typeof OPENING_ASSEMBLY_ID_PREFIX}${string}` - +export type InteriorWallAssemblyId = `${typeof INTERIOR_WALL_ASSEMBLY_ID_PREFIX}${string}` // ID generation helpers export const createStoreyId = (): StoreyId => createId(STOREY_ID_PREFIX) export const createPerimeterId = (): PerimeterId => createId(PERIMETER_ID_PREFIX) @@ -108,6 +110,7 @@ export const createFloorAssemblyId = (): FloorAssemblyId => createId(FLOOR_ASSEM export const createRoofAssemblyId = (): RoofAssemblyId => createId(ROOF_ASSEMBLY_ID_PREFIX) export const createOpeningAssemblyId = (): OpeningAssemblyId => createId(OPENING_ASSEMBLY_ID_PREFIX) export const createLayerSetId = (): LayerSetId => createId(LAYER_SET_ID_PREFIX) +export const createInteriorWallAssemblyId = (): InteriorWallAssemblyId => createId(INTERIOR_WALL_ASSEMBLY_ID_PREFIX) // Default floor construction config ID constant export const DEFAULT_FLOOR_ASSEMBLY_ID = 'fa_clt_default' as FloorAssemblyId @@ -126,7 +129,7 @@ export const isOpeningId = (id: string): id is OpeningId => id.startsWith(OPENIN export const isWallPostId = (id: string): id is WallPostId => id.startsWith(POST_ID_PREFIX) export const isRoofOverhangId = (id: string): id is RoofOverhangId => id.startsWith(ROOF_OVERHANG_ID_PREFIX) export const isConstraintId = (id: string): id is ConstraintId => id.startsWith(CONSTRAINT_ID_PREFIX) -export const isRoomId = (id: string): id is RoomId => id.startsWith(ROOF_ID_PREFIX) +export const isRoomId = (id: string): id is RoomId => id.startsWith(ROOM_ID_PREFIX) export const isWallNodeId = (id: string): id is WallNodeId => id.startsWith(WALL_NODE_ID_PREFIX) export const isIntermediateWallId = (id: string): id is IntermediateWallId => id.startsWith(INTERMEDIATE_WALL_ID_PREFIX) @@ -137,6 +140,8 @@ export const isFloorAssemblyId = (id: string): id is FloorAssemblyId => id.start export const isRoofAssemblyId = (id: string): id is RoofAssemblyId => id.startsWith(ROOF_ASSEMBLY_ID_PREFIX) export const isOpeningAssemblyId = (id: string): id is OpeningAssemblyId => id.startsWith(OPENING_ASSEMBLY_ID_PREFIX) export const isLayerSetId = (id: string): id is LayerSetId => id.startsWith(LAYER_SET_ID_PREFIX) +export const isInteriorWallAssemblyId = (id: string): id is InteriorWallAssemblyId => + id.startsWith(INTERIOR_WALL_ASSEMBLY_ID_PREFIX) // Entity type definitions for hit testing export type EntityType = diff --git a/src/building/model/rooms.ts b/src/building/model/rooms.ts index 6e0b328b4..e1ea66ef8 100644 --- a/src/building/model/rooms.ts +++ b/src/building/model/rooms.ts @@ -1,6 +1,14 @@ import { type Area, type Length, type LineSegment2D, type Polygon2D, type Vec2 } from '@/shared/geometry' -import type { IntermediateWallId, OpeningId, PerimeterId, PerimeterWallId, RoomId, WallId, WallNodeId } from './ids' +import type { + InteriorWallAssemblyId, + IntermediateWallId, + OpeningId, + PerimeterId, + PerimeterWallId, + RoomId, + WallNodeId +} from './ids' export type RoomType = | 'living-room' @@ -16,6 +24,8 @@ export type RoomType = | 'service' | 'generic' +export type WallAxis = 'left' | 'center' | 'right' + export interface BaseWallNode { id: WallNodeId perimeterId: PerimeterId @@ -26,66 +36,71 @@ export interface PerimeterWallNode extends BaseWallNode { type: 'perimeter' wallId: PerimeterWallId offsetFromCornerStart: Length - - // Computed - position: Vec2 } export interface InnerWallNode extends BaseWallNode { type: 'inner' position: Vec2 - constructedBy: IntermediateWallId +} + +export interface WallNodeGeometry { + position: Vec2 +} - // Computed +export interface InnerWallNodeGeometry extends WallNodeGeometry { + connectedWallIds: IntermediateWallId[] boundary: Polygon2D } +export type PerimeterWallNodeWithGeometry = PerimeterWallNode & WallNodeGeometry +export type InnerWallNodeWithGeometry = InnerWallNode & InnerWallNodeGeometry + export type WallNode = PerimeterWallNode | InnerWallNode +export type WallNodeWithGeometry = PerimeterWallNodeWithGeometry | InnerWallNodeWithGeometry export interface Room { id: RoomId perimeterId: PerimeterId - wallIds: WallId[] // Detected automatically - + wallIds: IntermediateWallId[] // Detected automatically type: RoomType counter: number // Counts up the rooms per storey and room type (i.e. bedroom 1, bedroom 2, ...) customLabel?: string +} - // Computed geometry +export interface RoomGeometry { boundary: Polygon2D area: Area } +export type RoomWithGeometry = Room & RoomGeometry + export interface WallAttachment { nodeId: WallNodeId - axis: 'left' | 'center' | 'right' + axis: WallAxis } export interface IntermediateWall { id: IntermediateWallId perimeterId: PerimeterId - openingIds: OpeningId[] - leftRoomId: RoomId - rightRoomId: RoomId - + openingIds: OpeningId[] // TODO + leftRoomId?: RoomId // TODO + rightRoomId?: RoomId // TODO start: WallAttachment end: WallAttachment - thickness: Length + wallAssemblyId?: InteriorWallAssemblyId // TODO +} - // Computed geometry: +export interface IntermediateWallGeometry { boundary: Polygon2D - centerLine: LineSegment2D wallLength: Length - leftLength: Length leftLine: LineSegment2D rightLength: Length rightLine: LineSegment2D - - direction: Vec2 // Normalized from start -> end of wall - leftDirection: Vec2 // Normalized vector pointing left - - // TODO: wallAssemblyId: WallAssemblyId + direction: Vec2 + leftDirection: Vec2 } + +export type IntermediateWallWithGeometry = IntermediateWall & IntermediateWallGeometry From eca835a4ee2cd676b3840ca75a0528283a04789f Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Fri, 20 Mar 2026 17:13:13 +0100 Subject: [PATCH 02/21] Initial implementation of the store --- docs/intermediate-walls-architecture.md | 289 ++++++++++++ src/building/model/ids.ts | 1 + src/building/model/rooms.ts | 17 +- .../store/slices/intermediateWallGeometry.ts | 436 ++++++++++++++++++ .../store/slices/intermediateWallsSlice.ts | 416 +++++++++++++++++ 5 files changed, 1153 insertions(+), 6 deletions(-) create mode 100644 docs/intermediate-walls-architecture.md create mode 100644 src/building/store/slices/intermediateWallGeometry.ts create mode 100644 src/building/store/slices/intermediateWallsSlice.ts diff --git a/docs/intermediate-walls-architecture.md b/docs/intermediate-walls-architecture.md new file mode 100644 index 000000000..fa5fe5660 --- /dev/null +++ b/docs/intermediate-walls-architecture.md @@ -0,0 +1,289 @@ +# Intermediate Walls & Rooms Architecture + +## Goal + +Implement interior walls and rooms in the floor plan editor, enabling: + +- Drawing interior walls that connect to perimeter walls or other interior walls +- Split-on-connect model: when a wall connects to another wall's midpoint, split the target wall +- Perpendicular snapping and constraints +- Room detection from closed wall loops + +## Data Model + +### Wall Nodes + +Wall nodes are connection points for walls. Two types exist: + +```typescript +// Node on a perimeter wall - position computed from wall geometry +interface PerimeterWallNode { + id: WallNodeId + perimeterId: PerimeterId + type: 'perimeter' + wallId: PerimeterWallId + offsetFromCornerStart: Length // Distance along the wall +} + +// Free-standing node - position stored directly +interface InnerWallNode { + id: WallNodeId + perimeterId: PerimeterId + type: 'inner' + position: Vec2 +} +``` + +### Wall Attachments + +Walls connect to nodes via attachments, which include axis alignment: + +```typescript +type WallAxis = 'left' | 'center' | 'right' + +interface WallAttachment { + nodeId: WallNodeId + axis: WallAxis // Which axis of the wall aligns with the node +} +``` + +### Intermediate Walls + +Interior walls connect two wall nodes: + +```typescript +interface IntermediateWall { + id: IntermediateWallId + perimeterId: PerimeterId + openingIds: OpeningId[] + leftRoomId?: RoomId + rightRoomId?: RoomId + start: WallAttachment + end: WallAttachment + thickness: Length + wallAssemblyId?: InteriorWallAssemblyId +} +``` + +### Geometry Types + +Computed from model + other geometry: + +```typescript +interface WallNodeGeometry { + position: Vec2 +} + +interface InnerWallNodeGeometry extends WallNodeGeometry { + connectedWallIds: IntermediateWallId[] + boundary: Polygon2D +} + +interface IntermediateWallGeometry { + boundary: Polygon2D + centerLine: LineSegment2D + wallLength: Length + leftLength: Length + leftLine: LineSegment2D + rightLength: Length + rightLine: LineSegment2D + direction: Vec2 + leftDirection: Vec2 +} +``` + +## Store Architecture + +### State Interface + +```typescript +interface IntermediateWallsState { + intermediateWalls: Record + _intermediateWallGeometry: Record + + wallNodes: Record + _wallNodeGeometry: Record +} +``` + +### Actions + +**Wall CRUD:** + +- `addIntermediateWall(perimeterId, start, end, thickness)` - Create wall between two attachments +- `removeIntermediateWall(wallId)` - Remove wall, cleanup orphaned nodes +- `updateIntermediateWallThickness(wallId, thickness)` +- `updateIntermediateWallAlignment(wallId, start, end)` + +**Node CRUD:** + +- `addPerimeterWallNode(perimeterId, wallId, offset)` - Add node on perimeter wall +- `addInnerWallNode(perimeterId, position)` - Add free-standing node +- `removeWallNode(nodeId)` - Remove node and connected walls +- `updateInnerWallNodePosition(nodeId, position)` +- `updatePerimeterWallNodeOffset(nodeId, offset)` + +### Getters + +Combine base model with geometry: + +- `getIntermediateWallById(id)` → `IntermediateWallWithGeometry` +- `getIntermediateWallsByPerimeter(perimeterId)` → `IntermediateWallWithGeometry[]` +- `getAllIntermediateWalls()` → `IntermediateWallWithGeometry[]` +- `getWallNodeById(id)` → `WallNodeWithGeometry` +- `getWallNodesByPerimeter(perimeterId)` → `WallNodeWithGeometry[]` +- `getAllWallNodes()` → `WallNodeWithGeometry[]` + +## Geometry Computation + +### File: `intermediateWallGeometry.ts` + +Follows the pattern from `perimeterGeometry.ts`: + +1. Pure functions that take state + IDs +2. Called directly after any model change +3. No circular imports - state interface defined in slice, geometry file imports it + +### Key Functions + +```typescript +// Update single wall geometry +function updateIntermediateWallGeometry( + state: IntermediateWallsState & PerimeterGeometryAccess, + wallId: IntermediateWallId +): void + +// Update all walls (e.g., after perimeter change) +function updateAllIntermediateWallGeometry(state: IntermediateWallsState & PerimeterGeometryAccess): void +``` + +### Node Position Calculation + +```typescript +function getNodePosition(state: IntermediateWallsState & PerimeterGeometryAccess, nodeId: WallNodeId): Vec2 { + const node = state.wallNodes[nodeId] + if (node.type === 'inner') { + return node.position + } + // Perimeter node: compute from wall geometry + const wallGeometry = state._perimeterWallGeometry[node.wallId] + return pointOnLineSegment(wallGeometry.innerLine, node.offsetFromCornerStart) +} +``` + +### Wall Geometry Calculation + +1. Get start/end node positions via `getNodePosition()` +2. Compute center line from start to end +3. Offset by thickness/2 to get left/right lines +4. Build boundary polygon from left/right lines + +## Split-on-Connect Model + +When a wall endpoint attaches to the middle of another wall: + +1. **Detect intersection** - The target wall's center line intersects near the new wall's endpoint +2. **Create node** - Add a wall node at the intersection point +3. **Split target wall** - Replace one wall with two: + - Original start → New node + - New node → Original end +4. **Update references** - Any room references point to both new walls + +This ensures every wall endpoint is a node, simplifying topology. + +## Perpendicular Constraints + +### Snapping + +During wall drawing, snap to perpendicular lines: + +- From current point, project to perpendicular of nearby walls +- Visual feedback shows snap candidate + +### Constraint Storage + +```typescript +interface PerpendicularConstraint { + id: ConstraintId + wallId: IntermediateWallId + perpendicularToWallId: IntermediateWallId + atNodeId: WallNodeId // The shared node +} +``` + +Constraints are stored separately and enforced during geometry updates. + +## Implementation Phases + +### Phase 1: Store & Geometry (Current) + +- [ ] Implement `intermediateWallGeometry.ts` +- [ ] Implement `intermediateWallsSlice.ts` actions +- [ ] Integrate into main store +- [ ] Unit tests for geometry computation + +### Phase 2: Drawing Tools + +- [ ] Create `intermediateWallTool` for drawing +- [ ] Implement perpendicular snapping +- [ ] Implement wall-node creation on click +- [ ] Connect to perimeter walls + +### Phase 3: Split-on-Connect + +- [ ] Detect wall intersections during placement +- [ ] Implement wall splitting logic +- [ ] Update geometry after splits +- [ ] Handle edge cases (T-junctions, crosses) + +### Phase 4: Rendering + +- [ ] Render intermediate walls on canvas +- [ ] Render wall nodes (connection points) +- [ ] Selection and hover states +- [ ] Measurements display + +### Phase 5: Constraints + +- [ ] Store constraint model +- [ ] Enforce perpendicular constraints +- [ ] Visual constraint indicators +- [ ] Constraint deletion + +### Phase 6: Room Detection + +- [ ] Implement room boundary detection +- [ ] Auto-assign walls to rooms +- [ ] Room labeling UI +- [ ] Area calculations + +## File Structure + +``` +src/building/ +├── model/ +│ ├── ids.ts # WallNodeId, IntermediateWallId +│ ├── rooms.ts # Type definitions +│ └── index.ts # Exports +├── store/ +│ ├── slices/ +│ │ ├── intermediateWallsSlice.ts # State + actions +│ │ └── intermediateWallGeometry.ts # Geometry computation +│ └── store.ts # Integration +└── ... + +src/editor/ +└── tools/ + └── intermediateWall/ # Phase 2 + ├── tool.ts + ├── snapping.ts + └── inspector.tsx +``` + +## Key Patterns to Follow + +1. **Vec2 creation**: Always use `newVec2(x, y)`, never object literals +2. **Geometry updates**: Call `updateIntermediateWallGeometry(state, id)` after every mutation +3. **Getters**: Combine base model + geometry in getter functions +4. **Timestamps**: Update via `touch(state, entityId)` for undo/redo +5. **State access**: Geometry functions need perimeter geometry access for perimeter nodes diff --git a/src/building/model/ids.ts b/src/building/model/ids.ts index 8df9e51d1..dfeae916c 100644 --- a/src/building/model/ids.ts +++ b/src/building/model/ids.ts @@ -45,6 +45,7 @@ export type EntityId = | PerimeterWallId | PerimeterCornerId | IntermediateWallId + | WallNodeId | OpeningId | WallPostId | FloorAreaId diff --git a/src/building/model/rooms.ts b/src/building/model/rooms.ts index e1ea66ef8..fb8f70399 100644 --- a/src/building/model/rooms.ts +++ b/src/building/model/rooms.ts @@ -30,6 +30,7 @@ export interface BaseWallNode { id: WallNodeId perimeterId: PerimeterId type: 'perimeter' | 'inner' + connectedWallIds: IntermediateWallId[] } export interface PerimeterWallNode extends BaseWallNode { @@ -43,16 +44,20 @@ export interface InnerWallNode extends BaseWallNode { position: Vec2 } -export interface WallNodeGeometry { - position: Vec2 +export interface BaseWallNodeGeometry { + boundary: Polygon2D + center: Vec2 } -export interface InnerWallNodeGeometry extends WallNodeGeometry { - connectedWallIds: IntermediateWallId[] - boundary: Polygon2D +export interface PerimeterWallNodeGeometry extends BaseWallNodeGeometry { + position: Vec2 } -export type PerimeterWallNodeWithGeometry = PerimeterWallNode & WallNodeGeometry +export type InnerWallNodeGeometry = BaseWallNodeGeometry + +export type WallNodeGeometry = PerimeterWallNodeGeometry | InnerWallNodeGeometry + +export type PerimeterWallNodeWithGeometry = PerimeterWallNode & PerimeterWallNodeGeometry export type InnerWallNodeWithGeometry = InnerWallNode & InnerWallNodeGeometry export type WallNode = PerimeterWallNode | InnerWallNode diff --git a/src/building/store/slices/intermediateWallGeometry.ts b/src/building/store/slices/intermediateWallGeometry.ts new file mode 100644 index 000000000..656eba4d9 --- /dev/null +++ b/src/building/store/slices/intermediateWallGeometry.ts @@ -0,0 +1,436 @@ +import type { Perimeter } from '@/building/model' +import type { IntermediateWallId, PerimeterId, WallNodeId } from '@/building/model/ids' +import type { + InnerWallNode, + InnerWallNodeGeometry, + PerimeterWallNode, + PerimeterWallNodeGeometry, + WallAxis +} from '@/building/model/rooms' +import type { IntermediateWallsState } from '@/building/store/slices/intermediateWallsSlice' +import type { PerimetersState } from '@/building/store/slices/perimeterSlice' +import type { TimestampsState } from '@/building/store/slices/timestampsSlice' +import { + type Length, + type Line2D, + type Vec2, + ZERO_VEC2, + addVec2, + direction, + dotVec2, + eqVec2, + lenVec2, + lineIntersection, + midpoint, + negVec2, + normVec2, + perpendicularCCW, + perpendicularCW, + projectVec2, + scaleAddVec2, + scaleVec2, + subVec2 +} from '@/shared/geometry' +import { ensurePolygonIsClockwise } from '@/shared/geometry/polygon' + +const COLINEAR_POINT_OFFSET = 10 + +export function updateAllWallNodeGeometry( + state: IntermediateWallsState & PerimetersState & TimestampsState, + perimeterId: PerimeterId +): void { + if (!(perimeterId in state.perimeters)) return + const perimeter = state.perimeters[perimeterId] + + const nodePositions = getNodePositions(perimeter, state) + const wallLines = getWallLines(perimeter, state, nodePositions) + + for (const nodeId of perimeter.wallNodeIds) { + const node = state.wallNodes[nodeId] + + if (node.type === 'perimeter') { + updatePerimeterNode(state, node, wallLines, nodePositions) + } else { + const connectedWallLines = node.connectedWallIds.map(wallId => ({ wallId, ...wallLines.get(wallId)! })) + + let points: Vec2[] + if (connectedWallLines.length === 0) { + throw new Error(`Inner wall node ${nodeId} has no connected walls`) + } else if (connectedWallLines.length === 1) { + points = updateWallEnd(state, connectedWallLines[0], node) + } else if (connectedWallLines.length === 2) { + points = updateSimpleCorner(node, connectedWallLines[0], connectedWallLines[1], nodePositions, state) + } else { + points = updateComplexCorner(node, connectedWallLines, nodePositions, state) + } + + if (points.length >= 3) { + const sum = points.reduce((acc, p) => addVec2(acc, p), ZERO_VEC2) + const centroid = scaleVec2(sum, 1 / points.length) + const newGeometry: InnerWallNodeGeometry = { + center: centroid, + boundary: ensurePolygonIsClockwise({ points }) + } + state._wallNodeGeometry[nodeId] = newGeometry + } + } + } +} + +function updateComplexCorner( + node: InnerWallNode, + connectedWallLines: { left: Line2D; right: Line2D; wallId: IntermediateWallId }[], + nodePositions: Map, + state: IntermediateWallsState & PerimetersState & TimestampsState +) { + connectedWallLines.sort((a, b) => { + const aDir = a.left.direction + const bDir = b.left.direction + const angleA = Math.atan2(aDir[1], aDir[0]) + const angleB = Math.atan2(bDir[1], bDir[0]) + return angleA - angleB + }) + + const points: Vec2[] = [] + for (let i = 0; i < connectedWallLines.length; i++) { + const iNext = (i + 1) % connectedWallLines.length + const a = connectedWallLines[i].right + const b = connectedWallLines[iNext].left + + let aPos: Vec2, bPos: Vec2 + + const dot = dotVec2(a.direction, b.direction) + if (Math.abs(dot) > 0.99) { + // Lines are parallel -> add points along the wall line at the node position + const nodePos = nodePositions.get(node.id)! + + aPos = scaleAddVec2(a.point, a.direction, projectVec2(a.point, nodePos, a.direction)) + bPos = scaleAddVec2(b.point, b.direction, projectVec2(b.point, nodePos, b.direction)) + } else { + const intersection = lineIntersection(a, b)! + aPos = intersection + bPos = intersection + } + + if (eqVec2(aPos, bPos)) { + points.push(aPos) + } else { + points.push(aPos, bPos) + } + + const wallAId = connectedWallLines[i].wallId + const wallA = state.intermediateWalls[wallAId] + const geometryA = state._intermediateWallGeometry[wallAId] + if (wallA.start.nodeId === node.id) { + geometryA.rightLine.start = aPos + } else { + geometryA.rightLine.end = aPos + } + + const wallBId = connectedWallLines[iNext].wallId + const wallB = state.intermediateWalls[wallBId] + const geometryB = state._intermediateWallGeometry[wallBId] + if (wallB.start.nodeId === node.id) { + geometryB.leftLine.start = bPos + } else { + geometryB.leftLine.end = bPos + } + } + return points +} + +function updateSimpleCorner( + node: InnerWallNode, + a: { left: Line2D; right: Line2D; wallId: IntermediateWallId }, + b: { left: Line2D; right: Line2D; wallId: IntermediateWallId }, + nodePositions: Map, + state: IntermediateWallsState & PerimetersState & TimestampsState +) { + const dot = dotVec2(a.left.direction, b.left.direction) + if (Math.abs(dot) > 0.99) { + // Lines are almost parallel -> cutoff perpendicular at the node position + const nodePos = nodePositions.get(node.id)! + + const aLeft = scaleAddVec2(a.left.point, a.left.direction, projectVec2(a.left.point, nodePos, a.left.direction)) + const aRight = scaleAddVec2( + a.right.point, + a.right.direction, + projectVec2(a.right.point, nodePos, a.right.direction) + ) + + const wallA = state.intermediateWalls[a.wallId] + const geometryA = state._intermediateWallGeometry[a.wallId] + if (wallA.start.nodeId === node.id) { + geometryA.leftLine.start = aLeft + geometryA.rightLine.start = aRight + } else { + geometryA.leftLine.end = aLeft + geometryA.rightLine.end = aRight + } + const aDir = wallA.start.nodeId === node.id ? negVec2(a.left.direction) : a.left.direction + const p1 = scaleAddVec2(aLeft, aDir, COLINEAR_POINT_OFFSET) + const p2 = scaleAddVec2(aRight, aDir, COLINEAR_POINT_OFFSET) + + const bLeft = scaleAddVec2(b.left.point, b.left.direction, projectVec2(b.left.point, nodePos, b.left.direction)) + const bRight = scaleAddVec2( + b.right.point, + b.right.direction, + projectVec2(b.right.point, nodePos, b.right.direction) + ) + + const wallB = state.intermediateWalls[b.wallId] + const geometryB = state._intermediateWallGeometry[b.wallId] + if (wallB.start.nodeId === node.id) { + geometryB.leftLine.start = bLeft + geometryB.rightLine.start = bRight + } else { + geometryB.leftLine.end = bLeft + geometryB.rightLine.end = bRight + } + const bDir = wallB.start.nodeId === node.id ? negVec2(b.left.direction) : b.left.direction + const p3 = scaleAddVec2(bRight, bDir, COLINEAR_POINT_OFFSET) + const p4 = scaleAddVec2(bLeft, bDir, COLINEAR_POINT_OFFSET) + + return [p1, p2, p3, p4] + } else { + // Lines are not parallel -> use intersection points + const i1 = lineIntersection(a.left, b.right)! + const i2 = lineIntersection(a.left, b.left)! + const i3 = lineIntersection(a.right, b.left)! + const i4 = lineIntersection(a.right, b.right)! + + const wallA = state.intermediateWalls[a.wallId] + const geometryA = state._intermediateWallGeometry[a.wallId] + if (wallA.start.nodeId === node.id) { + geometryA.leftLine.start = i2 + geometryA.rightLine.start = i4 + } else { + geometryA.leftLine.end = i2 + geometryA.rightLine.end = i4 + } + + const wallB = state.intermediateWalls[b.wallId] + const geometryB = state._intermediateWallGeometry[b.wallId] + if (wallB.start.nodeId === node.id) { + geometryB.leftLine.start = i2 + geometryB.rightLine.start = i4 + } else { + geometryB.leftLine.end = i2 + geometryB.rightLine.end = i4 + } + + return [i1, i2, i3, i4] + } +} + +function updateWallEnd( + state: IntermediateWallsState & PerimetersState & TimestampsState, + connectedWall: { left: Line2D; right: Line2D; wallId: IntermediateWallId }, + node: InnerWallNode +) { + const wall = state.intermediateWalls[connectedWall.wallId] + const geometry = state._intermediateWallGeometry[connectedWall.wallId] + const leftLine = connectedWall.left + const left = scaleAddVec2( + leftLine.point, + leftLine.direction, + projectVec2(leftLine.point, node.position, leftLine.direction) + ) + const rightLine = connectedWall.right + const right = scaleAddVec2( + rightLine.point, + rightLine.direction, + projectVec2(rightLine.point, node.position, rightLine.direction) + ) + + if (wall.start.nodeId === node.id) { + geometry.leftLine.start = left + geometry.rightLine.start = right + } else { + geometry.leftLine.end = left + geometry.rightLine.end = right + } + + // TODO: Properly handle single wall case by creating a rectangle based on the wall line and node position + return [left, right] +} + +function updatePerimeterNode( + state: IntermediateWallsState & PerimetersState & TimestampsState, + node: PerimeterWallNode, + wallLines: Map, + nodePositions: Map +) { + const wall = state.perimeterWalls[node.wallId] + const wallGeometry = state._perimeterWallGeometry[node.wallId] + const start = wallGeometry.insideLine.start + + let minOffset = Infinity + let maxOffset = -Infinity + for (const wallId of node.connectedWallIds) { + const geometry = wallLines.get(wallId) + if (!geometry) continue + + const leftIntersection = lineIntersection({ point: start, direction: wallGeometry.direction }, geometry.left) + if (leftIntersection) { + const projection = projectVec2(start, leftIntersection, wallGeometry.direction) + minOffset = Math.min(minOffset, projection) + maxOffset = Math.max(maxOffset, projection) + } + + const rightIntersection = lineIntersection({ point: start, direction: wallGeometry.direction }, geometry.right) + if (rightIntersection) { + const projection = projectVec2(start, rightIntersection, wallGeometry.direction) + minOffset = Math.min(minOffset, projection) + maxOffset = Math.max(maxOffset, projection) + } + + if (leftIntersection && rightIntersection) { + const iWall = state.intermediateWalls[wallId] + if (iWall.start.nodeId === node.id) { + state._intermediateWallGeometry[wallId].leftLine.start = leftIntersection + state._intermediateWallGeometry[wallId].rightLine.start = rightIntersection + } else { + state._intermediateWallGeometry[wallId].leftLine.end = leftIntersection + state._intermediateWallGeometry[wallId].rightLine.end = rightIntersection + } + } + } + + const minInside = scaleAddVec2(start, wallGeometry.direction, minOffset) + const maxInside = scaleAddVec2(start, wallGeometry.direction, maxOffset) + const minOutside = scaleAddVec2(minInside, wallGeometry.outsideDirection, wall.thickness) + const maxOutside = scaleAddVec2(maxInside, wallGeometry.outsideDirection, wall.thickness) + + const newGeometry: PerimeterWallNodeGeometry = { + position: nodePositions.get(node.id)!, + center: midpoint(minInside, maxOutside), + boundary: ensurePolygonIsClockwise({ + points: [minInside, maxInside, maxOutside, minOutside] + }) + } + + state._wallNodeGeometry[node.id] = newGeometry +} + +function getWallLines( + perimeter: Perimeter, + state: IntermediateWallsState & PerimetersState & TimestampsState, + nodePositions: Map +) { + const wallLines = new Map() + for (const wallId of perimeter.intermediateWallIds) { + const wall = state.intermediateWalls[wallId] + const startPos = nodePositions.get(wall.start.nodeId) + const endPos = nodePositions.get(wall.end.nodeId) + if (!startPos || !endPos) continue + + const lines = computeWallLines(startPos, wall.start.axis, endPos, wall.end.axis, wall.thickness) + + state._intermediateWallGeometry[wallId] = { + direction: lines.left.direction, + leftDirection: perpendicularCCW(lines.left.direction), + boundary: { points: [] }, + centerLine: { start: ZERO_VEC2, end: ZERO_VEC2 }, + wallLength: 0, + leftLength: 0, + leftLine: { start: ZERO_VEC2, end: ZERO_VEC2 }, + rightLength: 0, + rightLine: { start: ZERO_VEC2, end: ZERO_VEC2 } + } + + wallLines.set(wallId, lines) + } + + return wallLines +} + +function getNodePositions(perimeter: Perimeter, state: IntermediateWallsState & PerimetersState & TimestampsState) { + const nodePositions = new Map() + for (const nodeId of perimeter.wallNodeIds) { + const node = state.wallNodes[nodeId] + + if (node.type === 'perimeter') { + const wall = state.perimeterWalls[node.wallId] + const wallGeometry = state._perimeterWallGeometry[node.wallId] + const cornerGeometry = state._perimeterCornerGeometry[wall.startCornerId] + const position = scaleAddVec2(cornerGeometry.insidePoint, wallGeometry.direction, node.offsetFromCornerStart) + + nodePositions.set(nodeId, position) + } else { + nodePositions.set(nodeId, node.position) + } + } + return nodePositions +} + +export function computeWallLines( + start: Vec2, + startAxis: WallAxis, + end: Vec2, + endAxis: WallAxis, + thickness: Length +): { left: Line2D; right: Line2D } { + if (startAxis === endAxis) { + const dir = direction(start, end) + const leftDir = perpendicularCCW(dir) + const halfThickness = thickness / 2 + + const leftBase = + startAxis === 'left' + ? start + : startAxis === 'center' + ? scaleAddVec2(start, leftDir, halfThickness) + : scaleAddVec2(start, leftDir, thickness) + const rightBase = + startAxis === 'right' + ? start + : startAxis === 'center' + ? scaleAddVec2(start, leftDir, -halfThickness) + : scaleAddVec2(start, leftDir, -thickness) + + return { + left: { point: leftBase, direction: dir }, + right: { point: rightBase, direction: dir } + } + } + + const v = subVec2(end, start) + const len = lenVec2(v) + + if (thickness > len) { + throw new Error('Wall thickness larger than distance between points') + } + + const vDir = normVec2(v) + const perp = perpendicularCCW(vDir) + + const alpha = thickness / len + const beta = Math.sqrt(1 - alpha * alpha) + + // normal between the two lines + const n = addVec2(scaleVec2(vDir, alpha), scaleVec2(perp, beta)) + + // direction of both lines + const dir = perpendicularCW(n) + const leftDir = perpendicularCCW(dir) + + const leftBase = + startAxis === 'left' + ? start + : endAxis === 'left' + ? end + : scaleAddVec2(start, leftDir, startAxis === 'center' ? thickness / 2 : thickness) + + const rightBase = + startAxis === 'right' + ? start + : endAxis === 'right' + ? end + : scaleAddVec2(start, leftDir, startAxis === 'center' ? -thickness / 2 : -thickness) + + return { + left: { point: leftBase, direction: dir }, + right: { point: rightBase, direction: dir } + } +} diff --git a/src/building/store/slices/intermediateWallsSlice.ts b/src/building/store/slices/intermediateWallsSlice.ts new file mode 100644 index 000000000..4410ea67e --- /dev/null +++ b/src/building/store/slices/intermediateWallsSlice.ts @@ -0,0 +1,416 @@ +import type { StateCreator } from 'zustand' + +import type { + InnerWallNodeGeometry, + InnerWallNodeWithGeometry, + IntermediateWall, + IntermediateWallGeometry, + IntermediateWallWithGeometry, + PerimeterWallNodeGeometry, + PerimeterWallNodeWithGeometry, + WallAttachment, + WallAxis, + WallNode, + WallNodeGeometry, + WallNodeWithGeometry +} from '@/building/model' +import type { IntermediateWallId, PerimeterId, PerimeterWallId, WallNodeId } from '@/building/model/ids' +import { createIntermediateWallId, createWallNodeId } from '@/building/model/ids' +import { NotFoundError } from '@/building/store/errors' +import type { PerimetersState } from '@/building/store/slices/perimeterSlice' +import { + type TimestampsState, + removeTimestampDraft, + updateTimestampDraft +} from '@/building/store/slices/timestampsSlice' +import type { Length, Vec2 } from '@/shared/geometry' +import { copyVec2 } from '@/shared/geometry' + +import { updateAllWallNodeGeometry } from './intermediateWallGeometry' + +export interface IntermediateWallsState { + intermediateWalls: Record + _intermediateWallGeometry: Record + + wallNodes: Record + _wallNodeGeometry: Record +} + +export interface IntermediateWallsActions { + addIntermediateWall: ( + perimeterId: PerimeterId, + start: WallAttachment, + end: WallAttachment, + thickness: Length + ) => IntermediateWallWithGeometry + removeIntermediateWall: (wallId: IntermediateWallId) => void + updateIntermediateWallThickness: (wallId: IntermediateWallId, thickness: Length) => void + updateIntermediateWallAlignment: (wallId: IntermediateWallId, start: WallAxis, end: WallAxis) => void + + addPerimeterWallNode: ( + perimeterId: PerimeterId, + wallId: PerimeterWallId, + offsetFromCornerStart: Length + ) => PerimeterWallNodeWithGeometry + addInnerWallNode: (perimeterId: PerimeterId, position: Vec2) => InnerWallNodeWithGeometry + removeWallNode: (nodeId: WallNodeId) => void + updateInnerWallNodePosition: (nodeId: WallNodeId, position: Vec2) => void + updatePerimeterWallNodeOffset: (nodeId: WallNodeId, offsetFromCornerStart: Length) => void + + getIntermediateWallById: (wallId: IntermediateWallId) => IntermediateWallWithGeometry + getIntermediateWallsByPerimeter: (perimeterId: PerimeterId) => IntermediateWallWithGeometry[] + getAllIntermediateWalls: () => IntermediateWallWithGeometry[] + getWallNodeById: (nodeId: WallNodeId) => WallNodeWithGeometry + getWallNodesByPerimeter: (perimeterId: PerimeterId) => WallNodeWithGeometry[] + getAllWallNodes: () => WallNodeWithGeometry[] +} + +export type IntermediateWallsSlice = IntermediateWallsState & { actions: IntermediateWallsActions } + +export const createIntermediateWallsSlice: StateCreator< + IntermediateWallsSlice & PerimetersState & TimestampsState, + [['zustand/immer', never]], + [], + IntermediateWallsSlice +> = (set, get) => ({ + intermediateWalls: {}, + _intermediateWallGeometry: {}, + wallNodes: {}, + _wallNodeGeometry: {}, + + actions: { + addIntermediateWall: (perimeterId: PerimeterId, start: WallAttachment, end: WallAttachment, thickness: Length) => { + if (thickness <= 0) { + throw new Error('Wall thickness must be greater than 0') + } + + let result!: IntermediateWallWithGeometry + set(state => { + if (!(perimeterId in state.perimeters)) { + throw new NotFoundError('Perimeter', perimeterId) + } + + const perimeter = state.perimeters[perimeterId] + + const wallId = createIntermediateWallId() + const wall: IntermediateWall = { + id: wallId, + perimeterId, + openingIds: [], + start, + end, + thickness + } + + state.intermediateWalls[wallId] = wall + perimeter.intermediateWallIds.push(wallId) + + updateAllWallNodeGeometry(state, perimeterId) + + updateTimestampDraft(state, wallId) + result = { ...wall, ...state._intermediateWallGeometry[wallId] } + }) + + return result + }, + + removeIntermediateWall: (wallId: IntermediateWallId) => { + set(state => { + if (!(wallId in state.intermediateWalls)) return + + const wall = state.intermediateWalls[wallId] + const perimeter = state.perimeters[wall.perimeterId] + + perimeter.intermediateWallIds = perimeter.intermediateWallIds.filter(id => id !== wallId) + + delete state.intermediateWalls[wallId] + delete state._intermediateWallGeometry[wallId] + + removeTimestampDraft(state, wallId) + + cleanupOrphanedNodes(state, wall.start.nodeId, wallId) + cleanupOrphanedNodes(state, wall.end.nodeId, wallId) + + updateAllWallNodeGeometry(state, wall.perimeterId) + }) + }, + + updateIntermediateWallThickness: (wallId: IntermediateWallId, thickness: Length) => { + if (thickness <= 0) { + throw new Error('Wall thickness must be greater than 0') + } + + set(state => { + if (!(wallId in state.intermediateWalls)) { + throw new NotFoundError('Intermediate wall', wallId) + } + const wall = state.intermediateWalls[wallId] + + wall.thickness = thickness + updateAllWallNodeGeometry(state, wall.perimeterId) + updateTimestampDraft(state, wallId) + }) + }, + + updateIntermediateWallAlignment: (wallId: IntermediateWallId, start: WallAxis, end: WallAxis) => { + set(state => { + if (!(wallId in state.intermediateWalls)) { + throw new NotFoundError('Intermediate wall', wallId) + } + const wall = state.intermediateWalls[wallId] + + wall.start.axis = start + wall.end.axis = end + + updateAllWallNodeGeometry(state, wall.perimeterId) + updateTimestampDraft(state, wallId) + }) + }, + + addPerimeterWallNode: (perimeterId: PerimeterId, wallId: PerimeterWallId, offsetFromCornerStart: Length) => { + let result!: PerimeterWallNodeWithGeometry + set(state => { + if (!(perimeterId in state.perimeters)) { + throw new NotFoundError('Perimeter', perimeterId) + } + + if (!(wallId in state.perimeterWalls)) { + throw new NotFoundError('Perimeter wall', wallId) + } + + const perimeter = state.perimeters[perimeterId] + + const nodeId = createWallNodeId() + const node: WallNode = { + id: nodeId, + perimeterId, + type: 'perimeter', + wallId, + offsetFromCornerStart, + connectedWallIds: [] + } + + state.wallNodes[nodeId] = node + perimeter.wallNodeIds.push(nodeId) + + updateAllWallNodeGeometry(state, perimeterId) + + updateTimestampDraft(state, nodeId) + const geometry = state._wallNodeGeometry[nodeId] as PerimeterWallNodeGeometry + result = { ...node, ...geometry } + }) + + return result + }, + + addInnerWallNode: (perimeterId: PerimeterId, position: Vec2) => { + let result!: InnerWallNodeWithGeometry + set(state => { + if (!(perimeterId in state.perimeters)) { + throw new NotFoundError('Perimeter', perimeterId) + } + + const perimeter = state.perimeters[perimeterId] + + const nodeId = createWallNodeId() + const node: WallNode = { + id: nodeId, + perimeterId, + type: 'inner', + position: copyVec2(position), + connectedWallIds: [] + } + + state.wallNodes[nodeId] = node + perimeter.wallNodeIds.push(nodeId) + + updateAllWallNodeGeometry(state, perimeterId) + + updateTimestampDraft(state, nodeId) + const geometry = state._wallNodeGeometry[nodeId] as InnerWallNodeGeometry + result = { ...node, ...geometry } + }) + + return result + }, + + removeWallNode: (nodeId: WallNodeId) => { + set(state => { + if (!(nodeId in state.wallNodes)) return + + const node = state.wallNodes[nodeId] + const perimeter = state.perimeters[node.perimeterId] + + const connectedWalls = Object.values(state.intermediateWalls).filter( + wall => wall.start.nodeId === nodeId || wall.end.nodeId === nodeId + ) + + for (const wall of connectedWalls) { + perimeter.intermediateWallIds = perimeter.intermediateWallIds.filter(id => id !== wall.id) + delete state.intermediateWalls[wall.id] + delete state._intermediateWallGeometry[wall.id] + removeTimestampDraft(state, wall.id) + + const otherNodeId = wall.start.nodeId === nodeId ? wall.end.nodeId : wall.start.nodeId + cleanupOrphanedNodes(state, otherNodeId, wall.id) + } + + perimeter.wallNodeIds = perimeter.wallNodeIds.filter(id => id !== nodeId) + delete state.wallNodes[nodeId] + delete state._wallNodeGeometry[nodeId] + + removeTimestampDraft(state, nodeId) + + updateAllWallNodeGeometry(state, node.perimeterId) + }) + }, + + updateInnerWallNodePosition: (nodeId: WallNodeId, position: Vec2) => { + set(state => { + if (!(nodeId in state.wallNodes)) { + throw new NotFoundError('Wall node', nodeId) + } + + const node = state.wallNodes[nodeId] + if (node.type !== 'inner') { + throw new Error('Cannot update position of perimeter wall node') + } + + node.position = copyVec2(position) + + const connectedWalls = Object.values(state.intermediateWalls).filter( + wall => wall.start.nodeId === nodeId || wall.end.nodeId === nodeId + ) + + for (const wall of connectedWalls) { + updateTimestampDraft(state, wall.id) + } + + updateAllWallNodeGeometry(state, node.perimeterId) + updateTimestampDraft(state, nodeId) + }) + }, + + updatePerimeterWallNodeOffset: (nodeId: WallNodeId, offsetFromCornerStart: Length) => { + set(state => { + if (!(nodeId in state.wallNodes)) { + throw new NotFoundError('Wall node', nodeId) + } + + const node = state.wallNodes[nodeId] + if (node.type !== 'perimeter') { + throw new Error('Cannot update offset of inner wall node') + } + + node.offsetFromCornerStart = offsetFromCornerStart + + const connectedWalls = Object.values(state.intermediateWalls).filter( + wall => wall.start.nodeId === nodeId || wall.end.nodeId === nodeId + ) + + for (const wall of connectedWalls) { + updateTimestampDraft(state, wall.id) + } + + updateAllWallNodeGeometry(state, node.perimeterId) + updateTimestampDraft(state, nodeId) + }) + }, + + getIntermediateWallById: (wallId: IntermediateWallId) => { + const state = get() + if (!(wallId in state.intermediateWalls)) { + throw new NotFoundError('Intermediate wall', wallId) + } + const wall = state.intermediateWalls[wallId] + const geometry = state._intermediateWallGeometry[wallId] + return { ...wall, ...geometry } + }, + + getIntermediateWallsByPerimeter: (perimeterId: PerimeterId) => { + const state = get() + if (!(perimeterId in state.perimeters)) { + throw new NotFoundError('Perimeter', perimeterId) + } + const perimeter = state.perimeters[perimeterId] + return perimeter.intermediateWallIds.map(wallId => { + if (!(wallId in state.intermediateWalls)) { + throw new NotFoundError('Intermediate wall', wallId) + } + const wall = state.intermediateWalls[wallId] + const geometry = state._intermediateWallGeometry[wallId] + return { ...wall, ...geometry } + }) + }, + + getAllIntermediateWalls: () => { + const state = get() + return Object.values(state.intermediateWalls).map(wall => ({ + ...wall, + ...state._intermediateWallGeometry[wall.id] + })) + }, + + getWallNodeById: (nodeId: WallNodeId) => { + const state = get() + if (!(nodeId in state.wallNodes)) { + throw new NotFoundError('Wall node', nodeId) + } + const node = state.wallNodes[nodeId] + const geometry = state._wallNodeGeometry[nodeId] + + return node.type === 'inner' + ? { ...node, ...(geometry as InnerWallNodeGeometry) } + : { ...node, ...(geometry as PerimeterWallNodeGeometry) } + }, + + getWallNodesByPerimeter: (perimeterId: PerimeterId) => { + const state = get() + if (!(perimeterId in state.perimeters)) { + throw new NotFoundError('Perimeter', perimeterId) + } + const perimeter = state.perimeters[perimeterId] + return perimeter.wallNodeIds.map(nodeId => { + if (!(nodeId in state.wallNodes)) { + throw new NotFoundError('Wall node', nodeId) + } + const node = state.wallNodes[nodeId] + const geometry = state._wallNodeGeometry[nodeId] + return node.type === 'inner' + ? { ...node, ...(geometry as InnerWallNodeGeometry) } + : { ...node, ...(geometry as PerimeterWallNodeGeometry) } + }) + }, + + getAllWallNodes: () => { + const state = get() + return Object.values(state.wallNodes).map(node => { + const geometry = state._wallNodeGeometry[node.id] + return node.type === 'inner' + ? { ...node, ...(geometry as InnerWallNodeGeometry) } + : { ...node, ...(geometry as PerimeterWallNodeGeometry) } + }) + } + } +}) + +function cleanupOrphanedNodes( + state: IntermediateWallsSlice & PerimetersState & TimestampsState, + nodeId: WallNodeId, + excludedWallId?: IntermediateWallId +): void { + if (!(nodeId in state.wallNodes)) return + const node = state.wallNodes[nodeId] + + const remainingConnections = Object.values(state.intermediateWalls).filter( + wall => wall.id !== excludedWallId && (wall.start.nodeId === nodeId || wall.end.nodeId === nodeId) + ) + + if (remainingConnections.length === 0) { + const perimeter = state.perimeters[node.perimeterId] + perimeter.wallNodeIds = perimeter.wallNodeIds.filter(id => id !== nodeId) + delete state.wallNodes[nodeId] + delete state._wallNodeGeometry[nodeId] + removeTimestampDraft(state, nodeId) + } +} From 4d63903dfef0020480936b7e3ef264ac1295fb69 Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Fri, 20 Mar 2026 17:35:01 +0100 Subject: [PATCH 03/21] Add missing update for wall geometry --- .../store/slices/intermediateWallGeometry.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/building/store/slices/intermediateWallGeometry.ts b/src/building/store/slices/intermediateWallGeometry.ts index 656eba4d9..ab1e3c0df 100644 --- a/src/building/store/slices/intermediateWallGeometry.ts +++ b/src/building/store/slices/intermediateWallGeometry.ts @@ -3,6 +3,8 @@ import type { IntermediateWallId, PerimeterId, WallNodeId } from '@/building/mod import type { InnerWallNode, InnerWallNodeGeometry, + IntermediateWall, + IntermediateWallGeometry, PerimeterWallNode, PerimeterWallNodeGeometry, WallAxis @@ -17,6 +19,7 @@ import { ZERO_VEC2, addVec2, direction, + distVec2, dotVec2, eqVec2, lenVec2, @@ -75,6 +78,41 @@ export function updateAllWallNodeGeometry( } } } + + for (const wallId of perimeter.intermediateWallIds) { + updateWallGeometry(state._intermediateWallGeometry[wallId], state.intermediateWalls[wallId]) + } +} + +function updateWallGeometry(geometry: IntermediateWallGeometry, wall: IntermediateWall) { + const leftStart = geometry.leftLine.start + const leftEnd = geometry.leftLine.end + const rightStart = geometry.rightLine.start + const rightEnd = geometry.rightLine.end + const halfThickness = wall.thickness / 2 + + const center = midpoint(midpoint(leftStart, leftEnd), midpoint(rightStart, rightEnd)) + const leftStartProjection = Math.abs(projectVec2(center, leftStart, geometry.direction)) + const rightStartProjection = Math.abs(projectVec2(center, rightStart, geometry.direction)) + + const centerStart = + leftStartProjection < rightStartProjection + ? scaleAddVec2(leftStart, geometry.leftDirection, -halfThickness) + : scaleAddVec2(rightStart, geometry.leftDirection, halfThickness) + + const leftEndProjection = Math.abs(projectVec2(center, leftEnd, geometry.direction)) + const rightEndProjection = Math.abs(projectVec2(center, rightEnd, geometry.direction)) + + const centerEnd = + leftEndProjection < rightEndProjection + ? scaleAddVec2(leftEnd, geometry.leftDirection, -halfThickness) + : scaleAddVec2(rightEnd, geometry.leftDirection, halfThickness) + + geometry.centerLine = { start: centerStart, end: centerEnd } + geometry.wallLength = distVec2(centerStart, centerEnd) + geometry.leftLength = distVec2(leftStart, leftEnd) + geometry.rightLength = distVec2(rightStart, rightEnd) + geometry.boundary = ensurePolygonIsClockwise({ points: [leftStart, leftEnd, rightEnd, rightStart] }) } function updateComplexCorner( From f05c3f218189881d665bc2ddbf0109e18945007c Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Fri, 20 Mar 2026 21:57:02 +0100 Subject: [PATCH 04/21] Implement showing the intermediate walls in the UI --- src/building/model/ids.ts | 10 ++--- src/building/store/helpers.ts | 8 ++++ src/building/store/hooks.ts | 44 +++++++++++++++++++ src/building/store/migrations/index.ts | 6 ++- src/building/store/migrations/toVersion12.ts | 34 ++++---------- src/building/store/migrations/toVersion15.ts | 22 ++++++++++ src/building/store/store.ts | 18 ++++++++ src/building/store/types.ts | 21 ++++++++- .../canvas/layers/tools/SelectionOverlay.tsx | 20 ++++++++- .../layers/walls/IntermediateWallShape.tsx | 22 ++++++++++ .../canvas/layers/walls/PerimeterShape.tsx | 16 +++++++ .../canvas/layers/walls/WallNodeShape.tsx | 28 ++++++++++++ .../tools/basic/movement/movementBehaviors.ts | 4 +- 13 files changed, 215 insertions(+), 38 deletions(-) create mode 100644 src/building/store/migrations/toVersion15.ts create mode 100644 src/editor/canvas/layers/walls/IntermediateWallShape.tsx create mode 100644 src/editor/canvas/layers/walls/WallNodeShape.tsx diff --git a/src/building/model/ids.ts b/src/building/model/ids.ts index dfeae916c..ebaa6c5c7 100644 --- a/src/building/model/ids.ts +++ b/src/building/model/ids.ts @@ -75,9 +75,9 @@ export type SelectableId = | RoofId | RoofOverhangId | ConstraintId -// | RoomId -// | WallNodeId -// | IntermediateWallId + // | RoomId + | WallNodeId + | IntermediateWallId // Config ids export type RingBeamAssemblyId = `${typeof RING_BEAM_ID_PREFIX}${string}` @@ -151,9 +151,9 @@ export type EntityType = | 'perimeter-corner' | 'opening' | 'wall-post' - // | 'intermediate-wall' + | 'intermediate-wall' // | 'room' - // | 'wall-node' + | 'wall-node' | 'floor-area' | 'floor-opening' | 'roof' diff --git a/src/building/store/helpers.ts b/src/building/store/helpers.ts index 996cc1e60..0eff5e62b 100644 --- a/src/building/store/helpers.ts +++ b/src/building/store/helpers.ts @@ -3,12 +3,14 @@ import { isConstraintId, isFloorAreaId, isFloorOpeningId, + isIntermediateWallId, isOpeningId, isPerimeterCornerId, isPerimeterId, isPerimeterWallId, isRoofId, isRoofOverhangId, + isWallNodeId, isWallPostId } from '@/building/model' import { getModelActions } from '@/building/store/store' @@ -45,6 +47,12 @@ export function deleteEntity(selectedId: SelectableId): boolean { } else if (isRoofOverhangId(selectedId)) { // Cannot be deleted return false + } else if (isIntermediateWallId(selectedId)) { + modelStore.removeIntermediateWall(selectedId) + return true + } else if (isWallNodeId(selectedId)) { + modelStore.removeWallNode(selectedId) + return true } else { assertUnreachable(selectedId, `Unknown sub-entity type for deletion: ${selectedId}`) } diff --git a/src/building/store/hooks.ts b/src/building/store/hooks.ts index 7b310a13a..93d5606f9 100644 --- a/src/building/store/hooks.ts +++ b/src/building/store/hooks.ts @@ -5,6 +5,7 @@ import type { Constraint, FloorArea, FloorOpening, + IntermediateWallWithGeometry, OpeningWithGeometry, PerimeterCornerGeometry, PerimeterCornerWithGeometry, @@ -13,6 +14,7 @@ import type { Roof, RoofOverhang, Storey, + WallNodeWithGeometry, WallPostWithGeometry } from '@/building/model' import { @@ -20,6 +22,7 @@ import { type ConstraintId, type FloorAreaId, type FloorOpeningId, + type IntermediateWallId, type OpeningId, type PerimeterCornerId, type PerimeterId, @@ -28,16 +31,19 @@ import { type RoofOverhangId, type SelectableId, type StoreyId, + type WallNodeId, type WallPostId, isConstraintId, isFloorAreaId, isFloorOpeningId, + isIntermediateWallId, isOpeningId, isPerimeterCornerId, isPerimeterId, isPerimeterWallId, isRoofId, isRoofOverhangId, + isWallNodeId, isWallPostId } from '@/building/model/ids' import { assertUnreachable } from '@/shared/utils' @@ -80,6 +86,8 @@ export const useModelEntityById = ( | FloorOpening | Roof | RoofOverhang + | IntermediateWallWithGeometry + | WallNodeWithGeometry | null => { const selector = useCallback( (state: Store) => { @@ -93,6 +101,8 @@ export const useModelEntityById = ( if (isFloorOpeningId(id)) return state.floorOpenings[id] if (isRoofId(id)) return state.roofs[id] if (isRoofOverhangId(id)) return state.roofOverhangs[id] + if (isIntermediateWallId(id)) return state.intermediateWalls[id] + if (isWallNodeId(id)) return state.wallNodes[id] if (isConstraintId(id)) return state.buildingConstraints[id] ?? null assertUnreachable(id, `Unsupported entity: ${id}`) }, @@ -107,6 +117,8 @@ export const useModelEntityById = ( if (isPerimeterWallId(id)) return state._perimeterWallGeometry[id] if (isPerimeterCornerId(id)) return state._perimeterCornerGeometry[id] if (isRoofOverhangId(id)) return state.roofOverhangs[id] + if (isIntermediateWallId(id)) return state._intermediateWallGeometry[id] + if (isWallNodeId(id)) return state._wallNodeGeometry[id] return emptyGeometry }, [id] @@ -123,6 +135,8 @@ export const useModelEntityById = ( if (isFloorOpeningId(id)) return state.actions.getFloorOpeningById if (isRoofId(id)) return state.actions.getRoofById if (isRoofOverhangId(id)) return state.actions.getRoofOverhangById + if (isIntermediateWallId(id)) return state.actions.getIntermediateWallById + if (isWallNodeId(id)) return state.actions.getWallNodeById if (isConstraintId(id)) return state.actions.getBuildingConstraintById assertUnreachable(id, `Unsupported entity: ${id}`) }, @@ -294,3 +308,33 @@ export const usePerimeterCornerGeometries = (): Record state._perimeterCornerGeometry) export const useModelActions = (): StoreActions => useModelStore(state => state.actions) + +export const useIntermediateWallById = (id: IntermediateWallId): IntermediateWallWithGeometry => { + const wall = useModelStore(state => state.intermediateWalls[id]) + const geometry = useModelStore(state => state._intermediateWallGeometry[id]) + const getIntermediateWallById = useModelStore(state => state.actions.getIntermediateWallById) + return useMemo(() => getIntermediateWallById(id), [wall, geometry]) +} + +export const useIntermediateWallsByPerimeter = (id: PerimeterId): IntermediateWallWithGeometry[] => { + const perimeter = useModelStore(state => state.perimeters[id]) + const walls = useModelStore(state => state.intermediateWalls) + const geometries = useModelStore(state => state._intermediateWallGeometry) + const getIntermediateWallsByPerimeter = useModelStore(state => state.actions.getIntermediateWallsByPerimeter) + return useMemo(() => getIntermediateWallsByPerimeter(id), [perimeter, walls, geometries, id]) +} + +export const useWallNodeById = (id: WallNodeId): WallNodeWithGeometry => { + const node = useModelStore(state => state.wallNodes[id]) + const geometry = useModelStore(state => state._wallNodeGeometry[id]) + const getWallNodeById = useModelStore(state => state.actions.getWallNodeById) + return useMemo(() => getWallNodeById(id), [node, geometry]) +} + +export const useWallNodesByPerimeter = (id: PerimeterId): WallNodeWithGeometry[] => { + const perimeter = useModelStore(state => state.perimeters[id]) + const nodes = useModelStore(state => state.wallNodes) + const geometries = useModelStore(state => state._wallNodeGeometry) + const getWallNodesByPerimeter = useModelStore(state => state.actions.getWallNodesByPerimeter) + return useMemo(() => getWallNodesByPerimeter(id), [perimeter, nodes, geometries, id]) +} diff --git a/src/building/store/migrations/index.ts b/src/building/store/migrations/index.ts index aae0ea8e3..75377d563 100644 --- a/src/building/store/migrations/index.ts +++ b/src/building/store/migrations/index.ts @@ -5,8 +5,9 @@ import { migrateToVersion11 } from './toVersion11' import { migrateToVersion12 } from './toVersion12' import { migrateToVersion13 } from './toVersion13' import { migrateToVersion14 } from './toVersion14' +import { migrateToVersion15 } from './toVersion15' -export const MODEL_STORE_VERSION = 14 +export const MODEL_STORE_VERSION = 15 const migrations: Migration[] = [ migrateToVersion9, @@ -14,7 +15,8 @@ const migrations: Migration[] = [ migrateToVersion11, migrateToVersion12, migrateToVersion13, - migrateToVersion14 + migrateToVersion14, + migrateToVersion15 ] export function applyMigrations(state: unknown, version: number): unknown { diff --git a/src/building/store/migrations/toVersion12.ts b/src/building/store/migrations/toVersion12.ts index 33888c536..ca1428753 100644 --- a/src/building/store/migrations/toVersion12.ts +++ b/src/building/store/migrations/toVersion12.ts @@ -5,6 +5,7 @@ import type { OpeningType, PerimeterCorner, PerimeterCornerId, + PerimeterId, PerimeterWall, PerimeterWallId, RingBeamAssemblyId, @@ -15,8 +16,6 @@ import type { WallPostId, WallPostType } from '@/building/model' -import { updatePerimeterGeometry } from '@/building/store/slices/perimeterGeometry' -import type { StoreState } from '@/building/store/types' import type { MaterialId } from '@/materials/types' import { type Polygon2D, newVec2 } from '@/shared/geometry' @@ -61,10 +60,12 @@ export const migrateToVersion12: Migration = state => { const newWallPosts: Record = {} try { + const perimeters = Object.values(state.perimeters) as object[] // Process each perimeter - for (const perimeter of Object.values(state.perimeters)) { + for (const perimeter of perimeters) { if (!isRecord(perimeter)) continue if (typeof perimeter.id !== 'string') continue + if ('wallIds' in perimeter) return // Already migrated // Extract old structure const oldWalls: unknown[] = Array.isArray(perimeter.walls) ? perimeter.walls : [] @@ -126,7 +127,7 @@ export const migrateToVersion12: Migration = state => { // Create normalized corner newPerimeterCorners[cornerId] = { id: cornerId, - perimeterId: perimeter.id, + perimeterId: perimeter.id as PerimeterId, previousWallId, nextWallId, referencePoint, @@ -163,7 +164,7 @@ export const migrateToVersion12: Migration = state => { newOpenings[openingId] = { id: openingId, type: 'opening', - perimeterId: perimeter.id, + perimeterId: perimeter.id as PerimeterId, wallId, openingType: (oldOpening.type ?? 'door') as OpeningType, centerOffsetFromWallStart: @@ -189,7 +190,7 @@ export const migrateToVersion12: Migration = state => { newWallPosts[postId] = { id: postId, type: 'post', - perimeterId: perimeter.id, + perimeterId: perimeter.id as PerimeterId, wallId, postType: (oldPost.type ?? 'center') as WallPostType, centerOffsetFromWallStart: @@ -208,7 +209,7 @@ export const migrateToVersion12: Migration = state => { // Create normalized wall const newWall: PerimeterWall = { id: wallId, - perimeterId: perimeter.id, + perimeterId: perimeter.id as PerimeterId, startCornerId, endCornerId, entityIds, @@ -241,28 +242,9 @@ export const migrateToVersion12: Migration = state => { // Assign new normalized structures to state state.perimeterWalls = newPerimeterWalls - state._perimeterWallGeometry = {} state.perimeterCorners = newPerimeterCorners - state._perimeterCornerGeometry = {} state.openings = newOpenings - state._openingGeometry = {} state.wallPosts = newWallPosts - state._wallPostGeometry = {} - state._perimeterGeometry = {} - - // Recalculate geometry for all perimeters - for (const perimeter of Object.values(state.perimeters)) { - if (!isRecord(perimeter)) continue - if (typeof perimeter.id !== 'string') continue - - try { - // Cast state to PerimetersState for geometry calculation - updatePerimeterGeometry(state as unknown as StoreState, perimeter.id) - } catch (error) { - console.error(`Failed to recalculate geometry for perimeter ${perimeter.id}:`, error) - // Continue with other perimeters even if one fails - } - } } catch (error) { // If migration fails catastrophically, reset the store console.error('Migration to version 12 failed, resetting perimeter data:', error) diff --git a/src/building/store/migrations/toVersion15.ts b/src/building/store/migrations/toVersion15.ts new file mode 100644 index 000000000..36cfe3952 --- /dev/null +++ b/src/building/store/migrations/toVersion15.ts @@ -0,0 +1,22 @@ +import type { Migration } from './shared' +import { isRecord } from './shared' + +export const migrateToVersion15: Migration = state => { + if (!isRecord(state)) return + + if (!isRecord(state.intermediateWalls)) { + ;(state as { intermediateWalls: Record }).intermediateWalls = {} + } + + if (!isRecord(state._intermediateWallGeometry)) { + ;(state as { _intermediateWallGeometry: Record })._intermediateWallGeometry = {} + } + + if (!isRecord(state.wallNodes)) { + ;(state as { wallNodes: Record }).wallNodes = {} + } + + if (!isRecord(state._wallNodeGeometry)) { + ;(state as { _wallNodeGeometry: Record })._wallNodeGeometry = {} + } +} diff --git a/src/building/store/store.ts b/src/building/store/store.ts index 6e4364d0f..a9a2800d1 100644 --- a/src/building/store/store.ts +++ b/src/building/store/store.ts @@ -11,6 +11,8 @@ import { Bounds2D } from '@/shared/geometry' import { MODEL_STORE_VERSION, applyMigrations } from './migrations' import { createConstraintsSlice, rebuildReverseIndex } from './slices/constraintsSlice' import { createFloorsSlice } from './slices/floorsSlice' +import { updateAllWallNodeGeometry } from './slices/intermediateWallGeometry' +import { createIntermediateWallsSlice } from './slices/intermediateWallsSlice' import { updatePerimeterGeometry } from './slices/perimeterGeometry' import { createPerimetersSlice } from './slices/perimeterSlice' import { createRoofsSlice } from './slices/roofsSlice' @@ -49,6 +51,7 @@ export const useModelStore = create()( const roofsSlice = immer(createRoofsSlice)(set, get, store) const timestampsSlice = immer(createTimestampsSlice)(set, get, store) const constraintsSlice = immer(createConstraintsSlice)(set, get, store) + const intermediateWallsSlice = immer(createIntermediateWallsSlice)(set, get, store) return { ...storeysSlice, @@ -57,6 +60,7 @@ export const useModelStore = create()( ...roofsSlice, ...timestampsSlice, ...constraintsSlice, + ...intermediateWallsSlice, actions: { ...storeysSlice.actions, ...perimetersSlice.actions, @@ -64,6 +68,7 @@ export const useModelStore = create()( ...roofsSlice.actions, ...timestampsSlice.actions, ...constraintsSlice.actions, + ...intermediateWallsSlice.actions, getBounds: (storeyId: StoreyId): Bounds2D => { const { getPerimetersByStorey, getFloorAreasByStorey, getRoofsByStorey } = get().actions @@ -166,6 +171,8 @@ function partializeState(state: Store): PartializedStoreState { _openingGeometry, _wallPostGeometry, _constraintsByEntity, + _intermediateWallGeometry, + _wallNodeGeometry, ...rest } = state return rest @@ -182,6 +189,9 @@ function regeneratePartializedState(state: PartializedStoreState): void { restoredState._perimeterCornerGeometry = {} restoredState._openingGeometry = {} restoredState._wallPostGeometry = {} + restoredState._intermediateWallGeometry = {} + restoredState._wallNodeGeometry = {} + for (const perimeterId of Object.keys(restoredState.perimeters)) { try { updatePerimeterGeometry(restoredState, perimeterId as PerimeterId) @@ -190,6 +200,14 @@ function regeneratePartializedState(state: PartializedStoreState): void { } } + for (const perimeterId of Object.keys(restoredState.perimeters)) { + try { + updateAllWallNodeGeometry(restoredState, perimeterId as PerimeterId) + } catch (error) { + console.error('Error updating intermediate wall geometry for perimeterId:', perimeterId, error) + } + } + restoredState._constraintsByEntity = {} rebuildReverseIndex(restoredState) } diff --git a/src/building/store/types.ts b/src/building/store/types.ts index 321bd47b9..1368facdb 100644 --- a/src/building/store/types.ts +++ b/src/building/store/types.ts @@ -3,16 +3,31 @@ import type { Bounds2D } from '@/shared/geometry' import type { ConstraintsActions, ConstraintsState } from './slices/constraintsSlice' import type { FloorsActions, FloorsState } from './slices/floorsSlice' +import type { IntermediateWallsActions, IntermediateWallsState } from './slices/intermediateWallsSlice' import type { PerimetersActions, PerimetersState } from './slices/perimeterSlice' import type { RoofsActions, RoofsState } from './slices/roofsSlice' import type { StoreysActions, StoreysState } from './slices/storeysSlice' import type { TimestampsActions, TimestampsState } from './slices/timestampsSlice' export interface StoreState - extends StoreysState, PerimetersState, FloorsState, RoofsState, TimestampsState, ConstraintsState {} + extends + StoreysState, + PerimetersState, + FloorsState, + RoofsState, + TimestampsState, + ConstraintsState, + IntermediateWallsState {} export interface StoreActions - extends StoreysActions, PerimetersActions, FloorsActions, RoofsActions, TimestampsActions, ConstraintsActions { + extends + StoreysActions, + PerimetersActions, + FloorsActions, + RoofsActions, + TimestampsActions, + ConstraintsActions, + IntermediateWallsActions { reset: () => void getBounds: (storeyId: StoreyId) => Bounds2D } @@ -28,4 +43,6 @@ export type PartializedStoreState = Omit< | '_openingGeometry' | '_wallPostGeometry' | '_constraintsByEntity' + | '_intermediateWallGeometry' + | '_wallNodeGeometry' > diff --git a/src/editor/canvas/layers/tools/SelectionOverlay.tsx b/src/editor/canvas/layers/tools/SelectionOverlay.tsx index da3106c98..14fd17c3a 100644 --- a/src/editor/canvas/layers/tools/SelectionOverlay.tsx +++ b/src/editor/canvas/layers/tools/SelectionOverlay.tsx @@ -1,6 +1,12 @@ -import type { PerimeterCornerGeometry, PerimeterCornerWithGeometry, RoofOverhang } from '@/building/model' +import type { + IntermediateWallWithGeometry, + PerimeterCornerGeometry, + PerimeterCornerWithGeometry, + RoofOverhang, + WallNodeWithGeometry +} from '@/building/model' import type { SelectableId } from '@/building/model/ids' -import { isPerimeterCornerId, isRoofOverhangId } from '@/building/model/ids' +import { isIntermediateWallId, isPerimeterCornerId, isRoofOverhangId, isWallNodeId } from '@/building/model/ids' import { useModelEntityById } from '@/building/store' import { useCurrentSelection } from '@/editor/canvas/state/selectionStore' import { type Vec2, direction, perpendicular, scaleAddVec2 } from '@/shared/geometry' @@ -21,6 +27,16 @@ function useSelectionOutlinePoints(currentSelection: SelectableId | null): Vec2[ return (entity as RoofOverhang).area.points } + // Handle intermediate walls + if (isIntermediateWallId(currentSelection)) { + return (entity as IntermediateWallWithGeometry).boundary.points + } + + // Handle wall nodes + if (isWallNodeId(currentSelection)) { + return (entity as WallNodeWithGeometry).boundary.points + } + // Handle all other entities with polygon or area properties if ('outerPolygon' in entity) return entity.outerPolygon.points if ('polygon' in entity) return entity.polygon.points diff --git a/src/editor/canvas/layers/walls/IntermediateWallShape.tsx b/src/editor/canvas/layers/walls/IntermediateWallShape.tsx new file mode 100644 index 000000000..32ae18b7d --- /dev/null +++ b/src/editor/canvas/layers/walls/IntermediateWallShape.tsx @@ -0,0 +1,22 @@ +import type { IntermediateWallId } from '@/building/model/ids' +import { useIntermediateWallById } from '@/building/store' +import { MATERIAL_COLORS } from '@/shared/theme/colors' +import { polygonToSvgPath } from '@/shared/utils/svg' + +export function IntermediateWallShape({ wallId }: { wallId: IntermediateWallId }): React.JSX.Element { + const wall = useIntermediateWallById(wallId) + + const fillColor = MATERIAL_COLORS.strawbale + + const wallPath = polygonToSvgPath(wall.boundary) + + return ( + + + + ) +} diff --git a/src/editor/canvas/layers/walls/PerimeterShape.tsx b/src/editor/canvas/layers/walls/PerimeterShape.tsx index 1831fa9c0..5e75c1169 100644 --- a/src/editor/canvas/layers/walls/PerimeterShape.tsx +++ b/src/editor/canvas/layers/walls/PerimeterShape.tsx @@ -1,5 +1,8 @@ import type { PerimeterWithGeometry } from '@/building/model' +import { useIntermediateWallsByPerimeter, useWallNodesByPerimeter } from '@/building/store' +import { IntermediateWallShape } from '@/editor/canvas/layers/walls/IntermediateWallShape' import { PerimeterWallEntitiesShape } from '@/editor/canvas/layers/walls/PerimeterWallEntitiesShape' +import { WallNodeShape } from '@/editor/canvas/layers/walls/WallNodeShape' import { useViewMode } from '@/editor/canvas/state/viewModeStore' import { polygonToSvgPath } from '@/shared/utils/svg' @@ -9,6 +12,9 @@ import { PerimeterWallShape } from './PerimeterWallShape' export function PerimeterShape({ perimeter }: { perimeter: PerimeterWithGeometry }): React.JSX.Element { const mode = useViewMode() + const intermediateWalls = useIntermediateWallsByPerimeter(perimeter.id) + const wallNodes = useWallNodesByPerimeter(perimeter.id) + const innerPath = polygonToSvgPath(perimeter.innerPolygon) const outerPath = polygonToSvgPath(perimeter.outerPolygon) @@ -61,6 +67,16 @@ export function PerimeterShape({ perimeter }: { perimeter: PerimeterWithGeometry {perimeter.wallIds.map(id => ( ))} + + {/* Render intermediate walls */} + {intermediateWalls.map(wall => ( + + ))} + + {/* Render wall nodes */} + {wallNodes.map(node => ( + + ))} ) } diff --git a/src/editor/canvas/layers/walls/WallNodeShape.tsx b/src/editor/canvas/layers/walls/WallNodeShape.tsx new file mode 100644 index 000000000..dee94a88a --- /dev/null +++ b/src/editor/canvas/layers/walls/WallNodeShape.tsx @@ -0,0 +1,28 @@ +import type { WallNodeId } from '@/building/model/ids' +import { useWallNodeById } from '@/building/store' +import { MATERIAL_COLORS } from '@/shared/theme/colors' +import { polygonToSvgPath } from '@/shared/utils/svg' + +const NODE_RADIUS = 100 + +export function WallNodeShape({ nodeId }: { nodeId: WallNodeId }): React.JSX.Element { + const node = useWallNodeById(nodeId) + + const fillColor = MATERIAL_COLORS.strawbale + + const pathD = polygonToSvgPath(node.boundary) + + return ( + + + + + ) +} diff --git a/src/editor/tools/basic/movement/movementBehaviors.ts b/src/editor/tools/basic/movement/movementBehaviors.ts index cb4126935..ca4c85a1c 100644 --- a/src/editor/tools/basic/movement/movementBehaviors.ts +++ b/src/editor/tools/basic/movement/movementBehaviors.ts @@ -20,7 +20,9 @@ const MOVEMENT_BEHAVIORS: Record | null> 'floor-area': new FloorAreaMovementBehavior(), 'floor-opening': new FloorOpeningMovementBehavior(), roof: new RoofMovementBehavior(), - 'roof-overhang': null + 'roof-overhang': null, + 'intermediate-wall': null, + 'wall-node': null } export function getMovementBehavior(entityType: EntityType): MovementBehavior | null { From 795a7a52a235e4b2a51803bbaac1b28ba9960f8f Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Fri, 20 Mar 2026 22:09:49 +0100 Subject: [PATCH 05/21] Fix linter issues --- .../store/slices/intermediateWallGeometry.ts | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/building/store/slices/intermediateWallGeometry.ts b/src/building/store/slices/intermediateWallGeometry.ts index ab1e3c0df..d9a11cbb5 100644 --- a/src/building/store/slices/intermediateWallGeometry.ts +++ b/src/building/store/slices/intermediateWallGeometry.ts @@ -54,7 +54,12 @@ export function updateAllWallNodeGeometry( if (node.type === 'perimeter') { updatePerimeterNode(state, node, wallLines, nodePositions) } else { - const connectedWallLines = node.connectedWallIds.map(wallId => ({ wallId, ...wallLines.get(wallId)! })) + const connectedWallLines = node.connectedWallIds + .map(wallId => { + const lines = wallLines.get(wallId) + return lines ? { wallId, ...lines } : null + }) + .filter((item): item is { wallId: IntermediateWallId; left: Line2D; right: Line2D } => item !== null) let points: Vec2[] if (connectedWallLines.length === 0) { @@ -140,12 +145,18 @@ function updateComplexCorner( const dot = dotVec2(a.direction, b.direction) if (Math.abs(dot) > 0.99) { // Lines are parallel -> add points along the wall line at the node position - const nodePos = nodePositions.get(node.id)! + const nodePos = nodePositions.get(node.id) + if (!nodePos) { + throw new Error(`Node position not found for node ${node.id}`) + } aPos = scaleAddVec2(a.point, a.direction, projectVec2(a.point, nodePos, a.direction)) bPos = scaleAddVec2(b.point, b.direction, projectVec2(b.point, nodePos, b.direction)) } else { - const intersection = lineIntersection(a, b)! + const intersection = lineIntersection(a, b) + if (!intersection) { + throw new Error(`No intersection found between wall lines at node ${node.id}`) + } aPos = intersection bPos = intersection } @@ -187,7 +198,10 @@ function updateSimpleCorner( const dot = dotVec2(a.left.direction, b.left.direction) if (Math.abs(dot) > 0.99) { // Lines are almost parallel -> cutoff perpendicular at the node position - const nodePos = nodePositions.get(node.id)! + const nodePos = nodePositions.get(node.id) + if (!nodePos) { + throw new Error(`Node position not found for node ${node.id}`) + } const aLeft = scaleAddVec2(a.left.point, a.left.direction, projectVec2(a.left.point, nodePos, a.left.direction)) const aRight = scaleAddVec2( @@ -232,10 +246,14 @@ function updateSimpleCorner( return [p1, p2, p3, p4] } else { // Lines are not parallel -> use intersection points - const i1 = lineIntersection(a.left, b.right)! - const i2 = lineIntersection(a.left, b.left)! - const i3 = lineIntersection(a.right, b.left)! - const i4 = lineIntersection(a.right, b.right)! + const i1 = lineIntersection(a.left, b.right) + const i2 = lineIntersection(a.left, b.left) + const i3 = lineIntersection(a.right, b.left) + const i4 = lineIntersection(a.right, b.right) + + if (!i1 || !i2 || !i3 || !i4) { + throw new Error(`Could not compute all intersection points at node ${node.id}`) + } const wallA = state.intermediateWalls[a.wallId] const geometryA = state._intermediateWallGeometry[a.wallId] @@ -340,8 +358,13 @@ function updatePerimeterNode( const minOutside = scaleAddVec2(minInside, wallGeometry.outsideDirection, wall.thickness) const maxOutside = scaleAddVec2(maxInside, wallGeometry.outsideDirection, wall.thickness) + const nodePos = nodePositions.get(node.id) + if (!nodePos) { + throw new Error(`Node position not found for perimeter wall node ${node.id}`) + } + const newGeometry: PerimeterWallNodeGeometry = { - position: nodePositions.get(node.id)!, + position: nodePos, center: midpoint(minInside, maxOutside), boundary: ensurePolygonIsClockwise({ points: [minInside, maxInside, maxOutside, minOutside] From 2c39131bc836be036b0c5667225f78c9c2f266a2 Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Fri, 20 Mar 2026 23:38:50 +0100 Subject: [PATCH 06/21] WIP --- docs/intermediate-walls-architecture.md | 884 +++++++++++++++++- .../store/slices/intermediateWallsSlice.ts | 68 ++ .../add/IntermediateWallTool.ts | 270 ++++++ .../tools/shared/polyline/BasePolylineTool.ts | 292 ++++++ 4 files changed, 1465 insertions(+), 49 deletions(-) create mode 100644 src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts create mode 100644 src/editor/tools/shared/polyline/BasePolylineTool.ts diff --git a/docs/intermediate-walls-architecture.md b/docs/intermediate-walls-architecture.md index fa5fe5660..302655712 100644 --- a/docs/intermediate-walls-architecture.md +++ b/docs/intermediate-walls-architecture.md @@ -213,71 +213,816 @@ interface PerpendicularConstraint { Constraints are stored separately and enforced during geometry updates. -## Implementation Phases +--- -### Phase 1: Store & Geometry (Current) +# Intermediate Wall Drawing Tool - Detailed Implementation Plan -- [ ] Implement `intermediateWallGeometry.ts` -- [ ] Implement `intermediateWallsSlice.ts` actions -- [ ] Integrate into main store -- [ ] Unit tests for geometry computation +## Overview -### Phase 2: Drawing Tools +Implement a tool to draw intermediate (interior partition) walls in a floor plan editor. The tool draws open chains of connected wall segments (not closed polygons), similar to drawing a polyline. -- [ ] Create `intermediateWallTool` for drawing -- [ ] Implement perpendicular snapping -- [ ] Implement wall-node creation on click -- [ ] Connect to perimeter walls +### Key Behaviors -### Phase 3: Split-on-Connect +1. **Chain drawing**: Click multiple points to create a chain of connected walls +2. **Snap to existing elements**: Nodes, perimeter walls, other intermediate walls +3. **Perpendicular snapping**: Snap to 90° angles from walls +4. **Alignment snapping**: Snap to extension lines from existing walls +5. **Center axis alignment**: Walls align their center axis with nodes by default +6. **Auto-finish on T-junction**: Placing a point on an existing wall creates a T-junction and ends the chain +7. **Manual finish**: Press Enter to finish at a free position +8. **Configurable thickness**: Inspector allows adjusting wall thickness -- [ ] Detect wall intersections during placement -- [ ] Implement wall splitting logic -- [ ] Update geometry after splits -- [ ] Handle edge cases (T-junctions, crosses) +## Node Creation Logic -### Phase 4: Rendering +When the user clicks to place a wall endpoint, determine the appropriate node type: -- [ ] Render intermediate walls on canvas -- [ ] Render wall nodes (connection points) -- [ ] Selection and hover states -- [ ] Measurements display +### Case 1: Click on Perimeter Wall Endpoint (Corner) -### Phase 5: Constraints +``` +Input: Snapped to PerimeterCorner +Action: Use addPerimeterWallNode at offset 0 or wall length +Node: PerimeterWallNode (no split needed - corners already exist) +``` -- [ ] Store constraint model -- [ ] Enforce perpendicular constraints -- [ ] Visual constraint indicators -- [ ] Constraint deletion +### Case 2: Click on Perimeter Wall Midpoint -### Phase 6: Room Detection +``` +Input: Snapped to point on PerimeterWall (not corner) +Action: Use addPerimeterWallNode at computed offset +Node: PerimeterWallNode (NO split - perimeter walls don't split for intermediate walls) +``` -- [ ] Implement room boundary detection -- [ ] Auto-assign walls to rooms -- [ ] Room labeling UI -- [ ] Area calculations +### Case 3: Click on Existing Wall Node -## File Structure +``` +Input: Snapped to existing WallNode (inner or perimeter) +Action: Reuse existing node +Node: Existing WallNode +``` + +### Case 4: Click on Intermediate Wall Midpoint (T-Junction) + +``` +Input: Snapped to point on IntermediateWall (not at node) +Action: + 1. Call splitIntermediateWallAtPoint(wallId, point) - NEW ACTION NEEDED + 2. This creates: + - New InnerWallNode at the split point + - Two new IntermediateWalls replacing the original + - Deletes original wall + 3. Use the new node's ID +Node: InnerWallNode (created by split action) +Behavior: END THE CHAIN - T-junction completes the drawing session +``` + +### Case 5: Click on Free Position Inside Perimeter + +``` +Input: Point not snapped to any wall/node, but inside a perimeter polygon +Action: Use addInnerWallNode at the point +Node: InnerWallNode +``` + +### Case 6: Click Outside Perimeter + +``` +Input: Point not inside any perimeter polygon +Action: Reject the click (validation error) +Behavior: Show error feedback, don't create wall +``` + +## Store Actions + +### New Action: `splitIntermediateWallAtPoint` + +Add to `intermediateWallsSlice.ts`: + +```typescript +interface SplitIntermediateWallAtPointPayload { + wallId: IntermediateWallId + point: Vec2 // Point on the wall's center line +} +// Returns the new WallNodeId at the split point +splitIntermediateWallAtPoint: (state: IntermediateWallsState, payload: SplitIntermediateWallAtPointPayload) => + WallNodeId ``` -src/building/ -├── model/ -│ ├── ids.ts # WallNodeId, IntermediateWallId -│ ├── rooms.ts # Type definitions -│ └── index.ts # Exports -├── store/ -│ ├── slices/ -│ │ ├── intermediateWallsSlice.ts # State + actions -│ │ └── intermediateWallGeometry.ts # Geometry computation -│ └── store.ts # Integration -└── ... -src/editor/ -└── tools/ - └── intermediateWall/ # Phase 2 - ├── tool.ts - ├── snapping.ts - └── inspector.tsx +**Implementation steps:** + +1. Get the original wall and its geometry +2. Find the split point's parameter `t` along the center line (0-1) +3. Create a new `InnerWallNode` at the split point +4. Create two new `IntermediateWall` entities: + - Wall A: original start → new node (copy start attachment, new end attachment) + - Wall B: new node → original end (new start attachment, copy end attachment) +5. Copy properties (thickness, assembly) to both new walls +6. Delete the original wall +7. Update geometry for both new walls +8. Return the new node's ID + +```typescript +function splitIntermediateWallAtPoint( + state: IntermediateWallsState, + payload: SplitIntermediateWallAtPointPayload +): WallNodeId { + const { wallId, point } = payload + const wall = state.intermediateWalls[wallId] + const geometry = state._intermediateWallGeometry[wallId] + + // Find parameter t along center line + const t = findParameterOnLineSegment(geometry.centerLine, point) + + // Create new inner node at split point + const newNodeId = generateWallNodeId() + state.wallNodes[newNodeId] = { + id: newNodeId, + perimeterId: wall.perimeterId, + type: 'inner', + position: point + } + + // Create two new walls + const wallAId = generateIntermediateWallId() + const wallBId = generateIntermediateWallId() + + state.intermediateWalls[wallAId] = { + id: wallAId, + perimeterId: wall.perimeterId, + start: wall.start, + end: { nodeId: newNodeId, axis: wall.start.axis }, // Match axis alignment + thickness: wall.thickness, + wallAssemblyId: wall.wallAssemblyId, + openingIds: [] // TODO: Split openings appropriately + } + + state.intermediateWalls[wallBId] = { + id: wallBId, + perimeterId: wall.perimeterId, + start: { nodeId: newNodeId, axis: wall.end.axis }, + end: wall.end, + thickness: wall.thickness, + wallAssemblyId: wall.wallAssemblyId, + openingIds: [] + } + + // Delete original wall + delete state.intermediateWalls[wallId] + delete state._intermediateWallGeometry[wallId] + + // Update geometry for new walls + updateIntermediateWallGeometry(state, wallAId) + updateIntermediateWallGeometry(state, wallBId) + updateWallNodeGeometry(state, newNodeId) + + return newNodeId +} +``` + +## BasePolylineTool Base Class + +Create `src/editor/tools/shared/polyline/BasePolylineTool.ts`: + +Similar to `BasePolygonTool` but for open chains instead of closed polygons. + +### Key Differences from BasePolygonTool + +| Aspect | BasePolygonTool | BasePolylineTool | +| ---------------- | ----------------------------- | -------------------------------------------- | +| Shape | Closed polygon | Open polyline | +| Finish condition | Click on first point or Enter | Click on existing wall (T-junction) or Enter | +| Minimum points | 3 | 2 | +| Preview | Closed loop | Open chain | + +### Abstract Interface + +```typescript +interface PolylineToolConfig { + // Convert snap result to a point + snapToPoint(snap: SnapResult): Vec2 + + // Determine if snap is a "terminating" snap (e.g., on existing wall) + isTerminatingSnap(snap: SnapResult): boolean + + // Create the entity for a segment + createSegment(startPoint: PointType, endPoint: PointType, startSnap: SnapResult, endSnap: SnapResult): void + + // Validate a potential point placement + validatePoint(points: PointType[], newPoint: PointType, newSnap: SnapResult): ValidationResult + + // Get preview geometry for current segment + getPreviewGeometry(startPoint: PointType, currentPoint: Vec2): PreviewGeometry +} + +abstract class BasePolylineTool implements Tool { + protected points: PointType[] = [] + protected currentSnap: SnapResult | null = null + protected config: PolylineToolConfig + + // Common tool lifecycle + onActivate(): void + onDeactivate(): void + + // Mouse handling + onMouseMove(position: Vec2, modifiers: Modifiers): void + onClick(position: Vec2, modifiers: Modifiers): void + + // Keyboard handling + onKeyDown(key: string, modifiers: Modifiers): void + + // Abstract methods for subclasses + protected abstract performSnap(worldPos: Vec2): SnapResult | null + protected abstract createPointFromSnap(snap: SnapResult): PointType + + // Protected helpers + protected finishChain(): void + protected cancelChain(): void + protected addPoint(point: PointType, snap: SnapResult): void +} +``` + +### State Management + +```typescript +interface PolylineToolState { + points: PointType[] + isFinished: boolean + isValid: boolean + validationError?: string +} +``` + +## IntermediateWallTool Implementation + +Create `src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts`: + +### Snap Types + +```typescript +type IntermediateWallSnapResult = + | { type: 'perimeter-corner'; corner: PerimeterCorner; position: Vec2 } + | { type: 'perimeter-wall'; wall: PerimeterWall; position: Vec2; offset: Length } + | { type: 'intermediate-wall-node'; node: WallNode; position: Vec2 } + | { type: 'intermediate-wall-midpoint'; wall: IntermediateWall; position: Vec2 } + | { type: 'perpendicular'; basePoint: Vec2; targetPoint: Vec2; referenceWall: IntermediateWall | PerimeterWall } + | { type: 'alignment'; point: Vec2; referenceLine: LineSegment2D } + | { type: 'free'; position: Vec2 } +``` + +### Point Type + +```typescript +interface IntermediateWallPoint { + position: Vec2 + snapResult: IntermediateWallSnapResult + perimeterId: PerimeterId | null // Determined during placement + nodeId?: WallNodeId // Set after node creation +} +``` + +### Tool Class + +```typescript +class IntermediateWallTool extends BasePolylineTool { + private thickness: Length = fromMillimeters(100) // Default 100mm + + constructor( + private store: Store, + private snappingService: SnappingService, + private canvas: Canvas + ) { + super({ + snapToPoint: snap => snap.position, + isTerminatingSnap: snap => snap.type === 'intermediate-wall-midpoint', + createSegment: (start, end, startSnap, endSnap) => this.createWallSegment(start, end, startSnap, endSnap), + validatePoint: (points, newPoint, newSnap) => this.validateWallPoint(points, newPoint, newSnap), + getPreviewGeometry: (start, current) => this.getWallPreview(start, current) + }) + } + + protected performSnap(worldPos: Vec2): IntermediateWallSnapResult | null { + // Priority order: + // 1. Existing wall nodes + // 2. Perimeter corners + // 3. Perimeter wall points + // 4. Intermediate wall midpoints + // 5. Perpendicular snap + // 6. Alignment snap + // 7. Free position (if inside perimeter) + + return this.snappingService.snapForIntermediateWall(worldPos, this.points) + } + + protected createPointFromSnap(snap: IntermediateWallSnapResult): IntermediateWallPoint { + const perimeterId = this.determinePerimeterId(snap) + return { + position: snap.position, + snapResult: snap, + perimeterId + } + } + + private determinePerimeterId(snap: IntermediateWallSnapResult): PerimeterId | null { + switch (snap.type) { + case 'perimeter-corner': + return snap.corner.perimeterId + case 'perimeter-wall': + return snap.wall.perimeterId + case 'intermediate-wall-node': + return snap.node.perimeterId + case 'intermediate-wall-midpoint': + return snap.wall.perimeterId + case 'perpendicular': + case 'alignment': + case 'free': + return this.findContainingPerimeter(snap.position) + } + } + + private createWallSegment( + start: IntermediateWallPoint, + end: IntermediateWallPoint, + startSnap: IntermediateWallSnapResult, + endSnap: IntermediateWallSnapResult + ): void { + if (!start.perimeterId || !end.perimeterId) return + if (start.perimeterId !== end.perimeterId) return + + const perimeterId = start.perimeterId + + // Get or create start node + const startNodeId = this.getOrCreateNode(startSnap, perimeterId) + // Get or create end node + const endNodeId = this.getOrCreateNode(endSnap, perimeterId) + + if (!startNodeId || !endNodeId) return + + // Create the wall + this.store.dispatch.addIntermediateWall({ + perimeterId, + start: { nodeId: startNodeId, axis: 'center' }, + end: { nodeId: endNodeId, axis: 'center' }, + thickness: this.thickness + }) + } + + private getOrCreateNode(snap: IntermediateWallSnapResult, perimeterId: PerimeterId): WallNodeId | null { + switch (snap.type) { + case 'perimeter-corner': + // Find existing node at corner or create one + return this.store.dispatch.addPerimeterWallNode({ + perimeterId, + wallId: snap.corner.startingWallId, // Use appropriate wall + offsetFromCornerStart: fromMillimeters(0) + }) + + case 'perimeter-wall': + return this.store.dispatch.addPerimeterWallNode({ + perimeterId, + wallId: snap.wall.id, + offsetFromCornerStart: snap.offset + }) + + case 'intermediate-wall-node': + return snap.node.id + + case 'intermediate-wall-midpoint': + // This triggers the split and returns new node ID + return this.store.dispatch.splitIntermediateWallAtPoint({ + wallId: snap.wall.id, + point: snap.position + }) + + case 'perpendicular': + case 'alignment': + case 'free': + return this.store.dispatch.addInnerWallNode({ + perimeterId, + position: snap.position + }) + } + } + + private validateWallPoint( + points: IntermediateWallPoint[], + newPoint: IntermediateWallPoint, + newSnap: IntermediateWallSnapResult + ): ValidationResult { + // 1. Must be inside a perimeter + if (!newPoint.perimeterId) { + return { valid: false, error: 'Point must be inside a perimeter' } + } + + // 2. If there are existing points, must be same perimeter + if (points.length > 0 && points[0].perimeterId !== newPoint.perimeterId) { + return { valid: false, error: 'All points must be in the same perimeter' } + } + + // 3. Check for self-intersection with existing segments + if (points.length > 0) { + const lastPoint = points[points.length - 1] + const newSegment = { start: lastPoint.position, end: newPoint.position } + + for (let i = 0; i < points.length - 1; i++) { + const existingSegment = { start: points[i].position, end: points[i + 1].position } + if (lineSegmentsIntersect(newSegment, existingSegment)) { + return { valid: false, error: 'Wall cannot cross existing walls' } + } + } + } + + // 4. Check for intersection with existing intermediate walls + const existingWalls = this.store.getIntermediateWallsByPerimeter(newPoint.perimeterId) + for (const wall of existingWalls) { + if (points.length > 0) { + const lastPoint = points[points.length - 1] + const newSegment = { start: lastPoint.position, end: newPoint.position } + const wallSegment = wall.geometry.centerLine + + // Allow touching at endpoints, but not crossing + if (lineSegmentsIntersect(newSegment, wallSegment)) { + // Check if intersection is at an endpoint (allowed) + const intersection = getLineSegmentIntersection(newSegment, wallSegment) + if (intersection && !isEndpoint(intersection, wallSegment)) { + return { valid: false, error: 'Wall cannot cross existing walls' } + } + } + } + } + + // 5. Minimum wall length + if (points.length > 0) { + const lastPoint = points[points.length - 1] + const distance = vec2Distance(lastPoint.position, newPoint.position) + if (distance < fromMillimeters(50)) { + // Minimum 50mm + return { valid: false, error: 'Wall segment too short' } + } + } + + return { valid: true } + } + + private getWallPreview(start: IntermediateWallPoint, current: Vec2): PreviewGeometry { + const direction = vec2Normalize(vec2Subtract(current, start.position)) + const perpendicular = vec2Perpendicular(direction) + const halfThickness = this.thickness / 2 + + const offset = vec2Scale(perpendicular, halfThickness) + + return { + type: 'polygon', + points: [ + vec2Add(start.position, offset), + vec2Add(current, offset), + vec2Subtract(current, offset), + vec2Subtract(start.position, offset) + ], + style: { fill: 'rgba(200, 200, 200, 0.5)', stroke: '#666', strokeWidth: 1 } + } + } + + setThickness(thickness: Length): void { + this.thickness = thickness + } +} +``` + +## Snapping Service Extension + +Extend `SnappingService.ts` to support intermediate wall snapping: + +```typescript +interface SnappingService { + // Existing methods... + + // New method for intermediate wall tool + snapForIntermediateWall( + worldPos: Vec2, + existingPoints: IntermediateWallPoint[] + ): IntermediateWallSnapResult | null +} + +// Implementation +snapForIntermediateWall( + worldPos: Vec2, + existingPoints: IntermediateWallPoint[] +): IntermediateWallSnapResult | null { + const snapRadius = this.getSnapRadius() + + // 1. Check existing wall nodes + const wallNodes = this.store.getAllWallNodes() + for (const node of wallNodes) { + if (vec2Distance(worldPos, node.geometry.position) < snapRadius) { + return { + type: 'intermediate-wall-node', + node: node.model, + position: node.geometry.position + } + } + } + + // 2. Check perimeter corners + const corners = this.store.getAllPerimeterCorners() + for (const corner of corners) { + if (vec2Distance(worldPos, corner.geometry.position) < snapRadius) { + return { + type: 'perimeter-corner', + corner: corner.model, + position: corner.geometry.position + } + } + } + + // 3. Check perimeter walls + const perimeterWalls = this.store.getAllPerimeterWalls() + for (const wall of perimeterWalls) { + const projection = projectPointOnLineSegment(wall.geometry.innerLine, worldPos) + if (projection.distance < snapRadius) { + return { + type: 'perimeter-wall', + wall: wall.model, + position: projection.point, + offset: projection.t * wall.geometry.wallLength + } + } + } + + // 4. Check intermediate walls (midpoint snapping) + const intermediateWalls = this.store.getAllIntermediateWalls() + for (const wall of intermediateWalls) { + const projection = projectPointOnLineSegment(wall.geometry.centerLine, worldPos) + if (projection.distance < snapRadius) { + // Check if near an endpoint (already handled by node snap) + const nearStart = vec2Distance(projection.point, wall.geometry.centerLine.start) < snapRadius + const nearEnd = vec2Distance(projection.point, wall.geometry.centerLine.end) < snapRadius + if (!nearStart && !nearEnd) { + return { + type: 'intermediate-wall-midpoint', + wall: wall.model, + position: projection.point + } + } + } + } + + // 5. Perpendicular snapping (if we have previous point) + if (existingPoints.length > 0) { + const lastPoint = existingPoints[existingPoints.length - 1] + const perpSnap = this.findPerpendicularSnap(worldPos, lastPoint.position, [ + ...perimeterWalls.map(w => w.geometry.centerLine), + ...intermediateWalls.map(w => w.geometry.centerLine) + ]) + if (perpSnap && vec2Distance(worldPos, perpSnap.targetPoint) < snapRadius * 2) { + return { + type: 'perpendicular', + basePoint: lastPoint.position, + targetPoint: perpSnap.targetPoint, + referenceWall: perpSnap.referenceWall + } + } + } + + // 6. Alignment snapping + const alignmentSnap = this.findAlignmentSnap(worldPos, [ + ...perimeterWalls.map(w => w.geometry.centerLine), + ...intermediateWalls.map(w => w.geometry.centerLine) + ]) + if (alignmentSnap) { + return alignmentSnap + } + + // 7. Free position (validate inside perimeter) + const containingPerimeter = this.findContainingPerimeter(worldPos) + if (containingPerimeter) { + return { type: 'free', position: worldPos } + } + + return null +} +``` + +## Inspector Component + +Create `src/editor/tools/intermediate-wall/add/IntermediateWallToolInspector.tsx`: + +```tsx +import { useEffect, useState } from 'react' + +import { Length, fromMillimeters, toMillimeters } from '@/building/model/units' + +import { IntermediateWallTool } from './IntermediateWallTool' + +interface IntermediateWallToolInspectorProps { + tool: IntermediateWallTool +} + +function IntermediateWallToolInspector({ tool }: IntermediateWallToolInspectorProps) { + const [thickness, setThickness] = useState(() => toMillimeters(tool.getThickness())) + + useEffect(() => { + tool.setThickness(fromMillimeters(thickness)) + }, [thickness, tool]) + + return ( +
+

Intermediate Wall

+ +
+ + setThickness(Number(e.target.value))} + min={50} + max={500} + step={10} + /> +
+ +
+

Click to place wall points

+

Press Enter to finish

+

Press Escape to cancel

+
+
+ ) +} + +export default IntermediateWallToolInspector +``` + +## Overlay Component + +Create `src/editor/tools/intermediate-wall/add/IntermediateWallToolOverlay.tsx`: + +```tsx +import { useEffect, useRef } from 'react' + +import { useToolState } from '@/editor/canvas/hooks/useToolState' + +import { IntermediateWallTool } from './IntermediateWallTool' + +interface IntermediateWallToolOverlayProps { + tool: IntermediateWallTool +} + +function IntermediateWallToolOverlay({ tool }: IntermediateWallToolOverlayProps) { + const canvasRef = useRef(null) + const toolState = useToolState(tool) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const ctx = canvas.getContext('2d') + if (!ctx) return + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height) + + // Draw placed points and segments + const points = toolState.points + if (points.length > 0) { + // Draw segments + ctx.strokeStyle = '#666' + ctx.lineWidth = toolState.thickness + ctx.lineCap = 'butt' + + ctx.beginPath() + ctx.moveTo(points[0].position.x, points[0].position.y) + for (let i = 1; i < points.length; i++) { + ctx.lineTo(points[i].position.x, points[i].position.y) + } + ctx.stroke() + + // Draw points + ctx.fillStyle = '#333' + for (const point of points) { + ctx.beginPath() + ctx.arc(point.position.x, point.position.y, 4, 0, Math.PI * 2) + ctx.fill() + } + } + + // Draw preview segment + if (points.length > 0 && toolState.currentSnap) { + const lastPoint = points[points.length - 1] + const currentPoint = toolState.currentSnap.position + + // Preview line + ctx.strokeStyle = 'rgba(100, 100, 100, 0.5)' + ctx.lineWidth = toPixels(toolState.thickness) + ctx.setLineDash([5, 5]) + ctx.beginPath() + ctx.moveTo(lastPoint.position.x, lastPoint.position.y) + ctx.lineTo(currentPoint.x, currentPoint.y) + ctx.stroke() + ctx.setLineDash([]) + + // Snap indicator + drawSnapIndicator(ctx, toolState.currentSnap) + } + + // Draw validation error if any + if (!toolState.isValid && toolState.validationError) { + ctx.fillStyle = 'rgba(255, 0, 0, 0.8)' + ctx.font = '14px sans-serif' + ctx.fillText(toolState.validationError, 10, 20) + } + }, [toolState]) + + return +} + +function drawSnapIndicator(ctx: CanvasRenderingContext2D, snap: IntermediateWallSnapResult) { + const { position, type } = snap + + ctx.strokeStyle = type === 'intermediate-wall-midpoint' ? '#ff6600' : '#00ff00' + ctx.lineWidth = 2 + + ctx.beginPath() + ctx.arc(position.x, position.y, 8, 0, Math.PI * 2) + ctx.stroke() + + // Special indicator for T-junction + if (type === 'intermediate-wall-midpoint') { + ctx.fillStyle = '#ff6600' + ctx.font = '12px sans-serif' + ctx.fillText('T', position.x + 12, position.y + 4) + } +} + +export default IntermediateWallToolOverlay +``` + +## Tool Registration + +### Update `src/editor/tools/system/types.ts` + +```typescript +export type ToolId = + | 'select' + | 'perimeter-add' + | 'intermediate-wall-add' // NEW + | // ... other tools +``` + +### Update `src/editor/tools/system/metadata.ts` + +```typescript +import intermediateWallAddIcon from '@/shared/assets/icons/wall-intermediate.svg' + +export const toolMetadata: Record = { + // ... existing tools + + 'intermediate-wall-add': { + id: 'intermediate-wall-add', + name: 'Intermediate Wall', + icon: intermediateWallAddIcon, + category: 'walls', + shortcut: 'W', + description: 'Draw interior partition walls', + factory: () => new IntermediateWallTool(/* dependencies */) + } +} +``` + +## Validation Rules Summary + +1. **Inside perimeter**: All points must be inside a perimeter polygon +2. **Same perimeter**: All points in a chain must belong to the same perimeter +3. **No self-intersection**: Wall segments cannot cross each other within the same chain +4. **No wall crossing**: Cannot cross existing intermediate walls (except at endpoints) +5. **Minimum length**: Each segment must be at least 50mm +6. **Perimeter walls allowed**: Can touch/cross perimeter walls (they're boundaries) + +## File Structure + +``` +src/editor/tools/ +├── shared/ +│ ├── polygon/ +│ │ └── BasePolygonTool.ts (existing) +│ └── polyline/ +│ └── BasePolylineTool.ts (NEW) +│ +├── intermediate-wall/ +│ └── add/ +│ ├── IntermediateWallTool.ts (NEW) +│ ├── IntermediateWallToolInspector.tsx (NEW) +│ └── IntermediateWallToolOverlay.tsx (NEW) +│ +└── system/ + ├── types.ts (UPDATE - add ToolId) + ├── metadata.ts (UPDATE - register tool) + └── ToolSystem.ts (UPDATE - if needed) + +src/building/store/slices/ +└── intermediateWallsSlice.ts (UPDATE - add splitIntermediateWallAtPoint) + +src/editor/canvas/services/ +└── SnappingService.ts (UPDATE - add snapForIntermediateWall) ``` ## Key Patterns to Follow @@ -287,3 +1032,44 @@ src/editor/ 3. **Getters**: Combine base model + geometry in getter functions 4. **Timestamps**: Update via `touch(state, entityId)` for undo/redo 5. **State access**: Geometry functions need perimeter geometry access for perimeter nodes + +## Implementation Phases + +### Phase 1: Store & Geometry (Current) + +- [x] Implement `intermediateWallGeometry.ts` +- [x] Implement `intermediateWallsSlice.ts` actions +- [x] Integrate into main store +- [ ] Unit tests for geometry computation +- [ ] Implement `splitIntermediateWallAtPoint` action + +### Phase 2: Drawing Tools + +- [ ] Create `BasePolylineTool` base class +- [ ] Create `IntermediateWallTool` +- [ ] Implement snapping for intermediate walls +- [ ] Implement perpendicular snapping +- [ ] Implement node creation logic +- [ ] Connect to perimeter walls + +### Phase 3: UI Components + +- [ ] Create `IntermediateWallToolInspector` +- [ ] Create `IntermediateWallToolOverlay` +- [ ] Add tool icon +- [ ] Register tool in ToolSystem + +### Phase 4: Testing + +- [ ] Test snapping to all snap types +- [ ] Test T-junction creation +- [ ] Test validation rules +- [ ] Test chain drawing and finishing +- [ ] Test cancellation + +### Phase 5: Polish + +- [ ] Visual feedback for snap types +- [ ] Error messages display +- [ ] Keyboard shortcuts +- [ ] Undo/redo support diff --git a/src/building/store/slices/intermediateWallsSlice.ts b/src/building/store/slices/intermediateWallsSlice.ts index 4410ea67e..a34a031ac 100644 --- a/src/building/store/slices/intermediateWallsSlice.ts +++ b/src/building/store/slices/intermediateWallsSlice.ts @@ -53,6 +53,7 @@ export interface IntermediateWallsActions { offsetFromCornerStart: Length ) => PerimeterWallNodeWithGeometry addInnerWallNode: (perimeterId: PerimeterId, position: Vec2) => InnerWallNodeWithGeometry + splitIntermediateWallAtPoint: (wallId: IntermediateWallId, point: Vec2) => WallNodeId removeWallNode: (nodeId: WallNodeId) => void updateInnerWallNodePosition: (nodeId: WallNodeId, position: Vec2) => void updatePerimeterWallNodeOffset: (nodeId: WallNodeId, offsetFromCornerStart: Length) => void @@ -234,6 +235,73 @@ export const createIntermediateWallsSlice: StateCreator< return result }, + splitIntermediateWallAtPoint: (wallId: IntermediateWallId, point: Vec2) => { + let newNodeId!: WallNodeId + set(state => { + if (!(wallId in state.intermediateWalls)) { + throw new NotFoundError('Intermediate wall', wallId) + } + + const originalWall = state.intermediateWalls[wallId] + const perimeter = state.perimeters[originalWall.perimeterId] + + const newNodeIdInner = createWallNodeId() + const newNode: WallNode = { + id: newNodeIdInner, + perimeterId: originalWall.perimeterId, + type: 'inner', + position: copyVec2(point), + connectedWallIds: [] + } + state.wallNodes[newNodeIdInner] = newNode + perimeter.wallNodeIds.push(newNodeIdInner) + + const wallAId = createIntermediateWallId() + const wallBId = createIntermediateWallId() + + const wallA: IntermediateWall = { + id: wallAId, + perimeterId: originalWall.perimeterId, + openingIds: [], + start: originalWall.start, + end: { nodeId: newNodeIdInner, axis: originalWall.start.axis }, + thickness: originalWall.thickness, + wallAssemblyId: originalWall.wallAssemblyId + } + + const wallB: IntermediateWall = { + id: wallBId, + perimeterId: originalWall.perimeterId, + openingIds: [], + start: { nodeId: newNodeIdInner, axis: originalWall.end.axis }, + end: originalWall.end, + thickness: originalWall.thickness, + wallAssemblyId: originalWall.wallAssemblyId + } + + state.intermediateWalls[wallAId] = wallA + state.intermediateWalls[wallBId] = wallB + + perimeter.intermediateWallIds.push(wallAId) + perimeter.intermediateWallIds.push(wallBId) + perimeter.intermediateWallIds = perimeter.intermediateWallIds.filter(id => id !== wallId) + + delete state.intermediateWalls[wallId] + delete state._intermediateWallGeometry[wallId] + removeTimestampDraft(state, wallId) + + updateTimestampDraft(state, wallAId) + updateTimestampDraft(state, wallBId) + updateTimestampDraft(state, newNodeIdInner) + + updateAllWallNodeGeometry(state, originalWall.perimeterId) + + newNodeId = newNodeIdInner + }) + + return newNodeId + }, + removeWallNode: (nodeId: WallNodeId) => { set(state => { if (!(nodeId in state.wallNodes)) return diff --git a/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts new file mode 100644 index 000000000..dc4f03b0c --- /dev/null +++ b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts @@ -0,0 +1,270 @@ +import { getModelActions } from '@/building/store' +import { getConfigActions } from '@/config/store' +import { getViewModeActions } from '@/editor/canvas/state/viewModeStore' +import { + BasePolylineTool, + type PolylineToolStateBase, + type PolylineValidationContext +} from '@/editor/tools/shared/polyline/BasePolylineTool' +import type { ToolSystem } from '@/editor/tools/system/ToolSystem' +import type { ToolImplementation } from '@/editor/tools/system/types' +import { type Length, type LineSegment2D, type Vec2, distanceToLineSegment, newVec2 } from '@/shared/geometry' +import { isPointInPolygon } from '@/shared/geometry/polygon' + +import { IntermediateWallToolInspector } from './IntermediateWallToolInspector' +import { IntermediateWallToolOverlay } from './IntermediateWallToolOverlay' + +interface IntermediateWallToolState extends PolylineToolStateBase { + thickness: Length +} + +const SNAP_TOLERANCE = 200 + +export class IntermediateWallTool extends BasePolylineTool implements ToolImplementation { + readonly id = 'intermediate-wall.add' + readonly overlayComponent = IntermediateWallToolOverlay + readonly inspectorComponent = IntermediateWallToolInspector + + constructor(toolSystem: ToolSystem) { + super(toolSystem, { + thickness: 120 + }) + } + + public setThickness(thickness: Length): void { + this.state.thickness = thickness + this.triggerRender() + } + + protected onToolActivated(): void { + getViewModeActions().ensureMode('walls') + const configStore = getConfigActions() + this.state.thickness = configStore.getDefaultInteriorWallThickness() ?? 120 + this.updateValidationContext() + } + + protected createInitialValidationContext(): PolylineValidationContext { + return { + existingWalls: [] + } + } + + protected updateValidationContext(): void { + const modelActions = getModelActions() + const walls = modelActions.getAllIntermediateWalls() + this.state.validationContext.existingWalls = walls.map(w => ({ + centerLine: w.geometry.centerLine + })) + } + + protected extendSnapContext( + context: import('@/editor/canvas/services/SnappingService').SnappingContext + ): import('@/editor/canvas/services/SnappingService').SnappingContext { + const modelActions = getModelActions() + const perimeters = modelActions.getAllPerimeters() + const walls = modelActions.getAllIntermediateWalls() + const wallNodes = modelActions.getAllWallNodes() + + const snapPoints: Vec2[] = [...context.snapPoints] + + for (const wall of walls) { + snapPoints.push(wall.geometry.centerLine.start) + snapPoints.push(wall.geometry.centerLine.end) + } + + for (const node of wallNodes) { + snapPoints.push(node.center) + } + + const referenceLineSegments: LineSegment2D[] = [...(context.referenceLineSegments ?? [])] + + for (const wall of walls) { + referenceLineSegments.push(wall.geometry.centerLine) + } + + for (const perimeter of perimeters) { + for (let i = 0; i < perimeter.wallIds.length; i++) { + const wall = modelActions.getPerimeterWallById(perimeter.wallIds[i]) + referenceLineSegments.push(wall.insideLine) + } + } + + return { + ...context, + snapPoints, + referenceLineSegments + } + } + + protected shouldTerminateAtSnap( + _snapResult: import('@/editor/canvas/services/SnappingService').SnapResult | undefined + ): boolean { + return false + } + + protected onPolylineCompleted(points: Vec2[]): void { + if (points.length < 2) return + + const modelActions = getModelActions() + const perimeters = modelActions.getAllPerimeters() + + let perimeterId = this.findPerimeterContainingPoint(points[0]) + if (!perimeterId) { + perimeterId = this.findPerimeterContainingPoint(points[points.length - 1]) + } + if (!perimeterId) { + for (const p of perimeters) { + perimeterId = p.id + break + } + } + if (!perimeterId) { + console.error('No perimeter found for intermediate wall') + return + } + + for (let i = 0; i < points.length - 1; i++) { + const startPoint = points[i] + const endPoint = points[i + 1] + + const startNode = this.getOrCreateNodeForPoint(startPoint, perimeterId) + const endNode = this.getOrCreateNodeForPoint(endPoint, perimeterId) + + modelActions.addIntermediateWall( + perimeterId, + { nodeId: startNode.id, axis: 'center' }, + { nodeId: endNode.id, axis: 'center' }, + this.state.thickness + ) + } + } + + private findPerimeterContainingPoint(point: Vec2): import('@/building/model/ids').PerimeterId | null { + const modelActions = getModelActions() + const perimeters = modelActions.getAllPerimeters() + + for (const perimeter of perimeters) { + if (isPointInPolygon(point, perimeter.boundaryPolygon)) { + return perimeter.id + } + } + return null + } + + private getOrCreateNodeForPoint( + point: Vec2, + perimeterId: import('@/building/model/ids').PerimeterId + ): { id: import('@/building/model/ids').WallNodeId } { + const modelActions = getModelActions() + + const perimeterWallNode = this.findPerimeterWallNodeAtPoint(point, perimeterId) + if (perimeterWallNode) { + return { id: perimeterWallNode.id } + } + + const existingWallNode = this.findExistingWallNodeAtPoint(point, perimeterId) + if (existingWallNode) { + return { id: existingWallNode.id } + } + + const intermediateWallSplit = this.findIntermediateWallToSplitAtPoint(point, perimeterId) + if (intermediateWallSplit) { + const newNodeId = modelActions.splitIntermediateWallAtPoint(intermediateWallSplit.wallId, point) + return { id: newNodeId } + } + + return modelActions.addInnerWallNode(perimeterId, point) + } + + private findPerimeterWallNodeAtPoint( + point: Vec2, + _perimeterId: import('@/building/model/ids').PerimeterId + ): { id: import('@/building/model/ids').WallNodeId } | null { + const modelActions = getModelActions() + const perimeters = modelActions.getAllPerimeters() + + for (const perimeter of perimeters) { + for (const wallId of perimeter.wallIds) { + const wall = modelActions.getPerimeterWallById(wallId) + const distStart = distanceToLineSegment(point, wall.insideLine) + const distEnd = distanceToLineSegment(point, wall.insideLine) + + if (distStart < SNAP_TOLERANCE || distEnd < SNAP_TOLERANCE) { + const distToInsideLine = distanceToLineSegment(point, wall.insideLine) + if (distToInsideLine < SNAP_TOLERANCE) { + const wallLength = wall.insideLength + const t = this.calculateParametricPosition(point, wall.insideLine) + const offset = Math.round(t * wallLength) + + const existingNodes = modelActions.getWallNodesByPerimeter(perimeter.id) + for (const node of existingNodes) { + if ( + node.type === 'perimeter' && + node.wallId === wallId && + Math.abs(node.offsetFromCornerStart - offset) < SNAP_TOLERANCE + ) { + return { id: node.id } + } + } + + const newNode = modelActions.addPerimeterWallNode(perimeter.id, wallId, offset) + return { id: newNode.id } + } + } + } + } + return null + } + + private findExistingWallNodeAtPoint( + point: Vec2, + perimeterId: import('@/building/model/ids').PerimeterId + ): { id: import('@/building/model/ids').WallNodeId } | null { + const modelActions = getModelActions() + const nodes = modelActions.getWallNodesByPerimeter(perimeterId) + + for (const node of nodes) { + const dist = Math.sqrt((point[0] - node.center[0]) ** 2 + (point[1] - node.center[1]) ** 2) + if (dist < SNAP_TOLERANCE) { + return { id: node.id } + } + } + return null + } + + private findIntermediateWallToSplitAtPoint( + point: Vec2, + perimeterId: import('@/building/model/ids').PerimeterId + ): { wallId: import('@/building/model/ids').IntermediateWallId } | null { + const modelActions = getModelActions() + const walls = modelActions.getIntermediateWallsByPerimeter(perimeterId) + + for (const wall of walls) { + const dist = distanceToLineSegment(point, wall.geometry.centerLine) + if (dist < SNAP_TOLERANCE) { + const centerLine = wall.geometry.centerLine + const lineLength = Math.sqrt( + (centerLine.end[0] - centerLine.start[0]) ** 2 + (centerLine.end[1] - centerLine.start[1]) ** 2 + ) + const distToStart = Math.sqrt((point[0] - centerLine.start[0]) ** 2 + (point[1] - centerLine.start[1]) ** 2) + const distToEnd = Math.sqrt((point[0] - centerLine.end[0]) ** 2 + (point[1] - centerLine.end[1]) ** 2) + + if (distToStart > SNAP_TOLERANCE && distToEnd > SNAP_TOLERANCE && lineLength > SNAP_TOLERANCE * 2) { + return { wallId: wall.id } + } + } + } + return null + } + + private calculateParametricPosition(point: Vec2, line: LineSegment2D): number { + const lineVec = newVec2(line.end[0] - line.start[0], line.end[1] - line.start[1]) + const pointVec = newVec2(point[0] - line.start[0], point[1] - line.start[1]) + + const lineLengthSq = lineVec[0] * lineVec[0] + lineVec[1] * lineVec[1] + if (lineLengthSq === 0) return 0 + + const dot = pointVec[0] * lineVec[0] + pointVec[1] * lineVec[1] + return Math.max(0, Math.min(1, dot / lineLengthSq)) + } +} diff --git a/src/editor/tools/shared/polyline/BasePolylineTool.ts b/src/editor/tools/shared/polyline/BasePolylineTool.ts new file mode 100644 index 000000000..2e0f74ce9 --- /dev/null +++ b/src/editor/tools/shared/polyline/BasePolylineTool.ts @@ -0,0 +1,292 @@ +import { type SnapResult, type SnappingContext, SnappingService } from '@/editor/canvas/services/SnappingService' +import type { LengthInputPosition } from '@/editor/canvas/services/length-input' +import { activateLengthInput, deactivateLengthInput } from '@/editor/canvas/services/length-input' +import { viewportActions } from '@/editor/canvas/state/viewportStore' +import { BaseTool } from '@/editor/tools/system/BaseTool' +import type { ToolSystem } from '@/editor/tools/system/ToolSystem' +import type { CursorStyle, EditorEvent } from '@/editor/tools/system/types' +import { + type Length, + type LineSegment2D, + type Vec2, + ZERO_VEC2, + direction, + scaleAddVec2, + wouldPolygonSelfIntersect +} from '@/shared/geometry' + +export interface PolylineValidationContext { + existingWalls: { centerLine: LineSegment2D }[] +} + +export interface PolylineToolStateBase { + points: Vec2[] + pointer: Vec2 + snapResult?: SnapResult + snapContext: SnappingContext + isCurrentSegmentValid: boolean + lengthOverride: Length | null + segmentLengthOverrides: (Length | null)[] + validationContext: PolylineValidationContext +} + +export abstract class BasePolylineTool extends BaseTool { + public state: TState + + private readonly snappingService: SnappingService + + protected constructor(toolSystem: ToolSystem, initialState: Omit) { + super(toolSystem) + this.state = { + points: [] as Vec2[], + pointer: ZERO_VEC2, + snapResult: undefined, + isCurrentSegmentValid: true, + lengthOverride: null, + segmentLengthOverrides: [] as (Length | null)[], + validationContext: this.createInitialValidationContext(), + snapContext: this.createBaseSnapContext([]), + ...initialState + } as TState + this.snappingService = new SnappingService() + } + + protected createInitialValidationContext(): PolylineValidationContext { + return { + existingWalls: [] + } + } + + handlePointerDown(event: EditorEvent): boolean { + this.state.pointer = event.worldCoordinates + this.state.snapResult = this.findSnap(event.worldCoordinates) + const snapCoords = this.state.snapResult?.position ?? event.worldCoordinates + + if (!this.state.isCurrentSegmentValid) return true + + const shouldTerminate = this.shouldTerminateAtSnap(this.state.snapResult) + + let pointToAdd = snapCoords + if (this.state.lengthOverride && this.state.points.length > 0) { + const lastPoint = this.state.points[this.state.points.length - 1] + const dir = direction(lastPoint, snapCoords) + pointToAdd = scaleAddVec2(lastPoint, dir, this.state.lengthOverride) + } + + if (this.state.points.length > 0) { + this.state.segmentLengthOverrides.push(this.state.lengthOverride) + } + + this.state.points.push(pointToAdd) + this.updateSnapContext() + this.clearLengthOverride() + this.updateValidation() + + if (this.state.points.length >= 1) { + this.activateLengthInputForNextSegment() + } + + if (shouldTerminate && this.state.points.length >= 2) { + this.complete() + } + + return true + } + + handlePointerMove(event: EditorEvent): boolean { + this.state.pointer = event.worldCoordinates + this.state.snapResult = this.findSnap(event.worldCoordinates) + this.updateValidation() + this.triggerRender() + return true + } + + handleKeyDown(event: KeyboardEvent): boolean { + if (event.key === 'Escape') { + if (this.state.lengthOverride) { + this.clearLengthOverride() + return true + } + if (this.state.points.length > 0) { + this.cancel() + return true + } + return false + } + + if (event.key === 'Enter' && this.state.points.length >= this.getMinimumPointCount()) { + this.complete() + return true + } + + return false + } + + onActivate(): void { + this.resetDrawingState() + this.onToolActivated() + this.updateSnapContext() + } + + onDeactivate(): void { + this.resetDrawingState() + this.onToolDeactivated() + deactivateLengthInput() + } + + getCursor(): CursorStyle { + return 'crosshair' + } + + public cancel(): void { + this.resetDrawingState() + this.onPolylineCancelled() + deactivateLengthInput() + } + + public complete(): void { + if (this.state.points.length < this.getMinimumPointCount()) return + + const points = [...this.state.points] + + try { + this.onPolylineCompleted(points) + } catch (error) { + this.onPolylineCompletionFailed(error) + } + + this.resetDrawingState() + deactivateLengthInput() + } + + public getPreviewPosition(): Vec2 { + const currentPos = this.state.snapResult?.position ?? this.state.pointer + + if (!this.state.lengthOverride || this.state.points.length === 0) { + return currentPos + } + + const lastPoint = this.state.points[this.state.points.length - 1] + const dir = direction(lastPoint, currentPos) + return scaleAddVec2(lastPoint, dir, this.state.lengthOverride) + } + + protected updateSnapContext(): void { + const context = this.createBaseSnapContext(this.state.points) + this.state.snapContext = this.extendSnapContext(context) + this.triggerRender() + } + + protected createBaseSnapContext(points: readonly Vec2[]): SnappingContext { + const referenceLineSegments: LineSegment2D[] = [] + for (let i = 1; i < points.length; i += 1) { + referenceLineSegments.push({ start: points[i - 1], end: points[i] }) + } + + const referencePoint = points.length > 0 ? points[points.length - 1] : undefined + + const snapPoints: Vec2[] = [] + + return { + snapPoints, + alignPoints: [...points], + referencePoint, + referenceLineSegments + } + } + + protected extendSnapContext(context: SnappingContext): SnappingContext { + return context + } + + public getMinimumPointCount(): number { + return 2 + } + + protected shouldTerminateAtSnap(_snapResult: SnapResult | undefined): boolean { + return false + } + + protected abstract onPolylineCompleted(points: Vec2[]): void + + protected onPolylineCancelled(): void {} + + protected onPolylineCompletionFailed(error: unknown): void { + console.error('Failed to create polyline:', error) + } + + protected onToolActivated(): void {} + + protected onToolDeactivated(): void {} + + private findSnap(target: Vec2): SnapResult | undefined { + const result = this.snappingService.findSnapResult(target, this.state.snapContext) + return result ?? undefined + } + + private updateValidation(): void { + if (this.state.points.length === 0) { + this.state.isCurrentSegmentValid = true + return + } + + const currentPos = this.state.snapResult?.position ?? this.state.pointer + this.state.isCurrentSegmentValid = !wouldPolygonSelfIntersect(this.state.points, currentPos) + } + + public setLengthOverride(length: Length | null): void { + this.state.lengthOverride = length + this.triggerRender() + } + + public clearLengthOverride(): void { + this.state.lengthOverride = null + this.triggerRender() + } + + private activateLengthInputForNextSegment(): void { + if (this.state.points.length === 0) return + + activateLengthInput({ + position: this.getLengthInputPosition(), + placeholder: this.getLengthInputPlaceholder(), + onCommit: (length: Length) => { + this.setLengthOverride(length) + }, + onCancel: () => { + this.clearLengthOverride() + } + }) + } + + private getLengthInputPlaceholder(): string { + return 'Enter length...' + } + + private getLengthInputPosition(): LengthInputPosition { + const { worldToStage } = viewportActions() + + if (this.state.points.length === 0) { + return { x: 400, y: 300 } + } + + const lastPoint = this.state.points[this.state.points.length - 1] + const stageCoords = worldToStage(lastPoint) + + return { + x: stageCoords[0] + 20, + y: stageCoords[1] - 30 + } + } + + private resetDrawingState(): void { + this.state.points = [] + this.state.pointer = ZERO_VEC2 + this.state.snapResult = undefined + this.state.isCurrentSegmentValid = true + this.state.lengthOverride = null + this.state.segmentLengthOverrides = [] + this.state.validationContext = this.createInitialValidationContext() + this.updateSnapContext() + } +} From ae05b90188a62dd67e7425a6b960e9427822b12d Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Thu, 26 Mar 2026 12:59:41 +0100 Subject: [PATCH 07/21] Snapping service refactor --- .../canvas/components/SnappingLines.tsx | 10 +- .../canvas/services/SnappingService.test.ts | 422 ++++++++---------- src/editor/canvas/services/SnappingService.ts | 394 +++++++++------- src/editor/tools/basic/movement/MoveTool.ts | 4 +- .../FloorAreaMovementBehavior.test.ts | 4 +- .../behaviors/FloorAreaMovementBehavior.ts | 63 +-- .../FloorOpeningMovementBehavior.test.ts | 4 +- .../behaviors/FloorOpeningMovementBehavior.ts | 63 +-- .../PerimeterCornerMovementBehavior.ts | 64 +-- .../behaviors/PerimeterMovementBehavior.ts | 50 ++- .../behaviors/PolygonMovementBehavior.ts | 24 +- .../behaviors/RoofMovementBehavior.ts | 43 +- src/editor/tools/basic/movement/types.ts | 2 - .../floors/add-area/FloorAreaTool.test.ts | 46 +- .../add-opening/FloorOpeningTool.test.ts | 50 +-- .../floors/shared/BaseFloorPolygonTool.ts | 27 +- .../add/IntermediateWallTool.ts | 27 +- src/editor/tools/roofs/RoofTool.ts | 17 +- .../tools/shared/polygon/BasePolygonTool.ts | 91 ++-- .../polygon/PolygonToolOverlay.test.tsx | 20 +- .../tools/shared/polyline/BasePolylineTool.ts | 83 ++-- 21 files changed, 734 insertions(+), 774 deletions(-) diff --git a/src/editor/canvas/components/SnappingLines.tsx b/src/editor/canvas/components/SnappingLines.tsx index f2dbd8fa9..afbdfe768 100644 --- a/src/editor/canvas/components/SnappingLines.tsx +++ b/src/editor/canvas/components/SnappingLines.tsx @@ -6,11 +6,11 @@ import type { Line2D } from '@/shared/geometry' import { eqVec2, newVec2 } from '@/shared/geometry' interface SnappingLinesProps { - snapResult?: SnapResult | null - snapResults?: SnapResult[] + snapResult?: SnapResult | null + snapResults?: SnapResult[] } -function deduplicateLines(lines: Line2D[]): Line2D[] { +function deduplicateLines(lines: readonly Line2D[]): Line2D[] { const seen = new Set() return lines.filter(line => { const key = `${line.point[0]},${line.point[1]},${line.direction[0]},${line.direction[1]}` @@ -25,11 +25,11 @@ export function SnappingLines({ snapResult, snapResults }: SnappingLinesProps): const stageWidth = useStageWidth() const stageHeight = useStageHeight() - const allResults: SnapResult[] = [] + const allResults: SnapResult[] = [] if (snapResult) allResults.push(snapResult) if (snapResults) allResults.push(...snapResults) - const allLines = deduplicateLines(allResults.flatMap(r => r.lines ?? [])) + const allLines = deduplicateLines(allResults.flatMap(r => (r.lines ? [...r.lines] : []))) if (allLines.length === 0) { return null diff --git a/src/editor/canvas/services/SnappingService.test.ts b/src/editor/canvas/services/SnappingService.test.ts index a079fbf02..f4d62df9c 100644 --- a/src/editor/canvas/services/SnappingService.test.ts +++ b/src/editor/canvas/services/SnappingService.test.ts @@ -1,355 +1,281 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { newVec2 } from '@/shared/geometry' -import { type LineSegment2D, ZERO_VEC2, newVec2 } from '@/shared/geometry' - -import { type SnapConfig, type SnappingContext, SnappingService } from './SnappingService' +import { type SnapCandidate, type SnappingContext, SnappingService } from './SnappingService' describe('SnappingService', () => { - let service: SnappingService + let service: SnappingService beforeEach(() => { - service = new SnappingService() + service = new SnappingService({ candidates: [] }) }) - describe('Constructor and Configuration', () => { - it('should create service with default config', () => { - const defaultService = new SnappingService() - expect(defaultService).toBeInstanceOf(SnappingService) + describe('Constructor', () => { + it('should create service with empty candidates', () => { + expect(service).toBeInstanceOf(SnappingService) + }) + + it('should always include origin candidates', () => { + const target = newVec2(10, 10) + const result = service.findSnapResult(target) + expect(result).not.toBeNull() + expect(result?.meta).toBe('origin') + }) + + it('should create service with default distances', () => { + const context: SnappingContext = { candidates: [] } + const svc = new SnappingService(context) + expect(svc).toBeInstanceOf(SnappingService) }) - it('should create service with custom config', () => { - const customConfig: Partial = { - pointSnapDistance: 300, - lineSnapDistance: 150, - minDistance: 75 + it('should create service with custom distances', () => { + const context: SnappingContext = { + candidates: [], + defaultPointDistance: 300, + defaultLineDistance: 150 } - const customService = new SnappingService(customConfig) - expect(customService).toBeInstanceOf(SnappingService) + const svc = new SnappingService(context) + expect(svc).toBeInstanceOf(SnappingService) }) }) describe('Point Snapping', () => { - it('should snap to nearby point within snap distance', () => { - const point1 = newVec2(100, 100) - const point2 = newVec2(300, 300) - const context: SnappingContext = { - snapPoints: [point1, point2] - } + it('should snap to nearby snap point', () => { + const point = newVec2(100, 100) + const candidates: SnapCandidate[] = [{ type: 'point', position: point, mode: 'snap' }] + const svc = new SnappingService({ candidates }) - // Target point close to point1 (within default 200mm snap distance) const target = newVec2(150, 120) - const result = service.findSnapResult(target, context) + const result = svc.findSnapResult(target) expect(result).not.toBeNull() - expect(result?.position).toEqual(point1) + expect(result?.position).toEqual(point) + expect(result?.type).toBe('snap') }) - it('should not snap to point outside snap distance', () => { - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1] - } + it('should not snap to point outside default distance', () => { + const point = newVec2(100, 100) + const candidates: SnapCandidate[] = [{ type: 'point', position: point, mode: 'snap' }] + const svc = new SnappingService({ candidates }) - // Target point far from point1 (outside 200mm snap distance) const target = newVec2(500, 500) - const result = service.findSnapResult(target, context) + const result = svc.findSnapResult(target) expect(result).toBeNull() }) - it('should snap to closest point when multiple points are nearby', () => { + it('should snap to closest point when multiple are nearby', () => { const point1 = newVec2(100, 100) const point2 = newVec2(150, 150) - const context: SnappingContext = { - snapPoints: [point1, point2] - } + const candidates: SnapCandidate[] = [ + { type: 'point', position: point1, mode: 'snap' }, + { type: 'point', position: point2, mode: 'snap' } + ] + const svc = new SnappingService({ candidates }) - // Target closer to point1 const target = newVec2(120, 110) - const result = service.findSnapResult(target, context) + const result = svc.findSnapResult(target) expect(result).not.toBeNull() expect(result?.position).toBe(point1) }) }) - describe('Line Snapping', () => { - it('should snap to horizontal line through point', () => { - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1] - } + describe('Align Point Snapping', () => { + it('should snap to horizontal line through align point', () => { + const point = newVec2(100, 100) + const candidates: SnapCandidate[] = [{ type: 'point', position: point, mode: 'align' }] + const svc = new SnappingService({ candidates }) - // Target point near horizontal line through point1, but far enough from point to avoid point snapping - const target = newVec2(400, 110) // 10mm away from horizontal line, 300+ from point - const result = service.findSnapResult(target, context) + const target = newVec2(400, 110) + const result = svc.findSnapResult(target) expect(result).not.toBeNull() - expect(result?.position[1]).toBe(100) // Should snap to y=100 (horizontal line) - expect(result?.position[0]).toBe(400) // X should remain the same - expect(result?.lines).toHaveLength(1) + expect(result?.position[1]).toBe(100) + expect(result?.position[0]).toBe(400) + expect(result?.type).toBe('align') }) - it('should snap to vertical line through point', () => { - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1] - } + it('should snap to vertical line through align point', () => { + const point = newVec2(100, 100) + const candidates: SnapCandidate[] = [{ type: 'point', position: point, mode: 'align' }] + const svc = new SnappingService({ candidates }) - // Target point near vertical line through point1, but far enough from point to avoid point snapping - const target = newVec2(110, 400) // 10mm away from vertical line, 300+ from point - const result = service.findSnapResult(target, context) + const target = newVec2(110, 400) + const result = svc.findSnapResult(target) expect(result).not.toBeNull() - expect(result?.position[0]).toBe(100) // Should snap to x=100 (vertical line) - expect(result?.position[1]).toBe(400) // Y should remain the same - expect(result?.lines).toHaveLength(1) - }) - - it('should not snap to line outside line snap distance', () => { - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1] - } - - // Target point far from any line (outside 100mm line snap distance) - const target = newVec2(250, 250) - const result = service.findSnapResult(target, context) - - expect(result).toBeNull() + expect(result?.position[0]).toBe(100) + expect(result?.position[1]).toBe(400) + expect(result?.type).toBe('align') }) + }) - it('should snap to reference point lines when provided', () => { - const context: SnappingContext = { - snapPoints: [], - referencePoint: newVec2(100, 100) - } + describe('Line Snapping', () => { + it('should snap to infinite line', () => { + const candidates: SnapCandidate[] = [ + { + type: 'line', + line: { point: newVec2(100, 100), direction: newVec2(1, 0) } + } + ] + const svc = new SnappingService({ candidates }) - // Target near horizontal line through reference point - const target = newVec2(200, 110) - const result = service.findSnapResult(target, context) + const target = newVec2(300, 110) + const result = svc.findSnapResult(target) expect(result).not.toBeNull() expect(result?.position[1]).toBe(100) - expect(result?.position[0]).toBe(200) + expect(result?.position[0]).toBe(300) }) - it('should respect minimum distance from reference point', () => { - const context: SnappingContext = { - snapPoints: [], // No points to avoid point snapping - referencePoint: newVec2(100, 100) - } - - // Target that would snap to horizontal line through reference point - // but is too close (within 50mm default minDistance) - const target = newVec2(130, 105) // 5mm from horizontal line, 31mm from reference point - const result = service.findSnapResult(target, context) + it('should snap to line segment', () => { + const candidates: SnapCandidate[] = [ + { + type: 'segment', + segment: { start: newVec2(100, 100), end: newVec2(200, 100) } + } + ] + const svc = new SnappingService({ candidates }) - // Should not snap because projected position would be too close to reference point - expect(result).toBeNull() - }) - }) - - describe('Line Wall Snapping', () => { - it('should snap to extension line of perimeter wall', () => { - const wall: LineSegment2D = { - start: newVec2(100, 100), - end: newVec2(200, 100) - } - const context: SnappingContext = { - snapPoints: [], - referenceLineSegments: [wall] - } - - // Target on extension of horizontal wall - const target = newVec2(300, 110) - const result = service.findSnapResult(target, context) + const target = newVec2(150, 110) + const result = svc.findSnapResult(target) expect(result).not.toBeNull() - expect(result?.position[1]).toBe(100) // Should snap to extension line - expect(result?.position[0]).toBe(300) // X should remain + expect(result?.position[1]).toBe(100) + expect(result?.position[0]).toBe(150) }) - it('should snap to perpendicular line of perimeter wall', () => { - const wall: LineSegment2D = { - start: newVec2(100, 100), - end: newVec2(200, 100) - } - const context: SnappingContext = { - snapPoints: [], - referenceLineSegments: [wall] - } + it('should not snap to segment when projected point is outside segment', () => { + const candidates: SnapCandidate[] = [ + { + type: 'segment', + segment: { start: newVec2(100, 100), end: newVec2(200, 100) } + } + ] + const svc = new SnappingService({ candidates, defaultLineDistance: 10 }) - // Target near perpendicular line from wall start - const target = newVec2(110, 200) - const result = service.findSnapResult(target, context) + const target = newVec2(300, 105) + const result = svc.findSnapResult(target) - expect(result).not.toBeNull() - expect(result?.position[0]).toBe(100) // Should snap to perpendicular at start point - expect(result?.position[1]).toBe(200) + expect(result).toBeNull() }) }) describe('Intersection Snapping', () => { it('should snap to intersection of two lines', () => { - const point1 = newVec2(1000, 1000) // Creates horizontal and vertical lines - const point2 = newVec2(2000, 2000) // Creates horizontal and vertical lines - const context: SnappingContext = { - snapPoints: [point1, point2] - } + const candidates: SnapCandidate[] = [ + { type: 'point', position: newVec2(1000, 1000), mode: 'align' }, + { type: 'point', position: newVec2(2000, 2000), mode: 'align' } + ] + const svc = new SnappingService({ candidates }) - // Target near intersection of vertical line through point1 and horizontal line through point2 - // Make sure it's closer to the intersection than to either individual point - const target = newVec2(1005, 1995) // Close to intersection (1000, 2000) - const result = service.findSnapResult(target, context) + const target = newVec2(1005, 1995) + const result = svc.findSnapResult(target) expect(result).not.toBeNull() - expect(result?.lines?.length).toBe(2) // Should indicate intersection of two lines - expect(result?.position[0]).toBe(1000) // Intersection x from point1's vertical line - expect(result?.position[1]).toBe(2000) // Intersection y from point2's horizontal line - }) - - it('should prefer intersection over single line snap when both are available', () => { - const point1 = newVec2(1000, 1000) - const point2 = newVec2(2000, 2000) - const context: SnappingContext = { - snapPoints: [point1, point2] - } - - // Target equidistant from single line and intersection - const target = newVec2(1000, 1995) - const result = service.findSnapResult(target, context) - - expect(result).not.toBeNull() - expect(result?.lines?.length).toBe(2) // Should indicate intersection of two lines + expect(result?.lines?.length).toBe(2) expect(result?.position[0]).toBe(1000) expect(result?.position[1]).toBe(2000) }) }) - describe('Custom Configuration', () => { - it('should use custom point snap distance', () => { - const customService = new SnappingService({ - pointSnapDistance: 10, // Very small snap distance - lineSnapDistance: 5 // Also very small line snap distance - }) + describe('Reference Point', () => { + it('should filter results too close to reference point', () => { + const candidates: SnapCandidate[] = [{ type: 'point', position: newVec2(100, 100), mode: 'align' }] + const svc = new SnappingService({ candidates }) + svc.referencePoint = newVec2(100, 100) + svc.referenceMinDistance = 50 - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1] - } - - // Target outside both point and line snap distances - const target = newVec2(200, 200) // Far from point and any lines - const result = customService.findSnapResult(target, context) + const target = newVec2(130, 105) + const result = svc.findSnapResult(target) expect(result).toBeNull() }) - it('should use custom line snap distance', () => { - const customService = new SnappingService({ - pointSnapDistance: 10, // Very small point snap distance - lineSnapDistance: 5 // Very small line snap distance - }) - - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1] - } + it('should allow results far enough from reference point', () => { + const candidates: SnapCandidate[] = [{ type: 'point', position: newVec2(100, 100), mode: 'align' }] + const svc = new SnappingService({ candidates }) + svc.referencePoint = newVec2(100, 100) + svc.referenceMinDistance = 50 - // Target far enough from point but within default line distance, outside custom line distance - const target = newVec2(400, 130) // Far from point, 30mm from horizontal line - const result = customService.findSnapResult(target, context) + const target = newVec2(400, 110) + const result = svc.findSnapResult(target) - expect(result).toBeNull() + expect(result).not.toBeNull() + expect(result?.position[1]).toBe(100) }) - it('should use custom minimum distance', () => { - const customService = new SnappingService({ - pointSnapDistance: 10, // Very small point snap to avoid point snapping - lineSnapDistance: 50, // Enable line snapping - minDistance: 80 // Large minimum distance - }) + it('should not filter when reference point is null', () => { + const candidates: SnapCandidate[] = [{ type: 'point', position: newVec2(100, 100), mode: 'snap' }] + const svc = new SnappingService({ candidates }) + svc.referencePoint = null - const context: SnappingContext = { - snapPoints: [], // No points - use reference point to create lines - referencePoint: newVec2(100, 100) - } + const target = newVec2(110, 105) + const result = svc.findSnapResult(target) - // Target that would snap to horizontal line through reference point at (100, 100) - // Projected position would be (150, 100), which is 50mm from reference point - // Since minDistance is 80mm, this should be rejected - const target = newVec2(150, 110) // 10mm from horizontal line, projected to (150, 100) - const result = customService.findSnapResult(target, context) - - expect(result).toBeNull() + expect(result).not.toBeNull() }) }) - describe('Edge Cases', () => { - it('should snap to origin even if no points are in context', () => { - const context: SnappingContext = { - snapPoints: [] + describe('Priority', () => { + it('should prefer higher priority candidates', () => { + const lowPriority: SnapCandidate = { type: 'point', position: newVec2(100, 100), mode: 'snap', priority: 0 } + const highPriority: SnapCandidate = { + type: 'point', + position: newVec2(200, 200), + mode: 'snap', + priority: 1 } + const candidates: SnapCandidate[] = [lowPriority, highPriority] + const svc = new SnappingService({ candidates }) - const target = newVec2(50, 50) - const result = service.findSnapResult(target, context) + const target = newVec2(180, 180) + const result = svc.findSnapResult(target) expect(result).not.toBeNull() - expect(result?.position).toEqual(ZERO_VEC2) - expect(result?.lines).toHaveLength(2) + expect(result?.position).toEqual(newVec2(200, 200)) }) + }) - it('should handle single point', () => { - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1] - } - - const target = newVec2(120, 110) - const result = service.findSnapResult(target, context) + describe('addSnapCandidate', () => { + it('should dynamically add candidates', () => { + const svc = new SnappingService({ candidates: [] }) - expect(result).not.toBeNull() - expect(result?.position).toBe(point1) - }) + svc.addSnapCandidate({ type: 'point', position: newVec2(500, 500), mode: 'snap' }) - it('should handle undefined reference point gracefully', () => { - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1], - referencePoint: undefined - } - - const target = newVec2(120, 110) - const result = service.findSnapResult(target, context) + const target = newVec2(510, 505) + const result = svc.findSnapResult(target) expect(result).not.toBeNull() + expect(result?.position).toEqual(newVec2(500, 500)) }) - it('should handle undefined reference line walls gracefully', () => { - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1], - referenceLineSegments: undefined - } + it('should expand align points into horizontal and vertical lines', () => { + const svc = new SnappingService({ candidates: [] }) - const target = newVec2(120, 110) - const result = service.findSnapResult(target, context) + svc.addSnapCandidate({ type: 'point', position: newVec2(300, 300), mode: 'align' }) - expect(result).not.toBeNull() + const horizontalResult = svc.findSnapResult(newVec2(500, 310)) + expect(horizontalResult).not.toBeNull() + expect(horizontalResult?.position[1]).toBe(300) + + const verticalResult = svc.findSnapResult(newVec2(310, 500)) + expect(verticalResult).not.toBeNull() + expect(verticalResult?.position[0]).toBe(300) }) + }) - it('should handle empty reference line walls array', () => { - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1], - referenceLineSegments: [] - } + describe('minDistance', () => { + it('should use per-candidate minDistance when set', () => { + const candidates: SnapCandidate[] = [ + { type: 'line', line: { point: newVec2(0, 100), direction: newVec2(1, 0) }, minDistance: 10 } + ] + const svc = new SnappingService({ candidates, defaultLineDistance: 200 }) - const target = newVec2(120, 110) - const result = service.findSnapResult(target, context) + const target = newVec2(200, 115) + const result = svc.findSnapResult(target) - expect(result).not.toBeNull() + expect(result).toBeNull() }) }) }) diff --git a/src/editor/canvas/services/SnappingService.ts b/src/editor/canvas/services/SnappingService.ts index 9033ac9b6..512ce3b24 100644 --- a/src/editor/canvas/services/SnappingService.ts +++ b/src/editor/canvas/services/SnappingService.ts @@ -5,218 +5,276 @@ import { type Vec2, ZERO_VEC2, distSqrVec2, + distVec2, distanceToInfiniteLine, + dotVec2, + lenSqrVec2, lineFromSegment, lineIntersection, newVec2, - projectPointOntoLine + projectPointOntoLine, + scaleAddVec2, + subVec2 } from '@/shared/geometry' -export interface SnapResult { - position: Vec2 - lines?: Line2D[] // Array of 1 or 2 lines to render (1 for line snap, 2 for intersection) +export interface SnappingContext { + candidates: SnapCandidate[] + defaultPointDistance?: Length + defaultLineDistance?: Length } -// Context for snapping operations -export interface SnappingContext { - snapPoints: Vec2[] - alignPoints?: Vec2[] - referencePoint?: Vec2 - referenceLineSegments?: LineSegment2D[] +export interface SnapResult { + position: Vec2 + distance: Length + lines?: readonly [Line2D] | readonly [Line2D, Line2D] + meta?: T | 'origin' + type: 'snap' | 'align' } -// Snapping configuration -export interface SnapConfig { - pointSnapDistance: Length - lineSnapDistance: Length - minDistance: Length +export interface SnapMeta { + priority?: number + minDistance?: Length + meta?: T | 'origin' } -// Default snapping configuration -export const DEFAULT_SNAP_CONFIG: SnapConfig = { - pointSnapDistance: 200, // 500mm - lineSnapDistance: 100, // 100mm - minDistance: 50 // 50mm +export interface SnapPoint extends SnapMeta { + type: 'point' + position: Vec2 + mode: 'snap' | 'align' } -/** - * Integrated snapping service that handles all snap calculations - * Combines the functionality of the old SnappingEngine and SnappingService - */ -export class SnappingService { - private readonly snapConfig: SnapConfig - - constructor(snapConfig: Partial = {}) { - this.snapConfig = { ...DEFAULT_SNAP_CONFIG, ...snapConfig } - } - - /** - * Find the snap result for a target point - * This is the main function that should be used by all components - * @param target - The point to snap - * @param context - Snapping context with snap points and lines - * @param tolerance - Optional override for both point and line snap distances - */ - findSnapResult(target: Vec2, context: SnappingContext, tolerance?: number): SnapResult | null { - // Step 1: Try point snapping first (highest priority) - const pointSnapResult = this.findPointSnapPosition(target, context, tolerance) - - if (pointSnapResult != null) return pointSnapResult +export interface SnapLine extends SnapMeta { + type: 'line' + line: Line2D +} - // Step 2: Generate snap lines and check for line/intersection snapping - const snapLines = this.generateSnapLines(context) +export interface SnapLineSegment extends SnapMeta { + type: 'segment' + segment: LineSegment2D +} - return this.findLineSnapPosition(target, snapLines, context, tolerance) - } +export type SnapCandidate = SnapPoint | SnapLine | SnapLineSegment - /** - * Find existing points for direct point snapping - */ - private findPointSnapPosition(target: Vec2, context: SnappingContext, tolerance?: number): SnapResult | null { - const snapDistanceSq = (tolerance ?? this.snapConfig.pointSnapDistance) ** 2 - let bestPoint: Vec2 | null = null - let bestDistanceSq = snapDistanceSq - - for (const point of context.snapPoints) { - const targetDistSq = distSqrVec2(target, point) - if (targetDistSq <= bestDistanceSq) { - bestDistanceSq = targetDistSq - bestPoint = point - } - } +interface InternalMeta { + priority: number + isDerived: boolean + minDistance: Length + lines?: readonly [Line2D] | readonly [Line2D, Line2D] +} - return bestPoint != null ? { position: bestPoint } : null +type InternalSnapCandidate = SnapCandidate & InternalMeta + +const PRIORITY_EPS = 0.1 + +const DEFAULT_DISTANCE = 100 + +const DEFAULT_CANDIDATES: InternalSnapCandidate[] = [ + { + type: 'point', + position: ZERO_VEC2, + mode: 'snap', + priority: 0, + meta: 'origin', + isDerived: false, + minDistance: DEFAULT_DISTANCE + }, + { + type: 'line', + line: { + point: ZERO_VEC2, + direction: newVec2(1, 0) + }, + priority: 0, + meta: 'origin', + isDerived: false, + minDistance: DEFAULT_DISTANCE, + lines: [{ point: ZERO_VEC2, direction: newVec2(1, 0) }] + }, + { + type: 'line', + line: { + point: ZERO_VEC2, + direction: newVec2(0, 1) + }, + priority: 0, + meta: 'origin', + isDerived: false, + minDistance: DEFAULT_DISTANCE, + lines: [{ point: ZERO_VEC2, direction: newVec2(0, 1) }] } +] as const - /** - * Generate snap lines for architectural alignment - */ - private generateSnapLines(context: SnappingContext): Line2D[] { - const snapLines: Line2D[] = [] +export class SnappingService { + private readonly candidates: InternalSnapCandidate[] = [] - const allPoints = [ZERO_VEC2, ...context.snapPoints, ...(context.alignPoints ?? [])] + private readonly context: SnappingContext - // 1. Add horizontal and vertical lines through all points - for (const point of allPoints) { - // Horizontal line through point - snapLines.push({ - point, - direction: newVec2(1, 0) - }) + referencePoint: Vec2 | null = null + referenceMinDistance: Length = DEFAULT_DISTANCE - // Vertical line through point - snapLines.push({ - point, - direction: newVec2(0, 1) - }) + constructor(context: SnappingContext) { + this.context = context + for (const candidate of DEFAULT_CANDIDATES) { + this.addSnapCandidateInternal(candidate as InternalSnapCandidate) } - - // 2. Add horizontal and vertical lines through reference point (if any) - if (context.referencePoint != null) { - // Horizontal line through point - snapLines.push({ - point: context.referencePoint, - direction: newVec2(1, 0) - }) - - // Vertical line through point - snapLines.push({ - point: context.referencePoint, - direction: newVec2(0, 1) - }) + for (const candidate of context.candidates) { + this.addSnapCandidate(candidate) } + } - // 3. Add extension and perpendicular lines for reference line walls (if any) - for (const wall of context.referenceLineSegments ?? []) { - const line = lineFromSegment(wall) - - // Extension line (same direction as wall) - snapLines.push({ - point: line.point, - direction: line.direction - }) - - // Perpendicular lines (90 degrees rotated) - snapLines.push({ - point: wall.start, - direction: newVec2(-line.direction[1], line.direction[0]) + addSnapCandidate(candidate: SnapCandidate): void { + if (candidate.type === 'point' && candidate.mode === 'align') { + const { type: _, mode: __, position, ...rest } = candidate + this.addSnapCandidate({ + ...rest, + type: 'line', + line: { + point: position, + direction: newVec2(1, 0) + } }) - snapLines.push({ - point: wall.end, - direction: newVec2(-line.direction[1], line.direction[0]) + this.addSnapCandidate({ + ...rest, + type: 'line', + line: { + point: position, + direction: newVec2(0, 1) + } }) + } else { + const internalCandidate = this.toInternal(candidate) + this.addSnapCandidateInternal(internalCandidate) + if (internalCandidate.type === 'line') { + const otherLines = this.candidates.filter( + (c): c is SnapLine & InternalMeta => c.type === 'line' && !c.isDerived && c !== internalCandidate + ) + for (const existing of otherLines) { + this.addIntersectionSnapCandidates(internalCandidate, existing) + } + } } + } - return snapLines + private toInternal(candidate: SnapCandidate) { + return { + ...candidate, + priority: candidate.priority ?? 0, + isDerived: false, + minDistance: this.getSnapThreshold(candidate), + lines: + candidate.type === 'line' + ? ([candidate.line] as const) + : candidate.type === 'segment' + ? ([lineFromSegment(candidate.segment)] as const) + : undefined + } } - /** - * Find snap position on lines or line intersections - */ - private findLineSnapPosition( - target: Vec2, - snapLines: Line2D[], - context: SnappingContext, - tolerance?: number - ): SnapResult | null { - const lineSnapDistance = tolerance ?? this.snapConfig.lineSnapDistance - const minDistanceSquared = this.snapConfig.minDistance ** 2 - const nearbyLines: { line: Line2D; distance: number; projectedPosition: Vec2 }[] = [] - let closestDist = Infinity - let closestIndex = -1 - - for (const line of snapLines) { - const distance = distanceToInfiniteLine(target, line) - if (distance <= lineSnapDistance) { - const projectedPosition = projectPointOntoLine(target, line) - if ( - context.referencePoint == null || - distSqrVec2(projectedPosition, context.referencePoint) >= minDistanceSquared - ) { - nearbyLines.push({ line, distance, projectedPosition }) - if (distance < closestDist) { - closestDist = distance - closestIndex = nearbyLines.length - 1 - } + findSnapResult(target: Vec2, distanceOverride?: Length): SnapResult | null { + const referenceDistSq = this.referenceMinDistance * this.referenceMinDistance + let priority = Infinity + const results = [] as SnapResult[] + for (const candidate of this.candidates) { + if (candidate.priority !== priority) { + if (results.length > 0) { + return this.getBestSnapResult(results) + } else { + priority = candidate.priority + } + } + const { distance, point } = this.calculateCandidate(target, candidate) + if (distance <= (distanceOverride ?? candidate.minDistance)) { + if (!this.referencePoint || distSqrVec2(point, this.referencePoint) >= referenceDistSq) { + const snapType = 'mode' in candidate ? candidate.mode : candidate.type === 'segment' ? 'snap' : 'align' + results.push({ + position: point, + distance, + lines: candidate.lines, + meta: candidate.meta, + type: snapType + }) } } } + return this.getBestSnapResult(results) + } - if (nearbyLines.length === 0) { - return null - } + private getBestSnapResult(results: SnapResult[]): SnapResult | null { + results.sort((a, b) => a.distance - b.distance) + return results[0] ?? null + } - if (nearbyLines.length === 1) { - return { lines: [nearbyLines[0].line], position: nearbyLines[0].projectedPosition } - } + private calculateCandidate(target: Vec2, candidate: SnapCandidate): { distance: Length; point: Vec2 } { + switch (candidate.type) { + case 'point': + return { distance: distVec2(target, candidate.position), point: candidate.position } + case 'line': + return { + distance: distanceToInfiniteLine(target, candidate.line), + point: projectPointOntoLine(target, candidate.line) + } + case 'segment': { + const lineVector = subVec2(candidate.segment.end, candidate.segment.start) + const pointVector = subVec2(target, candidate.segment.start) - // Check for intersections between the closest line and other nearby lines - const lineSnapDistSq = lineSnapDistance ** 2 + const lineLengthSquared = lenSqrVec2(lineVector) + if (lineLengthSquared === 0) { + return { distance: distVec2(target, candidate.segment.start), point: candidate.segment.start } + } - for (let i = 0; i < nearbyLines.length - 1; i++) { - for (let j = i + 1; j < nearbyLines.length; j++) { - const line1 = nearbyLines[i] - const line2 = nearbyLines[j] + const t = Math.max(0, Math.min(1, dotVec2(pointVector, lineVector) / lineLengthSquared)) + const closest = scaleAddVec2(candidate.segment.start, lineVector, t) - const intersection = lineIntersection(line1.line, line2.line) - if (intersection == null) continue + return { distance: distVec2(target, closest), point: closest } + } + } + } - if (distSqrVec2(target, intersection) > lineSnapDistSq) continue + private getSnapThreshold(candidate: SnapCandidate): Length { + if (candidate.minDistance != null) { + return candidate.minDistance + } + switch (candidate.type) { + case 'point': + return this.context.defaultPointDistance ?? DEFAULT_DISTANCE + case 'line': + case 'segment': + return this.context.defaultLineDistance ?? DEFAULT_DISTANCE + } + } - if (context.referencePoint == null || distSqrVec2(intersection, context.referencePoint) >= minDistanceSquared) { - return { position: intersection, lines: [line1.line, line2.line] } - } - } + private addSnapCandidateInternal(candidate: InternalSnapCandidate): void { + const index = this.candidates.findIndex(c => c.priority < candidate.priority) + if (index !== -1) { + this.candidates.splice(index, 0, candidate) + } else { + this.candidates.push(candidate) } + } - // No intersection found, return closest line snap - const bestSnap = nearbyLines[closestIndex] - return { - position: bestSnap.projectedPosition, - lines: [bestSnap.line] + private addIntersectionSnapCandidates(line1: SnapLine & InternalMeta, line2: SnapLine & InternalMeta): void { + const intersection = lineIntersection(line1.line, line2.line) + if (intersection) { + const meta = + line1.priority > line2.priority + ? line1.meta + : line2.priority > line1.priority + ? line2.meta + : line1.meta === line2.meta + ? line1.meta + : undefined + const minDistance = Math.min(line1.minDistance, line2.minDistance) + const priority = Math.max(line1.priority, line2.priority) + PRIORITY_EPS + this.addSnapCandidateInternal({ + type: 'point', + position: intersection, + mode: 'snap', + priority, + isDerived: true, + minDistance, + meta, + lines: [line1.line, line2.line] + }) } } } - -// Create a default singleton instance -export const defaultSnappingService = new SnappingService() diff --git a/src/editor/tools/basic/movement/MoveTool.ts b/src/editor/tools/basic/movement/MoveTool.ts index bef7947de..38ccb144e 100644 --- a/src/editor/tools/basic/movement/MoveTool.ts +++ b/src/editor/tools/basic/movement/MoveTool.ts @@ -1,6 +1,5 @@ import type { SelectableId } from '@/building/model/ids' import { getModelActions } from '@/building/store' -import { defaultSnappingService } from '@/editor/canvas/services/SnappingService' import { findEditorEntityAt } from '@/editor/canvas/services/editorHitTesting' import type { LengthInputConfig } from '@/editor/canvas/services/length-input' import { activateLengthInput, deactivateLengthInput } from '@/editor/canvas/services/length-input' @@ -86,8 +85,7 @@ export class MoveTool extends BaseTool implements ToolImplementation { entityId: hitResult.entityId, parentIds: hitResult.parentIds, entity, - store, - snappingService: defaultSnappingService + store } // Initialize pointer state and movement state diff --git a/src/editor/tools/basic/movement/behaviors/FloorAreaMovementBehavior.test.ts b/src/editor/tools/basic/movement/behaviors/FloorAreaMovementBehavior.test.ts index 6dabe4e18..957b29156 100644 --- a/src/editor/tools/basic/movement/behaviors/FloorAreaMovementBehavior.test.ts +++ b/src/editor/tools/basic/movement/behaviors/FloorAreaMovementBehavior.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest' import type { FloorArea } from '@/building/model' import type { StoreActions } from '@/building/store/types' -import { SnappingService } from '@/editor/canvas/services/SnappingService' import { type Vec2, copyVec2, newVec2 } from '@/shared/geometry' import { FloorAreaMovementBehavior, type FloorAreaMovementState } from './FloorAreaMovementBehavior' @@ -50,8 +49,7 @@ describe('FloorAreaMovementBehavior', () => { entityId: floorArea.id, parentIds: [], entity: entityContext, - store: storeActions, - snappingService: new SnappingService() + store: storeActions } satisfies Parameters[1] it('commits movement by translating all points', () => { diff --git a/src/editor/tools/basic/movement/behaviors/FloorAreaMovementBehavior.ts b/src/editor/tools/basic/movement/behaviors/FloorAreaMovementBehavior.ts index 40f28e117..73886e610 100644 --- a/src/editor/tools/basic/movement/behaviors/FloorAreaMovementBehavior.ts +++ b/src/editor/tools/basic/movement/behaviors/FloorAreaMovementBehavior.ts @@ -2,7 +2,7 @@ import type { FloorArea, FloorOpening, PerimeterWithGeometry } from '@/building/ import type { SelectableId } from '@/building/model/ids' import { isFloorAreaId } from '@/building/model/ids' import type { StoreActions } from '@/building/store/types' -import type { SnappingContext } from '@/editor/canvas/services/SnappingService' +import { type SnapCandidate, SnappingService } from '@/editor/canvas/services/SnappingService' import type { MovementContext } from '@/editor/tools/basic/movement/types' import { type Polygon2D, type Vec2, polygonEdges } from '@/shared/geometry' @@ -19,27 +19,6 @@ export interface FloorAreaEntityContext extends PolygonEntityContext { export type FloorAreaMovementState = PolygonMovementState -function buildSnapContext( - perimeters: PerimeterWithGeometry[], - otherAreas: FloorArea[], - openings: FloorOpening[] -): SnappingContext { - const perimeterPoints = perimeters.flatMap(perimeter => perimeter.innerPolygon.points) - const perimeterSegments = perimeters.flatMap(perimeter => [...polygonEdges(perimeter.innerPolygon)]) - - const areaPoints = otherAreas.flatMap(area => area.area.points) - const areaSegments = otherAreas.flatMap(area => createPolygonSegments(area.area.points)) - - const openingPoints = openings.flatMap(opening => opening.area.points) - const openingSegments = openings.flatMap(opening => createPolygonSegments(opening.area.points)) - - return { - snapPoints: [...perimeterPoints, ...areaPoints, ...openingPoints], - alignPoints: [...perimeterPoints, ...areaPoints, ...openingPoints], - referenceLineSegments: [...perimeterSegments, ...areaSegments, ...openingSegments] - } -} - export class FloorAreaMovementBehavior extends PolygonMovementBehavior { getEntity(entityId: SelectableId, _parentIds: SelectableId[], store: StoreActions): FloorAreaEntityContext { if (!isFloorAreaId(entityId)) { @@ -55,10 +34,44 @@ export class FloorAreaMovementBehavior extends PolygonMovementBehavior area.id !== floorArea.id) const openings = store.getFloorOpeningsByStorey(floorArea.storeyId) - return { - floorArea, - snapContext: buildSnapContext(perimeters, otherAreas, openings) + const snapCandidates = this.buildSnapCandidates(perimeters, otherAreas, openings) + const snapService = new SnappingService({ candidates: snapCandidates }) + + return { floorArea, snapService } + } + + private buildSnapCandidates( + perimeters: PerimeterWithGeometry[], + otherAreas: FloorArea[], + openings: FloorOpening[] + ): SnapCandidate[] { + const candidates: SnapCandidate[] = [] + + const perimeterPoints = perimeters.flatMap(perimeter => perimeter.innerPolygon.points) + const perimeterSegments = perimeters.flatMap(perimeter => [...polygonEdges(perimeter.innerPolygon)]) + + const areaPoints = otherAreas.flatMap(area => area.area.points) + const areaSegments = otherAreas.flatMap(area => createPolygonSegments(area.area.points)) + + const openingPoints = openings.flatMap(opening => opening.area.points) + const openingSegments = openings.flatMap(opening => createPolygonSegments(opening.area.points)) + + const allPoints = [...perimeterPoints, ...areaPoints, ...openingPoints] + for (const point of allPoints) { + candidates.push({ type: 'point', position: point, mode: 'snap' }) } + + const alignPoints = [...perimeterPoints, ...areaPoints, ...openingPoints] + for (const point of alignPoints) { + candidates.push({ type: 'point', position: point, mode: 'align' }) + } + + const allSegments = [...perimeterSegments, ...areaSegments, ...openingSegments] + for (const segment of allSegments) { + candidates.push({ type: 'segment', segment }) + } + + return candidates } protected getPolygonPoints(context: MovementContext): readonly Vec2[] { diff --git a/src/editor/tools/basic/movement/behaviors/FloorOpeningMovementBehavior.test.ts b/src/editor/tools/basic/movement/behaviors/FloorOpeningMovementBehavior.test.ts index f52b9fc92..99140ec9b 100644 --- a/src/editor/tools/basic/movement/behaviors/FloorOpeningMovementBehavior.test.ts +++ b/src/editor/tools/basic/movement/behaviors/FloorOpeningMovementBehavior.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest' import type { FloorOpening } from '@/building/model' import type { StoreActions } from '@/building/store/types' -import { SnappingService } from '@/editor/canvas/services/SnappingService' import { type Vec2, copyVec2, newVec2 } from '@/shared/geometry' import { FloorOpeningMovementBehavior, type FloorOpeningMovementState } from './FloorOpeningMovementBehavior' @@ -50,8 +49,7 @@ describe('FloorOpeningMovementBehavior', () => { entityId: opening.id, parentIds: [], entity: entityContext, - store: storeActions, - snappingService: new SnappingService() + store: storeActions } satisfies Parameters[1] it('commits movement by updating the floor opening polygon', () => { diff --git a/src/editor/tools/basic/movement/behaviors/FloorOpeningMovementBehavior.ts b/src/editor/tools/basic/movement/behaviors/FloorOpeningMovementBehavior.ts index c539a3885..49d55f656 100644 --- a/src/editor/tools/basic/movement/behaviors/FloorOpeningMovementBehavior.ts +++ b/src/editor/tools/basic/movement/behaviors/FloorOpeningMovementBehavior.ts @@ -2,7 +2,7 @@ import type { FloorArea, FloorOpening, PerimeterWithGeometry } from '@/building/ import type { SelectableId } from '@/building/model/ids' import { isFloorOpeningId } from '@/building/model/ids' import type { StoreActions } from '@/building/store/types' -import type { SnappingContext } from '@/editor/canvas/services/SnappingService' +import { type SnapCandidate, SnappingService } from '@/editor/canvas/services/SnappingService' import type { MovementContext } from '@/editor/tools/basic/movement/types' import { type Polygon2D, type Vec2, polygonEdges } from '@/shared/geometry' @@ -19,27 +19,6 @@ export interface FloorOpeningEntityContext extends PolygonEntityContext { export type FloorOpeningMovementState = PolygonMovementState -function buildSnapContext( - perimeters: PerimeterWithGeometry[], - areas: FloorArea[], - openings: FloorOpening[] -): SnappingContext { - const perimeterPoints = perimeters.flatMap(perimeter => perimeter.innerPolygon.points) - const perimeterSegments = perimeters.flatMap(perimeter => [...polygonEdges(perimeter.innerPolygon)]) - - const areaPoints = areas.flatMap(area => area.area.points) - const areaSegments = areas.flatMap(area => createPolygonSegments(area.area.points)) - - const openingPoints = openings.flatMap(opening => opening.area.points) - const openingSegments = openings.flatMap(opening => createPolygonSegments(opening.area.points)) - - return { - snapPoints: [...perimeterPoints, ...areaPoints, ...openingPoints], - alignPoints: [...perimeterPoints, ...areaPoints, ...openingPoints], - referenceLineSegments: [...perimeterSegments, ...areaSegments, ...openingSegments] - } -} - export class FloorOpeningMovementBehavior extends PolygonMovementBehavior { getEntity(entityId: SelectableId, _parentIds: SelectableId[], store: StoreActions): FloorOpeningEntityContext { if (!isFloorOpeningId(entityId)) { @@ -55,10 +34,44 @@ export class FloorOpeningMovementBehavior extends PolygonMovementBehavior o.id !== opening.id) - return { - opening, - snapContext: buildSnapContext(perimeters, floorAreas, otherOpenings) + const snapCandidates = this.buildSnapCandidates(perimeters, floorAreas, otherOpenings) + const snapService = new SnappingService({ candidates: snapCandidates }) + + return { opening, snapService } + } + + private buildSnapCandidates( + perimeters: PerimeterWithGeometry[], + areas: FloorArea[], + openings: FloorOpening[] + ): SnapCandidate[] { + const candidates: SnapCandidate[] = [] + + const perimeterPoints = perimeters.flatMap(perimeter => perimeter.innerPolygon.points) + const perimeterSegments = perimeters.flatMap(perimeter => [...polygonEdges(perimeter.innerPolygon)]) + + const areaPoints = areas.flatMap(area => area.area.points) + const areaSegments = areas.flatMap(area => createPolygonSegments(area.area.points)) + + const openingPoints = openings.flatMap(opening => opening.area.points) + const openingSegments = openings.flatMap(opening => createPolygonSegments(opening.area.points)) + + const allPoints = [...perimeterPoints, ...areaPoints, ...openingPoints] + for (const point of allPoints) { + candidates.push({ type: 'point', position: point, mode: 'snap' }) } + + const alignPoints = [...perimeterPoints, ...areaPoints, ...openingPoints] + for (const point of alignPoints) { + candidates.push({ type: 'point', position: point, mode: 'align' }) + } + + const allSegments = [...perimeterSegments, ...areaSegments, ...openingSegments] + for (const segment of allSegments) { + candidates.push({ type: 'segment', segment }) + } + + return candidates } protected getPolygonPoints(context: MovementContext): readonly Vec2[] { diff --git a/src/editor/tools/basic/movement/behaviors/PerimeterCornerMovementBehavior.ts b/src/editor/tools/basic/movement/behaviors/PerimeterCornerMovementBehavior.ts index e929631eb..c9199e9d7 100644 --- a/src/editor/tools/basic/movement/behaviors/PerimeterCornerMovementBehavior.ts +++ b/src/editor/tools/basic/movement/behaviors/PerimeterCornerMovementBehavior.ts @@ -2,7 +2,8 @@ import { type WrappedGcs, gcsService } from '@/building/gcs/service' import type { PerimeterCornerWithGeometry } from '@/building/model' import { type PerimeterCornerId, type SelectableId, isPerimeterCornerId } from '@/building/model/ids' import type { StoreActions } from '@/building/store/types' -import type { SnapResult, SnappingContext } from '@/editor/canvas/services/SnappingService' +import type { SnapCandidate, SnapResult } from '@/editor/canvas/services/SnappingService' +import { SnappingService } from '@/editor/canvas/services/SnappingService' import { PerimeterCornerMovementPreview } from '@/editor/tools/basic/movement/previews/PerimeterCornerMovementPreview' import type { MovementBehavior, @@ -10,22 +11,20 @@ import type { MovementState, PointerMovementState } from '@/editor/tools/basic/movement/types' -import { type LineSegment2D, type Vec2, addVec2, subVec2 } from '@/shared/geometry' +import { type Vec2, addVec2, subVec2 } from '@/shared/geometry' -// Corner movement needs access to the wall to update the boundary export interface CornerEntityContext { corner: PerimeterCornerWithGeometry corners: PerimeterCornerWithGeometry[] - cornerIndex: number // Index of the boundary point that corresponds to this corner - snapContext: SnappingContext + cornerIndex: number + snapService: SnappingService gcs: WrappedGcs } -// Corner movement state export interface CornerMovementState extends MovementState { position: Vec2 - movementDelta: Vec2 // The 2D movement delta - snapResult?: SnapResult + movementDelta: Vec2 + snapResult?: SnapResult newBoundary: Vec2[] } @@ -46,18 +45,13 @@ export class PerimeterCornerMovementBehavior implements MovementBehavior c.referencePoint), - referenceLineSegments: snapLines - } + const snapCandidates = this.buildSnapCandidates(corners, cornerIndex) + const snapService = new SnappingService({ candidates: snapCandidates }) const fixedCornerIds = perimeter.cornerIds.filter(c => c !== entityId) const gcs = gcsService.getGcs(fixedCornerIds) @@ -66,7 +60,7 @@ export class PerimeterCornerMovementBehavior implements MovementBehavior ): CornerMovementState { - const { corner, snapContext, gcs } = context.entity + const { corner, snapService, gcs } = context.entity const newPosition = addVec2(corner.referencePoint, pointerState.delta) - const snapResult = context.snappingService.findSnapResult(newPosition, snapContext) + const snapResult = snapService.findSnapResult(newPosition) const finalPosition = snapResult?.position ?? newPosition - // Feed the target position to the GCS solver gcs.updateDrag(finalPosition[0], finalPosition[1]) - // Read solved positions to build the boundary const newBoundary = gcs.getPerimeterBoundary(corner.perimeterId) - - // The actual solved position of the dragged corner const solvedPosition = gcs.getCornerPosition(corner.id) return { @@ -115,9 +105,6 @@ export class PerimeterCornerMovementBehavior implements MovementBehavior): boolean { - // The GCS solver's internal validator already checks self-intersection, - // min wall length, wall consistency, and colinearity. - // If we got a boundary back from the solver, it's valid. return true } @@ -153,17 +140,32 @@ export class PerimeterCornerMovementBehavior implements MovementBehavior[] { + const candidates: SnapCandidate[] = [] + + candidates.push({ + type: 'point', + position: corners[cornerIndex].referencePoint, + mode: 'snap' + }) + + for (const c of corners) { + candidates.push({ + type: 'point', + position: c.referencePoint, + mode: 'align' + }) + } for (let i = 0; i < corners.length; i++) { const nextIndex = (i + 1) % corners.length if (i === cornerIndex || nextIndex === cornerIndex) continue - const start = corners[i].referencePoint - const end = corners[nextIndex].referencePoint - snapLines.push({ start, end }) + candidates.push({ + type: 'segment', + segment: { start: corners[i].referencePoint, end: corners[nextIndex].referencePoint } + }) } - return snapLines + return candidates } } diff --git a/src/editor/tools/basic/movement/behaviors/PerimeterMovementBehavior.ts b/src/editor/tools/basic/movement/behaviors/PerimeterMovementBehavior.ts index f35bdf90a..c497c9027 100644 --- a/src/editor/tools/basic/movement/behaviors/PerimeterMovementBehavior.ts +++ b/src/editor/tools/basic/movement/behaviors/PerimeterMovementBehavior.ts @@ -3,7 +3,7 @@ import type { PerimeterWithGeometry } from '@/building/model' import type { SelectableId } from '@/building/model/ids' import { isPerimeterId } from '@/building/model/ids' import type { StoreActions } from '@/building/store/types' -import type { SnappingContext } from '@/editor/canvas/services/SnappingService' +import { type SnapCandidate, SnappingService } from '@/editor/canvas/services/SnappingService' import type { MovementContext } from '@/editor/tools/basic/movement/types' import { type Vec2, ZERO_VEC2, addVec2, distSqrVec2 } from '@/shared/geometry' import { arePolygonsIntersecting } from '@/shared/geometry/polygon' @@ -47,26 +47,14 @@ export class PerimeterMovementBehavior extends PolygonMovementBehavior s.id === activeStorey) - const lowerStorey = storeyIndex > 0 ? storeys[storeyIndex - 1] : null + const otherPerimeters = store.getPerimetersByStorey(perimeter.storeyId).filter(p => p.id !== entityId) + const lowerStorey = store.getStoreyBelow(perimeter.storeyId) const lowerPerimeters = lowerStorey ? store.getPerimetersByStorey(lowerStorey.id) : [] - const lowerPerimeterPoints = lowerPerimeters.flatMap(p => - referenceSide === 'inside' ? p.innerPolygon.points : p.outerPolygon.points - ) - - const otherPerimeters = store.getPerimetersByStorey(activeStorey).filter(p => p.id !== entityId) - const otherPerimeterPoints = otherPerimeters.flatMap(p => - referenceSide === 'inside' ? p.innerPolygon.points : p.outerPolygon.points - ) - const snapContext: SnappingContext = { - snapPoints: [ZERO_VEC2, ...lowerPerimeterPoints], - alignPoints: otherPerimeterPoints - } + const snapCandidates = this.buildSnapCandidates(otherPerimeters, lowerPerimeters, referenceSide) + const snapService = new SnappingService({ candidates: snapCandidates }) - return { perimeter, snapContext } + return { perimeter, snapService } } protected getPolygonPoints(context: MovementContext): readonly Vec2[] { @@ -89,6 +77,32 @@ export class PerimeterMovementBehavior extends PolygonMovementBehavior[] { + const candidates: SnapCandidate[] = [] + + const lowerPoints = lowerPerimeters.flatMap(p => + referenceSide === 'inside' ? p.innerPolygon.points : p.outerPolygon.points + ) + + for (const point of lowerPoints) { + candidates.push({ type: 'point', position: point, mode: 'snap' }) + } + + const otherPoints = otherPerimeters.flatMap(p => + referenceSide === 'inside' ? p.innerPolygon.points : p.outerPolygon.points + ) + + for (const point of otherPoints) { + candidates.push({ type: 'point', position: point, mode: 'align' }) + } + + return candidates + } + private autoLockOriginCorners( movementState: PerimeterMovementState, context: MovementContext diff --git a/src/editor/tools/basic/movement/behaviors/PolygonMovementBehavior.ts b/src/editor/tools/basic/movement/behaviors/PolygonMovementBehavior.ts index 679758dce..09fbf090d 100644 --- a/src/editor/tools/basic/movement/behaviors/PolygonMovementBehavior.ts +++ b/src/editor/tools/basic/movement/behaviors/PolygonMovementBehavior.ts @@ -1,6 +1,7 @@ import type { SelectableId } from '@/building/model' import type { StoreActions } from '@/building/store' -import type { SnapResult, SnappingContext } from '@/editor/canvas/services/SnappingService' +import type { SnapResult } from '@/editor/canvas/services/SnappingService' +import { SnappingService } from '@/editor/canvas/services/SnappingService' import { PolygonMovementPreview } from '@/editor/tools/basic/movement/previews/PolygonMovementPreview' import type { MovementBehavior, @@ -11,12 +12,12 @@ import type { import { type Vec2, addVec2, copyVec2, distSqrVec2, subVec2 } from '@/shared/geometry' export interface PolygonEntityContext { - snapContext: SnappingContext + snapService: SnappingService } export interface PolygonMovementState extends MovementState { previewPolygon: readonly Vec2[] - snapResults: SnapResult[] + snapResults: SnapResult[] } export abstract class PolygonMovementBehavior implements MovementBehavior< @@ -38,13 +39,14 @@ export abstract class PolygonMovementBehavior): PolygonMovementState { const originalPoints = this.getPolygonPoints(context) const previewPoints = originalPoints.map(point => addVec2(point, pointerState.delta)) - const snapContext = this.getSnapContext(context) + + const service = context.entity.snapService let bestScore = Infinity let resultDelta = copyVec2(pointerState.delta) for (let index = 0; index < previewPoints.length; index += 1) { - const snapResult = context.snappingService.findSnapResult(previewPoints[index], snapContext) ?? undefined + const snapResult = service.findSnapResult(previewPoints[index]) ?? undefined if (!snapResult) continue const score = @@ -58,13 +60,7 @@ export abstract class PolygonMovementBehavior service.findSnapResult(point, 1)).filter(r => r != null) return { previewPolygon: finalPoints, @@ -85,10 +81,6 @@ export abstract class PolygonMovementBehavior): SnappingContext { - return context.entity.snapContext - } - protected translatePoints(points: readonly Vec2[], delta: Vec2): Vec2[] { return points.map(point => addVec2(point, delta)) } diff --git a/src/editor/tools/basic/movement/behaviors/RoofMovementBehavior.ts b/src/editor/tools/basic/movement/behaviors/RoofMovementBehavior.ts index d2a631f4c..05970bd92 100644 --- a/src/editor/tools/basic/movement/behaviors/RoofMovementBehavior.ts +++ b/src/editor/tools/basic/movement/behaviors/RoofMovementBehavior.ts @@ -2,7 +2,7 @@ import type { PerimeterWithGeometry, Roof } from '@/building/model' import type { SelectableId } from '@/building/model/ids' import { isRoofId } from '@/building/model/ids' import type { StoreActions } from '@/building/store/types' -import type { SnappingContext } from '@/editor/canvas/services/SnappingService' +import { type SnapCandidate, SnappingService } from '@/editor/canvas/services/SnappingService' import type { MovementContext } from '@/editor/tools/basic/movement/types' import { type Polygon2D, type Vec2, polygonEdges } from '@/shared/geometry' @@ -19,20 +19,6 @@ export interface RoofEntityContext extends PolygonEntityContext { export type RoofMovementState = PolygonMovementState -function buildSnapContext(perimeters: PerimeterWithGeometry[], otherRoofs: Roof[]): SnappingContext { - // Only snap to outer points and outer edges of perimeters - const perimeterPoints = perimeters.flatMap(perimeter => perimeter.outerPolygon.points) - const perimeterSegments = perimeters.flatMap(perimeter => [...polygonEdges(perimeter.outerPolygon)]) - - const roofPoints = otherRoofs.flatMap(roof => roof.referencePolygon.points) - const roofSegments = otherRoofs.flatMap(roof => createPolygonSegments(roof.referencePolygon.points)) - - return { - snapPoints: [...perimeterPoints, ...roofPoints], - referenceLineSegments: [...perimeterSegments, ...roofSegments] - } -} - export class RoofMovementBehavior extends PolygonMovementBehavior { getEntity(entityId: SelectableId, _parentIds: SelectableId[], store: StoreActions): RoofEntityContext { if (!isRoofId(entityId)) { @@ -46,11 +32,32 @@ export class RoofMovementBehavior extends PolygonMovementBehavior r.id !== roof.id) + const snapCandidates = this.buildSnapCandidates(perimeters, otherRoofs) + const snapService = new SnappingService({ candidates: snapCandidates }) + + return { roof, snapService } + } - return { - roof, - snapContext: buildSnapContext(perimeters, otherRoofs) + private buildSnapCandidates(perimeters: PerimeterWithGeometry[], otherRoofs: Roof[]): SnapCandidate[] { + const candidates: SnapCandidate[] = [] + + const perimeterPoints = perimeters.flatMap(perimeter => perimeter.outerPolygon.points) + const perimeterSegments = perimeters.flatMap(perimeter => [...polygonEdges(perimeter.outerPolygon)]) + + const roofPoints = otherRoofs.flatMap(roof => roof.referencePolygon.points) + const roofSegments = otherRoofs.flatMap(roof => createPolygonSegments(roof.referencePolygon.points)) + + const allPoints = [...perimeterPoints, ...roofPoints] + for (const point of allPoints) { + candidates.push({ type: 'point', position: point, mode: 'snap' }) } + + const allSegments = [...perimeterSegments, ...roofSegments] + for (const segment of allSegments) { + candidates.push({ type: 'segment', segment }) + } + + return candidates } protected getPolygonPoints(context: MovementContext): readonly Vec2[] { diff --git a/src/editor/tools/basic/movement/types.ts b/src/editor/tools/basic/movement/types.ts index bdefef770..fe1c7f687 100644 --- a/src/editor/tools/basic/movement/types.ts +++ b/src/editor/tools/basic/movement/types.ts @@ -2,7 +2,6 @@ import type React from 'react' import type { SelectableId } from '@/building/model/ids' import type { StoreActions } from '@/building/store/types' -import type { SnappingService } from '@/editor/canvas/services/SnappingService' import { type Vec2 } from '@/shared/geometry' export interface MovementContext { @@ -10,7 +9,6 @@ export interface MovementContext { parentIds: SelectableId[] entity: T store: StoreActions - snappingService: SnappingService } export interface PointerMovementState { diff --git a/src/editor/tools/floors/add-area/FloorAreaTool.test.ts b/src/editor/tools/floors/add-area/FloorAreaTool.test.ts index 42ca6c438..fc8c3a3e7 100644 --- a/src/editor/tools/floors/add-area/FloorAreaTool.test.ts +++ b/src/editor/tools/floors/add-area/FloorAreaTool.test.ts @@ -1,9 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { FloorArea, FloorOpening, Perimeter, PerimeterWithGeometry } from '@/building/model' +import type { FloorArea, FloorOpening, Perimeter } from '@/building/model' import { ToolSystem } from '@/editor/tools/system/ToolSystem' -import { type Vec2, newVec2 } from '@/shared/geometry' -import { partial } from '@/test/helpers' +import { newVec2 } from '@/shared/geometry' import { FloorAreaTool } from './FloorAreaTool' @@ -41,45 +40,4 @@ describe('FloorAreaTool', () => { expect(mockModelActions.addFloorArea).toHaveBeenCalledTimes(1) expect(mockModelActions.addFloorArea).toHaveBeenCalledWith('storey_1', { points }) }) - - it('extends snapping context with perimeter and floor geometry', () => { - const perimeter = partial({ - innerPolygon: { points: [newVec2(1, 1), newVec2(2, 2), newVec2(3, 3)] }, - outerPolygon: { points: [newVec2(4, 4), newVec2(5, 5), newVec2(6, 6)] } - }) - - const floorArea = partial({ - area: { points: [newVec2(0, 0), newVec2(0, 200), newVec2(200, 200)] } - }) - - const floorOpening = partial({ - area: { points: [newVec2(50, 50), newVec2(80, 50), newVec2(80, 80)] } - }) - - mockModelActions.getPerimetersByStorey.mockReturnValue([perimeter]) - mockModelActions.getFloorAreasByStorey.mockReturnValue([floorArea]) - mockModelActions.getFloorOpeningsByStorey.mockReturnValue([floorOpening]) - - const toolSystem = new ToolSystem() - const tool = new FloorAreaTool(toolSystem) - const baseContext = { - snapPoints: [] as Vec2[], - alignPoints: [] as Vec2[], - referenceLineSegments: [] - } - - const result = ( - tool as unknown as { extendSnapContext: (ctx: typeof baseContext) => typeof baseContext } - ).extendSnapContext(baseContext) - - expect(result.snapPoints).toEqual( - expect.arrayContaining([...perimeter.outerPolygon.points, ...floorArea.area.points, ...floorOpening.area.points]) - ) - expect(result.referenceLineSegments).toEqual( - expect.arrayContaining([ - expect.objectContaining({ start: perimeter.outerPolygon.points[0], end: perimeter.outerPolygon.points[1] }), - expect.objectContaining({ start: floorArea.area.points[0], end: floorArea.area.points[1] }) - ]) - ) - }) }) diff --git a/src/editor/tools/floors/add-opening/FloorOpeningTool.test.ts b/src/editor/tools/floors/add-opening/FloorOpeningTool.test.ts index 443c9122e..a81222fc1 100644 --- a/src/editor/tools/floors/add-opening/FloorOpeningTool.test.ts +++ b/src/editor/tools/floors/add-opening/FloorOpeningTool.test.ts @@ -1,9 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { FloorArea, FloorOpening, Perimeter, PerimeterWithGeometry } from '@/building/model' +import type { FloorArea, FloorOpening, Perimeter } from '@/building/model' import { ToolSystem } from '@/editor/tools/system/ToolSystem' -import { type Vec2, newVec2 } from '@/shared/geometry' -import { partial } from '@/test/helpers' +import { newVec2 } from '@/shared/geometry' import { FloorOpeningTool } from './FloorOpeningTool' @@ -41,49 +40,4 @@ describe('FloorOpeningTool', () => { expect(mockModelActions.addFloorOpening).toHaveBeenCalledTimes(1) expect(mockModelActions.addFloorOpening).toHaveBeenCalledWith('storey_opening', { points }) }) - - it('reuses floor and perimeter geometry for snapping context', () => { - const perimeter = partial({ - innerPolygon: { points: [newVec2(1, 1), newVec2(2, 2), newVec2(3, 3)] }, - outerPolygon: { points: [newVec2(4, 4), newVec2(5, 5), newVec2(6, 6)] } - }) - - const floorArea = { - id: 'floorarea_existing', - storeyId: 'storey_opening', - area: { points: [newVec2(0, 0), newVec2(0, 120), newVec2(120, 120)] } - } as FloorArea - - const floorOpening = { - id: 'flooropening_existing', - storeyId: 'storey_opening', - area: { points: [newVec2(20, 20), newVec2(40, 20), newVec2(40, 40)] } - } as FloorOpening - - mockModelActions.getPerimetersByStorey.mockReturnValue([perimeter]) - mockModelActions.getFloorAreasByStorey.mockReturnValue([floorArea]) - mockModelActions.getFloorOpeningsByStorey.mockReturnValue([floorOpening]) - - const toolSystem = new ToolSystem() - const tool = new FloorOpeningTool(toolSystem) - const baseContext = { - snapPoints: [] as Vec2[], - alignPoints: [] as Vec2[], - referenceLineSegments: [] - } - - const result = ( - tool as unknown as { extendSnapContext: (ctx: typeof baseContext) => typeof baseContext } - ).extendSnapContext(baseContext) - - expect(result.snapPoints).toEqual( - expect.arrayContaining([...perimeter.outerPolygon.points, ...floorArea.area.points, ...floorOpening.area.points]) - ) - expect(result.referenceLineSegments).toEqual( - expect.arrayContaining([ - expect.objectContaining({ start: perimeter.outerPolygon.points[0], end: perimeter.outerPolygon.points[1] }), - expect.objectContaining({ start: floorOpening.area.points[0], end: floorOpening.area.points[1] }) - ]) - ) - }) }) diff --git a/src/editor/tools/floors/shared/BaseFloorPolygonTool.ts b/src/editor/tools/floors/shared/BaseFloorPolygonTool.ts index 02311839e..6726c30d8 100644 --- a/src/editor/tools/floors/shared/BaseFloorPolygonTool.ts +++ b/src/editor/tools/floors/shared/BaseFloorPolygonTool.ts @@ -1,5 +1,5 @@ import { getModelActions } from '@/building/store' -import type { SnappingContext } from '@/editor/canvas/services/SnappingService' +import type { SnappingService } from '@/editor/canvas/services/SnappingService' import { getViewModeActions } from '@/editor/canvas/state/viewModeStore' import { BasePolygonTool, type PolygonToolStateBase } from '@/editor/tools/shared/polygon/BasePolygonTool' import { type LineSegment2D, type Vec2, polygonEdges } from '@/shared/geometry' @@ -17,7 +17,7 @@ const createPolygonSegments = (points: readonly Vec2[]): LineSegment2D[] => { } export abstract class BaseFloorPolygonTool extends BasePolygonTool { - protected extendSnapContext(context: SnappingContext): SnappingContext { + protected override setupSnapService(snapService: SnappingService): void { const { getPerimetersByStorey, getFloorAreasByStorey, getFloorOpeningsByStorey, getActiveStoreyId } = getModelActions() @@ -38,16 +38,19 @@ export abstract class BaseFloorPolygonTool const openingPoints = floorOpenings.flatMap(opening => opening.area.points) const openingSegments = floorOpenings.flatMap(opening => createPolygonSegments(opening.area.points)) - return { - ...context, - snapPoints: [...context.snapPoints, ...perimeterPoints, ...areaPoints, ...openingPoints], - alignPoints: [...(context.alignPoints ?? []), ...perimeterPoints, ...areaPoints, ...openingPoints], - referenceLineSegments: [ - ...(context.referenceLineSegments ?? []), - ...perimeterSegments, - ...areaSegments, - ...openingSegments - ] + const allPoints = [...perimeterPoints, ...areaPoints, ...openingPoints] + for (const point of allPoints) { + snapService.addSnapCandidate({ type: 'point', position: point, mode: 'snap' }) + } + + const alignPoints = [...perimeterPoints, ...areaPoints, ...openingPoints] + for (const point of alignPoints) { + snapService.addSnapCandidate({ type: 'point', position: point, mode: 'align' }) + } + + const allSegments = [...perimeterSegments, ...areaSegments, ...openingSegments] + for (const segment of allSegments) { + snapService.addSnapCandidate({ type: 'segment', segment }) } } diff --git a/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts index dc4f03b0c..08fbdcb77 100644 --- a/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts +++ b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts @@ -1,5 +1,6 @@ import { getModelActions } from '@/building/store' import { getConfigActions } from '@/config/store' +import { type SnapCandidate } from '@/editor/canvas/services/SnappingService' import { getViewModeActions } from '@/editor/canvas/state/viewModeStore' import { BasePolylineTool, @@ -57,47 +58,39 @@ export class IntermediateWallTool extends BasePolylineTool[]): SnapCandidate[] { const modelActions = getModelActions() const perimeters = modelActions.getAllPerimeters() const walls = modelActions.getAllIntermediateWalls() const wallNodes = modelActions.getAllWallNodes() - const snapPoints: Vec2[] = [...context.snapPoints] + const extended = [...candidates] for (const wall of walls) { - snapPoints.push(wall.geometry.centerLine.start) - snapPoints.push(wall.geometry.centerLine.end) + extended.push({ type: 'point', position: wall.geometry.centerLine.start, mode: 'snap' }) + extended.push({ type: 'point', position: wall.geometry.centerLine.end, mode: 'snap' }) } for (const node of wallNodes) { - snapPoints.push(node.center) + extended.push({ type: 'point', position: node.center, mode: 'snap' }) } - const referenceLineSegments: LineSegment2D[] = [...(context.referenceLineSegments ?? [])] - for (const wall of walls) { - referenceLineSegments.push(wall.geometry.centerLine) + extended.push({ type: 'segment', segment: wall.geometry.centerLine }) } for (const perimeter of perimeters) { for (let i = 0; i < perimeter.wallIds.length; i++) { const wall = modelActions.getPerimeterWallById(perimeter.wallIds[i]) - referenceLineSegments.push(wall.insideLine) + extended.push({ type: 'segment', segment: wall.insideLine }) } } - return { - ...context, - snapPoints, - referenceLineSegments - } + return extended } protected shouldTerminateAtSnap( - _snapResult: import('@/editor/canvas/services/SnappingService').SnapResult | undefined + _snapResult: import('@/editor/canvas/services/SnappingService2').SnapResult | undefined ): boolean { return false } diff --git a/src/editor/tools/roofs/RoofTool.ts b/src/editor/tools/roofs/RoofTool.ts index 77539df69..2c4015d52 100644 --- a/src/editor/tools/roofs/RoofTool.ts +++ b/src/editor/tools/roofs/RoofTool.ts @@ -1,7 +1,7 @@ import type { RoofAssemblyId, RoofType } from '@/building/model' import { getModelActions } from '@/building/store' import { getConfigActions } from '@/config/store' -import type { SnappingContext } from '@/editor/canvas/services/SnappingService' +import type { SnappingService } from '@/editor/canvas/services/SnappingService' import { getViewModeActions } from '@/editor/canvas/state/viewModeStore' import { BasePolygonTool, type PolygonToolStateBase } from '@/editor/tools/shared/polygon/BasePolygonTool' import { PolygonToolOverlay } from '@/editor/tools/shared/polygon/PolygonToolOverlay' @@ -72,24 +72,25 @@ export class RoofTool extends BasePolygonTool implements ToolImpl this.triggerRender() } - protected extendSnapContext(context: SnappingContext): SnappingContext { + protected override setupSnapService(snapService: SnappingService): void { const { getPerimetersByStorey, getRoofsByStorey, getActiveStoreyId } = getModelActions() const activeStoreyId = getActiveStoreyId() const perimeters = getPerimetersByStorey(activeStoreyId) const roofs = getRoofsByStorey(activeStoreyId) - // Only snap to outer points and outer edges of perimeters const perimeterPoints = perimeters.flatMap(perimeter => perimeter.outerPolygon.points) const perimeterSegments = perimeters.flatMap(perimeter => [...polygonEdges(perimeter.outerPolygon)]) const roofPoints = roofs.flatMap(roof => roof.referencePolygon.points) const roofSegments = roofs.flatMap(roof => createPolygonSegments(roof.referencePolygon.points)) - return { - ...context, - snapPoints: [...context.snapPoints, ...perimeterPoints, ...roofPoints], - referenceLineSegments: [...(context.referenceLineSegments ?? []), ...perimeterSegments, ...roofSegments] + for (const point of [...perimeterPoints, ...roofPoints]) { + snapService.addSnapCandidate({ type: 'point', position: point, mode: 'snap' }) + } + + for (const segment of [...perimeterSegments, ...roofSegments]) { + snapService.addSnapCandidate({ type: 'segment', segment }) } } @@ -109,13 +110,11 @@ export class RoofTool extends BasePolygonTool implements ToolImpl const { addRoof, getActiveStoreyId } = getModelActions() const activeStoreyId = getActiveStoreyId() - // Calculate direction perpendicular to first edge if (polygon.points.length < 2) { console.error('Polygon must have at least 2 points') return } - // Use first side (index 0) as main side for direction const mainSideIndex = 0 addRoof( diff --git a/src/editor/tools/shared/polygon/BasePolygonTool.ts b/src/editor/tools/shared/polygon/BasePolygonTool.ts index 2a7f3ee93..84c8b0ffe 100644 --- a/src/editor/tools/shared/polygon/BasePolygonTool.ts +++ b/src/editor/tools/shared/polygon/BasePolygonTool.ts @@ -1,4 +1,4 @@ -import { type SnapResult, type SnappingContext, SnappingService } from '@/editor/canvas/services/SnappingService' +import { type SnapResult, SnappingService } from '@/editor/canvas/services/SnappingService' import type { LengthInputPosition } from '@/editor/canvas/services/length-input' import { activateLengthInput, deactivateLengthInput } from '@/editor/canvas/services/length-input' import { viewportActions } from '@/editor/canvas/state/viewportStore' @@ -7,7 +7,6 @@ import type { ToolSystem } from '@/editor/tools/system/ToolSystem' import type { CursorStyle, EditorEvent } from '@/editor/tools/system/types' import { type Length, - type LineSegment2D, type Polygon2D, type Vec2, ZERO_VEC2, @@ -21,8 +20,8 @@ import { export interface PolygonToolStateBase { points: Vec2[] pointer: Vec2 - snapResult?: SnapResult - snapContext: SnappingContext + snapResult?: SnapResult + snapService: SnappingService isCurrentSegmentValid: boolean isClosingSegmentValid: boolean lengthOverride: Length | null @@ -40,10 +39,12 @@ export interface PolygonToolStateBase { export abstract class BasePolygonTool extends BaseTool { public state: TState - private readonly snappingService: SnappingService - protected constructor(toolSystem: ToolSystem, initialState: Omit) { super(toolSystem) + + const snapService = new SnappingService({ candidates: [] }) + this.setupSnapService(snapService) + this.state = { points: [] as Vec2[], pointer: ZERO_VEC2, @@ -53,10 +54,9 @@ export abstract class BasePolygonTool exten lengthOverride: null, segmentLengthOverrides: [] as (Length | null)[], originSnappedIndex: null, - snapContext: this.extendSnapContext(this.createBaseSnapContext([])), + snapService, ...initialState } as TState - this.snappingService = new SnappingService() } handlePointerDown(event: EditorEvent): boolean { @@ -96,8 +96,8 @@ export abstract class BasePolygonTool exten this.state.originSnappedIndex = this.state.points.length } - this.state.points.push(pointToAdd) - this.updateSnapContext() + this.addPoint(pointToAdd) + this.clearLengthOverride() this.updateValidation() @@ -109,6 +109,36 @@ export abstract class BasePolygonTool exten return true } + private addPoint(pointToAdd: Vec2) { + this.state.points.push(pointToAdd) + + const snapService = this.state.snapService + snapService.referencePoint = pointToAdd + snapService.addSnapCandidate({ + type: 'point', + position: pointToAdd, + mode: 'align' + }) + + if (this.state.points.length === 1) { + snapService.addSnapCandidate({ + type: 'point', + position: pointToAdd, + mode: 'snap', + priority: 1 + }) + } + + if (this.state.points.length > 1) { + const lastPoint = this.state.points[this.state.points.length - 2] + const line = { point: lastPoint, direction: direction(lastPoint, pointToAdd) } + snapService.addSnapCandidate({ + type: 'line', + line + }) + } + } + handlePointerMove(event: EditorEvent): boolean { const stageCoords = event.worldCoordinates this.state.pointer = stageCoords @@ -143,7 +173,7 @@ export abstract class BasePolygonTool exten onActivate(): void { this.resetDrawingState() this.onToolActivated() - this.updateSnapContext() + this.createSnapService() } onDeactivate(): void { @@ -196,35 +226,14 @@ export abstract class BasePolygonTool exten return scaleAddVec2(lastPoint, dir, this.state.lengthOverride) } - /** - * Allows subclasses to trigger a re-computation of the snapping context when - * external geometry changes. - */ - protected updateSnapContext(): void { - const context = this.createBaseSnapContext(this.state.points) - this.state.snapContext = this.extendSnapContext(context) - this.triggerRender() - } - - protected createBaseSnapContext(points: readonly Vec2[]): SnappingContext { - const referenceLineSegments: LineSegment2D[] = [] - for (let i = 1; i < points.length; i += 1) { - referenceLineSegments.push({ start: points[i - 1], end: points[i] }) - } - - const snapPoints = points.length > 0 ? [points[0]] : [] - const referencePoint = points.length > 0 ? points[points.length - 1] : undefined - - return { - snapPoints, - alignPoints: [...points], - referencePoint, - referenceLineSegments - } + protected createSnapService(): void { + const service = new SnappingService({ candidates: [] }) + this.setupSnapService(service) + this.state.snapService = service } - protected extendSnapContext(context: SnappingContext): SnappingContext { - return context + protected setupSnapService(_snapService: SnappingService): void { + // Override to add additional snap candidates from subclass } public getMinimumPointCount(): number { @@ -258,8 +267,8 @@ export abstract class BasePolygonTool exten protected abstract onPolygonCompleted(polygon: Polygon2D): void - private findSnap(target: Vec2): SnapResult | undefined { - const result = this.snappingService.findSnapResult(target, this.state.snapContext) + private findSnap(target: Vec2): SnapResult | undefined { + const result = this.state.snapService.findSnapResult(target) return result ?? undefined } @@ -352,6 +361,6 @@ export abstract class BasePolygonTool exten this.state.lengthOverride = null this.state.segmentLengthOverrides = [] this.state.originSnappedIndex = null - this.updateSnapContext() + this.createSnapService() } } diff --git a/src/editor/tools/shared/polygon/PolygonToolOverlay.test.tsx b/src/editor/tools/shared/polygon/PolygonToolOverlay.test.tsx index 31818ac35..e62b66ebb 100644 --- a/src/editor/tools/shared/polygon/PolygonToolOverlay.test.tsx +++ b/src/editor/tools/shared/polygon/PolygonToolOverlay.test.tsx @@ -36,11 +36,7 @@ describe('PolygonToolOverlay', () => { points: [], pointer: ZERO_VEC2, snapResult: undefined, - snapContext: { - snapPoints: [], - alignPoints: [], - referenceLineSegments: [] - }, + snapCandidates: [], isCurrentSegmentValid: true, isClosingSegmentValid: true, segmentLengthOverrides: [], @@ -153,8 +149,10 @@ describe('PolygonToolOverlay', () => { describe('snapping behavior', () => { it('renders snap position when snap result exists', () => { - const snapResult: SnapResult = { - position: newVec2(125, 125) + const snapResult: SnapResult = { + position: newVec2(125, 125), + distance: 10, + type: 'snap' } mockTool.state.points = [newVec2(100, 100)] @@ -167,8 +165,10 @@ describe('PolygonToolOverlay', () => { }) it('renders snap lines when snap result has lines', () => { - const snapResult: SnapResult = { + const snapResult: SnapResult = { position: newVec2(150, 150), + distance: 10, + type: 'align', lines: [ { point: newVec2(100, 100), @@ -219,8 +219,10 @@ describe('PolygonToolOverlay', () => { mockUseStageHeight.mockReturnValue(1200) mockUseZoom.mockReturnValue(2.0) - const snapResult: SnapResult = { + const snapResult: SnapResult = { position: newVec2(100, 100), + distance: 10, + type: 'align', lines: [ { point: newVec2(100, 100), diff --git a/src/editor/tools/shared/polyline/BasePolylineTool.ts b/src/editor/tools/shared/polyline/BasePolylineTool.ts index 2e0f74ce9..556c7ea6a 100644 --- a/src/editor/tools/shared/polyline/BasePolylineTool.ts +++ b/src/editor/tools/shared/polyline/BasePolylineTool.ts @@ -1,4 +1,9 @@ -import { type SnapResult, type SnappingContext, SnappingService } from '@/editor/canvas/services/SnappingService' +import { + type SnapCandidate, + type SnapResult, + type SnappingContext, + SnappingService +} from '@/editor/canvas/services/SnappingService' import type { LengthInputPosition } from '@/editor/canvas/services/length-input' import { activateLengthInput, deactivateLengthInput } from '@/editor/canvas/services/length-input' import { viewportActions } from '@/editor/canvas/state/viewportStore' @@ -22,8 +27,8 @@ export interface PolylineValidationContext { export interface PolylineToolStateBase { points: Vec2[] pointer: Vec2 - snapResult?: SnapResult - snapContext: SnappingContext + snapResult?: SnapResult + snapCandidates: SnapCandidate[] isCurrentSegmentValid: boolean lengthOverride: Length | null segmentLengthOverrides: (Length | null)[] @@ -33,10 +38,11 @@ export interface PolylineToolStateBase { export abstract class BasePolylineTool extends BaseTool { public state: TState - private readonly snappingService: SnappingService + private snappingService: SnappingService | null = null protected constructor(toolSystem: ToolSystem, initialState: Omit) { super(toolSystem) + const initialCandidates = this.createBaseSnapCandidates([]) this.state = { points: [] as Vec2[], pointer: ZERO_VEC2, @@ -45,10 +51,9 @@ export abstract class BasePolylineTool ext lengthOverride: null, segmentLengthOverrides: [] as (Length | null)[], validationContext: this.createInitialValidationContext(), - snapContext: this.createBaseSnapContext([]), + snapCandidates: this.extendSnapCandidates(initialCandidates), ...initialState } as TState - this.snappingService = new SnappingService() } protected createInitialValidationContext(): PolylineValidationContext { @@ -78,7 +83,7 @@ export abstract class BasePolylineTool ext } this.state.points.push(pointToAdd) - this.updateSnapContext() + this.updateSnapCandidates() this.clearLengthOverride() this.updateValidation() @@ -125,7 +130,7 @@ export abstract class BasePolylineTool ext onActivate(): void { this.resetDrawingState() this.onToolActivated() - this.updateSnapContext() + this.updateSnapCandidates() } onDeactivate(): void { @@ -171,39 +176,43 @@ export abstract class BasePolylineTool ext return scaleAddVec2(lastPoint, dir, this.state.lengthOverride) } - protected updateSnapContext(): void { - const context = this.createBaseSnapContext(this.state.points) - this.state.snapContext = this.extendSnapContext(context) + protected updateSnapCandidates(): void { + const candidates = this.createBaseSnapCandidates(this.state.points) + this.state.snapCandidates = this.extendSnapCandidates(candidates) + this.snappingService = null this.triggerRender() } - protected createBaseSnapContext(points: readonly Vec2[]): SnappingContext { - const referenceLineSegments: LineSegment2D[] = [] + protected createBaseSnapCandidates(points: readonly Vec2[]): SnapCandidate[] { + const candidates: SnapCandidate[] = [] + for (let i = 1; i < points.length; i += 1) { - referenceLineSegments.push({ start: points[i - 1], end: points[i] }) + candidates.push({ + type: 'segment', + segment: { start: points[i - 1], end: points[i] } + }) } - const referencePoint = points.length > 0 ? points[points.length - 1] : undefined - - const snapPoints: Vec2[] = [] - - return { - snapPoints, - alignPoints: [...points], - referencePoint, - referenceLineSegments + for (const point of points) { + candidates.push({ + type: 'point', + position: point, + mode: 'align' + }) } + + return candidates } - protected extendSnapContext(context: SnappingContext): SnappingContext { - return context + protected extendSnapCandidates(candidates: SnapCandidate[]): SnapCandidate[] { + return candidates } public getMinimumPointCount(): number { return 2 } - protected shouldTerminateAtSnap(_snapResult: SnapResult | undefined): boolean { + protected shouldTerminateAtSnap(_snapResult: SnapResult | undefined): boolean { return false } @@ -219,8 +228,23 @@ export abstract class BasePolylineTool ext protected onToolDeactivated(): void {} - private findSnap(target: Vec2): SnapResult | undefined { - const result = this.snappingService.findSnapResult(target, this.state.snapContext) + private getOrCreateSnappingService(): SnappingService { + if (!this.snappingService) { + const context: SnappingContext = { + candidates: this.state.snapCandidates + } + this.snappingService = new SnappingService(context) + + if (this.state.points.length > 0) { + this.snappingService.referencePoint = this.state.points[this.state.points.length - 1] + this.snappingService.referenceMinDistance = 50 + } + } + return this.snappingService + } + + private findSnap(target: Vec2): SnapResult | undefined { + const result = this.getOrCreateSnappingService().findSnapResult(target) return result ?? undefined } @@ -287,6 +311,7 @@ export abstract class BasePolylineTool ext this.state.lengthOverride = null this.state.segmentLengthOverrides = [] this.state.validationContext = this.createInitialValidationContext() - this.updateSnapContext() + this.snappingService = null + this.updateSnapCandidates() } } From 5f4f54d614ecc21b2370c9cf9586126731518e15 Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Thu, 26 Mar 2026 19:05:40 +0100 Subject: [PATCH 08/21] Fix up the implementation of the intermediate wall tool --- src/@types/resources.d.ts | 1 + src/editor/EditorToolbar.tsx | 1 + .../add/IntermediateWallTool.ts | 528 ++++++++++++------ .../add/IntermediateWallToolInspector.tsx | 179 ++++++ .../add/IntermediateWallToolOverlay.tsx | 156 ++++++ .../add/PerimeterToolInspector.test.tsx | 13 +- .../polygon/PolygonToolOverlay.test.tsx | 11 +- .../tools/shared/polyline/BasePolylineTool.ts | 317 ----------- .../tools/system/ToolSystemProvider.tsx | 2 + src/editor/tools/system/metadata.ts | 7 +- src/editor/tools/system/types.ts | 1 + src/shared/i18n/locales/de/toolbar.json | 1 + src/shared/i18n/locales/en/toolbar.json | 1 + 13 files changed, 724 insertions(+), 494 deletions(-) create mode 100644 src/editor/tools/intermediate-wall/add/IntermediateWallToolInspector.tsx create mode 100644 src/editor/tools/intermediate-wall/add/IntermediateWallToolOverlay.tsx delete mode 100644 src/editor/tools/shared/polyline/BasePolylineTool.ts diff --git a/src/@types/resources.d.ts b/src/@types/resources.d.ts index 325c91b2f..ca2fe6e6b 100644 --- a/src/@types/resources.d.ts +++ b/src/@types/resources.d.ts @@ -1884,6 +1884,7 @@ interface Resources { "basicSelect": "Select", "floorsAddArea": "Floor Area", "floorsAddOpening": "Floor Opening", + "intermediateWallAdd": "Draw Intermediate Wall", "perimeterAdd": "Building Perimeter", "perimeterAddOpening": "Add Opening", "perimeterAddPost": "Add Post", diff --git a/src/editor/EditorToolbar.tsx b/src/editor/EditorToolbar.tsx index fabef6775..e3fa25160 100644 --- a/src/editor/EditorToolbar.tsx +++ b/src/editor/EditorToolbar.tsx @@ -67,6 +67,7 @@ export function EditorToolbar(): React.JSX.Element { + diff --git a/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts index 08fbdcb77..29896d56f 100644 --- a/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts +++ b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts @@ -1,35 +1,79 @@ +import { + type PerimeterId, + type WallId, + type WallNodeId, + isIntermediateWallId, + isPerimeterWallId, + isWallNodeId +} from '@/building/model' import { getModelActions } from '@/building/store' -import { getConfigActions } from '@/config/store' -import { type SnapCandidate } from '@/editor/canvas/services/SnappingService' -import { getViewModeActions } from '@/editor/canvas/state/viewModeStore' +import { type SnapResult, SnappingService } from '@/editor/canvas/services/SnappingService' import { - BasePolylineTool, - type PolylineToolStateBase, - type PolylineValidationContext -} from '@/editor/tools/shared/polyline/BasePolylineTool' + type LengthInputPosition, + activateLengthInput, + deactivateLengthInput +} from '@/editor/canvas/services/length-input' +import { getViewModeActions } from '@/editor/canvas/state/viewModeStore' +import { viewportActions } from '@/editor/canvas/state/viewportStore' +import { BaseTool } from '@/editor/tools/system/BaseTool' import type { ToolSystem } from '@/editor/tools/system/ToolSystem' -import type { ToolImplementation } from '@/editor/tools/system/types' -import { type Length, type LineSegment2D, type Vec2, distanceToLineSegment, newVec2 } from '@/shared/geometry' -import { isPointInPolygon } from '@/shared/geometry/polygon' +import type { CursorStyle, EditorEvent, ToolImplementation } from '@/editor/tools/system/types' +import { + type Length, + type LineSegment2D, + type Vec2, + ZERO_VEC2, + direction, + lineFromSegment, + projectVec2, + scaleAddVec2 +} from '@/shared/geometry' +import { type Polygon2D, isPointInPolygon, segmentsIntersect } from '@/shared/geometry/polygon' +import { assertUnreachable } from '@/shared/utils' import { IntermediateWallToolInspector } from './IntermediateWallToolInspector' import { IntermediateWallToolOverlay } from './IntermediateWallToolOverlay' -interface IntermediateWallToolState extends PolylineToolStateBase { +type SnapEntityId = WallId | WallNodeId + +interface IntermediateWallToolState { + points: Vec2[] + pointer: Vec2 + snapResult?: SnapResult + startEntity?: SnapEntityId + perimeterId?: PerimeterId + isValid: boolean + lengthOverride: Length | null + segmentLengthOverrides: (Length | null)[] thickness: Length } -const SNAP_TOLERANCE = 200 +const SNAP_NODE_TOLERANCE = 200 -export class IntermediateWallTool extends BasePolylineTool implements ToolImplementation { +export class IntermediateWallTool extends BaseTool implements ToolImplementation { readonly id = 'intermediate-wall.add' readonly overlayComponent = IntermediateWallToolOverlay readonly inspectorComponent = IntermediateWallToolInspector + public state: IntermediateWallToolState + + private snappingService = new SnappingService({ candidates: [] }) + private validationLines: Record = {} + private validationPolygons: Record = {} + constructor(toolSystem: ToolSystem) { - super(toolSystem, { + super(toolSystem) + this.state = { + points: [] as Vec2[], + pointer: ZERO_VEC2, + snapResult: undefined, + startEntity: undefined, + perimeterId: undefined, + isValid: true, + lengthOverride: null, + segmentLengthOverrides: [] as (Length | null)[], thickness: 120 - }) + } } public setThickness(thickness: Length): void { @@ -37,91 +81,215 @@ export class IntermediateWallTool extends BasePolylineTool ({ - centerLine: w.geometry.centerLine - })) - } + let pointToAdd = snapCoords + if (this.state.lengthOverride && this.state.points.length > 0) { + const lastPoint = this.state.points[this.state.points.length - 1] + const dir = direction(lastPoint, snapCoords) + pointToAdd = scaleAddVec2(lastPoint, dir, this.state.lengthOverride) + } - protected extendSnapCandidates(candidates: SnapCandidate[]): SnapCandidate[] { - const modelActions = getModelActions() - const perimeters = modelActions.getAllPerimeters() - const walls = modelActions.getAllIntermediateWalls() - const wallNodes = modelActions.getAllWallNodes() + if (this.state.points.length > 0) { + this.state.segmentLengthOverrides.push(this.state.lengthOverride) + } - const extended = [...candidates] + this.addPoint(pointToAdd) - for (const wall of walls) { - extended.push({ type: 'point', position: wall.geometry.centerLine.start, mode: 'snap' }) - extended.push({ type: 'point', position: wall.geometry.centerLine.end, mode: 'snap' }) + if (this.state.points.length === 1) { + const perimeterId = this.findPerimeterContainingPoint(pointToAdd) + if (perimeterId) { + this.validationPolygons = { [perimeterId]: this.validationPolygons[perimeterId] } + this.state.perimeterId = perimeterId + } else { + // Unreachable since validation prevents user from placing first point outside of perimeter + throw new Error('Failed to find perimeter for intermediate wall start point') + } } - for (const node of wallNodes) { - extended.push({ type: 'point', position: node.center, mode: 'snap' }) + this.clearLengthOverride() + this.state.isValid = this.checkValidation() + + if (this.state.points.length >= 1) { + this.activateLengthInputForNextSegment() } - for (const wall of walls) { - extended.push({ type: 'segment', segment: wall.geometry.centerLine }) + if (snapEntity && this.state.points.length >= 2) { + this.complete(snapEntity) } - for (const perimeter of perimeters) { - for (let i = 0; i < perimeter.wallIds.length; i++) { - const wall = modelActions.getPerimeterWallById(perimeter.wallIds[i]) - extended.push({ type: 'segment', segment: wall.insideLine }) - } + return true + } + + private addPoint(pointToAdd: Vec2) { + this.state.points.push(pointToAdd) + + this.snappingService.referencePoint = pointToAdd + this.snappingService.addSnapCandidate({ + type: 'point', + position: pointToAdd, + mode: 'align' + }) + + if (this.state.points.length === 1) { + this.snappingService.addSnapCandidate({ + type: 'point', + position: pointToAdd, + mode: 'snap', + priority: 1 + }) } - return extended + if (this.state.points.length > 1) { + const lastPoint = this.state.points[this.state.points.length - 2] + const line = { point: lastPoint, direction: direction(lastPoint, pointToAdd) } + this.snappingService.addSnapCandidate({ + type: 'line', + line + }) + } } - protected shouldTerminateAtSnap( - _snapResult: import('@/editor/canvas/services/SnappingService2').SnapResult | undefined - ): boolean { + handlePointerMove(event: EditorEvent): boolean { + this.state.pointer = event.worldCoordinates + this.state.snapResult = this.findSnap(event.worldCoordinates) + this.state.isValid = this.checkValidation() + this.triggerRender() + return true + } + + handleKeyDown(event: KeyboardEvent): boolean { + if (event.key === 'Escape') { + if (this.state.lengthOverride) { + this.clearLengthOverride() + return true + } + if (this.state.points.length > 0) { + this.cancel() + return true + } + return false + } + + if (event.key === 'Enter' && this.state.points.length >= 2) { + this.complete() + return true + } + return false } - protected onPolylineCompleted(points: Vec2[]): void { - if (points.length < 2) return + onActivate(): void { + getViewModeActions().ensureMode('walls') + this.setupContext() + } + + onDeactivate(): void { + this.resetDrawingState() + this.resetContext() + } + protected resetContext(): void { + this.snappingService = new SnappingService({ candidates: [] }) + this.validationLines = {} + this.validationPolygons = {} + } + + protected setupContext(): void { const modelActions = getModelActions() - const perimeters = modelActions.getAllPerimeters() + const storeyId = modelActions.getActiveStoreyId() + const perimeters = modelActions.getPerimetersByStorey(storeyId) - let perimeterId = this.findPerimeterContainingPoint(points[0]) - if (!perimeterId) { - perimeterId = this.findPerimeterContainingPoint(points[points.length - 1]) - } - if (!perimeterId) { - for (const p of perimeters) { - perimeterId = p.id - break + for (const perimeter of perimeters) { + this.validationPolygons[perimeter.id] = perimeter.outerPolygon + + const perimeterWalls = modelActions.getPerimeterWallsById(perimeter.id) + for (const wall of perimeterWalls) { + const halfThickness = wall.thickness / 2 + this.snappingService.addSnapCandidate({ + type: 'segment', + segment: wall.insideLine, + minDistance: halfThickness, + meta: wall.id, + priority: 1 + }) + this.validationLines[wall.id] = [wall.insideLine] + } + + const intermediateWalls = modelActions.getIntermediateWallsByPerimeter(perimeter.id) + for (const wall of intermediateWalls) { + const halfThickness = wall.thickness / 2 + this.snappingService.addSnapCandidate({ + type: 'segment', + segment: wall.centerLine, + minDistance: halfThickness, + meta: wall.id, + priority: 1 + }) + this.snappingService.addSnapCandidate({ + type: 'line', + line: lineFromSegment(wall.leftLine), + minDistance: halfThickness, + priority: -1 + }) + this.snappingService.addSnapCandidate({ + type: 'line', + line: lineFromSegment(wall.rightLine), + minDistance: halfThickness, + priority: -1 + }) + this.validationLines[wall.id] = [wall.leftLine, wall.rightLine] + } + + const wallNodes = modelActions.getWallNodesByPerimeter(perimeter.id) + for (const node of wallNodes) { + this.snappingService.addSnapCandidate({ + type: 'point', + position: node.center, + mode: 'snap', + meta: node.id, + priority: 2, + minDistance: SNAP_NODE_TOLERANCE + }) } } + } + + protected onPolylineCompleted(points: Vec2[], snapEntity?: SnapEntityId): void { + if (points.length < 2) return + + const modelActions = getModelActions() + + const perimeterId = this.state.perimeterId if (!perimeterId) { - console.error('No perimeter found for intermediate wall') - return + // Unreachable since this is set on first point + throw new Error('No perimeter found for intermediate wall') } for (let i = 0; i < points.length - 1; i++) { const startPoint = points[i] const endPoint = points[i + 1] + const isLast = i === points.length - 2 - const startNode = this.getOrCreateNodeForPoint(startPoint, perimeterId) - const endNode = this.getOrCreateNodeForPoint(endPoint, perimeterId) + const startNode = + i === 0 && this.state.startEntity + ? this.getOrCreateEntityNode(startPoint, this.state.startEntity) + : modelActions.addInnerWallNode(perimeterId, startPoint) + + const endNode = + isLast && snapEntity + ? this.getOrCreateEntityNode(endPoint, snapEntity) + : modelActions.addInnerWallNode(perimeterId, endPoint) modelActions.addIntermediateWall( perimeterId, @@ -132,132 +300,156 @@ export class IntermediateWallTool extends BasePolylineTool isPointInPolygon(point, polygon))?.[0] ?? null + } - for (const perimeter of perimeters) { - if (isPointInPolygon(point, perimeter.boundaryPolygon)) { - return perimeter.id - } + private getOrCreateEntityNode(point: Vec2, entityId: SnapEntityId): { id: WallNodeId } { + if (isWallNodeId(entityId)) { + return { id: entityId } } - return null - } - private getOrCreateNodeForPoint( - point: Vec2, - perimeterId: import('@/building/model/ids').PerimeterId - ): { id: import('@/building/model/ids').WallNodeId } { const modelActions = getModelActions() - const perimeterWallNode = this.findPerimeterWallNodeAtPoint(point, perimeterId) - if (perimeterWallNode) { - return { id: perimeterWallNode.id } + if (isPerimeterWallId(entityId)) { + const wall = modelActions.getPerimeterWallById(entityId) + const offset = projectVec2(wall.insideLine.start, point, direction(wall.insideLine.start, wall.insideLine.end)) + return modelActions.addPerimeterWallNode(wall.perimeterId, wall.id, offset) } - const existingWallNode = this.findExistingWallNodeAtPoint(point, perimeterId) - if (existingWallNode) { - return { id: existingWallNode.id } + if (isIntermediateWallId(entityId)) { + return { id: modelActions.splitIntermediateWallAtPoint(entityId, point) } } - const intermediateWallSplit = this.findIntermediateWallToSplitAtPoint(point, perimeterId) - if (intermediateWallSplit) { - const newNodeId = modelActions.splitIntermediateWallAtPoint(intermediateWallSplit.wallId, point) - return { id: newNodeId } + assertUnreachable(entityId, 'invalid entity id for node creation') + } + + getCursor(): CursorStyle { + return 'crosshair' + } + + public cancel(): void { + this.resetDrawingState() + this.resetContext() + this.setupContext() + deactivateLengthInput() + } + + public complete(snapEntity?: SnapEntityId): void { + const points = [...this.state.points] + + try { + this.onPolylineCompleted(points, snapEntity) + } catch (error) { + console.error('Failed to create polyline:', error) } - return modelActions.addInnerWallNode(perimeterId, point) + this.resetDrawingState() + deactivateLengthInput() } - private findPerimeterWallNodeAtPoint( - point: Vec2, - _perimeterId: import('@/building/model/ids').PerimeterId - ): { id: import('@/building/model/ids').WallNodeId } | null { - const modelActions = getModelActions() - const perimeters = modelActions.getAllPerimeters() + public getPreviewPosition(): Vec2 { + const currentPos = this.state.snapResult?.position ?? this.state.pointer - for (const perimeter of perimeters) { - for (const wallId of perimeter.wallIds) { - const wall = modelActions.getPerimeterWallById(wallId) - const distStart = distanceToLineSegment(point, wall.insideLine) - const distEnd = distanceToLineSegment(point, wall.insideLine) - - if (distStart < SNAP_TOLERANCE || distEnd < SNAP_TOLERANCE) { - const distToInsideLine = distanceToLineSegment(point, wall.insideLine) - if (distToInsideLine < SNAP_TOLERANCE) { - const wallLength = wall.insideLength - const t = this.calculateParametricPosition(point, wall.insideLine) - const offset = Math.round(t * wallLength) - - const existingNodes = modelActions.getWallNodesByPerimeter(perimeter.id) - for (const node of existingNodes) { - if ( - node.type === 'perimeter' && - node.wallId === wallId && - Math.abs(node.offsetFromCornerStart - offset) < SNAP_TOLERANCE - ) { - return { id: node.id } - } - } - - const newNode = modelActions.addPerimeterWallNode(perimeter.id, wallId, offset) - return { id: newNode.id } + if (!this.state.lengthOverride || this.state.points.length === 0) { + return currentPos + } + + const lastPoint = this.state.points[this.state.points.length - 1] + const dir = direction(lastPoint, currentPos) + return scaleAddVec2(lastPoint, dir, this.state.lengthOverride) + } + + private findSnap(target: Vec2): SnapResult | undefined { + return this.snappingService.findSnapResult(target) ?? undefined + } + + private checkValidation(): boolean { + const isInsideValidationPolygons = Object.values(this.validationPolygons).some(polygon => + isPointInPolygon(this.state.pointer, polygon) + ) + if (!isInsideValidationPolygons) { + return false + } + + if (this.state.points.length > 0) { + const lastPoint = this.state.points[this.state.points.length - 1] + const currentPos = this.state.snapResult?.position ?? this.state.pointer + const snapEntityId = this.state.snapResult?.meta + const segmentToValidate = { start: lastPoint, end: currentPos } + for (const [entityId, lines] of Object.entries(this.validationLines)) { + if (entityId === snapEntityId) continue + if (this.state.points.length === 1 && this.state.startEntity === entityId) continue + for (const line of lines) { + if (segmentsIntersect(segmentToValidate.start, segmentToValidate.end, line.start, line.end)) { + return false } } } + + const previousSegments = this.state.points.slice(0, -1) + for (let i = 0; i < previousSegments.length - 1; i++) { + const segStart = previousSegments[i] + const segEnd = previousSegments[i + 1] + if (segmentsIntersect(segmentToValidate.start, segmentToValidate.end, segStart, segEnd)) { + return false + } + } } - return null + + return true } - private findExistingWallNodeAtPoint( - point: Vec2, - perimeterId: import('@/building/model/ids').PerimeterId - ): { id: import('@/building/model/ids').WallNodeId } | null { - const modelActions = getModelActions() - const nodes = modelActions.getWallNodesByPerimeter(perimeterId) + public setLengthOverride(length: Length | null): void { + this.state.lengthOverride = length + this.triggerRender() + } - for (const node of nodes) { - const dist = Math.sqrt((point[0] - node.center[0]) ** 2 + (point[1] - node.center[1]) ** 2) - if (dist < SNAP_TOLERANCE) { - return { id: node.id } - } - } - return null + public clearLengthOverride(): void { + this.state.lengthOverride = null + this.triggerRender() } - private findIntermediateWallToSplitAtPoint( - point: Vec2, - perimeterId: import('@/building/model/ids').PerimeterId - ): { wallId: import('@/building/model/ids').IntermediateWallId } | null { - const modelActions = getModelActions() - const walls = modelActions.getIntermediateWallsByPerimeter(perimeterId) - - for (const wall of walls) { - const dist = distanceToLineSegment(point, wall.geometry.centerLine) - if (dist < SNAP_TOLERANCE) { - const centerLine = wall.geometry.centerLine - const lineLength = Math.sqrt( - (centerLine.end[0] - centerLine.start[0]) ** 2 + (centerLine.end[1] - centerLine.start[1]) ** 2 - ) - const distToStart = Math.sqrt((point[0] - centerLine.start[0]) ** 2 + (point[1] - centerLine.start[1]) ** 2) - const distToEnd = Math.sqrt((point[0] - centerLine.end[0]) ** 2 + (point[1] - centerLine.end[1]) ** 2) - - if (distToStart > SNAP_TOLERANCE && distToEnd > SNAP_TOLERANCE && lineLength > SNAP_TOLERANCE * 2) { - return { wallId: wall.id } - } + private activateLengthInputForNextSegment(): void { + if (this.state.points.length === 0) return + + activateLengthInput({ + position: this.getLengthInputPosition(), + onCommit: (length: Length) => { + this.setLengthOverride(length) + }, + onCancel: () => { + this.clearLengthOverride() } - } - return null + }) } - private calculateParametricPosition(point: Vec2, line: LineSegment2D): number { - const lineVec = newVec2(line.end[0] - line.start[0], line.end[1] - line.start[1]) - const pointVec = newVec2(point[0] - line.start[0], point[1] - line.start[1]) + private getLengthInputPosition(): LengthInputPosition { + const { worldToStage } = viewportActions() - const lineLengthSq = lineVec[0] * lineVec[0] + lineVec[1] * lineVec[1] - if (lineLengthSq === 0) return 0 + if (this.state.points.length === 0) { + return { x: 400, y: 300 } + } + + const lastPoint = this.state.points[this.state.points.length - 1] + const stageCoords = worldToStage(lastPoint) + + return { + x: stageCoords[0] + 20, + y: stageCoords[1] - 30 + } + } - const dot = pointVec[0] * lineVec[0] + pointVec[1] * lineVec[1] - return Math.max(0, Math.min(1, dot / lineLengthSq)) + private resetDrawingState(): void { + this.state.points = [] + this.state.pointer = ZERO_VEC2 + this.state.snapResult = undefined + this.state.startEntity = undefined + this.state.perimeterId = undefined + this.state.isValid = true + this.state.lengthOverride = null + this.state.segmentLengthOverrides = [] + this.state.thickness = 120 } } diff --git a/src/editor/tools/intermediate-wall/add/IntermediateWallToolInspector.tsx b/src/editor/tools/intermediate-wall/add/IntermediateWallToolInspector.tsx new file mode 100644 index 000000000..d64257329 --- /dev/null +++ b/src/editor/tools/intermediate-wall/add/IntermediateWallToolInspector.tsx @@ -0,0 +1,179 @@ +import * as Label from '@radix-ui/react-label' +import { Info, X } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { useReactiveTool } from '@/editor/tools/system/hooks/useReactiveTool' +import type { ToolInspectorProps } from '@/editor/tools/system/types' +import { useFormatters } from '@/shared/i18n/useFormatters' +import { LengthField } from '@/shared/ui/LengthField' +import { MeasurementInfo } from '@/shared/ui/MeasurementInfo' +import { Button } from '@/shared/ui/components/button' +import { Callout, CalloutIcon, CalloutText } from '@/shared/ui/components/callout' +import { Kbd } from '@/shared/ui/components/kbd' +import { Separator } from '@/shared/ui/components/separator' + +import type { IntermediateWallTool } from './IntermediateWallTool' + +export function IntermediateWallToolInspector({ tool }: ToolInspectorProps): React.JSX.Element { + const { t } = useTranslation('tool') + const { formatLength } = useFormatters() + const { state } = useReactiveTool(tool) + + // Force re-renders when tool state changes + const [, forceUpdate] = useState({}) + + useEffect( + () => + tool.onRenderNeeded(() => { + forceUpdate({}) + }), + [tool] + ) + + return ( +
+
+ {/* Informational Note */} + + + + + + TODO + + + + {/* Tool Properties */} +
+ {/* Wall Thickness */} +
+ + {t($ => $.perimeter.wallThickness)} + + +
+
+ { + tool.setThickness(value) + }} + min={10} + max={undefined} + step={10} + size="sm" + unit="mm" + className="grow" + /> +
+
+ + {/* Length Override Display */} + {state.lengthOverride && ( + <> + +
+ {t($ => $.perimeter.lengthOverride)} +
+ {formatLength(state.lengthOverride)} + +
+
+ + )} + + {/* Help Text */} + +
+ {t($ => $.perimeter.controlsHeading)} + • {t($ => $.perimeter.controlPlace)} + • {t($ => $.perimeter.controlSnap)} + • {t($ => $.perimeter.controlNumbers)} + {state.lengthOverride ? ( + + • {t($ => $.keyboard.esc)}{' '} + {t($ => $.perimeter.controlEscOverride, { + key: '' + }) + .replace('{{key}}', '') + .trim()} + + ) : ( + + • {t($ => $.keyboard.esc)}{' '} + {t($ => $.perimeter.controlEscAbort, { + key: '' + }) + .replace('{{key}}', '') + .trim()} + + )} + {state.points.length >= 3 && ( + <> + + • {t($ => $.keyboard.enter)}{' '} + {t($ => $.perimeter.controlEnter, { + key: '' + }) + .replace('{{key}}', '') + .trim()} + + • {t($ => $.perimeter.controlClickFirst)} + + )} +
+ + {/* Actions */} + {state.points.length > 0 && ( + <> + +
+ {state.points.length >= 3 && ( + + )} + +
+ + )} +
+
+ ) +} diff --git a/src/editor/tools/intermediate-wall/add/IntermediateWallToolOverlay.tsx b/src/editor/tools/intermediate-wall/add/IntermediateWallToolOverlay.tsx new file mode 100644 index 000000000..183e06666 --- /dev/null +++ b/src/editor/tools/intermediate-wall/add/IntermediateWallToolOverlay.tsx @@ -0,0 +1,156 @@ +import React, { useMemo } from 'react' + +import { SnappingLines } from '@/editor/canvas/components/SnappingLines' +import { useZoom } from '@/editor/canvas/state/viewportStore' +import { useReactiveTool } from '@/editor/tools/system/hooks/useReactiveTool' +import type { ToolOverlayComponentProps } from '@/editor/tools/system/types' +import { type Vec2, addVec2, direction, perpendicularCCW, scaleVec2 } from '@/shared/geometry' + +import type { IntermediateWallTool } from './IntermediateWallTool' + +interface SegmentLine { + points: string +} + +interface DerivedSegments { + type: 'segments' + segments: SegmentLine[] +} + +function toSvgPoints(points: readonly Vec2[], close = false): string { + let result = points.map(point => `${point[0]},${point[1]}`).join(' ') + if (close && points.length > 0) { + result += ` ${points[0][0]},${points[0][1]}` + } + return result +} + +function computeDerivedSegments( + inputPoints: readonly Vec2[], + referenceSide: 'left' | 'right', + thickness: number +): DerivedSegments | null { + if (thickness <= 0 || inputPoints.length < 2) { + return null + } + + const multiplier = referenceSide === 'left' ? 1 : -1 + const segments: SegmentLine[] = [] + + for (let i = 0; i < inputPoints.length - 1; i += 1) { + const start = inputPoints[i] + const end = inputPoints[(i + 1) % inputPoints.length] + + const segDirection = direction(start, end) + const outward = perpendicularCCW(segDirection) + const offset = scaleVec2(outward, thickness * multiplier) + + const offsetStart = addVec2(start, offset) + const offsetEnd = addVec2(end, offset) + + segments.push({ points: `${offsetStart[0]},${offsetStart[1]} ${offsetEnd[0]},${offsetEnd[1]}` }) + } + + return { type: 'segments', segments } +} + +export function IntermediateWallToolOverlay({ + tool +}: ToolOverlayComponentProps): React.JSX.Element | null { + const { state } = useReactiveTool(tool) + const zoom = useZoom() + + const scaledLineWidth = Math.max(1, 2 / zoom) + const dashSize = 10 / zoom + const scaledDashPattern = `${dashSize} ${dashSize}` + const scaledDashPattern2 = `${3 / zoom} ${10 / zoom}` + const scaledPointRadius = 5 / zoom + const scaledPointStrokeWidth = 1 / zoom + + const previewPos = tool.getPreviewPosition() + + const workingPoints = useMemo(() => { + const points: Vec2[] = [...state.points, previewPos] + return points + }, [state.points, previewPos]) + + const derivedGeometry = useMemo(() => { + if (workingPoints.length < 2 || state.thickness <= 0) { + return null + } + + return computeDerivedSegments(workingPoints, 'left', state.thickness) + }, [workingPoints, state.thickness, tool]) + + return ( + + + + {derivedGeometry?.type === 'segments' && + derivedGeometry.segments.map((segment, index) => ( + + ))} + + {state.points.length > 1 && ( + + )} + + {state.points.length > 0 && ( + + )} + + {state.points.map((point, index) => ( + + ))} + + + + ) +} diff --git a/src/editor/tools/perimeter/add/PerimeterToolInspector.test.tsx b/src/editor/tools/perimeter/add/PerimeterToolInspector.test.tsx index f7dbda1dc..ccdef913b 100644 --- a/src/editor/tools/perimeter/add/PerimeterToolInspector.test.tsx +++ b/src/editor/tools/perimeter/add/PerimeterToolInspector.test.tsx @@ -3,8 +3,10 @@ import { fireEvent, render, screen } from '@testing-library/react' import { type Mock, vi } from 'vitest' import { createWallAssemblyId } from '@/building/model/ids' +import type { SnappingService } from '@/editor/canvas/services/SnappingService' import { ToolSystem } from '@/editor/tools/system/ToolSystem' import { ZERO_VEC2 } from '@/shared/geometry' +import { partialMock } from '@/test/helpers' import { PerimeterTool } from './PerimeterTool' import { PerimeterToolInspector } from './PerimeterToolInspector' @@ -74,11 +76,12 @@ describe('PerimeterToolInspector', () => { points: [], pointer: ZERO_VEC2, snapResult: undefined, - snapContext: { - snapPoints: [], - alignPoints: [], - referenceLineSegments: [] - }, + snapService: partialMock>({ + referencePoint: null, + referenceMinDistance: 42, + findSnapResult: vi.fn(), + addSnapCandidate: vi.fn() + }), isCurrentSegmentValid: true, isClosingSegmentValid: true, wallAssemblyId: createWallAssemblyId(), diff --git a/src/editor/tools/shared/polygon/PolygonToolOverlay.test.tsx b/src/editor/tools/shared/polygon/PolygonToolOverlay.test.tsx index e62b66ebb..62872e216 100644 --- a/src/editor/tools/shared/polygon/PolygonToolOverlay.test.tsx +++ b/src/editor/tools/shared/polygon/PolygonToolOverlay.test.tsx @@ -1,11 +1,11 @@ import { beforeEach, vi } from 'vitest' import { createWallAssemblyId } from '@/building/model/ids' -import type { SnapResult } from '@/editor/canvas/services/SnappingService' +import type { SnapResult, SnappingService } from '@/editor/canvas/services/SnappingService' import { PerimeterTool } from '@/editor/tools/perimeter/add/PerimeterTool' import { ToolSystem } from '@/editor/tools/system/ToolSystem' import { ZERO_VEC2, newVec2 } from '@/shared/geometry' -import { renderSvg } from '@/test/helpers' +import { partialMock, renderSvg } from '@/test/helpers' import { PolygonToolOverlay } from './PolygonToolOverlay' @@ -36,7 +36,12 @@ describe('PolygonToolOverlay', () => { points: [], pointer: ZERO_VEC2, snapResult: undefined, - snapCandidates: [], + snapService: partialMock>({ + referencePoint: null, + referenceMinDistance: 42, + findSnapResult: vi.fn(), + addSnapCandidate: vi.fn() + }), isCurrentSegmentValid: true, isClosingSegmentValid: true, segmentLengthOverrides: [], diff --git a/src/editor/tools/shared/polyline/BasePolylineTool.ts b/src/editor/tools/shared/polyline/BasePolylineTool.ts deleted file mode 100644 index 556c7ea6a..000000000 --- a/src/editor/tools/shared/polyline/BasePolylineTool.ts +++ /dev/null @@ -1,317 +0,0 @@ -import { - type SnapCandidate, - type SnapResult, - type SnappingContext, - SnappingService -} from '@/editor/canvas/services/SnappingService' -import type { LengthInputPosition } from '@/editor/canvas/services/length-input' -import { activateLengthInput, deactivateLengthInput } from '@/editor/canvas/services/length-input' -import { viewportActions } from '@/editor/canvas/state/viewportStore' -import { BaseTool } from '@/editor/tools/system/BaseTool' -import type { ToolSystem } from '@/editor/tools/system/ToolSystem' -import type { CursorStyle, EditorEvent } from '@/editor/tools/system/types' -import { - type Length, - type LineSegment2D, - type Vec2, - ZERO_VEC2, - direction, - scaleAddVec2, - wouldPolygonSelfIntersect -} from '@/shared/geometry' - -export interface PolylineValidationContext { - existingWalls: { centerLine: LineSegment2D }[] -} - -export interface PolylineToolStateBase { - points: Vec2[] - pointer: Vec2 - snapResult?: SnapResult - snapCandidates: SnapCandidate[] - isCurrentSegmentValid: boolean - lengthOverride: Length | null - segmentLengthOverrides: (Length | null)[] - validationContext: PolylineValidationContext -} - -export abstract class BasePolylineTool extends BaseTool { - public state: TState - - private snappingService: SnappingService | null = null - - protected constructor(toolSystem: ToolSystem, initialState: Omit) { - super(toolSystem) - const initialCandidates = this.createBaseSnapCandidates([]) - this.state = { - points: [] as Vec2[], - pointer: ZERO_VEC2, - snapResult: undefined, - isCurrentSegmentValid: true, - lengthOverride: null, - segmentLengthOverrides: [] as (Length | null)[], - validationContext: this.createInitialValidationContext(), - snapCandidates: this.extendSnapCandidates(initialCandidates), - ...initialState - } as TState - } - - protected createInitialValidationContext(): PolylineValidationContext { - return { - existingWalls: [] - } - } - - handlePointerDown(event: EditorEvent): boolean { - this.state.pointer = event.worldCoordinates - this.state.snapResult = this.findSnap(event.worldCoordinates) - const snapCoords = this.state.snapResult?.position ?? event.worldCoordinates - - if (!this.state.isCurrentSegmentValid) return true - - const shouldTerminate = this.shouldTerminateAtSnap(this.state.snapResult) - - let pointToAdd = snapCoords - if (this.state.lengthOverride && this.state.points.length > 0) { - const lastPoint = this.state.points[this.state.points.length - 1] - const dir = direction(lastPoint, snapCoords) - pointToAdd = scaleAddVec2(lastPoint, dir, this.state.lengthOverride) - } - - if (this.state.points.length > 0) { - this.state.segmentLengthOverrides.push(this.state.lengthOverride) - } - - this.state.points.push(pointToAdd) - this.updateSnapCandidates() - this.clearLengthOverride() - this.updateValidation() - - if (this.state.points.length >= 1) { - this.activateLengthInputForNextSegment() - } - - if (shouldTerminate && this.state.points.length >= 2) { - this.complete() - } - - return true - } - - handlePointerMove(event: EditorEvent): boolean { - this.state.pointer = event.worldCoordinates - this.state.snapResult = this.findSnap(event.worldCoordinates) - this.updateValidation() - this.triggerRender() - return true - } - - handleKeyDown(event: KeyboardEvent): boolean { - if (event.key === 'Escape') { - if (this.state.lengthOverride) { - this.clearLengthOverride() - return true - } - if (this.state.points.length > 0) { - this.cancel() - return true - } - return false - } - - if (event.key === 'Enter' && this.state.points.length >= this.getMinimumPointCount()) { - this.complete() - return true - } - - return false - } - - onActivate(): void { - this.resetDrawingState() - this.onToolActivated() - this.updateSnapCandidates() - } - - onDeactivate(): void { - this.resetDrawingState() - this.onToolDeactivated() - deactivateLengthInput() - } - - getCursor(): CursorStyle { - return 'crosshair' - } - - public cancel(): void { - this.resetDrawingState() - this.onPolylineCancelled() - deactivateLengthInput() - } - - public complete(): void { - if (this.state.points.length < this.getMinimumPointCount()) return - - const points = [...this.state.points] - - try { - this.onPolylineCompleted(points) - } catch (error) { - this.onPolylineCompletionFailed(error) - } - - this.resetDrawingState() - deactivateLengthInput() - } - - public getPreviewPosition(): Vec2 { - const currentPos = this.state.snapResult?.position ?? this.state.pointer - - if (!this.state.lengthOverride || this.state.points.length === 0) { - return currentPos - } - - const lastPoint = this.state.points[this.state.points.length - 1] - const dir = direction(lastPoint, currentPos) - return scaleAddVec2(lastPoint, dir, this.state.lengthOverride) - } - - protected updateSnapCandidates(): void { - const candidates = this.createBaseSnapCandidates(this.state.points) - this.state.snapCandidates = this.extendSnapCandidates(candidates) - this.snappingService = null - this.triggerRender() - } - - protected createBaseSnapCandidates(points: readonly Vec2[]): SnapCandidate[] { - const candidates: SnapCandidate[] = [] - - for (let i = 1; i < points.length; i += 1) { - candidates.push({ - type: 'segment', - segment: { start: points[i - 1], end: points[i] } - }) - } - - for (const point of points) { - candidates.push({ - type: 'point', - position: point, - mode: 'align' - }) - } - - return candidates - } - - protected extendSnapCandidates(candidates: SnapCandidate[]): SnapCandidate[] { - return candidates - } - - public getMinimumPointCount(): number { - return 2 - } - - protected shouldTerminateAtSnap(_snapResult: SnapResult | undefined): boolean { - return false - } - - protected abstract onPolylineCompleted(points: Vec2[]): void - - protected onPolylineCancelled(): void {} - - protected onPolylineCompletionFailed(error: unknown): void { - console.error('Failed to create polyline:', error) - } - - protected onToolActivated(): void {} - - protected onToolDeactivated(): void {} - - private getOrCreateSnappingService(): SnappingService { - if (!this.snappingService) { - const context: SnappingContext = { - candidates: this.state.snapCandidates - } - this.snappingService = new SnappingService(context) - - if (this.state.points.length > 0) { - this.snappingService.referencePoint = this.state.points[this.state.points.length - 1] - this.snappingService.referenceMinDistance = 50 - } - } - return this.snappingService - } - - private findSnap(target: Vec2): SnapResult | undefined { - const result = this.getOrCreateSnappingService().findSnapResult(target) - return result ?? undefined - } - - private updateValidation(): void { - if (this.state.points.length === 0) { - this.state.isCurrentSegmentValid = true - return - } - - const currentPos = this.state.snapResult?.position ?? this.state.pointer - this.state.isCurrentSegmentValid = !wouldPolygonSelfIntersect(this.state.points, currentPos) - } - - public setLengthOverride(length: Length | null): void { - this.state.lengthOverride = length - this.triggerRender() - } - - public clearLengthOverride(): void { - this.state.lengthOverride = null - this.triggerRender() - } - - private activateLengthInputForNextSegment(): void { - if (this.state.points.length === 0) return - - activateLengthInput({ - position: this.getLengthInputPosition(), - placeholder: this.getLengthInputPlaceholder(), - onCommit: (length: Length) => { - this.setLengthOverride(length) - }, - onCancel: () => { - this.clearLengthOverride() - } - }) - } - - private getLengthInputPlaceholder(): string { - return 'Enter length...' - } - - private getLengthInputPosition(): LengthInputPosition { - const { worldToStage } = viewportActions() - - if (this.state.points.length === 0) { - return { x: 400, y: 300 } - } - - const lastPoint = this.state.points[this.state.points.length - 1] - const stageCoords = worldToStage(lastPoint) - - return { - x: stageCoords[0] + 20, - y: stageCoords[1] - 30 - } - } - - private resetDrawingState(): void { - this.state.points = [] - this.state.pointer = ZERO_VEC2 - this.state.snapResult = undefined - this.state.isCurrentSegmentValid = true - this.state.lengthOverride = null - this.state.segmentLengthOverrides = [] - this.state.validationContext = this.createInitialValidationContext() - this.snappingService = null - this.updateSnapCandidates() - } -} diff --git a/src/editor/tools/system/ToolSystemProvider.tsx b/src/editor/tools/system/ToolSystemProvider.tsx index 25f6be264..0d653da54 100644 --- a/src/editor/tools/system/ToolSystemProvider.tsx +++ b/src/editor/tools/system/ToolSystemProvider.tsx @@ -8,6 +8,7 @@ import { SelectTool } from '@/editor/tools/basic/SelectTool' import { MoveTool } from '@/editor/tools/basic/movement/MoveTool' import { FloorAreaTool } from '@/editor/tools/floors/add-area/FloorAreaTool' import { FloorOpeningTool } from '@/editor/tools/floors/add-opening/FloorOpeningTool' +import { IntermediateWallTool } from '@/editor/tools/intermediate-wall/add/IntermediateWallTool' import { AddOpeningTool } from '@/editor/tools/perimeter/add-opening/AddOpeningTool' import { AddPostTool } from '@/editor/tools/perimeter/add-post/AddPostTool' import { PerimeterTool } from '@/editor/tools/perimeter/add/PerimeterTool' @@ -33,6 +34,7 @@ function createAndInitializeToolSystem(): ToolSystem { system.registerTool(SplitWallTool) system.registerTool(RoofTool) system.registerTool(TestDataTool) + system.registerTool(IntermediateWallTool) const handleDelete = () => { const selectedId = getCurrentSelection() diff --git a/src/editor/tools/system/metadata.ts b/src/editor/tools/system/metadata.ts index 9afce10a0..c32307388 100644 --- a/src/editor/tools/system/metadata.ts +++ b/src/editor/tools/system/metadata.ts @@ -1,4 +1,4 @@ -import { BetweenVerticalStart, MousePointer, Move, Rocket } from 'lucide-react' +import { BetweenVerticalStart, BrickWallIcon, MousePointer, Move, Rocket } from 'lucide-react' import { FloorAreaIcon, @@ -65,6 +65,11 @@ export const TOOL_METADATA: Record = { nameKey: 'testData', iconComponent: Rocket, hotkey: 't' + }, + 'intermediate-wall.add': { + nameKey: 'intermediateWallAdd', + iconComponent: BrickWallIcon, + hotkey: 'i' } } as const diff --git a/src/editor/tools/system/types.ts b/src/editor/tools/system/types.ts index 6954cf242..254b8d465 100644 --- a/src/editor/tools/system/types.ts +++ b/src/editor/tools/system/types.ts @@ -16,6 +16,7 @@ export type ToolId = | 'perimeter.split-wall' | 'roofs.add-roof' | 'test.data' + | 'intermediate-wall.add' export type CursorStyle = | 'default' diff --git a/src/shared/i18n/locales/de/toolbar.json b/src/shared/i18n/locales/de/toolbar.json index 4f8b240df..c7c688a0a 100644 --- a/src/shared/i18n/locales/de/toolbar.json +++ b/src/shared/i18n/locales/de/toolbar.json @@ -23,6 +23,7 @@ "basicSelect": "Auswählen", "floorsAddArea": "Bodenfläche zeichnen", "floorsAddOpening": "Boden-Aussparung zeichnen", + "intermediateWallAdd": "Zwischenwand zeichnen", "perimeterAdd": "Gebäudekontur zeichnen", "perimeterAddOpening": "Öffnung hinzufügen", "perimeterAddPost": "Ständer hinzufügen", diff --git a/src/shared/i18n/locales/en/toolbar.json b/src/shared/i18n/locales/en/toolbar.json index 86f4816d4..5c0ed7220 100644 --- a/src/shared/i18n/locales/en/toolbar.json +++ b/src/shared/i18n/locales/en/toolbar.json @@ -23,6 +23,7 @@ "basicSelect": "Select", "floorsAddArea": "Floor Area", "floorsAddOpening": "Floor Opening", + "intermediateWallAdd": "Draw Intermediate Wall", "perimeterAdd": "Building Perimeter", "perimeterAddOpening": "Add Opening", "perimeterAddPost": "Add Post", From b5e257f002185e8c27ad9339a11932dba110cf87 Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Thu, 26 Mar 2026 19:53:35 +0100 Subject: [PATCH 09/21] Fix some issues --- src/building/model/rooms.ts | 2 +- .../store/slices/intermediateWallGeometry.ts | 16 +++++++--------- .../store/slices/intermediateWallsSlice.ts | 15 +++++++++++++++ .../canvas/layers/tools/SelectionOutline.tsx | 13 ++++++++++++- .../canvas/layers/tools/SelectionOverlay.tsx | 3 ++- src/editor/canvas/layers/walls/WallNodeShape.tsx | 4 ++-- 6 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/building/model/rooms.ts b/src/building/model/rooms.ts index fb8f70399..55e858841 100644 --- a/src/building/model/rooms.ts +++ b/src/building/model/rooms.ts @@ -45,7 +45,7 @@ export interface InnerWallNode extends BaseWallNode { } export interface BaseWallNodeGeometry { - boundary: Polygon2D + boundary?: Polygon2D center: Vec2 } diff --git a/src/building/store/slices/intermediateWallGeometry.ts b/src/building/store/slices/intermediateWallGeometry.ts index d9a11cbb5..38a0211ee 100644 --- a/src/building/store/slices/intermediateWallGeometry.ts +++ b/src/building/store/slices/intermediateWallGeometry.ts @@ -63,7 +63,7 @@ export function updateAllWallNodeGeometry( let points: Vec2[] if (connectedWallLines.length === 0) { - throw new Error(`Inner wall node ${nodeId} has no connected walls`) + points = [node.position] } else if (connectedWallLines.length === 1) { points = updateWallEnd(state, connectedWallLines[0], node) } else if (connectedWallLines.length === 2) { @@ -72,15 +72,13 @@ export function updateAllWallNodeGeometry( points = updateComplexCorner(node, connectedWallLines, nodePositions, state) } - if (points.length >= 3) { - const sum = points.reduce((acc, p) => addVec2(acc, p), ZERO_VEC2) - const centroid = scaleVec2(sum, 1 / points.length) - const newGeometry: InnerWallNodeGeometry = { - center: centroid, - boundary: ensurePolygonIsClockwise({ points }) - } - state._wallNodeGeometry[nodeId] = newGeometry + const sum = points.reduce((acc, p) => addVec2(acc, p), ZERO_VEC2) + const centroid = scaleVec2(sum, 1 / points.length) + const newGeometry: InnerWallNodeGeometry = { + center: centroid, + boundary: points.length >= 3 ? ensurePolygonIsClockwise({ points }) : undefined } + state._wallNodeGeometry[nodeId] = newGeometry } } diff --git a/src/building/store/slices/intermediateWallsSlice.ts b/src/building/store/slices/intermediateWallsSlice.ts index a34a031ac..b6521bbc8 100644 --- a/src/building/store/slices/intermediateWallsSlice.ts +++ b/src/building/store/slices/intermediateWallsSlice.ts @@ -106,6 +106,15 @@ export const createIntermediateWallsSlice: StateCreator< state.intermediateWalls[wallId] = wall perimeter.intermediateWallIds.push(wallId) + if (!(start.nodeId in state.wallNodes)) { + throw new NotFoundError('Wall node', start.nodeId) + } + state.wallNodes[start.nodeId].connectedWallIds.push(wallId) + if (!(end.nodeId in state.wallNodes)) { + throw new NotFoundError('Wall node', end.nodeId) + } + state.wallNodes[end.nodeId].connectedWallIds.push(wallId) + updateAllWallNodeGeometry(state, perimeterId) updateTimestampDraft(state, wallId) @@ -127,6 +136,12 @@ export const createIntermediateWallsSlice: StateCreator< delete state.intermediateWalls[wallId] delete state._intermediateWallGeometry[wallId] + const startNode = state.wallNodes[wall.start.nodeId] + const endNode = state.wallNodes[wall.end.nodeId] + + startNode.connectedWallIds = startNode.connectedWallIds.filter(id => id !== wallId) + endNode.connectedWallIds = endNode.connectedWallIds.filter(id => id !== wallId) + removeTimestampDraft(state, wallId) cleanupOrphanedNodes(state, wall.start.nodeId, wallId) diff --git a/src/editor/canvas/layers/tools/SelectionOutline.tsx b/src/editor/canvas/layers/tools/SelectionOutline.tsx index 752e51ff4..cc633524a 100644 --- a/src/editor/canvas/layers/tools/SelectionOutline.tsx +++ b/src/editor/canvas/layers/tools/SelectionOutline.tsx @@ -16,7 +16,18 @@ export function SelectionOutline({ points }: { points: Vec2[] }): React.JSX.Elem const strokeWidth = 4 / zoom const dashPattern = `${10 / zoom} ${10 / zoom}` - return ( + return points.length === 1 ? ( + + ) : ( - + {pathD && } Date: Sat, 28 Mar 2026 06:23:16 +0100 Subject: [PATCH 10/21] WIP: Add tests --- .../store/slices/__tests__/testHelpers.ts | 342 +++++++- .../slices/intermediateWallGeometry.test.ts | 454 ++++++++++ .../slices/intermediateWallsSlice.test.ts | 790 ++++++++++++++++++ .../store/slices/intermediateWallsSlice.ts | 15 +- 4 files changed, 1595 insertions(+), 6 deletions(-) create mode 100644 src/building/store/slices/intermediateWallGeometry.test.ts create mode 100644 src/building/store/slices/intermediateWallsSlice.test.ts diff --git a/src/building/store/slices/__tests__/testHelpers.ts b/src/building/store/slices/__tests__/testHelpers.ts index 143f04f07..5f7dfe06c 100644 --- a/src/building/store/slices/__tests__/testHelpers.ts +++ b/src/building/store/slices/__tests__/testHelpers.ts @@ -1,9 +1,18 @@ import { expect, vi } from 'vitest' -import type { WallPostParams } from '@/building/model' -import type { PerimeterId } from '@/building/model/ids' +import type { + Perimeter, + PerimeterCorner, + PerimeterCornerGeometry, + PerimeterWall, + PerimeterWallGeometry, + WallPostParams +} from '@/building/model' +import type { PerimeterCornerId, PerimeterId, PerimeterWallId } from '@/building/model/ids' import { createStoreyId, isOpeningId, isWallPostId } from '@/building/model/ids' import { NotFoundError } from '@/building/store/errors' +import type { IntermediateWallsSlice, IntermediateWallsState } from '@/building/store/slices/intermediateWallsSlice' +import { createIntermediateWallsSlice } from '@/building/store/slices/intermediateWallsSlice' import { type PerimetersSlice, type PerimetersState, @@ -240,3 +249,332 @@ export function mockPost(params: Partial): WallPostParams { ...params } } + +// --------------------------------------------------------------------------- +// Intermediate wall test helpers +// --------------------------------------------------------------------------- + +interface MockPerimeterData { + perimeterId: PerimeterId + wallIds: PerimeterWallId[] + cornerIds: PerimeterCornerId[] +} + +export function createMockPerimeterState( + width = 10000, + height = 5000, + thickness = 420 +): { + perimetersState: PerimetersState + perimeterData: MockPerimeterData +} { + const w = width + const h = height + const t = thickness + + const wallIds: PerimeterWallId[] = [ + 'outwall_bottom' as PerimeterWallId, + 'outwall_right' as PerimeterWallId, + 'outwall_top' as PerimeterWallId, + 'outwall_left' as PerimeterWallId + ] + const cornerIds: PerimeterCornerId[] = [ + 'outcorner_bl' as PerimeterCornerId, + 'outcorner_br' as PerimeterCornerId, + 'outcorner_tr' as PerimeterCornerId, + 'outcorner_tl' as PerimeterCornerId + ] + const perimeterId = 'perimeter_test' as PerimeterId + + const perimeter: Perimeter = { + id: perimeterId, + storeyId: 'storey_test' as any, + wallIds, + cornerIds, + roomIds: [], + wallNodeIds: [], + intermediateWallIds: [], + referenceSide: 'inside' + } + + const perimeterWalls: Record = { + [wallIds[0]]: { + id: wallIds[0], + perimeterId, + startCornerId: cornerIds[0], + endCornerId: cornerIds[1], + entityIds: [], + thickness: t, + wallAssemblyId: 'wa_test' as any + }, + [wallIds[1]]: { + id: wallIds[1], + perimeterId, + startCornerId: cornerIds[1], + endCornerId: cornerIds[2], + entityIds: [], + thickness: t, + wallAssemblyId: 'wa_test' as any + }, + [wallIds[2]]: { + id: wallIds[2], + perimeterId, + startCornerId: cornerIds[2], + endCornerId: cornerIds[3], + entityIds: [], + thickness: t, + wallAssemblyId: 'wa_test' as any + }, + [wallIds[3]]: { + id: wallIds[3], + perimeterId, + startCornerId: cornerIds[3], + endCornerId: cornerIds[0], + entityIds: [], + thickness: t, + wallAssemblyId: 'wa_test' as any + } + } + + const perimeterCorners: Record = { + [cornerIds[0]]: { + id: cornerIds[0], + perimeterId, + previousWallId: wallIds[3], + nextWallId: wallIds[0], + referencePoint: newVec2(0, 0), + constructedByWall: 'previous' + }, + [cornerIds[1]]: { + id: cornerIds[1], + perimeterId, + previousWallId: wallIds[0], + nextWallId: wallIds[1], + referencePoint: newVec2(w, 0), + constructedByWall: 'previous' + }, + [cornerIds[2]]: { + id: cornerIds[2], + perimeterId, + previousWallId: wallIds[1], + nextWallId: wallIds[2], + referencePoint: newVec2(w, h), + constructedByWall: 'previous' + }, + [cornerIds[3]]: { + id: cornerIds[3], + perimeterId, + previousWallId: wallIds[2], + nextWallId: wallIds[3], + referencePoint: newVec2(0, h), + constructedByWall: 'previous' + } + } + + const _perimeterGeometry: Record = { + [perimeterId]: { + outerPolygon: { + points: [newVec2(-t, -t), newVec2(w + t, -t), newVec2(w + t, h + t), newVec2(-t, h + t)] + }, + innerPolygon: { + points: [newVec2(0, 0), newVec2(w, 0), newVec2(w, h), newVec2(0, h)] + } + } + } + + const _perimeterWallGeometry: Record = { + [wallIds[0]]: { + insideLine: { start: newVec2(0, 0), end: newVec2(w, 0) }, + outsideLine: { start: newVec2(-t, -t), end: newVec2(w + t, -t) }, + insideLength: w, + outsideLength: w, + wallLength: w, + direction: newVec2(1, 0), + outsideDirection: newVec2(0, -1), + polygon: { points: [] } + }, + [wallIds[1]]: { + insideLine: { start: newVec2(w, 0), end: newVec2(w, h) }, + outsideLine: { start: newVec2(w + t, -t), end: newVec2(w + t, h + t) }, + insideLength: h, + outsideLength: h, + wallLength: h, + direction: newVec2(0, 1), + outsideDirection: newVec2(1, 0), + polygon: { points: [] } + }, + [wallIds[2]]: { + insideLine: { start: newVec2(w, h), end: newVec2(0, h) }, + outsideLine: { start: newVec2(w + t, h + t), end: newVec2(-t, h + t) }, + insideLength: w, + outsideLength: w, + wallLength: w, + direction: newVec2(-1, 0), + outsideDirection: newVec2(0, 1), + polygon: { points: [] } + }, + [wallIds[3]]: { + insideLine: { start: newVec2(0, h), end: newVec2(0, 0) }, + outsideLine: { start: newVec2(-t, h + t), end: newVec2(-t, -t) }, + insideLength: h, + outsideLength: h, + wallLength: h, + direction: newVec2(0, -1), + outsideDirection: newVec2(-1, 0), + polygon: { points: [] } + } + } + + const _perimeterCornerGeometry: Record = { + [cornerIds[0]]: { + insidePoint: newVec2(0, 0), + outsidePoint: newVec2(-t, -t), + interiorAngle: 90, + exteriorAngle: 270, + polygon: { points: [] } + }, + [cornerIds[1]]: { + insidePoint: newVec2(w, 0), + outsidePoint: newVec2(w + t, -t), + interiorAngle: 90, + exteriorAngle: 270, + polygon: { points: [] } + }, + [cornerIds[2]]: { + insidePoint: newVec2(w, h), + outsidePoint: newVec2(w + t, h + t), + interiorAngle: 90, + exteriorAngle: 270, + polygon: { points: [] } + }, + [cornerIds[3]]: { + insidePoint: newVec2(0, h), + outsidePoint: newVec2(-t, h + t), + interiorAngle: 90, + exteriorAngle: 270, + polygon: { points: [] } + } + } + + const perimetersState: PerimetersState = { + perimeters: { [perimeterId]: perimeter }, + _perimeterGeometry, + perimeterWalls, + _perimeterWallGeometry, + perimeterCorners, + _perimeterCornerGeometry, + openings: {}, + _openingGeometry: {}, + wallPosts: {}, + _wallPostGeometry: {} + } + + return { + perimetersState, + perimeterData: { perimeterId, wallIds, cornerIds } + } +} + +export type IntermediateWallsTestState = IntermediateWallsSlice & + PerimetersState & { timestamps: Record } + +export function setupIntermediateWallsSlice( + perimeterStateOverrides?: Partial, + intermediateStateOverrides?: Partial +) { + const { perimetersState, perimeterData } = createMockPerimeterState() + + const mergedPerimeters: PerimetersState = { ...perimetersState, ...perimeterStateOverrides } + const mergedIntermediate: IntermediateWallsState = { + intermediateWalls: {}, + _intermediateWallGeometry: {}, + wallNodes: {}, + _wallNodeGeometry: {}, + ...intermediateStateOverrides + } + + const mockSet = vi.fn() + const mockGet = vi.fn() + + const state: IntermediateWallsTestState = { + ...mergedPerimeters, + ...mergedIntermediate, + timestamps: {}, + actions: null as any + } + + state.actions = createIntermediateWallsSlice(mockSet, mockGet, state as any).actions + + mockGet.mockImplementation(() => state) + + mockSet.mockImplementation((updater: any) => { + if (typeof updater === 'function') { + updater(state) + } + }) + + return { state, mockSet, mockGet, perimeterData } +} + +export function expectConsistentIntermediateWallReferences( + state: IntermediateWallsState & { perimeters: Record }, + perimeterId: PerimeterId +): void { + const perimeter = state.perimeters[perimeterId] + expect(perimeter).toBeDefined() + + for (const wallId of perimeter.intermediateWallIds) { + const wall = state.intermediateWalls[wallId] + expect(wall, `Intermediate wall ${wallId} should exist`).toBeDefined() + expect(wall.perimeterId, `Wall ${wallId} should reference perimeter ${perimeterId}`).toBe(perimeterId) + + const startNode = state.wallNodes[wall.start.nodeId] + expect(startNode, `Start node ${wall.start.nodeId} of wall ${wallId} should exist`).toBeDefined() + expect(startNode.connectedWallIds, `Start node of wall ${wallId} should reference it`).toContain(wallId) + + const endNode = state.wallNodes[wall.end.nodeId] + expect(endNode, `End node ${wall.end.nodeId} of wall ${wallId} should exist`).toBeDefined() + expect(endNode.connectedWallIds, `End node of wall ${wallId} should reference it`).toContain(wallId) + } + + for (const nodeId of perimeter.wallNodeIds) { + const node = state.wallNodes[nodeId] + expect(node, `Wall node ${nodeId} should exist`).toBeDefined() + expect(node.perimeterId, `Node ${nodeId} should reference perimeter ${perimeterId}`).toBe(perimeterId) + + for (const connectedWallId of node.connectedWallIds) { + expect( + state.intermediateWalls[connectedWallId], + `Connected wall ${connectedWallId} of node ${nodeId} should exist` + ).toBeDefined() + } + } +} + +export function expectNoOrphanedIntermediateEntities( + state: IntermediateWallsState & { perimeters: Record } +): void { + const allWallIds = new Set() + const allNodeIds = new Set() + + for (const perimeter of Object.values(state.perimeters)) { + for (const id of perimeter.intermediateWallIds) allWallIds.add(id) + for (const id of perimeter.wallNodeIds) allNodeIds.add(id) + } + + for (const wallId of Object.keys(state.intermediateWalls)) { + expect(allWallIds.has(wallId), `Intermediate wall ${wallId} should be referenced by a perimeter`).toBe(true) + } + + for (const nodeId of Object.keys(state.wallNodes)) { + expect(allNodeIds.has(nodeId), `Wall node ${nodeId} should be referenced by a perimeter`).toBe(true) + } + + for (const wallId of Object.keys(state._intermediateWallGeometry)) { + expect(allWallIds.has(wallId), `Intermediate wall geometry for ${wallId} should have corresponding wall`).toBe(true) + } + + for (const nodeId of Object.keys(state._wallNodeGeometry)) { + expect(allNodeIds.has(nodeId), `Wall node geometry for ${nodeId} should have corresponding node`).toBe(true) + } +} diff --git a/src/building/store/slices/intermediateWallGeometry.test.ts b/src/building/store/slices/intermediateWallGeometry.test.ts new file mode 100644 index 000000000..b5ef81eae --- /dev/null +++ b/src/building/store/slices/intermediateWallGeometry.test.ts @@ -0,0 +1,454 @@ +import { describe, expect, it } from 'vitest' + +import { createIntermediateWallId, createWallNodeId } from '@/building/model/ids' +import { computeWallLines, updateAllWallNodeGeometry } from '@/building/store/slices/intermediateWallGeometry' +import { distVec2, dotVec2, newVec2, perpendicularCCW } from '@/shared/geometry' + +import { setupIntermediateWallsSlice } from './__tests__/testHelpers' + +describe('intermediateWallGeometry', () => { + describe('computeWallLines', () => { + const start = newVec2(0, 0) + const end = newVec2(1000, 0) + const thickness = 100 + + describe('same axis alignment (startAxis === endAxis)', () => { + it('should create parallel left/right lines for center/center on horizontal segment', () => { + const result = computeWallLines(start, 'center', end, 'center', thickness) + + expect(result.left.direction).toEqual(newVec2(1, 0)) + expect(result.right.direction).toEqual(newVec2(1, 0)) + + const leftPerpDir = perpendicularCCW(result.left.direction) + const distance = Math.abs(dotVec2(leftPerpDir, result.right.point) - dotVec2(leftPerpDir, result.left.point)) + expect(distance).toBeCloseTo(thickness, 1) + }) + + it('should create parallel left/right lines for center/center on vertical segment', () => { + const vStart = newVec2(0, 0) + const vEnd = newVec2(0, 1000) + + const result = computeWallLines(vStart, 'center', vEnd, 'center', thickness) + + expect(result.left.direction).toEqual(newVec2(0, 1)) + expect(result.right.direction).toEqual(newVec2(0, 1)) + + const leftPerpDir = perpendicularCCW(result.left.direction) + const distance = Math.abs(dotVec2(leftPerpDir, result.right.point) - dotVec2(leftPerpDir, result.left.point)) + expect(distance).toBeCloseTo(thickness, 1) + }) + + it('should place left line through start point for left/left', () => { + const result = computeWallLines(start, 'left', end, 'left', thickness) + + expect(result.left.point).toEqual(start) + }) + + it('should place right line through start point for right/right', () => { + const result = computeWallLines(start, 'right', end, 'right', thickness) + + expect(result.right.point).toEqual(start) + }) + + it('should offset left and right equally for center/center', () => { + const result = computeWallLines(start, 'center', end, 'center', thickness) + + const midX = (start[0] + end[0]) / 2 + const midY = (start[1] + end[1]) / 2 + const midPoint = newVec2(midX, midY) + + const leftPerpDist = dotVec2(perpendicularCCW(result.left.direction), result.left.point) + const rightPerpDist = dotVec2(perpendicularCCW(result.right.direction), result.right.point) + + const centerPerpDist = dotVec2(perpendicularCCW(result.left.direction), midPoint) + + expect(leftPerpDist).toBeCloseTo(centerPerpDist + thickness / 2, 1) + expect(rightPerpDist).toBeCloseTo(centerPerpDist - thickness / 2, 1) + }) + + it('should work correctly on a diagonal segment (45 degrees)', () => { + const dStart = newVec2(0, 0) + const dEnd = newVec2(1000, 1000) + + const result = computeWallLines(dStart, 'center', dEnd, 'center', thickness) + + const dir = result.left.direction + const expectedDirLen = Math.sqrt(2) + expect(dir[0]).toBeCloseTo(1 / expectedDirLen, 5) + expect(dir[1]).toBeCloseTo(1 / expectedDirLen, 5) + + const perpDir = perpendicularCCW(dir) + const distance = Math.abs(dotVec2(perpDir, result.right.point) - dotVec2(perpDir, result.left.point)) + expect(distance).toBeCloseTo(thickness, 1) + }) + + it('should have left/right distance equal to thickness for center/center', () => { + const result = computeWallLines(start, 'center', end, 'center', thickness) + + const perpDir = perpendicularCCW(result.left.direction) + const distance = Math.abs(dotVec2(perpDir, result.right.point) - dotVec2(perpDir, result.left.point)) + expect(distance).toBeCloseTo(thickness, 1) + }) + }) + + describe('different axis alignment (startAxis !== endAxis)', () => { + it('should use start point as left base for left/right', () => { + const result = computeWallLines(start, 'left', end, 'right', thickness) + + expect(result.left.point).toEqual(start) + }) + + it('should offset right base by -thickness from left for left/right', () => { + const result = computeWallLines(start, 'left', end, 'right', thickness) + + const leftPerpDir = perpendicularCCW(result.left.direction) + const leftDist = dotVec2(leftPerpDir, result.left.point) + const rightDist = dotVec2(leftPerpDir, result.right.point) + expect(Math.abs(leftDist - rightDist)).toBeCloseTo(thickness, 1) + }) + + it('should have parallel left/right line directions', () => { + const result = computeWallLines(start, 'left', end, 'right', thickness) + + const cross = + result.left.direction[0] * result.right.direction[1] - result.left.direction[1] * result.right.direction[0] + expect(Math.abs(cross)).toBeLessThan(1e-10) + }) + + it('should throw when thickness exceeds distance between points', () => { + const closeStart = newVec2(0, 0) + const closeEnd = newVec2(50, 0) + + expect(() => computeWallLines(closeStart, 'left', closeEnd, 'right', 100)).toThrow( + 'Wall thickness larger than distance between points' + ) + }) + + it('should succeed when thickness is just under distance', () => { + const closeStart = newVec2(0, 0) + const closeEnd = newVec2(100, 0) + + expect(() => computeWallLines(closeStart, 'left', closeEnd, 'right', 99.9)).not.toThrow() + }) + }) + }) + + describe('updateAllWallNodeGeometry', () => { + it('should compute geometry for a single wall between two inner nodes', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeAId = createWallNodeId() + const nodeBId = createWallNodeId() + const wallId = createIntermediateWallId() + + state.wallNodes[nodeAId] = { + id: nodeAId, + perimeterId, + type: 'inner', + position: newVec2(2000, 2500), + connectedWallIds: [wallId] + } + state.wallNodes[nodeBId] = { + id: nodeBId, + perimeterId, + type: 'inner', + position: newVec2(8000, 2500), + connectedWallIds: [wallId] + } + state.intermediateWalls[wallId] = { + id: wallId, + perimeterId, + openingIds: [], + start: { nodeId: nodeAId, axis: 'center' }, + end: { nodeId: nodeBId, axis: 'center' }, + thickness: 120 + } + state.perimeters[perimeterId].intermediateWallIds.push(wallId) + state.perimeters[perimeterId].wallNodeIds.push(nodeAId, nodeBId) + + updateAllWallNodeGeometry(state, perimeterId) + + const wallGeometry = state._intermediateWallGeometry[wallId] + expect(wallGeometry).toBeDefined() + expect(wallGeometry.wallLength).toBeCloseTo(6000, 0) + expect(wallGeometry.boundary.points).toHaveLength(4) + expect(wallGeometry.centerLine.start).toBeDefined() + expect(wallGeometry.centerLine.end).toBeDefined() + + const centerLen = distVec2(wallGeometry.centerLine.start, wallGeometry.centerLine.end) + expect(centerLen).toBeCloseTo(6000, 0) + expect(centerLen).toBeCloseTo(wallGeometry.wallLength, 0) + }) + + it('should compute node geometry for wall end node (1 connected wall)', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeAId = createWallNodeId() + const nodeBId = createWallNodeId() + const wallId = createIntermediateWallId() + + state.wallNodes[nodeAId] = { + id: nodeAId, + perimeterId, + type: 'inner', + position: newVec2(2000, 2500), + connectedWallIds: [wallId] + } + state.wallNodes[nodeBId] = { + id: nodeBId, + perimeterId, + type: 'inner', + position: newVec2(8000, 2500), + connectedWallIds: [wallId] + } + state.intermediateWalls[wallId] = { + id: wallId, + perimeterId, + openingIds: [], + start: { nodeId: nodeAId, axis: 'center' }, + end: { nodeId: nodeBId, axis: 'center' }, + thickness: 120 + } + state.perimeters[perimeterId].intermediateWallIds.push(wallId) + state.perimeters[perimeterId].wallNodeIds.push(nodeAId, nodeBId) + + updateAllWallNodeGeometry(state, perimeterId) + + const nodeAGeometry = state._wallNodeGeometry[nodeAId] + expect(nodeAGeometry).toBeDefined() + expect(nodeAGeometry.boundary).toBeUndefined() + + const nodeBGeometry = state._wallNodeGeometry[nodeBId] + expect(nodeBGeometry).toBeDefined() + expect(nodeBGeometry.boundary).toBeUndefined() + }) + + it('should compute node boundary with 4 points for simple 90-degree corner (2 walls)', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeId = createWallNodeId() + const nodeAId = createWallNodeId() + const nodeBId = createWallNodeId() + const wallAId = createIntermediateWallId() + const wallBId = createIntermediateWallId() + + state.wallNodes[nodeId] = { + id: nodeId, + perimeterId, + type: 'inner', + position: newVec2(5000, 2500), + connectedWallIds: [wallAId, wallBId] + } + state.wallNodes[nodeAId] = { + id: nodeAId, + perimeterId, + type: 'inner', + position: newVec2(2000, 2500), + connectedWallIds: [wallAId] + } + state.wallNodes[nodeBId] = { + id: nodeBId, + perimeterId, + type: 'inner', + position: newVec2(5000, 4500), + connectedWallIds: [wallBId] + } + state.intermediateWalls[wallAId] = { + id: wallAId, + perimeterId, + openingIds: [], + start: { nodeId: nodeAId, axis: 'center' }, + end: { nodeId, axis: 'center' }, + thickness: 120 + } + state.intermediateWalls[wallBId] = { + id: wallBId, + perimeterId, + openingIds: [], + start: { nodeId, axis: 'center' }, + end: { nodeId: nodeBId, axis: 'center' }, + thickness: 120 + } + state.perimeters[perimeterId].intermediateWallIds.push(wallAId, wallBId) + state.perimeters[perimeterId].wallNodeIds.push(nodeId, nodeAId, nodeBId) + + updateAllWallNodeGeometry(state, perimeterId) + + const nodeGeometry = state._wallNodeGeometry[nodeId] + expect(nodeGeometry).toBeDefined() + expect(nodeGeometry.boundary?.points).toHaveLength(4) + }) + + it('should handle colinear walls meeting at a node (180-degree junction)', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeId = createWallNodeId() + const nodeAId = createWallNodeId() + const nodeBId = createWallNodeId() + const wallAId = createIntermediateWallId() + const wallBId = createIntermediateWallId() + + state.wallNodes[nodeId] = { + id: nodeId, + perimeterId, + type: 'inner', + position: newVec2(5000, 2500), + connectedWallIds: [wallAId, wallBId] + } + state.wallNodes[nodeAId] = { + id: nodeAId, + perimeterId, + type: 'inner', + position: newVec2(2000, 2500), + connectedWallIds: [wallAId] + } + state.wallNodes[nodeBId] = { + id: nodeBId, + perimeterId, + type: 'inner', + position: newVec2(8000, 2500), + connectedWallIds: [wallBId] + } + state.intermediateWalls[wallAId] = { + id: wallAId, + perimeterId, + openingIds: [], + start: { nodeId: nodeAId, axis: 'center' }, + end: { nodeId, axis: 'center' }, + thickness: 120 + } + state.intermediateWalls[wallBId] = { + id: wallBId, + perimeterId, + openingIds: [], + start: { nodeId, axis: 'center' }, + end: { nodeId: nodeBId, axis: 'center' }, + thickness: 120 + } + state.perimeters[perimeterId].intermediateWallIds.push(wallAId, wallBId) + state.perimeters[perimeterId].wallNodeIds.push(nodeId, nodeAId, nodeBId) + + updateAllWallNodeGeometry(state, perimeterId) + + const nodeGeometry = state._wallNodeGeometry[nodeId] + expect(nodeGeometry).toBeDefined() + expect(nodeGeometry.boundary?.points).toHaveLength(4) + + const wallAGeometry = state._intermediateWallGeometry[wallAId] + const wallBGeometry = state._intermediateWallGeometry[wallBId] + expect(wallAGeometry).toBeDefined() + expect(wallBGeometry).toBeDefined() + expect(wallAGeometry.wallLength + wallBGeometry.wallLength).toBeCloseTo(6000, 0) + }) + + it('should compute position for perimeter wall node from offset', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId, wallIds } = perimeterData + + const nodeId = createWallNodeId() + const innerNodeId = createWallNodeId() + const wallId = createIntermediateWallId() + const offset = 3000 + + state.wallNodes[nodeId] = { + id: nodeId, + perimeterId, + type: 'perimeter', + wallId: wallIds[0], + offsetFromCornerStart: offset, + connectedWallIds: [wallId] + } + state.wallNodes[innerNodeId] = { + id: innerNodeId, + perimeterId, + type: 'inner', + position: newVec2(3000, 2500), + connectedWallIds: [wallId] + } + state.intermediateWalls[wallId] = { + id: wallId, + perimeterId, + openingIds: [], + start: { nodeId, axis: 'center' }, + end: { nodeId: innerNodeId, axis: 'center' }, + thickness: 120 + } + state.perimeters[perimeterId].intermediateWallIds.push(wallId) + state.perimeters[perimeterId].wallNodeIds.push(nodeId, innerNodeId) + + updateAllWallNodeGeometry(state, perimeterId) + + const nodeGeometry = state._wallNodeGeometry[nodeId] + expect(nodeGeometry).toBeDefined() + expect(nodeGeometry.center[0]).toBeCloseTo(3000, 0) + expect(nodeGeometry.center[1]).toBeCloseTo(-210, 0) + }) + + it('should return early for non-existent perimeter', () => { + const { state } = setupIntermediateWallsSlice() + + expect(() => { updateAllWallNodeGeometry(state, 'perimeter_nonexistent' as any); }).not.toThrow() + }) + + it('should return without error for empty perimeter (no walls/nodes)', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + + state.perimeters[perimeterData.perimeterId].intermediateWallIds = [] + state.perimeters[perimeterData.perimeterId].wallNodeIds = [] + + expect(() => { updateAllWallNodeGeometry(state, perimeterData.perimeterId); }).not.toThrow() + }) + + it('should have centerLine between leftLine and rightLine for center/center wall', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeAId = createWallNodeId() + const nodeBId = createWallNodeId() + const wallId = createIntermediateWallId() + + state.wallNodes[nodeAId] = { + id: nodeAId, + perimeterId, + type: 'inner', + position: newVec2(2000, 2500), + connectedWallIds: [wallId] + } + state.wallNodes[nodeBId] = { + id: nodeBId, + perimeterId, + type: 'inner', + position: newVec2(8000, 2500), + connectedWallIds: [wallId] + } + state.intermediateWalls[wallId] = { + id: wallId, + perimeterId, + openingIds: [], + start: { nodeId: nodeAId, axis: 'center' }, + end: { nodeId: nodeBId, axis: 'center' }, + thickness: 120 + } + state.perimeters[perimeterId].intermediateWallIds.push(wallId) + state.perimeters[perimeterId].wallNodeIds.push(nodeAId, nodeBId) + + updateAllWallNodeGeometry(state, perimeterId) + + const geo = state._intermediateWallGeometry[wallId] + expect(geo).toBeDefined() + + const perpDir = perpendicularCCW(geo.direction) + + const centerStartPerp = dotVec2(perpDir, geo.centerLine.start) + const leftStartPerp = dotVec2(perpDir, geo.leftLine.start) + const rightStartPerp = dotVec2(perpDir, geo.rightLine.start) + + expect(centerStartPerp).toBeGreaterThan(Math.min(leftStartPerp, rightStartPerp)) + expect(centerStartPerp).toBeLessThan(Math.max(leftStartPerp, rightStartPerp)) + }) + }) +}) diff --git a/src/building/store/slices/intermediateWallsSlice.test.ts b/src/building/store/slices/intermediateWallsSlice.test.ts new file mode 100644 index 000000000..d1ca02810 --- /dev/null +++ b/src/building/store/slices/intermediateWallsSlice.test.ts @@ -0,0 +1,790 @@ +import { describe, expect, it } from 'vitest' + +import { NotFoundError } from '@/building/store/errors' + +import { + expectConsistentIntermediateWallReferences, + expectNoOrphanedIntermediateEntities, + setupIntermediateWallsSlice +} from './__tests__/testHelpers' + +describe('intermediateWallsSlice', () => { + describe('addInnerWallNode', () => { + it('should create an inner wall node and return geometry', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const result = state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + + expect(result.type).toBe('inner') + expect(result.position[0]).toBeCloseTo(3000, 0) + expect(result.position[1]).toBeCloseTo(2500, 0) + expect(result.perimeterId).toBe(perimeterId) + expect(result.center).toBeDefined() + expect(state.wallNodes[result.id]).toBeDefined() + expect(state._wallNodeGeometry[result.id]).toBeDefined() + expect(state.perimeters[perimeterId].wallNodeIds).toContain(result.id) + }) + + it('should throw NotFoundError for non-existent perimeter', () => { + const { state } = setupIntermediateWallsSlice() + + expect(() => state.actions.addInnerWallNode('perimeter_nonexistent' as any, { 0: 0, 1: 0 } as any)).toThrow( + NotFoundError + ) + }) + }) + + describe('addPerimeterWallNode', () => { + it('should create a perimeter wall node with correct offset', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId, wallIds } = perimeterData + + const result = state.actions.addPerimeterWallNode(perimeterId, wallIds[0], 3000) + + expect(result.type).toBe('perimeter') + expect(result.wallId).toBe(wallIds[0]) + expect(result.offsetFromCornerStart).toBe(3000) + expect(result.perimeterId).toBe(perimeterId) + expect(state.wallNodes[result.id]).toBeDefined() + expect(state._wallNodeGeometry[result.id]).toBeDefined() + expect(state.perimeters[perimeterId].wallNodeIds).toContain(result.id) + }) + + it('should throw NotFoundError for non-existent perimeter', () => { + const { state } = setupIntermediateWallsSlice() + + expect(() => + state.actions.addPerimeterWallNode('perimeter_nonexistent' as any, 'outwall_test' as any, 1000) + ).toThrow(NotFoundError) + }) + + it('should throw NotFoundError for non-existent perimeter wall', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + + expect(() => + state.actions.addPerimeterWallNode(perimeterData.perimeterId, 'outwall_nonexistent' as any, 1000) + ).toThrow(NotFoundError) + }) + }) + + describe('addIntermediateWall', () => { + it('should create a wall and update node connections', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + expect(wall.perimeterId).toBe(perimeterId) + expect(wall.thickness).toBe(120) + expect(wall.start.nodeId).toBe(nodeA.id) + expect(wall.end.nodeId).toBe(nodeB.id) + expect(wall.wallLength).toBeGreaterThan(0) + expect(wall.boundary.points).toHaveLength(4) + + expect(state.intermediateWalls[wall.id]).toBeDefined() + expect(state._intermediateWallGeometry[wall.id]).toBeDefined() + expect(state.perimeters[perimeterId].intermediateWallIds).toContain(wall.id) + expect(state.wallNodes[nodeA.id].connectedWallIds).toContain(wall.id) + expect(state.wallNodes[nodeB.id].connectedWallIds).toContain(wall.id) + + expectConsistentIntermediateWallReferences(state, perimeterId) + }) + + it('should throw NotFoundError for non-existent perimeter', () => { + const { state } = setupIntermediateWallsSlice() + + const nodeId = 'wallnode_test' as any + expect(() => + state.actions.addIntermediateWall( + 'perimeter_nonexistent' as any, + { nodeId, axis: 'center' }, + { nodeId, axis: 'center' }, + 120 + ) + ).toThrow(NotFoundError) + }) + + it('should throw NotFoundError for non-existent node', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + + const nodeA = state.actions.addInnerWallNode(perimeterData.perimeterId, { 0: 2000, 1: 2500 } as any) + + expect(() => + state.actions.addIntermediateWall( + perimeterData.perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: 'wallnode_nonexistent' as any, axis: 'center' }, + 120 + ) + ).toThrow(NotFoundError) + }) + + it('should throw for thickness <= 0', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + + expect(() => + state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 0 + ) + ).toThrow('Wall thickness must be greater than 0') + + expect(() => + state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + -50 + ) + ).toThrow('Wall thickness must be greater than 0') + }) + }) + + describe('removeIntermediateWall', () => { + it('should remove wall and clean up orphaned nodes', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + state.actions.removeIntermediateWall(wall.id) + + expect(state.intermediateWalls[wall.id]).toBeUndefined() + expect(state._intermediateWallGeometry[wall.id]).toBeUndefined() + expect(state.perimeters[perimeterId].intermediateWallIds).not.toContain(wall.id) + expect(state.wallNodes[nodeA.id]).toBeUndefined() + expect(state.wallNodes[nodeB.id]).toBeUndefined() + }) + + it('should remove orphaned nodes after wall removal', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + state.actions.removeIntermediateWall(wall.id) + + expect(state.wallNodes[nodeA.id]).toBeUndefined() + expect(state.wallNodes[nodeB.id]).toBeUndefined() + expect(state._wallNodeGeometry[nodeA.id]).toBeUndefined() + expect(state._wallNodeGeometry[nodeB.id]).toBeUndefined() + expect(state.perimeters[perimeterId].wallNodeIds).not.toContain(nodeA.id) + expect(state.perimeters[perimeterId].wallNodeIds).not.toContain(nodeB.id) + }) + + it('should only remove orphaned nodes, keeping nodes with other connections', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 5000, 1: 2500 } as any) + const nodeC = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + + const wall1 = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + const wall2 = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeB.id, axis: 'center' }, + { nodeId: nodeC.id, axis: 'center' }, + 120 + ) + + state.actions.removeIntermediateWall(wall2.id) + + expect(state.wallNodes[nodeA.id]).toBeDefined() + expect(state.wallNodes[nodeB.id]).toBeDefined() + expect(state.wallNodes[nodeC.id]).toBeUndefined() + expect(state.wallNodes[nodeB.id].connectedWallIds).toEqual([wall1.id]) + }) + + it('should be a no-op for non-existent wall', () => { + const { state } = setupIntermediateWallsSlice() + + expect(() => { state.actions.removeIntermediateWall('intermediate_nonexistent' as any); }).not.toThrow() + }) + + it('should recompute geometry for remaining walls', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 5000, 1: 2500 } as any) + const nodeC = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + + const wall1 = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + const wall2 = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeB.id, axis: 'center' }, + { nodeId: nodeC.id, axis: 'center' }, + 120 + ) + + state.actions.removeIntermediateWall(wall2.id) + + const remainingGeo = state._intermediateWallGeometry[wall1.id] + expect(remainingGeo).toBeDefined() + expect(remainingGeo.wallLength).toBeCloseTo(3000, 0) + expectNoOrphanedIntermediateEntities(state) + }) + }) + + describe('updateIntermediateWallThickness', () => { + it('should update wall thickness and recompute geometry', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + state.actions.updateIntermediateWallThickness(wall.id, 200) + + expect(state.intermediateWalls[wall.id].thickness).toBe(200) + const geo = state._intermediateWallGeometry[wall.id] + expect(geo.wallLength).toBeCloseTo(6000, 0) + }) + + it('should throw for non-existent wall', () => { + const { state } = setupIntermediateWallsSlice() + + expect(() => { state.actions.updateIntermediateWallThickness('intermediate_nonexistent' as any, 200); }).toThrow( + NotFoundError + ) + }) + + it('should throw for thickness <= 0', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + expect(() => { state.actions.updateIntermediateWallThickness(wall.id, 0); }).toThrow( + 'Wall thickness must be greater than 0' + ) + }) + }) + + describe('updateIntermediateWallAlignment', () => { + it('should update wall alignment and recompute geometry', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + state.actions.updateIntermediateWallAlignment(wall.id, 'left', 'right') + + expect(state.intermediateWalls[wall.id].start.axis).toBe('left') + expect(state.intermediateWalls[wall.id].end.axis).toBe('right') + expect(state._intermediateWallGeometry[wall.id]).toBeDefined() + }) + + it('should throw for non-existent wall', () => { + const { state } = setupIntermediateWallsSlice() + + expect(() => + { state.actions.updateIntermediateWallAlignment('intermediate_nonexistent' as any, 'left', 'right'); } + ).toThrow(NotFoundError) + }) + }) + + describe('splitIntermediateWallAtPoint', () => { + it('should split wall at midpoint into two walls', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + const newNodeId = state.actions.splitIntermediateWallAtPoint(wall.id, { 0: 5000, 1: 2500 } as any) + + expect(state.intermediateWalls[wall.id]).toBeUndefined() + expect(state._intermediateWallGeometry[wall.id]).toBeUndefined() + + const remainingWalls = state.perimeters[perimeterId].intermediateWallIds.filter(id => id !== wall.id) + expect(remainingWalls).toHaveLength(2) + + const wallA = state.intermediateWalls[remainingWalls[0]] + const wallB = state.intermediateWalls[remainingWalls[1]] + expect(wallA).toBeDefined() + expect(wallB).toBeDefined() + + expect(wallA.start.nodeId).toBe(nodeA.id) + expect(wallA.end.nodeId).toBe(newNodeId) + expect(wallB.start.nodeId).toBe(newNodeId) + expect(wallB.end.nodeId).toBe(nodeB.id) + + expect(wallA.thickness).toBe(120) + expect(wallB.thickness).toBe(120) + + const splitNode = state.wallNodes[newNodeId] + expect(splitNode).toBeDefined() + expect(splitNode.type).toBe('inner') + expect(splitNode.connectedWallIds).toHaveLength(2) + expect(splitNode.connectedWallIds).toContain(wallA.id) + expect(splitNode.connectedWallIds).toContain(wallB.id) + + expectConsistentIntermediateWallReferences(state, perimeterId) + expectNoOrphanedIntermediateEntities(state) + }) + + it('should preserve wallAssemblyId across split', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + state.intermediateWalls[wall.id].wallAssemblyId = 'iwa_test' as any + + state.actions.splitIntermediateWallAtPoint(wall.id, { 0: 5000, 1: 2500 } as any) + + const remainingWalls = state.perimeters[perimeterId].intermediateWallIds.filter(id => id !== wall.id) + const wallA = state.intermediateWalls[remainingWalls[0]] + const wallB = state.intermediateWalls[remainingWalls[1]] + + expect(wallA.wallAssemblyId).toBe('iwa_test') + expect(wallB.wallAssemblyId).toBe('iwa_test') + }) + + it('should split at 1/3 point with correct proportions', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + state.actions.splitIntermediateWallAtPoint(wall.id, { 0: 4000, 1: 2500 } as any) + + const remainingWalls = state.perimeters[perimeterId].intermediateWallIds.filter(id => id !== wall.id) + const geoA = state._intermediateWallGeometry[remainingWalls[0]] + const geoB = state._intermediateWallGeometry[remainingWalls[1]] + + expect(geoA.wallLength).toBeCloseTo(2000, -1) + expect(geoB.wallLength).toBeCloseTo(4000, -1) + }) + + it('should throw NotFoundError for non-existent wall', () => { + const { state } = setupIntermediateWallsSlice() + + expect(() => + state.actions.splitIntermediateWallAtPoint('intermediate_nonexistent' as any, { 0: 0, 1: 0 } as any) + ).toThrow(NotFoundError) + }) + + it('should return new node ID and add it to perimeter wallNodeIds', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + const newNodeId = state.actions.splitIntermediateWallAtPoint(wall.id, { 0: 5000, 1: 2500 } as any) + + expect(state.perimeters[perimeterId].wallNodeIds).toContain(newNodeId) + }) + }) + + describe('updateInnerWallNodePosition', () => { + it('should update position and recompute connected wall geometry', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + state.actions.updateInnerWallNodePosition(nodeA.id, { 0: 3000, 1: 2500 } as any) + + expect((state.wallNodes[nodeA.id] as any).position[0]).toBeCloseTo(3000, 0) + const geo = state._intermediateWallGeometry[wall.id] + expect(geo.wallLength).toBeCloseTo(5000, 0) + }) + + it('should throw for non-existent node', () => { + const { state } = setupIntermediateWallsSlice() + + expect(() => + { state.actions.updateInnerWallNodePosition('wallnode_nonexistent' as any, { 0: 0, 1: 0 } as any); } + ).toThrow(NotFoundError) + }) + + it('should throw when trying to update a perimeter node', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId, wallIds } = perimeterData + + const node = state.actions.addPerimeterWallNode(perimeterId, wallIds[0], 3000) + + expect(() => { state.actions.updateInnerWallNodePosition(node.id, { 0: 0, 1: 0 } as any); }).toThrow( + 'Cannot update position of perimeter wall node' + ) + }) + }) + + describe('updatePerimeterWallNodeOffset', () => { + it('should update offset and recompute geometry', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId, wallIds } = perimeterData + + const perimeterNode = state.actions.addPerimeterWallNode(perimeterId, wallIds[0], 3000) + const innerNode = state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: perimeterNode.id, axis: 'center' }, + { nodeId: innerNode.id, axis: 'center' }, + 120 + ) + + state.actions.updatePerimeterWallNodeOffset(perimeterNode.id, 5000) + + expect((state.wallNodes[perimeterNode.id] as any).offsetFromCornerStart).toBe(5000) + expect(state._intermediateWallGeometry[wall.id]).toBeDefined() + }) + + it('should throw for non-existent node', () => { + const { state } = setupIntermediateWallsSlice() + + expect(() => { state.actions.updatePerimeterWallNodeOffset('wallnode_nonexistent' as any, 1000); }).toThrow( + NotFoundError + ) + }) + + it('should throw when trying to update an inner node', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const node = state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + + expect(() => { state.actions.updatePerimeterWallNodeOffset(node.id, 1000); }).toThrow( + 'Cannot update offset of inner wall node' + ) + }) + }) + + describe('removeWallNode', () => { + it('should cascade delete connected walls', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 5000, 1: 2500 } as any) + const nodeC = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + + const wall1 = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + const wall2 = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeB.id, axis: 'center' }, + { nodeId: nodeC.id, axis: 'center' }, + 120 + ) + + state.actions.removeWallNode(nodeB.id) + + expect(state.wallNodes[nodeB.id]).toBeUndefined() + expect(state.intermediateWalls[wall1.id]).toBeUndefined() + expect(state.intermediateWalls[wall2.id]).toBeUndefined() + expect(state.wallNodes[nodeA.id]).toBeUndefined() + expect(state.wallNodes[nodeC.id]).toBeUndefined() + expectNoOrphanedIntermediateEntities(state) + }) + + it('should be a no-op for non-existent node', () => { + const { state } = setupIntermediateWallsSlice() + + expect(() => { state.actions.removeWallNode('wallnode_nonexistent' as any); }).not.toThrow() + }) + }) + + describe('getters', () => { + it('getIntermediateWallById should return merged model + geometry', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + const result = state.actions.getIntermediateWallById(wall.id) + + expect(result.id).toBe(wall.id) + expect(result.perimeterId).toBe(perimeterId) + expect(result.thickness).toBe(120) + expect(result.wallLength).toBeGreaterThan(0) + expect(result.boundary.points).toHaveLength(4) + expect(result.centerLine).toBeDefined() + }) + + it('getIntermediateWallById should throw for non-existent wall', () => { + const { state } = setupIntermediateWallsSlice() + + expect(() => state.actions.getIntermediateWallById('intermediate_nonexistent' as any)).toThrow(NotFoundError) + }) + + it('getAllIntermediateWalls should return all walls', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 5000, 1: 2500 } as any) + const nodeC = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + + state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeB.id, axis: 'center' }, + { nodeId: nodeC.id, axis: 'center' }, + 120 + ) + + const allWalls = state.actions.getAllIntermediateWalls() + expect(allWalls).toHaveLength(2) + allWalls.forEach(w => { + expect(w.wallLength).toBeGreaterThan(0) + expect(w.boundary.points).toBeDefined() + }) + }) + + it('getIntermediateWallsByPerimeter should return filtered walls', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + + state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + const walls = state.actions.getIntermediateWallsByPerimeter(perimeterId) + expect(walls).toHaveLength(1) + }) + + it('getIntermediateWallsByPerimeter should throw for non-existent perimeter', () => { + const { state } = setupIntermediateWallsSlice() + + expect(() => state.actions.getIntermediateWallsByPerimeter('perimeter_nonexistent' as any)).toThrow(NotFoundError) + }) + + it('getWallNodeById should return merged model + geometry for inner node', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const node = state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + + const result = state.actions.getWallNodeById(node.id) + + expect(result.id).toBe(node.id) + expect(result.type).toBe('inner') + expect(result.position[0]).toBeCloseTo(3000, 0) + expect(result.center).toBeDefined() + }) + + it('getWallNodeById should return merged model + geometry for perimeter node', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId, wallIds } = perimeterData + + const node = state.actions.addPerimeterWallNode(perimeterId, wallIds[0], 3000) + + const result = state.actions.getWallNodeById(node.id) + + expect(result.id).toBe(node.id) + expect(result.type).toBe('perimeter') + expect(result.center).toBeDefined() + }) + + it('getWallNodeById should throw for non-existent node', () => { + const { state } = setupIntermediateWallsSlice() + + expect(() => state.actions.getWallNodeById('wallnode_nonexistent' as any)).toThrow(NotFoundError) + }) + + it('getAllWallNodes should return all nodes', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId, wallIds } = perimeterData + + state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + state.actions.addPerimeterWallNode(perimeterId, wallIds[0], 5000) + + const nodes = state.actions.getAllWallNodes() + expect(nodes).toHaveLength(2) + }) + + it('getWallNodesByPerimeter should return filtered nodes', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId, wallIds } = perimeterData + + state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + state.actions.addPerimeterWallNode(perimeterId, wallIds[0], 5000) + + const nodes = state.actions.getWallNodesByPerimeter(perimeterId) + expect(nodes).toHaveLength(2) + }) + + it('getWallNodesByPerimeter should throw for non-existent perimeter', () => { + const { state } = setupIntermediateWallsSlice() + + expect(() => state.actions.getWallNodesByPerimeter('perimeter_nonexistent' as any)).toThrow(NotFoundError) + }) + }) + + describe('reference integrity', () => { + it('should maintain consistent references after add + remove cycle', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + state.actions.removeIntermediateWall(wall.id) + + expectNoOrphanedIntermediateEntities(state) + }) + + it('should maintain consistent references after split', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + state.actions.splitIntermediateWallAtPoint(wall.id, { 0: 5000, 1: 2500 } as any) + + expectConsistentIntermediateWallReferences(state, perimeterId) + expectNoOrphanedIntermediateEntities(state) + }) + + it('should maintain consistent references after node position update', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId } = perimeterData + + const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + state.actions.updateInnerWallNodePosition(nodeA.id, { 0: 3000, 1: 2500 } as any) + + expectConsistentIntermediateWallReferences(state, perimeterId) + expectNoOrphanedIntermediateEntities(state) + }) + }) +}) diff --git a/src/building/store/slices/intermediateWallsSlice.ts b/src/building/store/slices/intermediateWallsSlice.ts index b6521bbc8..6164b3725 100644 --- a/src/building/store/slices/intermediateWallsSlice.ts +++ b/src/building/store/slices/intermediateWallsSlice.ts @@ -260,20 +260,19 @@ export const createIntermediateWallsSlice: StateCreator< const originalWall = state.intermediateWalls[wallId] const perimeter = state.perimeters[originalWall.perimeterId] + const wallAId = createIntermediateWallId() + const wallBId = createIntermediateWallId() const newNodeIdInner = createWallNodeId() const newNode: WallNode = { id: newNodeIdInner, perimeterId: originalWall.perimeterId, type: 'inner', position: copyVec2(point), - connectedWallIds: [] + connectedWallIds: [wallAId, wallBId] } state.wallNodes[newNodeIdInner] = newNode perimeter.wallNodeIds.push(newNodeIdInner) - const wallAId = createIntermediateWallId() - const wallBId = createIntermediateWallId() - const wallA: IntermediateWall = { id: wallAId, perimeterId: originalWall.perimeterId, @@ -301,6 +300,14 @@ export const createIntermediateWallsSlice: StateCreator< perimeter.intermediateWallIds.push(wallBId) perimeter.intermediateWallIds = perimeter.intermediateWallIds.filter(id => id !== wallId) + const originalStartNode = state.wallNodes[originalWall.start.nodeId] + originalStartNode.connectedWallIds = originalStartNode.connectedWallIds + .filter(id => id !== wallId) + .concat(wallAId) + + const originalEndNode = state.wallNodes[originalWall.end.nodeId] + originalEndNode.connectedWallIds = originalEndNode.connectedWallIds.filter(id => id !== wallId).concat(wallBId) + delete state.intermediateWalls[wallId] delete state._intermediateWallGeometry[wallId] removeTimestampDraft(state, wallId) From d239f39838ba8dee8e582f2b5a6af029527c609d Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Sat, 28 Mar 2026 20:59:00 +0100 Subject: [PATCH 11/21] Some fixes --- .../store/slices/intermediateWallGeometry.ts | 4 +-- .../canvas/layers/walls/WallNodeShape.tsx | 6 ++-- .../add/IntermediateWallTool.ts | 32 ++++++++++--------- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/building/store/slices/intermediateWallGeometry.ts b/src/building/store/slices/intermediateWallGeometry.ts index 38a0211ee..9615739f8 100644 --- a/src/building/store/slices/intermediateWallGeometry.ts +++ b/src/building/store/slices/intermediateWallGeometry.ts @@ -410,10 +410,8 @@ function getNodePositions(perimeter: Perimeter, state: IntermediateWallsState & const node = state.wallNodes[nodeId] if (node.type === 'perimeter') { - const wall = state.perimeterWalls[node.wallId] const wallGeometry = state._perimeterWallGeometry[node.wallId] - const cornerGeometry = state._perimeterCornerGeometry[wall.startCornerId] - const position = scaleAddVec2(cornerGeometry.insidePoint, wallGeometry.direction, node.offsetFromCornerStart) + const position = scaleAddVec2(wallGeometry.insideLine.start, wallGeometry.direction, node.offsetFromCornerStart) nodePositions.set(nodeId, position) } else { diff --git a/src/editor/canvas/layers/walls/WallNodeShape.tsx b/src/editor/canvas/layers/walls/WallNodeShape.tsx index 96889b6dd..128931250 100644 --- a/src/editor/canvas/layers/walls/WallNodeShape.tsx +++ b/src/editor/canvas/layers/walls/WallNodeShape.tsx @@ -3,7 +3,7 @@ import { useWallNodeById } from '@/building/store' import { MATERIAL_COLORS } from '@/shared/theme/colors' import { polygonToSvgPath } from '@/shared/utils/svg' -const NODE_RADIUS = 100 +const NODE_RADIUS = 50 export function WallNodeShape({ nodeId }: { nodeId: WallNodeId }): React.JSX.Element { const node = useWallNodeById(nodeId) @@ -19,8 +19,10 @@ export function WallNodeShape({ nodeId }: { nodeId: WallNodeId }): React.JSX.Ele cx={node.center[0]} cy={node.center[1]} r={NODE_RADIUS} - fill={fillColor} + fill="var(--color-background)" + fillOpacity={0.5} stroke="var(--color-border-contrast)" + strokeOpacity={0.5} strokeWidth={10} /> diff --git a/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts index 29896d56f..ee5356a67 100644 --- a/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts +++ b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts @@ -25,6 +25,7 @@ import { ZERO_VEC2, direction, lineFromSegment, + perpendicular, projectVec2, scaleAddVec2 } from '@/shared/geometry' @@ -152,10 +153,14 @@ export class IntermediateWallTool extends BaseTool implements ToolImplementation if (this.state.points.length > 1) { const lastPoint = this.state.points[this.state.points.length - 2] - const line = { point: lastPoint, direction: direction(lastPoint, pointToAdd) } + const dir = direction(lastPoint, pointToAdd) this.snappingService.addSnapCandidate({ type: 'line', - line + line: { point: lastPoint, direction: dir } + }) + this.snappingService.addSnapCandidate({ + type: 'line', + line: { point: pointToAdd, direction: perpendicular(dir) } }) } } @@ -276,20 +281,17 @@ export class IntermediateWallTool extends BaseTool implements ToolImplementation throw new Error('No perimeter found for intermediate wall') } - for (let i = 0; i < points.length - 1; i++) { - const startPoint = points[i] - const endPoint = points[i + 1] - const isLast = i === points.length - 2 - - const startNode = - i === 0 && this.state.startEntity - ? this.getOrCreateEntityNode(startPoint, this.state.startEntity) - : modelActions.addInnerWallNode(perimeterId, startPoint) + const nodes = points.map((point, index) => { + return index === 0 && this.state.startEntity + ? this.getOrCreateEntityNode(point, this.state.startEntity) + : index === points.length - 1 && snapEntity + ? this.getOrCreateEntityNode(point, snapEntity) + : modelActions.addInnerWallNode(perimeterId, point) + }) - const endNode = - isLast && snapEntity - ? this.getOrCreateEntityNode(endPoint, snapEntity) - : modelActions.addInnerWallNode(perimeterId, endPoint) + for (let index = 0; index < points.length - 1; index++) { + const startNode = nodes[index] + const endNode = nodes[index + 1] modelActions.addIntermediateWall( perimeterId, From b5e23389bc4418120fc575d3690aa6eda8a4caec Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Mon, 30 Mar 2026 21:33:05 +0200 Subject: [PATCH 12/21] Fix deletion cascades for intermediate walls --- src/building/gcs/constraintGenerator.test.ts | 3 +- src/building/model/perimeters.ts | 1 + src/building/store/migrations/toVersion12.ts | 1 + src/building/store/migrations/toVersion15.ts | 8 + .../slices/__tests__/constraintsSlice.test.ts | 4 + .../store/slices/__tests__/testHelpers.ts | 52 +- src/building/store/slices/cleanup.test.ts | 447 ++++++++++++++++++ src/building/store/slices/cleanup.ts | 106 +++++ .../slices/intermediateWallGeometry.test.ts | 10 +- .../slices/intermediateWallsSlice.test.ts | 109 ++++- .../store/slices/intermediateWallsSlice.ts | 62 +-- src/building/store/slices/perimeterSlice.ts | 124 +++-- .../assemblies/walls/segmentation.test.ts | 3 +- 13 files changed, 773 insertions(+), 157 deletions(-) create mode 100644 src/building/store/slices/cleanup.test.ts create mode 100644 src/building/store/slices/cleanup.ts diff --git a/src/building/gcs/constraintGenerator.test.ts b/src/building/gcs/constraintGenerator.test.ts index 7eb7f4c16..555eda0e7 100644 --- a/src/building/gcs/constraintGenerator.test.ts +++ b/src/building/gcs/constraintGenerator.test.ts @@ -70,7 +70,8 @@ function makeWall( outsideLine: { start: newVec2(0, 0), end: newVec2(0, 0) }, direction: opts.direction, outsideDirection: newVec2(0, 0), - polygon: { points: [] } + polygon: { points: [] }, + wallNodeIds: [] } } diff --git a/src/building/model/perimeters.ts b/src/building/model/perimeters.ts index 486ea7104..ad3c2d6cb 100644 --- a/src/building/model/perimeters.ts +++ b/src/building/model/perimeters.ts @@ -41,6 +41,7 @@ export interface PerimeterWall { startCornerId: PerimeterCornerId endCornerId: PerimeterCornerId entityIds: WallEntityId[] + wallNodeIds: WallNodeId[] thickness: Length wallAssemblyId: WallAssemblyId diff --git a/src/building/store/migrations/toVersion12.ts b/src/building/store/migrations/toVersion12.ts index ca1428753..c38f53bcf 100644 --- a/src/building/store/migrations/toVersion12.ts +++ b/src/building/store/migrations/toVersion12.ts @@ -213,6 +213,7 @@ export const migrateToVersion12: Migration = state => { startCornerId, endCornerId, entityIds, + wallNodeIds: [], thickness, wallAssemblyId } diff --git a/src/building/store/migrations/toVersion15.ts b/src/building/store/migrations/toVersion15.ts index 36cfe3952..6ec19cb0e 100644 --- a/src/building/store/migrations/toVersion15.ts +++ b/src/building/store/migrations/toVersion15.ts @@ -19,4 +19,12 @@ export const migrateToVersion15: Migration = state => { if (!isRecord(state._wallNodeGeometry)) { ;(state as { _wallNodeGeometry: Record })._wallNodeGeometry = {} } + + if (isRecord(state.perimeterWalls)) { + for (const wall of Object.values(state.perimeterWalls)) { + if (isRecord(wall) && !Array.isArray(wall.wallNodeIds)) { + ;(wall as { wallNodeIds: unknown[] }).wallNodeIds = [] + } + } + } } diff --git a/src/building/store/slices/__tests__/constraintsSlice.test.ts b/src/building/store/slices/__tests__/constraintsSlice.test.ts index df5f396fd..aaa11782a 100644 --- a/src/building/store/slices/__tests__/constraintsSlice.test.ts +++ b/src/building/store/slices/__tests__/constraintsSlice.test.ts @@ -5,6 +5,7 @@ import type { PerimeterId, PerimeterWallId, StoreyId } from '@/building/model/id import { createStoreyId, createWallAssemblyId } from '@/building/model/ids' import type { ConstraintsSlice } from '@/building/store/slices/constraintsSlice' import { createConstraintsSlice } from '@/building/store/slices/constraintsSlice' +import { createIntermediateWallsSlice } from '@/building/store/slices/intermediateWallsSlice' import type { PerimetersSlice } from '@/building/store/slices/perimeterSlice' import { createPerimetersSlice } from '@/building/store/slices/perimeterSlice' import { ensurePolygonIsClockwise, wouldClosingPolygonSelfIntersect } from '@/shared/geometry/polygon' @@ -40,14 +41,17 @@ function setupCombinedSlice() { const testStoreyId = createStoreyId() const perimeterSlice = createPerimetersSlice(mockSet, mockGet, mockStore) + const intermediateWallsSlice = createIntermediateWallsSlice(mockSet, mockGet, mockStore) const constraintsSlice = createConstraintsSlice(mockSet, mockGet, mockStore) const slice = { ...perimeterSlice, + ...intermediateWallsSlice, ...constraintsSlice, timestamps: {}, actions: { ...perimeterSlice.actions, + ...intermediateWallsSlice.actions, ...constraintsSlice.actions } } as any as CombinedSlice diff --git a/src/building/store/slices/__tests__/testHelpers.ts b/src/building/store/slices/__tests__/testHelpers.ts index 5f7dfe06c..6fdfb2b23 100644 --- a/src/building/store/slices/__tests__/testHelpers.ts +++ b/src/building/store/slices/__tests__/testHelpers.ts @@ -11,6 +11,7 @@ import type { import type { PerimeterCornerId, PerimeterId, PerimeterWallId } from '@/building/model/ids' import { createStoreyId, isOpeningId, isWallPostId } from '@/building/model/ids' import { NotFoundError } from '@/building/store/errors' +import type { ConstraintsState } from '@/building/store/slices/constraintsSlice' import type { IntermediateWallsSlice, IntermediateWallsState } from '@/building/store/slices/intermediateWallsSlice' import { createIntermediateWallsSlice } from '@/building/store/slices/intermediateWallsSlice' import { @@ -18,6 +19,7 @@ import { type PerimetersState, createPerimetersSlice } from '@/building/store/slices/perimeterSlice' +import type { TimestampsState } from '@/config/store/slices/timestampsSlice' import type { MaterialId } from '@/materials/types' import type { Polygon2D } from '@/shared/geometry' import { newVec2 } from '@/shared/geometry' @@ -70,7 +72,14 @@ export function setupPerimeterSlice() { const testStoreyId = createStoreyId() slice = createPerimetersSlice(mockSet, mockGet, mockStore) - slice = { ...slice, timestamps: {}, buildingConstraints: {}, _constraintsByEntity: {} } as any + slice = { + ...slice, + timestamps: {}, + buildingConstraints: {}, + _constraintsByEntity: {}, + intermediateWalls: {}, + wallNodes: {} + } as any mockGet.mockImplementation(() => slice) @@ -304,6 +313,7 @@ export function createMockPerimeterState( startCornerId: cornerIds[0], endCornerId: cornerIds[1], entityIds: [], + wallNodeIds: [], thickness: t, wallAssemblyId: 'wa_test' as any }, @@ -313,6 +323,7 @@ export function createMockPerimeterState( startCornerId: cornerIds[1], endCornerId: cornerIds[2], entityIds: [], + wallNodeIds: [], thickness: t, wallAssemblyId: 'wa_test' as any }, @@ -322,6 +333,7 @@ export function createMockPerimeterState( startCornerId: cornerIds[2], endCornerId: cornerIds[3], entityIds: [], + wallNodeIds: [], thickness: t, wallAssemblyId: 'wa_test' as any }, @@ -331,6 +343,7 @@ export function createMockPerimeterState( startCornerId: cornerIds[3], endCornerId: cornerIds[0], entityIds: [], + wallNodeIds: [], thickness: t, wallAssemblyId: 'wa_test' as any } @@ -475,8 +488,7 @@ export function createMockPerimeterState( } } -export type IntermediateWallsTestState = IntermediateWallsSlice & - PerimetersState & { timestamps: Record } +export type IntermediateWallsTestState = IntermediateWallsSlice & PerimetersState & TimestampsState & ConstraintsState export function setupIntermediateWallsSlice( perimeterStateOverrides?: Partial, @@ -500,6 +512,8 @@ export function setupIntermediateWallsSlice( ...mergedPerimeters, ...mergedIntermediate, timestamps: {}, + _constraintsByEntity: {}, + buildingConstraints: {}, actions: null as any } @@ -517,7 +531,7 @@ export function setupIntermediateWallsSlice( } export function expectConsistentIntermediateWallReferences( - state: IntermediateWallsState & { perimeters: Record }, + state: IntermediateWallsState & { perimeters: Record; perimeterWalls: Record }, perimeterId: PerimeterId ): void { const perimeter = state.perimeters[perimeterId] @@ -548,20 +562,40 @@ export function expectConsistentIntermediateWallReferences( `Connected wall ${connectedWallId} of node ${nodeId} should exist` ).toBeDefined() } + + // Verify perimeter-wall nodes are tracked in their PerimeterWall.wallNodeIds + if (node.type === 'perimeter') { + const perimWall = state.perimeterWalls[node.wallId] + expect(perimWall, `Perimeter wall ${node.wallId} of node ${nodeId} should exist`).toBeDefined() + expect( + perimWall.wallNodeIds, + `Perimeter wall ${node.wallId} should track node ${nodeId} in wallNodeIds` + ).toContain(nodeId) + } } } export function expectNoOrphanedIntermediateEntities( - state: IntermediateWallsState & { perimeters: Record } + state: IntermediateWallsState & { + perimeters: Record + perimeterWalls: Record + } ): void { const allWallIds = new Set() const allNodeIds = new Set() + const allPerimeterWallNodeIds = new Set() for (const perimeter of Object.values(state.perimeters)) { for (const id of perimeter.intermediateWallIds) allWallIds.add(id) for (const id of perimeter.wallNodeIds) allNodeIds.add(id) } + for (const perimWall of Object.values(state.perimeterWalls)) { + for (const id of perimWall.wallNodeIds) { + allPerimeterWallNodeIds.add(id) + } + } + for (const wallId of Object.keys(state.intermediateWalls)) { expect(allWallIds.has(wallId), `Intermediate wall ${wallId} should be referenced by a perimeter`).toBe(true) } @@ -577,4 +611,12 @@ export function expectNoOrphanedIntermediateEntities( for (const nodeId of Object.keys(state._wallNodeGeometry)) { expect(allNodeIds.has(nodeId), `Wall node geometry for ${nodeId} should have corresponding node`).toBe(true) } + + // Verify perimeter-wall node IDs are consistent with perimeter.wallNodeIds + for (const nodeId of allPerimeterWallNodeIds) { + expect( + allNodeIds.has(nodeId), + `PerimeterWall.wallNodeIds entry ${nodeId} should be in a perimeter's wallNodeIds` + ).toBe(true) + } } diff --git a/src/building/store/slices/cleanup.test.ts b/src/building/store/slices/cleanup.test.ts new file mode 100644 index 000000000..6fa640526 --- /dev/null +++ b/src/building/store/slices/cleanup.test.ts @@ -0,0 +1,447 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { Perimeter, WallAssemblyId } from '@/building/model' +import { createConstraintsSlice } from '@/building/store/slices/constraintsSlice' +import { createIntermediateWallsSlice } from '@/building/store/slices/intermediateWallsSlice' +import { createPerimetersSlice } from '@/building/store/slices/perimeterSlice' +import type { Store } from '@/building/store/types' +import { newVec2 } from '@/shared/geometry' + +import { + createRectangularBoundary, + createTriangularBoundary, + expectConsistentIntermediateWallReferences, + expectNoOrphanedIntermediateEntities +} from './__tests__/testHelpers' +import { cleanUpOrphaned } from './cleanup' + +function setupMockStore() { + const mockSet = vi.fn() + const mockGet = vi.fn() + const mockStore = {} as any + + const perimeterSlice = createPerimetersSlice(mockSet, mockGet, mockStore) + const intermediateWallsSlice = createIntermediateWallsSlice(mockSet, mockGet, mockStore) + const constraintsSlice = createConstraintsSlice(mockSet, mockGet, mockStore) + + const store = { + ...perimeterSlice, + ...intermediateWallsSlice, + ...constraintsSlice, + timestamps: {}, + actions: { + ...perimeterSlice.actions, + ...intermediateWallsSlice.actions, + ...constraintsSlice.actions + } + } as any as Store + + mockGet.mockImplementation(() => store) + + mockSet.mockImplementation((updater: any) => { + if (typeof updater === 'function') { + updater(store) + } + }) + + return store +} + +describe('cleanUpOrphaned', () => { + let store: Store + let perimeter: Perimeter + let otherPerimeter: Perimeter + + beforeEach(() => { + store = setupMockStore() + + perimeter = store.actions.addPerimeter('storey_1', createRectangularBoundary(), 'wa_test' as WallAssemblyId, 300) + otherPerimeter = store.actions.addPerimeter( + 'storey_1', + createTriangularBoundary(), + 'wa_test' as WallAssemblyId, + 300 + ) + const node1 = store.actions.addInnerWallNode(perimeter.id, newVec2(3000, 0)) + const node2 = store.actions.addPerimeterWallNode(perimeter.id, perimeter.wallIds[0], 100) + + store.actions.addIntermediateWall( + perimeter.id, + { axis: 'left', nodeId: node1.id }, + { axis: 'left', nodeId: node2.id }, + 300 + ) + }) + + describe('perimeter wall cleanup', () => { + it('should remove perimeter wall when its perimeter is deleted', () => { + delete store.perimeters[perimeter.id] + cleanUpOrphaned(store) + + for (const wid of perimeter.wallIds) { + expect(store.perimeterWalls[wid]).toBeUndefined() + } + }) + + it('should remove perimeter wall when removed from perimeter.wallIds', () => { + const removedWallIds = [perimeter.wallIds[1], perimeter.wallIds[3]] + store.perimeters[perimeter.id].wallIds = [perimeter.wallIds[0], perimeter.wallIds[2]] + cleanUpOrphaned(store) + + for (const wid of removedWallIds) { + expect(store.perimeterWalls[wid]).toBeUndefined() + expect(store._perimeterWallGeometry[wid]).toBeUndefined() + } + }) + }) + + describe('corner cleanup', () => { + it('should remove orphaned corners when perimeter is deleted', () => { + delete store.perimeters[perimeter.id] + cleanUpOrphaned(store) + + for (const cid of perimeter.cornerIds) { + expect(store.perimeterCorners[cid]).toBeUndefined() + expect(store._perimeterCornerGeometry[cid]).toBeUndefined() + } + }) + }) + + describe('intermediate wall cleanup', () => { + it('should cascade delete intermediate walls when perimeter is deleted', () => { + const wn1 = store.actions.addInnerWallNode(perimeter.id, newVec2(0, 0)).id + const wn2 = store.actions.addInnerWallNode(perimeter.id, newVec2(0, 1000)).id + const iw1 = store.actions.addIntermediateWall( + perimeter.id, + { axis: 'left', nodeId: wn1 }, + { axis: 'left', nodeId: wn2 }, + 10 + ).id + + delete store.perimeters[perimeter.id] + cleanUpOrphaned(store) + + expect(store.intermediateWalls[iw1]).toBeUndefined() + expect(store._intermediateWallGeometry[iw1]).toBeUndefined() + expect(store.wallNodes[wn1]).toBeUndefined() + expect(store.wallNodes[wn2]).toBeUndefined() + expect(store._wallNodeGeometry[wn1]).toBeUndefined() + expect(store._wallNodeGeometry[wn2]).toBeUndefined() + }) + + it('should not affect intermediate walls on other perimeters when one is deleted', () => { + const wn1 = store.actions.addInnerWallNode(perimeter.id, newVec2(2000, 0)).id + const wn2 = store.actions.addInnerWallNode(perimeter.id, newVec2(8000, 0)).id + const iw1 = store.actions.addIntermediateWall( + perimeter.id, + { axis: 'left', nodeId: wn1 }, + { axis: 'left', nodeId: wn2 }, + 120 + ).id + + const ow1 = store.actions.addInnerWallNode(otherPerimeter.id, newVec2(500, 500)).id + const ow2 = store.actions.addInnerWallNode(otherPerimeter.id, newVec2(1000, 1000)).id + const oiw1 = store.actions.addIntermediateWall( + otherPerimeter.id, + { axis: 'left', nodeId: ow1 }, + { axis: 'left', nodeId: ow2 }, + 120 + ).id + + delete store.perimeters[perimeter.id] + cleanUpOrphaned(store) + + expect(store.intermediateWalls[iw1]).toBeUndefined() + expect(store.intermediateWalls[oiw1]).toBeDefined() + expect(store.wallNodes[ow1]).toBeDefined() + expect(store.wallNodes[ow2]).toBeDefined() + expectNoOrphanedIntermediateEntities(store) + }) + + it('should delete intermediate wall when its start node is deleted', () => { + const wn1 = store.actions.addInnerWallNode(perimeter.id, newVec2(2000, 0)).id + const wn2 = store.actions.addInnerWallNode(perimeter.id, newVec2(8000, 0)).id + const iw1 = store.actions.addIntermediateWall( + perimeter.id, + { axis: 'left', nodeId: wn1 }, + { axis: 'left', nodeId: wn2 }, + 120 + ).id + + delete store.wallNodes[wn1] + delete store._wallNodeGeometry[wn1] + cleanUpOrphaned(store) + + expect(store.intermediateWalls[iw1]).toBeUndefined() + expect(store._intermediateWallGeometry[iw1]).toBeUndefined() + expectNoOrphanedIntermediateEntities(store) + }) + }) + + describe('wall node cleanup', () => { + it('should remove orphaned inner wall nodes when their intermediate wall is deleted', () => { + const wn1 = store.actions.addInnerWallNode(perimeter.id, newVec2(2000, 0)).id + const wn2 = store.actions.addInnerWallNode(perimeter.id, newVec2(8000, 0)).id + const iw1 = store.actions.addIntermediateWall( + perimeter.id, + { axis: 'left', nodeId: wn1 }, + { axis: 'left', nodeId: wn2 }, + 120 + ).id + + const wallNodeCountBefore = Object.keys(store.wallNodes).length + delete store.intermediateWalls[iw1] + delete store._intermediateWallGeometry[iw1] + cleanUpOrphaned(store) + + expect(store.wallNodes[wn1]).toBeUndefined() + expect(store.wallNodes[wn2]).toBeUndefined() + expect(Object.keys(store.wallNodes).length).toBeLessThan(wallNodeCountBefore) + expectNoOrphanedIntermediateEntities(store) + }) + + it('should remove perimeter-wall node when its perimeter wall is deleted', () => { + const wn1 = store.actions.addPerimeterWallNode(perimeter.id, perimeter.wallIds[0], 3000).id + const wn2 = store.actions.addInnerWallNode(perimeter.id, newVec2(3000, 0)).id + const iw1 = store.actions.addIntermediateWall( + perimeter.id, + { axis: 'left', nodeId: wn1 }, + { axis: 'left', nodeId: wn2 }, + 120 + ).id + const wid = perimeter.wallIds[0] + + delete store.perimeterWalls[wid] + delete store._perimeterWallGeometry[wid] + cleanUpOrphaned(store) + + expect(store.wallNodes[wn1]).toBeUndefined() + expect(store.wallNodes[wn2]).toBeUndefined() + expect(store.intermediateWalls[iw1]).toBeUndefined() + expectNoOrphanedIntermediateEntities(store) + }) + + it('should keep shared nodes when one of their walls is deleted', () => { + const wn1 = store.actions.addInnerWallNode(perimeter.id, newVec2(2000, 0)).id + const wn2 = store.actions.addInnerWallNode(perimeter.id, newVec2(5000, 0)).id + const wn3 = store.actions.addInnerWallNode(perimeter.id, newVec2(8000, 0)).id + const iw1 = store.actions.addIntermediateWall( + perimeter.id, + { axis: 'left', nodeId: wn1 }, + { axis: 'left', nodeId: wn2 }, + 120 + ).id + const iw2 = store.actions.addIntermediateWall( + perimeter.id, + { axis: 'left', nodeId: wn2 }, + { axis: 'left', nodeId: wn3 }, + 120 + ).id + + delete store.intermediateWalls[iw2] + delete store._intermediateWallGeometry[iw2] + cleanUpOrphaned(store) + + expect(store.wallNodes[wn1]).toBeDefined() + expect(store.wallNodes[wn2]).toBeDefined() + expect(store.wallNodes[wn2].connectedWallIds).toEqual([iw1]) + expect(store.wallNodes[wn3]).toBeUndefined() + expect(store.intermediateWalls[iw1]).toBeDefined() + expectNoOrphanedIntermediateEntities(store) + }) + + it('should cascade: deleting a shared node cascades to connected walls, then to their other nodes', () => { + const wn1 = store.actions.addInnerWallNode(perimeter.id, newVec2(2000, 0)).id + const wn2 = store.actions.addInnerWallNode(perimeter.id, newVec2(5000, 0)).id + const wn3 = store.actions.addInnerWallNode(perimeter.id, newVec2(8000, 0)).id + const iw1 = store.actions.addIntermediateWall( + perimeter.id, + { axis: 'left', nodeId: wn1 }, + { axis: 'left', nodeId: wn2 }, + 120 + ).id + const iw2 = store.actions.addIntermediateWall( + perimeter.id, + { axis: 'left', nodeId: wn2 }, + { axis: 'left', nodeId: wn3 }, + 120 + ).id + + delete store.wallNodes[wn2] + delete store._wallNodeGeometry[wn2] + cleanUpOrphaned(store) + + expect(store.wallNodes[wn1]).toBeUndefined() + expect(store.wallNodes[wn2]).toBeUndefined() + expect(store.wallNodes[wn3]).toBeUndefined() + expect(store.intermediateWalls[iw1]).toBeUndefined() + expect(store.intermediateWalls[iw2]).toBeUndefined() + expectNoOrphanedIntermediateEntities(store) + }) + }) + + describe('perimeter reference cleanup', () => { + it('should update perimeter.wallNodeIds to only include valid nodes', () => { + const wn1 = store.actions.addInnerWallNode(perimeter.id, newVec2(2000, 0)).id + const wn2 = store.actions.addInnerWallNode(perimeter.id, newVec2(8000, 0)).id + const iw1 = store.actions.addIntermediateWall( + perimeter.id, + { axis: 'left', nodeId: wn1 }, + { axis: 'left', nodeId: wn2 }, + 120 + ).id + + const p = store.perimeters[perimeter.id] + expect(p.wallNodeIds).toContain(wn1) + expect(p.wallNodeIds).toContain(wn2) + + delete store.intermediateWalls[iw1] + delete store._intermediateWallGeometry[iw1] + cleanUpOrphaned(store) + + const pAfter = store.perimeters[perimeter.id] + expect(pAfter.wallNodeIds).not.toContain(wn1) + expect(pAfter.wallNodeIds).not.toContain(wn2) + expectNoOrphanedIntermediateEntities(store) + }) + + it('should update perimeter.intermediateWallIds to only include valid walls', () => { + const wn1 = store.actions.addInnerWallNode(perimeter.id, newVec2(2000, 0)).id + const wn2 = store.actions.addInnerWallNode(perimeter.id, newVec2(5000, 0)).id + const wn3 = store.actions.addInnerWallNode(perimeter.id, newVec2(8000, 0)).id + const iw1 = store.actions.addIntermediateWall( + perimeter.id, + { axis: 'left', nodeId: wn1 }, + { axis: 'left', nodeId: wn2 }, + 120 + ).id + const iw2 = store.actions.addIntermediateWall( + perimeter.id, + { axis: 'left', nodeId: wn2 }, + { axis: 'left', nodeId: wn3 }, + 120 + ).id + + const p = store.perimeters[perimeter.id] + expect(p.intermediateWallIds).toContain(iw1) + expect(p.intermediateWallIds).toContain(iw2) + + delete store.intermediateWalls[iw1] + delete store._intermediateWallGeometry[iw1] + cleanUpOrphaned(store) + + const pAfter = store.perimeters[perimeter.id] + expect(pAfter.intermediateWallIds).not.toContain(iw1) + expect(pAfter.intermediateWallIds).toContain(iw2) + expectNoOrphanedIntermediateEntities(store) + }) + + it('should update perimeter.wallIds to only include valid walls', () => { + const wid = perimeter.wallIds[1] + const p = store.perimeters[perimeter.id] + expect(p.wallIds).toContain(wid) + + delete store.perimeterWalls[wid] + delete store._perimeterWallGeometry[wid] + cleanUpOrphaned(store) + + const pAfter = store.perimeters[perimeter.id] + expect(pAfter.wallIds).not.toContain(wid) + expect(pAfter.wallIds).toHaveLength(3) + }) + }) + + describe('perimeter wall wallNodeIds cleanup', () => { + it('should remove deleted node IDs from PerimeterWall.wallNodeIds', () => { + const node = store.actions.addPerimeterWallNode(perimeter.id, perimeter.wallIds[0], 3000) + const wid = perimeter.wallIds[0] + expect(store.perimeterWalls[wid].wallNodeIds).toContain(node.id) + + delete store.wallNodes[node.id] + delete store._wallNodeGeometry[node.id] + cleanUpOrphaned(store) + + expect(store.perimeterWalls[wid].wallNodeIds).not.toContain(node.id) + }) + }) + + describe('geometry cleanup', () => { + it('should remove orphaned geometry for intermediate walls and nodes', () => { + const wn1 = store.actions.addInnerWallNode(perimeter.id, newVec2(2000, 0)).id + const wn2 = store.actions.addInnerWallNode(perimeter.id, newVec2(8000, 0)).id + const iw1 = store.actions.addIntermediateWall( + perimeter.id, + { axis: 'left', nodeId: wn1 }, + { axis: 'left', nodeId: wn2 }, + 120 + ).id + + delete store.intermediateWalls[iw1] + delete store._intermediateWallGeometry[iw1] + cleanUpOrphaned(store) + + expect(store._wallNodeGeometry[wn1]).toBeUndefined() + expect(store._wallNodeGeometry[wn2]).toBeUndefined() + expectNoOrphanedIntermediateEntities(store) + }) + }) + + describe('edge cases', () => { + it('should handle empty state without errors', () => { + for (const pid of Object.keys(store.perimeters)) { + delete store.perimeters[pid as any] + } + cleanUpOrphaned(store) + + expect(Object.keys(store.perimeterWalls)).toHaveLength(0) + expect(Object.keys(store.perimeterCorners)).toHaveLength(0) + }) + + it('should handle full perimeter deletion with perimeter-wall nodes', () => { + const wn1 = store.actions.addPerimeterWallNode(perimeter.id, perimeter.wallIds[0], 3000).id + const wn2 = store.actions.addInnerWallNode(perimeter.id, newVec2(3000, 0)).id + store.actions.addIntermediateWall(perimeter.id, { axis: 'left', nodeId: wn1 }, { axis: 'left', nodeId: wn2 }, 120) + + delete store.perimeters[perimeter.id] + cleanUpOrphaned(store) + + expect(Object.keys(store.intermediateWalls)).toHaveLength(0) + expect(Object.keys(store.wallNodes)).toHaveLength(0) + expect(Object.keys(store._intermediateWallGeometry)).toHaveLength(0) + expect(Object.keys(store._wallNodeGeometry)).toHaveLength(0) + }) + }) + + describe('reference integrity', () => { + it('should maintain consistent references after perimeter deletion', () => { + const wn1 = store.actions.addInnerWallNode(perimeter.id, newVec2(2000, 0)).id + const wn2 = store.actions.addInnerWallNode(perimeter.id, newVec2(8000, 0)).id + store.actions.addIntermediateWall(perimeter.id, { axis: 'left', nodeId: wn1 }, { axis: 'left', nodeId: wn2 }, 120) + + delete store.perimeters[perimeter.id] + cleanUpOrphaned(store) + + expectNoOrphanedIntermediateEntities(store) + }) + + it('should maintain consistent references after wall node deletion', () => { + const wn1 = store.actions.addInnerWallNode(perimeter.id, newVec2(2000, 0)).id + const wn2 = store.actions.addInnerWallNode(perimeter.id, newVec2(5000, 0)).id + const wn3 = store.actions.addInnerWallNode(perimeter.id, newVec2(8000, 0)).id + store.actions.addIntermediateWall(perimeter.id, { axis: 'left', nodeId: wn1 }, { axis: 'left', nodeId: wn2 }, 120) + const iw2 = store.actions.addIntermediateWall( + perimeter.id, + { axis: 'left', nodeId: wn2 }, + { axis: 'left', nodeId: wn3 }, + 120 + ).id + + delete store.intermediateWalls[iw2] + delete store._intermediateWallGeometry[iw2] + cleanUpOrphaned(store) + + expectConsistentIntermediateWallReferences(store, perimeter.id) + expectNoOrphanedIntermediateEntities(store) + }) + }) +}) diff --git a/src/building/store/slices/cleanup.ts b/src/building/store/slices/cleanup.ts new file mode 100644 index 000000000..80ee6c1e0 --- /dev/null +++ b/src/building/store/slices/cleanup.ts @@ -0,0 +1,106 @@ +import type { EntityId, WallId, WallNodeId } from '@/building/model/ids' +import { removeConstraintsForEntityDraft } from '@/building/store/slices/constraintsSlice' +import { removeTimestampDraft } from '@/building/store/slices/timestampsSlice' +import type { StoreState } from '@/building/store/types' + +export function cleanUpOrphaned(state: StoreState) { + // Track IDs of entities to delete for timestamp cleanup + const entityIdsToRemove: EntityId[] = [] + + // Track valid wall IDs while cleaning up walls + const validWallIds = new Set() + const validNodeIds = new Set() + + // Clean up orphaned perimeter walls + for (const wall of Object.values(state.perimeterWalls)) { + if (!(wall.perimeterId in state.perimeters) || !state.perimeters[wall.perimeterId].wallIds.includes(wall.id)) { + delete state.perimeterWalls[wall.id] + delete state._perimeterWallGeometry[wall.id] + entityIdsToRemove.push(wall.id) + removeConstraintsForEntityDraft(state, wall.id) + } else { + validWallIds.add(wall.id) + wall.wallNodeIds = wall.wallNodeIds.filter(nodeId => nodeId in state.wallNodes) + } + } + + // Clean up orphaned intermediate walls + for (const wall of Object.values(state.intermediateWalls)) { + const startNodeExists = wall.start.nodeId in state.wallNodes + const endNodeExists = wall.end.nodeId in state.wallNodes + if ( + !(wall.perimeterId in state.perimeters) || + !state.perimeters[wall.perimeterId].intermediateWallIds.includes(wall.id) || + !startNodeExists || + !endNodeExists + ) { + delete state.intermediateWalls[wall.id] + delete state._intermediateWallGeometry[wall.id] + entityIdsToRemove.push(wall.id) + removeConstraintsForEntityDraft(state, wall.id) + } else { + validWallIds.add(wall.id) + } + } + + // Clean up orphaned corners + for (const corner of Object.values(state.perimeterCorners)) { + if ( + !(corner.perimeterId in state.perimeters) || + !state.perimeters[corner.perimeterId].cornerIds.includes(corner.id) + ) { + delete state.perimeterCorners[corner.id] + delete state._perimeterCornerGeometry[corner.id] + entityIdsToRemove.push(corner.id) + removeConstraintsForEntityDraft(state, corner.id) + } + } + + // Clean up orphaned openings + for (const opening of Object.values(state.openings)) { + if (!validWallIds.has(opening.wallId) || !state.perimeterWalls[opening.wallId].entityIds.includes(opening.id)) { + delete state.openings[opening.id] + delete state._openingGeometry[opening.id] + entityIdsToRemove.push(opening.id) + removeConstraintsForEntityDraft(state, opening.id) + } + } + + // Clean up orphaned posts + for (const post of Object.values(state.wallPosts)) { + if (!validWallIds.has(post.wallId) || !state.perimeterWalls[post.wallId].entityIds.includes(post.id)) { + delete state.wallPosts[post.id] + delete state._wallPostGeometry[post.id] + entityIdsToRemove.push(post.id) + removeConstraintsForEntityDraft(state, post.id) + } + } + + // Clean up orphaned wall nodes + for (const node of Object.values(state.wallNodes)) { + const remainingWallIds = node.connectedWallIds.filter(wallId => validWallIds.has(wallId)) + const dependantWallExists = node.type !== 'perimeter' || validWallIds.has(node.wallId) + if (remainingWallIds.length === 0 || !dependantWallExists) { + // Node is orphaned - delete it + delete state.wallNodes[node.id] + delete state._wallNodeGeometry[node.id] + entityIdsToRemove.push(node.id) + removeConstraintsForEntityDraft(state, node.id) + } else { + validNodeIds.add(node.id) + node.connectedWallIds = remainingWallIds + } + } + + // Update perimeter references to walls and nodes to ensure they only reference valid IDs + for (const perimeter of Object.values(state.perimeters)) { + perimeter.wallIds = perimeter.wallIds.filter(wallId => validWallIds.has(wallId)) + perimeter.intermediateWallIds = perimeter.intermediateWallIds.filter(wallId => validWallIds.has(wallId)) + perimeter.wallNodeIds = perimeter.wallNodeIds.filter(nodeId => validNodeIds.has(nodeId)) + } + + if (entityIdsToRemove.length > 0) { + removeTimestampDraft(state, ...entityIdsToRemove) + cleanUpOrphaned(state) // Recursively clean up in case of cascading deletions + } +} diff --git a/src/building/store/slices/intermediateWallGeometry.test.ts b/src/building/store/slices/intermediateWallGeometry.test.ts index b5ef81eae..deea2e65b 100644 --- a/src/building/store/slices/intermediateWallGeometry.test.ts +++ b/src/building/store/slices/intermediateWallGeometry.test.ts @@ -117,7 +117,7 @@ describe('intermediateWallGeometry', () => { it('should throw when thickness exceeds distance between points', () => { const closeStart = newVec2(0, 0) - const closeEnd = newVec2(50, 0) + const closeEnd = newVec2(99, 0) expect(() => computeWallLines(closeStart, 'left', closeEnd, 'right', 100)).toThrow( 'Wall thickness larger than distance between points' @@ -391,7 +391,9 @@ describe('intermediateWallGeometry', () => { it('should return early for non-existent perimeter', () => { const { state } = setupIntermediateWallsSlice() - expect(() => { updateAllWallNodeGeometry(state, 'perimeter_nonexistent' as any); }).not.toThrow() + expect(() => { + updateAllWallNodeGeometry(state, 'perimeter_nonexistent' as any) + }).not.toThrow() }) it('should return without error for empty perimeter (no walls/nodes)', () => { @@ -400,7 +402,9 @@ describe('intermediateWallGeometry', () => { state.perimeters[perimeterData.perimeterId].intermediateWallIds = [] state.perimeters[perimeterData.perimeterId].wallNodeIds = [] - expect(() => { updateAllWallNodeGeometry(state, perimeterData.perimeterId); }).not.toThrow() + expect(() => { + updateAllWallNodeGeometry(state, perimeterData.perimeterId) + }).not.toThrow() }) it('should have centerLine between leftLine and rightLine for center/center wall', () => { diff --git a/src/building/store/slices/intermediateWallsSlice.test.ts b/src/building/store/slices/intermediateWallsSlice.test.ts index d1ca02810..4f68f0067 100644 --- a/src/building/store/slices/intermediateWallsSlice.test.ts +++ b/src/building/store/slices/intermediateWallsSlice.test.ts @@ -233,7 +233,9 @@ describe('intermediateWallsSlice', () => { it('should be a no-op for non-existent wall', () => { const { state } = setupIntermediateWallsSlice() - expect(() => { state.actions.removeIntermediateWall('intermediate_nonexistent' as any); }).not.toThrow() + expect(() => { + state.actions.removeIntermediateWall('intermediate_nonexistent' as any) + }).not.toThrow() }) it('should recompute geometry for remaining walls', () => { @@ -290,9 +292,9 @@ describe('intermediateWallsSlice', () => { it('should throw for non-existent wall', () => { const { state } = setupIntermediateWallsSlice() - expect(() => { state.actions.updateIntermediateWallThickness('intermediate_nonexistent' as any, 200); }).toThrow( - NotFoundError - ) + expect(() => { + state.actions.updateIntermediateWallThickness('intermediate_nonexistent' as any, 200) + }).toThrow(NotFoundError) }) it('should throw for thickness <= 0', () => { @@ -308,9 +310,9 @@ describe('intermediateWallsSlice', () => { 120 ) - expect(() => { state.actions.updateIntermediateWallThickness(wall.id, 0); }).toThrow( - 'Wall thickness must be greater than 0' - ) + expect(() => { + state.actions.updateIntermediateWallThickness(wall.id, 0) + }).toThrow('Wall thickness must be greater than 0') }) }) @@ -338,9 +340,9 @@ describe('intermediateWallsSlice', () => { it('should throw for non-existent wall', () => { const { state } = setupIntermediateWallsSlice() - expect(() => - { state.actions.updateIntermediateWallAlignment('intermediate_nonexistent' as any, 'left', 'right'); } - ).toThrow(NotFoundError) + expect(() => { + state.actions.updateIntermediateWallAlignment('intermediate_nonexistent' as any, 'left', 'right') + }).toThrow(NotFoundError) }) }) @@ -489,9 +491,9 @@ describe('intermediateWallsSlice', () => { it('should throw for non-existent node', () => { const { state } = setupIntermediateWallsSlice() - expect(() => - { state.actions.updateInnerWallNodePosition('wallnode_nonexistent' as any, { 0: 0, 1: 0 } as any); } - ).toThrow(NotFoundError) + expect(() => { + state.actions.updateInnerWallNodePosition('wallnode_nonexistent' as any, { 0: 0, 1: 0 } as any) + }).toThrow(NotFoundError) }) it('should throw when trying to update a perimeter node', () => { @@ -500,9 +502,9 @@ describe('intermediateWallsSlice', () => { const node = state.actions.addPerimeterWallNode(perimeterId, wallIds[0], 3000) - expect(() => { state.actions.updateInnerWallNodePosition(node.id, { 0: 0, 1: 0 } as any); }).toThrow( - 'Cannot update position of perimeter wall node' - ) + expect(() => { + state.actions.updateInnerWallNodePosition(node.id, { 0: 0, 1: 0 } as any) + }).toThrow('Cannot update position of perimeter wall node') }) }) @@ -529,9 +531,9 @@ describe('intermediateWallsSlice', () => { it('should throw for non-existent node', () => { const { state } = setupIntermediateWallsSlice() - expect(() => { state.actions.updatePerimeterWallNodeOffset('wallnode_nonexistent' as any, 1000); }).toThrow( - NotFoundError - ) + expect(() => { + state.actions.updatePerimeterWallNodeOffset('wallnode_nonexistent' as any, 1000) + }).toThrow(NotFoundError) }) it('should throw when trying to update an inner node', () => { @@ -540,9 +542,9 @@ describe('intermediateWallsSlice', () => { const node = state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) - expect(() => { state.actions.updatePerimeterWallNodeOffset(node.id, 1000); }).toThrow( - 'Cannot update offset of inner wall node' - ) + expect(() => { + state.actions.updatePerimeterWallNodeOffset(node.id, 1000) + }).toThrow('Cannot update offset of inner wall node') }) }) @@ -581,7 +583,9 @@ describe('intermediateWallsSlice', () => { it('should be a no-op for non-existent node', () => { const { state } = setupIntermediateWallsSlice() - expect(() => { state.actions.removeWallNode('wallnode_nonexistent' as any); }).not.toThrow() + expect(() => { + state.actions.removeWallNode('wallnode_nonexistent' as any) + }).not.toThrow() }) }) @@ -787,4 +791,63 @@ describe('intermediateWallsSlice', () => { expectNoOrphanedIntermediateEntities(state) }) }) + + describe('PerimeterWall.wallNodeIds sync', () => { + it('should track perimeter wall node IDs on the PerimeterWall', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId, wallIds } = perimeterData + + const node = state.actions.addPerimeterWallNode(perimeterId, wallIds[0], 3000) + + expect(state.perimeterWalls[wallIds[0]].wallNodeIds).toContain(node.id) + expect(state.perimeterWalls[wallIds[1]].wallNodeIds).not.toContain(node.id) + expect(state.perimeters[perimeterId].wallNodeIds).toContain(node.id) + + expectConsistentIntermediateWallReferences(state, perimeterId) + expectNoOrphanedIntermediateEntities(state) + }) + + it('should not track inner wall node IDs on PerimeterWall', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId, wallIds } = perimeterData + + const node = state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + + for (const wallId of wallIds) { + expect(state.perimeterWalls[wallId].wallNodeIds).not.toContain(node.id) + } + expect(state.perimeters[perimeterId].wallNodeIds).toContain(node.id) + }) + + it('should remove node ID from PerimeterWall when node is deleted', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId, wallIds } = perimeterData + + const node = state.actions.addPerimeterWallNode(perimeterId, wallIds[0], 3000) + expect(state.perimeterWalls[wallIds[0]].wallNodeIds).toContain(node.id) + + state.actions.removeWallNode(node.id) + + expect(state.perimeterWalls[wallIds[0]].wallNodeIds).not.toContain(node.id) + }) + + it('should remove node ID from PerimeterWall when orphaned after wall removal', () => { + const { state, perimeterData } = setupIntermediateWallsSlice() + const { perimeterId, wallIds } = perimeterData + + const perimNode = state.actions.addPerimeterWallNode(perimeterId, wallIds[0], 3000) + const innerNode = state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + state.actions.addIntermediateWall( + perimeterId, + { nodeId: perimNode.id, axis: 'center' }, + { nodeId: innerNode.id, axis: 'center' }, + 120 + ) + + state.actions.removeIntermediateWall(state.perimeters[perimeterId].intermediateWallIds[0]) + + expect(state.perimeterWalls[wallIds[0]].wallNodeIds).not.toContain(perimNode.id) + expectNoOrphanedIntermediateEntities(state) + }) + }) }) diff --git a/src/building/store/slices/intermediateWallsSlice.ts b/src/building/store/slices/intermediateWallsSlice.ts index 6164b3725..5d716a43a 100644 --- a/src/building/store/slices/intermediateWallsSlice.ts +++ b/src/building/store/slices/intermediateWallsSlice.ts @@ -17,12 +17,10 @@ import type { import type { IntermediateWallId, PerimeterId, PerimeterWallId, WallNodeId } from '@/building/model/ids' import { createIntermediateWallId, createWallNodeId } from '@/building/model/ids' import { NotFoundError } from '@/building/store/errors' -import type { PerimetersState } from '@/building/store/slices/perimeterSlice' -import { - type TimestampsState, - removeTimestampDraft, - updateTimestampDraft -} from '@/building/store/slices/timestampsSlice' +import { cleanUpOrphaned } from '@/building/store/slices/cleanup' +import { removeConstraintsForEntityDraft } from '@/building/store/slices/constraintsSlice' +import { removeTimestampDraft, updateTimestampDraft } from '@/building/store/slices/timestampsSlice' +import type { StoreState } from '@/building/store/types' import type { Length, Vec2 } from '@/shared/geometry' import { copyVec2 } from '@/shared/geometry' @@ -69,7 +67,7 @@ export interface IntermediateWallsActions { export type IntermediateWallsSlice = IntermediateWallsState & { actions: IntermediateWallsActions } export const createIntermediateWallsSlice: StateCreator< - IntermediateWallsSlice & PerimetersState & TimestampsState, + IntermediateWallsSlice & StoreState, [['zustand/immer', never]], [], IntermediateWallsSlice @@ -136,16 +134,8 @@ export const createIntermediateWallsSlice: StateCreator< delete state.intermediateWalls[wallId] delete state._intermediateWallGeometry[wallId] - const startNode = state.wallNodes[wall.start.nodeId] - const endNode = state.wallNodes[wall.end.nodeId] - - startNode.connectedWallIds = startNode.connectedWallIds.filter(id => id !== wallId) - endNode.connectedWallIds = endNode.connectedWallIds.filter(id => id !== wallId) - removeTimestampDraft(state, wallId) - - cleanupOrphanedNodes(state, wall.start.nodeId, wallId) - cleanupOrphanedNodes(state, wall.end.nodeId, wallId) + cleanUpOrphaned(state) updateAllWallNodeGeometry(state, wall.perimeterId) }) @@ -208,6 +198,7 @@ export const createIntermediateWallsSlice: StateCreator< state.wallNodes[nodeId] = node perimeter.wallNodeIds.push(nodeId) + state.perimeterWalls[wallId].wallNodeIds.push(nodeId) updateAllWallNodeGeometry(state, perimeterId) @@ -329,27 +320,13 @@ export const createIntermediateWallsSlice: StateCreator< if (!(nodeId in state.wallNodes)) return const node = state.wallNodes[nodeId] - const perimeter = state.perimeters[node.perimeterId] - - const connectedWalls = Object.values(state.intermediateWalls).filter( - wall => wall.start.nodeId === nodeId || wall.end.nodeId === nodeId - ) - for (const wall of connectedWalls) { - perimeter.intermediateWallIds = perimeter.intermediateWallIds.filter(id => id !== wall.id) - delete state.intermediateWalls[wall.id] - delete state._intermediateWallGeometry[wall.id] - removeTimestampDraft(state, wall.id) - - const otherNodeId = wall.start.nodeId === nodeId ? wall.end.nodeId : wall.start.nodeId - cleanupOrphanedNodes(state, otherNodeId, wall.id) - } - - perimeter.wallNodeIds = perimeter.wallNodeIds.filter(id => id !== nodeId) delete state.wallNodes[nodeId] delete state._wallNodeGeometry[nodeId] + cleanUpOrphaned(state) removeTimestampDraft(state, nodeId) + removeConstraintsForEntityDraft(state, nodeId) updateAllWallNodeGeometry(state, node.perimeterId) }) @@ -483,24 +460,3 @@ export const createIntermediateWallsSlice: StateCreator< } } }) - -function cleanupOrphanedNodes( - state: IntermediateWallsSlice & PerimetersState & TimestampsState, - nodeId: WallNodeId, - excludedWallId?: IntermediateWallId -): void { - if (!(nodeId in state.wallNodes)) return - const node = state.wallNodes[nodeId] - - const remainingConnections = Object.values(state.intermediateWalls).filter( - wall => wall.id !== excludedWallId && (wall.start.nodeId === nodeId || wall.end.nodeId === nodeId) - ) - - if (remainingConnections.length === 0) { - const perimeter = state.perimeters[node.perimeterId] - perimeter.wallNodeIds = perimeter.wallNodeIds.filter(id => id !== nodeId) - delete state.wallNodes[nodeId] - delete state._wallNodeGeometry[nodeId] - removeTimestampDraft(state, nodeId) - } -} diff --git a/src/building/store/slices/perimeterSlice.ts b/src/building/store/slices/perimeterSlice.ts index b46ac5e16..647077699 100644 --- a/src/building/store/slices/perimeterSlice.ts +++ b/src/building/store/slices/perimeterSlice.ts @@ -21,7 +21,6 @@ import type { WallPostWithGeometry } from '@/building/model' import type { - EntityId, OpeningId, PerimeterCornerId, PerimeterId, @@ -30,6 +29,7 @@ import type { StoreyId, WallAssemblyId, WallEntityId, + WallNodeId, WallPostId } from '@/building/model/ids' import { @@ -43,7 +43,6 @@ import { } from '@/building/model/ids' import { InvalidOperationError, NotFoundError } from '@/building/store/errors' import { - type ConstraintsState, applyMergedConstraintsDraft, captureConstraintsForMerge, handleWallSplitConstraintsDraft, @@ -54,9 +53,11 @@ import { removeTimestampDraft, updateTimestampDraft } from '@/building/store/slices/timestampsSlice' +import type { StoreState } from '@/building/store/types' import { type Length, type Polygon2D, type Vec2, addVec2, copyVec2, distVec2, scaleAddVec2 } from '@/shared/geometry' import { ensurePolygonIsClockwise, wouldClosingPolygonSelfIntersect } from '@/shared/geometry/polygon' +import { cleanUpOrphaned } from './cleanup' import { updateEntityGeometry, updatePerimeterGeometry } from './perimeterGeometry' export interface PerimetersState { @@ -187,7 +188,7 @@ export interface PerimetersActions { export type PerimetersSlice = PerimetersState & { actions: PerimetersActions } export const createPerimetersSlice: StateCreator< - PerimetersSlice & TimestampsState & ConstraintsState, + PerimetersSlice & StoreState, [['zustand/immer', never]], [], PerimetersSlice @@ -253,7 +254,8 @@ export const createPerimetersSlice: StateCreator< endCornerId: cornerIds[(i + 1) % n], thickness: wallThickness, wallAssemblyId, - entityIds: [] + entityIds: [], + wallNodeIds: [] })) const perimeter = { @@ -453,7 +455,8 @@ export const createPerimetersSlice: StateCreator< wallAssemblyId: wall.wallAssemblyId, baseRingBeamAssemblyId: wall.baseRingBeamAssemblyId, topRingBeamAssemblyId: wall.topRingBeamAssemblyId, - entityIds: secondWallEntities.map(e => e.id) + entityIds: secondWallEntities.map(e => e.id), + wallNodeIds: [] } // Insert new corner at the correct position @@ -481,8 +484,30 @@ export const createPerimetersSlice: StateCreator< } } + // Distribute wall nodes between the two halves + const firstWallNodeIds: WallNodeId[] = [] + const secondWallNodeIds: WallNodeId[] = [] + const iwSplit = state + for (const nodeId of wall.wallNodeIds) { + const node = iwSplit.wallNodes[nodeId] + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, @typescript-eslint/prefer-optional-chain + if (node && node.type === 'perimeter') { + if (node.offsetFromCornerStart < splitPosition) { + firstWallNodeIds.push(nodeId) + } else { + node.wallId = newWallId + node.offsetFromCornerStart -= splitPosition + secondWallNodeIds.push(nodeId) + } + } else { + firstWallNodeIds.push(nodeId) + } + } + wall.wallNodeIds = firstWallNodeIds + newWall.wallNodeIds = secondWallNodeIds + // Recalculate geometry - cleanUpOrphaned(state) + cleanUpOrphaned(state as StoreState) updatePerimeterGeometry(state, wall.perimeterId) // Transfer constraints from the original wall to the split halves @@ -1141,11 +1166,7 @@ export const createPerimetersSlice: StateCreator< }) // Helper to remove a corner and merge adjacent walls -const removeCornerAndMergeWalls = ( - state: PerimetersSlice & TimestampsState & ConstraintsState, - perimeter: Perimeter, - corner: PerimeterCorner -): void => { +const removeCornerAndMergeWalls = (state: StoreState, perimeter: Perimeter, corner: PerimeterCorner): void => { // Get wall properties for merging const wall1 = state.perimeterWalls[corner.previousWallId] const wall2 = state.perimeterWalls[corner.nextWallId] @@ -1164,8 +1185,10 @@ const removeCornerAndMergeWalls = ( // Check if corner is exactly straight (180°) to preserve openings let entityIds: WallEntityId[] = [] + let wallNodeIds: WallNodeId[] = [] if (isColinear) { entityIds = [...wall1.entityIds, ...wall2.entityIds] + wallNodeIds = [...wall1.wallNodeIds, ...wall2.wallNodeIds] for (const id of wall1.entityIds) { const entity = isOpeningId(id) ? state.openings[id] : state.wallPosts[id] entity.wallId = mergedId @@ -1176,6 +1199,19 @@ const removeCornerAndMergeWalls = ( entity.wallId = mergedId entity.centerOffsetFromWallStart += wall1Geometry.wallLength } + for (const nodeId of wall1.wallNodeIds) { + const node = state.wallNodes[nodeId] + if (node.type === 'perimeter') { + node.wallId = mergedId + } + } + for (const nodeId of wall2.wallNodeIds) { + const node = state.wallNodes[nodeId] + if (node.type === 'perimeter') { + node.wallId = mergedId + node.offsetFromCornerStart += wall1Geometry.wallLength + } + } } const mergedWall: PerimeterWall = { @@ -1185,7 +1221,8 @@ const removeCornerAndMergeWalls = ( endCornerId: wall2.endCornerId, thickness: mergedThickness, wallAssemblyId: wall1.wallAssemblyId, - entityIds + entityIds, + wallNodeIds } perimeter.cornerIds = perimeter.cornerIds.filter(id => id !== corner.id) @@ -1216,10 +1253,7 @@ const removeCornerAndMergeWalls = ( } // Helper to remove a wall and merge the adjacent walls -const removeWallAndMergeAdjacent = ( - state: PerimetersSlice & TimestampsState & ConstraintsState, - wall: PerimeterWall -): void => { +const removeWallAndMergeAdjacent = (state: StoreState, wall: PerimeterWall): void => { const perimeter = state.perimeters[wall.perimeterId] const startCorner = state.perimeterCorners[wall.startCornerId] const endCorner = state.perimeterCorners[wall.endCornerId] @@ -1249,7 +1283,8 @@ const removeWallAndMergeAdjacent = ( endCornerId: newEndCorner.id, thickness: mergedThickness, wallAssemblyId: prevWall.wallAssemblyId, - entityIds: [] // Entities are deleted + entityIds: [], + wallNodeIds: [] } state.perimeterWalls[mergedWall.id] = mergedWall @@ -1281,6 +1316,7 @@ const removeWallAndMergeAdjacent = ( * Calculate valid placement range for posts, including corner extensions * Returns [minOffset, maxOffset] */ + const getWallPostPlacementBounds = ( state: PerimetersSlice & TimestampsState, wallId: PerimeterWallId @@ -1423,60 +1459,6 @@ const validatePostOnWall = ( ) } -function cleanUpOrphaned(state: PerimetersSlice & TimestampsState & ConstraintsState) { - // Track IDs of entities to delete for timestamp cleanup - const entityIdsToRemove: EntityId[] = [] - - // Track valid wall IDs while cleaning up walls - const validWallIds = new Set() - for (const wall of Object.values(state.perimeterWalls)) { - if (!(wall.perimeterId in state.perimeters) || !state.perimeters[wall.perimeterId].wallIds.includes(wall.id)) { - delete state.perimeterWalls[wall.id] - delete state._perimeterWallGeometry[wall.id] - entityIdsToRemove.push(wall.id) - removeConstraintsForEntityDraft(state, wall.id) - } else { - validWallIds.add(wall.id) - } - } - - // Clean up orphaned corners - for (const corner of Object.values(state.perimeterCorners)) { - if ( - !(corner.perimeterId in state.perimeters) || - !state.perimeters[corner.perimeterId].cornerIds.includes(corner.id) - ) { - delete state.perimeterCorners[corner.id] - delete state._perimeterCornerGeometry[corner.id] - entityIdsToRemove.push(corner.id) - removeConstraintsForEntityDraft(state, corner.id) - } - } - - // Clean up orphaned openings - for (const opening of Object.values(state.openings)) { - if (!validWallIds.has(opening.wallId) || !state.perimeterWalls[opening.wallId].entityIds.includes(opening.id)) { - delete state.openings[opening.id] - delete state._openingGeometry[opening.id] - entityIdsToRemove.push(opening.id) - } - } - - // Clean up orphaned posts - for (const post of Object.values(state.wallPosts)) { - if (!validWallIds.has(post.wallId) || !state.perimeterWalls[post.wallId].entityIds.includes(post.id)) { - delete state.wallPosts[post.id] - delete state._wallPostGeometry[post.id] - entityIdsToRemove.push(post.id) - } - } - - // Clean up orphaned timestamps if actions are provided - if (entityIdsToRemove.length > 0) { - removeTimestampDraft(state, ...entityIdsToRemove) - } -} - function findNearestValidWallEntityPosition( state: PerimetersState, wallId: PerimeterWallId, diff --git a/src/construction/assemblies/walls/segmentation.test.ts b/src/construction/assemblies/walls/segmentation.test.ts index d4cab859b..40d057494 100644 --- a/src/construction/assemblies/walls/segmentation.test.ts +++ b/src/construction/assemblies/walls/segmentation.test.ts @@ -137,7 +137,8 @@ function createMockWall( end: newVec2(wallLength, thickness) }, direction: newVec2(1, 0), - outsideDirection: newVec2(0, 1) + outsideDirection: newVec2(0, 1), + wallNodeIds: [] } } From eff6737887510557f677dd703056baee0a3090e0 Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Mon, 30 Mar 2026 22:06:29 +0200 Subject: [PATCH 13/21] Add inspectors for new entities --- src/@types/resources.d.ts | 10 +++ .../inspectors/IntermediateWallInspector.tsx | 79 +++++++++++++++++++ src/editor/inspectors/WallNodeInspector.tsx | 41 ++++++++++ .../tools/basic/SelectToolInspector.tsx | 12 ++- src/shared/i18n/locales/de/inspector.json | 10 +++ src/shared/i18n/locales/en/inspector.json | 10 +++ src/shared/i18n/locales/fr/inspector.json | 12 ++- 7 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 src/editor/inspectors/IntermediateWallInspector.tsx create mode 100644 src/editor/inspectors/WallNodeInspector.tsx diff --git a/src/@types/resources.d.ts b/src/@types/resources.d.ts index ca2fe6e6b..9b6c76c1a 100644 --- a/src/@types/resources.d.ts +++ b/src/@types/resources.d.ts @@ -1320,6 +1320,12 @@ interface Resources { "perimeter": "Perimeter", "removeFloorOpening": "Remove floor opening" }, + "intermediateWall": { + "deleteWall": "Delete Wall", + "fitToView": "Fit to View", + "thickness": "Thickness", + "wallLength": "Length" + }, "opening": { "confirmDelete": "Are you sure you want to remove this opening?", "deleteOpening": "Delete opening", @@ -1461,6 +1467,10 @@ interface Resources { "wallToWindowRatio": "Wall-to-window ratio (WWR)", "windowArea": "Window Area" }, + "wallNode": { + "deleteNode": "Delete Node", + "fitToView": "Fit to View" + }, "wallPost": { "actsAsPost": "Acts as Post", "behavior": "Behavior", diff --git a/src/editor/inspectors/IntermediateWallInspector.tsx b/src/editor/inspectors/IntermediateWallInspector.tsx new file mode 100644 index 000000000..c71afb35e --- /dev/null +++ b/src/editor/inspectors/IntermediateWallInspector.tsx @@ -0,0 +1,79 @@ +import * as Label from '@radix-ui/react-label' +import { Trash } from 'lucide-react' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' + +import type { IntermediateWallId } from '@/building/model/ids' +import { useIntermediateWallById, useModelActions } from '@/building/store' +import { useViewportActions } from '@/editor/canvas/state/viewportStore' +import { Bounds2D } from '@/shared/geometry' +import { useFormatters } from '@/shared/i18n/useFormatters' +import { LengthField } from '@/shared/ui/LengthField' +import { Button } from '@/shared/ui/components/button' +import { DataList } from '@/shared/ui/components/data-list' +import { Separator } from '@/shared/ui/components/separator' +import { FitToViewIcon } from '@/shared/ui/icons' + +export function IntermediateWallInspector({ wallId }: { wallId: IntermediateWallId }): React.JSX.Element { + const { t } = useTranslation('inspector') + const { formatLength } = useFormatters() + const wall = useIntermediateWallById(wallId) + const { removeIntermediateWall, updateIntermediateWallThickness } = useModelActions() + const { fitToView } = useViewportActions() + + const handleFitToView = useCallback(() => { + const bounds = Bounds2D.fromPoints(wall.boundary.points) + fitToView(bounds) + }, [wall, fitToView]) + + const handleDelete = useCallback(() => { + removeIntermediateWall(wallId) + }, [removeIntermediateWall, wallId]) + + return ( +
+
+
+ + {t($ => $.intermediateWall.thickness)} + + { + updateIntermediateWallThickness(wallId, value) + }} + min={1} + step={10} + size="sm" + unit="cm" + className="w-20 grow" + /> +
+ + + + {t($ => $.intermediateWall.wallLength)} + {formatLength(wall.wallLength)} + + + + + +
+ + +
+
+
+ ) +} diff --git a/src/editor/inspectors/WallNodeInspector.tsx b/src/editor/inspectors/WallNodeInspector.tsx new file mode 100644 index 000000000..c1f6b62fb --- /dev/null +++ b/src/editor/inspectors/WallNodeInspector.tsx @@ -0,0 +1,41 @@ +import { Trash } from 'lucide-react' +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' + +import type { WallNodeId } from '@/building/model/ids' +import { useModelActions, useWallNodeById } from '@/building/store' +import { useViewportActions } from '@/editor/canvas/state/viewportStore' +import { Bounds2D } from '@/shared/geometry' +import { Button } from '@/shared/ui/components/button' +import { FitToViewIcon } from '@/shared/ui/icons' + +export function WallNodeInspector({ nodeId }: { nodeId: WallNodeId }): React.JSX.Element { + const { t } = useTranslation('inspector') + const node = useWallNodeById(nodeId) + const { removeWallNode } = useModelActions() + const { fitToView } = useViewportActions() + + const handleFitToView = useCallback(() => { + const bounds = Bounds2D.fromPoints(node.boundary?.points ?? [node.center]) + fitToView(bounds) + }, [node, fitToView]) + + const handleDelete = useCallback(() => { + removeWallNode(nodeId) + }, [removeWallNode, nodeId]) + + return ( +
+
+
+ + +
+
+
+ ) +} diff --git a/src/editor/tools/basic/SelectToolInspector.tsx b/src/editor/tools/basic/SelectToolInspector.tsx index de3a40e3b..2baaea5ae 100644 --- a/src/editor/tools/basic/SelectToolInspector.tsx +++ b/src/editor/tools/basic/SelectToolInspector.tsx @@ -4,18 +4,21 @@ import { useTranslation } from 'react-i18next' import { isFloorAreaId, isFloorOpeningId, + isIntermediateWallId, isOpeningId, isPerimeterCornerId, isPerimeterId, isPerimeterWallId, isRoofId, isRoofOverhangId, + isWallNodeId, isWallPostId } from '@/building/model/ids' import { useActiveStoreyId } from '@/building/store' import { useCurrentSelection } from '@/editor/canvas/state/selectionStore' import { FloorAreaInspector } from '@/editor/inspectors/FloorAreaInspector' import { FloorOpeningInspector } from '@/editor/inspectors/FloorOpeningInspector' +import { IntermediateWallInspector } from '@/editor/inspectors/IntermediateWallInspector' import { OpeningInspector } from '@/editor/inspectors/OpeningInspector' import { PerimeterCornerInspector } from '@/editor/inspectors/PerimeterCornerInspector' import { PerimeterInspector } from '@/editor/inspectors/PerimeterInspector' @@ -23,6 +26,7 @@ import { PerimeterWallInspector } from '@/editor/inspectors/PerimeterWallInspect import { RoofInspector } from '@/editor/inspectors/RoofInspector' import { RoofOverhangInspector } from '@/editor/inspectors/RoofOverhangInspector' import { StoreyInspector } from '@/editor/inspectors/StoreyInspector' +import { WallNodeInspector } from '@/editor/inspectors/WallNodeInspector' import { WallPostInspector } from '@/editor/inspectors/WallPostInspector' import { Callout, CalloutIcon, CalloutText } from '@/shared/ui/components/callout' @@ -49,6 +53,10 @@ export function SelectToolInspector(): React.JSX.Element { )} {selectedId && isRoofId(selectedId) && } {selectedId && isRoofOverhangId(selectedId) && } + {selectedId && isIntermediateWallId(selectedId) && ( + + )} + {selectedId && isWallNodeId(selectedId) && } {/* Unknown entity type */} {selectedId && @@ -60,7 +68,9 @@ export function SelectToolInspector(): React.JSX.Element { !isFloorAreaId(selectedId) && !isFloorOpeningId(selectedId) && !isRoofId(selectedId) && - !isRoofOverhangId(selectedId) && ( + !isRoofOverhangId(selectedId) && + !isIntermediateWallId(selectedId) && + !isWallNodeId(selectedId) && ( diff --git a/src/shared/i18n/locales/de/inspector.json b/src/shared/i18n/locales/de/inspector.json index 4935e98bd..8dfcf11eb 100644 --- a/src/shared/i18n/locales/de/inspector.json +++ b/src/shared/i18n/locales/de/inspector.json @@ -48,6 +48,12 @@ "perimeter": "Umfang", "removeFloorOpening": "Aussparung entfernen" }, + "intermediateWall": { + "deleteWall": "Wand löschen", + "fitToView": "Auf Ansicht anpassen", + "thickness": "Dicke", + "wallLength": "Länge" + }, "opening": { "confirmDelete": "Möchtest du diese Öffnung wirklich entfernen?", "deleteOpening": "Öffnung löschen", @@ -189,6 +195,10 @@ "wallToWindowRatio": "Wand-Fenster-Verhältnis (WWR)", "windowArea": "Fensterfläche" }, + "wallNode": { + "deleteNode": "Knoten löschen", + "fitToView": "Auf Ansicht anpassen" + }, "wallPost": { "actsAsPost": "Fungiert als Ständer", "behavior": "Verhalten", diff --git a/src/shared/i18n/locales/en/inspector.json b/src/shared/i18n/locales/en/inspector.json index f590e5f29..ff5f82626 100644 --- a/src/shared/i18n/locales/en/inspector.json +++ b/src/shared/i18n/locales/en/inspector.json @@ -50,6 +50,12 @@ "perimeter": "Perimeter", "removeFloorOpening": "Remove floor opening" }, + "intermediateWall": { + "deleteWall": "Delete Wall", + "fitToView": "Fit to View", + "thickness": "Thickness", + "wallLength": "Length" + }, "opening": { "confirmDelete": "Are you sure you want to remove this opening?", "deleteOpening": "Delete opening", @@ -191,6 +197,10 @@ "wallToWindowRatio": "Wall-to-window ratio (WWR)", "windowArea": "Window Area" }, + "wallNode": { + "deleteNode": "Delete Node", + "fitToView": "Fit to View" + }, "wallPost": { "actsAsPost": "Acts as Post", "behavior": "Behavior", diff --git a/src/shared/i18n/locales/fr/inspector.json b/src/shared/i18n/locales/fr/inspector.json index 956de0a04..d6344c85c 100644 --- a/src/shared/i18n/locales/fr/inspector.json +++ b/src/shared/i18n/locales/fr/inspector.json @@ -52,6 +52,12 @@ "perimeter": "Périmètre", "removeFloorOpening": "Supprimer l'ouverture de plancher" }, + "intermediateWall": { + "deleteWall": "Supprimer le mur", + "fitToView": "Ajuster à la vue", + "thickness": "Épaisseur", + "wallLength": "Longueur" + }, "opening": { "confirmDelete": "Êtes-vous sûr de vouloir supprimer cette ouverture ?", "deleteOpening": "Supprimer l'ouverture", @@ -193,6 +199,10 @@ "wallToWindowRatio": "Ratio mur/fenêtre (WWR)", "windowArea": "Surface des fenêtres" }, + "wallNode": { + "deleteNode": "Supprimer le nœud", + "fitToView": "Ajuster à la vue" + }, "wallPost": { "actsAsPost": "Fait office de montant", "behavior": "Comportement", @@ -211,4 +221,4 @@ "typeOutside": "Extérieur", "width": "Largeur" } -} \ No newline at end of file +} From 3b567f91f1fe5478573ac828b3556fa36c7378b2 Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Mon, 30 Mar 2026 22:54:58 +0200 Subject: [PATCH 14/21] Updated docs --- docs/intermediate-walls-architecture.md | 1905 ++++++++++++++--------- 1 file changed, 1152 insertions(+), 753 deletions(-) diff --git a/docs/intermediate-walls-architecture.md b/docs/intermediate-walls-architecture.md index 302655712..214f89e14 100644 --- a/docs/intermediate-walls-architecture.md +++ b/docs/intermediate-walls-architecture.md @@ -7,7 +7,23 @@ Implement interior walls and rooms in the floor plan editor, enabling: - Drawing interior walls that connect to perimeter walls or other interior walls - Split-on-connect model: when a wall connects to another wall's midpoint, split the target wall - Perpendicular snapping and constraints -- Room detection from closed wall loops +- Wall entities (doors, windows, posts) on interior walls +- Configurable wall assemblies for interior walls with 3D construction +- Room detection from closed wall loops with user labeling + +## Status Overview + +| Phase | Status | +| -------------------------------------------- | ----------- | +| Phase 1: Store & Geometry | Done | +| Phase 2: Drawing Tools | Done | +| Phase 3: UI Components | Done | +| Phase A: Constraints for Intermediate Walls | Not started | +| Phase B: Wall Entities on Intermediate Walls | Not started | +| Phase C: Interior Wall Assembly System | Not started | +| Phase D: Room Detection & Labeling | Not started | +| Phase E: Room Preset Tool | Not started | +| Phase F: Room Split Tool | Not started | ## Data Model @@ -22,7 +38,8 @@ interface PerimeterWallNode { perimeterId: PerimeterId type: 'perimeter' wallId: PerimeterWallId - offsetFromCornerStart: Length // Distance along the wall + offsetFromCornerStart: Length + connectedWallIds: IntermediateWallId[] } // Free-standing node - position stored directly @@ -31,6 +48,7 @@ interface InnerWallNode { perimeterId: PerimeterId type: 'inner' position: Vec2 + connectedWallIds: IntermediateWallId[] } ``` @@ -43,7 +61,7 @@ type WallAxis = 'left' | 'center' | 'right' interface WallAttachment { nodeId: WallNodeId - axis: WallAxis // Which axis of the wall aligns with the node + axis: WallAxis } ``` @@ -65,9 +83,65 @@ interface IntermediateWall { } ``` -### Geometry Types +### Wall Entities (Planned Extension) + +Currently wall entities are tied to perimeter walls. The plan extends them to intermediate walls: + +```typescript +// CURRENT (perimeter only): +interface BaseWallEntity { + id: WallEntityId + perimeterId: PerimeterId + wallId: PerimeterWallId // <-- Must widen to WallId + type: 'opening' | 'post' + centerOffsetFromWallStart: Length + width: Length +} + +// PLANNED: +interface BaseWallEntity { + id: WallEntityId + perimeterId: PerimeterId + wallId: WallId // PerimeterWallId | IntermediateWallId + type: 'opening' | 'post' + centerOffsetFromWallStart: Length + width: Length +} +``` + +### Rooms + +```typescript +type RoomType = + | 'living-room' + | 'kitchen' + | 'dining-room' + | 'bedroom' + | 'bathroom' + | 'wc' + | 'hallway' + | 'office' + | 'storage' + | 'utility' + | 'service' + | 'generic' + +interface Room { + id: RoomId + perimeterId: PerimeterId + wallIds: IntermediateWallId[] // Auto-detected + type: RoomType + counter: number // bedroom 1, bedroom 2, etc. + customLabel?: string +} -Computed from model + other geometry: +interface RoomGeometry { + boundary: Polygon2D + area: Area +} +``` + +### Geometry Types ```typescript interface WallNodeGeometry { @@ -92,984 +166,1309 @@ interface IntermediateWallGeometry { } ``` -## Store Architecture +## Completed Implementation (Phases 1-3) -### State Interface +### Store & Geometry (`src/building/store/slices/`) + +- **`intermediateWallsSlice.ts`** (462 lines): Full CRUD for walls and nodes + - Wall actions: `addIntermediateWall`, `removeIntermediateWall`, `updateIntermediateWallThickness`, `updateIntermediateWallAlignment` + - Node actions: `addPerimeterWallNode`, `addInnerWallNode`, `splitIntermediateWallAtPoint`, `removeWallNode`, `updateInnerWallNodePosition`, `updatePerimeterWallNodeOffset` + - All getters: `getIntermediateWallById`, `getIntermediateWallsByPerimeter`, `getAllIntermediateWalls`, `getWallNodeById`, `getWallNodesByPerimeter`, `getAllWallNodes` +- **`intermediateWallGeometry.ts`** (493 lines): Wall lines, corner computation, boundary polygons +- **`cleanup.ts`**: Orphan cleanup for intermediate walls, wall nodes, and constraints +- **Tests**: 853 lines (slice) + 458 lines (geometry) + cleanup tests + +### Drawing Tool (`src/editor/tools/intermediate-wall/add/`) + +- **`IntermediateWallTool.ts`** (457 lines): Multi-segment polyline drawing with: + - Snapping to perimeter walls, existing intermediate walls, and wall nodes + - Validation (inside perimeter, no crossing, minimum length) + - Auto-creation of wall nodes (inner, perimeter, split on T-junction) + - Length input override + - Chain completion by clicking first point or pressing Enter + - Perpendicular snapping within the chain (to previous segment direction) +- **`IntermediateWallToolInspector.tsx`**: Thickness field, length override, help text +- **`IntermediateWallToolOverlay.tsx`**: SVG overlay with snapped points, polyline segments, preview +- **Registered** in ToolSystem with hotkey `i` + +### Select/Edit Support + +- **Selectable**: Intermediate walls can be selected via `SelectTool` +- **Inspectable**: `IntermediateWallInspector` provides thickness editing, length display, delete +- **NOT movable**: No `IntermediateWallMovementBehavior` exists + +--- + +# Phase A: Constraints for Intermediate Walls + +## Goal + +Enable the GCS (geometric constraint solver) to enforce constraints on intermediate walls, including: + +- Wall length constraints +- Horizontal/vertical alignment constraints +- Perpendicular constraints between intermediate walls and perimeter/intermediate walls +- Node position constraints when attached to perimeter walls +- Wall entity position constraints on intermediate walls + +## Current State + +- **Constraint model** (`src/building/model/constraints.ts`): Already supports `IntermediateWallId` and `WallNodeId` as `ConstraintEntityId` in the reverse index +- **Constraint types**: `WallLengthConstraint`, `HorizontalWallConstraint`, `VerticalWallConstraint` etc. already accept `WallId` (which includes `IntermediateWallId`) +- **Constraint store** (`constraintsSlice.ts`): Wall split/merge constraint transfer already handles `WallId` +- **BUT**: No GCS sync, no constraint generation, no translator support for intermediate walls + +## A.1: GCS Geometry Registration + +### File: `src/building/gcs/store.ts` + +Add intermediate wall geometry to the GCS: ```typescript -interface IntermediateWallsState { - intermediateWalls: Record - _intermediateWallGeometry: Record +// New action: add geometry for an intermediate wall +addIntermediateWallGeometry(state, wallId: IntermediateWallId): void - wallNodes: Record - _wallNodeGeometry: Record -} +// New action: remove geometry for an intermediate wall +removeIntermediateWallGeometry(state, wallId: IntermediateWallId): void + +// Update existing: called when wall changes +updateIntermediateWallGeometry(state, wallId: IntermediateWallId): void ``` -### Actions +**GCS point scheme for intermediate walls:** + +For each wall, create: + +- `wallnode_{nodeId}` -- point at the wall node position (shared across connected walls) +- `intermediate_{wallId}_ref` -- start point on center line +- `intermediate_{wallId}_nonref` -- end point on center line + +Constraints: -**Wall CRUD:** +- Coincident: node point = wall endpoint point +- For perimeter-attached nodes: point-on-line constraint (node point on perimeter wall's reference line) +- For inner nodes: position can be free or constrained +- p2p_distance for wall length -- `addIntermediateWall(perimeterId, start, end, thickness)` - Create wall between two attachments -- `removeIntermediateWall(wallId)` - Remove wall, cleanup orphaned nodes -- `updateIntermediateWallThickness(wallId, thickness)` -- `updateIntermediateWallAlignment(wallId, start, end)` +**For wall entities on intermediate walls** (deferred to Phase B): -**Node CRUD:** +- Same pattern as perimeter entities: start/center/end points on center line, width constraint -- `addPerimeterWallNode(perimeterId, wallId, offset)` - Add node on perimeter wall -- `addInnerWallNode(perimeterId, position)` - Add free-standing node -- `removeWallNode(nodeId)` - Remove node and connected walls -- `updateInnerWallNodePosition(nodeId, position)` -- `updatePerimeterWallNodeOffset(nodeId, offset)` +### Extend `PerimeterRegistryEntry` -### Getters +Add tracking for intermediate wall GCS elements within a perimeter: -Combine base model with geometry: +```typescript +interface PerimeterRegistryEntry { + // ... existing fields + intermediateWallIds: IntermediateWallId[] + wallNodeIds: WallNodeId[] +} +``` -- `getIntermediateWallById(id)` → `IntermediateWallWithGeometry` -- `getIntermediateWallsByPerimeter(perimeterId)` → `IntermediateWallWithGeometry[]` -- `getAllIntermediateWalls()` → `IntermediateWallWithGeometry[]` -- `getWallNodeById(id)` → `WallNodeWithGeometry` -- `getWallNodesByPerimeter(perimeterId)` → `WallNodeWithGeometry[]` -- `getAllWallNodes()` → `WallNodeWithGeometry[]` +## A.2: GCS Sync Subscriptions -## Geometry Computation +### File: `src/building/gcs/gcsSync.ts` -### File: `intermediateWallGeometry.ts` +Add subscriptions for intermediate wall changes: + +```typescript +subscribeToIntermediateWalls(): void +// - On wall added: call addIntermediateWallGeometry +// - On wall removed: call removeIntermediateWallGeometry +// - On wall thickness/alignment changed: call updateIntermediateWallGeometry +// - On wall geometry changed (from node move): update GCS points + +subscribeToWallNodes(): void +// - On node added: create GCS point, add attachment constraints +// - On node removed: remove GCS point and constraints +// - On node position/offset changed: update GCS point +// - For perimeter-attached nodes: add point-on-line to perimeter wall +``` -Follows the pattern from `perimeterGeometry.ts`: +## A.3: Constraint Translator Updates -1. Pure functions that take state + IDs -2. Called directly after any model change -3. No circular imports - state interface defined in slice, geometry file imports it +### File: `src/building/gcs/constraintTranslator.ts` -### Key Functions +Extend `TranslationContext` to handle intermediate walls: ```typescript -// Update single wall geometry -function updateIntermediateWallGeometry( - state: IntermediateWallsState & PerimeterGeometryAccess, - wallId: IntermediateWallId -): void +interface TranslationContext { + // Existing (perimeter-only): + getLineStartPointId(lineId: string): string | undefined + getWallCornerIds(wallId: WallId): { startCornerId: PerimeterCornerId; endCornerId: PerimeterCornerId } | undefined + getCornerAdjacentWallIds(cornerId: PerimeterCornerId): { previousWallId: WallId; nextWallId: WallId } | undefined + getReferenceSide(cornerId: PerimeterCornerId): 'left' | 'right' + + // New (intermediate wall support): + getWallNodeIds(wallId: IntermediateWallId): { startNodeId: WallNodeId; endNodeId: WallNodeId } | undefined + getWallNodeGcsPointId(nodeId: WallNodeId): string | undefined + getIntermediateWallGcsLineId(wallId: IntermediateWallId): string | undefined +} +``` + +**Implementation in `store.ts`:** + +```typescript +getWallNodeIds: (wallId: IntermediateWallId) => { + const wall = modelActionsRef.getIntermediateWallById(wallId) + return { startNodeId: wall.start.nodeId, endNodeId: wall.end.nodeId } +} + +getWallNodeGcsPointId: (nodeId: WallNodeId) => { + return `wallnode_${nodeId}` +} -// Update all walls (e.g., after perimeter change) -function updateAllIntermediateWallGeometry(state: IntermediateWallsState & PerimeterGeometryAccess): void +getIntermediateWallGcsLineId: (wallId: IntermediateWallId) => { + return `intermediate_${wallId}_ref` +} ``` -### Node Position Calculation +### Update constraint translation functions + +- `translateWallLengthConstraint`: Handle `IntermediateWallId` by using `getWallNodeIds` instead of `getWallCornerIds` +- `translateHorizontalWallConstraint` / `translateVerticalWallConstraint`: Handle `IntermediateWallId` by constraining node positions +- `translateWallEntityAbsoluteConstraint` / `translateWallEntityRelativeConstraint`: Handle entities on intermediate walls (deferred to Phase B) + +### New helper functions ```typescript -function getNodePosition(state: IntermediateWallsState & PerimeterGeometryAccess, nodeId: WallNodeId): Vec2 { - const node = state.wallNodes[nodeId] - if (node.type === 'inner') { - return node.position - } - // Perimeter node: compute from wall geometry - const wallGeometry = state._perimeterWallGeometry[node.wallId] - return pointOnLineSegment(wallGeometry.innerLine, node.offsetFromCornerStart) +// In constraintTranslator.ts or a shared helpers file +function wallRefLineId(wallId: WallId): string { + if (isPerimeterWallId(wallId)) return `wall_${wallId}_ref` + return `intermediate_${wallId}_ref` } + +function wallNodePointId(nodeId: WallNodeId): string { + return `wallnode_${nodeId}` +} +``` + +## A.4: Constraint Generation for Intermediate Walls + +### File: `src/building/gcs/constraintGenerator.ts` + +Auto-generate constraints when intermediate walls are created: + +```typescript +function generateIntermediateWallConstraints( + wall: IntermediateWallWithGeometry, + allWalls: IntermediateWallWithGeometry[], + perimeterWalls: PerimeterWallWithGeometry[] +): ConstraintInput[] ``` -### Wall Geometry Calculation +**Rules:** + +1. **Length constraint**: Every intermediate wall gets a `WallLengthConstraint` with its current length on the `center` side +2. **Horizontal/Vertical**: If wall direction is nearly horizontal (within 1mm) or vertical, add corresponding constraint +3. **Perpendicular to perimeter wall**: If an endpoint is attached to a perimeter wall and the intermediate wall is nearly perpendicular (within tolerance), add constraint +4. **Perpendicular to other intermediate wall**: If two intermediate walls share a node and are nearly perpendicular, add constraint +5. **Colinear**: If two intermediate walls share a node and are nearly colinear, add constraint -1. Get start/end node positions via `getNodePosition()` -2. Compute center line from start to end -3. Offset by thickness/2 to get left/right lines -4. Build boundary polygon from left/right lines +### When to generate -## Split-on-Connect Model +- After `addIntermediateWall`: Generate constraints for the new wall +- After `splitIntermediateWallAtPoint`: Regenerate constraints for the two new walls +- After wall endpoint attachment changes: Re-evaluate perpendicular/colinear relationships -When a wall endpoint attaches to the middle of another wall: +## A.5: Perpendicular Snapping Enhancement -1. **Detect intersection** - The target wall's center line intersects near the new wall's endpoint -2. **Create node** - Add a wall node at the intersection point -3. **Split target wall** - Replace one wall with two: - - Original start → New node - - New node → Original end -4. **Update references** - Any room references point to both new walls +### File: `src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts` -This ensures every wall endpoint is a node, simplifying topology. +Currently only snaps perpendicular within the chain. Extend to snap perpendicular to existing walls: -## Perpendicular Constraints +**Strategy**: Add snap line candidates for perpendicular directions from existing wall endpoints. -### Snapping +When the user is drawing and has at least one placed point: -During wall drawing, snap to perpendicular lines: +1. For each nearby perimeter wall: compute the perpendicular direction from the nearest point on the wall +2. For each nearby intermediate wall: compute the perpendicular direction from the nearest point on the wall +3. Add these as snap line candidates to the `SnappingService` -- From current point, project to perpendicular of nearby walls -- Visual feedback shows snap candidate +The `SnappingService` already handles line-line intersection snapping, so adding perpendicular snap lines will produce intersection points automatically. -### Constraint Storage +### File: `src/editor/canvas/services/SnappingService.ts` + +No changes needed to the snapping service itself - just add perpendicular snap line candidates from the tool. + +## A.6: Wall Node Movement Behavior (Optional) + +### File: `src/editor/tools/basic/movement/movementBehaviors.ts` + +Enable moving intermediate wall nodes (which drags connected walls): ```typescript -interface PerpendicularConstraint { - id: ConstraintId - wallId: IntermediateWallId - perpendicularToWallId: IntermediateWallId - atNodeId: WallNodeId // The shared node +class WallNodeMovementBehavior implements MovementBehavior { + // On drag start: identify the node and all connected walls + // On drag move: update node position, which triggers geometry recalculation + // - For inner nodes: update position directly + // - For perimeter nodes: update offset along perimeter wall + // On drag end: commit position, resolve constraints } ``` -Constraints are stored separately and enforced during geometry updates. +Register in movement behaviors: + +```typescript +'wall-node': WallNodeMovementBehavior, +``` + +**Interaction with GCS**: When a node is moved, the GCS solver should re-resolve and potentially adjust other constrained elements. This requires the GCS sync to be working (A.2). + +## A.7: Testing + +- Test GCS point/line creation for intermediate walls +- Test constraint translation for intermediate wall constraints +- Test constraint generation (perpendicular, H/V, length) +- Test GCS sync subscription (add/remove/update intermediate walls) +- Test wall node movement with constraint enforcement +- Test perpendicular snapping to existing walls --- -# Intermediate Wall Drawing Tool - Detailed Implementation Plan +# Phase B: Wall Entities on Intermediate Walls -## Overview +## Goal -Implement a tool to draw intermediate (interior partition) walls in a floor plan editor. The tool draws open chains of connected wall segments (not closed polygons), similar to drawing a polyline. +Enable full entity support (openings, posts) on intermediate walls, matching the feature set available on perimeter walls: -### Key Behaviors +- Add/remove/update openings (door, window, passage) on intermediate walls +- Add/remove/update wall posts on intermediate walls +- Entity validation (fit within wall, no overlap) +- Entity geometry computation +- Entity splitting when walls are split +- Entity merging when walls are merged -1. **Chain drawing**: Click multiple points to create a chain of connected walls -2. **Snap to existing elements**: Nodes, perimeter walls, other intermediate walls -3. **Perpendicular snapping**: Snap to 90° angles from walls -4. **Alignment snapping**: Snap to extension lines from existing walls -5. **Center axis alignment**: Walls align their center axis with nodes by default -6. **Auto-finish on T-junction**: Placing a point on an existing wall creates a T-junction and ends the chain -7. **Manual finish**: Press Enter to finish at a free position -8. **Configurable thickness**: Inspector allows adjusting wall thickness +## Current State -## Node Creation Logic +- **Entity model** (`src/building/model/wallEntities.ts`): `BaseWallEntity.wallId` is `PerimeterWallId` (must widen) +- **Entity storage**: Entities stored in `perimeterSlice.ts` via `wall.entityIds` arrays +- **Entity CRUD**: Actions in `perimeterSlice.ts` (`addWallOpening`, `removeWallOpening`, etc.) +- **Entity geometry**: Computed in `perimeterGeometry.ts` +- **`IntermediateWall.openingIds`**: TODO placeholder, no backing store +- **Segmentation system**: Tied to `PerimeterWallWithGeometry` -When the user clicks to place a wall endpoint, determine the appropriate node type: +## B.1: Widen Entity Model -### Case 1: Click on Perimeter Wall Endpoint (Corner) +### File: `src/building/model/wallEntities.ts` -``` -Input: Snapped to PerimeterCorner -Action: Use addPerimeterWallNode at offset 0 or wall length -Node: PerimeterWallNode (no split needed - corners already exist) +```typescript +interface BaseWallEntity { + id: WallEntityId + perimeterId: PerimeterId + wallId: WallId // Was: PerimeterWallId + type: 'opening' | 'post' + centerOffsetFromWallStart: Length + width: Length +} ``` -### Case 2: Click on Perimeter Wall Midpoint +Add a discriminator to know which store slice owns the entity: -``` -Input: Snapped to point on PerimeterWall (not corner) -Action: Use addPerimeterWallNode at computed offset -Node: PerimeterWallNode (NO split - perimeter walls don't split for intermediate walls) +```typescript +interface BaseWallEntity { + // ... existing fields + wallType: 'perimeter' | 'intermediate' +} ``` -### Case 3: Click on Existing Wall Node +**Migration**: Existing entities get `wallType: 'perimeter'` automatically. The `Opening` and `WallPost` types inherit `wallType` from `BaseWallEntity`. -``` -Input: Snapped to existing WallNode (inner or perimeter) -Action: Reuse existing node -Node: Existing WallNode -``` +## B.2: Entity Storage Architecture -### Case 4: Click on Intermediate Wall Midpoint (T-Junction) +### Approach: Shared storage, slice-specific dispatch -``` -Input: Snapped to point on IntermediateWall (not at node) -Action: - 1. Call splitIntermediateWallAtPoint(wallId, point) - NEW ACTION NEEDED - 2. This creates: - - New InnerWallNode at the split point - - Two new IntermediateWalls replacing the original - - Deletes original wall - 3. Use the new node's ID -Node: InnerWallNode (created by split action) -Behavior: END THE CHAIN - T-junction completes the drawing session -``` +Two options were considered: -### Case 5: Click on Free Position Inside Perimeter +1. **Separate records in intermediateWallsSlice** -- duplicate opening/post records +2. **Shared records, slice-specific dispatch** -- single source of truth, dispatch based on `wallType` -``` -Input: Point not snapped to any wall/node, but inside a perimeter polygon -Action: Use addInnerWallNode at the point -Node: InnerWallNode +**Chosen: Option 2** -- Keep `openings` and `wallPosts` records in `perimeterSlice.ts` (or extract to a shared slice), but dispatch entity operations based on `wallType`. + +### File changes: + +**Option A (simpler)**: Keep entity records in `perimeterSlice.ts` but add intermediate-wall-aware actions: + +```typescript +// In intermediateWallsSlice.ts or a new wallEntitiesSlice.ts: +addIntermediateWallOpening(wallId: IntermediateWallId, params: OpeningParams): OpeningWithGeometry +removeIntermediateWallOpening(openingId: OpeningId): void +updateIntermediateWallOpening(openingId: OpeningId, updates: Partial): void +// Same for wall posts ``` -### Case 6: Click Outside Perimeter +**Option B (cleaner)**: Extract entity storage to a shared `wallEntitiesSlice.ts`: +- Move `openings`, `wallPosts`, `_openingGeometry`, `_wallPostGeometry` records +- Add generic actions that accept `WallId` and dispatch based on type +- Both `perimeterSlice.ts` and `intermediateWallsSlice.ts` reference the shared slice + +**Recommendation**: Start with Option A for simpler migration, refactor to Option B later if needed. + +## B.3: Entity CRUD Actions + +### In `src/building/store/slices/intermediateWallsSlice.ts`: + +```typescript +// Opening actions +addIntermediateWallOpening(wallId: IntermediateWallId, params: OpeningParams): OpeningWithGeometry +removeIntermediateWallOpening(openingId: OpeningId): void +updateIntermediateWallOpening(openingId: OpeningId, updates: Partial): void +isIntermediateWallOpeningPlacementValid(wallId, centerOffset, width, excluded?): boolean + +// Wall post actions +addIntermediateWallPost(wallId: IntermediateWallId, params: WallPostParams): WallPostWithGeometry +removeIntermediateWallPost(postId: WallPostId): void +updateIntermediateWallPost(postId: WallPostId, updates: Partial): void +isIntermediateWallPostPlacementValid(wallId, centerOffset, width, excluded?): boolean + +// Validation helpers +isIntermediateWallEntityPlacementValid(wallId, centerOffset, width, excluded?, options?): boolean +findNearestValidIntermediateWallEntityPosition(wallId, preferredCenter, width, excluded?, options?): Length | null ``` -Input: Point not inside any perimeter polygon -Action: Reject the click (validation error) -Behavior: Show error feedback, don't create wall + +**Validation logic**: Reuse the existing `validateWallItemPlacement()` pattern from `perimeterSlice.ts`, adapted for intermediate wall geometry (using `centerLine` instead of `innerLine`). + +## B.4: Entity Geometry Computation + +### File: `src/building/store/slices/intermediateWallGeometry.ts` + +Add entity geometry computation for intermediate wall entities: + +```typescript +function updateIntermediateWallEntityGeometry( + state: IntermediateWallsState, + wallId: IntermediateWallId, + entityId: WallEntityId +): void ``` -## Store Actions +**Geometry computation**: + +- Project entity position onto the intermediate wall's `centerLine` +- Compute entity polygon based on wall thickness (perpendicular offset from center line) +- Entity `insideLine` / `outsideLine` mapped to wall's `leftLine` / `rightLine` + +## B.5: Entity Handling During Wall Split/Merge + +### In `splitIntermediateWallAtPoint`: + +When splitting an intermediate wall that has entities: + +1. Compute which entities belong to each half (by `centerOffsetFromWallStart` vs split point) +2. Entities that straddle the split point: fail the split (return null) or remove the entity +3. Entities on the second half: adjust `centerOffsetFromWallStart` by subtracting the first half's length +4. Update `openingIds` arrays on both new walls + +### In wall merge (future): + +When merging two intermediate walls (removing a colinear node): + +1. Combine entity lists from both walls +2. Adjust offsets on the second wall's entities by adding the first wall's length +3. Handle overlaps between entities from both walls + +## B.6: Entity Inspector UI + +### File: `src/editor/inspectors/IntermediateWallInspector.tsx` + +Extend to show entity list and allow add/remove/edit: + +- List of openings and posts on the selected wall +- Add opening/post buttons +- Click entity to select and show entity-specific inspector +- Reuse existing `OpeningInspector` / `WallPostInspector` components (adapted for intermediate wall context) + +### File: `src/editor/inspectors/` (new or existing) + +Add/edit entity modals adapted for intermediate walls: + +- Opening type selector (door, window, passage) +- Width, height, sill height fields +- Assembly override selector + +## B.7: Cleanup Integration + +### File: `src/building/store/slices/cleanup.ts` + +Ensure intermediate wall entity cleanup works correctly: + +- When an intermediate wall is removed: remove all its entities +- When an entity is removed: remove from `openingIds` array +- When a perimeter is removed: cascade to intermediate walls and their entities + +## B.8: Testing -### New Action: `splitIntermediateWallAtPoint` +- Test add/remove/update opening on intermediate wall +- Test add/remove/update wall post on intermediate wall +- Test entity validation (fit, overlap) +- Test entity geometry computation +- Test entity handling during wall split +- Test cleanup cascade for intermediate wall entities -Add to `intermediateWallsSlice.ts`: +--- + +# Phase C: Interior Wall Assembly System + +## Goal + +Enable configurable wall assemblies for intermediate walls with 3D construction, starting with a simple monolithic assembly and expanding to match the perimeter wall assembly types. + +## Current State + +- **`InteriorWallAssemblyId`**: Type and generator exist (`iwa_*` prefix) +- **`IntermediateWall.wallAssemblyId`**: TODO placeholder +- **Config store**: No interior wall assembly config slice exists +- **Assembly interface**: `WallAssembly.construct()` takes `PerimeterWallWithGeometry` only +- **Segmentation**: Tied to `PerimeterWallWithGeometry` +- **3D builder**: No `buildIntermediateWallCoreModel()` function + +## C.1: Interior Wall Assembly Config + +### File: `src/config/types.ts` + +Add config types for interior wall assemblies: ```typescript -interface SplitIntermediateWallAtPointPayload { - wallId: IntermediateWallId - point: Vec2 // Point on the wall's center line +interface InteriorWallAssemblyConfig { + type: InteriorWallAssemblyType + insideLayerSetId?: LayerSetId + outsideLayerSetId?: LayerSetId + openingAssemblyId?: OpeningAssemblyId +} + +type InteriorWallAssemblyType = 'monolithic' | 'framed' | 'solid' | 'module' + +interface MonolithicInteriorWallConfig extends InteriorWallAssemblyConfig { + type: 'monolithic' + material: MaterialId + coreThickness: Length +} + +interface FramedInteriorWallConfig extends InteriorWallAssemblyConfig { + type: 'framed' + studSpacing: Length + studMaterial: MaterialId + infillMaterial: MaterialId + claddingInside?: LayerSetId + claddingOutside?: LayerSetId } -// Returns the new WallNodeId at the split point -splitIntermediateWallAtPoint: (state: IntermediateWallsState, payload: SplitIntermediateWallAtPointPayload) => - WallNodeId +// ... more types as needed ``` -**Implementation steps:** +### File: `src/config/store/slices/interiorWalls.ts` (new) -1. Get the original wall and its geometry -2. Find the split point's parameter `t` along the center line (0-1) -3. Create a new `InnerWallNode` at the split point -4. Create two new `IntermediateWall` entities: - - Wall A: original start → new node (copy start attachment, new end attachment) - - Wall B: new node → original end (new start attachment, copy end attachment) -5. Copy properties (thickness, assembly) to both new walls -6. Delete the original wall -7. Update geometry for both new walls -8. Return the new node's ID +Config store slice following the established pattern: ```typescript -function splitIntermediateWallAtPoint( - state: IntermediateWallsState, - payload: SplitIntermediateWallAtPointPayload -): WallNodeId { - const { wallId, point } = payload - const wall = state.intermediateWalls[wallId] - const geometry = state._intermediateWallGeometry[wallId] - - // Find parameter t along center line - const t = findParameterOnLineSegment(geometry.centerLine, point) - - // Create new inner node at split point - const newNodeId = generateWallNodeId() - state.wallNodes[newNodeId] = { - id: newNodeId, - perimeterId: wall.perimeterId, - type: 'inner', - position: point - } +interface InteriorWallAssembliesState { + interiorWallAssemblies: Record + defaultInteriorWallAssemblyId: InteriorWallAssemblyId | null +} - // Create two new walls - const wallAId = generateIntermediateWallId() - const wallBId = generateIntermediateWallId() - - state.intermediateWalls[wallAId] = { - id: wallAId, - perimeterId: wall.perimeterId, - start: wall.start, - end: { nodeId: newNodeId, axis: wall.start.axis }, // Match axis alignment - thickness: wall.thickness, - wallAssemblyId: wall.wallAssemblyId, - openingIds: [] // TODO: Split openings appropriately - } +interface InteriorWallAssembliesActions { + addInteriorWallAssembly(config: Omit): InteriorWallAssemblyId + removeInteriorWallAssembly(id: InteriorWallAssemblyId): void + updateInteriorWallAssemblyConfig(id: InteriorWallAssemblyId, updates: Partial): void + updateInteriorWallAssemblyName(id: InteriorWallAssemblyId, name: string): void + duplicateInteriorWallAssembly(id: InteriorWallAssemblyId): InteriorWallAssemblyId + getInteriorWallAssemblyById(id: InteriorWallAssemblyId): InteriorWallAssemblyConfig | undefined + getAllInteriorWallAssemblies(): InteriorWallAssemblyConfig[] + setDefaultInteriorWallAssembly(id: InteriorWallAssemblyId): void + getDefaultInteriorWallAssembly(): InteriorWallAssemblyConfig | undefined + resetToDefaults(): void +} +``` + +### File: `src/config/store/slices/interiorWalls.defaults.ts` (new) - state.intermediateWalls[wallBId] = { - id: wallBId, - perimeterId: wall.perimeterId, - start: { nodeId: newNodeId, axis: wall.end.axis }, - end: wall.end, - thickness: wall.thickness, - wallAssemblyId: wall.wallAssemblyId, - openingIds: [] +Default assemblies: + +```typescript +const defaultInteriorWallAssemblies = [ + { + id: createInteriorWallAssemblyId(), + name: 'Monolithic 100mm', + type: 'monolithic' as const, + material: 'straw', + coreThickness: fromMillimeters(100) } + // ... more defaults +] + +const defaultInteriorWallAssemblyId = defaultInteriorWallAssemblies[0].id +``` - // Delete original wall - delete state.intermediateWalls[wallId] - delete state._intermediateWallGeometry[wallId] +## C.2: Interior Wall Assembly Interface - // Update geometry for new walls - updateIntermediateWallGeometry(state, wallAId) - updateIntermediateWallGeometry(state, wallBId) - updateWallNodeGeometry(state, newNodeId) +### File: `src/construction/assemblies/interiorWalls/types.ts` (new) - return newNodeId +```typescript +interface InteriorWallAssembly { + construct(wall: IntermediateWallWithGeometry, storeyContext: StoreyContext): ConstructionModel + get tag(): Tag + get thicknessRange(): ThicknessRange + getCorePhysicsStructure(coreThickness: Length, height: Length): PhysicsSeries[] + getPhysicsStructure(totalThickness: Length, height: Length): AssemblyPhysicsStructure } ``` -## BasePolylineTool Base Class +**Note**: Uses `IntermediateWallWithGeometry` instead of `PerimeterWallWithGeometry`. + +### File: `src/construction/assemblies/interiorWalls/monolithic.ts` (new) + +First assembly implementation: + +```typescript +class MonolithicInteriorWallAssembly implements InteriorWallAssembly { + constructor(private config: MonolithicInteriorWallConfig) {} + + construct(wall: IntermediateWallWithGeometry, storeyContext: StoreyContext): ConstructionModel { + // Create a simple rectangular cuboid for the wall + // Use wall.centerLine for position, wall.thickness for width + // Use storey context for height + // Handle entities (openings/posts) by cutting voids or adding sub-areas + } +} +``` -Create `src/editor/tools/shared/polyline/BasePolylineTool.ts`: +### File: `src/construction/assemblies/interiorWalls/index.ts` (new) -Similar to `BasePolygonTool` but for open chains instead of closed polygons. +```typescript +function resolveInteriorWallAssembly(config: InteriorWallAssemblyConfig): InteriorWallAssembly { + switch (config.type) { + case 'monolithic': + return new MonolithicInteriorWallAssembly(config) + case 'framed': + return new FramedInteriorWallAssembly(config) + // ... + } +} +``` -### Key Differences from BasePolygonTool +## C.3: Interior Wall Segmentation -| Aspect | BasePolygonTool | BasePolylineTool | -| ---------------- | ----------------------------- | -------------------------------------------- | -| Shape | Closed polygon | Open polyline | -| Finish condition | Click on first point or Enter | Click on existing wall (T-junction) or Enter | -| Minimum points | 3 | 2 | -| Preview | Closed loop | Open chain | +### File: `src/construction/assemblies/interiorWalls/segmentation.ts` (new) -### Abstract Interface +Simplified segmentation for intermediate walls (no corner extensions): ```typescript -interface PolylineToolConfig { - // Convert snap result to a point - snapToPoint(snap: SnapResult): Vec2 +function* segmentedInteriorWallConstruction( + wall: IntermediateWallWithGeometry, + storeyContext: StoreyContext, + wallConstruction: WallSegmentConstruction, + openingAssemblyId?: OpeningAssemblyId +): Generator +``` - // Determine if snap is a "terminating" snap (e.g., on existing wall) - isTerminatingSnap(snap: SnapResult): boolean +**Key differences from perimeter wall segmentation:** - // Create the entity for a segment - createSegment(startPoint: PointType, endPoint: PointType, startSnap: SnapResult, endSnap: SnapResult): void +- No corner extensions (walls attach to wall nodes, not perimeter corners) +- Uses `centerLine` for positioning (perimeter uses `innerLine`) +- Simpler wall area calculation (no reference side concept) +- Entity positions along `centerLine` instead of `innerLine` +- Height from storey context (no roof integration for interior walls) - // Validate a potential point placement - validatePoint(points: PointType[], newPoint: PointType, newSnap: SnapResult): ValidationResult +**Entity handling:** - // Get preview geometry for current segment - getPreviewGeometry(startPoint: PointType, currentPoint: Vec2): PreviewGeometry -} +- Same pattern as perimeter: iterate through sorted entities, construct segments between them +- Openings cut voids in the wall +- Posts are sub-areas within the wall + +## C.4: 3D Construction Builder -abstract class BasePolylineTool implements Tool { - protected points: PointType[] = [] - protected currentSnap: SnapResult | null = null - protected config: PolylineToolConfig +### File: `src/construction/store/builders.ts` - // Common tool lifecycle - onActivate(): void - onDeactivate(): void +Add intermediate wall construction: - // Mouse handling - onMouseMove(position: Vec2, modifiers: Modifiers): void - onClick(position: Vec2, modifiers: Modifiers): void +```typescript +function buildIntermediateWallCoreModel(wallId: IntermediateWallId): CoreModel { + const wall = getIntermediateWallById(wallId) + const perimeter = getPerimeterById(wall.perimeterId) + const storeyContext = getWallStoreyContextCached(perimeter.storeyId) - // Keyboard handling - onKeyDown(key: string, modifiers: Modifiers): void + const assemblyConfig = wall.wallAssemblyId + ? getInteriorWallAssemblyById(wall.wallAssemblyId) + : getDefaultInteriorWallAssembly() - // Abstract methods for subclasses - protected abstract performSnap(worldPos: Vec2): SnapResult | null - protected abstract createPointFromSnap(snap: SnapResult): PointType + if (!assemblyConfig) return emptyCoreModel() - // Protected helpers - protected finishChain(): void - protected cancelChain(): void - protected addPoint(point: PointType, snap: SnapResult): void + const assembly = resolveInteriorWallAssembly(assemblyConfig) + const wallModel = assembly.construct(wall, storeyContext) + return { model: wallModel, tags: [...], sourceId: wall.id } } ``` -### State Management +### Integration with composite builders + +Update composite builders to include intermediate walls: ```typescript -interface PolylineToolState { - points: PointType[] - isFinished: boolean - isValid: boolean - validationError?: string -} +// In buildPerimeterComposite: +// Add intermediate wall transforms alongside perimeter wall transforms + +// In buildStoreyComposite: +// Include intermediate walls for each perimeter in the storey ``` -## IntermediateWallTool Implementation +## C.5: Assembly Assignment in Inspector -Create `src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts`: +### File: `src/editor/inspectors/IntermediateWallInspector.tsx` -### Snap Types +Add assembly selector dropdown: -```typescript -type IntermediateWallSnapResult = - | { type: 'perimeter-corner'; corner: PerimeterCorner; position: Vec2 } - | { type: 'perimeter-wall'; wall: PerimeterWall; position: Vec2; offset: Length } - | { type: 'intermediate-wall-node'; node: WallNode; position: Vec2 } - | { type: 'intermediate-wall-midpoint'; wall: IntermediateWall; position: Vec2 } - | { type: 'perpendicular'; basePoint: Vec2; targetPoint: Vec2; referenceWall: IntermediateWall | PerimeterWall } - | { type: 'alignment'; point: Vec2; referenceLine: LineSegment2D } - | { type: 'free'; position: Vec2 } +```tsx +// Assembly field + store.dispatch.updateIntermediateWallAssembly(wall.id, assemblyId)} + assemblies={getAllInteriorWallAssemblies()} +/> ``` -### Point Type +### Store action ```typescript -interface IntermediateWallPoint { - position: Vec2 - snapResult: IntermediateWallSnapResult - perimeterId: PerimeterId | null // Determined during placement - nodeId?: WallNodeId // Set after node creation -} +// In intermediateWallsSlice.ts: +updateIntermediateWallAssembly(wallId: IntermediateWallId, assemblyId: InteriorWallAssemblyId): void ``` -### Tool Class +## C.6: Testing -```typescript -class IntermediateWallTool extends BasePolylineTool { - private thickness: Length = fromMillimeters(100) // Default 100mm - - constructor( - private store: Store, - private snappingService: SnappingService, - private canvas: Canvas - ) { - super({ - snapToPoint: snap => snap.position, - isTerminatingSnap: snap => snap.type === 'intermediate-wall-midpoint', - createSegment: (start, end, startSnap, endSnap) => this.createWallSegment(start, end, startSnap, endSnap), - validatePoint: (points, newPoint, newSnap) => this.validateWallPoint(points, newPoint, newSnap), - getPreviewGeometry: (start, current) => this.getWallPreview(start, current) - }) - } +- Test config store CRUD for interior wall assemblies +- Test monolithic assembly construction (basic cuboid) +- Test assembly construction with openings +- Test assembly construction with posts +- Test 3D builder integration +- Test assembly assignment via inspector - protected performSnap(worldPos: Vec2): IntermediateWallSnapResult | null { - // Priority order: - // 1. Existing wall nodes - // 2. Perimeter corners - // 3. Perimeter wall points - // 4. Intermediate wall midpoints - // 5. Perpendicular snap - // 6. Alignment snap - // 7. Free position (if inside perimeter) - - return this.snappingService.snapForIntermediateWall(worldPos, this.points) - } +--- - protected createPointFromSnap(snap: IntermediateWallSnapResult): IntermediateWallPoint { - const perimeterId = this.determinePerimeterId(snap) - return { - position: snap.position, - snapResult: snap, - perimeterId - } - } +# Phase D: Room Detection & Labeling - private determinePerimeterId(snap: IntermediateWallSnapResult): PerimeterId | null { - switch (snap.type) { - case 'perimeter-corner': - return snap.corner.perimeterId - case 'perimeter-wall': - return snap.wall.perimeterId - case 'intermediate-wall-node': - return snap.node.perimeterId - case 'intermediate-wall-midpoint': - return snap.wall.perimeterId - case 'perpendicular': - case 'alignment': - case 'free': - return this.findContainingPerimeter(snap.position) - } - } +## Goal - private createWallSegment( - start: IntermediateWallPoint, - end: IntermediateWallPoint, - startSnap: IntermediateWallSnapResult, - endSnap: IntermediateWallSnapResult - ): void { - if (!start.perimeterId || !end.perimeterId) return - if (start.perimeterId !== end.perimeterId) return +Automatically detect rooms from closed wall loops and allow users to assign room types and labels. - const perimeterId = start.perimeterId +## Current State - // Get or create start node - const startNodeId = this.getOrCreateNode(startSnap, perimeterId) - // Get or create end node - const endNodeId = this.getOrCreateNode(endSnap, perimeterId) +- **`Room` model type**: Defined in `rooms.ts` with `RoomType` enum +- **`RoomId`**: Defined in `ids.ts` but commented out of `SelectableId` +- **`Perimeter.roomIds`**: Always `[]` +- **`IntermediateWall.leftRoomId` / `rightRoomId`**: TODO placeholders +- **No detection algorithm exists** - if (!startNodeId || !endNodeId) return +## D.1: Room Detection Algorithm - // Create the wall - this.store.dispatch.addIntermediateWall({ - perimeterId, - start: { nodeId: startNodeId, axis: 'center' }, - end: { nodeId: endNodeId, axis: 'center' }, - thickness: this.thickness - }) - } +### File: `src/building/store/slices/roomDetection.ts` (new) - private getOrCreateNode(snap: IntermediateWallSnapResult, perimeterId: PerimeterId): WallNodeId | null { - switch (snap.type) { - case 'perimeter-corner': - // Find existing node at corner or create one - return this.store.dispatch.addPerimeterWallNode({ - perimeterId, - wallId: snap.corner.startingWallId, // Use appropriate wall - offsetFromCornerStart: fromMillimeters(0) - }) - - case 'perimeter-wall': - return this.store.dispatch.addPerimeterWallNode({ - perimeterId, - wallId: snap.wall.id, - offsetFromCornerStart: snap.offset - }) - - case 'intermediate-wall-node': - return snap.node.id - - case 'intermediate-wall-midpoint': - // This triggers the split and returns new node ID - return this.store.dispatch.splitIntermediateWallAtPoint({ - wallId: snap.wall.id, - point: snap.position - }) - - case 'perpendicular': - case 'alignment': - case 'free': - return this.store.dispatch.addInnerWallNode({ - perimeterId, - position: snap.position - }) - } - } +The algorithm detects rooms from the wall graph topology: - private validateWallPoint( - points: IntermediateWallPoint[], - newPoint: IntermediateWallPoint, - newSnap: IntermediateWallSnapResult - ): ValidationResult { - // 1. Must be inside a perimeter - if (!newPoint.perimeterId) { - return { valid: false, error: 'Point must be inside a perimeter' } - } +### Input - // 2. If there are existing points, must be same perimeter - if (points.length > 0 && points[0].perimeterId !== newPoint.perimeterId) { - return { valid: false, error: 'All points must be in the same perimeter' } - } +For a given perimeter: - // 3. Check for self-intersection with existing segments - if (points.length > 0) { - const lastPoint = points[points.length - 1] - const newSegment = { start: lastPoint.position, end: newPoint.position } +1. Collect all wall segments forming the boundary: + - Perimeter wall segments (each `PerimeterWall` becomes 1-2 segments depending on intermediate wall attachment points) + - Intermediate wall segments (each `IntermediateWall` is 1 segment) +2. Build a planar graph: nodes are wall endpoints (corners + wall nodes), edges are wall segments +3. Identify all minimal cycles (faces) in the planar graph - for (let i = 0; i < points.length - 1; i++) { - const existingSegment = { start: points[i].position, end: points[i + 1].position } - if (lineSegmentsIntersect(newSegment, existingSegment)) { - return { valid: false, error: 'Wall cannot cross existing walls' } - } - } - } +### Algorithm - // 4. Check for intersection with existing intermediate walls - const existingWalls = this.store.getIntermediateWallsByPerimeter(newPoint.perimeterId) - for (const wall of existingWalls) { - if (points.length > 0) { - const lastPoint = points[points.length - 1] - const newSegment = { start: lastPoint.position, end: newPoint.position } - const wallSegment = wall.geometry.centerLine - - // Allow touching at endpoints, but not crossing - if (lineSegmentsIntersect(newSegment, wallSegment)) { - // Check if intersection is at an endpoint (allowed) - const intersection = getLineSegmentIntersection(newSegment, wallSegment) - if (intersection && !isEndpoint(intersection, wallSegment)) { - return { valid: false, error: 'Wall cannot cross existing walls' } - } - } - } - } +``` +function detectRooms(perimeterId: PerimeterId): Room[]: + // 1. Build edge list + edges = [] + for each perimeter wall: + add edge from wall start corner to wall end corner + // If intermediate wall nodes exist on this wall, split the edge + for each PerimeterWallNode on this wall (sorted by offset): + split edge at node position + + for each intermediate wall: + add edge from start node to end node + + // 2. Build planar graph + graph = PlanarGraph.fromEdges(edges) + + // 3. Find all minimal cycles (faces) + faces = graph.findMinimalCycles() + + // 4. Filter out the outer face (the perimeter itself) + // The outer face contains all other faces + outerFace = face with largest area + rooms = faces.filter(f => f !== outerFace) + + // 5. Compute room geometry + for each room: + boundary = Polygon2D from cycle vertices + area = polygonArea(boundary) + + return rooms +``` - // 5. Minimum wall length - if (points.length > 0) { - const lastPoint = points[points.length - 1] - const distance = vec2Distance(lastPoint.position, newPoint.position) - if (distance < fromMillimeters(50)) { - // Minimum 50mm - return { valid: false, error: 'Wall segment too short' } - } - } +### Cycle Detection Approach - return { valid: true } - } +Use the **dual graph** approach for planar subdivision: - private getWallPreview(start: IntermediateWallPoint, current: Vec2): PreviewGeometry { - const direction = vec2Normalize(vec2Subtract(current, start.position)) - const perpendicular = vec2Perpendicular(direction) - const halfThickness = this.thickness / 2 - - const offset = vec2Scale(perpendicular, halfThickness) - - return { - type: 'polygon', - points: [ - vec2Add(start.position, offset), - vec2Add(current, offset), - vec2Subtract(current, offset), - vec2Subtract(start.position, offset) - ], - style: { fill: 'rgba(200, 200, 200, 0.5)', stroke: '#666', strokeWidth: 1 } - } - } +1. Build adjacency graph from edges +2. For each directed edge, find the minimal cycle by following the "next edge" (leftmost turn at each vertex) +3. This naturally enumerates all faces of the planar subdivision +4. Filter to keep only interior faces (rooms), not the exterior face - setThickness(thickness: Length): void { - this.thickness = thickness - } +### Incremental Updates + +Room detection should run: + +- After any intermediate wall is added/removed/split +- After any wall node is added/removed/moved +- After perimeter geometry changes + +**Optimization**: Only re-detect rooms for the affected perimeter. + +## D.2: Room Store Slice + +### File: `src/building/store/slices/roomsSlice.ts` (new) + +```typescript +interface RoomsState { + rooms: Record + _roomGeometry: Record } -``` -## Snapping Service Extension +interface RoomsActions { + // Internal (called by intermediate wall/perimeter mutations): + _detectRoomsForPerimeter(perimeterId: PerimeterId): void + _updateRoomLeftRight(wallId: IntermediateWallId): void + + // User actions: + updateRoomType(roomId: RoomId, type: RoomType): void + updateRoomCustomLabel(roomId: RoomId, label: string): void -Extend `SnappingService.ts` to support intermediate wall snapping: + // Getters: + getRoomById(id: RoomId): RoomWithGeometry | undefined + getRoomsByPerimeter(perimeterId: PerimeterId): RoomWithGeometry[] + getAllRooms(): RoomWithGeometry[] +} +``` + +### Room Geometry Computation ```typescript -interface SnappingService { - // Existing methods... - - // New method for intermediate wall tool - snapForIntermediateWall( - worldPos: Vec2, - existingPoints: IntermediateWallPoint[] - ): IntermediateWallSnapResult | null +function updateRoomGeometry(state: RoomsState, roomId: RoomId): void { + const room = state.rooms[roomId] + // Boundary is computed during detection + const boundary = room.boundary // Polygon2D from cycle vertices + const area = polygonArea(boundary) + state._roomGeometry[roomId] = { boundary, area } } +``` -// Implementation -snapForIntermediateWall( - worldPos: Vec2, - existingPoints: IntermediateWallPoint[] -): IntermediateWallSnapResult | null { - const snapRadius = this.getSnapRadius() - - // 1. Check existing wall nodes - const wallNodes = this.store.getAllWallNodes() - for (const node of wallNodes) { - if (vec2Distance(worldPos, node.geometry.position) < snapRadius) { - return { - type: 'intermediate-wall-node', - node: node.model, - position: node.geometry.position - } - } - } +### Left/Right Room Assignment - // 2. Check perimeter corners - const corners = this.store.getAllPerimeterCorners() - for (const corner of corners) { - if (vec2Distance(worldPos, corner.geometry.position) < snapRadius) { - return { - type: 'perimeter-corner', - corner: corner.model, - position: corner.geometry.position - } - } - } +After room detection, assign rooms to wall sides: - // 3. Check perimeter walls - const perimeterWalls = this.store.getAllPerimeterWalls() - for (const wall of perimeterWalls) { - const projection = projectPointOnLineSegment(wall.geometry.innerLine, worldPos) - if (projection.distance < snapRadius) { - return { - type: 'perimeter-wall', - wall: wall.model, - position: projection.point, - offset: projection.t * wall.geometry.wallLength +```typescript +function assignRoomsToWalls(state: RoomsState, perimeterId: PerimeterId): void { + const rooms = getRoomsByPerimeter(perimeterId) + const walls = getIntermediateWallsByPerimeter(perimeterId) + + for (const wall of walls) { + const wallMidpoint = lineSegmentMidpoint(wall.geometry.centerLine) + const wallNormal = wall.geometry.direction // perpendicular to center line + + for (const room of rooms) { + if (pointInPolygon(wallMidpoint + wallNormal * epsilon, room.geometry.boundary)) { + wall.leftRoomId = room.id + } else if (pointInPolygon(wallMidpoint - wallNormal * epsilon, room.geometry.boundary)) { + wall.rightRoomId = room.id } } } +} +``` - // 4. Check intermediate walls (midpoint snapping) - const intermediateWalls = this.store.getAllIntermediateWalls() - for (const wall of intermediateWalls) { - const projection = projectPointOnLineSegment(wall.geometry.centerLine, worldPos) - if (projection.distance < snapRadius) { - // Check if near an endpoint (already handled by node snap) - const nearStart = vec2Distance(projection.point, wall.geometry.centerLine.start) < snapRadius - const nearEnd = vec2Distance(projection.point, wall.geometry.centerLine.end) < snapRadius - if (!nearStart && !nearEnd) { - return { - type: 'intermediate-wall-midpoint', - wall: wall.model, - position: projection.point - } - } - } - } +### Integration with Perimeter - // 5. Perpendicular snapping (if we have previous point) - if (existingPoints.length > 0) { - const lastPoint = existingPoints[existingPoints.length - 1] - const perpSnap = this.findPerpendicularSnap(worldPos, lastPoint.position, [ - ...perimeterWalls.map(w => w.geometry.centerLine), - ...intermediateWalls.map(w => w.geometry.centerLine) - ]) - if (perpSnap && vec2Distance(worldPos, perpSnap.targetPoint) < snapRadius * 2) { - return { - type: 'perpendicular', - basePoint: lastPoint.position, - targetPoint: perpSnap.targetPoint, - referenceWall: perpSnap.referenceWall - } - } - } +Update `Perimeter.roomIds` when rooms are detected: - // 6. Alignment snapping - const alignmentSnap = this.findAlignmentSnap(worldPos, [ - ...perimeterWalls.map(w => w.geometry.centerLine), - ...intermediateWalls.map(w => w.geometry.centerLine) - ]) - if (alignmentSnap) { - return alignmentSnap - } +```typescript +// In perimeterSlice or roomsSlice: +perimeter.roomIds = rooms.filter(r => r.perimeterId === perimeterId).map(r => r.id) +``` - // 7. Free position (validate inside perimeter) - const containingPerimeter = this.findContainingPerimeter(worldPos) - if (containingPerimeter) { - return { type: 'free', position: worldPos } - } +## D.3: Room Detection Triggers - return null -} +Room detection runs automatically after mutations: + +```typescript +// In intermediateWallsSlice.ts - after wall/node mutations: +// Call roomsSlice._detectRoomsForPerimeter(perimeterId) + +// In perimeterSlice.ts - after perimeter geometry changes: +// Call roomsSlice._detectRoomsForPerimeter(perimeterId) ``` -## Inspector Component +**Debounce**: If multiple mutations happen in quick succession (e.g., drawing a chain of walls), debounce room detection to run only after the last mutation. -Create `src/editor/tools/intermediate-wall/add/IntermediateWallToolInspector.tsx`: +## D.4: Room Selection -```tsx -import { useEffect, useState } from 'react' +### File: `src/building/model/ids.ts` + +Uncomment `RoomId` from `SelectableId`: + +```typescript +type SelectableId = + | PerimeterId + | PerimeterCornerId + | PerimeterWallId + | IntermediateWallId + | WallNodeId + | OpeningId + | WallPostId + | RoomId // <-- Uncomment +``` + +### File: `src/editor/canvas/layers/rooms/RoomShape.tsx` (new) -import { Length, fromMillimeters, toMillimeters } from '@/building/model/units' +SVG rendering for rooms: -import { IntermediateWallTool } from './IntermediateWallTool' +```tsx +function RoomShape({ roomId }: { roomId: RoomId }) { + const room = useRoomById(roomId) + if (!room) return null -interface IntermediateWallToolInspectorProps { - tool: IntermediateWallTool + return ( + + + + {room.customLabel || getRoomTypeLabel(room.type, room.counter)} + + + ) } +``` + +### Canvas layer integration + +Add room rendering as a background layer below walls. -function IntermediateWallToolInspector({ tool }: IntermediateWallToolInspectorProps) { - const [thickness, setThickness] = useState(() => toMillimeters(tool.getThickness())) +## D.5: Room Labeling UI - useEffect(() => { - tool.setThickness(fromMillimeters(thickness)) - }, [thickness, tool]) +### File: `src/editor/inspectors/RoomInspector.tsx` (new) + +Inspector for selected rooms: + +```tsx +function RoomInspector({ roomId }: { roomId: RoomId }) { + const room = useRoomById(roomId) + const [type, setType] = useState(room.type) + const [label, setLabel] = useState(room.customLabel) return ( -
-

Intermediate Wall

+
+

Room

+ +
+ + +
-
- +
+ setThickness(Number(e.target.value))} - min={50} - max={500} - step={10} + value={label} + onChange={e => { + setLabel(e.target.value) + store.dispatch.updateRoomCustomLabel(roomId, e.target.value) + }} />
-
-

Click to place wall points

-

Press Enter to finish

-

Press Escape to cancel

+
+ + {formatArea(room.geometry.area)}
) } - -export default IntermediateWallToolInspector ``` -## Overlay Component +### File: `src/editor/tools/basic/SelectToolInspector.tsx` -Create `src/editor/tools/intermediate-wall/add/IntermediateWallToolOverlay.tsx`: +Add room handling: ```tsx -import { useEffect, useRef } from 'react' +{ + selectedId && isRoomId(selectedId) && +} +``` -import { useToolState } from '@/editor/canvas/hooks/useToolState' +## D.6: Counter Management -import { IntermediateWallTool } from './IntermediateWallTool' +Room counters (e.g., "Bedroom 1", "Bedroom 2") need to be managed: -interface IntermediateWallToolOverlayProps { - tool: IntermediateWallTool +```typescript +// When room type changes: +function getNextCounter(perimeterId: PerimeterId, roomType: RoomType, excludeRoomId?: RoomId): number { + const rooms = getRoomsByPerimeter(perimeterId) + const existing = rooms.filter(r => r.type === roomType && r.id !== excludeRoomId).map(r => r.counter) + return existing.length > 0 ? Math.max(...existing) + 1 : 1 } -function IntermediateWallToolOverlay({ tool }: IntermediateWallToolOverlayProps) { - const canvasRef = useRef(null) - const toolState = useToolState(tool) +// When a room is deleted or type changed: +// Re-number remaining rooms of the same type to fill gaps +``` - useEffect(() => { - const canvas = canvasRef.current - if (!canvas) return +## D.7: Testing - const ctx = canvas.getContext('2d') - if (!ctx) return +- Test cycle detection on simple rectangular room (4 perimeter walls + 2 intermediate walls) +- Test cycle detection on L-shaped room +- Test cycle detection with T-junctions +- Test detection when rooms share a wall +- Test left/right room assignment +- Test room geometry computation (area) +- Test incremental detection after wall add/remove/split +- Test counter management +- Test room type labeling +- Test room selection - // Clear canvas - ctx.clearRect(0, 0, canvas.width, canvas.height) +--- - // Draw placed points and segments - const points = toolState.points - if (points.length > 0) { - // Draw segments - ctx.strokeStyle = '#666' - ctx.lineWidth = toolState.thickness - ctx.lineCap = 'butt' +# Phase E: Room Preset Tool - ctx.beginPath() - ctx.moveTo(points[0].position.x, points[0].position.y) - for (let i = 1; i < points.length; i++) { - ctx.lineTo(points[i].position.x, points[i].position.y) - } - ctx.stroke() - - // Draw points - ctx.fillStyle = '#333' - for (const point of points) { - ctx.beginPath() - ctx.arc(point.position.x, point.position.y, 4, 0, Math.PI * 2) - ctx.fill() - } - } +## Goal - // Draw preview segment - if (points.length > 0 && toolState.currentSnap) { - const lastPoint = points[points.length - 1] - const currentPoint = toolState.currentSnap.position - - // Preview line - ctx.strokeStyle = 'rgba(100, 100, 100, 0.5)' - ctx.lineWidth = toPixels(toolState.thickness) - ctx.setLineDash([5, 5]) - ctx.beginPath() - ctx.moveTo(lastPoint.position.x, lastPoint.position.y) - ctx.lineTo(currentPoint.x, currentPoint.y) - ctx.stroke() - ctx.setLineDash([]) - - // Snap indicator - drawSnapIndicator(ctx, toolState.currentSnap) - } +Place rectangular room presets inside an existing perimeter. The user configures dimensions and room type in a dialog, then clicks a position on the canvas where the room walls snap to existing walls. - // Draw validation error if any - if (!toolState.isValid && toolState.validationError) { - ctx.fillStyle = 'rgba(255, 0, 0, 0.8)' - ctx.font = '14px sans-serif' - ctx.fillText(toolState.validationError, 10, 20) - } - }, [toolState]) +## Prerequisites - return -} +- Phase D (Room Detection) must be complete for room creation +- Phase C (Assemblies) should be complete for wall assembly assignment -function drawSnapIndicator(ctx: CanvasRenderingContext2D, snap: IntermediateWallSnapResult) { - const { position, type } = snap +## E.1: Tool Concept - ctx.strokeStyle = type === 'intermediate-wall-midpoint' ? '#ff6600' : '#00ff00' - ctx.lineWidth = 2 +**Interaction flow:** - ctx.beginPath() - ctx.arc(position.x, position.y, 8, 0, Math.PI * 2) - ctx.stroke() +1. User activates the room preset tool +2. Inspector shows a dialog with: width, length, wall thickness, wall assembly, room type +3. After confirming dimensions, the tool enters canvas placement mode +4. A ghost rectangle follows the cursor, snapped to nearby walls +5. User clicks to place the room +6. Intermediate walls are created for sides that don't align with existing walls +7. Room detection runs, creating the new room with the configured type - // Special indicator for T-junction - if (type === 'intermediate-wall-midpoint') { - ctx.fillStyle = '#ff6600' - ctx.font = '12px sans-serif' - ctx.fillText('T', position.x + 12, position.y + 4) - } -} +**Snapping behavior:** -export default IntermediateWallToolOverlay -``` +- Each side of the rectangle snaps to nearby existing walls (perimeter or intermediate) +- If a side aligns with an existing wall, no new wall is created for that side +- If a corner snaps to an existing wall node, reuse that node +- If a corner is free, create an inner wall node +- If a corner snaps to a perimeter wall midpoint, create a perimeter wall node -## Tool Registration +## E.2: Room Preset Config -### Update `src/editor/tools/system/types.ts` +### File: `src/editor/tools/room/preset/types.ts` (new) ```typescript -export type ToolId = - | 'select' - | 'perimeter-add' - | 'intermediate-wall-add' // NEW - | // ... other tools +interface RoomPresetConfig { + width: Length // Interior width + length: Length // Interior length + thickness: Length // Wall thickness + wallAssemblyId?: InteriorWallAssemblyId + roomType: RoomType + customLabel?: string +} ``` -### Update `src/editor/tools/system/metadata.ts` +## E.3: RoomPresetTool + +### File: `src/editor/tools/room/preset/RoomPresetTool.ts` (new) + +Two-phase tool: dialog phase then placement phase. ```typescript -import intermediateWallAddIcon from '@/shared/assets/icons/wall-intermediate.svg' - -export const toolMetadata: Record = { - // ... existing tools - - 'intermediate-wall-add': { - id: 'intermediate-wall-add', - name: 'Intermediate Wall', - icon: intermediateWallAddIcon, - category: 'walls', - shortcut: 'W', - description: 'Draw interior partition walls', - factory: () => new IntermediateWallTool(/* dependencies */) +class RoomPresetTool extends BaseTool { + private config: RoomPresetConfig | null = null + private placementMode: boolean = false + + // Phase 1: Dialog (handled by inspector component) + // Inspector shows config fields, "Place" button sets this.config and placementMode = true + + // Phase 2: Canvas placement + handlePointerMove(position: Vec2): void { + // Compute ghost rectangle at cursor position + // Snap rectangle edges to nearby walls + // Render preview via overlay + } + + handlePointerDown(position: Vec2): void { + // 1. Determine which sides of the rectangle align with existing walls + // 2. For sides needing new walls: + // a. Compute start/end nodes (snap to existing nodes or create new ones) + // b. Create intermediate walls + // 3. Run room detection for the perimeter + // 4. Set the new room's type to config.roomType + // 5. Pop tool } } ``` -## Validation Rules Summary +## E.4: Snapping Logic + +The ghost rectangle has 4 edges. For each edge: + +1. **Check alignment with existing walls**: Project the edge onto nearby walls. If the edge is colinear with an existing wall and within snap distance, snap to it. +2. **Corner snapping**: Each corner snaps to: + - Existing wall nodes (highest priority) + - Perimeter wall midpoints (creates perimeter wall node) + - Other intermediate wall midpoints (triggers split) + - Free position (creates inner wall node) +3. **Dimension preservation**: When one edge snaps to an existing wall, the perpendicular edges may need to adjust to maintain the configured width/length. The user can toggle between "preserve dimensions" and "stretch to fit" modes. -1. **Inside perimeter**: All points must be inside a perimeter polygon -2. **Same perimeter**: All points in a chain must belong to the same perimeter -3. **No self-intersection**: Wall segments cannot cross each other within the same chain -4. **No wall crossing**: Cannot cross existing intermediate walls (except at endpoints) -5. **Minimum length**: Each segment must be at least 50mm -6. **Perimeter walls allowed**: Can touch/cross perimeter walls (they're boundaries) +## E.5: Wall Creation Strategy -## File Structure +After placement, determine which walls need to be created: ``` -src/editor/tools/ -├── shared/ -│ ├── polygon/ -│ │ └── BasePolygonTool.ts (existing) -│ └── polyline/ -│ └── BasePolylineTool.ts (NEW) -│ -├── intermediate-wall/ -│ └── add/ -│ ├── IntermediateWallTool.ts (NEW) -│ ├── IntermediateWallToolInspector.tsx (NEW) -│ └── IntermediateWallToolOverlay.tsx (NEW) -│ -└── system/ - ├── types.ts (UPDATE - add ToolId) - ├── metadata.ts (UPDATE - register tool) - └── ToolSystem.ts (UPDATE - if needed) - -src/building/store/slices/ -└── intermediateWallsSlice.ts (UPDATE - add splitIntermediateWallAtPoint) - -src/editor/canvas/services/ -└── SnappingService.ts (UPDATE - add snapForIntermediateWall) +for each side of the rectangle: + if side fully aligns with an existing wall: + // No new wall needed - existing wall becomes a room boundary + // Update room detection will pick this up + else if side partially aligns with an existing wall: + // Create intermediate wall for the non-aligned portion + // Snap endpoints to existing wall nodes or create new ones + else: + // Create full intermediate wall for this side + // Snap endpoints to nearest nodes or create new ones ``` -## Key Patterns to Follow +## E.6: Inspector Component + +### File: `src/editor/tools/room/preset/RoomPresetToolInspector.tsx` (new) + +```tsx +function RoomPresetToolInspector({ tool }: Props) { + // Dimension fields (width, length, thickness) + // Wall assembly selector + // Room type selector (dropdown of RoomType values) + // Custom label input + // "Place" button → enters placement mode + // Help text for placement phase +} +``` + +## E.7: Overlay Component + +### File: `src/editor/tools/room/preset/RoomPresetToolOverlay.tsx` (new) + +SVG overlay rendering: + +- Ghost rectangle with dashed lines +- Color-coded edges: green for snapped-to-existing, gray for new walls +- Dimension labels on edges +- Room type label at center +- Snap indicators at corners + +## E.8: Testing + +- Test dialog configuration and validation +- Test snapping to perimeter walls (full and partial alignment) +- Test snapping to existing wall nodes +- Test wall creation (new walls vs reusing existing) +- Test room detection after placement +- Test room type assignment +- Test placement that requires T-junction splits + +--- + +# Phase F: Room Split Tool + +## Goal + +Split an existing room by placing a wall across it, creating two new rooms. The user clicks a position inside the room and the split wall is placed horizontally or vertically through that point. + +## Prerequisites + +- Phase D (Room Detection) must be complete +- Phase A (Constraints) recommended for wall constraint generation -1. **Vec2 creation**: Always use `newVec2(x, y)`, never object literals -2. **Geometry updates**: Call `updateIntermediateWallGeometry(state, id)` after every mutation -3. **Getters**: Combine base model + geometry in getter functions -4. **Timestamps**: Update via `touch(state, entityId)` for undo/redo -5. **State access**: Geometry functions need perimeter geometry access for perimeter nodes +## F.1: Tool Concept -## Implementation Phases +**Interaction flow:** -### Phase 1: Store & Geometry (Current) +1. User activates the room split tool +2. Inspector shows: wall thickness, wall assembly, split orientation (horizontal/vertical) +3. User hovers over a room → the room highlights +4. A preview line appears through the room at the cursor position (horizontal or vertical) +5. The preview line snaps to align with existing wall nodes on the opposite walls +6. User clicks to place the split wall +7. The split wall is created, room detection runs, producing two rooms -- [x] Implement `intermediateWallGeometry.ts` -- [x] Implement `intermediateWallsSlice.ts` actions -- [x] Integrate into main store -- [ ] Unit tests for geometry computation -- [ ] Implement `splitIntermediateWallAtPoint` action +**Split orientation:** + +- Default: horizontal (wall runs left-right) +- Toggle: vertical (wall runs top-bottom) — via modifier key (e.g., Shift) or inspector toggle +- Auto-detect: based on room proportions (wider rooms get horizontal splits, taller rooms get vertical) + +## F.2: Split Computation + +### File: `src/editor/tools/room/split/roomSplitGeometry.ts` (new) + +```typescript +interface RoomSplitResult { + wallStart: Vec2 // Start point of the split wall + wallEnd: Vec2 // End point of the split wall + startSnap: SnapTarget // What the start snaps to + endSnap: SnapTarget // What the end snaps to + roomAPolygon: Polygon2D // Boundary of room A + roomBPolygon: Polygon2D // Boundary of room B +} + +type SnapTarget = + | { type: 'wall-node'; nodeId: WallNodeId } + | { type: 'perimeter-wall'; wallId: PerimeterWallId; offset: Length } + | { type: 'intermediate-wall-midpoint'; wallId: IntermediateWallId; point: Vec2 } + +function computeRoomSplit( + room: RoomWithGeometry, + position: Vec2, + orientation: 'horizontal' | 'vertical', + snapCandidates: SnapTarget[] +): RoomSplitResult +``` + +**Algorithm:** + +1. Determine the split line: horizontal or vertical line through `position`, clipped to the room boundary +2. Find where the split line intersects the room boundary walls +3. For each intersection point, determine the snap target (existing node, perimeter wall, or intermediate wall midpoint) +4. If snapping to an intermediate wall midpoint, plan a split (T-junction) +5. Compute the two resulting room polygons by splitting the original room boundary along the split line + +## F.3: RoomSplitTool + +### File: `src/editor/tools/room/split/RoomSplitTool.ts` (new) + +```typescript +class RoomSplitTool extends BaseTool { + private orientation: 'horizontal' | 'vertical' = 'horizontal' + private hoveredRoomId: RoomId | null = null + private splitResult: RoomSplitResult | null = null + + handlePointerMove(position: Vec2): void { + // 1. Determine which room the cursor is in + this.hoveredRoomId = findRoomAtPoint(position) + if (!this.hoveredRoomId) { + this.splitResult = null + return + } + + // 2. Compute split preview + this.splitResult = computeRoomSplit(room, position, this.orientation, snapCandidates) + } + + handlePointerDown(position: Vec2): void { + if (!this.hoveredRoomId || !this.splitResult) return + + // 1. Create start node (snap or new) + const startNodeId = getOrCreateNode(this.splitResult.startSnap) + + // 2. Create end node (may trigger intermediate wall split) + const endNodeId = getOrCreateNode(this.splitResult.endSnap) + + // 3. Create the split wall + addIntermediateWall({ + perimeterId: room.perimeterId, + start: { nodeId: startNodeId, axis: 'center' }, + end: { nodeId: endNodeId, axis: 'center' }, + thickness: this.thickness, + wallAssemblyId: this.assemblyId + }) + + // 4. Room detection runs automatically, producing two rooms + } + + handleKeyDown(key: string): void { + // Shift toggles orientation + if (key === 'Shift') { + this.orientation = this.orientation === 'horizontal' ? 'vertical' : 'horizontal' + } + } +} +``` + +## F.4: Inspector Component + +### File: `src/editor/tools/room/split/RoomSplitToolInspector.tsx` (new) + +```tsx +function RoomSplitToolInspector({ tool }: Props) { + return ( +
+

Split Room

+ + + +
+

Hover over a room and click to split

+

Hold Shift to toggle orientation

+

Press Escape to cancel

+
+
+ ) +} +``` -### Phase 2: Drawing Tools +## F.5: Overlay Component -- [ ] Create `BasePolylineTool` base class -- [ ] Create `IntermediateWallTool` -- [ ] Implement snapping for intermediate walls -- [ ] Implement perpendicular snapping -- [ ] Implement node creation logic -- [ ] Connect to perimeter walls +### File: `src/editor/tools/room/split/RoomSplitToolOverlay.tsx` (new) -### Phase 3: UI Components +SVG overlay rendering: -- [ ] Create `IntermediateWallToolInspector` -- [ ] Create `IntermediateWallToolOverlay` -- [ ] Add tool icon -- [ ] Register tool in ToolSystem +- Highlight the hovered room (lighter fill) +- Draw the split preview line (dashed, color-coded by orientation) +- Draw snap indicators at endpoints +- Show dimension labels for the two resulting sub-rooms +- Show area labels for each sub-room -### Phase 4: Testing +## F.6: Edge Cases -- [ ] Test snapping to all snap types -- [ ] Test T-junction creation -- [ ] Test validation rules -- [ ] Test chain drawing and finishing -- [ ] Test cancellation +- **Split through an existing intermediate wall**: The split line may cross an existing intermediate wall. In this case, create a T-junction (split the existing wall) and create two wall segments for the split. +- **Split at an existing wall node**: If the split line passes through an existing wall node, use that node directly — the result is more than two rooms (e.g., splitting one room into three if a wall node is on the split line). +- **Split against perimeter wall**: Endpoints on perimeter walls create perimeter wall nodes. +- **Very thin resulting room**: Warn if one of the resulting rooms would be below a minimum area threshold. -### Phase 5: Polish +## F.7: Testing -- [ ] Visual feedback for snap types -- [ ] Error messages display -- [ ] Keyboard shortcuts -- [ ] Undo/redo support +- Test horizontal split of rectangular room +- Test vertical split of rectangular room +- Test split with snapped endpoints to existing nodes +- Test split with endpoints on perimeter walls +- Test split through existing intermediate wall (T-junction) +- Test split at existing wall node (creating 3 rooms) +- Test orientation toggle (Shift key) +- Test room detection after split +- Test minimum area validation +- Test split of L-shaped room From 8e4293962f425f4bb8640bcad08f43ce722a7c8b Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Mon, 30 Mar 2026 23:25:58 +0200 Subject: [PATCH 15/21] Fix: Proper snapping to intersection with wall --- .../canvas/services/SnappingService.test.ts | 124 ++++++++++++++++++ src/editor/canvas/services/SnappingService.ts | 44 +++++++ .../add/IntermediateWallTool.ts | 2 + 3 files changed, 170 insertions(+) diff --git a/src/editor/canvas/services/SnappingService.test.ts b/src/editor/canvas/services/SnappingService.test.ts index f4d62df9c..094fe97b7 100644 --- a/src/editor/canvas/services/SnappingService.test.ts +++ b/src/editor/canvas/services/SnappingService.test.ts @@ -265,6 +265,130 @@ describe('SnappingService', () => { }) }) + describe('Segment-Line Intersection Snapping', () => { + it('should snap to intersection of a line and a segment when line is added first', () => { + const candidates: SnapCandidate[] = [ + { + type: 'segment', + segment: { start: newVec2(500, 100), end: newVec2(700, 300) }, + priority: 1 + }, + { + type: 'line', + line: { point: newVec2(0, 200), direction: newVec2(1, 0) }, + priority: 1 + } + ] + const svc = new SnappingService({ candidates }) + + const target = newVec2(595, 205) + const result = svc.findSnapResult(target) + + expect(result).not.toBeNull() + expect(result?.type).toBe('snap') + expect(result?.position[0]).toBe(600) + expect(result?.position[1]).toBe(200) + expect(result?.lines?.length).toBe(2) + }) + + it('should snap to intersection of a segment and a line when segment is added first', () => { + const candidates: SnapCandidate[] = [ + { + type: 'line', + line: { point: newVec2(0, 200), direction: newVec2(1, 0) }, + priority: 1 + }, + { + type: 'segment', + segment: { start: newVec2(500, 100), end: newVec2(700, 300) }, + priority: 1 + } + ] + const svc = new SnappingService({ candidates }) + + const target = newVec2(595, 205) + const result = svc.findSnapResult(target) + + expect(result).not.toBeNull() + expect(result?.type).toBe('snap') + expect(result?.position[0]).toBe(600) + expect(result?.position[1]).toBe(200) + expect(result?.lines?.length).toBe(2) + }) + + it('should not snap when line misses the segment', () => { + const candidates: SnapCandidate[] = [ + { + type: 'segment', + segment: { start: newVec2(500, 100), end: newVec2(600, 200) }, + priority: 1 + }, + { + type: 'line', + line: { point: newVec2(0, 500), direction: newVec2(1, 0) }, + priority: 1, + minDistance: 10 + } + ] + const svc = new SnappingService({ candidates }) + + const target = newVec2(550, 400) + const result = svc.findSnapResult(target) + + expect(result).toBeNull() + }) + + it('should generate segment-line intersection when segment is dynamically added', () => { + const svc = new SnappingService({ + candidates: [ + { + type: 'line', + line: { point: newVec2(0, 150), direction: newVec2(1, 0) } + } + ] + }) + + svc.addSnapCandidate({ + type: 'segment', + segment: { start: newVec2(0, 0), end: newVec2(300, 300) } + }) + + const target = newVec2(155, 145) + const result = svc.findSnapResult(target) + + expect(result).not.toBeNull() + expect(result?.type).toBe('snap') + expect(result?.position[0]).toBe(150) + expect(result?.position[1]).toBe(150) + expect(result?.lines?.length).toBe(2) + }) + + it('should generate segment-line intersection when line is dynamically added', () => { + const svc = new SnappingService({ + candidates: [ + { + type: 'segment', + segment: { start: newVec2(0, 0), end: newVec2(300, 300) } + } + ] + }) + + svc.addSnapCandidate({ + type: 'line', + line: { point: newVec2(0, 150), direction: newVec2(1, 0) } + }) + + const target = newVec2(155, 145) + const result = svc.findSnapResult(target) + + expect(result).not.toBeNull() + expect(result?.type).toBe('snap') + expect(result?.position[0]).toBe(150) + expect(result?.position[1]).toBe(150) + expect(result?.lines?.length).toBe(2) + }) + }) + describe('minDistance', () => { it('should use per-candidate minDistance when set', () => { const candidates: SnapCandidate[] = [ diff --git a/src/editor/canvas/services/SnappingService.ts b/src/editor/canvas/services/SnappingService.ts index 512ce3b24..42d6677a7 100644 --- a/src/editor/canvas/services/SnappingService.ts +++ b/src/editor/canvas/services/SnappingService.ts @@ -11,6 +11,7 @@ import { lenSqrVec2, lineFromSegment, lineIntersection, + lineSegmentIntersect, newVec2, projectPointOntoLine, scaleAddVec2, @@ -151,6 +152,19 @@ export class SnappingService { for (const existing of otherLines) { this.addIntersectionSnapCandidates(internalCandidate, existing) } + const otherSegments = this.candidates.filter( + (c): c is SnapLineSegment & InternalMeta => c.type === 'segment' && !c.isDerived + ) + for (const existing of otherSegments) { + this.addSegmentLineIntersectionSnapCandidates(internalCandidate, existing) + } + } else if (internalCandidate.type === 'segment') { + const otherLines = this.candidates.filter( + (c): c is SnapLine & InternalMeta => c.type === 'line' && !c.isDerived + ) + for (const existing of otherLines) { + this.addSegmentLineIntersectionSnapCandidates(existing, internalCandidate) + } } } } @@ -277,4 +291,34 @@ export class SnappingService { }) } } + + private addSegmentLineIntersectionSnapCandidates( + line: SnapLine & InternalMeta, + segment: SnapLineSegment & InternalMeta + ): void { + const intersection = lineSegmentIntersect(line.line, segment.segment) + if (intersection) { + const meta = + line.priority > segment.priority + ? line.meta + : segment.priority > line.priority + ? segment.meta + : line.meta === segment.meta + ? line.meta + : undefined + const minDistance = Math.min(line.minDistance, segment.minDistance) + const priority = Math.max(line.priority, segment.priority) + PRIORITY_EPS + const segmentLine = lineFromSegment(segment.segment) + this.addSnapCandidateInternal({ + type: 'point', + position: intersection, + mode: 'snap', + priority, + isDerived: true, + minDistance, + meta, + lines: [line.line, segmentLine] + }) + } + } } diff --git a/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts index ee5356a67..16f5f9e7d 100644 --- a/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts +++ b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts @@ -453,5 +453,7 @@ export class IntermediateWallTool extends BaseTool implements ToolImplementation this.state.lengthOverride = null this.state.segmentLengthOverrides = [] this.state.thickness = 120 + this.resetContext() + this.setupContext() } } From f70269ff110eccb974bdddef51982e926e62c125 Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Tue, 31 Mar 2026 08:18:18 +0200 Subject: [PATCH 16/21] Fix some regressions in with regards to snapping --- src/editor/canvas/services/SnappingService.ts | 2 ++ .../behaviors/PerimeterMovementBehavior.ts | 6 +++++- .../behaviors/PolygonMovementBehavior.ts | 13 ++++++------- .../behaviors/RoofMovementBehavior.ts | 14 ++++++++++---- .../tools/shared/polygon/BasePolygonTool.ts | 3 ++- .../05-cancelled-chromium-linux.png | Bin 5325 -> 5335 bytes .../05-cancelled-firefox-linux.png | Bin 31990 -> 32064 bytes .../05-cancelled-webkit-linux.png | Bin 6349 -> 6360 bytes .../08-completed-chromium-linux.png | Bin 17248 -> 17078 bytes .../08-completed-firefox-linux.png | Bin 47649 -> 47440 bytes .../08-completed-webkit-linux.png | Bin 14142 -> 14020 bytes .../17-roof-dragging-chromium-linux.png | Bin 51286 -> 50854 bytes .../17-roof-dragging-firefox-linux.png | Bin 87967 -> 87127 bytes ...pening-dragging-snap-wall-webkit-linux.png | Bin 52913 -> 52420 bytes .../06-cancelled-chromium-linux.png | Bin 5315 -> 5327 bytes .../06-cancelled-firefox-linux.png | Bin 32040 -> 32093 bytes .../06-cancelled-webkit-linux.png | Bin 6346 -> 6356 bytes .../10-completed-chromium-linux.png | Bin 9159 -> 8981 bytes .../10-completed-firefox-linux.png | Bin 38352 -> 38102 bytes .../10-completed-webkit-linux.png | Bin 10699 -> 10494 bytes 20 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/editor/canvas/services/SnappingService.ts b/src/editor/canvas/services/SnappingService.ts index 42d6677a7..0e7539f6b 100644 --- a/src/editor/canvas/services/SnappingService.ts +++ b/src/editor/canvas/services/SnappingService.ts @@ -25,6 +25,7 @@ export interface SnappingContext { } export interface SnapResult { + priority: number position: Vec2 distance: Length lines?: readonly [Line2D] | readonly [Line2D, Line2D] @@ -201,6 +202,7 @@ export class SnappingService { if (!this.referencePoint || distSqrVec2(point, this.referencePoint) >= referenceDistSq) { const snapType = 'mode' in candidate ? candidate.mode : candidate.type === 'segment' ? 'snap' : 'align' results.push({ + priority: candidate.priority, position: point, distance, lines: candidate.lines, diff --git a/src/editor/tools/basic/movement/behaviors/PerimeterMovementBehavior.ts b/src/editor/tools/basic/movement/behaviors/PerimeterMovementBehavior.ts index c497c9027..87e5d077b 100644 --- a/src/editor/tools/basic/movement/behaviors/PerimeterMovementBehavior.ts +++ b/src/editor/tools/basic/movement/behaviors/PerimeterMovementBehavior.ts @@ -52,7 +52,11 @@ export class PerimeterMovementBehavior extends PolygonMovementBehavior({ candidates: snapCandidates }) + const snapService = new SnappingService({ + candidates: snapCandidates, + defaultPointDistance: 200, + defaultLineDistance: 100 + }) return { perimeter, snapService } } diff --git a/src/editor/tools/basic/movement/behaviors/PolygonMovementBehavior.ts b/src/editor/tools/basic/movement/behaviors/PolygonMovementBehavior.ts index 09fbf090d..45e627616 100644 --- a/src/editor/tools/basic/movement/behaviors/PolygonMovementBehavior.ts +++ b/src/editor/tools/basic/movement/behaviors/PolygonMovementBehavior.ts @@ -42,19 +42,18 @@ export abstract class PolygonMovementBehavior 0 ? 5 : 1) - - if (score < bestScore) { - bestScore = score + const dist = distSqrVec2(previewPoints[index], snapResult.position) + if (highestPriority <= snapResult.priority && dist < bestDist) { + highestPriority = snapResult.priority + bestDist = dist resultDelta = subVec2(snapResult.position, originalPoints[index]) } } diff --git a/src/editor/tools/basic/movement/behaviors/RoofMovementBehavior.ts b/src/editor/tools/basic/movement/behaviors/RoofMovementBehavior.ts index 05970bd92..05bed8c07 100644 --- a/src/editor/tools/basic/movement/behaviors/RoofMovementBehavior.ts +++ b/src/editor/tools/basic/movement/behaviors/RoofMovementBehavior.ts @@ -33,7 +33,11 @@ export class RoofMovementBehavior extends PolygonMovementBehavior r.id !== roof.id) const snapCandidates = this.buildSnapCandidates(perimeters, otherRoofs) - const snapService = new SnappingService({ candidates: snapCandidates }) + const snapService = new SnappingService({ + candidates: snapCandidates, + defaultPointDistance: 200, + defaultLineDistance: 100 + }) return { roof, snapService } } @@ -47,9 +51,11 @@ export class RoofMovementBehavior extends PolygonMovementBehavior roof.referencePolygon.points) const roofSegments = otherRoofs.flatMap(roof => createPolygonSegments(roof.referencePolygon.points)) - const allPoints = [...perimeterPoints, ...roofPoints] - for (const point of allPoints) { - candidates.push({ type: 'point', position: point, mode: 'snap' }) + for (const point of perimeterPoints) { + candidates.push({ type: 'point', position: point, mode: 'snap', priority: 2 }) + } + for (const point of roofPoints) { + candidates.push({ type: 'point', position: point, mode: 'snap', priority: 1 }) } const allSegments = [...perimeterSegments, ...roofSegments] diff --git a/src/editor/tools/shared/polygon/BasePolygonTool.ts b/src/editor/tools/shared/polygon/BasePolygonTool.ts index 84c8b0ffe..51bbb9349 100644 --- a/src/editor/tools/shared/polygon/BasePolygonTool.ts +++ b/src/editor/tools/shared/polygon/BasePolygonTool.ts @@ -190,6 +190,7 @@ export abstract class BasePolygonTool exten this.resetDrawingState() this.onPolygonCancelled() deactivateLengthInput() + this.triggerRender() } public complete(): void { @@ -209,6 +210,7 @@ export abstract class BasePolygonTool exten this.resetDrawingState() deactivateLengthInput() + this.triggerRender() } /** @@ -354,7 +356,6 @@ export abstract class BasePolygonTool exten private resetDrawingState(): void { this.state.points = [] - this.state.pointer = ZERO_VEC2 this.state.snapResult = undefined this.state.isCurrentSegmentValid = true this.state.isClosingSegmentValid = true diff --git a/tests/tools/floor-tool.spec.ts-snapshots/05-cancelled-chromium-linux.png b/tests/tools/floor-tool.spec.ts-snapshots/05-cancelled-chromium-linux.png index 91fe930e0cc0443f573646cc52314e447f31196b..8942d347ce3dc81bfbd0b340f86c03286bd7054f 100644 GIT binary patch literal 5335 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 Ke)L08m;MGTgTVU$ diff --git a/tests/tools/floor-tool.spec.ts-snapshots/05-cancelled-firefox-linux.png b/tests/tools/floor-tool.spec.ts-snapshots/05-cancelled-firefox-linux.png index 25fc8c8b88e7de49e5161fbcf01ecffe6a84aa75..864cf43c128798cdec48268d24bbd5285183c7bf 100644 GIT binary patch literal 32064 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 F{{Uzp1_1y7 literal 31990 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 diff --git a/tests/tools/floor-tool.spec.ts-snapshots/05-cancelled-webkit-linux.png b/tests/tools/floor-tool.spec.ts-snapshots/05-cancelled-webkit-linux.png index 42826fbb23c577887fa3bff012db829e76fe6d1d..e1bd4ded397a0b06393773b68eeff747151b8474 100644 GIT binary patch delta 1307 zcmYk5drVUY6vl7Ct{tzA4XwIir(43%I8Zmmp{>|1id#VAE{(h@I^70y6?t4J<9y#aIr)xe z(?6RoMIoo71ySGSlv{LI$ z3Pq#{S{c)K{dO)+wnEh@HHLFMVV5Ke8RgEmx+Q#}P)OujKTn8sZMd;GD~#?e-&r+q zHnqJQqpZ?Q!@)9LMh^}Hhwo04XMmQL7Ol7`!5?ZH$n@}TSEgPrb{NGSk}N9`MUrQg zN<4NcZ0Pt%=vv+6@r?8ZgS32SQF^(&p)wy*K@c$;CIgN5QrH8#Gm)D72BI{Z;C@C< zKTq;#&`MendeU#BZ=HXArtxHC3QgH2$LJlVh)qm)TEqP^ zcwW>kwat5YFpaut$T9Tq<3euaYlTJN}f*xi3pun3c7Fw9R$&rJkr^A+H`O zb=I44&KxgbTIAi zj}6HckmXsHUZ-!313|?nV^2agS)wG0`YxaKG_;$YiH3V~>i08a=z;V2ff9Yn)zr&V zun*-cgHV+}Nc**(9$@pLH)=C<1bWH$d-VSDZHRa~;SJad98L^YEX|npH7P4iLN1x= z?p0J=B})MyU^7gM=t>Y1+ImpTmBi~EI5ME}Oc~9{OkdL!{4J$B z=JGn(ZB*MH%$5r66`OYERHvGT;o!^1|hSh zAznaE#00yLSh((a1-ID;@bP9H-l%r#tXVnhfI{gOn3faE2mRaA8E!ru$fyS8xwk}TLEQ)m{zGZbq$OXf&g@X*O`)Mqes48%x!7^$M1GPqg)2wnuw=MKQ zoco-7$1S^1w>>c8Yv$(`@2Hi@yt zGW!E5_Y2~v7tqRAOpdaE`}2m`*;=*JC{l`Hv);_{IIex^2V$-vp&8_v{D2({YVevD zf5du@NM@_bs9&sazU=r30E#C%W$2m}yEhj+7RU9H+qSOiytCp$oY|f`h$mY~e0V<< zmMgILm1cNi_3N~}K}Xy}FRc)uH-e7Td)_yA`x-}Yab9Q8hHgeHgFti_d*3_ts!;{& sG3p!8j)v2A(1(P0KLagcuVc4*=xcr2WBt;=qm%@Q{yIjW+n5NYPjautW+=#0Fxu)67%_F~UkPY{k}b z25X9ID^yHCN}&M~J849MAu3a?LzOZlAVL!AE0Vx&AS6q6!=}OMhv(~l+s%CTPIxQ3(k1#W;BKWn>_Ybl!d9qW|AC(Mru0EZmcw$Iy z!K^)nqhuWBJXTvr)(x^)lV)Nlmv*G{G6-rCM)>b;7k|c;-P+#~X6hE1Ae;v81Hm?n z&!jYUp>1zqv~4tNW_JJpe6#y+ki=G_I@bghRDR@@t__wuKw4gPn~0UK9<$Hw>JWA zzeL(9J0&mkN&8u)Qi-a~1vkp(ppT~}u!|QGD>6F5jAt8RNJ$PEG)c)i>owCnaQol@ zP763uS}HdcMXf@`9TOD1XQweP(urHnlQuI}{+bM>Q*=bXXw{lot_-xI6!)%+;QO!m zA9zg}F1su_VN=3ZWFQD6k& zW0`}$BPImikV_ffCb+O$M2mmLe{L&LwxKz2$nqtR1cDDPe)X>8A2qWbSk0XA~ zjJ9@a8cQvRg~uhDe1RdPZ;+1K)X&?+)+BR0erOQ@Bs=|H-*%#tlkKTa zp1hUU<7g&6@G^brVcQIuOkW3)$hL(7z_G3Ou(i`OG_!#1%wgw)U`#Iq0LFjuK4u{U zVc)7vf&8EdHJUB8eDU+wwJ$5*@>?V;MKVIcfGTxEsAEx}P;S*?NJ&_XTDbgV0MZbA z5NTK(u3oupaB+xmtcj`jPdLtOMH+)aL_!Zk_Ayd|232Ei*5^pc+jm1opTuQC;hoQN yK@f=!-bhzy8Y>_b1mUwWxGk{@ftN=!FdyKK`3u^Vesm!rK>DsMj&jF=s{a6=xtLD? diff --git a/tests/tools/floor-tool.spec.ts-snapshots/08-completed-chromium-linux.png b/tests/tools/floor-tool.spec.ts-snapshots/08-completed-chromium-linux.png index 86b221b16256952365b906a5859455ee0066c4fa..b1a99d4b4039650494cfc687e42b83f7172a10eb 100644 GIT binary patch literal 17078 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 diff --git a/tests/tools/floor-tool.spec.ts-snapshots/08-completed-firefox-linux.png b/tests/tools/floor-tool.spec.ts-snapshots/08-completed-firefox-linux.png index fc6d5d0c79c62d9898a365e481e7bf5445d11eae..b4461e963681118706ae8e2cf873fc4fe37dd74c 100644 GIT binary patch literal 47440 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 literal 47649 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Ⓢe*>(NsEg4rL>SOx$DG$R6p2%*uI*cX5wjl^M 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 diff --git a/tests/tools/floor-tool.spec.ts-snapshots/08-completed-webkit-linux.png b/tests/tools/floor-tool.spec.ts-snapshots/08-completed-webkit-linux.png index 03551d97725e2bcda4c36a3c05d3025c9ad4f5d5..a2b25e6e39d483faca30d295fdd64e85ae0e2680 100644 GIT binary patch literal 14020 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 literal 14142 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

3I5(r9W8-zF9z1>#ud_-yJ>jDXM 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�BIb}%5*q&zyI5`!xDd001f3NMFjA;L~ACX$1@*(S~xv`&Y;j2 zD*o@>we!3$PFgz?cnw(CUDRRzz07gxqc)f7lHiP|$rS)Sa%UJiu;YaXFi6 z6l$Jtkbd`YrYXv)OQ$UB@0zmu%2eOWc+f7m_|n&RR)2T@V^BdV?!I^{YZvo>8`v7e zv@iVO-7G(;t+X^QpSqG=9SoVwyU?0-dvDAyXE5gC?74rXZ>KQ+`>bg zca_{5XIISt&|;a~R*F@xbWj^v7wE<%nG3;9aQIK0dCZ^kH2usSh3W@@A#cG)zXtQC zs4gD>AAEzmr}VJ#ae7Mww)Fh1oIbT118B&KGBW(trB|_nc3W>2M3svszs#A$S``%m z>811D*V~S*JYja@IAFQ}b#RCaU$b``UaiI9OoZZYcN~+Id{4eMyk{pq!f7YYwy-=O zf5j)5FIpX|;hT12FNh~))^-NhhuS_?f}>bij$QMUR~ZBQ=?Xu6+V8XXx$1@fwUUZ5 zbsi17v#a+?A6WKWAxyS%($2pOTnjkvK}YfVQRhI2P@g%A6<+fFvoH_jkM(7r$Rfv~ zJL^{kAEq1N=Y-3+xcLZ{u8!ngOHR-H*y^wbfwH=?zzS5ZJd4e1fcgWBbv}q1lme8}@h?}EQa`q8e!+Yj zK7nElwK!oQ2UruuA+{*ZP5KvI7;OOp)uji#@<=9?|1rH2>bI%xxjt~q&m^wf*!zGl z0|Rt%z}ncIX-bUqd*$(q8Tbr$p$ z4auN{b8}oyI%-i0I12A6E^Ith6S|-j_RkF<%Ox$6Yem4Bun7W~ei`N+g$ z_Tvr}8_!aW3vHqAp`AN0Sj}=4%3+NHqU?4HtIxPE+qx#7!f}egEhq0c!=j*8-2M65u)RG968D z24!Vg;m`-5{{5Np{5)0Cej_<3emV!Qt94U+MC>SX?#$ zb)HAYUy6@$Ol~fX>B6jGhZRjX8(~{~V_v0X) ze^1)u>mdk~2S)~VoWRE%U>zXK53l!a+-t{Px+=tDB#vrgi*hLY%Gm4o8aNjV^^O5Q OnreFYi|;*t^Zx*pJnNbO diff --git a/tests/tools/move-tool.spec.ts-snapshots/20-floor-opening-dragging-snap-wall-webkit-linux.png b/tests/tools/move-tool.spec.ts-snapshots/20-floor-opening-dragging-snap-wall-webkit-linux.png index e1e55dd302c9c4b9444ca7ef5b6fdbbac951a764..5d40abf57e32f8e31b2ca21e90f3c7b22a09751d 100644 GIT binary patch literal 52420 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 oQ4avDRxKfV# yZ0F7rkld{xsn>R8+D5?MPw=7jAAfP`5B5R8i2wiq diff --git a/tests/tools/perimeter-tool.spec.ts-snapshots/06-cancelled-firefox-linux.png b/tests/tools/perimeter-tool.spec.ts-snapshots/06-cancelled-firefox-linux.png index bd2e479b4b02f0732c538f5ed6532e8537f43aa5..dc5a26014142f6d19f945a67930b81b205b19480 100644 GIT binary patch literal 32093 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 YOmf{1#gdT@=r${sdn`*^vUUIe1N~QP>i_@% diff --git a/tests/tools/perimeter-tool.spec.ts-snapshots/06-cancelled-webkit-linux.png b/tests/tools/perimeter-tool.spec.ts-snapshots/06-cancelled-webkit-linux.png index 24b82321186c1c29473865c334186e1e7006fb44..62ca00ea0a08914d7a5f3c5dbdb87911cd9bae34 100644 GIT binary patch literal 6356 zcmeHLZBSEZ7JkvSxKhCFIzkr;ZD(g(TNNEyzkuKhU8F0ZLZn3?x>gVn5Mu}sk}&HQ z>R?M}Y>VY{)JpV5!tQ8<5E9T-nTn9o7=n=8fDrWNy)`lmKH{WS1PkTk2(O&ynXige2 zw3UpRZ)&;a%SH$h;Rz?jWfci^=0R~)*FL`B0)n*m|6SRBB1iSllM#7qlbvG21oK!xwoQ zOhaoDBldc-l2Zpz#h^k!AODhOAnhkCoq!km{JVO>7>`9;>PuI;Jr<5YEhdRQD^B%!V9GP{BmOX$R*L0VAg|ZUN+Uy(UEP2a{mu8R@1p=i-c#6%&3$1^Q~8uu*iTG zD3M4XI_e`(yFOqBFebi<@z9(t_M+(8D9TE@xe`H=$M8yBSGiVb!`;IW)C(s|Eq&~4 zk#!Ia>elTi5>wZ?=?xJ6cgxsFGNs9H<5?cYwu^4Iw&e-B znAP*!^UK_yusf}`nC2Q6r#zDNMFGFCBe|psQ*4xfYBc)K8a~%fOLOQbmoLGEwW;S2 zgs8^C^>d7(xyj5|-MT(BC|b7u4X?gl>@>He>O76=LRFaq#4cIOSiWlf5lg@gUAv)g zk|0`I`|tFKn#%${&L~U%_W^EvB~^z#5b9GsAjBZUz{ETRm+ku@z@AapQxY>bFFG5L zHS}fDc*gIghOmN=>^lB|>Z&9SS}sy#F~zf%x0n?%)iQM)-%p^Yb>|~U3rEQIRzC=_0Cq)FATl^?aSjR-$s^$MG!|3=qFPaELpr>-bDfWb<_mcf#E^9tOCrP8y+ zJ`wg1O@CZ=&X3g4E40F2J;_VrNzlWXBj~wyU#w(sg=5vxDen2>H}!&@jUzXfsTqF+ zc>=ifF8A;1YPCfK>oNl=&=P8Xz|u!aqY@Jn4JqFER=VL`5+{T$l@~1pL!61uwSWe@ znK}YqP|3EMdYK)&(*w>^(QFo)txhzUhIbpx-V^5txML-|w!;5we2oJ@zZ`aIim$X@ zb<=WX7Cg>siioEPEG8$?JyS3M&1)DASyjxjQL5G;Q+C1@Es#+iZ*DVg^8X!ZvW+|{ z2Jt}Cj(}`jCs3n8K{XRMdS_<64f0g5ko+azBl&wfnVkGhJ?@}s#pLm-MuUf8pxvUp z+mLe!QCdvtcE__wV;`9{1IpBDYz0eC`Byr+gafj4IN1Is`NA=WxX zWh#b`0r1(riFyB$oslBKVM9$vOXYYleITebXYg!@;fFIJh8FLxTehpxRaz3;HMgg@ zpRo<(QMqNf1ISu)VOOGoTa(MPy_2R?IW9x?o)b@CEsR^QgRHmQ8-Zt^?GXs0?L|>U zX##o8hy(!7>qPQZ$7 z5VbufOma&Ar(eUCibThT6Okn&<{4IQV9Lrh^d*ophI;q4Rb1T=&i&xhk4o-LByu%! z#UOo#kB*=A(J$=uYby8uD>&@OQY)9f1mZO-RMyJVe2iZ8^}@lSKeGj!}wo%DO<%|xnXnM}M`;o~zu8Jyrk8K!lNTITf^z`oSMB-eTya1z$T2jUHhAN!r zCMO3@%CX!2IcX?n+PxQkQa3jgGqVXu5v{O|o8aWQ{HYzl6DCi!ftV6xgaal_C#jLM mjo;DjM(h+C`!For9JOguWLZ%-OQ%AO8Wo{k-4+ literal 6346 zcmeHLZBSEJ8opF5tQDdDSYYL&vu)SyxD_pEMFWLV(pB zsKQFSb=C5b@UeniB$+NmgpdS0t%e}TI$+PP{SdT*2SIPdSiK3JL~HLSfQtobzrQcU z=y4naH!DfL0U=f(GOfP&5`yeg19tg@q?af}_<{skWXGiBc#JCW-icVaBVdn}(>kkF z`~ZjdZkV%<(km-S=<(#x0+Z-qwxuR&$thIOdrI2|p&$Mo8q~1kS^X;{-zDwiw+RXF;Vu zGc4W@Qy#)N`cZrhf(TsZeT3jvU=j*JqQ9mRte|zp(YZGu$hLQO6$HIQn|up`c6yE4 zK~PS5hbwqLSr83=?>T@xKg4)Mkq_c@G*LhP;G&#o4cvDfJ$n zvnpzjbKCYn!O%?(yPr*&zaK;+$dt;U>uPNXnA&1se@=sGf2XESFtTM!l>KF7c}XOG z*f>1R$=kGvxx?#*NHmyxu$gq#p&CJUc-$#<>@PuoSeNnI=r26K0N)QkF0MamBauyP zDHsi@vlg*EIc#ANH-$<2T4w@T`HcKAW|!=MWv7Co8inn1n_K{}bE-#9x)}RN75KQF z%o^+*2@f@LXVmA%izV7LL9n_@l77*9@>V_xzYcWQno;;DF$<-r$Px{E@{3a1}~gE1zvyT zz;n|K38QP!EUZsVVsf?N)KMuhE&bPE&4=1txKnohT)#pWiT`0F%$I450S)Vxbkeo zAzRmw@GAj_9Z0;IHLl=9X8~*=$WTI$SZ%32T%^q_-M zsX^aPLMf6Up`Br>SAMaO`Y4ie?mzs6o2H!~eVMMgV>v_9HPPo)>>Zh)khE1DXn|f& zG&Fmw^mbVj%v6xeRU=|B6;;(xjB2UC1+7irz;~DFOypRg=u=A<*te_u!Ni?5 z%!9IKbi3g5zGED3#gJ%>DjDAvLJ%M%xEiC9O@czEYRnMs7qi4=;G|12lZlq+ch#;f zOo8FP@O}LQ9M8Ps$r+m2*5TN9v8*V1nnL-Ai;~wP$T2~a5hl$h!D;6Hw7tV)Rcn!^ zQMlO6MH|JAt*eTYi%Z+O(DFYKl$cCOeOzO)sj9O+Z3t%uvD4rT3D#^4 zxIjJvVDd`(sT4L&LV0*^3Tdkx5k)qH_PdkOp1Vg|II>O)^!|khnLLvcH{%v-Ol-Pc z;*>e-9aDJNu{!*B;dR35NFeJdMU;ym;bbL%$up7xIJDig6;Pi-$>9sSUx4R(RAqc4 z9XX!-?-tnfbF)12bAHe`lGM9kGUck@!tn6TV?|X;k;h0uBj#nE(b?M{Qpb9QA%d0h zQTH`ltx0&mZ%`215|@=1ha4`_eE@FLhn^OjZ4E_L+_%yhI`NVT!hEugOqp}!)1+-N zmM+}h+ea=`)r7}C;S~0~T}fD}5kn?2RXbd}s8h>eyJvS!&xwL%Y=t6{8f6rl$U{-& z^s{VsV(hZjw<-=9e6B8fk!`Sy1em6n*d(H%xYXq1g1diIDzB6a-1mvtV4pxlkEgVH zdQ^Exp5lf=U?%~p+9Q^N#!psmoFC1WRyEPVO5y$Mh<+# zWHtZ0ldh)?NS$?ks_aj0fdm5CJjDgl6286}hBII$JyRNmd-rF+qW_Q6Nxh9d?q6k= ThR+zF#!$fSz+DXA&rbXscB-Ud diff --git a/tests/tools/perimeter-tool.spec.ts-snapshots/10-completed-chromium-linux.png b/tests/tools/perimeter-tool.spec.ts-snapshots/10-completed-chromium-linux.png index 10b7986416b740182ae1a44bb7302f1fa444bb28..9f8015b6394cb0b1aa382d2dee439e4b3e1a0198 100644 GIT binary patch literal 8981 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 literal 9159 zcmeHNd03NImwy2T5iqS<1;OBgLM0Uxki`U~3MgPG2(lJXkyRlQ)&vYnmAW9xDmwy- zhE)+GOISov1cDGrWJy>;KnP(8gaCnLUbMEhW9OT1rr-3LdFHP?&wbx}-+NB(Ip_EL zoqOd6q>0SxO{)O_kU4VrkQD%k)c}B~$0|{9M1q_qF|;j7zLc89JKmp-J1e5k0ggg3FJrDagAnD4rwBo`N_j0$eYgJU*@b?;TuvwM4+lTMf4a%Yp$Te4MN`y*N$!ujyIB;hWX zS;4%)(bD6+`eunR`$uk#u{+E%&ibp00KlFu4FEXg)e4$(7a;~5h@X`P0LwoNTC)c0 zvwNwOStQ;Bx%=w2ySqLtod%sa%bTeWJW&Q6wuUS(y1e=z(|jLqd67bNO?Jr*Ka&Ey zHCa0_^FA}RLO%2noa=p9-_rEgeN=NAhcZNfEv8s;AVP&L2LRcRJpkb6_P=Y;VM#FL zML2tQA{R3_n5|-kZ!hQ|wAPPI(Ss}uoHib3okMbOe)6I-$ql{iyN+E=(WL~P;7FKJdAO9`9O`^Pq-??$|jX&6Y+lokHV(=!L z`Ebv{M$X&(t@DSz{WJkK|5`cq@AzwnZ&ICwqg25R>%n2Qs;QxCihNH|!1Y_N)?pnT z_#&%jk|5~SuGZC47<8$`@snzV{F;ei9AEo5hPVQ#jrCGO5(os|F*V_$P+;KF+iM_e zkvHE<7YH&1#X|lu9DA4tS+f=Z%#5-6O=^n+YT+!$-p~nd?;?dD98wclfnRI%Y|X>4 zx!N^LY=T84SH5?SBJZ-~06s}xFC1$TqPouV`7f`nqfm~M3uqR*!Qb|R7F4EiF1UBG zy#{xBuO6n?F*TiHA>=d}aJ1bg*E~y!Dg6+@A7U+e2bS7*$>xCyxRIt@*|fcc6fQq? z{t-;Oy?|C;YQNsdz9`Q=wO_wRkf1Fj_3|aUyMudqlvHiM#Rl9$S>uT z_B>3LNKdMnNrt}+;|G;fyqUOG+V1JJ8R6Wsy#47x%l-(V_UU4X<9VQ;>2ssh>IFZ+ z*<0-RxELUy`!Oq$br zBU77{vW3}D9R$65d?bqX%o{;^0R1J~xivN=pBZ1yS`Men&&_Ta_C<-eKY^}X0D;ks7sE@y*Mw?2#LoY zt|vfyTXAy-=c5!CFANhV=tqIc=V3$QCAW=prY4pIiqa;GRw5^Ao=U@Fi^G{dnR9a{ zYXP_jTh7kB63cI;Rdd^~slls;G&1=ESZ)5;aLvr?je_@WKF)%-7K=Ttivgk$yc~p> z89UBE@Gxd^5`w_Aa9KW*01`CNjNR4cAV>nX7-PjZ9BT74$B*_G%d@n5Ls%Bb4T#JT z!9m5tFg(I) zR9aqn!s!OGJt0GwkA@ls>`(XbqlfX+3#VYK1J;z2d%NsvI!7L_1K@8)dD<40XMM2B z9ov;R8r`x=a4)P5hazKmPp}L96(tTaXnd4gqwT(Od(G-8_I}lJ(_lTLoWn82DM*u? zseaTuR#0Bt5uxNbR{&Wqz?^{{jy!2td`mklM{TXB5k!XFQ_+8BM;YUsu-2108C3wmYF-dfdzl@;ne-^(y>e0boLu)^cz?+7Sm&2&X5Toyg4Yyk)<~hqWDw}=+ zJfO?np0U|re4dwkh&oY|$JPJQeV@;W6tyW2O{Y)|s>oE$vOoK4xWWK>iz z)i&dqqF0#R`80PeLcwBsl{tV$VlU^xY=={+4&~3CzK_zEMOIId-Vc=xEpU-1%g?fJ z(95;U!%K->XYZvj>1mly*khZSU*86X(&m*z7Agu)PN4R_*0@lD;?$DrE2&Q$R@r?u zl+i!JdMr=%ZR zBqh*l5XRIk-YQY4!`RFDJE$@~S;@i5KIsz>j|ih2M14oQmlj8rRvv1dFjVLC`d}~* zb+S;S8TP2lW(Bsud+? z^p9>*JUX%2Tzvb=6y2K=-q4~`t-J`MJaGLEU#+5IO`iBBN!uTx*`Zm^1d5!UCeBVKe1^@6z-bE@OO%EXHro1Fq7@{Iwb$8?_hL)Vyf$l0w$2B|CFvneCu++;Lu zO`$hK?U3OW+e*wId)$UZXFsLvcNQx=fHoSub6dga?ic&MuASIA@b%{rFdCQp=5D|JwGc!_w#x`UR~w@XgAZtTZ6e=JOI1 zCUpGSLueca)}Rn!IFm+(P%m|0(vBgNT9ra^!Yw~qpDz#Np{#7Y?y_{%lZH_`2w7xN z@jkK<^F(hZ_sUzjBOvBaNiI+?~ezeQ9luc`>cz%ST zcoas=RsloEwlc{&lP6I>^*nZ~>-awj&N}`x6>KnBX<|ZA$!>4xJDx}Qwm{=puu`4g z`GG{-KW^`vMto5&?{vWCE}Z9-$a(+`9GlL%rQCGv^OIWQ_Pg`>k&pg?)c!@t5w!vf ziUc3EfPc)S|M=1WJsy#|i^td3q10uqJ1YHNQ-_;c3QMc0)hbrSRL2qS)omhZ_VrT3 z=Z&3x0&T|52otYX*p2i`-af*3(0+DMrA^iF*Tq!UP*bCuzw!6ypCWYL$=zrGA(SsY zWT(jV$k>OuuIdWRn=2+)v6EL$1>sn(IAJ6~n-h5&vo~j|EnVPVT@~(w5Sey=;t@%h zs6r2?f9E0AcgOhRe6WsePbGoh!*;X~xF-LKPftQzZ!|BSnY=J;3cd)+8rNLEfK;2m z@es@MU4E#p5TrMe;n8KF6UOBdss$dn^RqZ?h|U~{7vt4>1H&uHP-zLt+o}`TeXoKi zZC$YBPT;mAJK5-mqj}LYk9Iio1=Y5ed95+v?dXF*qd=vj*_{x}durR}zGv^=ibqXZ1e%)8%SAUFUVc zwevpL8m?t|YVNe*PEw<}seV;lW#uLX3%KwOB=@CeA>NqW?R|m-&Iu%E*smb^25TyS z5V*B^v9S+VDHw|eX`i4GTdep2$f93#0{2su$*_y^+C^+d=#2&wFJoiIl%TG!L4&$Z z-4@(^1$YE5`sPiKwWRvhlxk(XIOx8NXJ4z$1?jO@G$$ly%FwGzN6rB7*RN$Z913N1 zW~)@srwhD`yS)jV){!Y2n?Ox{Jy~f=CNWu2ZJm0Qd&s_5yR-Hi7mtVmwH7xD)+?OiZ_jTcU-1Ot_Cr^T{ z5dNH{#)vN{SuvsJ`fzwNh=S4d1&tJQjg^l##xbGomJ0N^Pfkm!Xx;Ce?Z=6W8 zh75Z&#tv(L#D9N+lA{?3=#LaINo~I8v{FgP|5$eVznqc&dD}pwK(@B=3e%)@ZN3Xu z#lado;uJ+uInfy^PWHwG3q93Sz_J&GfldO`%kcSuN_(3o5j_z*YF5NDG8K;7;)s@- zYn#3^h}mVupx0?ao|&POaZ*_zrh}hOKYotyzQ04Qb&(B4@+9Kq^9$vD#`#UYJk+Do z;KvjnXBV$0j!A9a6Jyt-PQ|s>_Dru@ZDC((SrC-^;5=_<2UzFpKuO4@-DPl>{Pmqi zmQIddgH26xkd_8Z(c3s@;-%A5&EEMm|D<7TfJ3y$aYTY>h&0snM4gN=x$$iiGi^I0 z7H+*37;eiraZBbUnOHaInT;>-io`%fb33L3bO-+945ryg;V8aR z0^Ib28(IdT@2COV0Uv+?QwfW@bje?+YPpk&Iogpd<)d{C{uAfGUNMkF8M z2~mk^R$E0t5ksA4Y(ieDb1Unv5*prAHw*oNc_%^Q-K$NW5qyxKl-uSGq!b^zvyX_} zZlE^ZEd3Gc(ssrA9!!U8i-o+B{CcPZ6;|cCy>5q|tU0IF3@|eX=f%r6)BAHz+|fwN zSGfwGG+wP)^1PrYqCCF4w2b6boiILi>`7fn*Phvk)8N5%0-<7hQeF<#YY->tYOgEy z4BmK*6n6y@IAnU;Ts2cn2DX(gH)LEys*H+zINRKzu8*Isy7HBszVqUxdG*2@sjthN z$=8OQzE(bX4yW!#SncP!GBIjadU`7%&V%X@efh?-Z(^kaOajFduOERu9p$hX$_DpI zPKJ;u<~!R^u_h3Ds-D%!pd)nJ&7KZ$&{dTZlmlXB;BdYV{6P>znTm-HYWgujwUD-p zQqjT)F2mfDfa$S7UgoWR4Ge}Jv$am4sx;%$WH?!DLk3yF$%s4>Ox4RLHQypdjxM4A zF&LCBSCrNtQc;Stwpoxse+!|(BML8wZ%CMxwrzUvSrM#L{-hliwpw-?eB?uPhc;~Y z$+}?VM$>Tm&S;l^;J6+9H5_E9J~o_1h~42XaG1AI&u4Qp!sV(YY?p`j)3uiwBjf#+ z7cZ4!-=niGT*?NMLhvI>IW-99CKBj)N$XARwuk;=8P#o1E1tB^7?#6wk(KAzf_zm; z*R78~7t47D7Ou03uWWktArrVTuYVUDJjWNa^c{trw|f*D_G9Cg>eL_hAgk9;l1NWM zp57m9l{H;5(YkPG^dpU0Zkt%x`#zSaKT>fhCr2~GY1CzKH690RI8%5go?rq~^7R(oqt|7|^&-=vxkZJ_(dYidHf&N_DQe*o1j zSGgLG*Yx4Cr%{+H!e`|7lVqKw@gG-TxP8Vak?NN&jLZDTuFao!yguRd5>Gr{PeEED z((oI;KDp{#bx+U2I1l-a(I^)Ks$^5z*`l?)_PAzvv zjNQ33FUaLjlI;C^WMd4kdj)QqpuYsFqMGt8YBdY)n6T*V+sJ#MAp8cjCs0RLU)T>} zDxRN|$mp%cYKzpan@*OmJ-I2mp@zrDsVhVq@LnqpxdczJiL(jC;R6iCBf#VeQ2E@$ z9%mZ_^C@O()svGctzqNWCTL@&nZovE7;I7;I)BVy3T-e=mIUZApebb*g1MLaoO%OL zE2S0}?*MczEsy&6_{_VAM#zE+?pH=RdmiAUYJ3ZN!$9JyYd;e_hB5^*amfZ8mNMJG|LB4iC#Sh`Q{)Ki8*@YeG$UZL6 zMO5SQAl#LRtXnA6Mq|PER)Kp|A?)*YuBq0Oy?*0Hw#J)JP_$o*nBW}hLnO16fH`MS z?)I!m@4+hO%psNI0)!fRYk@SAxQNV5aUcjde@`d$3=OF%;>%X<}qKtMA_s@j*Rpu!cN#%2O z@-T-#HOSRGJ~oTI6POL1j%k&3E3Iu_qgzjxXN+3D)Q4>P|Bm|qchvvB%Mu?7_wN}) z_TdMn%U4Yxc`vcuh$YmIc7-o>I}U!lb&?1R)%|$?!TQTA;LWQz=7SG3;L`+qPJ+cB t1w5`0yt*P3-aDW&1zyJyUye610KHzI;&W6g6dVDL7$FZ8eeZhl-vG=A+&usQ diff --git a/tests/tools/perimeter-tool.spec.ts-snapshots/10-completed-firefox-linux.png b/tests/tools/perimeter-tool.spec.ts-snapshots/10-completed-firefox-linux.png index f5a61ca803ed60b8c6c4355666b04deee6a13332..aa3b3a6b139fda8fc84ac480903e50aeec9967d1 100644 GIT binary patch literal 38102 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 aue>UBVSIdleH{3o+18y~GBzDN^M3#|G^~68 literal 38352 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 From 2d7c8d37864cbd81c726ae4142f51c81200850a3 Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Tue, 31 Mar 2026 09:30:20 +0200 Subject: [PATCH 17/21] Allow picking alignment side and fix inspector --- src/@types/resources.d.ts | 22 +++++++ .../add/IntermediateWallTool.ts | 17 ++++-- .../add/IntermediateWallToolInspector.tsx | 61 +++++++++++++------ .../add/IntermediateWallToolOverlay.tsx | 6 +- .../polygon/PolygonToolOverlay.test.tsx | 3 + src/shared/i18n/locales/de/tool.json | 22 +++++++ src/shared/i18n/locales/en/tool.json | 24 +++++++- src/shared/i18n/locales/fr/tool.json | 22 +++++++ 8 files changed, 151 insertions(+), 26 deletions(-) diff --git a/src/@types/resources.d.ts b/src/@types/resources.d.ts index 9b6c76c1a..26751bcec 100644 --- a/src/@types/resources.d.ts +++ b/src/@types/resources.d.ts @@ -1709,6 +1709,28 @@ interface Resources { "description": "Draw an opening within an existing floor area. Use snapping to align with floor edges or other openings.", "title": "Floor Opening" }, + "intermediateWall": { + "cancelTooltip": "Cancel wall creation (Escape)", + "cancelWall": "✕ Cancel Wall", + "clearLengthOverride": "Clear length override (Escape)", + "completeTooltip": "Complete wall (Enter)", + "completeWall": "✓ Complete Wall", + "controlClickFirst": "Click an existing wall or node to finish", + "controlEnter": "{{key}} to complete wall", + "controlEscAbort": "{{key}} to abort wall", + "controlEscOverride": "{{key}} to clear override", + "controlNumbers": "Type numbers to set exact wall length", + "controlPlace": "Click to place wall points", + "controlSnap": "Points snap to grid and existing geometry", + "controlsHeading": "Controls:", + "infoLeft": "Draw the left edge of your intermediate wall. Click to place points, and finish by clicking an existing wall or node or pressing Enter.", + "infoRight": "Draw the right edge of your intermediate wall. Click to place points, and finish by clicking an existing wall or node or pressing Enter.", + "lengthOverride": "Length Override", + "referenceSide": "Alignment Side", + "referenceSideLeft": "Left", + "referenceSideRight": "Right", + "wallThickness": "Wall Thickness" + }, "keyboard": { "enter": "Enter", "esc": "Esc" diff --git a/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts index 16f5f9e7d..86bd30340 100644 --- a/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts +++ b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts @@ -37,6 +37,8 @@ import { IntermediateWallToolOverlay } from './IntermediateWallToolOverlay' type SnapEntityId = WallId | WallNodeId +export type IntermediateWallAlignment = 'left' | 'right' + interface IntermediateWallToolState { points: Vec2[] pointer: Vec2 @@ -47,6 +49,7 @@ interface IntermediateWallToolState { lengthOverride: Length | null segmentLengthOverrides: (Length | null)[] thickness: Length + alignment: IntermediateWallAlignment } const SNAP_NODE_TOLERANCE = 200 @@ -73,7 +76,8 @@ export class IntermediateWallTool extends BaseTool implements ToolImplementation isValid: true, lengthOverride: null, segmentLengthOverrides: [] as (Length | null)[], - thickness: 120 + thickness: 120, + alignment: 'left' } } @@ -82,6 +86,11 @@ export class IntermediateWallTool extends BaseTool implements ToolImplementation this.triggerRender() } + public setAlignment(alignment: IntermediateWallAlignment): void { + this.state.alignment = alignment + this.triggerRender() + } + handlePointerDown(event: EditorEvent): boolean { this.state.pointer = event.worldCoordinates this.state.snapResult = this.findSnap(event.worldCoordinates) @@ -295,8 +304,8 @@ export class IntermediateWallTool extends BaseTool implements ToolImplementation modelActions.addIntermediateWall( perimeterId, - { nodeId: startNode.id, axis: 'center' }, - { nodeId: endNode.id, axis: 'center' }, + { nodeId: startNode.id, axis: this.state.alignment }, + { nodeId: endNode.id, axis: this.state.alignment }, this.state.thickness ) } @@ -445,14 +454,12 @@ export class IntermediateWallTool extends BaseTool implements ToolImplementation private resetDrawingState(): void { this.state.points = [] - this.state.pointer = ZERO_VEC2 this.state.snapResult = undefined this.state.startEntity = undefined this.state.perimeterId = undefined this.state.isValid = true this.state.lengthOverride = null this.state.segmentLengthOverrides = [] - this.state.thickness = 120 this.resetContext() this.setupContext() } diff --git a/src/editor/tools/intermediate-wall/add/IntermediateWallToolInspector.tsx b/src/editor/tools/intermediate-wall/add/IntermediateWallToolInspector.tsx index d64257329..475ae9f60 100644 --- a/src/editor/tools/intermediate-wall/add/IntermediateWallToolInspector.tsx +++ b/src/editor/tools/intermediate-wall/add/IntermediateWallToolInspector.tsx @@ -12,8 +12,9 @@ import { Button } from '@/shared/ui/components/button' import { Callout, CalloutIcon, CalloutText } from '@/shared/ui/components/callout' import { Kbd } from '@/shared/ui/components/kbd' import { Separator } from '@/shared/ui/components/separator' +import { ToggleGroup, ToggleGroupItem } from '@/shared/ui/components/toggle-group' -import type { IntermediateWallTool } from './IntermediateWallTool' +import type { IntermediateWallAlignment, IntermediateWallTool } from './IntermediateWallTool' export function IntermediateWallToolInspector({ tool }: ToolInspectorProps): React.JSX.Element { const { t } = useTranslation('tool') @@ -40,7 +41,9 @@ export function IntermediateWallToolInspector({ tool }: ToolInspectorProps - TODO + + {state.alignment === 'left' ? t($ => $.intermediateWall.infoLeft) : t($ => $.intermediateWall.infoRight)} + @@ -49,7 +52,9 @@ export function IntermediateWallToolInspector({ tool }: ToolInspectorProps - {t($ => $.perimeter.wallThickness)} + + {t($ => $.intermediateWall.wallThickness)} +
@@ -68,6 +73,28 @@ export function IntermediateWallToolInspector({ tool }: ToolInspectorProps
+ +
+ + + {t($ => $.intermediateWall.referenceSide)} + + +
+ { + if (value) { + tool.setAlignment(value as IntermediateWallAlignment) + } + }} + > + {t($ => $.intermediateWall.referenceSideLeft)} + {t($ => $.intermediateWall.referenceSideRight)} +
{/* Length Override Display */} @@ -75,7 +102,7 @@ export function IntermediateWallToolInspector({ tool }: ToolInspectorProps
- {t($ => $.perimeter.lengthOverride)} + {t($ => $.intermediateWall.lengthOverride)}
{formatLength(state.lengthOverride)} @@ -97,14 +124,14 @@ export function IntermediateWallToolInspector({ tool }: ToolInspectorProps
- {t($ => $.perimeter.controlsHeading)} - • {t($ => $.perimeter.controlPlace)} - • {t($ => $.perimeter.controlSnap)} - • {t($ => $.perimeter.controlNumbers)} + {t($ => $.intermediateWall.controlsHeading)} + • {t($ => $.intermediateWall.controlPlace)} + • {t($ => $.intermediateWall.controlSnap)} + • {t($ => $.intermediateWall.controlNumbers)} {state.lengthOverride ? ( {t($ => $.keyboard.esc)}{' '} - {t($ => $.perimeter.controlEscOverride, { + {t($ => $.intermediateWall.controlEscOverride, { key: '' }) .replace('{{key}}', '') @@ -113,7 +140,7 @@ export function IntermediateWallToolInspector({ tool }: ToolInspectorProps{t($ => $.keyboard.esc)}{' '} - {t($ => $.perimeter.controlEscAbort, { + {t($ => $.intermediateWall.controlEscAbort, { key: '' }) .replace('{{key}}', '') @@ -124,13 +151,13 @@ export function IntermediateWallToolInspector({ tool }: ToolInspectorProps {t($ => $.keyboard.enter)}{' '} - {t($ => $.perimeter.controlEnter, { + {t($ => $.intermediateWall.controlEnter, { key: '' }) .replace('{{key}}', '') .trim()} - • {t($ => $.perimeter.controlClickFirst)} + • {t($ => $.intermediateWall.controlClickFirst)} )}
@@ -148,9 +175,9 @@ export function IntermediateWallToolInspector({ tool }: ToolInspectorProps $.perimeter.completeTooltip)} + title={t($ => $.intermediateWall.completeTooltip)} > - {t($ => $.perimeter.completePerimeter)} + {t($ => $.intermediateWall.completeWall)} {t($ => $.keyboard.enter)} @@ -163,9 +190,9 @@ export function IntermediateWallToolInspector({ tool }: ToolInspectorProps { tool.cancel() }} - title={t($ => $.perimeter.cancelTooltip)} + title={t($ => $.intermediateWall.cancelTooltip)} > - {t($ => $.perimeter.cancelPerimeter)} + {t($ => $.intermediateWall.cancelWall)} {t($ => $.keyboard.esc)} diff --git a/src/editor/tools/intermediate-wall/add/IntermediateWallToolOverlay.tsx b/src/editor/tools/intermediate-wall/add/IntermediateWallToolOverlay.tsx index 183e06666..ccab04151 100644 --- a/src/editor/tools/intermediate-wall/add/IntermediateWallToolOverlay.tsx +++ b/src/editor/tools/intermediate-wall/add/IntermediateWallToolOverlay.tsx @@ -4,7 +4,7 @@ import { SnappingLines } from '@/editor/canvas/components/SnappingLines' import { useZoom } from '@/editor/canvas/state/viewportStore' import { useReactiveTool } from '@/editor/tools/system/hooks/useReactiveTool' import type { ToolOverlayComponentProps } from '@/editor/tools/system/types' -import { type Vec2, addVec2, direction, perpendicularCCW, scaleVec2 } from '@/shared/geometry' +import { type Vec2, addVec2, direction, perpendicularCW, scaleVec2 } from '@/shared/geometry' import type { IntermediateWallTool } from './IntermediateWallTool' @@ -42,7 +42,7 @@ function computeDerivedSegments( const end = inputPoints[(i + 1) % inputPoints.length] const segDirection = direction(start, end) - const outward = perpendicularCCW(segDirection) + const outward = perpendicularCW(segDirection) const offset = scaleVec2(outward, thickness * multiplier) const offsetStart = addVec2(start, offset) @@ -79,7 +79,7 @@ export function IntermediateWallToolOverlay({ return null } - return computeDerivedSegments(workingPoints, 'left', state.thickness) + return computeDerivedSegments(workingPoints, state.alignment, state.thickness) }, [workingPoints, state.thickness, tool]) return ( diff --git a/src/editor/tools/shared/polygon/PolygonToolOverlay.test.tsx b/src/editor/tools/shared/polygon/PolygonToolOverlay.test.tsx index 62872e216..629f84a10 100644 --- a/src/editor/tools/shared/polygon/PolygonToolOverlay.test.tsx +++ b/src/editor/tools/shared/polygon/PolygonToolOverlay.test.tsx @@ -155,6 +155,7 @@ describe('PolygonToolOverlay', () => { describe('snapping behavior', () => { it('renders snap position when snap result exists', () => { const snapResult: SnapResult = { + priority: 0, position: newVec2(125, 125), distance: 10, type: 'snap' @@ -171,6 +172,7 @@ describe('PolygonToolOverlay', () => { it('renders snap lines when snap result has lines', () => { const snapResult: SnapResult = { + priority: 0, position: newVec2(150, 150), distance: 10, type: 'align', @@ -225,6 +227,7 @@ describe('PolygonToolOverlay', () => { mockUseZoom.mockReturnValue(2.0) const snapResult: SnapResult = { + priority: 0, position: newVec2(100, 100), distance: 10, type: 'align', diff --git a/src/shared/i18n/locales/de/tool.json b/src/shared/i18n/locales/de/tool.json index 68c4b5324..276e8f5a1 100644 --- a/src/shared/i18n/locales/de/tool.json +++ b/src/shared/i18n/locales/de/tool.json @@ -68,6 +68,28 @@ "description": "Zeichne eine Ausparung in eine vorhandenen Bodenfläche. Die Positionen richten sich automatisch an Bodenkanten oder anderen Öffnungen aus.", "title": "Aussparung" }, + "intermediateWall": { + "cancelTooltip": "Wanderstellung abbrechen (Escape)", + "cancelWall": "✕ Wand abbrechen", + "clearLengthOverride": "Längenüberschreibung löschen (Escape)", + "completeTooltip": "Wand abschließen (Eingabe)", + "completeWall": "✓ Wand abschließen", + "controlClickFirst": "Klicke auf eine bestehende Wand oder einen Knoten zum Abschließen", + "controlEnter": "{{key}} zum Abschließen der Wand", + "controlEscAbort": "{{key}} zum Abbrechen der Wand", + "controlEscOverride": "{{key}} zum Löschen der Überschreibung", + "controlNumbers": "Zahlen eingeben, um genaue Wandlänge festzulegen", + "controlPlace": "Klicke, um Wandpunkte zu platzieren", + "controlsHeading": "Steuerung:", + "controlSnap": "Punkte rasten am Raster und an vorhandener Geometrie ein", + "infoLeft": "Zeichne die linke Kante deiner Zwischenwand. Klicke, um Punkte zu platzieren, und schließe ab, indem du auf eine bestehende Wand oder einen Knoten klickst oder Eingabe drückst.", + "infoRight": "Zeichne die rechte Kante deiner Zwischenwand. Klicke, um Punkte zu platzieren, und schließe ab, indem du auf eine bestehende Wand oder einen Knoten klickst oder Eingabe drückst.", + "lengthOverride": "Längenüberschreibung", + "referenceSide": "Ausrichtungsseite", + "referenceSideLeft": "Links", + "referenceSideRight": "Rechts", + "wallThickness": "Wandstärke" + }, "keyboard": { "enter": "Eingabe", "esc": "Esc" diff --git a/src/shared/i18n/locales/en/tool.json b/src/shared/i18n/locales/en/tool.json index fb873689a..d4715e7ec 100644 --- a/src/shared/i18n/locales/en/tool.json +++ b/src/shared/i18n/locales/en/tool.json @@ -68,6 +68,28 @@ "description": "Draw an opening within an existing floor area. Use snapping to align with floor edges or other openings.", "title": "Floor Opening" }, + "intermediateWall": { + "cancelTooltip": "Cancel wall creation (Escape)", + "cancelWall": "✕ Cancel Wall", + "clearLengthOverride": "Clear length override (Escape)", + "completeTooltip": "Complete wall (Enter)", + "completeWall": "✓ Complete Wall", + "controlClickFirst": "Click an existing wall or node to finish", + "controlEnter": "{{key}} to complete wall", + "controlEscAbort": "{{key}} to abort wall", + "controlEscOverride": "{{key}} to clear override", + "controlNumbers": "Type numbers to set exact wall length", + "controlPlace": "Click to place wall points", + "controlsHeading": "Controls:", + "controlSnap": "Points snap to grid and existing geometry", + "infoLeft": "Draw the left edge of your intermediate wall. Click to place points, and finish by clicking an existing wall or node or pressing Enter.", + "infoRight": "Draw the right edge of your intermediate wall. Click to place points, and finish by clicking an existing wall or node or pressing Enter.", + "lengthOverride": "Length Override", + "referenceSide": "Alignment Side", + "referenceSideLeft": "Left", + "referenceSideRight": "Right", + "wallThickness": "Wall Thickness" + }, "keyboard": { "enter": "Enter", "esc": "Esc" @@ -227,4 +249,4 @@ "resetAll": "🗑️ Reset All Data", "resetWarning": "⚠️ This will permanently clear all perimeters, openings, and saved work" } -} \ No newline at end of file +} diff --git a/src/shared/i18n/locales/fr/tool.json b/src/shared/i18n/locales/fr/tool.json index dfe813813..903000676 100644 --- a/src/shared/i18n/locales/fr/tool.json +++ b/src/shared/i18n/locales/fr/tool.json @@ -68,6 +68,28 @@ "description": "Dessinez une ouverture dans une surface de plancher existante. Utilisez l'accrochage pour vous aligner sur les bords du plancher ou d'autres ouvertures.", "title": "Ouverture de plancher" }, + "intermediateWall": { + "cancelTooltip": "Annuler la création du mur (Échap)", + "cancelWall": "✕ Annuler le mur", + "clearLengthOverride": "Effacer la longueur saisie (Échap)", + "completeTooltip": "Terminer le mur (Entrée)", + "completeWall": "✓ Terminer le mur", + "controlClickFirst": "Cliquez sur un mur ou un nœud existant pour terminer", + "controlEnter": "{{key}} pour terminer le mur", + "controlEscAbort": "{{key}} pour annuler le mur", + "controlEscOverride": "{{key}} pour effacer la saisie", + "controlNumbers": "Tapez des nombres pour définir la longueur exacte du mur", + "controlPlace": "Cliquez pour placer les points du mur", + "controlsHeading": "Commandes :", + "controlSnap": "Les points s'alignent sur la grille et la géométrie existante", + "infoLeft": "Dessinez le bord gauche de votre mur intermédiaire. Cliquez pour placer des points et terminez en cliquant sur un mur ou un nœud existant ou en appuyant sur Entrée.", + "infoRight": "Dessinez le bord droit de votre mur intermédiaire. Cliquez pour placer des points et terminez en cliquant sur un mur ou un nœud existant ou en appuyant sur Entrée.", + "lengthOverride": "Longueur saisie", + "referenceSide": "Côté d'alignement", + "referenceSideLeft": "Gauche", + "referenceSideRight": "Droite", + "wallThickness": "Épaisseur du mur" + }, "keyboard": { "enter": "Entrée", "esc": "Échap" From 3a80b27438ad4b8e9b018f461e03decfd6f07e66 Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Tue, 31 Mar 2026 10:07:20 +0200 Subject: [PATCH 18/21] Fix some issues with geometry of T-intersections --- .../store/slices/intermediateWallGeometry.ts | 29 ++-- .../slices/intermediateWallsSlice.test.ts | 135 +++++++++--------- .../store/slices/intermediateWallsSlice.ts | 11 +- .../add/IntermediateWallTool.ts | 9 +- 4 files changed, 99 insertions(+), 85 deletions(-) diff --git a/src/building/store/slices/intermediateWallGeometry.ts b/src/building/store/slices/intermediateWallGeometry.ts index 9615739f8..8d87891ee 100644 --- a/src/building/store/slices/intermediateWallGeometry.ts +++ b/src/building/store/slices/intermediateWallGeometry.ts @@ -29,6 +29,7 @@ import { normVec2, perpendicularCCW, perpendicularCW, + projectPointOntoLine, projectVec2, scaleAddVec2, scaleVec2, @@ -57,9 +58,13 @@ export function updateAllWallNodeGeometry( const connectedWallLines = node.connectedWallIds .map(wallId => { const lines = wallLines.get(wallId) - return lines ? { wallId, ...lines } : null + const wall = state.intermediateWalls[wallId] + if (!lines) return null + const left = wall.start.nodeId === node.id ? lines.left : lines.right + const right = wall.start.nodeId === node.id ? lines.right : lines.left + return { wallId, left, right } }) - .filter((item): item is { wallId: IntermediateWallId; left: Line2D; right: Line2D } => item !== null) + .filter(item => item != null) let points: Vec2[] if (connectedWallLines.length === 0) { @@ -148,8 +153,8 @@ function updateComplexCorner( throw new Error(`Node position not found for node ${node.id}`) } - aPos = scaleAddVec2(a.point, a.direction, projectVec2(a.point, nodePos, a.direction)) - bPos = scaleAddVec2(b.point, b.direction, projectVec2(b.point, nodePos, b.direction)) + aPos = projectPointOntoLine(nodePos, a) + bPos = projectPointOntoLine(nodePos, b) } else { const intersection = lineIntersection(a, b) if (!intersection) { @@ -171,7 +176,7 @@ function updateComplexCorner( if (wallA.start.nodeId === node.id) { geometryA.rightLine.start = aPos } else { - geometryA.rightLine.end = aPos + geometryA.leftLine.end = aPos } const wallBId = connectedWallLines[iNext].wallId @@ -180,7 +185,7 @@ function updateComplexCorner( if (wallB.start.nodeId === node.id) { geometryB.leftLine.start = bPos } else { - geometryB.leftLine.end = bPos + geometryB.rightLine.end = bPos } } return points @@ -214,8 +219,8 @@ function updateSimpleCorner( geometryA.leftLine.start = aLeft geometryA.rightLine.start = aRight } else { - geometryA.leftLine.end = aLeft - geometryA.rightLine.end = aRight + geometryA.leftLine.end = aRight + geometryA.rightLine.end = aLeft } const aDir = wallA.start.nodeId === node.id ? negVec2(a.left.direction) : a.left.direction const p1 = scaleAddVec2(aLeft, aDir, COLINEAR_POINT_OFFSET) @@ -259,8 +264,8 @@ function updateSimpleCorner( geometryA.leftLine.start = i2 geometryA.rightLine.start = i4 } else { - geometryA.leftLine.end = i2 - geometryA.rightLine.end = i4 + geometryA.rightLine.end = i2 + geometryA.leftLine.end = i4 } const wallB = state.intermediateWalls[b.wallId] @@ -301,8 +306,8 @@ function updateWallEnd( geometry.leftLine.start = left geometry.rightLine.start = right } else { - geometry.leftLine.end = left - geometry.rightLine.end = right + geometry.rightLine.end = left + geometry.leftLine.end = right } // TODO: Properly handle single wall case by creating a rectangle based on the wall line and node position diff --git a/src/building/store/slices/intermediateWallsSlice.test.ts b/src/building/store/slices/intermediateWallsSlice.test.ts index 4f68f0067..96d3d46cd 100644 --- a/src/building/store/slices/intermediateWallsSlice.test.ts +++ b/src/building/store/slices/intermediateWallsSlice.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { NotFoundError } from '@/building/store/errors' +import { newVec2 } from '@/shared/geometry' import { expectConsistentIntermediateWallReferences, @@ -14,7 +15,7 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const result = state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + const result = state.actions.addInnerWallNode(perimeterId, newVec2(3000, 2500)) expect(result.type).toBe('inner') expect(result.position[0]).toBeCloseTo(3000, 0) @@ -29,9 +30,7 @@ describe('intermediateWallsSlice', () => { it('should throw NotFoundError for non-existent perimeter', () => { const { state } = setupIntermediateWallsSlice() - expect(() => state.actions.addInnerWallNode('perimeter_nonexistent' as any, { 0: 0, 1: 0 } as any)).toThrow( - NotFoundError - ) + expect(() => state.actions.addInnerWallNode('perimeter_nonexistent' as any, newVec2(0, 0))).toThrow(NotFoundError) }) }) @@ -73,8 +72,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall = state.actions.addIntermediateWall( perimeterId, @@ -116,7 +115,7 @@ describe('intermediateWallsSlice', () => { it('should throw NotFoundError for non-existent node', () => { const { state, perimeterData } = setupIntermediateWallsSlice() - const nodeA = state.actions.addInnerWallNode(perimeterData.perimeterId, { 0: 2000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterData.perimeterId, newVec2(2000, 2500)) expect(() => state.actions.addIntermediateWall( @@ -132,8 +131,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) expect(() => state.actions.addIntermediateWall( @@ -160,8 +159,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall = state.actions.addIntermediateWall( perimeterId, { nodeId: nodeA.id, axis: 'center' }, @@ -182,8 +181,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall = state.actions.addIntermediateWall( perimeterId, { nodeId: nodeA.id, axis: 'center' }, @@ -205,9 +204,9 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 5000, 1: 2500 } as any) - const nodeC = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(5000, 2500)) + const nodeC = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall1 = state.actions.addIntermediateWall( perimeterId, @@ -242,9 +241,9 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 5000, 1: 2500 } as any) - const nodeC = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(5000, 2500)) + const nodeC = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall1 = state.actions.addIntermediateWall( perimeterId, @@ -273,8 +272,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall = state.actions.addIntermediateWall( perimeterId, { nodeId: nodeA.id, axis: 'center' }, @@ -301,8 +300,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall = state.actions.addIntermediateWall( perimeterId, { nodeId: nodeA.id, axis: 'center' }, @@ -321,8 +320,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall = state.actions.addIntermediateWall( perimeterId, { nodeId: nodeA.id, axis: 'center' }, @@ -351,8 +350,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall = state.actions.addIntermediateWall( perimeterId, { nodeId: nodeA.id, axis: 'center' }, @@ -360,7 +359,7 @@ describe('intermediateWallsSlice', () => { 120 ) - const newNodeId = state.actions.splitIntermediateWallAtPoint(wall.id, { 0: 5000, 1: 2500 } as any) + const newNodeId = state.actions.splitIntermediateWallAtPoint(wall.id, newVec2(5000, 2500)) expect(state.intermediateWalls[wall.id]).toBeUndefined() expect(state._intermediateWallGeometry[wall.id]).toBeUndefined() @@ -396,8 +395,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall = state.actions.addIntermediateWall( perimeterId, { nodeId: nodeA.id, axis: 'center' }, @@ -407,7 +406,7 @@ describe('intermediateWallsSlice', () => { state.intermediateWalls[wall.id].wallAssemblyId = 'iwa_test' as any - state.actions.splitIntermediateWallAtPoint(wall.id, { 0: 5000, 1: 2500 } as any) + state.actions.splitIntermediateWallAtPoint(wall.id, newVec2(5000, 2500)) const remainingWalls = state.perimeters[perimeterId].intermediateWallIds.filter(id => id !== wall.id) const wallA = state.intermediateWalls[remainingWalls[0]] @@ -421,8 +420,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall = state.actions.addIntermediateWall( perimeterId, { nodeId: nodeA.id, axis: 'center' }, @@ -430,7 +429,7 @@ describe('intermediateWallsSlice', () => { 120 ) - state.actions.splitIntermediateWallAtPoint(wall.id, { 0: 4000, 1: 2500 } as any) + state.actions.splitIntermediateWallAtPoint(wall.id, newVec2(4000, 2500)) const remainingWalls = state.perimeters[perimeterId].intermediateWallIds.filter(id => id !== wall.id) const geoA = state._intermediateWallGeometry[remainingWalls[0]] @@ -444,7 +443,7 @@ describe('intermediateWallsSlice', () => { const { state } = setupIntermediateWallsSlice() expect(() => - state.actions.splitIntermediateWallAtPoint('intermediate_nonexistent' as any, { 0: 0, 1: 0 } as any) + state.actions.splitIntermediateWallAtPoint('intermediate_nonexistent' as any, newVec2(0, 0)) ).toThrow(NotFoundError) }) @@ -452,8 +451,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall = state.actions.addIntermediateWall( perimeterId, { nodeId: nodeA.id, axis: 'center' }, @@ -461,7 +460,7 @@ describe('intermediateWallsSlice', () => { 120 ) - const newNodeId = state.actions.splitIntermediateWallAtPoint(wall.id, { 0: 5000, 1: 2500 } as any) + const newNodeId = state.actions.splitIntermediateWallAtPoint(wall.id, newVec2(5000, 2500)) expect(state.perimeters[perimeterId].wallNodeIds).toContain(newNodeId) }) @@ -472,8 +471,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall = state.actions.addIntermediateWall( perimeterId, { nodeId: nodeA.id, axis: 'center' }, @@ -481,7 +480,7 @@ describe('intermediateWallsSlice', () => { 120 ) - state.actions.updateInnerWallNodePosition(nodeA.id, { 0: 3000, 1: 2500 } as any) + state.actions.updateInnerWallNodePosition(nodeA.id, newVec2(3000, 2500)) expect((state.wallNodes[nodeA.id] as any).position[0]).toBeCloseTo(3000, 0) const geo = state._intermediateWallGeometry[wall.id] @@ -492,7 +491,7 @@ describe('intermediateWallsSlice', () => { const { state } = setupIntermediateWallsSlice() expect(() => { - state.actions.updateInnerWallNodePosition('wallnode_nonexistent' as any, { 0: 0, 1: 0 } as any) + state.actions.updateInnerWallNodePosition('wallnode_nonexistent' as any, newVec2(0, 0)) }).toThrow(NotFoundError) }) @@ -503,7 +502,7 @@ describe('intermediateWallsSlice', () => { const node = state.actions.addPerimeterWallNode(perimeterId, wallIds[0], 3000) expect(() => { - state.actions.updateInnerWallNodePosition(node.id, { 0: 0, 1: 0 } as any) + state.actions.updateInnerWallNodePosition(node.id, newVec2(0, 0)) }).toThrow('Cannot update position of perimeter wall node') }) }) @@ -514,7 +513,7 @@ describe('intermediateWallsSlice', () => { const { perimeterId, wallIds } = perimeterData const perimeterNode = state.actions.addPerimeterWallNode(perimeterId, wallIds[0], 3000) - const innerNode = state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + const innerNode = state.actions.addInnerWallNode(perimeterId, newVec2(3000, 2500)) const wall = state.actions.addIntermediateWall( perimeterId, { nodeId: perimeterNode.id, axis: 'center' }, @@ -540,7 +539,7 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const node = state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + const node = state.actions.addInnerWallNode(perimeterId, newVec2(3000, 2500)) expect(() => { state.actions.updatePerimeterWallNodeOffset(node.id, 1000) @@ -553,9 +552,9 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 5000, 1: 2500 } as any) - const nodeC = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(5000, 2500)) + const nodeC = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall1 = state.actions.addIntermediateWall( perimeterId, @@ -594,8 +593,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall = state.actions.addIntermediateWall( perimeterId, { nodeId: nodeA.id, axis: 'center' }, @@ -623,9 +622,9 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 5000, 1: 2500 } as any) - const nodeC = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(5000, 2500)) + const nodeC = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) state.actions.addIntermediateWall( perimeterId, @@ -652,8 +651,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) state.actions.addIntermediateWall( perimeterId, @@ -676,7 +675,7 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const node = state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + const node = state.actions.addInnerWallNode(perimeterId, newVec2(3000, 2500)) const result = state.actions.getWallNodeById(node.id) @@ -709,7 +708,7 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId, wallIds } = perimeterData - state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + state.actions.addInnerWallNode(perimeterId, newVec2(3000, 2500)) state.actions.addPerimeterWallNode(perimeterId, wallIds[0], 5000) const nodes = state.actions.getAllWallNodes() @@ -720,7 +719,7 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId, wallIds } = perimeterData - state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + state.actions.addInnerWallNode(perimeterId, newVec2(3000, 2500)) state.actions.addPerimeterWallNode(perimeterId, wallIds[0], 5000) const nodes = state.actions.getWallNodesByPerimeter(perimeterId) @@ -739,8 +738,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall = state.actions.addIntermediateWall( perimeterId, { nodeId: nodeA.id, axis: 'center' }, @@ -757,8 +756,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) const wall = state.actions.addIntermediateWall( perimeterId, { nodeId: nodeA.id, axis: 'center' }, @@ -766,7 +765,7 @@ describe('intermediateWallsSlice', () => { 120 ) - state.actions.splitIntermediateWallAtPoint(wall.id, { 0: 5000, 1: 2500 } as any) + state.actions.splitIntermediateWallAtPoint(wall.id, newVec2(5000, 2500)) expectConsistentIntermediateWallReferences(state, perimeterId) expectNoOrphanedIntermediateEntities(state) @@ -776,8 +775,8 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId } = perimeterData - const nodeA = state.actions.addInnerWallNode(perimeterId, { 0: 2000, 1: 2500 } as any) - const nodeB = state.actions.addInnerWallNode(perimeterId, { 0: 8000, 1: 2500 } as any) + const nodeA = state.actions.addInnerWallNode(perimeterId, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) state.actions.addIntermediateWall( perimeterId, { nodeId: nodeA.id, axis: 'center' }, @@ -785,7 +784,7 @@ describe('intermediateWallsSlice', () => { 120 ) - state.actions.updateInnerWallNodePosition(nodeA.id, { 0: 3000, 1: 2500 } as any) + state.actions.updateInnerWallNodePosition(nodeA.id, newVec2(3000, 2500)) expectConsistentIntermediateWallReferences(state, perimeterId) expectNoOrphanedIntermediateEntities(state) @@ -811,7 +810,7 @@ describe('intermediateWallsSlice', () => { const { state, perimeterData } = setupIntermediateWallsSlice() const { perimeterId, wallIds } = perimeterData - const node = state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + const node = state.actions.addInnerWallNode(perimeterId, newVec2(3000, 2500)) for (const wallId of wallIds) { expect(state.perimeterWalls[wallId].wallNodeIds).not.toContain(node.id) @@ -836,7 +835,7 @@ describe('intermediateWallsSlice', () => { const { perimeterId, wallIds } = perimeterData const perimNode = state.actions.addPerimeterWallNode(perimeterId, wallIds[0], 3000) - const innerNode = state.actions.addInnerWallNode(perimeterId, { 0: 3000, 1: 2500 } as any) + const innerNode = state.actions.addInnerWallNode(perimeterId, newVec2(3000, 2500)) state.actions.addIntermediateWall( perimeterId, { nodeId: perimNode.id, axis: 'center' }, diff --git a/src/building/store/slices/intermediateWallsSlice.ts b/src/building/store/slices/intermediateWallsSlice.ts index 5d716a43a..c9975527e 100644 --- a/src/building/store/slices/intermediateWallsSlice.ts +++ b/src/building/store/slices/intermediateWallsSlice.ts @@ -22,7 +22,7 @@ import { removeConstraintsForEntityDraft } from '@/building/store/slices/constra import { removeTimestampDraft, updateTimestampDraft } from '@/building/store/slices/timestampsSlice' import type { StoreState } from '@/building/store/types' import type { Length, Vec2 } from '@/shared/geometry' -import { copyVec2 } from '@/shared/geometry' +import { copyVec2, lineFromSegment, projectPointOntoLine } from '@/shared/geometry' import { updateAllWallNodeGeometry } from './intermediateWallGeometry' @@ -249,8 +249,11 @@ export const createIntermediateWallsSlice: StateCreator< } const originalWall = state.intermediateWalls[wallId] + const originalGeometry = state._intermediateWallGeometry[wallId] const perimeter = state.perimeters[originalWall.perimeterId] + const projectedPoint = projectPointOntoLine(point, lineFromSegment(originalGeometry.leftLine)) + const wallAId = createIntermediateWallId() const wallBId = createIntermediateWallId() const newNodeIdInner = createWallNodeId() @@ -258,7 +261,7 @@ export const createIntermediateWallsSlice: StateCreator< id: newNodeIdInner, perimeterId: originalWall.perimeterId, type: 'inner', - position: copyVec2(point), + position: projectedPoint, connectedWallIds: [wallAId, wallBId] } state.wallNodes[newNodeIdInner] = newNode @@ -269,7 +272,7 @@ export const createIntermediateWallsSlice: StateCreator< perimeterId: originalWall.perimeterId, openingIds: [], start: originalWall.start, - end: { nodeId: newNodeIdInner, axis: originalWall.start.axis }, + end: { nodeId: newNodeIdInner, axis: 'left' }, thickness: originalWall.thickness, wallAssemblyId: originalWall.wallAssemblyId } @@ -278,7 +281,7 @@ export const createIntermediateWallsSlice: StateCreator< id: wallBId, perimeterId: originalWall.perimeterId, openingIds: [], - start: { nodeId: newNodeIdInner, axis: originalWall.end.axis }, + start: { nodeId: newNodeIdInner, axis: 'left' }, end: originalWall.end, thickness: originalWall.thickness, wallAssemblyId: originalWall.wallAssemblyId diff --git a/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts index 86bd30340..3fdbeae63 100644 --- a/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts +++ b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts @@ -245,7 +245,14 @@ export class IntermediateWallTool extends BaseTool implements ToolImplementation const halfThickness = wall.thickness / 2 this.snappingService.addSnapCandidate({ type: 'segment', - segment: wall.centerLine, + segment: wall.leftLine, + minDistance: halfThickness, + meta: wall.id, + priority: 1 + }) + this.snappingService.addSnapCandidate({ + type: 'segment', + segment: wall.rightLine, minDistance: halfThickness, meta: wall.id, priority: 1 From 2a89b75091f06dbb61cf2ff151c1b71e43b9ba4c Mon Sep 17 00:00:00 2001 From: Manuel Krebber Date: Tue, 31 Mar 2026 13:36:02 +0200 Subject: [PATCH 19/21] Fix issues with inspector and rerender or tool --- src/@types/resources.d.ts | 6 ++-- .../add/IntermediateWallTool.ts | 24 +++++++++++-- .../add/IntermediateWallToolInspector.tsx | 36 ++++++++----------- src/shared/i18n/locales/de/tool.json | 6 ++-- src/shared/i18n/locales/en/tool.json | 8 ++--- src/shared/i18n/locales/fr/tool.json | 6 ++-- 6 files changed, 49 insertions(+), 37 deletions(-) diff --git a/src/@types/resources.d.ts b/src/@types/resources.d.ts index 26751bcec..3c7d98312 100644 --- a/src/@types/resources.d.ts +++ b/src/@types/resources.d.ts @@ -1716,9 +1716,9 @@ interface Resources { "completeTooltip": "Complete wall (Enter)", "completeWall": "✓ Complete Wall", "controlClickFirst": "Click an existing wall or node to finish", - "controlEnter": "{{key}} to complete wall", - "controlEscAbort": "{{key}} to abort wall", - "controlEscOverride": "{{key}} to clear override", + "controlEnter": "Enter to complete wall", + "controlEscAbort": "Escape to abort wall", + "controlEscOverride": "Escape to clear override", "controlNumbers": "Type numbers to set exact wall length", "controlPlace": "Click to place wall points", "controlSnap": "Points snap to grid and existing geometry", diff --git a/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts index 3fdbeae63..5f48daeb6 100644 --- a/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts +++ b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts @@ -352,6 +352,7 @@ export class IntermediateWallTool extends BaseTool implements ToolImplementation this.resetContext() this.setupContext() deactivateLengthInput() + this.triggerRender() } public complete(snapEntity?: SnapEntityId): void { @@ -365,6 +366,7 @@ export class IntermediateWallTool extends BaseTool implements ToolImplementation this.resetDrawingState() deactivateLengthInput() + this.triggerRender() } public getPreviewPosition(): Vec2 { @@ -383,6 +385,19 @@ export class IntermediateWallTool extends BaseTool implements ToolImplementation return this.snappingService.findSnapResult(target) ?? undefined } + private resolveEntityToWallIds(entityId: SnapEntityId | undefined): Set { + if (!entityId) return new Set() + if (isWallNodeId(entityId)) { + const node = getModelActions().getWallNodeById(entityId) + const wallIds = new Set(node.connectedWallIds) + if (node.type === 'perimeter') { + wallIds.add(node.wallId) + } + return wallIds + } + return new Set([entityId]) + } + private checkValidation(): boolean { const isInsideValidationPolygons = Object.values(this.validationPolygons).some(polygon => isPointInPolygon(this.state.pointer, polygon) @@ -394,11 +409,14 @@ export class IntermediateWallTool extends BaseTool implements ToolImplementation if (this.state.points.length > 0) { const lastPoint = this.state.points[this.state.points.length - 1] const currentPos = this.state.snapResult?.position ?? this.state.pointer - const snapEntityId = this.state.snapResult?.meta + const snapMeta = this.state.snapResult?.meta + const snapEntityId = snapMeta !== 'origin' ? snapMeta : undefined + const snapWallIds = this.resolveEntityToWallIds(snapEntityId) + const startWallIds = this.resolveEntityToWallIds(this.state.startEntity) const segmentToValidate = { start: lastPoint, end: currentPos } for (const [entityId, lines] of Object.entries(this.validationLines)) { - if (entityId === snapEntityId) continue - if (this.state.points.length === 1 && this.state.startEntity === entityId) continue + if (snapWallIds.has(entityId as WallId)) continue + if (this.state.points.length === 1 && startWallIds.has(entityId as WallId)) continue for (const line of lines) { if (segmentsIntersect(segmentToValidate.start, segmentToValidate.end, line.start, line.end)) { return false diff --git a/src/editor/tools/intermediate-wall/add/IntermediateWallToolInspector.tsx b/src/editor/tools/intermediate-wall/add/IntermediateWallToolInspector.tsx index 475ae9f60..dc7ff83fa 100644 --- a/src/editor/tools/intermediate-wall/add/IntermediateWallToolInspector.tsx +++ b/src/editor/tools/intermediate-wall/add/IntermediateWallToolInspector.tsx @@ -1,7 +1,7 @@ import * as Label from '@radix-ui/react-label' import { Info, X } from 'lucide-react' import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' +import { Trans, useTranslation } from 'react-i18next' import { useReactiveTool } from '@/editor/tools/system/hooks/useReactiveTool' import type { ToolInspectorProps } from '@/editor/tools/system/types' @@ -130,32 +130,26 @@ export function IntermediateWallToolInspector({ tool }: ToolInspectorProps• {t($ => $.intermediateWall.controlNumbers)} {state.lengthOverride ? ( - • {t($ => $.keyboard.esc)}{' '} - {t($ => $.intermediateWall.controlEscOverride, { - key: '' - }) - .replace('{{key}}', '') - .trim()} + •{' '} + $.intermediateWall.controlEscOverride} components={{ kbd: }}> + Escape to cancel length override + ) : ( - • {t($ => $.keyboard.esc)}{' '} - {t($ => $.intermediateWall.controlEscAbort, { - key: '' - }) - .replace('{{key}}', '') - .trim()} + •{' '} + $.intermediateWall.controlEscAbort} components={{ kbd: }}> + Escape to cancel wall + )} - {state.points.length >= 3 && ( + {state.points.length >= 2 && ( <> - • {t($ => $.keyboard.enter)}{' '} - {t($ => $.intermediateWall.controlEnter, { - key: '' - }) - .replace('{{key}}', '') - .trim()} + •{' '} + $.intermediateWall.controlEnter} components={{ kbd: }}> + Enter to complete wall + • {t($ => $.intermediateWall.controlClickFirst)} @@ -167,7 +161,7 @@ export function IntermediateWallToolInspector({ tool }: ToolInspectorProps
- {state.points.length >= 3 && ( + {state.points.length >= 2 && (