Skip to main content

ifc_lite_geometry/space_dcel/
mod.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! # Persistent, editable space topology (DCEL)
6//!
7//! This is the Rust core of the interactive space-sketch editor. It does
8//! what the one-shot TS `auto-space-detect` pipeline does — turn a set of
9//! 2D wall-axis segments into the enclosed room faces of a floor plate —
10//! but instead of building a half-edge graph, walking it once, and
11//! **throwing it away** (returning bare `Vec<[f64;2]>` outlines), it keeps
12//! the [`SpacePlate`] alive as the authoritative topology and exposes
13//! local, O(degree) edit operations on it:
14//!
15//! - [`SpacePlate::drag_vertex`] — move a vertex; every incident face
16//!   follows in the same call because a shared wall is **one** edge whose
17//!   endpoints are shared vertices. This is the whole "pull one room, the
18//!   neighbour follows" trick, and it falls out of the structure for free.
19//! - [`SpacePlate::split_face`] — insert a partition between two vertices
20//!   of a face (subdivide a room). O(1) surgery; both children valid
21//!   immediately.
22//! - [`SpacePlate::merge_faces`] — remove a shared edge, unioning the two
23//!   rooms it separated. O(1) surgery.
24//!
25//! Every edit returns the [`FaceId`]s it touched (with recomputed outline
26//! + area) so the TS mirror can re-render only what changed.
27//!
28//! ## What this adds over the TS detector
29//!
30//! 1. **Persistence.** The DCEL survives across edits; ids are stable
31//!    (tombstoned, never reused) so a TS-side hit-test mirror can reference
32//!    a face/edge across frames.
33//! 2. **Source-element provenance.** Each input segment carries the IFC
34//!    element id it came from; that id is propagated through snapping,
35//!    T-junction resolution and intersection-splitting onto every bounding
36//!    half-edge. This is what makes `IfcRelSpaceBoundary` emission and the
37//!    leak-repair affordance possible at bake — the TS detector drops it.
38//! 3. **`prev` pointers + the outer face as a real face**, so split/merge
39//!    are pure pointer surgery with no re-walk of the whole plate.
40//!
41//! ## Conventions
42//!
43//! - Interior (room) faces wind **CCW** (signed area > 0). The unbounded
44//!   exterior winds **CW** and is flagged [`Face::is_outer`]. A plate with
45//!   several disconnected wall clusters has one outer face per component.
46//! - A half-edge's [`HalfEdge::face`] is the face on its **left**.
47//! - Holes / nested faces (a room enclosing a courtyard) are out of scope
48//!   for this prototype: every CW cycle is treated as exterior, matching
49//!   the TS detector. See the module TODO at the bottom.
50
51mod arrangement;
52mod geom2d;
53#[cfg(test)]
54mod tests;
55
56use arrangement::Arrangement;
57use geom2d::{is_simple_polygon, line_intersection, perp_distance, point_in_quad, polygon_area};
58
59/// Stable handle to a vertex. Survives edits; never reused after tombstoning.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
61pub struct VertexId(pub u32);
62
63/// Stable handle to a directed half-edge.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
65pub struct HalfEdgeId(pub u32);
66
67/// Stable handle to a face (a room, or the unbounded exterior).
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
69pub struct FaceId(pub u32);
70
71/// An input wall-axis segment tagged with the IFC element it came from.
72///
73/// `source_element` is the express id of the originating wall / divider.
74/// `None` marks a synthetic segment with no single source (rare; e.g. a
75/// user-drawn guide). It is propagated to every half-edge derived from this
76/// segment so the bake step can recover `IfcRelSpaceBoundary` links.
77#[derive(Debug, Clone, Copy)]
78pub struct InputSegment {
79    pub a: [f64; 2],
80    pub b: [f64; 2],
81    pub source_element: Option<u32>,
82    /// Half the source wall's thickness (metres). Carried onto every half-edge
83    /// derived from this segment so `net_outline` can inset each room edge by
84    /// its own wall's half-thickness — no fuzzy edge↔wall matching, unlike the
85    /// TS `offsetRoomFootprint`. `0.0` = unknown/no thickness (centreline only).
86    pub half_thickness: f64,
87}
88
89impl InputSegment {
90    pub fn new(a: [f64; 2], b: [f64; 2], source_element: Option<u32>) -> Self {
91        Self { a, b, source_element, half_thickness: 0.0 }
92    }
93
94    /// Builder: set the half-thickness (metres) carried to derived half-edges.
95    pub fn with_half_thickness(mut self, half_thickness: f64) -> Self {
96        self.half_thickness = half_thickness;
97        self
98    }
99}
100
101/// Tuning for the arrangement pass. Distances are in the same units as the
102/// input segments (the caller is expected to pre-scale to metres, exactly
103/// as `extract-walls` does on the TS side).
104#[derive(Debug, Clone, Copy)]
105pub struct BuildOptions {
106    /// Endpoints closer than this merge to one vertex. Also the radius for
107    /// snapping a dangling wall-end onto a neighbour's interior (T-junction).
108    pub snap_tolerance: f64,
109    /// Faces below this absolute area are discarded as slivers.
110    pub min_area: f64,
111}
112
113impl Default for BuildOptions {
114    fn default() -> Self {
115        // Mirrors the TS `generate-spaces` defaults.
116        Self { snap_tolerance: 0.1, min_area: 0.5 }
117    }
118}
119
120#[derive(Debug, Clone)]
121struct Vertex {
122    pos: [f64; 2],
123    /// One outgoing half-edge, or `None` once isolated/tombstoned.
124    outgoing: Option<HalfEdgeId>,
125    alive: bool,
126}
127
128#[derive(Debug, Clone)]
129struct HalfEdge {
130    origin: VertexId,
131    twin: HalfEdgeId,
132    next: HalfEdgeId,
133    prev: HalfEdgeId,
134    face: FaceId,
135    /// The IFC element this edge bounds, propagated from the input segment.
136    /// `None` for the exterior side of a boundary-less cut or a user split.
137    source_element: Option<u32>,
138    /// Half the source wall's thickness (metres); `0.0` for a user-drawn edge.
139    /// Both twins carry the same value (it's the wall, not a side).
140    half_thickness: f64,
141    alive: bool,
142}
143
144#[derive(Debug, Clone)]
145struct Face {
146    /// One boundary half-edge, or `None` once tombstoned.
147    half_edge: Option<HalfEdgeId>,
148    is_outer: bool,
149    /// Whether this bounded face is surfaced as a ROOM. Classified ONCE at build
150    /// (every bounded face for a centreline plate; only the gaps between wall
151    /// rectangles for a face-based plate) and then carried THROUGH edits — a
152    /// split inherits it, a merge ORs it. Never re-derived from geometry, so
153    /// dragging a vertex or cutting a room can't silently re-classify faces into
154    /// phantom rooms (the wall-rect centroid test was the culprit).
155    is_room: bool,
156    floor_z: f64,
157    ceiling_z: f64,
158    /// Set when the ceiling plane is inferred (e.g. pitched roof underside);
159    /// carried through edits so the sketch UI can telegraph "approximate".
160    non_planar_ceiling: bool,
161    alive: bool,
162}
163
164/// A face touched by an edit, with enough geometry for the caller to
165/// re-render it without re-querying the whole plate.
166#[derive(Debug, Clone, PartialEq)]
167pub struct FacePatch {
168    pub face: FaceId,
169    /// CCW outline; the first vertex is **not** repeated.
170    pub outline: Vec<[f64; 2]>,
171    pub area: f64,
172    /// `false` when the edit left the face self-intersecting or degenerate.
173    /// The op still applies (fluid dragging shouldn't fight the user); the
174    /// caller decides whether to telegraph the invalid state.
175    pub simple: bool,
176}
177
178/// Errors an edit op can reject with. Build never fails — a malformed input
179/// just yields fewer (or zero) interior faces, matching the TS detector.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum EditError {
182    /// A referenced id is tombstoned or out of range.
183    StaleHandle,
184    /// `split_face`: the two vertices aren't both on the target face.
185    VerticesNotOnFace,
186    /// `split_face`: the two endpoints are the same, or already adjacent on
187    /// the face (the cut would have zero area on one side).
188    DegenerateCut,
189    /// `merge_faces`: the edge borders the exterior, so there's no second
190    /// room to merge with (that's a delete, not a merge).
191    BordersExterior,
192    /// `merge_faces`: both sides are the same face — the edge is a bridge,
193    /// and removing it would change connectivity rather than union two rooms.
194    BridgeEdge,
195    /// `dissolve_vertex`: the vertex isn't a simple degree-2 node (a junction
196    /// where 3+ walls meet, or a dangling tip), so there's no unambiguous pair
197    /// of edges to weld into one.
198    VertexNotDissolvable,
199    /// `add_face`: the ring has fewer than 3 points, self-intersects, or
200    /// encloses near-zero area.
201    InvalidPolygon,
202}
203
204/// The persistent floor-plate topology. See the module docs.
205#[derive(Debug, Clone)]
206pub struct SpacePlate {
207    vertices: Vec<Vertex>,
208    half_edges: Vec<HalfEdge>,
209    faces: Vec<Face>,
210    /// Wall footprint rectangles (4 corners each), set only by the FACE-BASED
211    /// build (`build_from_wall_rects`). When non-empty, a bounded face is a ROOM
212    /// only if its centroid lies OUTSIDE every wall rectangle (it's a gap between
213    /// walls, not a wall interior). Empty for the centreline build, where every
214    /// non-outer bounded face is a room.
215    wall_rects: Vec<[[f64; 2]; 4]>,
216}
217
218const EPS: f64 = 1e-9;
219/// Perpendicular-distance threshold (metres) for "this degree-2 node is a
220/// redundant collinear point on a straight run" — used by `prune_orphans` to
221/// dissolve derivation cruft. Far below the wall snap tolerance (default 0.1 m)
222/// so genuine corners are never mistaken for collinear and dissolved away.
223const EPS_COLL: f64 = 1e-6;
224
225impl SpacePlate {
226    // ───────────────────────── construction ─────────────────────────
227
228    /// Build the plate from tagged wall-axis segments.
229    ///
230    /// Pipeline (ported from `auto-space-detect.ts`, provenance added):
231    /// 1. snap endpoints onto a spatial-hash grid,
232    /// 2. snap dangling endpoints onto nearby edge interiors (T-junctions),
233    /// 3. resolve interior crossings, splitting both hosts,
234    /// 4. dedupe undirected edges,
235    /// 5. build the half-edge graph with angle-sorted vertex fans,
236    /// 6. assign `next`/`prev` by the leftmost-turn rule and walk faces,
237    /// 7. flag CW cycles as exterior and drop sub-`min_area` rooms.
238    pub fn build(segments: &[InputSegment], options: BuildOptions) -> Self {
239        let arr = Arrangement::resolve(segments, options.snap_tolerance);
240        let mut plate = Self::from_arrangement(arr, options.min_area);
241        // The arrangement is non-destructive, so it can carry cruft that bounds
242        // no room — dangling spur walls and the redundant collinear nodes they
243        // leave behind. Clean them so a derived plate starts as just its rooms
244        // (a no-op on already-clean inputs; never changes a room's area).
245        plate.prune_orphans();
246        plate
247    }
248
249    /// FACE-BASED build → an editable CENTRELINE plate sitting on the wall axes.
250    ///
251    /// Two stages. (1) DETECT: arrange the wall footprint rectangle edges and keep
252    /// each bounded face whose centroid lies OUTSIDE every rectangle — i.e. a GAP
253    /// between walls (not a wall interior or a junction overlap). This locates the
254    /// rooms accurately, with the room boundary literally on the rendered wall
255    /// faces (no centroid-derived-centreline drift). (2) LIFT: take each gap room's
256    /// wall-AXIS outline (the net gap offset out by ½ thickness, corners
257    /// re-intersected) and arrange THOSE edges into the returned plate.
258    ///
259    /// The returned plate is therefore a normal centreline plate whose room
260    /// outlines ARE the wall axes and whose vertices ARE the displayed nodes — so
261    /// every editing op (drag / split / merge / dissolve) acts directly on what the
262    /// user sees, with no axis-vs-face offset. Each axis edge carries its wall's
263    /// half-thickness, so `net_outline` recovers the inner (net) and outer (gross)
264    /// faces. Adjacent rooms' shared wall maps to one shared centreline edge.
265    pub fn build_from_wall_rects(rects: &[[[f64; 2]; 4]], options: BuildOptions) -> Self {
266        // --- Stage 1: detect rooms as the gaps between wall rectangles. ---
267        let mut rect_edges: Vec<InputSegment> = Vec::with_capacity(rects.len() * 4);
268        for (wi, r) in rects.iter().enumerate() {
269            // Wall thickness = the rectangle's shorter side (faces are the long pair).
270            let side = |a: [f64; 2], b: [f64; 2]| ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt();
271            let half = side(r[0], r[1]).min(side(r[1], r[2])) / 2.0;
272            let src = Some(wi as u32);
273            for i in 0..4 {
274                rect_edges.push(InputSegment::new(r[i], r[(i + 1) % 4], src).with_half_thickness(half));
275            }
276        }
277        let mut gap = Self::from_arrangement(Arrangement::resolve(&rect_edges, options.snap_tolerance), options.min_area);
278        gap.wall_rects = rects.to_vec();
279
280        // --- Stage 2: lift each gap room to its wall-axis outline and re-arrange. ---
281        let mut axis_edges: Vec<InputSegment> = Vec::new();
282        for i in 0..gap.faces.len() {
283            let f = FaceId(i as u32);
284            if gap.faces[i].is_outer || !gap.is_gap_face(f) {
285                continue;
286            }
287            let axis = gap.gap_boundary(f, 1.0); // net gap → wall axis (½ thickness out)
288            let cycle: Vec<HalfEdgeId> = gap.face_half_edges(f).collect();
289            if axis.len() < 3 || axis.len() != cycle.len() {
290                continue;
291            }
292            for k in 0..axis.len() {
293                let he = &gap.half_edges[cycle[k].0 as usize];
294                axis_edges.push(
295                    InputSegment::new(axis[k], axis[(k + 1) % axis.len()], he.source_element)
296                        .with_half_thickness(he.half_thickness),
297                );
298            }
299        }
300        // Fallback: if the lift produced nothing usable (degenerate input), return
301        // the gap plate as-is so the caller still gets rooms.
302        if axis_edges.is_empty() {
303            for i in 0..gap.faces.len() {
304                let f = FaceId(i as u32);
305                gap.faces[i].is_room = !gap.faces[i].is_outer && gap.is_gap_face(f);
306            }
307            return gap;
308        }
309        // The returned plate has no `wall_rects`, so `is_room` defaults to every
310        // bounded face — exactly the lifted axis rooms.
311        Self::from_arrangement(Arrangement::resolve(&axis_edges, options.snap_tolerance), options.min_area)
312    }
313
314    fn from_arrangement(arr: Arrangement, min_area: f64) -> Self {
315        let mut plate = SpacePlate {
316            vertices: arr
317                .vertices
318                .iter()
319                .map(|&pos| Vertex { pos, outgoing: None, alive: true })
320                .collect(),
321            half_edges: Vec::with_capacity(arr.edges.len() * 2),
322            faces: Vec::new(),
323            wall_rects: Vec::new(),
324        };
325
326        // Two half-edges per undirected edge, twinned. Record outgoing fans.
327        let mut fans: Vec<Vec<(HalfEdgeId, f64)>> = vec![Vec::new(); arr.vertices.len()];
328        for e in &arr.edges {
329            let (a, b, src, ht) = (e.a, e.b, e.source, e.half_thickness);
330            let pa = arr.vertices[a];
331            let pb = arr.vertices[b];
332            let fwd = HalfEdgeId(plate.half_edges.len() as u32);
333            let bwd = HalfEdgeId(plate.half_edges.len() as u32 + 1);
334            // Placeholder next/prev/face — patched after fans are sorted.
335            plate.half_edges.push(HalfEdge {
336                origin: VertexId(a as u32),
337                twin: bwd,
338                next: fwd,
339                prev: fwd,
340                face: FaceId(0),
341                source_element: src,
342                half_thickness: ht,
343                alive: true,
344            });
345            plate.half_edges.push(HalfEdge {
346                origin: VertexId(b as u32),
347                twin: fwd,
348                next: bwd,
349                prev: bwd,
350                face: FaceId(0),
351                source_element: src,
352                half_thickness: ht,
353                alive: true,
354            });
355            fans[a].push((fwd, (pb[1] - pa[1]).atan2(pb[0] - pa[0])));
356            fans[b].push((bwd, (pa[1] - pb[1]).atan2(pa[0] - pb[0])));
357        }
358
359        for (v, fan) in fans.iter_mut().enumerate() {
360            // total_cmp (not partial_cmp/unwrap) so any NaN angle from a
361            // degenerate/coincident segment sorts deterministically rather
362            // than corrupting the next/prev wiring nondeterministically.
363            fan.sort_by(|p, q| p.1.total_cmp(&q.1));
364            plate.vertices[v].outgoing = fan.first().map(|(h, _)| *h);
365        }
366
367        // `next`: after arriving along `e` at vertex v = dest(e), leave on the
368        // clockwise neighbour of `e.twin` in v's angular fan. That yields a
369        // CCW interior walk. `prev` is the inverse.
370        for he in 0..plate.half_edges.len() {
371            let h = HalfEdgeId(he as u32);
372            let dest = plate.dest(h);
373            let twin = plate.half_edges[he].twin;
374            let fan = &fans[dest.0 as usize];
375            let idx = fan.iter().position(|(e, _)| *e == twin);
376            let Some(idx) = idx else { continue }; // structurally impossible
377            let nxt = fan[(idx + fan.len() - 1) % fan.len()].0;
378            plate.half_edges[he].next = nxt;
379            plate.half_edges[nxt.0 as usize].prev = h;
380        }
381
382        // Walk cycles → faces. CCW (signed > 0) = room; CW = exterior.
383        let mut visited = vec![false; plate.half_edges.len()];
384        for start in 0..plate.half_edges.len() {
385            if visited[start] {
386                continue;
387            }
388            let mut cycle = Vec::new();
389            let mut cur = HalfEdgeId(start as u32);
390            loop {
391                if visited[cur.0 as usize] {
392                    break;
393                }
394                visited[cur.0 as usize] = true;
395                cycle.push(cur);
396                cur = plate.half_edges[cur.0 as usize].next;
397                if cur.0 as usize == start {
398                    break;
399                }
400            }
401            let signed = plate.signed_area_of_cycle(&cycle);
402            let is_outer = signed <= 0.0;
403            // Drop sub-min-area rooms by folding them into no face: we still
404            // need a face record (half-edges must point somewhere), so we
405            // keep the cycle but flag tiny CCW faces as outer so they're not
406            // surfaced as rooms. Exterior cycles are always kept as outer.
407            let too_small = !is_outer && signed.abs() < min_area;
408            let face = FaceId(plate.faces.len() as u32);
409            plate.faces.push(Face {
410                half_edge: cycle.first().copied(),
411                is_outer: is_outer || too_small,
412                // Centreline default: every bounded, non-tiny face is a room.
413                // `build_from_wall_rects` re-classifies to gaps-only afterwards.
414                is_room: !(is_outer || too_small),
415                floor_z: 0.0,
416                ceiling_z: 0.0,
417                non_planar_ceiling: false,
418                alive: true,
419            });
420            for h in cycle {
421                plate.half_edges[h.0 as usize].face = face;
422            }
423        }
424
425        plate
426    }
427
428    // ───────────────────────────── edits ─────────────────────────────
429
430    /// Move `v` to `(x, y)`. Topology is untouched; every incident face is
431    /// updated by construction (they all reference this one vertex). Returns
432    /// a patch per incident *room* face — the same call updates a room and
433    /// its neighbour across a shared wall.
434    pub fn drag_vertex(&mut self, v: VertexId, x: f64, y: f64) -> Result<Vec<FacePatch>, EditError> {
435        let idx = v.0 as usize;
436        if idx >= self.vertices.len() || !self.vertices[idx].alive {
437            return Err(EditError::StaleHandle);
438        }
439        self.vertices[idx].pos = [x, y];
440        let mut faces: Vec<FaceId> = self
441            .outgoing_half_edges(v)
442            .map(|h| self.half_edges[h.0 as usize].face)
443            .collect();
444        faces.sort();
445        faces.dedup();
446        Ok(faces
447            .into_iter()
448            .filter(|f| !self.faces[f.0 as usize].is_outer)
449            .map(|f| self.face_patch(f))
450            .collect())
451    }
452
453    /// Subdivide `face` by inserting a partition edge between two of its
454    /// vertices. Returns patches for the kept face and the new face.
455    ///
456    /// The new edge carries `source_element` (`None` = a brand-new partition
457    /// the user drew, which the bake step materialises as a fresh wall or a
458    /// virtual boundary).
459    pub fn split_face(
460        &mut self,
461        face: FaceId,
462        va: VertexId,
463        vb: VertexId,
464        source_element: Option<u32>,
465    ) -> Result<Vec<FacePatch>, EditError> {
466        self.check_face(face)?;
467        if va == vb {
468            return Err(EditError::DegenerateCut);
469        }
470        // Find the boundary half-edges of `face` leaving va and vb.
471        let mut ha = None;
472        let mut hb = None;
473        for h in self.face_half_edges(face) {
474            let o = self.half_edges[h.0 as usize].origin;
475            if o == va {
476                ha = Some(h);
477            }
478            if o == vb {
479                hb = Some(h);
480            }
481        }
482        let (ha, hb) = match (ha, hb) {
483            (Some(a), Some(b)) => (a, b),
484            _ => return Err(EditError::VerticesNotOnFace),
485        };
486        // Adjacent on the face → one side has zero area. Reject.
487        if self.half_edges[ha.0 as usize].next == hb
488            || self.half_edges[hb.0 as usize].next == ha
489        {
490            return Err(EditError::DegenerateCut);
491        }
492
493        let pa_prev = self.half_edges[ha.0 as usize].prev;
494        let pb_prev = self.half_edges[hb.0 as usize].prev;
495
496        // New twin pair: e_ab (va→vb) keeps `face`; e_ba (vb→va) gets a new face.
497        let e_ab = HalfEdgeId(self.half_edges.len() as u32);
498        let e_ba = HalfEdgeId(self.half_edges.len() as u32 + 1);
499        let new_face = FaceId(self.faces.len() as u32);
500
501        self.half_edges.push(HalfEdge {
502            origin: va,
503            twin: e_ba,
504            next: hb,
505            prev: pa_prev,
506            face,
507            source_element,
508            half_thickness: 0.0, // a user-drawn partition has no wall thickness yet
509            alive: true,
510        });
511        self.half_edges.push(HalfEdge {
512            origin: vb,
513            twin: e_ab,
514            next: ha,
515            prev: pb_prev,
516            face: new_face,
517            source_element,
518            half_thickness: 0.0,
519            alive: true,
520        });
521
522        // Rewire the four neighbours around the new diagonal.
523        self.half_edges[pa_prev.0 as usize].next = e_ab;
524        self.half_edges[hb.0 as usize].prev = e_ab;
525        self.half_edges[pb_prev.0 as usize].next = e_ba;
526        self.half_edges[ha.0 as usize].prev = e_ba;
527
528        // Kept face anchors on e_ab; new face owns e_ba's cycle.
529        self.faces[face.0 as usize].half_edge = Some(e_ab);
530        let parent = self.faces[face.0 as usize].clone();
531        self.faces.push(Face {
532            half_edge: Some(e_ba),
533            is_outer: false,
534            is_room: parent.is_room, // both halves of a split stay rooms
535            floor_z: parent.floor_z,
536            ceiling_z: parent.ceiling_z,
537            non_planar_ceiling: parent.non_planar_ceiling,
538            alive: true,
539        });
540        // Re-home the new face's cycle (collect first — the walk borrows
541        // `self`, the assignment mutates it).
542        let new_cycle: Vec<HalfEdgeId> = self.face_half_edges(new_face).collect();
543        for h in new_cycle {
544            self.half_edges[h.0 as usize].face = new_face;
545        }
546
547        Ok(vec![self.face_patch(face), self.face_patch(new_face)])
548    }
549
550    /// Insert a new vertex at `(x, y)` on the undirected edge `edge` (and its
551    /// twin), subdividing it. No face is created — both incident faces simply
552    /// gain a boundary vertex. Returns the new vertex so the caller can use it
553    /// as a `split_face` endpoint. Pass a point on the edge segment to keep
554    /// areas unchanged (the caller projects the click onto the edge).
555    ///
556    /// This is what lets the user add a node where there wasn't a corner, so a
557    /// partition can start/end mid-wall rather than only at existing vertices.
558    pub fn split_edge(&mut self, edge: HalfEdgeId, x: f64, y: f64) -> Result<VertexId, EditError> {
559        let h = edge;
560        if h.0 as usize >= self.half_edges.len() || !self.half_edges[h.0 as usize].alive {
561            return Err(EditError::StaleHandle);
562        }
563        let t = self.half_edges[h.0 as usize].twin;
564        let f1 = self.half_edges[h.0 as usize].face;
565        let f2 = self.half_edges[t.0 as usize].face;
566        let h_next = self.half_edges[h.0 as usize].next;
567        let t_next = self.half_edges[t.0 as usize].next;
568        let h_src = self.half_edges[h.0 as usize].source_element;
569        let t_src = self.half_edges[t.0 as usize].source_element;
570        // The two new halves continue the same wall → inherit its thickness.
571        let h_ht = self.half_edges[h.0 as usize].half_thickness;
572        let t_ht = self.half_edges[t.0 as usize].half_thickness;
573
574        let n = VertexId(self.vertices.len() as u32);
575        let e1 = HalfEdgeId(self.half_edges.len() as u32); // N → B (was dest of h)
576        let e2 = HalfEdgeId(self.half_edges.len() as u32 + 1); // N → A (was dest of t)
577
578        self.vertices.push(Vertex { pos: [x, y], outgoing: Some(e1), alive: true });
579        self.half_edges.push(HalfEdge {
580            origin: n, twin: t, next: h_next, prev: h, face: f1, source_element: h_src, half_thickness: h_ht, alive: true,
581        });
582        self.half_edges.push(HalfEdge {
583            origin: n, twin: h, next: t_next, prev: t, face: f2, source_element: t_src, half_thickness: t_ht, alive: true,
584        });
585
586        // h becomes A→N; t becomes B→N. Their twins/nexts re-point through N.
587        self.half_edges[h.0 as usize].twin = e2;
588        self.half_edges[h.0 as usize].next = e1;
589        self.half_edges[t.0 as usize].twin = e1;
590        self.half_edges[t.0 as usize].next = e2;
591        self.half_edges[h_next.0 as usize].prev = e1;
592        self.half_edges[t_next.0 as usize].prev = e2;
593
594        Ok(n)
595    }
596
597    /// Remove the shared edge `edge` (and its twin), unioning the two rooms
598    /// it separated into one. Returns a patch for the surviving face.
599    pub fn merge_faces(&mut self, edge: HalfEdgeId) -> Result<Vec<FacePatch>, EditError> {
600        let h = edge;
601        if h.0 as usize >= self.half_edges.len() || !self.half_edges[h.0 as usize].alive {
602            return Err(EditError::StaleHandle);
603        }
604        let t = self.half_edges[h.0 as usize].twin;
605        let f_keep = self.half_edges[h.0 as usize].face;
606        let f_drop = self.half_edges[t.0 as usize].face;
607        if self.faces[f_keep.0 as usize].is_outer || self.faces[f_drop.0 as usize].is_outer {
608            return Err(EditError::BordersExterior);
609        }
610        if f_keep == f_drop {
611            return Err(EditError::BridgeEdge);
612        }
613
614        let (hn, hp) = {
615            let he = &self.half_edges[h.0 as usize];
616            (he.next, he.prev)
617        };
618        let (tn, tp) = {
619            let te = &self.half_edges[t.0 as usize];
620            (te.next, te.prev)
621        };
622
623        // Bypass both half-edges of the shared wall.
624        self.half_edges[hp.0 as usize].next = tn;
625        self.half_edges[tn.0 as usize].prev = hp;
626        self.half_edges[tp.0 as usize].next = hn;
627        self.half_edges[hn.0 as usize].prev = tp;
628
629        // The merged face is a room if either side was.
630        self.faces[f_keep.0 as usize].is_room |= self.faces[f_drop.0 as usize].is_room;
631        // Re-home f_drop's loop onto f_keep, then tombstone f_drop + the edge.
632        self.faces[f_keep.0 as usize].half_edge = Some(hp);
633        let merged_cycle: Vec<HalfEdgeId> = self.face_half_edges(f_keep).collect();
634        for he in merged_cycle {
635            self.half_edges[he.0 as usize].face = f_keep;
636        }
637        self.faces[f_drop.0 as usize].alive = false;
638        self.faces[f_drop.0 as usize].half_edge = None;
639
640        // Detach the removed half-edges from their endpoints' outgoing slot,
641        // tombstone them, and drop any vertex left isolated.
642        for (he, origin) in [(h, self.half_edges[h.0 as usize].origin), (t, self.half_edges[t.0 as usize].origin)] {
643            self.half_edges[he.0 as usize].alive = false;
644            self.repair_vertex_outgoing(origin, he);
645        }
646
647        Ok(vec![self.face_patch(f_keep)])
648    }
649
650    /// Dissolve a **degree-2** vertex `v`, welding its two incident edges into
651    /// one straight edge between its neighbours — the inverse of `split_edge`,
652    /// and the "delete this corner / errant node" affordance. Both incident
653    /// faces lose a boundary vertex; if `v` was a real corner the faces change
654    /// shape (the edge becomes the straight chord A→B), which is the intended
655    /// edit. Returns a patch per incident *room* face.
656    ///
657    /// Rejects:
658    /// - a vertex whose degree isn't exactly 2 — a wall junction or dangling
659    ///   tip has no unambiguous edge pair to merge (`VertexNotDissolvable`);
660    /// - a weld whose two neighbours are already directly joined, which would
661    ///   make a parallel edge / collapse a triangle to a digon (`DegenerateCut`).
662    pub fn dissolve_vertex(&mut self, v: VertexId) -> Result<Vec<FacePatch>, EditError> {
663        let vi = v.0 as usize;
664        if vi >= self.vertices.len() || !self.vertices[vi].alive {
665            return Err(EditError::StaleHandle);
666        }
667        // Degree must be exactly 2 (two live outgoing half-edges).
668        let outs: Vec<HalfEdgeId> = self.outgoing_half_edges(v).collect();
669        if outs.len() != 2 {
670            return Err(EditError::VertexNotDissolvable);
671        }
672        let (o1, o2) = (outs[0], outs[1]); // v→X, v→Y
673        let t1 = self.half_edges[o1.0 as usize].twin; // X→v — kept, becomes X→Y
674        let t2 = self.half_edges[o2.0 as usize].twin; // Y→v — kept, becomes Y→X
675        let x = self.dest(o1);
676        let y = self.dest(o2);
677        if x == y {
678            return Err(EditError::DegenerateCut); // both edges to one neighbour (digon)
679        }
680        // Welding X and Y when they already share an edge would duplicate it.
681        if self.outgoing_half_edges(x).any(|h| self.dest(h) == y) {
682            return Err(EditError::DegenerateCut);
683        }
684        // Degree-2 invariant: the edge arriving at v before o1 (in o1's face) is
685        // t2, and symmetrically prev(o2)==t1. If this doesn't hold one edge is a
686        // dangling antenna — bail rather than corrupt the rotation.
687        if self.half_edges[o1.0 as usize].prev != t2 || self.half_edges[o2.0 as usize].prev != t1 {
688            return Err(EditError::VertexNotDissolvable);
689        }
690        let fa = self.half_edges[t2.0 as usize].face; // face that saw Y→v→X
691        let fb = self.half_edges[t1.0 as usize].face; // face that saw X→v→Y
692        let qa = self.half_edges[o1.0 as usize].next; // what followed o1 in fa
693        let qb = self.half_edges[o2.0 as usize].next; // what followed o2 in fb
694
695        // The welded X↔Y edge only carries a wall provenance when BOTH survivors
696        // agreed on one — otherwise it spans two different source walls and must
697        // drop to `None`, so we don't emit a stale IfcRelSpaceBoundary link.
698        let welded_source = if self.half_edges[t1.0 as usize].source_element
699            == self.half_edges[t2.0 as usize].source_element
700        {
701            self.half_edges[t1.0 as usize].source_element
702        } else {
703            None
704        };
705
706        // Re-twin the survivors into one undirected edge X↔Y, splicing out v.
707        self.half_edges[t1.0 as usize].twin = t2; // t1 now X→Y
708        self.half_edges[t1.0 as usize].source_element = welded_source;
709        self.half_edges[t1.0 as usize].next = qb;
710        self.half_edges[qb.0 as usize].prev = t1;
711        self.half_edges[t2.0 as usize].twin = t1; // t2 now Y→X
712        self.half_edges[t2.0 as usize].source_element = welded_source;
713        self.half_edges[t2.0 as usize].next = qa;
714        self.half_edges[qa.0 as usize].prev = t2;
715
716        // Tombstone the two v-originating half-edges and v itself.
717        self.half_edges[o1.0 as usize].alive = false;
718        self.half_edges[o2.0 as usize].alive = false;
719        self.vertices[vi].outgoing = None;
720        self.vertices[vi].alive = false;
721
722        // A face anchor may have pointed at a now-dead half-edge.
723        for (face, keep) in [(fa, t2), (fb, t1)] {
724            let anchor = self.faces[face.0 as usize].half_edge;
725            if anchor == Some(o1) || anchor == Some(o2) {
726                self.faces[face.0 as usize].half_edge = Some(keep);
727            }
728        }
729        // X keeps its survivor t1 (origin X) and Y keeps t2 (origin Y); their
730        // outgoing slots could only have referenced o1/o2 via v, now gone.
731
732        let mut faces = vec![fa, fb];
733        faces.sort();
734        faces.dedup();
735        Ok(faces
736            .into_iter()
737            .filter(|f| !self.faces[f.0 as usize].is_outer)
738            .map(|f| self.face_patch(f))
739            .collect())
740    }
741
742    /// Live degree of a vertex (its number of live outgoing half-edges).
743    fn vertex_degree(&self, v: VertexId) -> usize {
744        let vi = v.0 as usize;
745        if vi >= self.vertices.len() || !self.vertices[vi].alive {
746            return 0;
747        }
748        self.outgoing_half_edges(v).count()
749    }
750
751    /// Remove the undirected edge of a **degree-1 spur tip** — a dangling wall
752    /// poking into a face — splicing the face cycle closed and tombstoning the
753    /// tip. `spur_he` may be either half-edge of the spur. Internal; driven by
754    /// `prune_orphans` / `remove_edge`. Area-neutral: the tip's out-and-back
755    /// boundary contributes cancelling shoelace terms, so no face area changes.
756    fn remove_spur_edge(&mut self, spur_he: HalfEdgeId) -> Result<(), EditError> {
757        let hi = spur_he.0 as usize;
758        if hi >= self.half_edges.len() || !self.half_edges[hi].alive {
759            return Err(EditError::StaleHandle);
760        }
761        let t = self.half_edges[hi].twin;
762        // Orient so `s = T→J` (origin is the degree-1 tip) and `s_t = J→T`.
763        let (s, s_t) = if self.vertex_degree(self.half_edges[hi].origin) == 1 {
764            (spur_he, t)
765        } else if self.vertex_degree(self.half_edges[t.0 as usize].origin) == 1 {
766            (t, spur_he)
767        } else {
768            return Err(EditError::VertexNotDissolvable); // neither end is a tip
769        };
770        let tip = self.half_edges[s.0 as usize].origin;
771        let j = self.half_edges[s_t.0 as usize].origin;
772        let f = self.half_edges[s.0 as usize].face;
773        // A genuine tip is a peninsula: both half-edges share one face and the
774        // rotation at the tip is the out-and-back pattern. Else it's corrupt.
775        if self.half_edges[s_t.0 as usize].face != f
776            || self.half_edges[s_t.0 as usize].next != s
777            || self.half_edges[s.0 as usize].prev != s_t
778        {
779            return Err(EditError::StaleHandle);
780        }
781        let a = self.half_edges[s_t.0 as usize].prev; // ends at J
782        let b = self.half_edges[s.0 as usize].next; // starts at J
783
784        if a == s {
785            // Lone stick: J is degree-1 too — the whole 2-vertex component is just
786            // this edge bounding one outer face. Tombstone the lot.
787            if !self.faces[f.0 as usize].is_outer {
788                return Err(EditError::StaleHandle); // a lone stick can't bound a room
789            }
790            self.half_edges[s.0 as usize].alive = false;
791            self.half_edges[s_t.0 as usize].alive = false;
792            self.vertices[tip.0 as usize].outgoing = None;
793            self.vertices[tip.0 as usize].alive = false;
794            self.vertices[j.0 as usize].outgoing = None;
795            self.vertices[j.0 as usize].alive = false;
796            self.faces[f.0 as usize].alive = false;
797            self.faces[f.0 as usize].half_edge = None;
798            return Ok(());
799        }
800
801        // Splice the spur out of F's cycle: A → B directly.
802        self.half_edges[a.0 as usize].next = b;
803        self.half_edges[b.0 as usize].prev = a;
804        self.half_edges[s.0 as usize].alive = false;
805        self.half_edges[s_t.0 as usize].alive = false;
806        self.vertices[tip.0 as usize].outgoing = None;
807        self.vertices[tip.0 as usize].alive = false;
808        self.repair_vertex_outgoing(j, s_t);
809        if matches!(self.faces[f.0 as usize].half_edge, Some(h) if h == s || h == s_t) {
810            self.faces[f.0 as usize].half_edge = Some(a);
811        }
812        Ok(())
813    }
814
815    /// Remove all orphaned cruft the wall arrangement leaves behind: dangling
816    /// spur walls (degree-1 chains), isolated vertices, and redundant collinear
817    /// degree-2 nodes. Idempotent, and never changes a room's area (spurs bound
818    /// no room; collinear dissolve only straightens a node already on its chord).
819    /// Returns how many topology elements were pruned.
820    pub fn prune_orphans(&mut self) -> usize {
821        let mut removed = 0usize;
822        // Phase A — spur sweep to a fixpoint (chews whole chains).
823        loop {
824            let tips: Vec<VertexId> = (0..self.vertices.len())
825                .map(|i| VertexId(i as u32))
826                .filter(|&v| self.vertex_degree(v) == 1)
827                .collect();
828            if tips.is_empty() {
829                break;
830            }
831            for tip in tips {
832                if self.vertex_degree(tip) != 1 {
833                    continue; // a sibling removal already changed it
834                }
835                let s = self.outgoing_half_edges(tip).next();
836                if let Some(s) = s {
837                    if self.remove_spur_edge(s).is_ok() {
838                        removed += 1;
839                    }
840                }
841            }
842        }
843        // Phase B — drop leftover degree-0 (isolated) vertices.
844        for i in 0..self.vertices.len() {
845            let v = VertexId(i as u32);
846            if self.vertices[i].alive && self.vertex_degree(v) == 0 {
847                self.vertices[i].alive = false;
848                self.vertices[i].outgoing = None;
849                removed += 1;
850            }
851        }
852        // Phase C — dissolve redundant collinear degree-2 nodes (fixpoint).
853        loop {
854            let mut progress = false;
855            let cands: Vec<VertexId> = (0..self.vertices.len())
856                .map(|i| VertexId(i as u32))
857                .filter(|&v| self.vertex_degree(v) == 2)
858                .collect();
859            for v in cands {
860                if self.vertex_degree(v) != 2 {
861                    continue;
862                }
863                let outs: Vec<HalfEdgeId> = self.outgoing_half_edges(v).collect();
864                let p = self.vertices[v.0 as usize].pos;
865                let x = self.vertices[self.dest(outs[0]).0 as usize].pos;
866                let y = self.vertices[self.dest(outs[1]).0 as usize].pos;
867                if perp_distance(p, x, y) >= EPS_COLL {
868                    continue; // a genuine corner — keep it
869                }
870                if self.dissolve_vertex(v).is_ok() {
871                    removed += 1;
872                    progress = true;
873                }
874            }
875            if !progress {
876                break;
877            }
878        }
879        removed
880    }
881
882    /// Remove the wall `edge`, choosing the right semantics from its two
883    /// incident faces, and auto-clean the orphans it leaves:
884    /// - room ↔ room → union the two rooms (`merge_faces`);
885    /// - bridge (same face both sides) or outer ↔ outer → delete it + `prune_orphans`;
886    /// - room ↔ outer (a real enclosing wall) → `BordersExterior` (don't open a room).
887    pub fn remove_edge(&mut self, edge: HalfEdgeId) -> Result<Vec<FacePatch>, EditError> {
888        let hi = edge.0 as usize;
889        if hi >= self.half_edges.len() || !self.half_edges[hi].alive {
890            return Err(EditError::StaleHandle);
891        }
892        let t = self.half_edges[hi].twin;
893        let f_keep = self.half_edges[hi].face;
894        let f_drop = self.half_edges[t.0 as usize].face;
895        let keep_outer = self.faces[f_keep.0 as usize].is_outer;
896        let drop_outer = self.faces[f_drop.0 as usize].is_outer;
897
898        if f_keep != f_drop && !keep_outer && !drop_outer {
899            return self.merge_faces(edge); // two real rooms → union
900        }
901        if f_keep != f_drop && keep_outer != drop_outer {
902            return Err(EditError::BordersExterior); // would open a room
903        }
904
905        // Bridge (f_keep == f_drop) or outer ↔ outer → delete + clean.
906        let hn = self.half_edges[hi].next;
907        let hp = self.half_edges[hi].prev;
908        let tn = self.half_edges[t.0 as usize].next;
909        let tp = self.half_edges[t.0 as usize].prev;
910        let oh = self.half_edges[hi].origin;
911        let ot = self.half_edges[t.0 as usize].origin;
912
913        self.half_edges[hp.0 as usize].next = tn;
914        self.half_edges[tn.0 as usize].prev = hp;
915        self.half_edges[tp.0 as usize].next = hn;
916        self.half_edges[hn.0 as usize].prev = tp;
917
918        if f_drop != f_keep {
919            // outer ↔ outer: fold f_drop's loop into f_keep.
920            self.faces[f_keep.0 as usize].half_edge = Some(hp);
921            let merged: Vec<HalfEdgeId> = self.face_half_edges(f_keep).collect();
922            for he in merged {
923                self.half_edges[he.0 as usize].face = f_keep;
924            }
925            self.faces[f_drop.0 as usize].alive = false;
926            self.faces[f_drop.0 as usize].half_edge = None;
927        }
928        self.half_edges[hi].alive = false;
929        self.half_edges[t.0 as usize].alive = false;
930        self.repair_vertex_outgoing(oh, edge);
931        self.repair_vertex_outgoing(ot, t);
932        // The face's anchor may have been one of the removed half-edges (esp. a
933        // bridge / spur in the outer face) — re-point it at a survivor.
934        self.reanchor_face_if_dead(f_keep);
935
936        self.prune_orphans();
937
938        let mut out = Vec::new();
939        if self.faces[f_keep.0 as usize].alive && !self.faces[f_keep.0 as usize].is_outer {
940            out.push(self.face_patch(f_keep));
941        }
942        Ok(out)
943    }
944
945    /// Author a brand-new room from a closed ring of points — the "draw a
946    /// room" affordance. The polygon becomes its **own** connected component:
947    /// a CCW interior room face plus the CW exterior face bounding it. It does
948    /// NOT merge into existing topology (there's no arrangement overlay), so
949    /// drawing over an existing room leaves two independent components — the
950    /// documented prototype limitation.
951    ///
952    /// `points` is the ring with no repeated closing vertex; winding is
953    /// normalised to CCW. Rejects a ring that is too short, self-intersecting,
954    /// or near-zero area (`InvalidPolygon`). Returns the new room's patch.
955    pub fn add_face(&mut self, points: &[[f64; 2]], source_element: Option<u32>) -> Result<FacePatch, EditError> {
956        if points.len() < 3 || !is_simple_polygon(points) {
957            return Err(EditError::InvalidPolygon);
958        }
959        // Reject consecutive coincident points, including the closing wrap. A
960        // zero-length edge slips past `is_simple_polygon` (its segment-cross test
961        // treats a degenerate segment as parallel), so a ring like [A, A, B, C]
962        // has non-zero area yet would persist a duplicate vertex + zero-length
963        // half-edge — malformed for later editing/offsetting/baking. Reachable
964        // from the draw UI (e.g. a double-click landing the final corner twice).
965        let n = points.len();
966        for i in 0..n {
967            let a = points[i];
968            let b = points[(i + 1) % n];
969            if (a[0] - b[0]).abs() < EPS && (a[1] - b[1]).abs() < EPS {
970                return Err(EditError::InvalidPolygon);
971            }
972        }
973        let signed = polygon_area(points);
974        if signed.abs() < EPS {
975            return Err(EditError::InvalidPolygon);
976        }
977        // Normalise to CCW so the interior winds positive (room on the left).
978        let ring: Vec<[f64; 2]> =
979            if signed > 0.0 { points.to_vec() } else { points.iter().rev().copied().collect() };
980        let nn = ring.len() as u32;
981
982        let v0 = self.vertices.len() as u32; // first new vertex id
983        let h0 = self.half_edges.len() as u32; // first interior half-edge id
984        let g0 = h0 + nn; // first exterior (twin) half-edge id
985        let room = FaceId(self.faces.len() as u32);
986        let outer = FaceId(self.faces.len() as u32 + 1);
987
988        for (i, &p) in ring.iter().enumerate() {
989            self.vertices.push(Vertex { pos: p, outgoing: Some(HalfEdgeId(h0 + i as u32)), alive: true });
990        }
991        // Interior half-edges h_i: v_i → v_{i+1}, CCW, room on the left.
992        for i in 0..nn {
993            self.half_edges.push(HalfEdge {
994                origin: VertexId(v0 + i),
995                twin: HalfEdgeId(g0 + i),
996                next: HalfEdgeId(h0 + (i + 1) % nn),
997                prev: HalfEdgeId(h0 + (i + nn - 1) % nn),
998                face: room,
999                source_element,
1000                half_thickness: 0.0, // a drawn room has no source wall thickness
1001                alive: true,
1002            });
1003        }
1004        // Exterior twins g_i: v_{i+1} → v_i, winding CW around the room.
1005        for i in 0..nn {
1006            self.half_edges.push(HalfEdge {
1007                origin: VertexId(v0 + (i + 1) % nn),
1008                twin: HalfEdgeId(h0 + i),
1009                next: HalfEdgeId(g0 + (i + nn - 1) % nn),
1010                prev: HalfEdgeId(g0 + (i + 1) % nn),
1011                face: outer,
1012                source_element,
1013                half_thickness: 0.0,
1014                alive: true,
1015            });
1016        }
1017        self.faces.push(Face {
1018            half_edge: Some(HalfEdgeId(h0)),
1019            is_outer: false,
1020            is_room: true, // a user-drawn partition is a room
1021            floor_z: 0.0,
1022            ceiling_z: 0.0,
1023            non_planar_ceiling: false,
1024            alive: true,
1025        });
1026        self.faces.push(Face {
1027            half_edge: Some(HalfEdgeId(g0)),
1028            is_outer: true,
1029            is_room: false,
1030            floor_z: 0.0,
1031            ceiling_z: 0.0,
1032            non_planar_ceiling: false,
1033            alive: true,
1034        });
1035
1036        Ok(self.face_patch(room))
1037    }
1038
1039    // ─────────────────────────── queries ────────────────────────────
1040
1041    /// The face on the far side of `edge` — its twin's face. O(1). This is
1042    /// the "who's my neighbour across this wall" query the UX leans on.
1043    pub fn neighbor_across(&self, edge: HalfEdgeId) -> Option<FaceId> {
1044        let he = self.half_edges.get(edge.0 as usize)?;
1045        if !he.alive {
1046            return None;
1047        }
1048        Some(self.half_edges[he.twin.0 as usize].face)
1049    }
1050
1051    /// Every live interior (room) face. In the FACE-BASED build (`wall_rects`
1052    /// non-empty) a bounded face is a room only if it's a gap between walls — its
1053    /// centroid lies outside every wall rectangle, so wall interiors and junction
1054    /// overlaps are excluded.
1055    pub fn rooms(&self) -> impl Iterator<Item = FaceId> + '_ {
1056        (0..self.faces.len())
1057            .map(|i| FaceId(i as u32))
1058            .filter(move |f| {
1059                let face = &self.faces[f.0 as usize];
1060                face.alive && face.is_room
1061            })
1062    }
1063
1064    /// Geometric gap test used ONCE at build to classify face-based rooms: a
1065    /// bounded face is a gap (room) when its centroid is not inside any wall
1066    /// rectangle. True for the centreline build (no `wall_rects`). Not used after
1067    /// build — `Face::is_room` is the carried-through source of truth.
1068    fn is_gap_face(&self, face: FaceId) -> bool {
1069        if self.wall_rects.is_empty() {
1070            return true;
1071        }
1072        let outline = self.face_outline(face);
1073        if outline.len() < 3 {
1074            return false;
1075        }
1076        let (mut cx, mut cy) = (0.0, 0.0);
1077        for p in &outline {
1078            cx += p[0];
1079            cy += p[1];
1080        }
1081        let c = [cx / outline.len() as f64, cy / outline.len() as f64];
1082        !self.wall_rects.iter().any(|r| point_in_quad(c, r))
1083    }
1084
1085    /// CCW outline of a face (no repeated closing vertex).
1086    pub fn face_outline(&self, face: FaceId) -> Vec<[f64; 2]> {
1087        self.face_half_edges(face)
1088            .map(|h| self.vertices[self.half_edges[h.0 as usize].origin.0 as usize].pos)
1089            .collect()
1090    }
1091
1092    /// Absolute area of a face.
1093    pub fn face_area(&self, face: FaceId) -> f64 {
1094        self.signed_area_of_cycle(&self.face_half_edges(face).collect::<Vec<_>>()).abs()
1095    }
1096
1097    /// The room's outline offset to a wall **boundary face** rather than the
1098    /// centreline: each boundary edge is moved perpendicular by its own wall's
1099    /// half-thickness — inward for the net (inner) face, outward for the gross
1100    /// (outer) face — then adjacent offset lines are re-intersected for the
1101    /// corners. Because every half-edge carries its source wall's thickness,
1102    /// this needs no fuzzy edge↔wall matching (unlike the TS `offsetRoomFootprint`).
1103    ///
1104    /// An edge shared with another room (its twin bounds a room, not the
1105    /// exterior) is pinned to the centreline in **outward** mode so it can't
1106    /// push into the neighbour. Falls back to the unchanged centreline outline
1107    /// when no offset applies, a corner is degenerate, or an inset would invert
1108    /// the polygon — so the result is always a sane ring.
1109    pub fn net_outline(&self, face: FaceId, inset: bool) -> Vec<[f64; 2]> {
1110        let centre = self.face_outline(face);
1111        let n = centre.len();
1112        if n < 3 {
1113            return centre;
1114        }
1115        let cycle: Vec<HalfEdgeId> = self.face_half_edges(face).collect();
1116        if cycle.len() != n {
1117            return centre; // outline / cycle mismatch (e.g. a hole) — don't guess
1118        }
1119        let sign = if inset { 1.0 } else { -1.0 };
1120        // Per edge: a point on its offset line + the edge's unit direction.
1121        let mut lines: Vec<([f64; 2], [f64; 2])> = Vec::with_capacity(n);
1122        for i in 0..n {
1123            let a = centre[i];
1124            let b = centre[(i + 1) % n];
1125            let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
1126            let l = (dx * dx + dy * dy).sqrt();
1127            if l < EPS {
1128                return centre;
1129            }
1130            let (ux, uy) = (dx / l, dy / l);
1131            let mut half = self.half_edges[cycle[i].0 as usize].half_thickness;
1132            // Outward: a shared (room↔room) edge would overlap the neighbour, so pin it.
1133            if !inset {
1134                if let Some(nbr) = self.neighbor_across(cycle[i]) {
1135                    if !self.faces[nbr.0 as usize].is_outer {
1136                        half = 0.0;
1137                    }
1138                }
1139            }
1140            let off = sign * half;
1141            // Inward normal of a CCW outline is to the left of a→b: (-uy, ux).
1142            lines.push(([a[0] - uy * off, a[1] + ux * off], [ux, uy]));
1143        }
1144        let mut verts: Vec<[f64; 2]> = Vec::with_capacity(n);
1145        for i in 0..n {
1146            let (pp, pd) = lines[(i + n - 1) % n];
1147            let (cp, cd) = lines[i];
1148            let hit = line_intersection(pp, [pp[0] + pd[0], pp[1] + pd[1]], cp, [cp[0] + cd[0], cp[1] + cd[1]]);
1149            // Parallel offset lines (a collinear node, e.g. a mid-wall split, or
1150            // two edges of equal thickness in a straight run) don't intersect —
1151            // drop the corner onto the current offset line so it sits flush on
1152            // the inset boundary instead of poking back to the centreline.
1153            verts.push(hit.unwrap_or_else(|| {
1154                let t = (centre[i][0] - cp[0]) * cd[0] + (centre[i][1] - cp[1]) * cd[1];
1155                [cp[0] + t * cd[0], cp[1] + t * cd[1]]
1156            }));
1157        }
1158        if verts.iter().any(|v| !v[0].is_finite() || !v[1].is_finite()) {
1159            return centre;
1160        }
1161        let got = polygon_area(&verts).abs();
1162        if got <= EPS {
1163            return centre;
1164        }
1165        if inset && got > polygon_area(&centre).abs() + 1e-6 {
1166            return centre; // the inset inverted the polygon — keep the centreline
1167        }
1168        verts
1169    }
1170
1171    /// FACE-BASED boundary of a gap room: the gap outline IS the net (inner-face)
1172    /// area, so this pushes every edge OUTWARD (into the wall, away from the room)
1173    /// by `factor × the source wall's half-thickness`, then re-intersects corners.
1174    /// `factor = 0` → net (the gap itself); `1` → the wall **axis / centre line**
1175    /// (½ thickness — where the editable node sits, on the wall mid); `2` → the
1176    /// gross outer face (full thickness). No shared-edge pinning: two rooms across
1177    /// a wall correctly meet at the mid axis and overlap into it for gross.
1178    pub fn gap_boundary(&self, face: FaceId, factor: f64) -> Vec<[f64; 2]> {
1179        let centre = self.face_outline(face);
1180        let n = centre.len();
1181        if n < 3 || factor.abs() < EPS {
1182            return centre;
1183        }
1184        let cycle: Vec<HalfEdgeId> = self.face_half_edges(face).collect();
1185        if cycle.len() != n {
1186            return centre;
1187        }
1188        // Per edge: the offset line (anchor + dir), the outward displacement
1189        // vector applied, and |offset| (for the corner miter clamp below).
1190        let mut lines: Vec<([f64; 2], [f64; 2])> = Vec::with_capacity(n);
1191        let mut disp: Vec<[f64; 2]> = Vec::with_capacity(n);
1192        let mut off_mag: Vec<f64> = Vec::with_capacity(n);
1193        for i in 0..n {
1194            let a = centre[i];
1195            let b = centre[(i + 1) % n];
1196            let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
1197            let l = (dx * dx + dy * dy).sqrt();
1198            if l < EPS {
1199                return centre;
1200            }
1201            let (ux, uy) = (dx / l, dy / l);
1202            let half = self.half_edges[cycle[i].0 as usize].half_thickness;
1203            // Outward (right of a→b on a CCW ring) = (uy, -ux), i.e. negate the
1204            // inward normal (-uy, ux). Push the edge out by factor × half.
1205            let off = factor * half;
1206            let d = [uy * off, -ux * off];
1207            lines.push(([a[0] + d[0], a[1] + d[1]], [ux, uy]));
1208            disp.push(d);
1209            off_mag.push(off.abs());
1210        }
1211        // Re-intersect adjacent offset lines at each corner. At a concave / very
1212        // acute corner the two near-parallel lines meet far away (an unbounded
1213        // miter), which would blow the polygon up; clamp any corner that moves
1214        // more than MITER_LIMIT × the local offset to a bevel (the original
1215        // corner displaced by the mean of its two edges' offsets).
1216        const MITER_LIMIT: f64 = 4.0;
1217        let mut verts: Vec<[f64; 2]> = Vec::with_capacity(n);
1218        for i in 0..n {
1219            let pj = (i + n - 1) % n;
1220            let (pp, pd) = lines[pj];
1221            let (cp, cd) = lines[i];
1222            let bevel = [
1223                centre[i][0] + 0.5 * (disp[pj][0] + disp[i][0]),
1224                centre[i][1] + 0.5 * (disp[pj][1] + disp[i][1]),
1225            ];
1226            let limit = MITER_LIMIT * off_mag[i].max(off_mag[pj]) + EPS;
1227            let pt = match line_intersection(pp, [pp[0] + pd[0], pp[1] + pd[1]], cp, [cp[0] + cd[0], cp[1] + cd[1]]) {
1228                Some(m) => {
1229                    let (ddx, ddy) = (m[0] - centre[i][0], m[1] - centre[i][1]);
1230                    if (ddx * ddx + ddy * ddy).sqrt() <= limit { m } else { bevel }
1231                }
1232                None => bevel,
1233            };
1234            verts.push(pt);
1235        }
1236        // Final guards: any non-finite / degenerate / runaway (a still-exploded
1237        // offset polygon should never dwarf the net area) → fall back to the net.
1238        let net_area = polygon_area(&centre).abs();
1239        let off_area = polygon_area(&verts).abs();
1240        if verts.iter().any(|v| !v[0].is_finite() || !v[1].is_finite())
1241            || off_area <= EPS
1242            || off_area > 4.0 * net_area + 25.0
1243        {
1244            return centre;
1245        }
1246        verts
1247    }
1248
1249    /// The bounding half-edges of a face paired with the IFC element each
1250    /// came from — the raw material for `IfcRelSpaceBoundary` at bake.
1251    pub fn bounding_elements(&self, face: FaceId) -> Vec<(HalfEdgeId, Option<u32>)> {
1252        self.face_half_edges(face)
1253            .map(|h| (h, self.half_edges[h.0 as usize].source_element))
1254            .collect()
1255    }
1256
1257    /// Set the floor / ceiling planes of a face (the vertical dimension that
1258    /// turns a 2D face into a prismatic space at bake).
1259    pub fn set_face_height(&mut self, face: FaceId, floor_z: f64, ceiling_z: f64, non_planar_ceiling: bool) {
1260        if let Some(f) = self.faces.get_mut(face.0 as usize) {
1261            f.floor_z = floor_z;
1262            f.ceiling_z = ceiling_z;
1263            f.non_planar_ceiling = non_planar_ceiling;
1264        }
1265    }
1266
1267    pub fn vertex_position(&self, v: VertexId) -> Option<[f64; 2]> {
1268        self.vertices.get(v.0 as usize).filter(|v| v.alive).map(|v| v.pos)
1269    }
1270
1271    /// Count of live rooms — handy for tests / UI summaries.
1272    pub fn room_count(&self) -> usize {
1273        self.rooms().count()
1274    }
1275
1276    /// Nearest live vertex to `(x, y)` within `tol`, for hit-testing a drag
1277    /// target from the TS side. Linear scan — a floor plate is small.
1278    pub fn find_vertex_near(&self, x: f64, y: f64, tol: f64) -> Option<VertexId> {
1279        let tol2 = tol * tol;
1280        let mut best: Option<(VertexId, f64)> = None;
1281        for (i, vtx) in self.vertices.iter().enumerate() {
1282            if !vtx.alive {
1283                continue;
1284            }
1285            let (dx, dy) = (vtx.pos[0] - x, vtx.pos[1] - y);
1286            let d2 = dx * dx + dy * dy;
1287            if d2 <= tol2 && best.map(|(_, b)| d2 < b).unwrap_or(true) {
1288                best = Some((VertexId(i as u32), d2));
1289            }
1290        }
1291        best.map(|(v, _)| v)
1292    }
1293
1294    /// Snapshot every live room as a patch (outline + area + simple flag) —
1295    /// for bulk render or seeding a fresh TS mirror after a rebuild.
1296    pub fn room_patches(&self) -> Vec<FacePatch> {
1297        self.rooms().map(|f| self.face_patch(f)).collect()
1298    }
1299
1300    // ─────────────────────────── internals ──────────────────────────
1301
1302    fn dest(&self, h: HalfEdgeId) -> VertexId {
1303        self.half_edges[self.half_edges[h.0 as usize].twin.0 as usize].origin
1304    }
1305
1306    /// Walk the face cycle starting at its anchor half-edge.
1307    fn face_half_edges(&self, face: FaceId) -> FaceWalk<'_> {
1308        // Tolerate an out-of-range or tombstoned face id (a stale handle from
1309        // JS) — yield an empty walk instead of panicking, so every public
1310        // query (`face_outline`/`face_area`/`bounding_elements`) is safe at the
1311        // wasm boundary.
1312        let start = self
1313            .faces
1314            .get(face.0 as usize)
1315            .filter(|f| f.alive)
1316            .and_then(|f| f.half_edge);
1317        FaceWalk { plate: self, start, cur: None }
1318    }
1319
1320    /// Outgoing half-edges around a vertex (via twin/next), live only.
1321    fn outgoing_half_edges(&self, v: VertexId) -> impl Iterator<Item = HalfEdgeId> + '_ {
1322        let start = self.vertices[v.0 as usize].outgoing;
1323        VertexFan { plate: self, start, cur: None }
1324    }
1325
1326    fn signed_area_of_cycle(&self, cycle: &[HalfEdgeId]) -> f64 {
1327        let mut acc = 0.0;
1328        for &h in cycle {
1329            let p = self.vertices[self.half_edges[h.0 as usize].origin.0 as usize].pos;
1330            let q = self.vertices[self.dest(h).0 as usize].pos;
1331            acc += p[0] * q[1] - q[0] * p[1];
1332        }
1333        acc * 0.5
1334    }
1335
1336    fn check_face(&self, face: FaceId) -> Result<(), EditError> {
1337        match self.faces.get(face.0 as usize) {
1338            Some(f) if f.alive && !f.is_outer => Ok(()),
1339            Some(_) => Err(EditError::StaleHandle),
1340            None => Err(EditError::StaleHandle),
1341        }
1342    }
1343
1344    /// After an edit, ensure `v.outgoing` doesn't point at the now-dead
1345    /// half-edge `dead`; pick any surviving outgoing edge, else isolate. We
1346    /// can't walk the vertex fan here (its start may be the dead edge), so we
1347    /// scan — merges are rare and this keeps the rotation system honest.
1348    fn repair_vertex_outgoing(&mut self, v: VertexId, dead: HalfEdgeId) {
1349        if self.vertices[v.0 as usize].outgoing != Some(dead) {
1350            return;
1351        }
1352        let replacement = (0..self.half_edges.len())
1353            .map(|i| HalfEdgeId(i as u32))
1354            .find(|h| {
1355                let he = &self.half_edges[h.0 as usize];
1356                he.alive && he.origin == v && *h != dead
1357            });
1358        self.vertices[v.0 as usize].outgoing = replacement;
1359        if replacement.is_none() {
1360            self.vertices[v.0 as usize].alive = false;
1361        }
1362    }
1363
1364    /// Ensure `f`'s anchor half-edge is a live half-edge that still belongs to
1365    /// `f`; if the anchor was tombstoned (or re-homed), re-point it at any
1366    /// surviving member, and tombstone the face if none remain.
1367    fn reanchor_face_if_dead(&mut self, f: FaceId) {
1368        let fi = f.0 as usize;
1369        if fi >= self.faces.len() || !self.faces[fi].alive {
1370            return;
1371        }
1372        let ok = matches!(self.faces[fi].half_edge, Some(h)
1373            if self.half_edges[h.0 as usize].alive && self.half_edges[h.0 as usize].face == f);
1374        if ok {
1375            return;
1376        }
1377        let replacement = (0..self.half_edges.len())
1378            .map(|i| HalfEdgeId(i as u32))
1379            .find(|h| {
1380                let he = &self.half_edges[h.0 as usize];
1381                he.alive && he.face == f
1382            });
1383        self.faces[fi].half_edge = replacement;
1384        if replacement.is_none() {
1385            self.faces[fi].alive = false;
1386        }
1387    }
1388
1389    fn face_patch(&self, face: FaceId) -> FacePatch {
1390        let outline = self.face_outline(face);
1391        let area = polygon_area(&outline).abs();
1392        let simple = is_simple_polygon(&outline);
1393        FacePatch { face, outline, area, simple }
1394    }
1395}
1396
1397/// Iterator over the half-edges of one face cycle.
1398struct FaceWalk<'a> {
1399    plate: &'a SpacePlate,
1400    start: Option<HalfEdgeId>,
1401    cur: Option<HalfEdgeId>,
1402}
1403
1404impl Iterator for FaceWalk<'_> {
1405    type Item = HalfEdgeId;
1406    fn next(&mut self) -> Option<HalfEdgeId> {
1407        let start = self.start?;
1408        let cur = match self.cur {
1409            None => start,
1410            Some(c) => {
1411                let n = self.plate.half_edges[c.0 as usize].next;
1412                if n == start {
1413                    return None;
1414                }
1415                n
1416            }
1417        };
1418        self.cur = Some(cur);
1419        Some(cur)
1420    }
1421}
1422
1423/// Iterator over the outgoing half-edges around a vertex (twin → next).
1424struct VertexFan<'a> {
1425    plate: &'a SpacePlate,
1426    start: Option<HalfEdgeId>,
1427    cur: Option<HalfEdgeId>,
1428}
1429
1430impl Iterator for VertexFan<'_> {
1431    type Item = HalfEdgeId;
1432    fn next(&mut self) -> Option<HalfEdgeId> {
1433        let start = self.start?;
1434        loop {
1435            let cur = match self.cur {
1436                None => start,
1437                Some(c) => {
1438                    // Around a vertex: twin (incoming) then its next (outgoing).
1439                    let twin = self.plate.half_edges[c.0 as usize].twin;
1440                    let n = self.plate.half_edges[twin.0 as usize].next;
1441                    if n == start {
1442                        return None;
1443                    }
1444                    n
1445                }
1446            };
1447            self.cur = Some(cur);
1448            if self.plate.half_edges[cur.0 as usize].alive {
1449                return Some(cur);
1450            }
1451            if cur == start {
1452                return None;
1453            }
1454        }
1455    }
1456}
1457
1458// TODO(space-dcel, follow-ups for the real feature):
1459//  - Robust predicates: `segment_intersection_param` is naive f64. Share the
1460//    adaptive-orientation floor being built for the pure-Rust CSG kernel
1461//    (csg-predicate-floor worktree) so dense national-grid wall sets don't
1462//    accumulate snap error.
1463//  - Holes / nested faces: a CW cycle is treated as exterior, so a room
1464//    enclosing a courtyard is mishandled. Add containment nesting.
1465//  - Net vs gross area: faces are centreline. Net area = inset each bounding
1466//    edge by half its source wall's thickness at quantity time; thickness must
1467//    ride `InputSegment` (extend with a `half_thickness` field).
1468//  - Leak diagnostics: detect open half-edges (a boundary that fails to close)
1469//    and surface them as per-face repair markers (§2.4 of the RFC).
1470//  - WASM seam: wrap `SpacePlate` in a stateful handle on `IfcAPI` with
1471//    explicit create/free — long-lived handles share the dlmalloc-GC hazard
1472//    from the cache-load crash fix; do NOT rely on JS GC to drop it.