Skip to content

Add DemaConsulting.CanvasNet.Drawing stroke-to-fill conversion (Drawing Phase 3: strokes) - #10

Merged
Malcolmnixon merged 13 commits into
mainfrom
feature/drawing-strokes
Sep 21, 2026
Merged

Malcolmnixon merged 13 commits into
mainfrom
feature/drawing-strokes

Conversation

@Malcolmnixon

Copy link
Copy Markdown
Member

Summary

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.
  • 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 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.

Validation performed locally

  • pwsh ./fix.ps1
  • pwsh ./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)

…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.
Copilot AI lite review requested due to automatic review settings September 21, 2026 15:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Valid dash inputs can hang, dash extraction can become quadratic, and degenerate or closed stroke geometry can produce incorrect output.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 2 Medium severity

Open (3)
What changed in this PR

Adds Phase 3 stroke-to-fill rendering to the Drawing subsystem.

Changes:

  • Adds PathStroker, StrokeStyle, caps, joins, dashing, and outline generation.
  • Adds comprehensive unit and rendering tests.
  • Updates requirements, design, verification, SysML, review metadata, README, and user documentation.
File Description
src/​.../​Drawing/​PathStroker.cs Public stroke conversion API
src/​.../​Drawing/​StrokeStyle.cs Immutable stroke configuration
src/​.../​Drawing/​StrokeOutliner.cs Outline, join, cap, and ring geometry
src/​.../​Drawing/​DashSplitter.cs Dash segmentation
src/​.../​Drawing/​StrokePathFlattener.cs Stroke-specific path flattening
src/​.../​Drawing/​LineCap.cs Cap enum
src/​.../​Drawing/​LineJoin.cs Join enum
src/​.../​Drawing/​NamespaceDoc.cs Namespace documentation
test/​.../​Drawing/​PathStrokerTests.cs End-to-end stroke tests
test/​.../​Drawing/​StrokeStyleTests.cs Style validation tests
test/​.../​Drawing/​StrokeOutlinerTests.cs Outline geometry tests
test/​.../​Drawing/​DashSplitterTests.cs Dash behavior tests
test/​.../​Drawing/​StrokePathFlattenerTests.cs Flattening tests
requirements.yaml Registers new requirements
docs/​reqstream/​... Adds stroke requirements
docs/​design/​... Adds stroke design documentation
docs/​verification/​... Adds stroke verification documentation
docs/​sysml2/​... Models the new unit and subsystem relationship
docs/​user_guide/​introduction.md Documents the public API
README.md Adds feature overview and example
.reviewmark.yaml Adds the PathStroker review set

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/DemaConsulting.CanvasNet/Drawing/DashSplitter.cs Outdated
Comment thread src/DemaConsulting.CanvasNet/Drawing/DashSplitter.cs Outdated
Comment thread src/DemaConsulting.CanvasNet/Drawing/StrokeOutliner.cs Outdated
…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.
Copilot AI review requested due to automatic review settings September 21, 2026 15:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Stroke geometry has incorrect inner-join and closed-degenerate behavior, and parent-level requirements cite unit tests as system/subsystem evidence.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 3 Medium severity

Open (4)
Previously missed (6)

In code that hasn't changed since last review

Medium severity Closed degenerate subpaths incorrectly apply line caps

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeOutliner.cs:47

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.

Medium severity Inner joins use bevel chords instead of offset-line intersections

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeOutliner.cs:307

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.

Medium severity Inner offset rings incorrectly apply the requested join style

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeOutliner.cs:356

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.

Medium severity Miter limit uses half-width instead of stroke width

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeStyle.cs:31

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.

Low severity 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.

Low severity Drawing requirement lacks system-level integration evidence

docs/​reqstream/​canvas-net/​drawing.yaml:105

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.

Comment thread src/DemaConsulting.CanvasNet/Drawing/StrokeOutliner.cs Outdated
- 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.
Copilot AI review requested due to automatic review settings September 21, 2026 16:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Dash intervals, closed-path inner joins, and miter-limit semantics contain correctness or contract inconsistencies.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (4)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Merge adjacent dash intervals when zero-length gaps occur

src/​DemaConsulting.CanvasNet/​Drawing/​DashSplitter.cs:82

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.

Medium severity Align miter-limit denominator with the documented stroke-width ratio

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeOutliner.cs:449

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.

Comment thread src/DemaConsulting.CanvasNet/Drawing/StrokeOutliner.cs Outdated
…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.
Copilot AI review requested due to automatic review settings September 21, 2026 16:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Zero-length dash phases and concave closed-contour joins currently produce incorrect geometry, with related documentation inconsistencies.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 1 Low severity

Open (2)
Resolved since last review (5)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Skip zero-length dash entries before determining parity

src/​DemaConsulting.CanvasNet/​Drawing/​DashSplitter.cs:239

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.

Medium severity Align miter limit documentation with its calculation

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeStyle.cs:32

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.

Comment thread src/DemaConsulting.CanvasNet/Drawing/StrokeOutliner.cs
Comment thread docs/design/canvas-net/drawing/path-stroker.md Outdated
Copilot AI review requested due to automatic review settings September 21, 2026 17:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The implementation has unresolved dash-phase and miter-limit correctness issues, and several documentation/requirements mappings are inaccurate.

Review effort: Lite
Findings: None

Resolved since last review (3)
Previously missed (5)

In code that hasn't changed since last review

Medium severity Handle zero-length dash entries at phase boundary

src/​DemaConsulting.CanvasNet/​Drawing/​DashSplitter.cs:239

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.

