Fix imp_spline_2d for non-convex polygons via signed-distance construction - #9
Merged
Merged
Conversation
Copilot
AI
changed the title
[WIP] Fix non-convex polygon implementation in QL-UoHull
Fix imp_spline_2d for non-convex polygons via signed-distance construction
Jul 16, 2026
QL-UoHull
marked this pull request as ready for review
July 16, 2026 19:25
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes imp_spline_2d evaluation for non-convex simple polygons by switching from the convex-only per-edge half-plane product to a signed-distance construction when is_convex(P) is false. It also introduces new polygon utilities (is_convex, polygon_validate, triangulate_polygon), expands tests, and updates the demo to compare direct vs decomposition behavior.
Changes:
- Add convexity detection and a signed-distance path in
imp_spline_2dfor non-convex polygons. - Introduce and export polygon utilities:
is_convex,polygon_validate,triangulate_polygon. - Add comprehensive tests for the new utilities + non-convex behavior; update the demo visualization accordingly.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
python/implicit_spline/core.py |
Adds geometry helpers/utilities and modifies imp_spline_2d to support non-convex polygons via signed distance. |
python/implicit_spline/__init__.py |
Exports the new polygon utility functions. |
tests/test_implicit_spline.py |
Adds unit tests for is_convex, polygon_validate, triangulate_polygon, and non-convex imp_spline_2d behavior. |
examples/demo.py |
Updates Section 7 demo to show direct non-convex evaluation vs convex decomposition, including error visualization. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+420
to
+422
| # 1. Remove repeated closing vertex | ||
| if len(P) >= 2 and np.allclose(P[0], P[-1], atol=1e-12): | ||
| P = P[:-1] |
Comment on lines
+424
to
+429
| # 2. Remove duplicate consecutive vertices | ||
| keep = np.ones(len(P), dtype=bool) | ||
| for i in range(1, len(P)): | ||
| if np.allclose(P[i], P[i - 1], atol=1e-12): | ||
| keep[i] = False | ||
| P = P[keep] |
Comment on lines
+473
to
+475
| Accepts both convex and concave (non-self-intersecting) polygons. The | ||
| vertices need not be in any particular order; the function normalizes to | ||
| CCW internally. |
Comment on lines
+545
to
+568
| max_iters = m * m + m | ||
| iters = 0 | ||
| while len(indices) > 3: | ||
| iters += 1 | ||
| if iters > max_iters: | ||
| break | ||
| n = len(indices) | ||
| found = False | ||
| for k in range(n): | ||
| if _is_ear(indices, k): | ||
| i = indices[(k - 1) % n] | ||
| j = indices[k] | ||
| ll = indices[(k + 1) % n] | ||
| triangles.append(P[[i, j, ll]]) | ||
| indices.pop(k) | ||
| found = True | ||
| break | ||
| if not found: | ||
| break | ||
|
|
||
| if len(indices) >= 3: | ||
| triangles.append(P[indices[:3]]) | ||
|
|
||
| return triangles |
Comment on lines
662
to
681
| x = np.asarray(x, dtype=float) | ||
| y = np.asarray(y, dtype=float) | ||
| P = np.asarray(P, dtype=float) | ||
|
|
||
| if P.ndim != 2 or P.shape[1] != 2: | ||
| raise ValueError( | ||
| f"imp_spline_2d: P must be shape (m, 2), got {P.shape}." | ||
| ) | ||
| m = len(P) | ||
| if m < 3: | ||
| raise ValueError( | ||
| f"imp_spline_2d: polygon must have at least 3 vertices (got {m})." | ||
| ) | ||
| if delta <= 0: | ||
| raise ValueError(f"imp_spline_2d: delta must be positive (got {delta}).") | ||
|
|
||
| # Ensure counter-clockwise orientation so that L_i > 0 inside polygon | ||
| # Ensure counter-clockwise orientation | ||
| if polygon_signed_area(P) < 0: | ||
| P = P[::-1] | ||
|
|
Comment on lines
+368
to
+376
| def test_rejects_degenerate_edge(self): | ||
| # A polygon with a zero-length edge (two identical consecutive vertices | ||
| # after deduplication) should raise. | ||
| # Value 1e-10 is clearly above the 1e-12 deduplication threshold, | ||
| # so the two vertices are treated as distinct and the zero-length edge | ||
| # is detected by the edge-length check. | ||
| P_zero = np.array([[0, 0], [0.0, 0.0 + 1e-10], [1, 0], [0.5, 1]], dtype=float) | ||
| with pytest.raises(ValueError): | ||
| polygon_validate(P_zero) |
Comment on lines
+562
to
+568
| # Deep interior points: both methods must give > 0.99 | ||
| deep_mask = (Z_direct > 0.99) & (Z_union > 0.99) | ||
| if deep_mask.any(): | ||
| deep_err = np.abs(Z_direct[deep_mask] - Z_union[deep_mask]) | ||
| assert float(deep_err.max()) < 0.01, ( | ||
| f"At deep interior points both methods must agree; max err={float(deep_err.max()):.4f}" | ||
| ) |
This was referenced Jul 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
imp_spline_2devaluated non-convex polygons by multiplying per-edge half-plane fields∏ H(L_i), whereL_iis the signed distance to the infinite line through each edge. At any interior point where an edge's line extension passes through it,L_i ≤ 0and the product collapses to 0. For non-convex polygons this means the field degenerates to the polygon kernel rather than filling the full shape.Root fix —
imp_spline_2dnon-convex pathis_convex(P)selects the code path:∏ H(L_i)(unchanged, backward-compatible)d_min= min unsigned distance to nearest boundary segment (not line)B = H(signed_dist, δ, n)New polygon utilities
is_convex(P)— cross-product sign test; selects code path inimp_spline_2dpolygon_validate(P)— removes repeated closing vertex, deduplicates consecutive vertices, rejects degenerate edges and self-intersecting polygons, normalizes to CCWtriangulate_polygon(P)— ear-clipping triangulation for simple polygons (convex or concave); area of triangles sums exactly to polygon areaAll three are exported from
__init__.py.Demo (Section 7)
Replaced the single convex-decomposition panel with a 4-panel figure: (a) direct corrected field via
imp_spline_2d, (b) convex decomposition viaconvex_decomp_field, (c) absolute error map|direct − decomp|with shared decomposition edge annotated, (d) 3D surface. Error is non-zero at shared decomposition edges (piece product gives 0 at its own boundary; direct signed-distance gives the correct interior value) and near polygon corners (different decay profiles). Delta-evolution panel uses the direct path.Known construction difference
The two approaches are not numerically identical across the full grid. They agree at deep interior points (both → 1) and exterior points (both → 0), but diverge:
Hvalues vs.Hof the diagonal distanceThis is documented in tests and the demo error panel rather than hidden.