diff --git a/docs/intermediate-walls-architecture.md b/docs/intermediate-walls-architecture.md new file mode 100644 index 00000000..214f89e1 --- /dev/null +++ b/docs/intermediate-walls-architecture.md @@ -0,0 +1,1474 @@ +# 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 +- 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 + +### 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 + connectedWallIds: IntermediateWallId[] +} + +// Free-standing node - position stored directly +interface InnerWallNode { + id: WallNodeId + perimeterId: PerimeterId + type: 'inner' + position: Vec2 + connectedWallIds: IntermediateWallId[] +} +``` + +### Wall Attachments + +Walls connect to nodes via attachments, which include axis alignment: + +```typescript +type WallAxis = 'left' | 'center' | 'right' + +interface WallAttachment { + nodeId: WallNodeId + axis: WallAxis +} +``` + +### 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 +} +``` + +### 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 +} + +interface RoomGeometry { + boundary: Polygon2D + area: Area +} +``` + +### Geometry Types + +```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 +} +``` + +## Completed Implementation (Phases 1-3) + +### 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 +// New action: add geometry for an intermediate wall +addIntermediateWallGeometry(state, wallId: IntermediateWallId): void + +// New action: remove geometry for an intermediate wall +removeIntermediateWallGeometry(state, wallId: IntermediateWallId): void + +// Update existing: called when wall changes +updateIntermediateWallGeometry(state, wallId: IntermediateWallId): void +``` + +**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: + +- 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 + +**For wall entities on intermediate walls** (deferred to Phase B): + +- Same pattern as perimeter entities: start/center/end points on center line, width constraint + +### Extend `PerimeterRegistryEntry` + +Add tracking for intermediate wall GCS elements within a perimeter: + +```typescript +interface PerimeterRegistryEntry { + // ... existing fields + intermediateWallIds: IntermediateWallId[] + wallNodeIds: WallNodeId[] +} +``` + +## A.2: GCS Sync Subscriptions + +### File: `src/building/gcs/gcsSync.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 +``` + +## A.3: Constraint Translator Updates + +### File: `src/building/gcs/constraintTranslator.ts` + +Extend `TranslationContext` to handle intermediate walls: + +```typescript +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}` +} + +getIntermediateWallGcsLineId: (wallId: IntermediateWallId) => { + return `intermediate_${wallId}_ref` +} +``` + +### 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 +// 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[] +``` + +**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 + +### When to generate + +- 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 + +## A.5: Perpendicular Snapping Enhancement + +### File: `src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts` + +Currently only snaps perpendicular within the chain. Extend to snap perpendicular to existing walls: + +**Strategy**: Add snap line candidates for perpendicular directions from existing wall endpoints. + +When the user is drawing and has at least one placed point: + +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` + +The `SnappingService` already handles line-line intersection snapping, so adding perpendicular snap lines will produce intersection points automatically. + +### 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 +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 +} +``` + +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 + +--- + +# Phase B: Wall Entities on Intermediate Walls + +## Goal + +Enable full entity support (openings, posts) on intermediate walls, matching the feature set available on perimeter walls: + +- 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 + +## Current State + +- **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` + +## B.1: Widen Entity Model + +### File: `src/building/model/wallEntities.ts` + +```typescript +interface BaseWallEntity { + id: WallEntityId + perimeterId: PerimeterId + wallId: WallId // Was: PerimeterWallId + type: 'opening' | 'post' + centerOffsetFromWallStart: Length + width: Length +} +``` + +Add a discriminator to know which store slice owns the entity: + +```typescript +interface BaseWallEntity { + // ... existing fields + wallType: 'perimeter' | 'intermediate' +} +``` + +**Migration**: Existing entities get `wallType: 'perimeter'` automatically. The `Opening` and `WallPost` types inherit `wallType` from `BaseWallEntity`. + +## B.2: Entity Storage Architecture + +### Approach: Shared storage, slice-specific dispatch + +Two options were considered: + +1. **Separate records in intermediateWallsSlice** -- duplicate opening/post records +2. **Shared records, slice-specific dispatch** -- single source of truth, dispatch based on `wallType` + +**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 +``` + +**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 +``` + +**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 +``` + +**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 + +- 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 + +--- + +# 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 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 +} + +// ... more types as needed +``` + +### File: `src/config/store/slices/interiorWalls.ts` (new) + +Config store slice following the established pattern: + +```typescript +interface InteriorWallAssembliesState { + interiorWallAssemblies: Record + defaultInteriorWallAssemblyId: InteriorWallAssemblyId | null +} + +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) + +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 +``` + +## C.2: Interior Wall Assembly Interface + +### File: `src/construction/assemblies/interiorWalls/types.ts` (new) + +```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 +} +``` + +**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 + } +} +``` + +### File: `src/construction/assemblies/interiorWalls/index.ts` (new) + +```typescript +function resolveInteriorWallAssembly(config: InteriorWallAssemblyConfig): InteriorWallAssembly { + switch (config.type) { + case 'monolithic': + return new MonolithicInteriorWallAssembly(config) + case 'framed': + return new FramedInteriorWallAssembly(config) + // ... + } +} +``` + +## C.3: Interior Wall Segmentation + +### File: `src/construction/assemblies/interiorWalls/segmentation.ts` (new) + +Simplified segmentation for intermediate walls (no corner extensions): + +```typescript +function* segmentedInteriorWallConstruction( + wall: IntermediateWallWithGeometry, + storeyContext: StoreyContext, + wallConstruction: WallSegmentConstruction, + openingAssemblyId?: OpeningAssemblyId +): Generator +``` + +**Key differences from perimeter wall segmentation:** + +- 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) + +**Entity handling:** + +- 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 + +### File: `src/construction/store/builders.ts` + +Add intermediate wall construction: + +```typescript +function buildIntermediateWallCoreModel(wallId: IntermediateWallId): CoreModel { + const wall = getIntermediateWallById(wallId) + const perimeter = getPerimeterById(wall.perimeterId) + const storeyContext = getWallStoreyContextCached(perimeter.storeyId) + + const assemblyConfig = wall.wallAssemblyId + ? getInteriorWallAssemblyById(wall.wallAssemblyId) + : getDefaultInteriorWallAssembly() + + if (!assemblyConfig) return emptyCoreModel() + + const assembly = resolveInteriorWallAssembly(assemblyConfig) + const wallModel = assembly.construct(wall, storeyContext) + return { model: wallModel, tags: [...], sourceId: wall.id } +} +``` + +### Integration with composite builders + +Update composite builders to include intermediate walls: + +```typescript +// In buildPerimeterComposite: +// Add intermediate wall transforms alongside perimeter wall transforms + +// In buildStoreyComposite: +// Include intermediate walls for each perimeter in the storey +``` + +## C.5: Assembly Assignment in Inspector + +### File: `src/editor/inspectors/IntermediateWallInspector.tsx` + +Add assembly selector dropdown: + +```tsx +// Assembly field + store.dispatch.updateIntermediateWallAssembly(wall.id, assemblyId)} + assemblies={getAllInteriorWallAssemblies()} +/> +``` + +### Store action + +```typescript +// In intermediateWallsSlice.ts: +updateIntermediateWallAssembly(wallId: IntermediateWallId, assemblyId: InteriorWallAssemblyId): void +``` + +## C.6: Testing + +- 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 + +--- + +# Phase D: Room Detection & Labeling + +## Goal + +Automatically detect rooms from closed wall loops and allow users to assign room types and labels. + +## Current State + +- **`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** + +## D.1: Room Detection Algorithm + +### File: `src/building/store/slices/roomDetection.ts` (new) + +The algorithm detects rooms from the wall graph topology: + +### Input + +For a given perimeter: + +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 + +### Algorithm + +``` +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 +``` + +### Cycle Detection Approach + +Use the **dual graph** approach for planar subdivision: + +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 + +### 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 +} + +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 + + // Getters: + getRoomById(id: RoomId): RoomWithGeometry | undefined + getRoomsByPerimeter(perimeterId: PerimeterId): RoomWithGeometry[] + getAllRooms(): RoomWithGeometry[] +} +``` + +### Room Geometry Computation + +```typescript +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 } +} +``` + +### Left/Right Room Assignment + +After room detection, assign rooms to wall sides: + +```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 + } + } + } +} +``` + +### Integration with Perimeter + +Update `Perimeter.roomIds` when rooms are detected: + +```typescript +// In perimeterSlice or roomsSlice: +perimeter.roomIds = rooms.filter(r => r.perimeterId === perimeterId).map(r => r.id) +``` + +## D.3: Room Detection Triggers + +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) +``` + +**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. + +## D.4: Room Selection + +### 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) + +SVG rendering for rooms: + +```tsx +function RoomShape({ roomId }: { roomId: RoomId }) { + const room = useRoomById(roomId) + if (!room) return null + + return ( + + + + {room.customLabel || getRoomTypeLabel(room.type, room.counter)} + + + ) +} +``` + +### Canvas layer integration + +Add room rendering as a background layer below walls. + +## D.5: Room Labeling UI + +### 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 ( +
+