Medium severity Miter limit uses half-width instead of stroke width

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeOutliner.cs:524

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.

Low severity 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.

Low severity 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.

Low severity Correct invalid expected pixel outside visible dashed stroke

docs/​user_guide/​introduction.md:993

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.
Copilot AI review requested due to automatic review settings September 21, 2026 18:02
@Malcolmnixon
Malcolmnixon force-pushed the feature/drawing-strokes branch from c9a998c to ee946cc Compare September 21, 2026 18:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Three correctness issues remain in degenerate closed contours, collapsed inner shells, and extreme dash-phase arithmetic.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 2 Medium severity

Open (2)
Resolved since last review (2)

Comment thread src/DemaConsulting.CanvasNet/Drawing/DashSplitter.cs Outdated
Comment thread src/DemaConsulting.CanvasNet/Drawing/StrokeOutliner.cs
…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().
Copilot AI review requested due to automatic review settings September 21, 2026 18:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Large-path dash traversal can hang, seam stitching can erase valid gaps, and collapsed inner offsets can discard valid closed strokes.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (2)
Previously missed (3)

In code that hasn't changed since last review

Medium severity Fixed tolerance incorrectly closes real dash gaps

src/​DemaConsulting.CanvasNet/​Drawing/​DashSplitter.cs:114

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).

Medium severity Collapsed inner offset discards valid stroke geometry

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeOutliner.cs:303

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.

Low severity Add tests for invalid LineCap and LineJoin values

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeStyle.cs:71

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.

Comment thread src/DemaConsulting.CanvasNet/Drawing/DashSplitter.cs Outdated
…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>
Copilot AI review requested due to automatic review settings September 21, 2026 19:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Wide closed strokes can produce invalid interior holes, and float signed-area cancellation can select the wrong styled ring.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (3)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Prevent translated shoelace cancellation from reversing stroke orientation

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeOutliner.cs:899

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.

Low severity Align cap documentation with degenerate closed-contour behavior

src/​DemaConsulting.CanvasNet/​Drawing/​LineCap.cs:8

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.

Comment thread src/DemaConsulting.CanvasNet/Drawing/StrokeOutliner.cs Outdated
… 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.
Copilot AI review requested due to automatic review settings September 21, 2026 19:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (2)
Previously missed (3)

In code that hasn't changed since last review

Medium severity Zero-length dash gaps create artificial caps

src/​DemaConsulting.CanvasNet/​Drawing/​DashSplitter.cs:407

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.

Medium severity Open stroke outlines reverse winding with subpath direction

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeOutliner.cs:210

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.

Medium severity Closed stroke rings reverse winding with contour direction

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeOutliner.cs:332

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.

Comment thread src/DemaConsulting.CanvasNet/Drawing/DashSplitter.cs Outdated
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.
Copilot AI review requested due to automatic review settings September 21, 2026 20:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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.

Review effort: Lite
Findings: 2 High severity

Open (2)
Resolved since last review (2)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Avoid merging valid dash gaps at closed-path seams

src/​DemaConsulting.CanvasNet/​Drawing/​DashSplitter.cs:114

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.

Medium severity Add tests for documented invalid stroke style parameters

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeStyle.cs:71

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.

Comment thread src/DemaConsulting.CanvasNet/Drawing/PathStroker.cs
Comment thread src/DemaConsulting.CanvasNet/Drawing/StrokeOutliner.cs Outdated
…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.
Copilot AI review requested due to automatic review settings September 21, 2026 20:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The outline math mishandles valid tiny segments and extreme-coordinate closed contours through absolute thresholds and float32 calculations.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (1)
Previously missed (3)

In code that hasn't changed since last review

Medium severity Float normalization misclassifies huge non-collinear contours

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeOutliner.cs:148

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.

Medium severity Tiny diagonal edges are incorrectly treated as horizontal

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeOutliner.cs:384

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.

Medium severity Float signed-area overflow breaks contour winding selection

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeOutliner.cs:1037

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.
Copilot AI review requested due to automatic review settings September 21, 2026 22:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Dash extraction can produce non-finite endpoints for valid extreme coordinates, and the requirements hierarchy is incomplete.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 1 Low severity

Open (2)
Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Add tests for all documented invalid StrokeStyle inputs

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeStyle.cs:71

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.

Comment thread src/DemaConsulting.CanvasNet/Drawing/DashSplitter.cs Outdated
Comment thread docs/reqstream/canvas-net/drawing.yaml
…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.
Copilot AI review requested due to automatic review settings September 21, 2026 22:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The review found interpolation precision loss, an excessively memory-intensive regression test, and untested public validation branches.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (1)
Previously missed (3)

In code that hasn't changed since last review

Medium severity Avoid float narrowing before long-edge interpolation

src/​DemaConsulting.CanvasNet/​Drawing/​DashSplitter.cs:556

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.

Medium severity Add tests for invalid StrokeStyle constructor inputs

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeStyle.cs:73

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.

Medium severity Reduce oversized path allocation in boundary test

test/​DemaConsulting.CanvasNet.Tests/​Drawing/​DashSplitterTests.cs:282

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.
Copilot AI review requested due to automatic review settings September 21, 2026 22:58

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Miter distance overflow and arbitrary orientation for near-zero segments can produce incorrect stroke geometry.

Review effort: Lite
Findings: None

Resolved since last review (1)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Preserve direction of short nonzero segments

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeOutliner.cs:414

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.

Medium severity Use double precision for miter ratio distance calculation

src/​DemaConsulting.CanvasNet/​Drawing/​StrokeOutliner.cs:738

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants