You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Implements Phase 3 of the CanvasNet Drawing roadmap: stroke-to-fill conversion.
Adds a new PathStroker unit to the DemaConsulting.CanvasNet.Drawing namespace that converts a stroked Path + StrokeStyle into a new fillable outline Path, which is then rasterized by the existing PathFiller/ScanlineRasterizer (NonZero fill rule) — no new rasterization code path is introduced.
New public API
StrokeStyle — immutable stroke parameters: Width, Cap, Join, MiterLimit (SVG-style, default 4), DashArray, DashOffset, with full constructor validation.
StrokePathFlattener — flattens subpaths to polylines while preserving open/closed state.
DashSplitter — splits a polyline into dash "on" segments (odd-length arrays, dash offset, closed-polyline wraparound).
StrokeOutliner — computes offset/join/cap geometry, including miter-limit bevel fallback, round-join/cap arc tessellation, and closed-subpath counter-wound outline rings.
Companion artifacts
Full artifact set added/updated to match the existing PathFiller unit's structure:
Requirements: system (canvas-net.yaml), subsystem (drawing.yaml), and unit (drawing/path-stroker.yaml) level requirements — behavior-only titles, no internal algorithm/class-name leakage.
Design docs: unit doc (design/canvas-net/drawing/path-stroker.md) plus subsystem/system/introduction updates.
Verification docs: unit doc plus subsystem update.
SysML2 model: new unit part plus subsystem update.
.reviewmark.yaml: new CanvasNet-Drawing-PathStroker review set.
README.md and docs/user_guide/introduction.md: new feature bullet, API reference section, and usage example.
Tests
New unit test files under test/DemaConsulting.CanvasNet.Tests/Drawing/: StrokeStyleTests, StrokePathFlattenerTests, DashSplitterTests, StrokeOutlinerTests, PathStrokerTests.
PathStrokerTests verifies actual rendered pixel coverage (via PathStroker.Stroke + PathFiller.Fill) for line caps, corner joins, a closed ring, miter-limit bevel fallback, dashed lines, zero-length/single-point subpaths, empty-path no-op, and argument validation.
No wall-clock/timing-based tests anywhere; complexity is discussed only in the design doc.
…ng Phase 3: strokes)
Implements PathStroker, StrokeStyle, LineCap, LineJoin, and internal helpers (StrokePathFlattener, DashSplitter, StrokeOutliner) that convert stroked paths into fillable outline geometry, reusing the existing PathFiller/ScanlineRasterizer NonZero rasterizer. Includes full companion artifacts: reqstream (system/subsystem/unit), design docs, verification docs, sysml2 model, .reviewmark.yaml entry, README and user guide updates, plus unit and end-to-end pixel-coverage tests.
…loat32 precision
GetArcSegmentCount could return 1 (a single straight chord) whenever
flattenTolerance / radius underflowed float32 precision (~1.19e-7), which
happens once stroke half-width exceeds roughly 2e6 units for ordinary
flatten tolerances. This silently degraded LineJoin.Round / LineCap.Round
to a sharp straight edge with no error, and applied inconsistently since
CreateCirclePolygon already guarded with Math.Max(4, ...) while
AppendArc's join/cap call sites did not.
GetArcSegmentCount now falls back to a conservative angle-based minimum
segment count (one segment per PI/2 radians of sweep) whenever the
tolerance-based computation underflows to zero, and applies that same
floor uniformly to every call site (via Math.Max), so round joins/caps
never collapse to a straight chord for any valid, finite radius/sweep.
This mirrors BezierFlattening's MaxRecursionDepth convention of
explicitly bounding pathological-input behavior instead of degrading
silently.
Adds regression tests proving:
- a round join at extreme half-width still bulges substantially away
from the equivalent bevel join's vertices (not just float32 noise),
- typical-scale round-cap tessellation still matches the exact segment
count implied by the flattening-tolerance formula.
An explicitly closed degenerate subpath (for example, MoveTo(p).Close()) reaches this branch too, but it unconditionally uses style.Cap. The LineCap contract says closed subpaths have no exposed ends and ignore caps; Round and Square therefore incorrectly emit a mark instead of an empty stroke. Gate point-stroke creation on !isClosed.
Inner joins use bevel chords instead of offset-line intersections
For the concave/inside side of a turn, connecting the two vertex-offset points creates a bevel chord through the stroke instead of continuing the two offset edges to their intersection. On the right-angle path used by the tests this joins (8,6) to (6,4), omitting the actual inner corner at (6,6), so the returned outline under-fills the bend. Use the offset-line intersection here, with the existing parallel-line fallback, rather than appending both points.
Inner offset rings incorrectly apply the requested join style
Both offset rings currently receive the requested join style. For a closed rectangle, LineJoin.Round or Bevel therefore changes the hole boundary as well as the exterior; a stroked closed contour should use the selected join on the exterior of each turn while the inner offset edges meet at their intersection. Applying the style to the inner ring produces the wrong stroke area and can also apply the miter limit to an inner corner that should remain an intersection.
Miter limit uses half-width instead of stroke width
This public contract defines the limit as miterLength/strokeWidth, but TryCreateMiter computes the ratio using halfWidth; the unit design also says MiterLimit * Width. These descriptions disagree by a factor of two, so callers cannot predict the cutoff (for example, a right-angle join with miterLimit=1 is rejected by the implementation). Please standardize the formula across the API docs, design, implementation, and a non-right-angle test.
Root requirements incorrectly link unit tests as system evidence
docs/reqstream/canvas-net.yaml:810
These entries are tagged as root system requirements, but they link PathStroker unit-test methods rather than CanvasNet_SystemIntegration_* tests. Existing root requirements and docs/verification/canvas-net.md use system-level integration evidence, and no stroke integration scenario was added here. Add system tests and document them, or do not use unit tests as system evidence.
This issue also appears on line 823 of the same file.
These tests are attached to a Drawing subsystem requirement, but every linked method is a PathStroker unit test. The repository's traceability convention uses subsystem/system integration tests at parent levels; otherwise this requirement is falsely satisfied by unit evidence. Add a Drawing-level integration test and verification entry, or keep these tests only under the PathStroker unit requirement.
This issue also appears on line 117 of the same file.
- DashSplitter.GetPatternLength now sums in double (not float) and Split
rejects a non-finite total pattern length before phase traversal, fixing
an infinite loop reachable via a legal dash array such as
[float.MaxValue, float.MaxValue] combined with a negative dashOffset.
- DashSplitter's per-interval extraction now threads monotonic vertex and
point-lookup cursors across all dash intervals instead of rescanning the
whole polyline per interval, restoring the documented O(N + M) linear-pass
cost instead of O(N * M).
- StrokeOutliner.Outline now special-cases a closed subpath reduced to
exactly two distinct points (zero signed area) by outlining it the same
way an equivalent open single-segment stroke would be outlined, instead
of producing a coincident outer/inner ring pair that FillRule.NonZero
cancels to nothing.
- StrokeOutliner.GetArcSegmentCount's flattenTolerance/radius underflow
fallback now clamps to a minimum of 2 segments, so small sweep angles
(at most 90 degrees) no longer collapse back to a single straight chord.
Adds regression tests for all four fixes, including public-API-level
coverage through PathStroker.Stroke and PathFiller pixel-coverage checks
for the degenerate closed 2-point subpath case.
Because StrokeStyle explicitly permits zero-valued entries, a valid pattern such as [2f, 0f] can produce multiple adjacent onIntervals with no off span. The Count == 1 shortcut is bypassed, so PathStroker outlines each adjacent interval as a separate open dash; at a corner this adds caps where no gap exists, and a closed contour loses its continuous join. Merge touching intervals (or normalize zero-off patterns to a solid run) before extraction.
Align miter-limit denominator with the documented stroke-width ratio
The public StrokeStyle contract defines MiterLimit as miterLength / strokeWidth, and the design documentation repeats that ratio, but this calculation divides by halfWidth. Thus a 90-degree join has ratio √2 here and is beveled at limit 1 (as the new tests expect), while the documented ratio is 1/√2 and should pass. Align the implementation, design/API documentation, and miter-limit tests on one denominator before exposing this as SVG-style behavior.
…t styled join
Closed-contour stroking built both the outer (outside) ring and the
inner (hole) ring using the caller's requested LineJoin (Miter/Round/
Bevel) at every vertex. The two sides are not symmetric: only the
outer side is a stylized convex corner; the inner side is the
boundary of the hole left by the union of the two offset strips and
must always be the exact offset-edge intersection, regardless of the
requested join style. Applying Bevel or Round to the inner side
chamfered/rounded away real stroke area at inner corners, producing
an incorrect hole shape (e.g. for a stroked closed rectangle).
BuildClosedSide and AppendStyledJoin now take an outer/inner
distinction so only the outer ring honors StrokeStyle.Join; the inner
ring always resolves to the geometrically exact edge intersection
(falling back to the un-joined offset points only when the edges are
parallel).
Added regression tests rasterizing a closed rectangle with Bevel,
Round, and Miter joins and comparing rendered pixel coverage at the
inner corner against an independently hand-derived exact-intersection
expectation.
When the valid dash phase lands on a zero-length entry, this method returns that entry's parity because the loop is skipped when offset == 0. The single-point/zero-length path fast paths call this method, so a style such as [0f, 4f] at offset 0 incorrectly emits a round/square mark even though the initial on-run has zero length. Advance past zero-length entries before returning and add a regression test for this case.
Align miter limit documentation with its calculation
The public constructor documents miterLimit as miterLength / strokeWidth, while StrokeOutliner.TryCreateMiter divides the vertex-to-intersection distance by halfWidth (and the design document describes MiterLimit * Width). These definitions produce different fallback thresholds, so consumers cannot determine which limit a value such as 1.0 represents. Update the public/design documentation or the calculation so the ratio is defined consistently.
When a legal dash pattern begins with a zero-length entry (for example [0f, 2f]) and the path has no measurable length, this loop is skipped when the normalized offset is zero, so IsDashOnAtStart returns index 0 as an “on” span. PathStroker then emits a round or square point cap even though the phase should immediately be in the off entry. Advance through zero-length entries at the phase boundary before deciding whether the dash is on.
Miter limit uses half-width instead of stroke width
The public contract describes this as an SVG-style ratio of miterLength / strokeWidth, and the design says to compare the intersection distance with MiterLimit * Width. Dividing by halfWidth doubles the effective limit: for a 90-degree join the ratio is about 2.828 / 4 = 0.707, so miterLimit: 1 should not bevel under the documented contract, but this code does. Use the documented denominator or revise the API/docs/tests consistently.
Remove visible geometry tests from no-op requirement
docs/reqstream/canvas-net.yaml:828
This no-op requirement lists tests that intentionally produce visible geometry: the closed rectangle and the round/square point strokes do not prove that Stroke returns an empty path. Keep only no-area cases here (for example the empty path and butt-capped zero-length stroke), and associate the rendering tests with the general stroke requirement.
Move positive-area rendering tests out of no-op requirement
docs/reqstream/canvas-net/drawing.yaml:120
These children do not decompose the PathStrokeNoOp requirement: DegenerateSubpaths includes visible round/square marks, while ClosedContours and ShellWinding describe positive-area rings. Keeping them under a no-op parent makes the traceability matrix classify rendering behavior as evidence for an empty result; move them under the general stroke requirement (or give them a separate parent) and leave this no-op requirement focused on empty/no-area behavior.
This expected output is incorrect for the example geometry: pixel [32, 24] is about 4.8 units from either V segment and 8 units from the apex, outside the 3-unit half-width stroke, so it is not inside a visible dash. Use a pixel on the first segment's visible run, such as [20, 32], or update the expected alpha.
1. StrokeOutliner: fix concave (reflex) closed-contour stroke joins by
determining outer/inner side per vertex from the local turn direction
(matching the existing open-path logic) instead of a global per-ring
flag, which was only correct for convex closed contours.
2. Rewrite the 3 ClosedRectangle*Join_InnerCornerMatchesExactIntersection*
tests to assert directly on StrokeOutliner.Outline ring vertices instead
of rasterized pixels, since the buggy and correct rectangle geometries
rasterize pixel-identically under NonZero fill. Add concave-contour
regression tests (ring-vertex and pixel-based) that do discriminate,
verified to fail against both prior buggy StrokeOutliner.cs revisions.
3. Update path-stroker.md's closed-side join handling description to match
the corrected per-vertex local-turn rule.
4. DashSplitter.IsDashOnAtStart: skip forward through zero-length dash
entries at an exact phase boundary (offset == 0) so a pattern beginning
with a zero-length 'on' entry correctly starts in the following 'off'
entry instead of staying in the zero-length entry. Add a regression
test on a zero-length path, verified to fail pre-fix.
5. Investigate the reported StrokeOutliner miter-limit halfWidth/Width
denominator: re-derive the SVG miterLength/strokeWidth ratio from first
principles and confirm (both mathematically and by reverting the code
experimentally) that the existing halfWidth-based ratio already matches
the standard 1/sin(theta/2) formula (~1.414 for a right angle) and that
switching to the full Width, as literally suggested, would halve the
ratio and break existing miter-limit tests. Fix the design doc and the
TryCreateMiter XML doc comment, which stated the formula incorrectly,
instead of the (already-correct) code.
6. docs/reqstream/canvas-net.yaml: move the visible-geometry stroke tests
(closed-rectangle ring, round/square point-stroke marks) out of the
PathStrokeNoOp requirement's linked tests and into the general
PathStroke requirement; PathStrokeNoOp now only links genuinely
no-area/empty cases.
7. docs/reqstream/canvas-net/drawing.yaml: move the DegenerateSubpaths,
ClosedContours, and ShellWinding children (and their tests) from under
the PathStrokeNoOp parent requirement to the general PathStroke
requirement, leaving only EmptyNoOp under PathStrokeNoOp.
8. docs/user_guide/introduction.md: fix Example 10's sampled pixel, which
was outside the visible dashed stroke for the documented geometry;
replaced with a pixel on the stroke centerline inside a visible dash
run, verified against the actual rendered output.
…enerate closed-contour detection to collinear contours
Round 3 fixes for two automated-review findings plus a broader audit pass:
DashSplitter.cs:
- A small negative dashOffset against an astronomically large dash
pattern (e.g. [float.MaxValue, float.MaxValue] with dashOffset -1)
was silently discarded when normalizing into [0, patternLength) via
'normalized += patternLength' in double: adding a tiny residual to a
~1e38 magnitude modulus rounds straight back to the modulus, landing
the phase at the wrong (first on) entry instead of the final unit of
the preceding off entry.
- Replaced the lossy normalize-then-walk-forward approach with a
LocatePhase dispatcher that resolves the offset via the exact %
remainder (never lossy) and then walks either forward (non-negative
remainder) or backward from the end of the pattern (negative
remainder). Both directions only ever compare/subtract against
individual finite dash entries, never the huge total pattern length,
so small offsets are never lost regardless of sign or pattern
magnitude.
- Added two regression tests asserting CORRECT phase/geometry output
(not just completion) for a symmetric and an asymmetric extreme-
magnitude-plus-small-negative-offset combination.
StrokeOutliner.cs:
- Generalized the degenerate-closed-contour special case from
'exactly two distinct points' to 'fewer than three distinct points,
or any number of distinct points that are all collinear' via new
IsDegenerateClosedContour/AreAllPointsCollinear helpers. A 3+ point
closed contour that traces back and forth along one line is just as
zero-area as the two-point case and was previously falling through
to CreateClosedStrokePolygons, producing two coincident-band,
opposite-winding rings that FillRule.NonZero cancels to nothing.
- Added the same degenerate-contour guard as a defense-in-depth check
at the top of CreateClosedStrokePolygons itself, so the method can
never construct a cancelling ring pair even if called directly.
- Added regression tests: a 3-point collinear closed contour and a
collinear closed contour with a duplicate leading point, both at the
StrokeOutliner unit level and the PathStroker pixel-rendering level.
Broader audit (no further genuine issues found):
- StrokeStyle's constructor already rejects non-finite Width,
MiterLimit, DashOffset, and dash-array entries, so NaN/Infinity
cannot reach StrokeOutliner or DashSplitter through the public API.
- TryIntersectLines/TryCreateMiter already guard near-zero
denominators and non-finite miter ratios from earlier rounds.
- CreateClosedStrokePolygons was the only closed-contour ring
construction call site; the new guard now covers it directly as
well as its caller in Outline().
The fixed 1e-5f tolerance can stitch across a real, positive dash gap. For a closed path whose pattern ends with an off span of 1e-6 units after at least one earlier on run, totalLength - onEnd is treated as zero and the last and first on segments are joined, incorrectly filling that gap. Only stitch an actually seam-contiguous boundary (or use a scale-aware error bound that cannot absorb valid dash lengths).
A valid closed contour can have an inward offset that collapses: a 4×4 square stroked with width 4 makes every inner-ring intersection the same center point, so innerRing.Count < 3 returns no polygons and discards the otherwise valid outer stroke. For wider strokes the inner offset can cross and the always-opposite-wound inner ring can subtract a hole that should have disappeared. Detect a collapsed/self-inverted inner offset and emit the filled union without that hole instead of returning an empty stroke.
The public constructor now rejects undefined LineCap/LineJoin values, but StrokeStyleTests does not exercise either enum-validation branch. Add invalid-enum cases so this documented API contract is protected against regressions, alongside the existing width and miter-limit validation tests.
…tterns
Accumulate cumulative path lengths and the dash-interval walking cursor
in double instead of float. At path lengths in the tens of millions of
units, float32's ULP can grow to meet or exceed a fine dash span (e.g.
[1, 1]), so position += span could round back to the same position and
the extraction loop would never terminate. Double keeps its ULP
negligible at any length this library supports, and a defensive
BitIncrement guard forces forward progress if a step ever still fails
to advance.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The signed-area accumulator uses float arithmetic, so translated shapes can lose their orientation through catastrophic cancellation. A clockwise 1x1 square around (8000,8000) can round the shoelace sum to zero; the area >= 0 branch then chooses the wrong outer side and applies Bevel/Round styling to the inner ring. Accumulate a translated shoelace sum in double and compare signs without multiplying large areas.
Align cap documentation with degenerate closed-contour behavior
This documentation says closed subpaths ignore caps, but the new degenerate-contour paths do not: Outline routes closed one-point and collinear contours through CreatePointStrokePolygons/CreateOpenStrokePolygons, so Round and Square caps are applied. Document this degenerate exception or change the implementation so the public cap contract is not contradictory.
… inradius
When a closed contour is stroked with a half-width that exceeds the
contour's local inradius, BuildClosedSide's per-vertex exact
offset-edge intersection construction produced a geometrically
invalid inner ring: adjacent forced-intersection vertices' shared
offset edge ran backwards relative to its source edge's direction
(the offset lines had been pushed past each other), yielding an
inverted, oversized ring rather than the correct empty erosion. Fed
to NonZero fill as a hole, this carved out a nonsensical unfilled
region - e.g. a 4x4 square stroked with Width: 10 left a bogus hole
spanning roughly [1,7], larger than the square itself.
BuildClosedSide now tracks which ring vertices are 'plain' forced
exact-intersection points (one point per vertex, no styled-join
points), and for each pair of adjacent plain vertices checks whether
their shared offset edge still runs in the same direction as its
source edge's tangent. CreateClosedStrokePolygons omits the inner
ring entirely when this collapse is detected, so the whole interior
renders as solid stroke - matching what a true geometric erosion by
that half-width would produce once half-width exceeds the inradius.
Adds StrokeOutliner_Outline_ClosedSquareHalfWidthExceedsInradius_ProducesNoInvalidHole
(reproduces the reported bug; fails against the pre-fix code) and
StrokeOutliner_Outline_ClosedSquareHalfWidthNearButBelowInradius_ProducesValidHole
(confirms the ordinary near-threshold case still produces a valid,
correctly nested hole). All prior StrokeOutliner/PathStroker tests,
including the concave and collinear-degenerate regression tests,
continue to pass unmodified.
Updates the PathStroker design doc with a new bullet describing the
inner-ring collapse detection and the verification doc's test
listing for the two new StrokeOutlinerTests cases.
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
Valid zero-gap dash patterns and oppositely directed overlapping subpaths can produce incorrect coverage, while extreme dashed coordinates can cause non-termination.
Get a fresh assessment by requesting another Copilot review.
A legal dash pattern can contain zero-length off entries because StrokeStyle only rejects negative/all-zero values. With dashArray: [5f, 0f] (or [5f, 0f, 2f]), BuildOnIntervals emits adjacent on intervals instead of one continuous interval; PathStroker then outlines each interval separately, so round caps add artificial circles at the zero-gap boundary (and closed paths can get artificial caps as well). Merge an on interval with the previous one when its start equals the previous end, so zero-length gaps do not create exposed ends.
Open stroke outlines reverse winding with subpath direction
The open outline's vertex order is tied to the source direction: leftSide is emitted forward and rightSide backward, so reversing a subpath reverses the polygon winding. PathStroker concatenates all subpath outlines into one path that the public docs require callers to fill with FillRule.NonZero; two overlapping subpaths with opposite directions therefore contribute +1 and -1 and cancel their overlap, potentially erasing a valid stroke. Normalize each independent stroke outline to a consistent winding (while retaining opposite winding for the two rings of one closed contour) so stroke coverage is independent of subpath direction.
Closed stroke rings reverse winding with contour direction
Closed contours have the same direction-dependence: outerSideSign is selected from the source signed area, so reversing a closed subpath reverses the outer ring; the inner ring is then flipped relative to that outer ring. If two overlapping closed subpaths are authored in opposite directions, their corresponding stroke bands contribute opposite winding and cancel under the documented FillRule.NonZero fill. Normalize the outer ring to a common winding before pairing it with the reversed inner ring, so reversing a source contour cannot erase overlapping stroke area.
BuildCumulativeLengths computed per-edge length via Vector2.Distance,
which internally computes dx*dx + dy*dy in float32 before taking the
square root. For a legal, finite edge spanning near-extreme float32
coordinates (e.g. near float.MinValue to near float.MaxValue), that
intermediate squaring overflows to +Infinity even though the true
distance is finite in double precision. The infinite edge length made
totalLength infinite in BuildOnIntervals, and its
while (position < totalLength) loop could never terminate since
position can never reach infinity, hanging PathStroker.Stroke.
Fix: widen the edge deltas to double before squaring and take the
square root in double precision, so a legitimately huge-but-finite
edge length never suffers a spurious float32 intermediate overflow.
Added a regression test proving an edge spanning near-extreme float32
coordinates completes (rather than hanging) and produces finite dash
segments. Verified the test reproduces the bug against the pre-fix
code (OutOfMemoryException after the traversal loop ran unbounded for
~2 minutes), and passes with the fix. All 2103 existing tests
(including prior hang-regression tests) continue to pass with zero
regressions.
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
Stroke outlines can cancel under NonZero filling, tiny seam gaps can be incorrectly stitched, and large finite coordinates can collapse frame normalization.
Get a fresh assessment by requesting another Copilot review.
This absolute 1e-5 threshold can stitch across a real seam gap, since valid dash lengths and offsets may be smaller than 1e-5. For a closed path that starts in an off span of, for example, 1e-6 and has visible runs at both ends, these checks merge the runs and the resulting outline fills the off gap across the seam. Use an exact seam-boundary check or a precision-scaled bound that cannot swallow representable dash distances.
Add tests for documented invalid stroke style parameters
The constructor documents validation for undefined LineCap/LineJoin, non-finite miterLimit/dashOffset, and non-finite dash entries, but the new tests cover none of those cases (only width, miter-below-one, negative/all-zero dashes). Sibling public API tests explicitly cover undefined enums and non-finite values, so add theory cases for these documented invalid-style categories to keep the constructor contract regression-tested.
…t32 edge-length overflow
- Normalize the winding of every independently-emitted OUTER stroke
outline (open-line outlines, point-cap circles/squares, and
closed-contour outer rings) to one fixed direction in
StrokeOutliner, regardless of outline kind or source contour
authoring order. PathStroker.Stroke emits each outline as an
independent NonZero-filled subpath, so previously two overlapping
outer outlines from the same Stroke call could carry opposite
signed winding purely by chance and cancel to an incorrect unfilled
hole in their overlap instead of reinforcing (unioning). Inner
(hole) rings remain intentionally unnormalized, fixed up relative
to their own outer ring so they stay the opposite winding, per the
existing (correct) shell/hole invariant.
- Compute the per-segment tangent/normal frame in BuildSegmentFrames
using double-precision coordinate deltas before narrowing back to
Vector2/float32, matching the pattern already used in
DashSplitter.BuildCumulativeLengths. A finite edge spanning
near-extreme float32 coordinates (e.g. -1e20 to 1e20) previously
overflowed float32's dx*dx+dy*dy inside Vector2.Length() to
Infinity, collapsing the tangent/normal to zero and silently
dropping the stroke outline for that segment.
- Add regression tests proving both defects reproduce pre-fix and are
resolved post-fix, with zero regressions across all existing
StrokeOutliner/PathStroker/DashSplitter tests.
- Update the PathStroker design and verification docs to describe the
outer-outline winding-normalization rule and list the new tests.
AreAllPointsCollinear normalizes with Vector2.Length(), which is float32. For a valid finite closed contour whose first edge spans more than about 1e19 units, that length becomes Infinity, so normalizedDirection becomes (0,0) and every perpendicular distance is zero. A non-collinear contour is then incorrectly routed through CreateOpenStrokePolygons instead of producing a closed shell; compute the normalization and cross product in double precision, as BuildSegmentFrames already does.
Tiny diagonal edges are incorrectly treated as horizontal
BuildSegmentFrames treats every nonzero edge shorter than 1e-6 as horizontal by substituting Vector2.UnitX. SimplifyPoints removes only exact duplicate vertices, so a valid tiny diagonal segment such as (0,0) to (5e-7,5e-7) reaches this branch and is stroked with the wrong tangent/normal and cap orientation. Do not replace nonzero edges with UnitX based on an absolute world-space threshold; use exact-zero/duplicate handling or a scale-relative degeneracy test.
ComputeSignedArea accumulates coordinate products in float. With large but finite translated coordinates, terms such as x*y can overflow to Infinity and cancellation can produce NaN; then the area >= 0f choice and the winding/collapse logic no longer select the correct outer and inner rings. A clockwise contour can consequently use the inward ring as the outer ring and bypass the inner-ring-collapse check. Accumulate and return the signed area in double so orientation remains reliable for the finite Vector2 range.
…te docs
- Widen AreAllPointsCollinear and ComputeSignedArea to compute vertex
differences, squared lengths, normalization, and shoelace cross products
in double precision, mirroring the pattern already applied to
BuildSegmentFrames/BuildCumulativeLengths. Fixes silent misclassification
of large-but-legitimate non-collinear closed contours as degenerate, and
unreliable outer/inner winding-sign selection, at near-extreme float32
vertex magnitudes.
- ComputeSignedArea now returns double (sign-only consumers), and the
outer/inner winding-sign comparison compares signs directly instead of
multiplying two areas together, avoiding a secondary overflow/NaN risk.
- Add a regression test proving a closed, non-collinear triangle with
~2.5e19-magnitude vertices was misclassified as degenerate pre-fix (1
outline instead of 2) and is correctly outlined as a shell post-fix.
- Update docs/verification/canvas-net/drawing/path-stroker.md with all test
names added across this session's fix rounds, grouped under new
"Concave/Collinear Closed Contours" and "Robustness Against Extreme and
Degenerate Numeric Input" subsections.
- Update docs/reqstream/canvas-net/drawing/path-stroker.yaml: link the same
newly-covered tests to existing requirements, and add new requirements for
degenerate/collinear closed contours, overlapping-outline union rendering,
inner-corner exact-intersection behavior, and per-vertex concave join
resolution.
- Update docs/design/canvas-net/drawing/path-stroker.md: document the
collinear-closed-contour degenerate case alongside the existing
single-point case, and add a "Double-Precision Internal Computations"
subsection explaining why several internal computations are deliberately
done in double precision despite the public float32-based API.
The public constructor documents independent validation for undefined LineCap/LineJoin, non-finite miter limits and dash offsets, and non-finite dash entries, but StrokeStyleTests only covers width, the lower miter bound, negative entries, and all-zero arrays. Add cases for these remaining branches so invalid public inputs cannot regress while the suite remains green.
…roke
The 6 new unit-level requirements added for degenerate-closed-contour,
overlap-union, inner-corner-exact-intersection, concave-join-resolution,
dash-robustness, and numeric-robustness behavior were unreachable from any
quality/system-tagged requirement, which the CI 'Build Documents' job's
reqstream --enforce orphan check correctly flagged. Link them as children of
the existing CanvasNet-Drawing-PathStroke subsystem requirement, alongside
the other PathStroker unit requirements it already decomposes into.
Although the cumulative distances are kept in double, this narrows the interpolation fraction to float before calling Vector2.Lerp. On a single long edge, that converts relative float rounding into large absolute dash-boundary errors (the long-edge discussion in the new test notes about 0.5 units at 17,000,000 units), even where the resulting coordinate is exactly representable. Interpolate each coordinate in double and narrow only the final values.
Add tests for invalid StrokeStyle constructor inputs
The constructor now exposes validation branches for undefined LineCap/LineJoin, non-finite miter limits and dash offsets, and non-finite dash entries, but the new StrokeStyleTests do not exercise any of those documented invalid categories. Because this constructor is the public validation boundary, add theory cases for each category so these guarantees cannot regress silently.
This test materializes 17 million vertices and asks Split to emit about 8.5 million two-point List<Vector2> segments. The input, cumulative-length array, interval list, segment list, and millions of per-segment objects/backing arrays can consume gigabytes and cause GC thrashing or OOM in CI, especially when the matrix runs net8.0/net9.0/net10.0. Exercise the same float-progress boundary with a much smaller path/output (for example, a large initial span followed by a one-unit span) and adjust the assertions accordingly.
… on Vector2.Lerp's documented float32 subtraction formula
GetPointAtDistance previously called Vector2.Lerp(edgeStart, edgeEnd, t) to
turn a distance-along-path back into a point. Vector2.Lerp's documented
contract computes start + (end - start) * amount with the subtraction
performed on float32 endpoints: for an edge from -float.MaxValue to
float.MaxValue, that subtraction overflows to +Infinity even though the
true (double-precision) delta is finite, which would poison every
downstream coordinate of the interpolated point with Infinity/NaN.
Interpolate each coordinate explicitly as start + (end - start) * t in
double precision before narrowing back to float, so the result no longer
depends on whichever internal formula a given runtime happens to use for
Vector2.Lerp.
Added a regression test exercising a dash-interval boundary landing
strictly inside a -float.MaxValue to float.MaxValue edge, asserting the
interpolated point is finite and numerically matches the analytically
expected position.
Final sweep: reviewed every remaining Vector2/float subtraction,
multiplication, division, Length()/LengthSquared()/Distance() call, and
double-to-float narrowing in DashSplitter.cs and StrokeOutliner.cs. No
further overflow-prone operations were found beyond what prior rounds
already fixed; the small vertex-plus-halfWidth-offset computations used
throughout StrokeOutliner's join/cap logic are bounded perturbations (not
overflow-prone) and any precision loss there at astronomical coordinate
magnitudes is an inherent limit of float32 vertex storage, not a fixable
arithmetic-ordering bug.
A nonzero segment shorter than NearZeroDistance is still retained by SimplifyPoints, but this branch replaces its actual direction with Vector2.UnitX. For example, a vertical (0,0) to (0,5e-7) segment is outlined as a horizontal strip; with a wide stroke this produces materially wrong geometry, not a negligible rounding difference. Collapse such short segments using the intended degenerate-point semantics or normalize their actual double-precision direction instead of choosing an arbitrary horizontal frame.
Use double precision for miter ratio distance calculation
StrokeStyle permits any finite positive width, but Vector2.Distance computes its squared distance in float. A valid large-width join (for example, a right angle with width around 2e20) can have a finite miter point whose distance calculation overflows to Infinity, so this branch rejects a miter whose actual ratio is below MiterLimit and incorrectly falls back to a bevel. Compute the distance/ratio in double, consistent with the surrounding extreme-value handling.
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
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.
Summary
Implements Phase 3 of the CanvasNet Drawing roadmap: stroke-to-fill conversion.
Adds a new
PathStrokerunit to theDemaConsulting.CanvasNet.Drawingnamespace that converts a strokedPath+StrokeStyleinto a new fillable outlinePath, which is then rasterized by the existingPathFiller/ScanlineRasterizer(NonZero fill rule) — no new rasterization code path is introduced.New public API
StrokeStyle— immutable stroke parameters:Width,Cap,Join,MiterLimit(SVG-style, default 4),DashArray,DashOffset, with full constructor validation.LineCap—Butt/Round/Square.LineJoin—Miter/Round/Bevel.PathStroker.Stroke(Path path, StrokeStyle style, float flattenTolerance = 0.25f) -> Path.Internal helpers
StrokePathFlattener— flattens subpaths to polylines while preserving open/closed state.DashSplitter— splits a polyline into dash "on" segments (odd-length arrays, dash offset, closed-polyline wraparound).StrokeOutliner— computes offset/join/cap geometry, including miter-limit bevel fallback, round-join/cap arc tessellation, and closed-subpath counter-wound outline rings.Companion artifacts
Full artifact set added/updated to match the existing
PathFillerunit's structure:canvas-net.yaml), subsystem (drawing.yaml), and unit (drawing/path-stroker.yaml) level requirements — behavior-only titles, no internal algorithm/class-name leakage.design/canvas-net/drawing/path-stroker.md) plus subsystem/system/introduction updates..reviewmark.yaml: newCanvasNet-Drawing-PathStrokerreview set.README.mdanddocs/user_guide/introduction.md: new feature bullet, API reference section, and usage example.Tests
test/DemaConsulting.CanvasNet.Tests/Drawing/:StrokeStyleTests,StrokePathFlattenerTests,DashSplitterTests,StrokeOutlinerTests,PathStrokerTests.PathStrokerTestsverifies actual rendered pixel coverage (viaPathStroker.Stroke+PathFiller.Fill) for line caps, corner joins, a closed ring, miter-limit bevel fallback, dashed lines, zero-length/single-point subpaths, empty-path no-op, and argument validation.Validation performed locally
pwsh ./fix.ps1pwsh ./build.ps1— 0 warnings, 0 errors, 2040/2040 tests passed (net8.0/net9.0/net10.0)pwsh ./lint.ps1— clean (yamllint, cspell, markdownlint, reqstream --lint / --matrix --enforce, reviewmark, sysml2tools, dotnet format)