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(¢re).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(¢re).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.