diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/region.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/region.rs index 4821ce9..5c25b15 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel2/region.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/region.rs @@ -596,6 +596,80 @@ mod tests { extracted.validate(); } + /// Selection entirely inside a filled face: the original face must survive. + /// + /// Before this fix, `insert_edge_both_connected` would overwrite F1's + /// `outer_half_edge` with the spur back-chain, causing F1 to lose its + /// rectangle boundary and visually disappear. + #[test] + fn rect_inner_region_select_preserves_face() { + use crate::shape::ShapeColor; + let red = ShapeColor::rgb(255, 0, 0); + let mut dcel = Dcel::new(); + + // Build a rectangle: (100,100)-(300,200) + let tl = Point::new(100.0, 100.0); + let tr = Point::new(300.0, 100.0); + let br = Point::new(300.0, 200.0); + let bl = Point::new(100.0, 200.0); + dcel.insert_stroke(&[ + line_cubic(tl, tr), line_cubic(tr, br), + line_cubic(br, bl), line_cubic(bl, tl), + ], None, None, 1.0); + + // Simulate paint-bucket: create face for rectangle interior and assign fill. + let fq = dcel.find_face_at_point(Point::new(200.0, 150.0)); + let f1 = if !fq.cycle_he.is_none() && fq.face.0 == 0 { + dcel.create_face_at_cycle(fq.cycle_he) + } else { + fq.face + }; + dcel.faces[f1.idx()].fill_color = Some(red); + dcel.validate(); + + // Confirm F1 has fill + assert!(dcel.faces.iter().skip(1).any(|f| !f.deleted && f.fill_color.is_some()), + "rect face should have fill before selection"); + + // Selection: (150,130)-(250,170) — entirely inside the rectangle + let sa = Point::new(150.0, 130.0); + let sb = Point::new(250.0, 130.0); + let sc = Point::new(250.0, 170.0); + let sd = Point::new(150.0, 170.0); + let mut region_path = BezPath::new(); + region_path.move_to(sa); + region_path.line_to(sb); + region_path.line_to(sc); + region_path.line_to(sd); + region_path.close_path(); + + let sel_result = dcel.insert_stroke(&[ + line_cubic(sa, sb), line_cubic(sb, sc), + line_cubic(sc, sd), line_cubic(sd, sa), + ], None, None, 1.0); + dcel.validate(); + + let boundary_verts = sel_result.new_vertices.clone(); + let extracted = dcel.extract_region(®ion_path, &boundary_verts); + + dcel.validate(); + extracted.validate(); + + // The live DCEL must still have a filled face (the original rectangle). + let live_filled = dcel.faces.iter().enumerate() + .filter(|(i, f)| *i > 0 && !f.deleted && f.fill_color.is_some()) + .count(); + assert!(live_filled >= 1, + "live DCEL should still have at least one filled face after selection; got {live_filled}"); + + // The extracted DCEL must also have a filled face (selection interior, inherits fill). + let extracted_filled = extracted.faces.iter().enumerate() + .filter(|(i, f)| *i > 0 && !f.deleted && f.fill_color.is_some()) + .count(); + assert!(extracted_filled >= 1, + "extracted DCEL should have at least one filled face; got {extracted_filled}"); + } + /// Replicate: multiple consecutive region-selects on two rectangles, /// some of which select empty space. Verifies no crash accumulates. #[test] diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs b/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs index 6140c13..fc63d5b 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel2/topology.rs @@ -331,6 +331,25 @@ impl Dcel { // the old face's outer_half_edge. let old_ohe = self.faces[actual_face.idx()].outer_half_edge; let fwd_has_old = !old_ohe.is_none() && self.cycle_contains(he_fwd, old_ohe); + let bwd_has_old = !fwd_has_old && !old_ohe.is_none() && self.cycle_contains(he_bwd, old_ohe); + + if !fwd_has_old && !bwd_has_old { + // Neither new cycle contains the face's existing outer boundary. + // This happens when the edge closes a loop entirely within the face + // (all selection vertices are isolated/floating, never connected to the + // face's real boundary). Don't overwrite outer_half_edge — it still + // correctly points to the face's real boundary (e.g. the rectangle). + // he_fwd = the enclosed interior (new face F2); he_bwd = reverse + // traversal of the selection boundary (stays in actual_face). + self.assign_cycle_face(he_bwd, actual_face); + let new_face = self.alloc_face(); + self.faces[new_face.idx()].fill_color = self.faces[actual_face.idx()].fill_color; + self.faces[new_face.idx()].image_fill = self.faces[actual_face.idx()].image_fill; + self.faces[new_face.idx()].fill_rule = self.faces[actual_face.idx()].fill_rule; + self.faces[new_face.idx()].outer_half_edge = he_fwd; + self.assign_cycle_face(he_fwd, new_face); + return (edge_id, new_face); + } let (he_old_cycle, he_new_cycle) = if fwd_has_old { (he_fwd, he_bwd) diff --git a/lightningbeam-ui/lightningbeam-core/src/selection.rs b/lightningbeam-ui/lightningbeam-core/src/selection.rs index 15397e6..08247a7 100644 --- a/lightningbeam-ui/lightningbeam-core/src/selection.rs +++ b/lightningbeam-ui/lightningbeam-core/src/selection.rs @@ -201,6 +201,16 @@ impl Selection { } } + /// Select a face by ID only, without adding boundary edges or vertices. + /// + /// Use this when the geometry lives in a separate DCEL (e.g. region selection's + /// `selected_dcel`) so we don't add stale edge/vertex IDs to the selection. + pub fn select_face_id_only(&mut self, face_id: FaceId) { + if !face_id.is_none() && face_id.0 != 0 { + self.selected_faces.insert(face_id); + } + } + /// Select a face and all its boundary edges + vertices. pub fn select_face(&mut self, face_id: FaceId, dcel: &Dcel) { if face_id.is_none() || face_id.0 == 0 || dcel.face(face_id).deleted { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 2ec4e3a..1f99736 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -1090,7 +1090,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Render selected DCEL from active region selection (with transform) if let Some(ref region_sel) = self.ctx.region_selection { - let sel_transform = camera_transform * region_sel.transform; + let sel_transform = overlay_transform * region_sel.transform; lightningbeam_core::renderer::render_dcel( ®ion_sel.selected_dcel, &mut scene, @@ -1105,6 +1105,27 @@ impl egui_wgpu::CallbackTrait for VelloCallback { scene }; + // Render region selection fill into the overlay scene. + // In HDR mode the main scene-building block returns an empty scene (only layer content + // goes through the HDR pipeline), so we must add the selected-DCEL fill here so it + // appears underneath the stipple overlay. In legacy mode the render_dcel call inside + // the block already handled this, but running it again is harmless since `scene` would + // be a fresh empty scene only in HDR mode. + if USE_HDR_COMPOSITING { + if let Some(ref region_sel) = self.ctx.region_selection { + let sel_transform = overlay_transform * region_sel.transform; + let mut image_cache = shared.image_cache.lock().unwrap(); + lightningbeam_core::renderer::render_dcel( + ®ion_sel.selected_dcel, + &mut scene, + sel_transform, + 1.0, + &self.ctx.document, + &mut image_cache, + ); + } + } + // Render drag preview objects with transparency if let (Some(delta), Some(active_layer_id)) = (self.ctx.drag_delta, self.ctx.active_layer_id) { if let Some(layer) = self.ctx.document.get_layer(&active_layer_id) { @@ -1478,18 +1499,6 @@ impl egui_wgpu::CallbackTrait for VelloCallback { _ => {} } - // 2c. Draw active region selection boundary - if let Some(ref region_sel) = self.ctx.region_selection { - // Draw the region boundary as a dashed outline - let boundary_color = Color::from_rgba8(255, 150, 0, 150); - scene.stroke( - &Stroke::new(1.0).with_dashes(0.0, &[6.0, 4.0]), - overlay_transform, - boundary_color, - None, - ®ion_sel.region_path, - ); - } // 3. Draw rectangle creation preview if let lightningbeam_core::tool::ToolState::CreatingRectangle { ref start_point, ref current_point, centered, constrain_square, .. } = self.ctx.tool_state { @@ -2744,6 +2753,12 @@ impl StagePane { None => return, // No active layer }; + // Revert any active region selection on mouse press before borrowing the document + // immutably, so the two selection modes don't coexist. + if self.rsp_primary_pressed(ui) { + Self::revert_region_selection_static(shared); + } + let active_layer = match shared.action_executor.document().get_layer(&active_layer_id) { Some(layer) => layer, None => return, @@ -4069,8 +4084,10 @@ impl StagePane { // Mouse down: start region selection if self.rsp_drag_started(response) { - // Revert any existing uncommitted region selection + // Revert any existing uncommitted region selection, and clear the + // regular selection so both selection modes don't coexist. Self::revert_region_selection_static(shared); + shared.selection.clear(); match *shared.region_select_mode { RegionSelectMode::Rectangle => { @@ -4264,6 +4281,17 @@ impl StagePane { shared.selection.clear(); + // Populate global selection with the faces from the extracted DCEL so + // property panels and other tools can see what is selected. We add face + // IDs only (no boundary edges/vertices) because the boundary geometry + // lives in selected_dcel, not in the live DCEL. + for (i, face) in selected_dcel.faces.iter().enumerate() { + if face.deleted || i == 0 { continue; } + if face.fill_color.is_some() || face.image_fill.is_some() { + shared.selection.select_face_id_only(lightningbeam_core::dcel::FaceId(i as u32)); + } + } + // Store region selection state with extracted DCEL *shared.region_selection = Some(lightningbeam_core::selection::RegionSelection { region_path,