Room

+ +
+ + +
+ +
+ + { + setLabel(e.target.value) + store.dispatch.updateRoomCustomLabel(roomId, e.target.value) + }} + /> +
+ +
+ + {formatArea(room.geometry.area)} +
+
+ ) +} +``` + +### File: `src/editor/tools/basic/SelectToolInspector.tsx` + +Add room handling: + +```tsx +{ + selectedId && isRoomId(selectedId) && +} +``` + +## D.6: Counter Management + +Room counters (e.g., "Bedroom 1", "Bedroom 2") need to be managed: + +```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 +} + +// When a room is deleted or type changed: +// Re-number remaining rooms of the same type to fill gaps +``` + +## D.7: Testing + +- 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 + +--- + +# Phase E: Room Preset Tool + +## Goal + +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. + +## Prerequisites + +- Phase D (Room Detection) must be complete for room creation +- Phase C (Assemblies) should be complete for wall assembly assignment + +## E.1: Tool Concept + +**Interaction flow:** + +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 + +**Snapping behavior:** + +- 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 + +## E.2: Room Preset Config + +### File: `src/editor/tools/room/preset/types.ts` (new) + +```typescript +interface RoomPresetConfig { + width: Length // Interior width + length: Length // Interior length + thickness: Length // Wall thickness + wallAssemblyId?: InteriorWallAssemblyId + roomType: RoomType + customLabel?: string +} +``` + +## E.3: RoomPresetTool + +### File: `src/editor/tools/room/preset/RoomPresetTool.ts` (new) + +Two-phase tool: dialog phase then placement phase. + +```typescript +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 + } +} +``` + +## 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. + +## E.5: Wall Creation Strategy + +After placement, determine which walls need to be created: + +``` +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 +``` + +## 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 + +## F.1: Tool Concept + +**Interaction flow:** + +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 + +**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

