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