|
| 1 | +# Vector Mesh Fill Rendering - Implementation Plan |
| 2 | + |
| 3 | +## Overview |
| 4 | +This document outlines the improvements needed for the vector mesh fill rendering system based on code review feedback for PR #3388. All changes will be made to `node-graph/gcore/src/vector/vector_attributes.rs`. |
| 5 | + |
| 6 | +--- |
| 7 | + |
| 8 | +## Issues and Solutions |
| 9 | + |
| 10 | +### 1. CRITICAL: Multiple Edges Between Same Vertices |
| 11 | + |
| 12 | +**Problem:** |
| 13 | +When multiple edges connect the same two vertices (e.g., two curves with different curvatures), they produce the same angle during neighbor sorting. This causes incorrect edge ordering when finding minimal faces. |
| 14 | + |
| 15 | +**Solution:** |
| 16 | +Add a derivative-based tiebreaker to distinguish edges with the same angle. |
| 17 | + |
| 18 | +**Implementation Steps:** |
| 19 | + |
| 20 | +1. **Add `derivative_at_start()` method to `BezierHandles` in `node-graph/gcore/src/subpath/structs.rs`:** |
| 21 | + ```rust |
| 22 | + /// Returns the tangent vector at the start of the curve (t=0) |
| 23 | + pub fn derivative_at_start(&self, start: DVec2, end: DVec2) -> DVec2 { |
| 24 | + match self { |
| 25 | + BezierHandles::Linear => end - start, |
| 26 | + BezierHandles::Quadratic { handle } => 2.0 * (handle - start), |
| 27 | + BezierHandles::Cubic { handle_start, .. } => 3.0 * (handle_start - start), |
| 28 | + } |
| 29 | + } |
| 30 | + ``` |
| 31 | + |
| 32 | +2. **Add `derivative_at_end()` method to `BezierHandles` in `node-graph/gcore/src/subpath/structs.rs`:** |
| 33 | + ```rust |
| 34 | + /// Returns the tangent vector at the end of the curve (t=1) |
| 35 | + pub fn derivative_at_end(&self, start: DVec2, end: DVec2) -> DVec2 { |
| 36 | + match self { |
| 37 | + BezierHandles::Linear => end - start, |
| 38 | + BezierHandles::Quadratic { handle } => 2.0 * (end - handle), |
| 39 | + BezierHandles::Cubic { handle_end, .. } => 3.0 * (end - handle_end), |
| 40 | + } |
| 41 | + } |
| 42 | + ``` |
| 43 | + |
| 44 | +3. **Update sorting in `find_closed_regions()` (around line 1052-1059):** |
| 45 | + - After comparing angles, if they're equal (within epsilon), compare derivative angles |
| 46 | + - For edges leaving a point, use `derivative_at_start()` |
| 47 | + - Account for reversed edges by using the appropriate derivative direction |
| 48 | + |
| 49 | +**Code Location:** Lines 1052-1059 in `vector_attributes.rs` |
| 50 | + |
| 51 | +**Priority:** CRITICAL (may cause rendering bugs with multiple edges) |
| 52 | + |
| 53 | +--- |
| 54 | + |
| 55 | +### 2. PERFORMANCE: Unnecessary Edge Calculation |
| 56 | + |
| 57 | +**Problem:** |
| 58 | +In `find_minimal_face_from_edge()`, the code recalculates angles and searches through all neighbors to find the next edge (lines 1115-1123), even though neighbors are already sorted by angle in `find_closed_regions()`. |
| 59 | + |
| 60 | +**Current Approach:** |
| 61 | +```rust |
| 62 | +let next = neighbors.iter() |
| 63 | + .filter(|(seg, _next, _rev)| *seg != prev_segment) |
| 64 | + .min_by(|a, b| { |
| 65 | + let dir_a = ...; |
| 66 | + let dir_b = ...; |
| 67 | + let angle_a = angle_between(prev_direction, dir_a); |
| 68 | + let angle_b = angle_between(prev_direction, dir_b); |
| 69 | + angle_a.partial_cmp(&angle_b).unwrap_or(std::cmp::Ordering::Equal) |
| 70 | + })?; |
| 71 | +``` |
| 72 | + |
| 73 | +**Better Approach:** |
| 74 | +```rust |
| 75 | +// Find the index of the edge we came from |
| 76 | +let prev_index = neighbors.iter() |
| 77 | + .position(|(seg, _next, _rev)| *seg == prev_segment)?; |
| 78 | + |
| 79 | +// The next edge in the sorted list is the minimal face edge |
| 80 | +// Wrap around using modulo to handle circular list |
| 81 | +let next = neighbors[(prev_index + 1) % neighbors.len()]; |
| 82 | +``` |
| 83 | + |
| 84 | +**Code Location:** Lines 1115-1123 in `vector_attributes.rs` |
| 85 | + |
| 86 | +**Benefits:** |
| 87 | +- O(n) → O(1) lookup per vertex |
| 88 | +- Eliminates angle calculations in the hot loop |
| 89 | +- Simpler, more readable code |
| 90 | + |
| 91 | +**Priority:** NICE TO HAVE (performance optimization) |
| 92 | + |
| 93 | +--- |
| 94 | + |
| 95 | +### 3. FEATURE: Outer Face Culling |
| 96 | + |
| 97 | +**Problem:** |
| 98 | +The current implementation includes the outer boundary face (e.g., the outer square of a grid mesh), which should often be excluded. |
| 99 | + |
| 100 | +**Solution:** |
| 101 | +Use the shoelace formula to compute signed area and filter out counterclockwise (outer) faces. |
| 102 | + |
| 103 | +**Implementation Steps:** |
| 104 | + |
| 105 | +1. **Add `compute_signed_area()` helper function:** |
| 106 | + ```rust |
| 107 | + fn compute_signed_area(&self, face: &FoundSubpath) -> f64 { |
| 108 | + let mut area = 0.0; |
| 109 | + for edge in &face.edges { |
| 110 | + let start_pos = self.point_domain.positions()[edge.start]; |
| 111 | + let end_pos = self.point_domain.positions()[edge.end]; |
| 112 | + area += (end_pos.x - start_pos.x) * (end_pos.y + start_pos.y); |
| 113 | + } |
| 114 | + area * 0.5 |
| 115 | + } |
| 116 | + ``` |
| 117 | + |
| 118 | +2. **Filter faces in `find_closed_regions()` before returning:** |
| 119 | + ```rust |
| 120 | + regions.retain(|face| { |
| 121 | + // Always include 2-edge faces (floating point issues with area) |
| 122 | + if face.edges.len() == 2 { |
| 123 | + return true; |
| 124 | + } |
| 125 | + |
| 126 | + let area = self.compute_signed_area(face); |
| 127 | + // Keep clockwise faces (negative area) and exclude counterclockwise (positive area) |
| 128 | + area < 0.0 |
| 129 | + }); |
| 130 | + ``` |
| 131 | + |
| 132 | +**Code Location:** End of `find_closed_regions()` (around line 1078) |
| 133 | + |
| 134 | +**Additional Considerations:** |
| 135 | +- Floating point precision: use a small epsilon if needed |
| 136 | +- Consider making this behavior optional via a parameter |
| 137 | +- Document the winding order convention |
| 138 | + |
| 139 | +**Priority:** NICE TO HAVE (feature enhancement) |
| 140 | + |
| 141 | +--- |
| 142 | + |
| 143 | +### 4. CODE QUALITY: Loop Style |
| 144 | + |
| 145 | +**Problem:** |
| 146 | +The loop uses an awkward pattern with manual iteration counting and break condition. |
| 147 | + |
| 148 | +**Current Code (lines 1097-1104):** |
| 149 | +```rust |
| 150 | +let mut iteration = 0; |
| 151 | +let max_iterations = adjacency.len() * 2; |
| 152 | + |
| 153 | +loop { |
| 154 | + iteration += 1; |
| 155 | + |
| 156 | + if iteration > max_iterations { |
| 157 | + return None; |
| 158 | + } |
| 159 | + // ... |
| 160 | +} |
| 161 | +``` |
| 162 | + |
| 163 | +**Better Code:** |
| 164 | +```rust |
| 165 | +let max_iterations = adjacency.len() * 2; |
| 166 | + |
| 167 | +for _iteration in 1..=max_iterations { |
| 168 | + // ... loop body ... |
| 169 | +} |
| 170 | + |
| 171 | +// If we exit the loop without returning, the path didn't close |
| 172 | +None |
| 173 | +``` |
| 174 | + |
| 175 | +**Code Location:** Lines 1097-1104 in `vector_attributes.rs` |
| 176 | + |
| 177 | +**Benefits:** |
| 178 | +- More idiomatic Rust |
| 179 | +- Clearer intent |
| 180 | +- Automatic bounds checking |
| 181 | + |
| 182 | +**Priority:** NITPICK (code style) |
| 183 | + |
| 184 | +--- |
| 185 | + |
| 186 | +### 5. CODE QUALITY: Redundant Check |
| 187 | + |
| 188 | +**Problem:** |
| 189 | +The infinite loop prevention check at line 1145 is redundant with the max_iterations check. |
| 190 | + |
| 191 | +**Current Code (line 1145):** |
| 192 | +```rust |
| 193 | +// Prevent infinite loops |
| 194 | +if path.len() > adjacency.len() { |
| 195 | + return None; |
| 196 | +} |
| 197 | +``` |
| 198 | + |
| 199 | +**Action:** |
| 200 | +Remove this check entirely. The `for` loop (after implementing #4) or the `max_iterations` check already prevents infinite loops. |
| 201 | + |
| 202 | +**Code Location:** Line 1145 in `vector_attributes.rs` |
| 203 | + |
| 204 | +**Priority:** NITPICK (code cleanup) |
| 205 | + |
| 206 | +--- |
| 207 | + |
| 208 | +### 6. CODE QUALITY: Cycle Detection Condition |
| 209 | + |
| 210 | +**Problem:** |
| 211 | +The cycle detection at line 1138 includes `e.id != next_segment` which may be unnecessary. |
| 212 | + |
| 213 | +**Current Code (line 1138):** |
| 214 | +```rust |
| 215 | +if path.iter().any(|e| e.end == next_point && e.id != next_segment) { |
| 216 | + return None; |
| 217 | +} |
| 218 | +``` |
| 219 | + |
| 220 | +**Investigation Needed:** |
| 221 | +- Is it possible for `e.end == next_point` and `e.id == next_segment`? |
| 222 | +- If the first condition is sufficient, simplify to: |
| 223 | +```rust |
| 224 | +if path.iter().any(|e| e.end == next_point) { |
| 225 | + return None; |
| 226 | +} |
| 227 | +``` |
| 228 | + |
| 229 | +**Code Location:** Line 1138 in `vector_attributes.rs` |
| 230 | + |
| 231 | +**Priority:** NITPICK (code clarity) |
| 232 | + |
| 233 | +--- |
| 234 | + |
| 235 | +### 7. CODE QUALITY: Code Organization |
| 236 | + |
| 237 | +**Problem:** |
| 238 | +The `if next_point == target` check happens before pushing to path, then duplicates the path-push logic inside. |
| 239 | + |
| 240 | +**Current Code (lines 1125-1134):** |
| 241 | +```rust |
| 242 | +if next_point == target { |
| 243 | + // Completed the cycle |
| 244 | + path.push(HalfEdge::new(next_segment, current, next_point, next_reversed)); |
| 245 | + |
| 246 | + // Mark all half-edges as used |
| 247 | + for edge in &path { |
| 248 | + used_half_edges.insert((edge.id, edge.reverse)); |
| 249 | + } |
| 250 | + |
| 251 | + return Some(FoundSubpath::new(path)); |
| 252 | +} |
| 253 | +``` |
| 254 | + |
| 255 | +**Better Organization:** |
| 256 | +Move this check to after line 1144 (after the cycle detection) and reuse the path-push logic: |
| 257 | + |
| 258 | +```rust |
| 259 | +// Check if we've created a cycle (might not be back to start) |
| 260 | +if path.iter().any(|e| e.end == next_point && e.id != next_segment) { |
| 261 | + return None; |
| 262 | +} |
| 263 | + |
| 264 | +path.push(HalfEdge::new(next_segment, current, next_point, next_reversed)); |
| 265 | + |
| 266 | +// Check if we've completed the face |
| 267 | +if next_point == target { |
| 268 | + // Mark all half-edges as used |
| 269 | + for edge in &path { |
| 270 | + used_half_edges.insert((edge.id, edge.reverse)); |
| 271 | + } |
| 272 | + return Some(FoundSubpath::new(path)); |
| 273 | +} |
| 274 | + |
| 275 | +prev_segment = next_segment; |
| 276 | +current = next_point; |
| 277 | +``` |
| 278 | + |
| 279 | +**Code Location:** Lines 1125-1147 in `vector_attributes.rs` |
| 280 | + |
| 281 | +**Benefits:** |
| 282 | +- Eliminates code duplication |
| 283 | +- More logical flow |
| 284 | +- Easier to maintain |
| 285 | + |
| 286 | +**Priority:** NITPICK (code organization) |
| 287 | + |
| 288 | +--- |
| 289 | + |
| 290 | +## Implementation Order |
| 291 | + |
| 292 | +### Phase 1: Critical Fixes |
| 293 | +1. **Multiple Edges Handling** (#1) |
| 294 | + - Add derivative methods to BezierHandles |
| 295 | + - Update sorting logic with tiebreaker |
| 296 | + |
| 297 | +### Phase 2: Performance & Features |
| 298 | +2. **Performance Optimization** (#2) |
| 299 | + - Replace min_by with index-based lookup |
| 300 | +3. **Outer Face Culling** (#3) |
| 301 | + - Add signed area calculation |
| 302 | + - Filter counterclockwise faces |
| 303 | + |
| 304 | +### Phase 3: Code Quality |
| 305 | +4. **Loop Style** (#4) |
| 306 | + - Convert to for loop |
| 307 | +5. **Remove Redundant Check** (#5) |
| 308 | + - Delete redundant infinite loop check |
| 309 | +6. **Cycle Detection** (#6) |
| 310 | + - Investigate and simplify if possible |
| 311 | +7. **Code Organization** (#7) |
| 312 | + - Reorganize target-check logic |
| 313 | + |
| 314 | +--- |
| 315 | + |
| 316 | +## Testing Considerations |
| 317 | + |
| 318 | +After implementing these changes, test with: |
| 319 | + |
| 320 | +1. **Multiple edges test:** Create a mesh with multiple curves between the same vertices |
| 321 | +2. **Grid mesh test:** Verify outer face is properly culled |
| 322 | +3. **Complex mesh test:** Test with various mesh topologies |
| 323 | +4. **Performance test:** Measure improvement from optimization #2 |
| 324 | +5. **Edge cases:** |
| 325 | + - 2-edge faces |
| 326 | + - Self-intersecting paths |
| 327 | + - Degenerate curves |
| 328 | + |
| 329 | +--- |
| 330 | + |
| 331 | +## Notes |
| 332 | + |
| 333 | +- All changes maintain backward compatibility |
| 334 | +- No API changes required (internal implementation only) |
| 335 | +- Changes are localized to vector_attributes.rs and subpath/structs.rs |
| 336 | +- Derivative methods in BezierHandles may be useful for other features in the future |
| 337 | + |
| 338 | +--- |
| 339 | + |
| 340 | +## Reviewer Approval Status |
| 341 | + |
| 342 | +**Status:** ✅ Approved with suggestions |
| 343 | + |
| 344 | +**Critical Items:** Issue #1 (if reviewer's understanding is correct) |
| 345 | +**Nice to Have:** Issues #2, #3 |
| 346 | +**Nitpicks:** Issues #4-7 |
0 commit comments