+
+
+ ) +} +``` + +## F.5: Overlay Component + +### File: `src/editor/tools/room/split/RoomSplitToolOverlay.tsx` (new) + +SVG overlay rendering: + +- 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 + +## F.6: Edge Cases + +- **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. + +## F.7: Testing + +- 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 diff --git a/src/@types/resources.d.ts b/src/@types/resources.d.ts index 325c91b2..3c7d9831 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", @@ -1699,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": "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", + "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" @@ -1884,6 +1916,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/building/gcs/constraintGenerator.test.ts b/src/building/gcs/constraintGenerator.test.ts index 7eb7f4c1..555eda0e 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/ids.ts b/src/building/model/ids.ts index 77ef26b5..ebaa6c5c 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_' @@ -44,6 +45,7 @@ export type EntityId = | PerimeterWallId | PerimeterCornerId | IntermediateWallId + | WallNodeId | OpeningId | WallPostId | FloorAreaId @@ -56,6 +58,7 @@ export type LayerSetId = `${typeof LAYER_SET_ID_PREFIX}${string}` export type AssemblyId = | RingBeamAssemblyId | WallAssemblyId + | InteriorWallAssemblyId | FloorAssemblyId | RoofAssemblyId | OpeningAssemblyId @@ -72,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}` @@ -82,7 +85,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 +111,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 +130,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 +141,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 = @@ -145,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/model/perimeters.ts b/src/building/model/perimeters.ts index 486ea710..ad3c2d6c 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/model/rooms.ts b/src/building/model/rooms.ts index 6e0b328b..55e85884 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,76 +24,88 @@ export type RoomType = | 'service' | 'generic' +export type WallAxis = 'left' | 'center' | 'right' + export interface BaseWallNode { id: WallNodeId perimeterId: PerimeterId type: 'perimeter' | 'inner' + connectedWallIds: IntermediateWallId[] } 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 +} - // Computed - boundary: Polygon2D +export interface BaseWallNodeGeometry { + boundary?: Polygon2D + center: Vec2 } +export interface PerimeterWallNodeGeometry extends BaseWallNodeGeometry { + position: Vec2 +} + +export type InnerWallNodeGeometry = BaseWallNodeGeometry + +export type WallNodeGeometry = PerimeterWallNodeGeometry | InnerWallNodeGeometry + +export type PerimeterWallNodeWithGeometry = PerimeterWallNode & PerimeterWallNodeGeometry +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 diff --git a/src/building/store/helpers.ts b/src/building/store/helpers.ts index 996cc1e6..0eff5e62 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 7b310a13..93d5606f 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 aae0ea8e..75377d56 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 33888c53..c38f53bc 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,10 +209,11 @@ export const migrateToVersion12: Migration = state => { // Create normalized wall const newWall: PerimeterWall = { id: wallId, - perimeterId: perimeter.id, + perimeterId: perimeter.id as PerimeterId, startCornerId, endCornerId, entityIds, + wallNodeIds: [], thickness, wallAssemblyId } @@ -241,28 +243,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 00000000..6ec19cb0 --- /dev/null +++ b/src/building/store/migrations/toVersion15.ts @@ -0,0 +1,30 @@ +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 = {} + } + + 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 df5f396f..aaa11782 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 143f04f0..6fdfb2b2 100644 --- a/src/building/store/slices/__tests__/testHelpers.ts +++ b/src/building/store/slices/__tests__/testHelpers.ts @@ -1,14 +1,25 @@ 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 { ConstraintsState } from '@/building/store/slices/constraintsSlice' +import type { IntermediateWallsSlice, IntermediateWallsState } from '@/building/store/slices/intermediateWallsSlice' +import { createIntermediateWallsSlice } from '@/building/store/slices/intermediateWallsSlice' import { type PerimetersSlice, 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' @@ -61,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) @@ -240,3 +258,365 @@ 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: [], + wallNodeIds: [], + thickness: t, + wallAssemblyId: 'wa_test' as any + }, + [wallIds[1]]: { + id: wallIds[1], + perimeterId, + startCornerId: cornerIds[1], + endCornerId: cornerIds[2], + entityIds: [], + wallNodeIds: [], + thickness: t, + wallAssemblyId: 'wa_test' as any + }, + [wallIds[2]]: { + id: wallIds[2], + perimeterId, + startCornerId: cornerIds[2], + endCornerId: cornerIds[3], + entityIds: [], + wallNodeIds: [], + thickness: t, + wallAssemblyId: 'wa_test' as any + }, + [wallIds[3]]: { + id: wallIds[3], + perimeterId, + startCornerId: cornerIds[3], + endCornerId: cornerIds[0], + entityIds: [], + wallNodeIds: [], + 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 & TimestampsState & ConstraintsState + +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: {}, + _constraintsByEntity: {}, + buildingConstraints: {}, + 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; perimeterWalls: 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() + } + + // 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 + 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) + } + + 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) + } + + // 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 00000000..6fa64052 --- /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 00000000..80ee6c1e --- /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 new file mode 100644 index 00000000..deea2e65 --- /dev/null +++ b/src/building/store/slices/intermediateWallGeometry.test.ts @@ -0,0 +1,458 @@ +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(99, 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/intermediateWallGeometry.ts b/src/building/store/slices/intermediateWallGeometry.ts new file mode 100644 index 00000000..91f23c67 --- /dev/null +++ b/src/building/store/slices/intermediateWallGeometry.ts @@ -0,0 +1,506 @@ +import type { Perimeter } from '@/building/model' +import type { IntermediateWallId, PerimeterId, WallNodeId } from '@/building/model/ids' +import type { + InnerWallNode, + InnerWallNodeGeometry, + IntermediateWall, + IntermediateWallGeometry, + 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, + distVec2, + dotVec2, + eqVec2, + lenVec2, + lineIntersection, + midpoint, + negVec2, + normVec2, + perpendicularCCW, + perpendicularCW, + projectPointOntoLine, + 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 => { + const lines = wallLines.get(wallId) + 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 != null) + + let points: Vec2[] + if (connectedWallLines.length === 0) { + points = [node.position] + } 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) + } + + 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 + } + } + + 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( + 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) + if (!nodePos) { + throw new Error(`Node position not found for node ${node.id}`) + } + + aPos = projectPointOntoLine(nodePos, a) + bPos = projectPointOntoLine(nodePos, b) + } else { + const intersection = lineIntersection(a, b) + if (!intersection) { + throw new Error(`No intersection found between wall lines at node ${node.id}`) + } + 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.leftLine.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.rightLine.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) + 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( + 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 = 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) + 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) + + 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] + if (wallA.start.nodeId === node.id) { + geometryA.leftLine.start = i2 + geometryA.rightLine.start = i4 + } else { + geometryA.rightLine.end = i2 + geometryA.leftLine.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.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 + 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 + + const nodePos = nodePositions.get(node.id) + if (!nodePos) { + throw new Error(`Node position not found for perimeter wall node ${node.id}`) + } + + if (node.connectedWallIds.length === 0) { + state._wallNodeGeometry[node.id] = { + position: nodePos, + center: nodePos + } + return + } + + 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: nodePos, + 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 wallGeometry = state._perimeterWallGeometry[node.wallId] + const position = scaleAddVec2(wallGeometry.insideLine.start, 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.test.ts b/src/building/store/slices/intermediateWallsSlice.test.ts new file mode 100644 index 00000000..96d3d46c --- /dev/null +++ b/src/building/store/slices/intermediateWallsSlice.test.ts @@ -0,0 +1,852 @@ +import { describe, expect, it } from 'vitest' + +import { NotFoundError } from '@/building/store/errors' +import { newVec2 } from '@/shared/geometry' + +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, newVec2(3000, 2500)) + + 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, newVec2(0, 0))).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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + + 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, newVec2(2000, 2500)) + + 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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + + 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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + 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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + 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, 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, + { 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, 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, + { 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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + 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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + 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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + 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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + const newNodeId = state.actions.splitIntermediateWallAtPoint(wall.id, newVec2(5000, 2500)) + + 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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + 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, newVec2(5000, 2500)) + + 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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + 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]] + 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, newVec2(0, 0)) + ).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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + const newNodeId = state.actions.splitIntermediateWallAtPoint(wall.id, newVec2(5000, 2500)) + + 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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + 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] + expect(geo.wallLength).toBeCloseTo(5000, 0) + }) + + it('should throw for non-existent node', () => { + const { state } = setupIntermediateWallsSlice() + + expect(() => { + state.actions.updateInnerWallNodePosition('wallnode_nonexistent' as any, newVec2(0, 0)) + }).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, newVec2(0, 0)) + }).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, newVec2(3000, 2500)) + 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, newVec2(3000, 2500)) + + 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, 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, + { 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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + 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, 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, + { 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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + + 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, newVec2(3000, 2500)) + + 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, newVec2(3000, 2500)) + 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, newVec2(3000, 2500)) + 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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + 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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + const wall = state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + state.actions.splitIntermediateWallAtPoint(wall.id, newVec2(5000, 2500)) + + 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, newVec2(2000, 2500)) + const nodeB = state.actions.addInnerWallNode(perimeterId, newVec2(8000, 2500)) + state.actions.addIntermediateWall( + perimeterId, + { nodeId: nodeA.id, axis: 'center' }, + { nodeId: nodeB.id, axis: 'center' }, + 120 + ) + + state.actions.updateInnerWallNodePosition(nodeA.id, newVec2(3000, 2500)) + + expectConsistentIntermediateWallReferences(state, perimeterId) + 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, newVec2(3000, 2500)) + + 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, newVec2(3000, 2500)) + 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 new file mode 100644 index 00000000..46541c1e --- /dev/null +++ b/src/building/store/slices/intermediateWallsSlice.ts @@ -0,0 +1,465 @@ +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 { 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, lineFromSegment, projectPointOntoLine } 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 + splitIntermediateWallAtPoint: (wallId: IntermediateWallId, point: Vec2) => WallNodeId + 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 & StoreState, + [['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) + + if (!(start.nodeId in state.wallNodes) || state.wallNodes[start.nodeId].perimeterId !== perimeterId) { + throw new NotFoundError('Wall node', start.nodeId) + } + state.wallNodes[start.nodeId].connectedWallIds.push(wallId) + if (!(end.nodeId in state.wallNodes) || state.wallNodes[end.nodeId].perimeterId !== perimeterId) { + throw new NotFoundError('Wall node', end.nodeId) + } + state.wallNodes[end.nodeId].connectedWallIds.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) + cleanUpOrphaned(state) + + 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) + state.perimeterWalls[wallId].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 + }, + + 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 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() + const newNode: WallNode = { + id: newNodeIdInner, + perimeterId: originalWall.perimeterId, + type: 'inner', + position: projectedPoint, + connectedWallIds: [wallAId, wallBId] + } + state.wallNodes[newNodeIdInner] = newNode + perimeter.wallNodeIds.push(newNodeIdInner) + + const wallA: IntermediateWall = { + id: wallAId, + perimeterId: originalWall.perimeterId, + openingIds: [], + start: originalWall.start, + end: { nodeId: newNodeIdInner, axis: 'left' }, + thickness: originalWall.thickness, + wallAssemblyId: originalWall.wallAssemblyId + } + + const wallB: IntermediateWall = { + id: wallBId, + perimeterId: originalWall.perimeterId, + openingIds: [], + start: { nodeId: newNodeIdInner, axis: 'left' }, + 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) + + 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) + + 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 + + const node = state.wallNodes[nodeId] + + delete state.wallNodes[nodeId] + delete state._wallNodeGeometry[nodeId] + + cleanUpOrphaned(state) + removeTimestampDraft(state, nodeId) + removeConstraintsForEntityDraft(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) } + }) + } + } +}) diff --git a/src/building/store/slices/perimeterSlice.ts b/src/building/store/slices/perimeterSlice.ts index b46ac5e1..64707769 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/building/store/store.ts b/src/building/store/store.ts index 6e4364d0..a9a2800d 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 321bd47b..1368facd 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/construction/assemblies/walls/segmentation.test.ts b/src/construction/assemblies/walls/segmentation.test.ts index d4cab859..40d05749 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: [] } } diff --git a/src/editor/EditorToolbar.tsx b/src/editor/EditorToolbar.tsx index fabef677..e3fa2516 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/canvas/components/SnappingLines.tsx b/src/editor/canvas/components/SnappingLines.tsx index f2dbd8fa..afbdfe76 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/layers/tools/SelectionOutline.tsx b/src/editor/canvas/layers/tools/SelectionOutline.tsx index 752e51ff..cc633524 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 ? ( + + ) : ( + + + ) +} diff --git a/src/editor/canvas/layers/walls/PerimeterShape.tsx b/src/editor/canvas/layers/walls/PerimeterShape.tsx index 1831fa9c..5e75c116 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 00000000..12893125 --- /dev/null +++ b/src/editor/canvas/layers/walls/WallNodeShape.tsx @@ -0,0 +1,30 @@ +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 = 50 + +export function WallNodeShape({ nodeId }: { nodeId: WallNodeId }): React.JSX.Element { + const node = useWallNodeById(nodeId) + + const fillColor = MATERIAL_COLORS.strawbale + + const pathD = node.boundary ? polygonToSvgPath(node.boundary) : undefined + + return ( + + {pathD && } + + + ) +} diff --git a/src/editor/canvas/services/SnappingService.test.ts b/src/editor/canvas/services/SnappingService.test.ts index a079fbf0..094fe97b 100644 --- a/src/editor/canvas/services/SnappingService.test.ts +++ b/src/editor/canvas/services/SnappingService.test.ts @@ -1,355 +1,405 @@ -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) + expect(result?.position[0]).toBe(100) + expect(result?.position[1]).toBe(400) + expect(result?.type).toBe('align') }) + }) - it('should not snap to line outside line snap distance', () => { - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1] - } + 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 point far from any line (outside 100mm line snap distance) - const target = newVec2(250, 250) - const result = service.findSnapResult(target, context) + const target = newVec2(300, 110) + const result = svc.findSnapResult(target) - expect(result).toBeNull() + expect(result).not.toBeNull() + expect(result?.position[1]).toBe(100) + expect(result?.position[0]).toBe(300) }) - it('should snap to reference point lines when provided', () => { - const context: SnappingContext = { - snapPoints: [], - referencePoint: newVec2(100, 100) - } + 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 }) - // Target near horizontal line through reference point - const target = newVec2(200, 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) - expect(result?.position[0]).toBe(200) + expect(result?.position[0]).toBe(150) }) - it('should respect minimum distance from reference point', () => { - const context: SnappingContext = { - snapPoints: [], // No points to avoid point snapping - referencePoint: newVec2(100, 100) - } + 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 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) + const target = newVec2(300, 105) + const result = svc.findSnapResult(target) - // 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] - } + describe('Intersection Snapping', () => { + it('should snap to intersection of two lines', () => { + 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 on extension of horizontal wall - const target = newVec2(300, 110) - const result = service.findSnapResult(target, context) + const target = newVec2(1005, 1995) + 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?.lines?.length).toBe(2) + expect(result?.position[0]).toBe(1000) + expect(result?.position[1]).toBe(2000) }) + }) - 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] - } + 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 - // Target near perpendicular line from wall start - const target = newVec2(110, 200) - const result = service.findSnapResult(target, context) + const target = newVec2(130, 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] - } + 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 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(400, 110) + 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 + expect(result?.position[1]).toBe(100) }) - 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] - } + 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 - // Target equidistant from single line and intersection - const target = newVec2(1000, 1995) - const result = service.findSnapResult(target, context) + const target = newVec2(110, 105) + 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) - 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 - }) - - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1] + 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 }) - // 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(180, 180) + const result = svc.findSnapResult(target) - expect(result).toBeNull() + expect(result).not.toBeNull() + expect(result?.position).toEqual(newVec2(200, 200)) }) + }) - 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 - }) + describe('addSnapCandidate', () => { + it('should dynamically add candidates', () => { + const svc = new SnappingService({ candidates: [] }) - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1] - } + svc.addSnapCandidate({ type: 'point', position: newVec2(500, 500), mode: 'snap' }) - // 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(510, 505) + const result = svc.findSnapResult(target) - expect(result).toBeNull() + expect(result).not.toBeNull() + expect(result?.position).toEqual(newVec2(500, 500)) }) - 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 expand align points into horizontal and vertical lines', () => { + const svc = new SnappingService({ candidates: [] }) - const context: SnappingContext = { - snapPoints: [], // No points - use reference point to create lines - referencePoint: newVec2(100, 100) - } + svc.addSnapCandidate({ type: 'point', position: newVec2(300, 300), mode: 'align' }) - // 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) + const horizontalResult = svc.findSnapResult(newVec2(500, 310)) + expect(horizontalResult).not.toBeNull() + expect(horizontalResult?.position[1]).toBe(300) - expect(result).toBeNull() + const verticalResult = svc.findSnapResult(newVec2(310, 500)) + expect(verticalResult).not.toBeNull() + expect(verticalResult?.position[0]).toBe(300) }) }) - describe('Edge Cases', () => { - it('should snap to origin even if no points are in context', () => { - const context: SnappingContext = { - snapPoints: [] - } - - const target = newVec2(50, 50) - const result = service.findSnapResult(target, context) + 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?.position).toEqual(ZERO_VEC2) - expect(result?.lines).toHaveLength(2) + 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 handle single point', () => { - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1] - } - - const target = newVec2(120, 110) - const result = service.findSnapResult(target, context) + 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?.position).toBe(point1) + 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 handle undefined reference point gracefully', () => { - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1], - referencePoint: undefined - } + 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) - const target = newVec2(120, 110) - const result = service.findSnapResult(target, context) + 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 handle undefined reference line walls gracefully', () => { - const point1 = newVec2(100, 100) - const context: SnappingContext = { - snapPoints: [point1], - referenceLineSegments: undefined - } + 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) } + } + ] + }) - const target = newVec2(120, 110) - const result = service.findSnapResult(target, context) + 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) }) + }) - 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 9033ac9b..0e7539f6 100644 --- a/src/editor/canvas/services/SnappingService.ts +++ b/src/editor/canvas/services/SnappingService.ts @@ -5,218 +5,322 @@ import { type Vec2, ZERO_VEC2, distSqrVec2, + distVec2, distanceToInfiniteLine, + dotVec2, + lenSqrVec2, lineFromSegment, lineIntersection, + lineSegmentIntersect, 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 { + priority: number + 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 } - } +export interface SnapLine extends SnapMeta { + type: 'line' + line: Line2D +} - /** - * 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) +export interface SnapLineSegment extends SnapMeta { + type: 'segment' + segment: LineSegment2D +} - if (pointSnapResult != null) return pointSnapResult +export type SnapCandidate = SnapPoint | SnapLine | SnapLineSegment - // Step 2: Generate snap lines and check for line/intersection snapping - const snapLines = this.generateSnapLines(context) +interface InternalMeta { + priority: number + isDerived: boolean + minDistance: Length + lines?: readonly [Line2D] | readonly [Line2D, Line2D] +} - return this.findLineSnapPosition(target, snapLines, context, tolerance) +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 - /** - * 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 - } - } +export class SnappingService { + private readonly candidates: InternalSnapCandidate[] = [] - return bestPoint != null ? { position: bestPoint } : null - } - - /** - * Generate snap lines for architectural alignment - */ - private generateSnapLines(context: SnappingContext): Line2D[] { - const snapLines: Line2D[] = [] + private readonly context: SnappingContext - const allPoints = [ZERO_VEC2, ...context.snapPoints, ...(context.alignPoints ?? [])] + referencePoint: Vec2 | null = null + referenceMinDistance: Length = DEFAULT_DISTANCE - // 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) - }) - - // 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) + } + 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) + } + } } + } - 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({ + priority: candidate.priority, + 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) + } + } + + 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] + }) } + } - // No intersection found, return closest line snap - const bestSnap = nearbyLines[closestIndex] - return { - position: bestSnap.projectedPosition, - lines: [bestSnap.line] + 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] + }) } } } - -// Create a default singleton instance -export const defaultSnappingService = new SnappingService() diff --git a/src/editor/inspectors/IntermediateWallInspector.tsx b/src/editor/inspectors/IntermediateWallInspector.tsx new file mode 100644 index 00000000..c71afb35 --- /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 00000000..c1f6b62f --- /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 de3a40e3..2baaea5a 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/editor/tools/basic/movement/MoveTool.ts b/src/editor/tools/basic/movement/MoveTool.ts index bef7947d..38ccb144 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 6dabe4e1..957b2915 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 40f28e11..73886e61 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 f52b9fc9..99140ec9 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 c539a388..49d55f65 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 e929631e..c9199e9d 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 f35bdf90..87e5d077 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,18 @@ 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, + defaultPointDistance: 200, + defaultLineDistance: 100 + }) - return { perimeter, snapContext } + return { perimeter, snapService } } protected getPolygonPoints(context: MovementContext): readonly Vec2[] { @@ -89,6 +81,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 679758dc..f01e1153 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,33 +39,27 @@ export abstract class PolygonMovementBehavior): PolygonMovementState { const originalPoints = this.getPolygonPoints(context) const previewPoints = originalPoints.map(point => addVec2(point, pointerState.delta)) - const snapContext = this.getSnapContext(context) - let bestScore = Infinity + const service = context.entity.snapService + + let highestPriority = -Infinity + let bestDist = 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 = - distSqrVec2(previewPoints[index], snapResult.position) * - (snapResult.lines && snapResult.lines.length > 0 ? 5 : 1) - - if (score < bestScore) { - bestScore = score + const dist = distSqrVec2(previewPoints[index], snapResult.position) + if (highestPriority < snapResult.priority || (highestPriority === snapResult.priority && dist < bestDist)) { + highestPriority = snapResult.priority + bestDist = dist resultDelta = subVec2(snapResult.position, originalPoints[index]) } } const finalPoints = this.translatePoints(originalPoints, resultDelta) - const epsilonTolerance = 1 - const snapResults: SnapResult[] = [] - - for (const point of finalPoints) { - const snapResult = context.snappingService.findSnapResult(point, snapContext, epsilonTolerance) - if (snapResult) snapResults.push(snapResult) - } + const snapResults = finalPoints.map(point => service.findSnapResult(point, 1)).filter(r => r != null) return { previewPolygon: finalPoints, @@ -85,10 +80,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 d2a631f4..05bed8c0 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,38 @@ export class RoofMovementBehavior extends PolygonMovementBehavior r.id !== roof.id) + const snapCandidates = this.buildSnapCandidates(perimeters, otherRoofs) + const snapService = new SnappingService({ + candidates: snapCandidates, + defaultPointDistance: 200, + defaultLineDistance: 100 + }) + + 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)) + + 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] + 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/movementBehaviors.ts b/src/editor/tools/basic/movement/movementBehaviors.ts index cb412693..ca4c85a1 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 { diff --git a/src/editor/tools/basic/movement/types.ts b/src/editor/tools/basic/movement/types.ts index bdefef77..fe1c7f68 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 42ca6c43..fc8c3a3e 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 443c9122..a81222fc 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 02311839..6726c30d 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 new file mode 100644 index 00000000..5f48daeb --- /dev/null +++ b/src/editor/tools/intermediate-wall/add/IntermediateWallTool.ts @@ -0,0 +1,491 @@ +import { + type PerimeterId, + type WallId, + type WallNodeId, + isIntermediateWallId, + isPerimeterWallId, + isWallNodeId +} from '@/building/model' +import { getModelActions } from '@/building/store' +import { type SnapResult, SnappingService } from '@/editor/canvas/services/SnappingService' +import { + 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 { CursorStyle, EditorEvent, ToolImplementation } from '@/editor/tools/system/types' +import { + type Length, + type LineSegment2D, + type Vec2, + ZERO_VEC2, + direction, + lineFromSegment, + perpendicular, + 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' + +type SnapEntityId = WallId | WallNodeId + +export type IntermediateWallAlignment = 'left' | 'right' + +interface IntermediateWallToolState { + points: Vec2[] + pointer: Vec2 + snapResult?: SnapResult + startEntity?: SnapEntityId + perimeterId?: PerimeterId + isValid: boolean + lengthOverride: Length | null + segmentLengthOverrides: (Length | null)[] + thickness: Length + alignment: IntermediateWallAlignment +} + +const SNAP_NODE_TOLERANCE = 200 + +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) + this.state = { + points: [] as Vec2[], + pointer: ZERO_VEC2, + snapResult: undefined, + startEntity: undefined, + perimeterId: undefined, + isValid: true, + lengthOverride: null, + segmentLengthOverrides: [] as (Length | null)[], + thickness: 120, + alignment: 'left' + } + } + + public setThickness(thickness: Length): void { + this.state.thickness = thickness + 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) + const snapCoords = this.state.snapResult?.position ?? event.worldCoordinates + const snapEntity = this.state.snapResult?.meta !== 'origin' ? this.state.snapResult?.meta : undefined + + if (!this.state.isValid) return true + + if (this.state.points.length === 0 && snapEntity) { + this.state.startEntity = snapEntity + } + + 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.addPoint(pointToAdd) + + 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') + } + } + + this.clearLengthOverride() + this.state.isValid = this.checkValidation() + + if (this.state.points.length >= 1) { + this.activateLengthInputForNextSegment() + } + + if (snapEntity && this.state.points.length >= 2) { + this.complete(snapEntity) + } + + 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 + }) + } + + if (this.state.points.length > 1) { + const lastPoint = this.state.points[this.state.points.length - 2] + const dir = direction(lastPoint, pointToAdd) + this.snappingService.addSnapCandidate({ + type: 'line', + line: { point: lastPoint, direction: dir } + }) + this.snappingService.addSnapCandidate({ + type: 'line', + line: { point: pointToAdd, direction: perpendicular(dir) } + }) + } + } + + 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 + } + + 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 storeyId = modelActions.getActiveStoreyId() + const perimeters = modelActions.getPerimetersByStorey(storeyId) + + 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.leftLine, + minDistance: halfThickness, + meta: wall.id, + priority: 1 + }) + this.snappingService.addSnapCandidate({ + type: 'segment', + segment: wall.rightLine, + 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) { + // Unreachable since this is set on first point + throw new Error('No perimeter found for intermediate wall') + } + + 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) + }) + + for (let index = 0; index < points.length - 1; index++) { + const startNode = nodes[index] + const endNode = nodes[index + 1] + + modelActions.addIntermediateWall( + perimeterId, + { nodeId: startNode.id, axis: this.state.alignment }, + { nodeId: endNode.id, axis: this.state.alignment }, + this.state.thickness + ) + } + } + + private findPerimeterContainingPoint(point: Vec2): PerimeterId | null { + const perimeterPolygons = Object.entries(this.validationPolygons) as [PerimeterId, Polygon2D][] + return perimeterPolygons.find(([_, polygon]) => isPointInPolygon(point, polygon))?.[0] ?? null + } + + private getOrCreateEntityNode(point: Vec2, entityId: SnapEntityId): { id: WallNodeId } { + if (isWallNodeId(entityId)) { + return { id: entityId } + } + + const modelActions = getModelActions() + + 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) + } + + if (isIntermediateWallId(entityId)) { + return { id: modelActions.splitIntermediateWallAtPoint(entityId, point) } + } + + assertUnreachable(entityId, 'invalid entity id for node creation') + } + + getCursor(): CursorStyle { + return 'crosshair' + } + + public cancel(): void { + this.resetDrawingState() + this.resetContext() + this.setupContext() + deactivateLengthInput() + this.triggerRender() + } + + public complete(snapEntity?: SnapEntityId): void { + const points = [...this.state.points] + + try { + this.onPolylineCompleted(points, snapEntity) + } catch (error) { + console.error('Failed to create polyline:', error) + } + + this.resetDrawingState() + deactivateLengthInput() + this.triggerRender() + } + + 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) + } + + private findSnap(target: Vec2): SnapResult | undefined { + 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) + ) + 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 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 (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 + } + } + } + + 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 true + } + + 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(), + onCommit: (length: Length) => { + this.setLengthOverride(length) + }, + onCancel: () => { + this.clearLengthOverride() + } + }) + } + + 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.snapResult = undefined + this.state.startEntity = undefined + this.state.perimeterId = undefined + this.state.isValid = true + this.state.lengthOverride = null + this.state.segmentLengthOverrides = [] + this.resetContext() + this.setupContext() + } +} 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 00000000..dc7ff83f --- /dev/null +++ b/src/editor/tools/intermediate-wall/add/IntermediateWallToolInspector.tsx @@ -0,0 +1,200 @@ +import * as Label from '@radix-ui/react-label' +import { Info, X } from 'lucide-react' +import { useEffect, useState } from 'react' +import { Trans, 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 { ToggleGroup, ToggleGroupItem } from '@/shared/ui/components/toggle-group' + +import type { IntermediateWallAlignment, 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 */} + + + + + + + {state.alignment === 'left' ? t($ => $.intermediateWall.infoLeft) : t($ => $.intermediateWall.infoRight)} + + + + + {/* Tool Properties */} +
+ {/* Wall Thickness */} +
+ + + {t($ => $.intermediateWall.wallThickness)} + + + +
+
+ { + tool.setThickness(value) + }} + min={10} + max={undefined} + step={10} + size="sm" + unit="mm" + className="grow" + /> +
+ +
+ + + {t($ => $.intermediateWall.referenceSide)} + + +
+ { + if (value) { + tool.setAlignment(value as IntermediateWallAlignment) + } + }} + > + {t($ => $.intermediateWall.referenceSideLeft)} + {t($ => $.intermediateWall.referenceSideRight)} + +
+ + {/* Length Override Display */} + {state.lengthOverride && ( + <> + +
+ {t($ => $.intermediateWall.lengthOverride)} +
+ {formatLength(state.lengthOverride)} + +
+
+ + )} + + {/* Help Text */} + +
+ {t($ => $.intermediateWall.controlsHeading)} + • {t($ => $.intermediateWall.controlPlace)} + • {t($ => $.intermediateWall.controlSnap)} + • {t($ => $.intermediateWall.controlNumbers)} + {state.lengthOverride ? ( + + •{' '} + $.intermediateWall.controlEscOverride} components={{ kbd: }}> + Escape to cancel length override + + + ) : ( + + •{' '} + $.intermediateWall.controlEscAbort} components={{ kbd: }}> + Escape to cancel wall + + + )} + {state.points.length >= 2 && ( + <> + + •{' '} + $.intermediateWall.controlEnter} components={{ kbd: }}> + Enter to complete wall + + + • {t($ => $.intermediateWall.controlClickFirst)} + + )} +
+ + {/* Actions */} + {state.points.length > 0 && ( + <> + +
+ {state.points.length >= 2 && ( + + )} + +
+ + )} +
+
+ ) +} 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 00000000..ccab0415 --- /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, perpendicularCW, 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 = perpendicularCW(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, state.alignment, 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 f7dbda1d..ccdef913 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/roofs/RoofTool.ts b/src/editor/tools/roofs/RoofTool.ts index 77539df6..2c4015d5 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 2a7f3ee9..51bbb934 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 { @@ -160,6 +190,7 @@ export abstract class BasePolygonTool exten this.resetDrawingState() this.onPolygonCancelled() deactivateLengthInput() + this.triggerRender() } public complete(): void { @@ -179,6 +210,7 @@ export abstract class BasePolygonTool exten this.resetDrawingState() deactivateLengthInput() + this.triggerRender() } /** @@ -196,35 +228,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 +269,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 } @@ -345,13 +356,12 @@ 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 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 31818ac3..629f84a1 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,11 +36,12 @@ describe('PolygonToolOverlay', () => { 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, segmentLengthOverrides: [], @@ -153,8 +154,11 @@ describe('PolygonToolOverlay', () => { describe('snapping behavior', () => { it('renders snap position when snap result exists', () => { - const snapResult: SnapResult = { - position: newVec2(125, 125) + const snapResult: SnapResult = { + priority: 0, + position: newVec2(125, 125), + distance: 10, + type: 'snap' } mockTool.state.points = [newVec2(100, 100)] @@ -167,8 +171,11 @@ describe('PolygonToolOverlay', () => { }) it('renders snap lines when snap result has lines', () => { - const snapResult: SnapResult = { + const snapResult: SnapResult = { + priority: 0, position: newVec2(150, 150), + distance: 10, + type: 'align', lines: [ { point: newVec2(100, 100), @@ -219,8 +226,11 @@ describe('PolygonToolOverlay', () => { mockUseStageHeight.mockReturnValue(1200) mockUseZoom.mockReturnValue(2.0) - const snapResult: SnapResult = { + const snapResult: SnapResult = { + priority: 0, position: newVec2(100, 100), + distance: 10, + type: 'align', lines: [ { point: newVec2(100, 100), diff --git a/src/editor/tools/system/ToolSystemProvider.tsx b/src/editor/tools/system/ToolSystemProvider.tsx index 25f6be26..0d653da5 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 9afce10a..c3230738 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 6954cf24..254b8d46 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/inspector.json b/src/shared/i18n/locales/de/inspector.json index 4935e98b..8dfcf11e 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/de/tool.json b/src/shared/i18n/locales/de/tool.json index 68c4b532..f3ee2366 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": "Eingabe zum Abschließen der Wand", + "controlEscAbort": "Escape zum Abbrechen der Wand", + "controlEscOverride": "Escape 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/de/toolbar.json b/src/shared/i18n/locales/de/toolbar.json index 4f8b240d..c7c688a0 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/inspector.json b/src/shared/i18n/locales/en/inspector.json index f590e5f2..ff5f8262 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/en/tool.json b/src/shared/i18n/locales/en/tool.json index fb873689..b8e50bfd 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": "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", + "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" diff --git a/src/shared/i18n/locales/en/toolbar.json b/src/shared/i18n/locales/en/toolbar.json index 86f4816d..5c0ed722 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", diff --git a/src/shared/i18n/locales/fr/inspector.json b/src/shared/i18n/locales/fr/inspector.json index 956de0a0..d6344c85 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 +} diff --git a/src/shared/i18n/locales/fr/tool.json b/src/shared/i18n/locales/fr/tool.json index dfe81381..b7fe47d1 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": "Entrée pour terminer le mur", + "controlEscAbort": "Échap pour annuler le mur", + "controlEscOverride": "Échap 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" 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 91fe930e..8942d347 100644 Binary files a/tests/tools/floor-tool.spec.ts-snapshots/05-cancelled-chromium-linux.png and b/tests/tools/floor-tool.spec.ts-snapshots/05-cancelled-chromium-linux.png differ 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 25fc8c8b..864cf43c 100644 Binary files a/tests/tools/floor-tool.spec.ts-snapshots/05-cancelled-firefox-linux.png and b/tests/tools/floor-tool.spec.ts-snapshots/05-cancelled-firefox-linux.png differ 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 42826fbb..e1bd4ded 100644 Binary files a/tests/tools/floor-tool.spec.ts-snapshots/05-cancelled-webkit-linux.png and b/tests/tools/floor-tool.spec.ts-snapshots/05-cancelled-webkit-linux.png differ 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 86b221b1..b1a99d4b 100644 Binary files a/tests/tools/floor-tool.spec.ts-snapshots/08-completed-chromium-linux.png and b/tests/tools/floor-tool.spec.ts-snapshots/08-completed-chromium-linux.png differ 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 fc6d5d0c..b4461e96 100644 Binary files a/tests/tools/floor-tool.spec.ts-snapshots/08-completed-firefox-linux.png and b/tests/tools/floor-tool.spec.ts-snapshots/08-completed-firefox-linux.png differ 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 03551d97..a2b25e6e 100644 Binary files a/tests/tools/floor-tool.spec.ts-snapshots/08-completed-webkit-linux.png and b/tests/tools/floor-tool.spec.ts-snapshots/08-completed-webkit-linux.png differ diff --git a/tests/tools/move-tool.spec.ts-snapshots/17-roof-dragging-chromium-linux.png b/tests/tools/move-tool.spec.ts-snapshots/17-roof-dragging-chromium-linux.png index 61bd773a..da0372bd 100644 Binary files a/tests/tools/move-tool.spec.ts-snapshots/17-roof-dragging-chromium-linux.png and b/tests/tools/move-tool.spec.ts-snapshots/17-roof-dragging-chromium-linux.png differ diff --git a/tests/tools/move-tool.spec.ts-snapshots/17-roof-dragging-firefox-linux.png b/tests/tools/move-tool.spec.ts-snapshots/17-roof-dragging-firefox-linux.png index e5a3c6f4..33bb1157 100644 Binary files a/tests/tools/move-tool.spec.ts-snapshots/17-roof-dragging-firefox-linux.png and b/tests/tools/move-tool.spec.ts-snapshots/17-roof-dragging-firefox-linux.png differ 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 e1e55dd3..5d40abf5 100644 Binary files a/tests/tools/move-tool.spec.ts-snapshots/20-floor-opening-dragging-snap-wall-webkit-linux.png and b/tests/tools/move-tool.spec.ts-snapshots/20-floor-opening-dragging-snap-wall-webkit-linux.png differ diff --git a/tests/tools/perimeter-tool.spec.ts-snapshots/06-cancelled-chromium-linux.png b/tests/tools/perimeter-tool.spec.ts-snapshots/06-cancelled-chromium-linux.png index 4d50bfb1..635b81f1 100644 Binary files a/tests/tools/perimeter-tool.spec.ts-snapshots/06-cancelled-chromium-linux.png and b/tests/tools/perimeter-tool.spec.ts-snapshots/06-cancelled-chromium-linux.png differ 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 bd2e479b..dc5a2601 100644 Binary files a/tests/tools/perimeter-tool.spec.ts-snapshots/06-cancelled-firefox-linux.png and b/tests/tools/perimeter-tool.spec.ts-snapshots/06-cancelled-firefox-linux.png differ 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 24b82321..62ca00ea 100644 Binary files a/tests/tools/perimeter-tool.spec.ts-snapshots/06-cancelled-webkit-linux.png and b/tests/tools/perimeter-tool.spec.ts-snapshots/06-cancelled-webkit-linux.png differ 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 10b79864..9f8015b6 100644 Binary files a/tests/tools/perimeter-tool.spec.ts-snapshots/10-completed-chromium-linux.png and b/tests/tools/perimeter-tool.spec.ts-snapshots/10-completed-chromium-linux.png differ 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 f5a61ca8..aa3b3a6b 100644 Binary files a/tests/tools/perimeter-tool.spec.ts-snapshots/10-completed-firefox-linux.png and b/tests/tools/perimeter-tool.spec.ts-snapshots/10-completed-firefox-linux.png differ diff --git a/tests/tools/perimeter-tool.spec.ts-snapshots/10-completed-webkit-linux.png b/tests/tools/perimeter-tool.spec.ts-snapshots/10-completed-webkit-linux.png index 6aa3d888..9b09bf86 100644 Binary files a/tests/tools/perimeter-tool.spec.ts-snapshots/10-completed-webkit-linux.png and b/tests/tools/perimeter-tool.spec.ts-snapshots/10-completed-webkit-linux.png differ