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