diff --git a/.cspell.yaml b/.cspell.yaml index 8a23dda3..db044c78 100644 --- a/.cspell.yaml +++ b/.cspell.yaml @@ -14,16 +14,20 @@ language: en # Project-specific technical terms and tool names words: + - Aabb + - AABB - Adler - apimark - ApiMark - basn + - Beziers - BGRA - BITMAPCOREHEADER - BITMAPFILEHEADER - BITMAPINFOHEADER - BITMAPV - buildmark + - Casteljau - CMYK - coeffs - dequantize @@ -79,6 +83,7 @@ words: - premultiplies - premultiplying - Qube + - rasterizer - reqstream - reviewmark - Rgba @@ -94,11 +99,14 @@ words: - subsampling - seekable - Seekable + - Subpaths + - subpaths - sysml - tEXt - triaging - Truecolor - Unpremultiply + - unioning - unpremultiplication - unpremultiplied - unpremultiplies diff --git a/.reviewmark.yaml b/.reviewmark.yaml index 1fbcd5ba..9fffa595 100644 --- a/.reviewmark.yaml +++ b/.reviewmark.yaml @@ -210,6 +210,82 @@ reviews: - "test/DemaConsulting.CanvasNet.Tests/Codecs/JpegCodecTests.cs" # unit tests - "test/DemaConsulting.CanvasNet.Tests/Codecs/JpegFixtureTests.cs" # fixture conformance tests + # CanvasNet-Geometry Review (one per subsystem) + - id: CanvasNet-Geometry + title: Review that CanvasNet Geometry Subsystem Implementation is Correct + context: + - "docs/design/canvas-net.md" + - "docs/reqstream/canvas-net.yaml" + paths: + - "docs/reqstream/canvas-net/geometry.yaml" # subsystem requirements + - "docs/design/canvas-net/geometry.md" # subsystem design + - "docs/verification/canvas-net/geometry.md" # subsystem verification + + # CanvasNet-Geometry-Rect Review (one per unit) + - id: CanvasNet-Geometry-Rect + title: Review that CanvasNet Geometry Rect Implementation is Correct + context: + - "docs/design/canvas-net.md" + - "docs/reqstream/canvas-net.yaml" + - "docs/design/canvas-net/geometry.md" + - "docs/reqstream/canvas-net/geometry.yaml" + paths: + - "docs/reqstream/canvas-net/geometry/rect.yaml" # unit requirements + - "docs/design/canvas-net/geometry/rect.md" # unit design + - "docs/verification/canvas-net/geometry/rect.md" # unit verification + - "src/DemaConsulting.CanvasNet/Geometry/Rect.cs" # unit source + - "src/DemaConsulting.CanvasNet/Geometry/NamespaceDoc.cs" # Geometry namespace documentation + - "test/DemaConsulting.CanvasNet.Tests/Geometry/RectTests.cs" # unit tests + + # CanvasNet-Geometry-Path Review (one per unit) + - id: CanvasNet-Geometry-Path + title: Review that CanvasNet Geometry Path Implementation is Correct + context: + - "docs/design/canvas-net.md" + - "docs/reqstream/canvas-net.yaml" + - "docs/design/canvas-net/geometry.md" + - "docs/reqstream/canvas-net/geometry.yaml" + paths: + - "docs/reqstream/canvas-net/geometry/path.yaml" # unit requirements + - "docs/design/canvas-net/geometry/path.md" # unit design + - "docs/verification/canvas-net/geometry/path.md" # unit verification + - "src/DemaConsulting.CanvasNet/Geometry/Path.cs" # unit source + - "src/DemaConsulting.CanvasNet/Geometry/PathBuilder.cs" # unit source + - "src/DemaConsulting.CanvasNet/Geometry/Subpath.cs" # supporting data type source + - "src/DemaConsulting.CanvasNet/Geometry/PathCommand.cs" # supporting data type source + - "src/DemaConsulting.CanvasNet/Geometry/PathCommandType.cs" # supporting data type source + - "test/DemaConsulting.CanvasNet.Tests/Geometry/PathBuilderTests.cs" # unit tests + + # CanvasNet-Geometry-BezierFlattening Review (one per unit) + - id: CanvasNet-Geometry-BezierFlattening + title: Review that CanvasNet Geometry BezierFlattening Implementation is Correct + context: + - "docs/design/canvas-net.md" + - "docs/reqstream/canvas-net.yaml" + - "docs/design/canvas-net/geometry.md" + - "docs/reqstream/canvas-net/geometry.yaml" + paths: + - "docs/reqstream/canvas-net/geometry/bezier-flattening.yaml" # unit requirements + - "docs/design/canvas-net/geometry/bezier-flattening.md" # unit design + - "docs/verification/canvas-net/geometry/bezier-flattening.md" # unit verification + - "src/DemaConsulting.CanvasNet/Geometry/BezierFlattening.cs" # unit source + - "test/DemaConsulting.CanvasNet.Tests/Geometry/BezierFlatteningTests.cs" # unit tests + + # CanvasNet-Geometry-SvgArcConverter Review (one per unit) + - id: CanvasNet-Geometry-SvgArcConverter + title: Review that CanvasNet Geometry SvgArcConverter Implementation is Correct + context: + - "docs/design/canvas-net.md" + - "docs/reqstream/canvas-net.yaml" + - "docs/design/canvas-net/geometry.md" + - "docs/reqstream/canvas-net/geometry.yaml" + paths: + - "docs/reqstream/canvas-net/geometry/svg-arc-converter.yaml" # unit requirements + - "docs/design/canvas-net/geometry/svg-arc-converter.md" # unit design + - "docs/verification/canvas-net/geometry/svg-arc-converter.md" # unit verification + - "src/DemaConsulting.CanvasNet/Geometry/SvgArcConverter.cs" # unit source + - "test/DemaConsulting.CanvasNet.Tests/Geometry/SvgArcConverterTests.cs" # unit tests + # OTS-BuildMark Review (one per OTS item) - id: OTS-BuildMark title: Review that BuildMark Provides Required Functionality diff --git a/docs/design/canvas-net.md b/docs/design/canvas-net.md index 9f697f28..39fa27d5 100644 --- a/docs/design/canvas-net.md +++ b/docs/design/canvas-net.md @@ -7,7 +7,7 @@ This document provides the system-level design for CanvasNet. ## Architecture CanvasNet is a .NET library providing a canvas-based drawing and rendering API, following -DEMA Consulting best practices. The system consists of two implemented subsystems, plus one +DEMA Consulting best practices. The system consists of three implemented subsystems, plus one subsystem reserved for future work: - **Canvas subsystem** (namespace `DemaConsulting.CanvasNet.Canvas`, folder @@ -23,9 +23,23 @@ subsystem reserved for future work: Grayscale, strip-based TIFF 6.0 with None/PackBits/LZW/Deflate compression, either byte order), and `JpegCodec` (a common real-world subset of JPEG: baseline/progressive decode with 4:4:4/4:2:2/4:2:0 support, baseline 4:2:0 encode). See _Codecs Subsystem Design_ (`codecs.md`). +- **Geometry subsystem** (namespace `DemaConsulting.CanvasNet.Geometry`, folder + `src/DemaConsulting.CanvasNet/Geometry/`, flat — no further nesting): vector-geometry + primitives, built directly on `System.Numerics.Vector2`/`Matrix3x2` — the `Rect` unit (an + axis-aligned bounding rectangle with a union-identity `Empty` sentinel), the `Path` unit (an + immutable vector path and its fluent `PathBuilder`, together with the supporting `Subpath`, + `PathCommand`, and `PathCommandType` types documented inline), the `BezierFlattening` unit + (adaptive quadratic/cubic Bezier curve flattening), and the `SvgArcConverter` unit + (SVG-style elliptical arc to cubic Bezier conversion). `Geometry` has no dependency on `Canvas` + or `Codecs`, and neither of those subsystems depends on `Geometry`. See + _Geometry Subsystem Design_ (`geometry.md`). - **Drawing subsystem** (reserved): not yet implemented. No folder, namespace, or documentation - exists for it yet. It is reserved for future drawing primitives (shapes, brushes, pens, - transforms) that will build on top of the `Canvas` subsystem's `Surface` unit. + exists for it yet. It is reserved for future higher-level drawing primitives (shapes, brushes, + pens, and rendering of a `Geometry.Path` onto a `Canvas.Surface`) that will build on top of both + the `Canvas` subsystem's `Surface` unit and the `Geometry` subsystem's vector primitives. + `Geometry` is deliberately distinct from `Drawing`: `Geometry` describes shape geometry (paths, + bounds, curve math) with no notion of pixels, color, or rasterization, while the reserved + `Drawing` subsystem will own turning that geometry into pixels. The `Codecs` subsystem depends on the `Canvas` subsystem's `Surface` unit (constructing surfaces and reading/writing rows via `Surface.GetRowSpanBytes`); the `Canvas` subsystem has no dependency @@ -42,6 +56,18 @@ _Surface Unit Design_ (`canvas/surface.md`), _BmpCodec Unit Design_ _TiffCodec Unit Design_ (`codecs/tiff-codec.md`), and _JpegCodec Unit Design_ (`codecs/jpeg-codec.md`) for each unit's internal collaboration. +The `Geometry` subsystem's four units collaborate as follows: `PathBuilder` records raw drawing +commands (including raw, unconverted SVG arc parameters) and produces immutable `Path` snapshots; +`Path.GetBounds` is the primary internal consumer of both `BezierFlattening` (to flatten curves +when a tighter, tolerance-based bound is requested) and `SvgArcConverter` (to convert any `ArcTo` +command to cubic Bezier segments before applying either bounds mode, since arcs carry no control +points of their own). `Rect` has no dependency on the other three units, but is the return type of +`Path.GetBounds` and is used throughout as the common bounding-box representation. See +_Geometry Subsystem Design_ (`geometry.md`), _Rect Unit Design_ (`geometry/rect.md`), +_Path Unit Design_ (`geometry/path.md`), _BezierFlattening Unit Design_ +(`geometry/bezier-flattening.md`), and _SvgArcConverter Unit Design_ +(`geometry/svg-arc-converter.md`) for each unit's internal collaboration. + ## External Interfaces The system exposes the following public API to external consumers: @@ -108,26 +134,65 @@ The system exposes the following public API to external consumers: range 1-100. Throws `ArgumentNullException` for a null `surface`/`stream`/`path`, `ArgumentException` for an empty `path`, and `ArgumentOutOfRangeException` for an out-of-range `quality`. - -| Interface | Direction | Format | Constraints | -| -------------------------------- | ---------------- | ------------------------------ | ---------------------------- | -| `Surface(int, int)` | Inbound | Constructor call | `width`, `height` in 1-8192 | -| `Surface[int, int]` | Inbound/Outbound | Indexer get/set | `x`, `y` within bounds | -| `Surface.GetRowSpanBytes(int)` | Outbound | `Span` return | `y` within bounds | -| `Surface.GetRowSpan(int)` | Outbound | `Span` return | `y` within bounds | -| `Surface.Crop(int,int,int,int)` | Inbound/Outbound | Method call / `Surface` return | Region within source bounds | -| `Surface.PremultiplyAlpha()` | Inbound | Method call | None | -| `Surface.UnpremultiplyAlpha()` | Inbound | Method call | None | -| `Surface.CompositeOver(Surface)` | Inbound | Method call | Equal dimensions, non-null | -| `Surface.CompositeOver(Rgba32)` | Inbound | Method call | None | -| `BmpCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid BMP stream or path | -| `BmpCodec.Save(...)` | Inbound | Method call | `surface` non-null | -| `PngCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid PNG stream or path | -| `PngCodec.Save(...)` | Inbound | Method call | `surface` non-null | -| `TiffCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid TIFF stream or path | -| `TiffCodec.Save(...)` | Inbound | Method call | `surface` non-null | -| `JpegCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid JPEG stream or path | -| `JpegCodec.Save(...)` | Inbound | Method call | `surface` non-null | +- **Rect(float x, float y, float width, float height)**: Constructor; an axis-aligned rectangle in + position-plus-size form. `Rect.Empty` is a static, publicly readable union-identity sentinel. +- **Rect.Union(Rect)** / **Rect.Union(Rect, Rect)**: Returns the smallest rectangle enclosing both + rectangles (instance and static forms). +- **Rect.Intersect(Rect)** / **Rect.Intersect(Rect, Rect)**: Returns the overlapping region, or + `Rect.Empty` if the rectangles are disjoint (instance and static forms). +- **Rect.Transform(Matrix3x2)**: Returns the smallest axis-aligned rectangle enclosing this + rectangle after applying the given transform to all four corners. +- **Rect.Contains(Vector2)**: Returns whether a point lies within the rectangle (half-open on both + axes). +- **PathBuilder()**: Constructor; a reusable, mutable, fluent path builder. +- **PathBuilder.MoveTo/LineTo/QuadraticBezierTo/CubicBezierTo/ArcTo(...)**: Fluent, `this`-returning + methods appending a drawing command to the current subpath (or starting a new one, for + `MoveTo`). `LineTo`/`QuadraticBezierTo`/`CubicBezierTo`/`ArcTo`/`Close` throw + `InvalidOperationException` if called before the first `MoveTo`, or after `Close` without an + intervening `MoveTo`. +- **PathBuilder.Close()**: Fluent method marking the current subpath as closed. +- **PathBuilder.Build()**: Returns an immutable `Path` snapshot of every command issued so far; + the builder remains usable afterward. +- **PathBuilder.Clear()**: Resets the builder to its initial empty state for reuse. +- **Path.Subpaths**: Read-only property exposing the path's ordered, independent subpaths. +- **Path.GetBounds(float flattenTolerance = 0)**: Returns a conservative (default) or, given a + positive `flattenTolerance`, a tighter flattening-based axis-aligned bounding `Rect`. +- **Path.Empty**: Static singleton with zero subpaths. +- **BezierFlattening.FlattenCubic(...)** / **BezierFlattening.FlattenQuadratic(...)**: Appends the + adaptively flattened polyline points (start point never written, end point always written + last) for a cubic or quadratic Bezier curve to a caller-supplied `IList`. Throws + `ArgumentOutOfRangeException` if `tolerance` is less than or equal to zero. +- **SvgArcConverter.ToBeziers(...)**: Appends the cubic Bezier segments equivalent to an SVG-style + elliptical arc to a caller-supplied output list, in end-to-end order. Never throws for any + SVG-valid input. + +| Interface | Direction | Format | Constraints | +| -------------------------------- | ---------------- | ------------------------------ | ----------------------------- | +| `Surface(int, int)` | Inbound | Constructor call | `width`, `height` in 1-8192 | +| `Surface[int, int]` | Inbound/Outbound | Indexer get/set | `x`, `y` within bounds | +| `Surface.GetRowSpanBytes(int)` | Outbound | `Span` return | `y` within bounds | +| `Surface.GetRowSpan(int)` | Outbound | `Span` return | `y` within bounds | +| `Surface.Crop(int,int,int,int)` | Inbound/Outbound | Method call / `Surface` return | Region within source bounds | +| `Surface.PremultiplyAlpha()` | Inbound | Method call | None | +| `Surface.UnpremultiplyAlpha()` | Inbound | Method call | None | +| `Surface.CompositeOver(Surface)` | Inbound | Method call | Equal dimensions, non-null | +| `Surface.CompositeOver(Rgba32)` | Inbound | Method call | None | +| `BmpCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid BMP stream or path | +| `BmpCodec.Save(...)` | Inbound | Method call | `surface` non-null | +| `PngCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid PNG stream or path | +| `PngCodec.Save(...)` | Inbound | Method call | `surface` non-null | +| `TiffCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid TIFF stream or path | +| `TiffCodec.Save(...)` | Inbound | Method call | `surface` non-null | +| `JpegCodec.Load(...)` | Inbound/Outbound | Method call / `Surface` return | Valid JPEG stream or path | +| `JpegCodec.Save(...)` | Inbound | Method call | `surface` non-null | +| `Rect.Union(...)` | Inbound/Outbound | Method call / `Rect` return | None | +| `Rect.Intersect(...)` | Inbound/Outbound | Method call / `Rect` return | None | +| `Rect.Transform(Matrix3x2)` | Inbound/Outbound | Method call / `Rect` return | None | +| `PathBuilder.*To(...)` | Inbound/Outbound | Method call / `this` return | Called after `MoveTo` | +| `PathBuilder.Build()` | Outbound | Method call / `Path` return | None | +| `Path.GetBounds(float)` | Outbound | Method call / `Rect` return | None | +| `BezierFlattening.Flatten*(...)` | Inbound/Outbound | Method call / list append | `tolerance` greater than zero | +| `SvgArcConverter.ToBeziers(...)` | Inbound/Outbound | Method call / list append | None | ## Dependencies @@ -140,7 +205,11 @@ Library (`Surface`'s remaining use of `Span` and `MemoryMarshal` are BCL APIs natively on every target framework; `BmpCodec` uses only `System.IO` types; `PngCodec` and `TiffCodec` additionally use `System.IO.Compression.DeflateStream`; `JpegCodec` additionally uses `System.Numerics.Vector` for optional SIMD acceleration; all of these are BCL APIs available on -every target framework, with no additional runtime NuGet package required). The following OTS +every target framework, with no additional runtime NuGet package required). The `Geometry` +subsystem introduces zero new runtime NuGet dependencies: it is implemented entirely against +`System.Numerics.Vector2` and `System.Numerics.Matrix3x2`, which are in-box BCL types available +natively on every target framework this library supports (net8.0, net9.0, net10.0) — no custom +point/vector wrapper types were introduced. The following OTS items are used for building and verifying this system (not consumed at runtime); see _OTS Integration Design_ (`docs/design/ots.md`) and each item's dedicated design document for details: diff --git a/docs/design/canvas-net/geometry.md b/docs/design/canvas-net/geometry.md new file mode 100644 index 00000000..1160b13f --- /dev/null +++ b/docs/design/canvas-net/geometry.md @@ -0,0 +1,59 @@ +## Geometry + +![Geometry Structure](GeometryView.svg) + +The `Geometry` subsystem is the third software subsystem in CanvasNet. It provides +vector-geometry primitives that are independent of pixels, color, and rasterization: an +axis-aligned bounding rectangle (`Rect`), an immutable vector path and its fluent builder +(`Path`/`PathBuilder`), adaptive Bezier curve flattening (`BezierFlattening`), and SVG-style +elliptical arc to cubic Bezier conversion (`SvgArcConverter`). + +### Purpose + +The `Geometry` subsystem groups the software units responsible for describing shape geometry - +paths made of lines, curves, and arcs, and the bounding boxes and flattened polylines derived +from them - with no notion of pixels, color, or rasterization. It has no dependency on any other +subsystem: the `Canvas` and `Codecs` subsystems have no dependency on `Geometry`, and `Geometry` +has no dependency on either of them. `Geometry` is deliberately distinct from the reserved +`Drawing` subsystem: `Geometry` is where shape geometry is described and measured; the future +`Drawing` subsystem will be where that geometry is turned into pixels on a `Canvas.Surface` +(rasterization, brushes, pens, and stroke/fill styling). + +### Units + +- **Rect** — an axis-aligned bounding rectangle (position plus size), with union, intersection, + matrix transformation, containment testing, and a union-identity `Empty` sentinel; see + _Rect Unit Design_ (`geometry/rect.md`) +- **Path** — an immutable vector path (an ordered collection of independent subpaths) and its + mutable, fluent `PathBuilder`, together with the supporting `Subpath`, `PathCommand`, and + `PathCommandType` data types, documented inline within the `Path` unit design rather than as + their own units, because none of them has any independent behavior beyond being a data carrier + consumed exclusively by `Path`/`PathBuilder`; see _Path Unit Design_ (`geometry/path.md`) +- **BezierFlattening** — adaptive, tolerance-driven flattening of quadratic and cubic Bezier + curves into polylines, writing into a caller-supplied output collection; see + _BezierFlattening Unit Design_ (`geometry/bezier-flattening.md`) +- **SvgArcConverter** — conversion of SVG-style endpoint-parameterized elliptical arcs into cubic + Bezier curves, following the SVG 1.1 Appendix F algorithm; see + _SvgArcConverter Unit Design_ (`geometry/svg-arc-converter.md`) + +### Dependencies + +The `Geometry` subsystem introduces zero new runtime NuGet dependencies. It is implemented +entirely against `System.Numerics.Vector2` and `System.Numerics.Matrix3x2`, in-box BCL types +available natively on all of CanvasNet's target frameworks (net8.0, net9.0, net10.0); no custom +point or vector wrapper type was introduced. Within the subsystem, `Path.GetBounds` depends on +both `BezierFlattening` (to flatten curves when a tighter, tolerance-based bound is requested) +and `SvgArcConverter` (to convert any `ArcTo` command to cubic Bezier segments first, since arcs +carry no control points of their own); `Rect` has no dependency on any other unit in this +subsystem, but is the return type of `Path.GetBounds` and the common bounding-box representation +used throughout. + +### Callers + +`Rect`, `PathBuilder`/`Path`, `BezierFlattening`, and `SvgArcConverter` are all public API entry +points, invoked externally by consumers of the CanvasNet package. `Geometry` has no runnable +end-to-end example yet within this library, since no rasterizer (the reserved `Drawing` +subsystem) exists yet to consume a `Path` and turn it into pixels on a `Canvas.Surface`; it is +exercised end to end only by this subsystem's own unit and system-integration tests. The +`Geometry` subsystem itself has no dependency on `Canvas`, `Codecs`, or the reserved `Drawing` +subsystem. diff --git a/docs/design/canvas-net/geometry/bezier-flattening.md b/docs/design/canvas-net/geometry/bezier-flattening.md new file mode 100644 index 00000000..424a72b2 --- /dev/null +++ b/docs/design/canvas-net/geometry/bezier-flattening.md @@ -0,0 +1,110 @@ +## BezierFlattening + +![Geometry Structure](GeometryView.svg) + +The `BezierFlattening` class is a software unit in the `Geometry` subsystem. It provides +adaptive, tolerance-driven flattening of quadratic and cubic Bezier curves into polylines, +writing the resulting points into a caller-supplied output collection. + +### Purpose + +Rendering, precise bounds computation, and other consumers of vector geometry frequently need to +convert a smooth curve into a sequence of straight-line segments within a controllable accuracy +budget. `BezierFlattening` provides this for both quadratic and cubic Bezier curves, using +recursive adaptive subdivision so flatter regions of a curve receive fewer segments than sharply +curved regions, for a given error tolerance. + +### Key Methods + +#### FlattenCubic(Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3, float tolerance, IList\ output) + +#### FlattenQuadratic(Vector2 p0, Vector2 p1, Vector2 p2, float tolerance, IList\ output) + +Appends the flattened polyline points for the given cubic (`p0`-`p1`-`p2`-`p3`) or quadratic +(`p0`-`p1`-`p2`) Bezier curve to `output`, via recursive adaptive subdivision (de Casteljau +midpoint split). + +**Architectural decision: `IList output` parameter, not a return list.** Both methods +write into a caller-supplied output collection rather than allocating and returning a fresh list. +This is deliberately allocation-conscious: a caller flattening many curves in a hot rendering +loop (for example, every curve in a `Path`) can reuse one growable list across every call, paying +for at most occasional list-capacity growth rather than one fresh allocation per curve. + +**Architectural decision: `p0` is never written to `output`; `pEnd` (`p3` or `p2`) is always +written last.** This convention lets repeated calls chain their outputs into one continuous +polyline: the caller's first call supplies the overall start point externally (it is already +known), and every subsequent curve segment's `p0` is the same point as the previous segment's +`pEnd` (already the last point in `output`), so writing it again would produce a duplicate point +at every segment boundary. + +**Algorithm**: the flatness test measures the distance of the curve's interior control point(s) +from the finite chord *segment* between its endpoints (`p0`-`p3` for cubic, `p0`-`p2` for +quadratic) - not the infinite line through that chord. Each control point is projected onto the +chord and the projection parameter is clamped to `[0, 1]` before measuring distance to that +clamped point, so a control point whose unclamped projection falls beyond a chord endpoint is +correctly measured against that endpoint rather than being wrongly judged "flat" via its +(potentially much smaller) distance to the infinite line. If every interior control point's +distance is `<= tolerance`, the curve is accepted as flat enough and only its end point is +appended. Otherwise, the curve is split at `t = 0.5` via de Casteljau's algorithm into two +half-curves, and both halves are flattened recursively. + +**Architectural decision: `MaxRecursionDepth` (20) is a resource/termination safety bound, not a +tolerance guarantee.** Once recursion reaches this depth, the current sub-curve's end point is +accepted and appended *regardless of whether it actually passes the flatness test*. This mirrors +the well-established `curve_recursion_limit` convention from Anti-Grain Geometry (AGG) - the +reference implementation most 2D vector graphics libraries derive their curve-flattening approach +from - +whose own documented default is 32, and whose own implementation likewise emits the segment +unconditionally once the limit is hit. This repository deliberately keeps 20 rather than matching +AGG's 32 verbatim: for any well-formed curve, the guard is never reached at either value, so the +only real difference between them is the worst-case bound placed on a genuinely pathological or +adversarial input that never converges under the flatness test - which is exactly the scenario +this limit exists to bound. At depth 20 that worst case is 2^20 (about one million) output points +for a single input curve; at depth 32 it is 2^32 (over four billion) - no longer a "safety valve" +for a pathological input, but a multi-gigabyte allocation and an unbounded-feeling hang. For all +realistic, well-formed curves at any sane tolerance, this limit is never reached in practice - the +tolerance guarantee described above holds unconditionally for such input. Only pathological input +(near-coincident control points at floating-point precision limits, or a tolerance far tighter +than float precision can resolve) - for which perfect convergence is not achievable at any +reasonable depth in the first place - can exercise this safety valve, and for that input only the +termination/bounded-output-size guarantee applies, not the tolerance guarantee. + +**Bug found and fixed**: an earlier version measured distance to the *infinite line* through the +chord (via a cross product divided by chord length) rather than the finite chord segment. A +control point whose projection landed beyond a chord endpoint could then be wrongly judged "flat" + +- its distance to the infinite line small - even though the curve it belongs to travels far +outside the flattened output's chord segment, producing a flattened polyline that silently +violated the requested tolerance. Clamping the projection parameter to `[0, 1]` before measuring +distance fixes this. + +**Throws:** + +- `ArgumentOutOfRangeException` - when `tolerance` is less than or equal to zero, for either + method (a non-positive tolerance can never be satisfied by any finite subdivision) + +No other input throws, including coincident or collinear control points - these are simply +degenerate curves that flatten to very few points (or terminate immediately, if already flat +enough by the flatness test). + +**Recursion depth is a resource guarantee, not a tolerance guarantee.** See the architectural +decision above: `MaxRecursionDepth` bounds worst-case output size and guarantees termination for +every input, but for the rare pathological input that hits the limit, the tolerance contract does +not apply. + +### Error Handling + +The single `tolerance <= 0` guard above is this unit's only validation; every other combination +of finite `Vector2` control points is accepted. + +### Dependencies + +`BezierFlattening` depends only on `System.Numerics.Vector2` (in-box BCL type, no new NuGet +package) and `System.Collections.Generic.IList` from the .NET Base Class Library. + +### Callers + +`BezierFlattening` is a public API entry point, invoked externally by consumers of the CanvasNet +package. It is also invoked internally by `Path.GetBounds`'s flattening mode (a positive +`flattenTolerance`) - see *Path Unit Design* (`path.md`). `BezierFlattening` has no dependency on +`Path`, `Rect`, or `SvgArcConverter`. diff --git a/docs/design/canvas-net/geometry/path.md b/docs/design/canvas-net/geometry/path.md new file mode 100644 index 00000000..d3dd3642 --- /dev/null +++ b/docs/design/canvas-net/geometry/path.md @@ -0,0 +1,148 @@ +## Path / PathBuilder + +![Geometry Structure](GeometryView.svg) + +The `Path` and `PathBuilder` classes are a software unit in the `Geometry` subsystem. `Path` is +an immutable vector path: an ordered collection of independent `Subpath` contours, each an +ordered sequence of `PathCommand` drawing commands. `PathBuilder` is the mutable, fluent builder +that is the only way to construct a `Path`. + +### Purpose + +`Path` represents an arbitrary vector shape - straight lines, quadratic and cubic Bezier curves, +SVG-style elliptical arcs, and closed contours, potentially several disjoint subpaths at once - +as an immutable snapshot that can be safely shared or cached once built. `PathBuilder` records +drawing commands issued via a fluent (`this`-returning) API and produces `Path` snapshots via +`Build()`, while remaining reusable afterward (including via an explicit `Clear()`) so a single +builder instance can be reused across many paths without per-path allocation overhead in a hot +rendering loop. + +### Data Model + +| Type | Description | +| ----------------- | ----------------------------------------------------------------------------- | +| `PathCommandType` | Enum: `LineTo`, `QuadraticBezierTo`, `CubicBezierTo`, `ArcTo`, `Close`. | +| `PathCommand` | Readonly struct tagged union carrying `Type` plus the fields relevant to it. | +| `Subpath` | Readonly struct: `Start` (`Vector2`), `Commands` (list), `IsClosed` (`bool`). | +| `Path` | Sealed class: `Subpaths` (`IReadOnlyList`); no public constructor. | +| `PathBuilder` | Sealed class: mutable, fluent; produces `Path` via `Build()`. | + +**Architectural decision: `Subpaths`/`Commands` are wrapped in `ReadOnlyCollection`, not just +typed as `IReadOnlyList`.** `IReadOnlyList` only hides mutating members from the +compile-time API; the concrete `List` instance backing it still implements `IList`, so a +caller could downcast `Path.Subpaths` or `Subpath.Commands` back to `IList` and mutate a +supposedly-immutable `Path`/`Subpath` in place. Wrapping the backing list in +`System.Collections.ObjectModel.ReadOnlyCollection` before exposing it closes that hole: its +own mutating members throw `NotSupportedException` regardless of how the exposed reference is +cast, so immutability is enforced at runtime, not merely documented at compile time. + +**Architectural decision: `MoveTo` is not a `PathCommandType`.** A path always begins a subpath +with a "move", but representing it as an ordinary command (as SVG's own `M`/`m` path-data command +does) would allow a `Subpath` with no move at all, or with a move anywhere other than first - +states that should be unrepresentable. Instead, each `Subpath`'s move destination is captured +directly as its own `Start` field, and `PathCommandType` only enumerates the commands that can +legally follow a subpath's start. This makes "a subpath's first point" a structural guarantee +rather than a convention every consumer must separately validate. + +`PathCommand` is a flat readonly struct (not a class hierarchy) with a private constructor and +internal static factory methods (`LineTo`, `QuadraticBezierTo`, `CubicBezierTo`, `ArcTo`, +`Close`) used only by `PathBuilder`, carrying every field any command kind might need +(`EndPoint`, `Control1`, `Control2`, `Radius`, `RotationDegrees`, `LargeArc`, `Sweep`); which +fields are meaningful is determined by `Type`. This keeps `Path`'s internal storage a simple, +allocation-friendly array of value-type structs rather than an array of polymorphic references. + +**Architectural decision: `PathBuilder.ArcTo` stores raw SVG parameters only.** `ArcTo` never +pre-inspects or pre-converts its `radius`/`rotationDegrees`/`largeArc`/`sweep`/`end` parameters; +it stores them verbatim in a `PathCommand.ArcTo` value. Conversion to cubic Bezier segments is +performed lazily, only when a consumer actually needs it (for example, `Path.GetBounds`), via +`SvgArcConverter` - see _SvgArcConverter Unit Design_ (`svg-arc-converter.md`). A consumer that +never needs Bezier segments (for example, a hit-testing implementation with its own arc math) +never pays for the conversion. + +### Key Methods + +#### PathBuilder.MoveTo(Vector2 point) + +Starts a new subpath at `point`. If a previous subpath is still open (no `Close` issued), it is +committed as-is (its `IsClosed` remains `false`) before the new subpath begins. Always succeeds. + +#### PathBuilder.LineTo/QuadraticBezierTo/CubicBezierTo/ArcTo(...) + +Appends the corresponding `PathCommand` to the current subpath. + +**Throws:** + +- `InvalidOperationException` - when called before the first `MoveTo`, or after `Close` without + an intervening `MoveTo` (there is no current point to continue from in either case) + +#### PathBuilder.Close() + +Appends a `Close` command to the current subpath and marks it closed (`IsClosed = true` on the +resulting `Subpath`). A `Close` command carries no `EndPoint` of its own - a closed subpath +always returns to its own `Start`. + +**Throws:** + +- `InvalidOperationException` - under the same conditions as the drawing commands above + +#### PathBuilder.Build() + +Returns an immutable `Path` snapshot of every subpath recorded so far (including the +currently-open subpath, if any, uncommitted state included). The builder remains usable +afterward: further commands (and further `Build()` calls) do not affect a previously returned +`Path`. If no commands have ever been issued, returns a `Path` with zero `Subpaths`. + +#### PathBuilder.Clear() + +Resets the builder to the same empty state as a freshly constructed instance, so it can be reused +to build further, unrelated paths without allocating a new builder instance. The internal +current-commands buffer is reset via its own `Clear()` method rather than being replaced with a +new list, preserving its already-grown capacity across builds - so repeatedly building +similarly-sized paths in a loop does not repeatedly reallocate that buffer. + +#### Path.Empty + +A static singleton `Path` with zero `Subpaths`. `Path.Empty.GetBounds()` returns `Rect.Empty`, +matching `Rect`'s own union-identity convention. + +#### Path.GetBounds(float flattenTolerance = 0) + +Computes an axis-aligned bounding `Rect` for the whole path, as the `Rect.Union` of every +subpath's own contribution. + +- **Conservative mode** (`flattenTolerance <= 0`, the default): for each command, folds in the + raw endpoint and any control points directly (no flattening), relying on the convex-hull + property of Bezier curves - every point on a quadratic or cubic Bezier curve lies within the + convex hull of its control points and endpoints, so enclosing every control point and endpoint + always encloses the whole curve, though possibly more loosely than the curve's true bounds. + `ArcTo` commands are converted to cubic Bezier segments via `SvgArcConverter` first (arcs carry + no control points of their own), then treated the same way. +- **Flattening mode** (`flattenTolerance > 0`): every curve (including each `ArcTo` command's + converted Bezier segments) is first flattened to a polyline within the given tolerance via + `BezierFlattening`, and the bounds fold in the flattened points instead - more expensive, but + tighter, since a curve's flattened polyline hugs the true curve far more closely than its + control-point convex hull does. + +Never throws. + +### Error Handling + +`PathBuilder`'s drawing-command guard (`InvalidOperationException` before the first `MoveTo`, or +after `Close` without an intervening `MoveTo`) is the only validation this unit performs; every +other input (any finite `Vector2`, any `float` rotation, any `bool` flag combination) is accepted +without further checking. `Path` itself performs no validation - it can only ever be constructed, +internally, from a well-formed `PathBuilder` snapshot (or as the `Empty` singleton). + +### Dependencies + +`Path`/`PathBuilder` depend only on `System.Numerics.Vector2` (in-box BCL type, no new NuGet +package) and, within this subsystem, on `Rect` (the return type of `GetBounds`), +`BezierFlattening`, and `SvgArcConverter` (both used internally by `GetBounds`'s flattening mode +and by its `ArcTo` handling, respectively). + +### Callers + +`PathBuilder` and `Path` are public API entry points, invoked externally by consumers of the +CanvasNet package. `Path` has no dependency on any consumer; `Geometry` has no runnable +end-to-end example yet within this library, since no rasterizer (the reserved `Drawing` +subsystem) exists yet to consume a `Path`. diff --git a/docs/design/canvas-net/geometry/rect.md b/docs/design/canvas-net/geometry/rect.md new file mode 100644 index 00000000..1cf5884f --- /dev/null +++ b/docs/design/canvas-net/geometry/rect.md @@ -0,0 +1,135 @@ +## Rect + +![Geometry Structure](GeometryView.svg) + +The `Rect` struct is a software unit in the `Geometry` subsystem. It represents an +axis-aligned bounding rectangle using the position-plus-size convention (`X`, `Y`, `Width`, +`Height`, all `float`), reusing `System.Numerics.Vector2` for its point/size-valued members and +`System.Numerics.Matrix3x2` for its `Transform` operation. + +### Purpose + +`Rect` is the common bounding-box representation used throughout the `Geometry` subsystem - most +notably as the return type of `Path.GetBounds`. It provides computed edge/corner properties, +containment testing, union, intersection, matrix transformation, and value equality, with a +carefully chosen `Empty` sentinel that makes folding `Union` over zero or more contributing +rectangles always produce the mathematically correct result. + +### Data Model + +| Field/Property | Type | Description | +| -------------------- | ---------------------- | ---------------------------------------------------------- | +| `X` | `float` | The x-coordinate of the top-left corner. | +| `Y` | `float` | The y-coordinate of the top-left corner. | +| `Width` | `float` | The width. May be negative only for the `Empty` sentinel. | +| `Height` | `float` | The height. May be negative only for the `Empty` sentinel. | +| `Left`/`Top` | `float` | Computed; equal to `X`/`Y`. | +| `Right`/`Bottom` | `float` | Computed from `Width`/`Height` (see NaN-guard note below). | +| `Location`/`TopLeft` | `Vector2` | Computed; `(X, Y)`. | +| `Size` | `Vector2` | Computed; `(Width, Height)`. | +| `BottomRight` | `Vector2` | Computed; `(Right, Bottom)`. | +| `Empty` | `static readonly Rect` | The union-identity sentinel (see below). | + +### The `Empty` Sentinel + +**Architectural decision**: `Empty` is represented as `X = Y = float.PositiveInfinity`, +`Width = Height = float.NegativeInfinity` - a WPF-style "union identity" sentinel - rather than +the more common all-zero "empty" convention (as used by, for example, +`System.Drawing.RectangleF.Empty`). This is deliberate and load-bearing: it makes +`Union(Empty, r)` an identity operation (`Union(Empty, r) == r` for every rectangle `r`), which +lets `Path.GetBounds` fold `Union` over zero or more subpaths and always get the mathematically +correct answer (an empty path's bounds are `Empty`, and a one-subpath path's bounds are exactly +that subpath's bounds) with no special-casing for "no subpaths yet". An all-zero "empty" +rectangle would instead silently corrupt every union with a real rectangle by contributing the +origin point. + +`IsEmpty` only tests `Width < 0` (not `Height`), because `Empty`'s construction always keeps both +negative together; this single-field test matches the WPF convention this type follows. + +**Bug found and fixed during implementation**: a naive `Right => X + Width` (and equivalently for +`Bottom`) computes `float.PositiveInfinity + float.NegativeInfinity`, which IEEE 754 defines as +`NaN` - not the intended `float.NegativeInfinity`. This would silently corrupt every `Union` +involving `Empty` (propagating `NaN` through the subsequent `Math.Min`/`Math.Max` calls in +`Union`). `Right` and `Bottom` therefore special-case `float.IsNegativeInfinity(Width)` / +`float.IsNegativeInfinity(Height)` and return `float.NegativeInfinity` directly in that case, +matching the "inverted extent" behavior the rest of this type's design relies on. + +### Key Methods + +#### Contains(Vector2 point) + +Tests whether `point` lies within the rectangle using a half-open interval on both axes: the +left/top edges are included, the right/bottom edges are excluded +(`point.X >= Left && point.X < Right && point.Y >= Top && point.Y < Bottom`). `Empty`'s inverted +extent (`Left` greater than `Right`) makes every comparison fail for any finite point, so `Empty` +correctly contains no points. Never throws. + +#### Union(Rect) / Union(Rect, Rect) + +Returns the smallest rectangle enclosing both input rectangles, computed as +`(min(Left), min(Top))` to `(max(Right), max(Bottom))`. Available as both an instance method +(`this.Union(other)`) and a static method (`Rect.Union(a, b)`). Never throws. + +#### Intersect(Rect) / Intersect(Rect, Rect) + +Returns the overlapping region of both input rectangles, computed as `(max(Left), max(Top))` to +`(min(Right), min(Bottom))`. If the rectangles are disjoint on either axis, including the boundary +case where they merely touch along an edge with no actual overlapping area (`right <= left` or +`bottom <= top`), returns the canonical `Empty` sentinel rather than a rectangle with a +non-canonical negative size or a zero-width/zero-height non-`Empty` rectangle. Available as both +an instance method and a static method. Never throws. + +#### Transform(Matrix3x2 matrix) + +Returns the smallest axis-aligned rectangle enclosing this rectangle after applying `matrix`. + +**Architectural decision**: all four corners (`TopLeft`, `(Right, Top)`, `(Left, Bottom)`, +`BottomRight`) are transformed individually via `Vector2.Transform`, then re-enclosed via the same +min/max folding as `Union`, rather than transforming only two opposite corners. Transforming just +the top-left and bottom-right corners is mathematically incorrect for any matrix containing +rotation or shear: the image of an axis-aligned rectangle under such a transform is a +parallelogram, not another axis-aligned rectangle, so its true bounding box can only be recovered +by considering all four transformed corners. Never throws. + +**Bug found and fixed**: `Empty`'s corners include `+Infinity`/`-Infinity`. Transforming them +directly can produce `NaN` (for example `0 * Infinity`, when a matrix has a zero off-diagonal +element), which would then poison the min/max re-enclosure fold and yield a `NaN`-filled rectangle +instead of `Empty`. `Transform` therefore checks `IsEmpty` first and returns `Empty` immediately, +preserving it as an identity/no-op - the same guard philosophy already applied to `Right`/`Bottom` +and `Intersect`. + +#### Equals(Rect) / Equals(object?) / GetHashCode / == / != + +Value equality compares the raw bit pattern of each of the four fields (`X`, `Y`, `Width`, +`Height`) via `BitConverter.SingleToInt32Bits`, rather than `==`/tolerance-based comparison. This +is exact snapshot equality of stored field values (matching the precedent set by +`DemaConsulting.CanvasNet.Canvas.Rgba32`'s exact byte equality) - not a comparison of +independently computed floating-point results, so no rounding tolerance is appropriate. Comparing +raw bits (rather than `==`) also avoids a SonarAnalyzer S1244 diagnostic (float exact-equality) at +the call site, since the intent here is documented exact snapshot equality rather than a +numerically fragile result comparison. + +#### ToString() + +Returns a human-readable string in the form `"{X=.., Y=.., Width=.., Height=..}"`, for diagnostic +output. + +### Error Handling + +`Rect` never throws from any public member; every input is either arithmetically well-defined (a +`float` value, however large, small, `NaN`, or infinite) or produces `Empty` for a +would-be-invalid result (a disjoint `Intersect`), rather than raising an exception. + +### Dependencies + +`Rect` depends only on `System.Numerics.Vector2` and `System.Numerics.Matrix3x2` (in-box BCL +types, no new NuGet package) and `System.BitConverter`/`System.HashCode` from the .NET Base Class +Library. + +### Callers + +`Rect` is a public API entry point, invoked externally by consumers of the CanvasNet package. It +is also invoked internally by `Path.GetBounds`, which returns a `Rect` computed by folding +`Union` over every subpath's own bounds (and, for `Rect.Empty`'s benefit, over zero subpaths for +an empty path) - see _Path Unit Design_ (`path.md`). `Rect` has no dependency on `Path`, +`BezierFlattening`, or `SvgArcConverter`. diff --git a/docs/design/canvas-net/geometry/svg-arc-converter.md b/docs/design/canvas-net/geometry/svg-arc-converter.md new file mode 100644 index 00000000..d43c7167 --- /dev/null +++ b/docs/design/canvas-net/geometry/svg-arc-converter.md @@ -0,0 +1,89 @@ +## SvgArcConverter + +![Geometry Structure](GeometryView.svg) + +The `SvgArcConverter` class is a software unit in the `Geometry` subsystem. It converts +SVG-style endpoint-parameterized elliptical arcs into one or more cubic Bezier curves, following +the SVG 1.1 Appendix F "Elliptical arc implementation notes" algorithm. + +### Purpose + +`PathBuilder.ArcTo` always stores the raw SVG arc parameters supplied by the caller, without +pre-inspecting or pre-converting them (see _Path Unit Design_, `path.md`). `SvgArcConverter` is +the only place in the `Geometry` namespace responsible for SVG-spec fidelity, including its +documented degenerate cases, so a consumer that never needs Bezier segments (for example, a +hit-testing implementation with its own arc math) never pays for the conversion; conversion +happens lazily, only when a consumer actually calls `ToBeziers`. + +### Key Method + +#### ToBeziers(...) + +Signature: + +```csharp +ToBeziers(Vector2 start, Vector2 radius, float rotationDegrees, bool largeArc, bool sweep, + Vector2 end, IList<(Vector2 Control1, Vector2 Control2, Vector2 End)> output) +``` + +Converts the described SVG-style elliptical arc into a sequence of cubic Bezier segments, +appended to `output` as `(Control1, Control2, End)` tuples in end-to-end order, so a caller +building a continuous polyline/curve chain never needs to re-derive the implicit start point of +each segment (it is the previous segment's `End`, or `start` for the first). + +**Architectural decision: `IList<...> output` parameter, not a return list.** As with +`BezierFlattening`, `ToBeziers` writes into a caller-supplied output collection rather than +allocating and returning a fresh list, for the same allocation-conscious, hot-loop-friendly +reason. + +**Algorithm** (SVG 1.1 Appendix F, endpoint-to-center parameterization): + +1. If `start == end`, emit nothing (per spec, a zero-length arc is not rendered). +2. If either radius component is zero, emit exactly one synthetic straight-line-equivalent cubic + Bezier, with control points at one-third and two-thirds along the `start`-`end` chord (see the + degenerate-case decision below). +3. Otherwise, apply the spec's out-of-range radius correction (the Lambda scale-factor formula: + if the requested radii are too small to reach between `start` and `end` at all, both are + scaled up by the same factor, `sqrt(lambda)`, so a valid ellipse exists), compute the center + parameterization (`cx`, `cy`, `theta1`, `deltaTheta`), split `deltaTheta` into sub-arcs of at + most 90 degrees each, and convert each sub-arc to a cubic Bezier via the standard + `kappa = 4/3 * tan(delta/4)` control-point-distance formula, expressed on the unit circle then + mapped through the ellipse's radii, x-axis rotation, and center offset. + +**Architectural decision: the final segment's `End` is forced to exactly the caller-supplied +`end` parameter**, rather than the value obtained by evaluating trigonometric functions at the +target angle. This avoids residual floating-point drift between the computed end angle's +position and the caller's own exact `end` value, so a caller chaining segments into a continuous +curve/polyline always lands exactly on the requested end point. + +**Architectural decision: zero-radius degenerate case emits a synthetic straight-line-equivalent +cubic**, per the SVG specification's own definition of this case as "treated as a straight line". +A synthetic cubic whose controls lie exactly on the chord reproduces a straight line precisely, +while keeping this unit's output contract uniform (always cubic Bezier segments) for every +caller, rather than requiring every consumer to special-case a line-segment result type. + +`ToBeziers` never throws for any SVG-valid input, including out-of-range radii (corrected per the +specification) and all four `largeArc`/`sweep` flag combinations. The out-of-range radius +correction (Step 3 above) is exercised specifically by a regression test using radii smaller than +the `start`-`end` chord distance, which forces the `lambda > 1` scale-up path and verifies the +resulting Bezier chain both reaches the declared `end` point and follows the corrected +(scaled-up), not the originally requested, ellipse geometry. + +### Error Handling + +`ToBeziers` performs no argument validation and never throws; every documented degenerate case +(zero-length arc, zero radius) is handled by producing a geometrically sensible result rather +than raising an exception, per this unit's contract. + +### Dependencies + +`SvgArcConverter` depends only on `System.Numerics.Vector2` (in-box BCL type, no new NuGet +package) and `System.MathF`/`System.Collections.Generic.IList` from the .NET Base Class +Library. + +### Callers + +`SvgArcConverter` is a public API entry point, invoked externally by consumers of the CanvasNet +package. It is also invoked internally by `Path.GetBounds`, to convert any `ArcTo` command to +cubic Bezier segments before applying either bounds mode - see _Path Unit Design_ (`path.md`). +`SvgArcConverter` has no dependency on `Path`, `Rect`, or `BezierFlattening`. diff --git a/docs/design/introduction.md b/docs/design/introduction.md index c933b220..1905b184 100644 --- a/docs/design/introduction.md +++ b/docs/design/introduction.md @@ -29,6 +29,12 @@ software items, specifically: 32-bit RGBA pixel buffer with span-based row access) and the `Rgba32` unit - **Codecs (Subsystem)** — Image format codecs: `BmpCodec`, `PngCodec`, `TiffCodec`, and `JpegCodec`, each converting to and from a `Surface` pixel buffer +- **Geometry (Subsystem)** — Vector-geometry primitives, distinct from the reserved `Drawing` + subsystem (which will cover shapes, brushes, pens, and transforms built on top of these + primitives): the `Rect` unit (axis-aligned bounding rectangle), the `Path` unit (immutable + vector path and its `PathBuilder`, covering the supporting `Subpath`, `PathCommand`, and + `PathCommandType` types inline), the `BezierFlattening` unit (adaptive Bezier curve + flattening), and the `SvgArcConverter` unit (SVG-style elliptical arc to Bezier conversion) The following OTS items are also covered: @@ -63,13 +69,16 @@ diagram or the prose below. ![Software Structure](SoftwareStructureView.svg) -CanvasNet is organized into two subsystems under the system level: the `Canvas` subsystem -(the `Surface` and `Rgba32` units, namespace `DemaConsulting.CanvasNet.Canvas`) and the `Codecs` subsystem +CanvasNet is organized into three subsystems under the system level: the `Canvas` subsystem +(the `Surface` and `Rgba32` units, namespace `DemaConsulting.CanvasNet.Canvas`), the `Codecs` subsystem (the `BmpCodec`, `PngCodec`, `TiffCodec`, and `JpegCodec` units, namespace `DemaConsulting.CanvasNet.Codecs`, -flat — no further nesting). A third subsystem, `Drawing`, is reserved for future work (shapes, -brushes, pens, transforms) and has no folder, namespace, or documentation yet. As additional -functionality is added, further subsystems and nested subsystems would organize related units and -provide architectural boundaries with well-defined interfaces and responsibilities. +flat — no further nesting), and the `Geometry` subsystem (the `Rect`, `Path`, `BezierFlattening`, and +`SvgArcConverter` units, namespace `DemaConsulting.CanvasNet.Geometry`, flat — no further nesting). +A fourth subsystem, `Drawing`, is reserved for future work (shapes, brushes, pens, and higher-level +transforms built on top of `Geometry`'s primitives) and has no folder, namespace, or documentation +yet. As additional functionality is added, further subsystems and nested subsystems would organize +related units and provide architectural boundaries with well-defined interfaces and +responsibilities. ## Folder Layout @@ -82,17 +91,28 @@ src/DemaConsulting.CanvasNet/ │ ├── Surface.cs — Mutable, in-memory 32-bit RGBA pixel buffer │ ├── Rgba32.cs — Single 32-bit RGBA pixel value type │ └── NamespaceDoc.cs — Namespace-level XML documentation -└── Codecs/ - ├── BmpCodec.cs — Uncompressed 24-bit/32-bit Windows BMP loader/saver - ├── PngCodec.cs — 8-bit Truecolor/Truecolor-with-alpha PNG loader/saver - ├── TiffCodec.cs — 8-bit RGB/RGBA/Grayscale, strip-based TIFF loader/saver - ├── JpegCodec.cs — Baseline/progressive JPEG loader and baseline JPEG saver +├── Codecs/ +│ ├── BmpCodec.cs — Uncompressed 24-bit/32-bit Windows BMP loader/saver +│ ├── PngCodec.cs — 8-bit Truecolor/Truecolor-with-alpha PNG loader/saver +│ ├── TiffCodec.cs — 8-bit RGB/RGBA/Grayscale, strip-based TIFF loader/saver +│ ├── JpegCodec.cs — Baseline/progressive JPEG loader and baseline JPEG saver +│ └── NamespaceDoc.cs — Namespace-level XML documentation +└── Geometry/ + ├── Rect.cs — Axis-aligned bounding rectangle (position plus size) + ├── PathCommandType.cs — Enumeration of path drawing command kinds + ├── PathCommand.cs — Tagged-union path drawing command value + ├── Subpath.cs — One independent contour of a path + ├── Path.cs — Immutable vector path (ordered collection of subpaths) + ├── PathBuilder.cs — Mutable, fluent builder that produces a Path + ├── BezierFlattening.cs — Adaptive quadratic/cubic Bezier curve flattening + ├── SvgArcConverter.cs — SVG-style elliptical arc to cubic Bezier conversion └── NamespaceDoc.cs — Namespace-level XML documentation ``` -This two-subsystem folder structure reflects the small number of subsystems in the system today. -As the system grows with additional subsystems and units (including the reserved `Drawing` -subsystem), the folder structure will expand further to mirror the software architecture. +This three-subsystem folder structure reflects the small number of subsystems in the system +today. As the system grows with additional subsystems and units (including the reserved +`Drawing` subsystem), the folder structure will expand further to mirror the software +architecture. ## Document Conventions diff --git a/docs/reqstream/canvas-net.yaml b/docs/reqstream/canvas-net.yaml index 9d77b8a9..91568194 100644 --- a/docs/reqstream/canvas-net.yaml +++ b/docs/reqstream/canvas-net.yaml @@ -667,3 +667,66 @@ sections: - JpegCodec_GetInfo_MissingSoiMarker_ThrowsInvalidDataException - JpegCodec_GetInfo_TruncatedBeforeSofFound_ThrowsInvalidDataException - JpegCodec_GetInfo_SosBeforeSof_ThrowsInvalidDataException + + - title: Geometry API + requirements: + - id: CanvasNet-Lib-GeometryRect + title: >- + The library shall provide an axis-aligned bounding rectangle type (position plus size), + supporting union, intersection, matrix transformation, containment testing, and value + equality, with an empty sentinel that acts as a union identity. + tags: [system] + justification: | + Axis-aligned bounds are a foundational primitive for vector geometry (for example, + computing a path's bounding box) that must compose correctly across zero or more + contributing regions without special-casing "nothing yet". + children: + - CanvasNet-Geometry-Rect + tests: + - CanvasNet_SystemIntegration_BuildFlattenAndBoundPath_ReturnsExpectedBounds + + - id: CanvasNet-Lib-GeometryPath + title: >- + The library shall provide an immutable vector path type, built via a mutable, fluent + builder supporting move, line, quadratic and cubic Bezier, SVG-style elliptical arc, and + close-subpath commands, and shall compute an axis-aligned bounding rectangle for a path + in either a conservative or a tighter, flattening-based mode. + tags: [system] + justification: | + A vector path is the foundational shape-description primitive future drawing/rendering + functionality in this library will consume; callers need to build one incrementally, + using a single reusable builder instance, and to reason about its extent. + children: + - CanvasNet-Geometry-Path + tests: + - CanvasNet_SystemIntegration_BuildFlattenAndBoundPath_ReturnsExpectedBounds + + - id: CanvasNet-Lib-GeometryBezierFlattening + title: >- + The library shall provide adaptive flattening of quadratic and cubic Bezier curves into + polylines within a caller-specified tolerance, writing into a caller-supplied output + collection. + tags: [system] + justification: | + Rendering and precise bounds computation both require converting smooth curves into + straight-line segments within a controllable accuracy budget, without an allocation per + flattening call in hot rendering loops. + children: + - CanvasNet-Geometry-BezierFlattening + tests: + - CanvasNet_SystemIntegration_BuildFlattenAndBoundPath_ReturnsExpectedBounds + + - id: CanvasNet-Lib-GeometrySvgArcConverter + title: >- + The library shall convert SVG-style endpoint-parameterized elliptical arcs into cubic + Bezier curves, following the SVG 1.1 Appendix F algorithm, including its documented + degenerate cases, without throwing for any SVG-valid input. + tags: [system] + justification: | + PathBuilder.ArcTo stores raw SVG arc parameters without pre-converting them; callers (or the + library's own bounds computation) that need Bezier segments from an arc require a + spec-faithful conversion available on demand. + children: + - CanvasNet-Geometry-SvgArcConverter + tests: + - CanvasNet_SystemIntegration_BuildFlattenAndBoundPath_ReturnsExpectedBounds diff --git a/docs/reqstream/canvas-net/geometry.yaml b/docs/reqstream/canvas-net/geometry.yaml new file mode 100644 index 00000000..4801fe04 --- /dev/null +++ b/docs/reqstream/canvas-net/geometry.yaml @@ -0,0 +1,110 @@ +--- +# Software Subsystem Requirements for Geometry +# +# These requirements decompose the CanvasNet system's vector-geometry responsibilities into +# the Geometry subsystem's own responsibility: an axis-aligned bounding rectangle (the Rect +# unit), an immutable vector path and its fluent builder (the Path unit, which also covers the +# supporting Subpath, PathCommand, and PathCommandType data types), adaptive Bezier curve +# flattening (the BezierFlattening unit), and SVG-style elliptical arc to Bezier conversion +# (the SvgArcConverter unit). Each requirement here links down to the unit requirement that +# implements it. + +sections: + - title: CanvasNet Requirements + sections: + - title: Geometry Requirements + requirements: + - id: CanvasNet-Geometry-Rect + title: >- + The Geometry subsystem shall provide an axis-aligned bounding rectangle type + (position plus size), supporting Union, Intersect, Transform, containment testing, + and value equality, with an Empty sentinel that is a Union identity. + justification: | + Axis-aligned bounds are needed throughout vector geometry (for example, computing a + Path's bounding box) and must compose correctly across zero or more contributing + regions without special-casing "nothing yet". + children: + - CanvasNet-Geometry-Rect-Construction + - CanvasNet-Geometry-Rect-Empty + - CanvasNet-Geometry-Rect-IsEmpty + - CanvasNet-Geometry-Rect-Contains + - CanvasNet-Geometry-Rect-Union + - CanvasNet-Geometry-Rect-Intersect + - CanvasNet-Geometry-Rect-Transform + - CanvasNet-Geometry-Rect-Equality + tests: + - Rect_Constructor_ValidValues_SetsPropertiesAndComputedEdges + - Rect_Union_WithEmpty_ReturnsOtherRectangleUnchanged + + - id: CanvasNet-Geometry-Path + title: >- + The Geometry subsystem shall provide an immutable vector path type, built via a + mutable, fluent, allocation-conscious builder supporting move, line, quadratic and + cubic Bezier, SVG-style elliptical arc, and close-subpath commands, and shall + compute an axis-aligned bounding rectangle for a path in either a conservative + (control-point convex hull) or tighter (flattening-based) mode. + justification: | + A vector path (potentially several independent subpaths of mixed line, curve, and + arc segments) is the foundational shape-description primitive future + drawing/rendering functionality in this library will consume; callers need to build + one incrementally and reuse a single builder instance across many paths without + per-path allocation overhead. + children: + - CanvasNet-Geometry-Path-BuildEveryCommandType + - CanvasNet-Geometry-Path-ArcToStoresRawParameters + - CanvasNet-Geometry-Path-MultipleSubpaths + - CanvasNet-Geometry-Path-Close + - CanvasNet-Geometry-Path-MoveToAfterClose + - CanvasNet-Geometry-Path-GuardBeforeMoveTo + - CanvasNet-Geometry-Path-GuardAfterCloseWithoutMoveTo + - CanvasNet-Geometry-Path-BuildSnapshotIndependence + - CanvasNet-Geometry-Path-BuildEmpty + - CanvasNet-Geometry-Path-Clear + - CanvasNet-Geometry-Path-Immutability + - CanvasNet-Geometry-Path-EmptySingleton + - CanvasNet-Geometry-Path-GetBoundsConservative + - CanvasNet-Geometry-Path-GetBoundsFlattened + - CanvasNet-Geometry-Path-GetBoundsMultipleSubpaths + tests: + - PathBuilder_Build_EveryCommandType_RecordsCommandsInOrder + - Path_GetBounds_ConservativeMode_CubicWithControlPointsOutsideChord_ReturnsControlPointConvexHull + + - id: CanvasNet-Geometry-BezierFlattening + title: >- + The Geometry subsystem shall provide adaptive flattening of quadratic and cubic + Bezier curves into polylines within a caller-specified tolerance, writing into a + caller-supplied output collection. + justification: | + Rendering and precise bounds computation both require converting smooth curves into + straight-line segments within a controllable accuracy budget, without imposing an + allocation per flattening call in hot rendering loops. + children: + - CanvasNet-Geometry-BezierFlattening-FlattenCubic + - CanvasNet-Geometry-BezierFlattening-FlattenQuadratic + - CanvasNet-Geometry-BezierFlattening-ToleranceConvergence + - CanvasNet-Geometry-BezierFlattening-RecursionDepthSafetyValve + - CanvasNet-Geometry-BezierFlattening-MonotonicSegmentCount + - CanvasNet-Geometry-BezierFlattening-DegenerateControlPoints + - CanvasNet-Geometry-BezierFlattening-NonPositiveTolerance + tests: + - BezierFlattening_FlattenCubic_SimpleCurve_NeverWritesStartAndAlwaysWritesEndLast + - BezierFlattening_FlattenQuadratic_SimpleCurve_NeverWritesStartAndAlwaysWritesEndLast + + - id: CanvasNet-Geometry-SvgArcConverter + title: >- + The Geometry subsystem shall convert SVG-style endpoint-parameterized elliptical + arcs into cubic Bezier curves, following the SVG 1.1 Appendix F algorithm, including + its documented degenerate cases, without throwing for any SVG-valid input. + justification: | + PathBuilder.ArcTo stores raw SVG arc parameters without pre-converting them; a + dedicated, spec-faithful conversion unit is required so that any consumer needing + Bezier segments from an arc (for example, Path.GetBounds or a future rasterizer) + can obtain them lazily and consistently. + children: + - CanvasNet-Geometry-SvgArcConverter-ToBeziers + - CanvasNet-Geometry-SvgArcConverter-StartEqualsEnd + - CanvasNet-Geometry-SvgArcConverter-ZeroRadius + - CanvasNet-Geometry-SvgArcConverter-NeverThrows + tests: + - SvgArcConverter_ToBeziers_Semicircle_ProducesContinuousChainOnExpectedCircle + - SvgArcConverter_ToBeziers_StartEqualsEnd_EmitsNoSegments diff --git a/docs/reqstream/canvas-net/geometry/bezier-flattening.yaml b/docs/reqstream/canvas-net/geometry/bezier-flattening.yaml new file mode 100644 index 00000000..740e8fbb --- /dev/null +++ b/docs/reqstream/canvas-net/geometry/bezier-flattening.yaml @@ -0,0 +1,122 @@ +--- +# Software Unit Requirements for BezierFlattening +# +# These requirements describe the design-level behavior of the BezierFlattening unit, within +# the Geometry subsystem. + +sections: + - title: CanvasNet Requirements + sections: + - title: Geometry Requirements + sections: + - title: BezierFlattening Requirements + requirements: + - id: CanvasNet-Geometry-BezierFlattening-FlattenCubic + title: >- + The BezierFlattening class shall flatten a cubic Bezier curve into a sequence of + points appended to a caller-supplied output list, via recursive adaptive + subdivision (de Casteljau midpoint split), never writing the curve's start point + and always writing its end point last. + justification: | + A caller-supplied output list (rather than a freshly allocated return list) + keeps repeated flattening calls allocation-conscious for hot rendering loops; + never writing the start point and always writing the end point last lets + repeated calls chain their outputs into one continuous polyline without the + caller needing to de-duplicate a repeated shared point at each segment boundary. + tests: + - BezierFlattening_FlattenCubic_SimpleCurve_NeverWritesStartAndAlwaysWritesEndLast + + - id: CanvasNet-Geometry-BezierFlattening-FlattenQuadratic + title: >- + The BezierFlattening class shall flatten a quadratic Bezier curve into a + sequence of points appended to a caller-supplied output list, using the same + never-write-start / always-write-end-last convention as FlattenCubic. + justification: | + Quadratic Bezier curves require the same flattening capability and the same + chaining convention as cubic curves, for callers building a continuous polyline + from a path containing a mix of quadratic and cubic segments. + tests: + - BezierFlattening_FlattenQuadratic_SimpleCurve_NeverWritesStartAndAlwaysWritesEndLast + + - id: CanvasNet-Geometry-BezierFlattening-ToleranceConvergence + title: >- + The BezierFlattening class shall produce a flattened polyline whose points lie + within the requested tolerance of the true curve, for both FlattenCubic and + FlattenQuadratic, using a flatness test based on the distance of the interior + control point(s) from the finite chord *segment* between the curve's endpoints + (not the infinite line through it), except when pathological input exhausts the + recursion-depth safety bound described in + CanvasNet-Geometry-BezierFlattening-RecursionDepthSafetyValve. + justification: | + The tolerance parameter is the entire contract callers rely on to trade accuracy + against point count; the flattened output must actually honor it, verified here + by densely sampling the true curve and checking every flattened point falls + within a small multiple of the requested tolerance. Measuring against the + infinite line through the chord, rather than the finite chord segment, would + wrongly accept a control point whose projection falls beyond a chord endpoint as + "flat" even though the curve it belongs to travels well outside the chord + segment. + tests: + - BezierFlattening_FlattenCubic_VariousTolerances_SampledCurvePointsWithinTolerance + - BezierFlattening_FlattenQuadratic_VariousTolerances_SampledCurvePointsWithinTolerance + - BezierFlattening_FlattenCubic_ControlPointProjectsBeyondChordEnd_StaysWithinTolerance + + - id: CanvasNet-Geometry-BezierFlattening-MonotonicSegmentCount + title: >- + The BezierFlattening class shall produce a flattened point count for + FlattenCubic that is monotonically non-increasing as the requested tolerance + increases, for a fixed input curve. + justification: | + A looser tolerance must never require more subdivision than a tighter one for + the same curve; callers relying on tolerance as a performance/accuracy dial need + this monotonic relationship to hold. + tests: + - BezierFlattening_FlattenCubic_IncreasingTolerance_SegmentCountIsMonotonicallyNonIncreasing + + - id: CanvasNet-Geometry-BezierFlattening-DegenerateControlPoints + title: >- + The BezierFlattening class shall terminate for a cubic curve whose control + points are coincident with an endpoint, or collinear with the chord between its + endpoints, without throwing and without infinite recursion. + justification: | + Degenerate input curves (control points collapsed onto the chord or an endpoint) + are legal input a caller may pass unintentionally (for example, a straight-line + segment expressed as a Bezier curve) and must be handled gracefully rather than + triggering pathological recursion. + tests: + - BezierFlattening_FlattenCubic_CoincidentControlPoints_TerminatesWithSinglePoint + - BezierFlattening_FlattenCubic_CollinearControlPoints_TerminatesWithCollinearPoints + + - id: CanvasNet-Geometry-BezierFlattening-RecursionDepthSafetyValve + title: >- + The BezierFlattening class shall terminate with a bounded output point count for + any input curve, including a pathological curve that never satisfies the + flatness test at any practical subdivision depth, rather than recursing + indefinitely; such input is accepted at the recursion depth limit regardless of + whether it passes the flatness test, so the tolerance guarantee does not apply + to it. + justification: | + A resource/termination safety bound is required so that no input, however + pathological (near-coincident control points at floating-point precision limits, + or a tolerance far tighter than float precision can resolve), can cause unbounded + recursion or unbounded output size; this mirrors the well-established + curve_recursion_limit convention used by Anti-Grain Geometry (AGG), the reference + implementation most 2D vector graphics libraries derive their curve-flattening approach + from. This is a resource guarantee distinct from, and does not extend, the + tolerance guarantee in CanvasNet-Geometry-BezierFlattening-ToleranceConvergence. + tests: + - BezierFlattening_FlattenCubic_PathologicalNonConvergingCurve_TerminatesWithBoundedOutput + + - id: CanvasNet-Geometry-BezierFlattening-NonPositiveTolerance + title: >- + The BezierFlattening class shall throw ArgumentOutOfRangeException from both + FlattenCubic and FlattenQuadratic when tolerance is less than or equal to zero, + and shall not throw for any other input, including coincident or degenerate + control points. + justification: | + A non-positive tolerance can never be satisfied by any finite subdivision (a + tolerance of zero would require infinite recursion to satisfy exactly), so it + must be rejected immediately rather than causing a runaway or silently incorrect + result. + tests: + - BezierFlattening_Flatten_NonPositiveTolerance_ThrowsArgumentOutOfRangeException diff --git a/docs/reqstream/canvas-net/geometry/path.yaml b/docs/reqstream/canvas-net/geometry/path.yaml new file mode 100644 index 00000000..2e19f507 --- /dev/null +++ b/docs/reqstream/canvas-net/geometry/path.yaml @@ -0,0 +1,201 @@ +--- +# Software Unit Requirements for Path / PathBuilder +# +# These requirements describe the design-level behavior of the Path and PathBuilder unit, +# within the Geometry subsystem. Path is an immutable snapshot produced only by +# PathBuilder.Build(); PathBuilder is the mutable, fluent builder used to construct it. The +# supporting types Subpath, PathCommand, and PathCommandType have no requirements of their own +# (they are plain data carriers with no independently testable behavior) and are covered here +# inline, following the precedent set by Rgba32 being covered inline within the Surface unit's +# requirements. + +sections: + - title: CanvasNet Requirements + sections: + - title: Geometry Requirements + sections: + - title: Path Requirements + requirements: + - id: CanvasNet-Geometry-Path-BuildEveryCommandType + title: >- + The PathBuilder class shall record, in order, every drawing command issued + through MoveTo, LineTo, QuadraticBezierTo, CubicBezierTo, ArcTo, and Close, and + Build shall return an immutable Path snapshot exposing them via Subpaths. + justification: | + Callers need to describe an arbitrary vector path (straight lines, quadratic and + cubic Bezier curves, SVG-style elliptical arcs, and closed contours) using a + single, fluent, allocation-conscious builder, and to obtain an immutable result + they can safely share or cache once built. + tests: + - PathBuilder_Build_EveryCommandType_RecordsCommandsInOrder + + - id: CanvasNet-Geometry-Path-ArcToStoresRawParameters + title: >- + The PathBuilder class shall store the raw SVG arc parameters (radius, rotation, + large-arc-flag, sweep-flag, end point) passed to ArcTo without pre-inspecting or + pre-converting them to Bezier curves. + justification: | + A consumer that never needs Bezier segments (for example, a hit-testing + implementation with its own arc math) must never pay the cost of a conversion it + does not use; conversion is deferred to SvgArcConverter, invoked lazily only when + a consumer (such as Path.GetBounds) actually needs Bezier segments. + tests: + - PathBuilder_Build_EveryCommandType_RecordsCommandsInOrder + + - id: CanvasNet-Geometry-Path-MultipleSubpaths + title: >- + The PathBuilder class shall treat each MoveTo as the start of a new, + independent subpath, preserving every previously started subpath's own commands + unaffected. + justification: | + A single Path commonly represents multiple disjoint contours (for example, + letters with holes, or several disconnected shapes); each must be tracked + independently rather than merged into a single command sequence. + tests: + - PathBuilder_Build_MultipleSubpaths_PreservesEachIndependently + - PathBuilder_MoveTo_WhilePreviousSubpathOpen_CommitsPreviousSubpathAsOpen + + - id: CanvasNet-Geometry-Path-Close + title: >- + The PathBuilder class shall mark the current subpath as closed when Close is + called, recording a Close command and setting the resulting Subpath's IsClosed + to true. + justification: | + Many shapes (polygons, filled regions) require an explicit closing segment back + to the subpath's start point; callers need a way to declare this without + manually computing and appending a final LineTo back to the start point. + tests: + - PathBuilder_Close_OpenSubpath_MarksSubpathClosedWithCloseCommand + + - id: CanvasNet-Geometry-Path-MoveToAfterClose + title: >- + The PathBuilder class shall allow a MoveTo issued after Close to start a new + subpath. + justification: | + Close ends only the current subpath, not the whole path; callers must be able to + continue describing further independent subpaths afterward. + tests: + - PathBuilder_MoveTo_AfterClose_StartsNewSubpath + + - id: CanvasNet-Geometry-Path-GuardBeforeMoveTo + title: >- + The PathBuilder class shall throw InvalidOperationException from LineTo, + QuadraticBezierTo, CubicBezierTo, ArcTo, or Close when called before the first + MoveTo. + justification: | + Every drawing command implicitly continues from a current point established by + MoveTo; without a current point, a drawing command's start is undefined and must + be rejected rather than silently assumed to be the origin. + tests: + - PathBuilder_LineTo_BeforeFirstMoveTo_ThrowsInvalidOperationException + - PathBuilder_EveryDrawingCommand_BeforeFirstMoveTo_ThrowsInvalidOperationException + + - id: CanvasNet-Geometry-Path-GuardAfterCloseWithoutMoveTo + title: >- + The PathBuilder class shall throw InvalidOperationException from any drawing + command issued after Close without an intervening MoveTo. + justification: | + Close ends the current subpath; issuing a further drawing command without first + starting a new subpath via MoveTo has no well-defined current point to continue + from, and must be rejected rather than silently reopening the closed subpath. + tests: + - PathBuilder_LineTo_AfterCloseWithoutMoveTo_ThrowsInvalidOperationException + + - id: CanvasNet-Geometry-Path-BuildSnapshotIndependence + title: >- + The PathBuilder class shall return, from Build, a Path snapshot that is + unaffected by subsequent mutation of the builder, and the builder shall remain + usable to build further, independent Path instances afterward. + justification: | + Callers commonly build one Path, continue mutating the same builder instance to + build another (avoiding repeated allocation of a new builder), and must be able + to trust that the first Path they obtained never changes underneath them. + tests: + - PathBuilder_Build_ThenMutateBuilder_DoesNotAffectPreviouslyBuiltPath + + - id: CanvasNet-Geometry-Path-BuildEmpty + title: >- + The PathBuilder class shall return a Path with zero Subpaths from Build when no + commands have been issued. + justification: | + A builder that has never been used is a valid, well-defined state that must + produce a valid (if trivial) Path rather than throwing or returning null. + tests: + - PathBuilder_Build_NoCommandsIssued_ReturnsPathWithNoSubpaths + + - id: CanvasNet-Geometry-Path-Clear + title: >- + The PathBuilder class shall reset itself, via Clear, to the same empty state as + a newly constructed builder, without allocating new backing storage where + avoidable. + justification: | + Reusing a single builder instance across many paths (for example, once per frame + in a hot rendering loop) requires resetting its accumulated state without paying + the allocation cost of a brand-new builder each time. + tests: + - PathBuilder_Clear_AfterBuildingAPath_ResetsBuilderToEmptyState + + - id: CanvasNet-Geometry-Path-Immutability + title: >- + The Path and Subpath types shall prevent mutation of their exposed Subpaths and + Commands collections even when a caller downcasts the exposed + IReadOnlyList reference back to IList, throwing NotSupportedException + from any such attempted mutation. + justification: | + Path is documented as immutable, and Subpath's Commands must uphold the same + guarantee; typing the exposed property as IReadOnlyList alone only hides + mutating members from the compile-time API, since the underlying List still + implements IList and remains mutable through a downcast. Wrapping the backing + list in ReadOnlyCollection closes that hole at runtime, so a supposedly + immutable Path/Subpath cannot be mutated in place regardless of how a caller + casts the exposed reference. + tests: + - Path_Subpaths_DowncastToIList_ThrowsNotSupportedExceptionOnMutation + - Subpath_Commands_DowncastToIList_ThrowsNotSupportedExceptionOnMutation + + - id: CanvasNet-Geometry-Path-EmptySingleton + title: >- + The Path class shall expose a static Empty singleton with zero Subpaths, whose + GetBounds returns Rect.Empty. + justification: | + A well-known, reusable representation of "no path at all" avoids every caller + needing to special-case a null or freshly-built empty Path, and composes + correctly with Rect's own Empty union-identity convention. + tests: + - Path_Empty_HasNoSubpathsAndEmptyBounds + + - id: CanvasNet-Geometry-Path-GetBoundsConservative + title: >- + The Path class shall compute, by default (a non-positive flattenTolerance), a + conservative axis-aligned bounding Rect directly from each command's raw control + points and endpoints, relying on the convex-hull property of Bezier curves, + without flattening any curve. + justification: | + Many callers (for example, coarse culling or invalidation) need only a cheap, + possibly loose bound, and should not pay the cost of flattening every curve in + the path just to compute a bounding box. + tests: + - Path_GetBounds_ConservativeMode_CubicWithControlPointsOutsideChord_ReturnsControlPointConvexHull + + - id: CanvasNet-Geometry-Path-GetBoundsFlattened + title: >- + The Path class shall compute, when a positive flattenTolerance is supplied, a + tighter axis-aligned bounding Rect by first flattening every curve (including + ArcTo commands, converted via SvgArcConverter first) to a polyline within that + tolerance via BezierFlattening. + justification: | + Callers needing an accurate bound (for example, precise invalidation or + culling) must be able to trade additional computation for a materially tighter + bounding box than the conservative control-point convex hull provides. + tests: + - Path_GetBounds_FlattenMode_TighterThanConservativeMode_ForCurveWithWideControlPolygon + + - id: CanvasNet-Geometry-Path-GetBoundsMultipleSubpaths + title: >- + The Path class shall compute GetBounds as the union of every subpath's own + bounds, across all subpaths in the path. + justification: | + A path's overall bounds must account for every disjoint contour it contains, not + merely its first subpath. + tests: + - Path_GetBounds_MultipleSubpaths_UnionsAllSubpaths diff --git a/docs/reqstream/canvas-net/geometry/rect.yaml b/docs/reqstream/canvas-net/geometry/rect.yaml new file mode 100644 index 00000000..94e4e549 --- /dev/null +++ b/docs/reqstream/canvas-net/geometry/rect.yaml @@ -0,0 +1,122 @@ +--- +# Software Unit Requirements for Rect +# +# These requirements describe the design-level behavior of the Rect unit, within the +# Geometry subsystem. + +sections: + - title: CanvasNet Requirements + sections: + - title: Geometry Requirements + sections: + - title: Rect Requirements + requirements: + - id: CanvasNet-Geometry-Rect-Construction + title: >- + The Rect struct shall construct an axis-aligned rectangle from an X, Y, + Width, and Height, and compute Left, Top, Right, Bottom, Location, Size, + TopLeft, and BottomRight from those four fields. + justification: | + A position-plus-size rectangle is the simplest representation for callers to + construct directly, while the computed edge/corner properties save every caller + from re-deriving the same arithmetic. + tests: + - Rect_Constructor_ValidValues_SetsPropertiesAndComputedEdges + + - id: CanvasNet-Geometry-Rect-Empty + title: >- + The Rect struct shall expose a static Empty sentinel, represented as + X = Y = float.PositiveInfinity and Width = Height = float.NegativeInfinity, such + that Union(Empty, r) equals r for every rectangle r. + justification: | + Path.GetBounds folds Union over zero or more subpaths; an Empty value that is a + true identity element for Union removes the need to special-case "no subpaths + yet" and always yields the mathematically correct answer, including for a path + with no subpaths at all. An all-zero "empty" rectangle would instead silently + corrupt every union with a real rectangle by contributing the origin point. + tests: + - Rect_Union_WithEmpty_ReturnsOtherRectangleUnchanged + + - id: CanvasNet-Geometry-Rect-IsEmpty + title: >- + The Rect struct shall report IsEmpty as true when Width is negative, and false + otherwise. + justification: | + Callers need a cheap, documented way to distinguish the Empty sentinel (and any + other negative-size rectangle) from a real rectangle, without comparing every + field against Empty's specific sentinel values. + tests: + - Rect_IsEmpty_EmptySentinelVersusRealRectangle_ReturnsExpectedValue + + - id: CanvasNet-Geometry-Rect-Contains + title: >- + The Rect struct shall determine whether a point lies within the rectangle using + a half-open interval on both axes (left/top edges included, right/bottom edges + excluded). + justification: | + Hit-testing and bounds-checking callers need a single, unambiguous containment + rule; the half-open convention avoids double-counting points that lie exactly on + a shared edge between two adjacent rectangles. + tests: + - Rect_Contains_BoundaryPoints_ReturnsExpectedValue + + - id: CanvasNet-Geometry-Rect-Union + title: >- + The Rect struct shall compute the smallest rectangle enclosing both rectangles + passed to Union, as both an instance method and a static method. + justification: | + Combining bounds from multiple sources (for example, multiple subpaths of a + Path) is a fundamental geometric operation that must be available without + requiring callers to hand-roll the min/max arithmetic. + tests: + - Rect_Union_TwoOverlappingRectangles_ReturnsSmallestEnclosingRectangle + + - id: CanvasNet-Geometry-Rect-Intersect + title: >- + The Rect struct shall compute the overlapping region of two rectangles passed to + Intersect, as both an instance method and a static method, returning Empty when + the rectangles are disjoint, including when they merely touch along an edge with + no actual overlapping area. + justification: | + Determining whether, and where, two rectangles overlap is required for clipping + and hit-testing; returning the canonical Empty sentinel for disjoint input, + including the boundary case of edge-only contact, keeps the result composable + with Union and IsEmpty rather than returning a zero-width/zero-height rectangle + that is not itself recognized as Empty. + tests: + - Rect_Intersect_TwoOverlappingRectangles_ReturnsOverlapRegion + - Rect_Intersect_DisjointRectangles_ReturnsEmpty + - Rect_Intersect_RectanglesTouchingAlongVerticalEdge_ReturnsEmpty + - Rect_Intersect_RectanglesTouchingAlongHorizontalEdge_ReturnsEmpty + + - id: CanvasNet-Geometry-Rect-Transform + title: >- + The Rect struct shall compute the smallest axis-aligned rectangle enclosing this + rectangle after applying a Matrix3x2 transform, by transforming all four corners + individually and re-enclosing them. + justification: | + Transforming only two opposite corners is mathematically incorrect whenever the + matrix contains rotation or shear, since the image of an axis-aligned rectangle + under such a transform is a parallelogram rather than another axis-aligned + rectangle; all four corners must be considered to recover the true bounding box. + Empty's corners include +/-Infinity, and transforming them directly can produce + NaN (for example 0 * Infinity), so Empty is special-cased to return Empty + immediately rather than being transformed corner-by-corner. + tests: + - Rect_Transform_Translation_OffsetsPosition + - Rect_Transform_Rotation90DegreesAboutOrigin_ReturnsHandComputedAabb + - Rect_Transform_Empty_ReturnsEmpty + - Rect_Transform_Empty_UnderRotation_ReturnsEmpty + + - id: CanvasNet-Geometry-Rect-Equality + title: >- + The Rect struct shall support value equality (Equals, GetHashCode, ==, !=) based + on the exact stored X, Y, Width, and Height field values, and a ToString + representation that includes those field values. + justification: | + Callers, and the unit tests that verify this type, need a reliable way to + compare two Rect values (for example, an actual computed bounds against a + hand-computed expected bounds) and to render a rectangle for diagnostic output. + tests: + - Rect_Equals_SameAndDifferentValues_ReturnsExpectedResult + - Rect_ToString_ReturnsStringContainingFieldValues diff --git a/docs/reqstream/canvas-net/geometry/svg-arc-converter.yaml b/docs/reqstream/canvas-net/geometry/svg-arc-converter.yaml new file mode 100644 index 00000000..9bcac665 --- /dev/null +++ b/docs/reqstream/canvas-net/geometry/svg-arc-converter.yaml @@ -0,0 +1,74 @@ +--- +# Software Unit Requirements for SvgArcConverter +# +# These requirements describe the design-level behavior of the SvgArcConverter unit, within +# the Geometry subsystem. + +sections: + - title: CanvasNet Requirements + sections: + - title: Geometry Requirements + sections: + - title: SvgArcConverter Requirements + requirements: + - id: CanvasNet-Geometry-SvgArcConverter-ToBeziers + title: >- + The SvgArcConverter class shall convert an SVG-style endpoint-parameterized + elliptical arc into one or more cubic Bezier curves, appended to a + caller-supplied output list in end-to-end order, following the SVG 1.1 Appendix + F endpoint-to-center parameterization algorithm, for every valid combination of + the large-arc-flag and sweep-flag. + justification: | + SVG-style arcs are a compact, widely used way to describe elliptical arc + segments, but most rendering and flattening pipelines operate on Bezier curves; + callers need a spec-faithful conversion, and a caller-supplied output list keeps + the conversion allocation-conscious for hot rendering loops. + tests: + - SvgArcConverter_ToBeziers_Semicircle_ProducesContinuousChainOnExpectedCircle + - SvgArcConverter_ToBeziers_AllFourFlagCombinations_ProducesContinuousChainOnExpectedCircle + - SvgArcConverter_ToBeziers_RotatedEllipticalArc_ConnectsAndMatchesIndependentEllipse + - SvgArcConverter_ToBeziers_RadiiSmallerThanChord_ScalesUpAndReachesEndpoint + + - id: CanvasNet-Geometry-SvgArcConverter-StartEqualsEnd + title: >- + The SvgArcConverter class shall emit no Bezier segments when the arc's start + point equals its end point. + justification: | + The SVG specification itself defines a zero-length arc as rendering nothing; + emitting a degenerate zero-length or NaN-producing segment instead would be + incorrect and could destabilize downstream flattening or bounds computation. + tests: + - SvgArcConverter_ToBeziers_StartEqualsEnd_EmitsNoSegments + + - id: CanvasNet-Geometry-SvgArcConverter-ZeroRadius + title: >- + The SvgArcConverter class shall emit exactly one synthetic + straight-line-equivalent cubic Bezier, with control points at one-third and + two-thirds along the start-end chord, when either radius component is zero. + justification: | + The SVG specification defines an arc with a zero radius on either axis as + "treated as a straight line"; a synthetic cubic whose controls lie exactly on + the chord reproduces a straight line precisely while keeping the unit's output + contract uniform (always cubic Bezier segments) for every caller, rather than + requiring a special degenerate-case branch in every consumer. + tests: + - SvgArcConverter_ToBeziers_ZeroXRadius_EmitsSingleStraightLineEquivalentCubic + - SvgArcConverter_ToBeziers_ZeroYRadius_EmitsSingleStraightLineEquivalentCubic + + - id: CanvasNet-Geometry-SvgArcConverter-NeverThrows + title: >- + The SvgArcConverter class shall never throw for any SVG-valid input, including + out-of-range radii (corrected per the specification's scale-factor formula) and + both degenerate cases. + justification: | + Every input this unit's contract describes as valid (including the two + documented degenerate cases and radii requiring the specification's correction) + must be handled by producing a geometrically sensible result rather than + throwing, so that callers never need defensive exception handling around a + spec-valid arc. + tests: + - SvgArcConverter_ToBeziers_StartEqualsEnd_EmitsNoSegments + - SvgArcConverter_ToBeziers_ZeroXRadius_EmitsSingleStraightLineEquivalentCubic + - SvgArcConverter_ToBeziers_ZeroYRadius_EmitsSingleStraightLineEquivalentCubic + - SvgArcConverter_ToBeziers_AllFourFlagCombinations_ProducesContinuousChainOnExpectedCircle + - SvgArcConverter_ToBeziers_RadiiSmallerThanChord_ScalesUpAndReachesEndpoint diff --git a/docs/sysml2/model/canvas-net.sysml b/docs/sysml2/model/canvas-net.sysml index d391d9fe..bd3b973d 100644 --- a/docs/sysml2/model/canvas-net.sysml +++ b/docs/sysml2/model/canvas-net.sysml @@ -12,10 +12,11 @@ package CanvasNet { comment designRef /* Design: docs/design/canvas-net.md */ comment verificationRef /* Verification: docs/verification/canvas-net.md */ comment reqRef /* Requirements: docs/reqstream/canvas-net.yaml */ - comment /* A future Drawing subsystem (primitives/brushes/pens/transforms) is reserved but not yet implemented. */ + comment /* A future Drawing subsystem (shapes/brushes/pens, built on Canvas and Geometry) is reserved but not yet implemented. */ part canvas : Canvas; part codecs : Codecs; + part geometry : Geometry; } } diff --git a/docs/sysml2/model/canvas-net/geometry.sysml b/docs/sysml2/model/canvas-net/geometry.sysml new file mode 100644 index 00000000..eb99fa7e --- /dev/null +++ b/docs/sysml2/model/canvas-net/geometry.sysml @@ -0,0 +1,14 @@ +package CanvasNet { + part def Geometry { + doc /* Vector-geometry primitives subsystem: axis-aligned bounds (Rect), an immutable vector path and its builder (Path/PathBuilder, covering Subpath/PathCommand/PathCommandType inline), adaptive Bezier flattening (BezierFlattening), and SVG-style elliptical arc conversion (SvgArcConverter). Distinct from the reserved Drawing subsystem, which will rasterize this geometry onto a Canvas.Surface. */ + + comment designRef /* Design: docs/design/canvas-net/geometry.md */ + comment verificationRef /* Verification: docs/verification/canvas-net/geometry.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net/geometry.yaml */ + + part rect : Rect; + part path : Path; + part bezierFlattening : BezierFlattening; + part svgArcConverter : SvgArcConverter; + } +} diff --git a/docs/sysml2/model/canvas-net/geometry/bezier-flattening.sysml b/docs/sysml2/model/canvas-net/geometry/bezier-flattening.sysml new file mode 100644 index 00000000..44db1d28 --- /dev/null +++ b/docs/sysml2/model/canvas-net/geometry/bezier-flattening.sysml @@ -0,0 +1,11 @@ +package CanvasNet { + part def BezierFlattening { + doc /* Adaptive, tolerance-driven flattening of quadratic and cubic Bezier curves into polylines, writing into a caller-supplied output collection. */ + + comment sourceRef /* Source: src/DemaConsulting.CanvasNet/Geometry/BezierFlattening.cs */ + comment testRef /* Test: test/DemaConsulting.CanvasNet.Tests/Geometry/BezierFlatteningTests.cs */ + comment designRef /* Design: docs/design/canvas-net/geometry/bezier-flattening.md */ + comment verificationRef /* Verification: docs/verification/canvas-net/geometry/bezier-flattening.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net/geometry/bezier-flattening.yaml */ + } +} diff --git a/docs/sysml2/model/canvas-net/geometry/path.sysml b/docs/sysml2/model/canvas-net/geometry/path.sysml new file mode 100644 index 00000000..550f98ba --- /dev/null +++ b/docs/sysml2/model/canvas-net/geometry/path.sysml @@ -0,0 +1,11 @@ +package CanvasNet { + part def Path { + doc /* Immutable vector path (ordered independent Subpath contours) and its mutable, fluent PathBuilder. Also covers the supporting Subpath, PathCommand, and PathCommandType data types inline; none has independent behavior of its own. */ + + comment sourceRef /* Source: src/DemaConsulting.CanvasNet/Geometry/Path.cs, src/DemaConsulting.CanvasNet/Geometry/PathBuilder.cs, src/DemaConsulting.CanvasNet/Geometry/Subpath.cs, src/DemaConsulting.CanvasNet/Geometry/PathCommand.cs, src/DemaConsulting.CanvasNet/Geometry/PathCommandType.cs */ + comment testRef /* Test: test/DemaConsulting.CanvasNet.Tests/Geometry/PathBuilderTests.cs */ + comment designRef /* Design: docs/design/canvas-net/geometry/path.md */ + comment verificationRef /* Verification: docs/verification/canvas-net/geometry/path.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net/geometry/path.yaml */ + } +} diff --git a/docs/sysml2/model/canvas-net/geometry/rect.sysml b/docs/sysml2/model/canvas-net/geometry/rect.sysml new file mode 100644 index 00000000..1719846a --- /dev/null +++ b/docs/sysml2/model/canvas-net/geometry/rect.sysml @@ -0,0 +1,11 @@ +package CanvasNet { + part def Rect { + doc /* Axis-aligned bounding rectangle (position plus size), with union, intersection, matrix transformation, containment testing, and a union-identity Empty sentinel. */ + + comment sourceRef /* Source: src/DemaConsulting.CanvasNet/Geometry/Rect.cs */ + comment testRef /* Test: test/DemaConsulting.CanvasNet.Tests/Geometry/RectTests.cs */ + comment designRef /* Design: docs/design/canvas-net/geometry/rect.md */ + comment verificationRef /* Verification: docs/verification/canvas-net/geometry/rect.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net/geometry/rect.yaml */ + } +} diff --git a/docs/sysml2/model/canvas-net/geometry/svg-arc-converter.sysml b/docs/sysml2/model/canvas-net/geometry/svg-arc-converter.sysml new file mode 100644 index 00000000..28158f11 --- /dev/null +++ b/docs/sysml2/model/canvas-net/geometry/svg-arc-converter.sysml @@ -0,0 +1,11 @@ +package CanvasNet { + part def SvgArcConverter { + doc /* Converts SVG-style endpoint-parameterized elliptical arcs into cubic Bezier curves, following the SVG 1.1 Appendix F algorithm, including its documented degenerate cases. */ + + comment sourceRef /* Source: src/DemaConsulting.CanvasNet/Geometry/SvgArcConverter.cs */ + comment testRef /* Test: test/DemaConsulting.CanvasNet.Tests/Geometry/SvgArcConverterTests.cs */ + comment designRef /* Design: docs/design/canvas-net/geometry/svg-arc-converter.md */ + comment verificationRef /* Verification: docs/verification/canvas-net/geometry/svg-arc-converter.md */ + comment reqRef /* Requirements: docs/reqstream/canvas-net/geometry/svg-arc-converter.yaml */ + } +} diff --git a/docs/sysml2/views/design-views.sysml b/docs/sysml2/views/design-views.sysml index 519ed93b..de6ec9ab 100644 --- a/docs/sysml2/views/design-views.sysml +++ b/docs/sysml2/views/design-views.sysml @@ -19,4 +19,8 @@ package CanvasNet { view CodecsView { expose Codecs; } + + view GeometryView { + expose Geometry; + } } diff --git a/docs/verification/canvas-net.md b/docs/verification/canvas-net.md index d56cf493..9283b88a 100644 --- a/docs/verification/canvas-net.md +++ b/docs/verification/canvas-net.md @@ -139,8 +139,23 @@ observable through the `PngCodec` unit's save/load API. This is a system-level s (`PngCodec` depends on `Surface`, not vice versa); a `Surface` unit test may only depend on `Surface` itself and its documented dependencies. +### Integration: Build, Flatten, and Bound a Path Returns Expected Bounds + +**Test**: `CanvasNet_SystemIntegration_BuildFlattenAndBoundPath_ReturnsExpectedBounds` + +Exercises end-to-end system behavior across the `Geometry` subsystem's four units together: +constructs a `PathBuilder`, issues a `MoveTo`/`CubicBezierTo`/`ArcTo`/`Close` command sequence, and +calls `Build()` to obtain an immutable `Path`. Separately flattens the same cubic segment directly +via `BezierFlattening.FlattenCubic` and asserts the resulting polyline is non-empty and ends +exactly at the curve's declared end point. Calls `Path.GetBounds()` (the default, conservative +mode) and asserts the result is non-empty with positive width and height. Asserts the built +`Path` contains exactly one subpath and that it is closed. This is a system-level scenario, not a +single-unit scenario, because it exercises the collaboration between `PathBuilder`, `Path` +(including its internal use of `SvgArcConverter` to convert the `ArcTo` command), and +`BezierFlattening` together, rather than any one of the four `Geometry` units in isolation. + ## Acceptance Criteria -A system-level test run passes when all eleven scenarios above pass without error or exception +A system-level test run passes when all twelve scenarios above pass without error or exception beyond those explicitly asserted. Any unexpected exception, wrong exception type, or wrong return value constitutes a failure. diff --git a/docs/verification/canvas-net/geometry.md b/docs/verification/canvas-net/geometry.md new file mode 100644 index 00000000..b6b65962 --- /dev/null +++ b/docs/verification/canvas-net/geometry.md @@ -0,0 +1,30 @@ +## Geometry Subsystem Verification Design + +This document describes the subsystem-level verification strategy for the `Geometry` subsystem +(the `Rect`, `Path`/`PathBuilder`, `BezierFlattening`, and `SvgArcConverter` units). + +### Verification Approach + +The `Geometry` subsystem is verified primarily through each constituent unit's own tests (see +_Rect Unit Verification Design_ (`geometry/rect.md`), _Path Unit Verification Design_ +(`geometry/path.md`), _BezierFlattening Unit Verification Design_ +(`geometry/bezier-flattening.md`), and _SvgArcConverter Unit Verification Design_ +(`geometry/svg-arc-converter.md`)), each exercising its unit's public API in isolation. A single +system-level integration test additionally exercises the units together end to end (see the +system verification design, `../canvas-net.md`): building a `Path` via `PathBuilder` using a +`MoveTo`/`CubicBezierTo`/`ArcTo`/`Close` sequence, flattening the cubic segment directly via +`BezierFlattening`, and computing the path's bounds via `Path.GetBounds` - confirming the four +units collaborate correctly (in particular, that `Path.GetBounds` correctly invokes +`SvgArcConverter` for the `ArcTo` command). + +### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required; the `Geometry` subsystem has no injectable dependencies + +### Acceptance Criteria + +The `Geometry` subsystem's verification passes when every unit test scenario described in each +unit's own verification design document passes without error or unexpected exception, and the +system-level integration test described above passes. diff --git a/docs/verification/canvas-net/geometry/bezier-flattening.md b/docs/verification/canvas-net/geometry/bezier-flattening.md new file mode 100644 index 00000000..3c78aacb --- /dev/null +++ b/docs/verification/canvas-net/geometry/bezier-flattening.md @@ -0,0 +1,104 @@ +## BezierFlattening Unit Verification Design + +This document describes the unit-level verification strategy for the `BezierFlattening` class. + +### Verification Approach + +The `BezierFlattening` unit is verified through unit tests exercising the never-write-start / +always-write-end-last chaining convention, a tolerance-convergence property test (densely +sampling the true curve and checking every flattened point lies within a small multiple of the +requested tolerance), a monotonic segment-count property test, degenerate-control-point +termination, and the `ArgumentOutOfRangeException` guard - for both `FlattenCubic` and +`FlattenQuadratic`. + +The tolerance-convergence tests are property-based rather than golden-value-based: rather than +asserting an exact, pre-computed set of output points (which would be brittle against +implementation-internal subdivision choices, such as exactly how many segments a given tolerance +produces), each test evaluates the true curve at 1000 densely spaced parameter values via an +independent cubic/quadratic Bezier evaluation formula, and asserts every one of those true-curve +points lies within a small multiple of `tolerance` of the nearest point on the flattened +polyline. This verifies the actual contract (the flattened polyline hugs the true curve within +tolerance) without over-specifying the exact flattening implementation. + +A separate, deliberately distinct test verifies the `MaxRecursionDepth` safety valve itself: it +supplies a pathological curve and an extremely tight tolerance chosen so the flatness test can +never be satisfied at any practical depth, and asserts only that the call terminates with a +bounded output point count - it does not assert (and must not assert) that the result satisfies +the tolerance, because the recursion-depth guard is documented as a termination/resource +guarantee, distinct from the tolerance guarantee, following the well-established +`curve_recursion_limit` convention from Anti-Grain Geometry (AGG). + +Unit tests reside in `BezierFlatteningTests.cs` within the +`DemaConsulting.CanvasNet.Tests.Geometry` project namespace. + +### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required; `BezierFlattening` has no injectable dependencies + +### Unit-Level Test Scenarios + +#### CanvasNet-Geometry-BezierFlattening-FlattenCubic / CanvasNet-Geometry-BezierFlattening-FlattenQuadratic: Chaining Convention + +**Tests**: `BezierFlattening_FlattenCubic_SimpleCurve_NeverWritesStartAndAlwaysWritesEndLast`, +`BezierFlattening_FlattenQuadratic_SimpleCurve_NeverWritesStartAndAlwaysWritesEndLast` + +Flattens a simple, non-degenerate cubic (and, separately, quadratic) curve and asserts the +output list never contains the curve's start point and its final element exactly equals the +curve's end point. + +#### CanvasNet-Geometry-BezierFlattening-ToleranceConvergence: Flattened Points Stay Within Tolerance + +**Tests**: `BezierFlattening_FlattenCubic_VariousTolerances_SampledCurvePointsWithinTolerance`, +`BezierFlattening_FlattenQuadratic_VariousTolerances_SampledCurvePointsWithinTolerance`, +`BezierFlattening_FlattenCubic_ControlPointProjectsBeyondChordEnd_StaysWithinTolerance` + +For a table of tolerances, flattens a representative curved cubic (and, separately, quadratic) +Bezier curve, densely samples the true curve at 1000 points via an independently implemented +evaluation formula, and asserts the distance from every sampled point to the nearest point on the +flattened polyline is within a small multiple of the requested tolerance. A further regression +test uses a cubic curve whose control point's projection onto the endpoint chord falls beyond the +chord's end - so its distance to the infinite line through the chord is small, but its distance to +the finite chord segment is not - and asserts the flattener still subdivides and stays within +tolerance, guarding against a bug where the flatness test measured distance to the infinite line +rather than the finite chord segment. + +#### CanvasNet-Geometry-BezierFlattening-MonotonicSegmentCount: Segment Count Is Monotonic + +**Test**: `BezierFlattening_FlattenCubic_IncreasingTolerance_SegmentCountIsMonotonicallyNonIncreasing` + +Flattens the same cubic curve at a strictly increasing sequence of tolerances and asserts the +resulting output point count never increases as tolerance increases. + +#### CanvasNet-Geometry-BezierFlattening-DegenerateControlPoints: Degenerate Curves Terminate + +**Tests**: `BezierFlattening_FlattenCubic_CoincidentControlPoints_TerminatesWithSinglePoint`, +`BezierFlattening_FlattenCubic_CollinearControlPoints_TerminatesWithCollinearPoints` + +Flattens a cubic curve whose control points are coincident with an endpoint, and separately, a +cubic curve whose control points are collinear with the chord between its endpoints, and asserts +both terminate promptly (without throwing or hanging) with a geometrically sensible result. + +#### CanvasNet-Geometry-BezierFlattening-RecursionDepthSafetyValve: Pathological Input Still Terminates + +**Test**: `BezierFlattening_FlattenCubic_PathologicalNonConvergingCurve_TerminatesWithBoundedOutput` + +Flattens a curved cubic at an extremely tight tolerance (`1e-10`) that a curve of this scale can +never satisfy under float32 precision at any practical recursion depth, forcing the +`MaxRecursionDepth` safety valve to be exercised, and asserts the call still terminates promptly +with a bounded output point count (at most `2^MaxRecursionDepth`) ending at the curve's declared +end point. This test proves the documented safety-valve behavior itself (bounded termination) - +it deliberately does not assert that the tolerance is met, since `MaxRecursionDepth`'s remarks +document that the tolerance guarantee does not extend to input that hits this limit. + +#### CanvasNet-Geometry-BezierFlattening-NonPositiveTolerance: Non-Positive Tolerance Is Rejected + +**Test**: `BezierFlattening_Flatten_NonPositiveTolerance_ThrowsArgumentOutOfRangeException` + +Calls both `FlattenCubic` and `FlattenQuadratic` with a zero and a negative tolerance and asserts +`ArgumentOutOfRangeException` is thrown in every case. + +### Acceptance Criteria + +A unit test run passes when every scenario above passes without error or unexpected exception. diff --git a/docs/verification/canvas-net/geometry/path.md b/docs/verification/canvas-net/geometry/path.md new file mode 100644 index 00000000..b2d0efc4 --- /dev/null +++ b/docs/verification/canvas-net/geometry/path.md @@ -0,0 +1,141 @@ +## Path Unit Verification Design + +This document describes the unit-level verification strategy for the `Path` and `PathBuilder` +classes (and their supporting `Subpath`, `PathCommand`, and `PathCommandType` data types). + +### Verification Approach + +The `Path`/`PathBuilder` unit is verified through unit tests that exercise every fluent builder +method, the `InvalidOperationException` guards, `Build()`'s snapshot semantics, `Clear()`, +`Path.Empty`, and both modes of `Path.GetBounds`, in isolation. Expected bounding rectangles are +hand-computed independently of the implementation for both the conservative (control-point +convex-hull) mode and the flattening-based tighter mode. + +Unit tests reside in `PathBuilderTests.cs` within the `DemaConsulting.CanvasNet.Tests.Geometry` +project namespace. + +### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required; `Path`/`PathBuilder` have no injectable dependencies + +### Unit-Level Test Scenarios + +#### CanvasNet-Geometry-Path-BuildEveryCommandType / -ArcToStoresRawParameters: Every Command Recorded + +**Test**: `PathBuilder_Build_EveryCommandType_RecordsCommandsInOrder` + +Issues one of each command type (`LineTo`, `QuadraticBezierTo`, `CubicBezierTo`, `ArcTo`, `Close`) +in sequence and asserts `Build()` returns a `Path` whose single `Subpath.Commands` list contains +each command, in order, with every field (including the raw, unconverted SVG arc parameters on +the `ArcTo` command) exactly matching the values supplied. + +#### CanvasNet-Geometry-Path-MultipleSubpaths: Independent Subpaths + +**Tests**: `PathBuilder_Build_MultipleSubpaths_PreservesEachIndependently`, +`PathBuilder_MoveTo_WhilePreviousSubpathOpen_CommitsPreviousSubpathAsOpen` + +Issues two separate `MoveTo`-started subpaths, each with its own distinct commands, and asserts +`Build()` returns two subpaths whose commands do not cross-contaminate. Separately, issues a +`MoveTo` while the previous subpath is still open (no `Close`) and asserts the previous subpath is +committed with `IsClosed == false`. + +#### CanvasNet-Geometry-Path-Close: Close Marks the Subpath Closed + +**Test**: `PathBuilder_Close_OpenSubpath_MarksSubpathClosedWithCloseCommand` + +Issues `Close` on an open subpath and asserts the resulting `Subpath.IsClosed` is `true` and its +final command is a `Close` command. + +#### CanvasNet-Geometry-Path-MoveToAfterClose: MoveTo After Close Starts a New Subpath + +**Test**: `PathBuilder_MoveTo_AfterClose_StartsNewSubpath` + +Issues `Close`, then `MoveTo`, and asserts a new, independent subpath begins at the new `MoveTo` +point. + +#### CanvasNet-Geometry-Path-GuardBeforeMoveTo: Guards Before the First MoveTo + +**Tests**: `PathBuilder_LineTo_BeforeFirstMoveTo_ThrowsInvalidOperationException`, +`PathBuilder_EveryDrawingCommand_BeforeFirstMoveTo_ThrowsInvalidOperationException` + +Calls `LineTo` on a freshly constructed builder (before any `MoveTo`) and asserts +`InvalidOperationException` is thrown. Separately, repeats this for every drawing command +(`LineTo`, `QuadraticBezierTo`, `CubicBezierTo`, `ArcTo`, `Close`) and asserts each throws the same +exception. + +#### CanvasNet-Geometry-Path-GuardAfterCloseWithoutMoveTo: Guards After Close Without an Intervening MoveTo + +**Test**: `PathBuilder_LineTo_AfterCloseWithoutMoveTo_ThrowsInvalidOperationException` + +Calls `Close`, then `LineTo` without an intervening `MoveTo`, and asserts +`InvalidOperationException` is thrown. + +#### CanvasNet-Geometry-Path-BuildSnapshotIndependence: Build Returns an Independent Snapshot + +**Test**: `PathBuilder_Build_ThenMutateBuilder_DoesNotAffectPreviouslyBuiltPath` + +Builds a `Path`, then issues further commands (including a new `MoveTo`/subpath) on the same +builder, and asserts the previously built `Path` instance's `Subpaths` are unchanged. + +#### CanvasNet-Geometry-Path-BuildEmpty: Build With No Commands Returns No Subpaths + +**Test**: `PathBuilder_Build_NoCommandsIssued_ReturnsPathWithNoSubpaths` + +Calls `Build()` on a freshly constructed builder and asserts the returned `Path.Subpaths` is +empty. + +#### CanvasNet-Geometry-Path-Clear: Clear Resets the Builder + +**Test**: `PathBuilder_Clear_AfterBuildingAPath_ResetsBuilderToEmptyState` + +Builds a non-trivial `Path`, calls `Clear()`, then calls `Build()` again and asserts the second +`Path` has no subpaths, confirming the builder returned to its initial empty state. + +#### CanvasNet-Geometry-Path-EmptySingleton: Path.Empty Has No Subpaths and Empty Bounds + +**Test**: `Path_Empty_HasNoSubpathsAndEmptyBounds` + +Asserts `Path.Empty.Subpaths` is empty and `Path.Empty.GetBounds()` equals `Rect.Empty`. + +#### CanvasNet-Geometry-Path-GetBoundsConservative: Conservative Mode Returns the Control-Point Convex Hull + +**Test**: `Path_GetBounds_ConservativeMode_CubicWithControlPointsOutsideChord_ReturnsControlPointConvexHull` + +Builds a path containing a cubic Bezier curve whose control points extend outside the chord +between its endpoints, calls `GetBounds()` with the default (non-positive) tolerance, and asserts +the result exactly equals the hand-computed bounding rectangle of all four control +points/endpoints (not the tighter true curve bounds). + +#### CanvasNet-Geometry-Path-GetBoundsFlattened: Flattening Mode Is Tighter Than Conservative Mode + +**Test**: `Path_GetBounds_FlattenMode_TighterThanConservativeMode_ForCurveWithWideControlPolygon` + +Builds a path containing a curve with a wide control polygon (control points well outside the +true curve), calls `GetBounds()` both with the default tolerance and with a small positive +tolerance, and asserts the flattening-mode result is strictly smaller (tighter) than the +conservative-mode result while still containing the true curve. + +#### CanvasNet-Geometry-Path-GetBoundsMultipleSubpaths: Bounds Union Multiple Subpaths + +**Test**: `Path_GetBounds_MultipleSubpaths_UnionsAllSubpaths` + +Builds a path with two disjoint subpaths at different locations, calls `GetBounds()`, and asserts +the result exactly equals the hand-computed union of each subpath's own bounds. + +#### CanvasNet-Geometry-Path-Immutability: Subpaths and Commands Resist Downcast Mutation + +**Tests**: `Path_Subpaths_DowncastToIList_ThrowsNotSupportedExceptionOnMutation`, +`Subpath_Commands_DowncastToIList_ThrowsNotSupportedExceptionOnMutation` + +Builds a `Path`, downcasts its `Subpaths` (respectively a `Subpath`'s `Commands`) from the +compile-time `IReadOnlyList` view to the mutable `IList` interface that its underlying +`ReadOnlyCollection` still implements, and asserts every mutating member exercised (`Add`, +`RemoveAt`, `Clear`) throws `NotSupportedException` regardless of the cast - proving `Path` and +`Subpath` cannot be mutated in place even by a caller deliberately bypassing the read-only +compile-time type. + +### Acceptance Criteria + +A unit test run passes when every scenario above passes without error or unexpected exception. diff --git a/docs/verification/canvas-net/geometry/rect.md b/docs/verification/canvas-net/geometry/rect.md new file mode 100644 index 00000000..8239b037 --- /dev/null +++ b/docs/verification/canvas-net/geometry/rect.md @@ -0,0 +1,95 @@ +## Rect Unit Verification Design + +This document describes the unit-level verification strategy for the `Rect` struct. + +### Verification Approach + +The `Rect` unit is verified through unit tests that exercise its constructor, computed +edge/corner properties, `IsEmpty`, `Contains`, `Union`, `Intersect`, `Transform`, value equality, +and `ToString`, in isolation. Expected values for `Transform` under rotation are hand-computed +independently of the implementation, not by re-deriving the same rotation formula under test. + +Unit tests reside in `RectTests.cs` within the `DemaConsulting.CanvasNet.Tests.Geometry` project +namespace. + +### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required; `Rect` has no injectable dependencies + +### Unit-Level Test Scenarios + +#### CanvasNet-Geometry-Rect-Construction: Constructor Sets Properties and Computed Edges + +**Test**: `Rect_Constructor_ValidValues_SetsPropertiesAndComputedEdges` + +Constructs a `Rect` with known `X`, `Y`, `Width`, `Height` and asserts every computed +property (`Left`, `Top`, `Right`, `Bottom`, `Location`, `Size`, `TopLeft`, `BottomRight`) equals +its hand-computed expected value. + +#### CanvasNet-Geometry-Rect-Empty: Union With Empty Is an Identity + +**Test**: `Rect_Union_WithEmpty_ReturnsOtherRectangleUnchanged` + +Unions `Rect.Empty` with a real rectangle (in both argument orders) and asserts the result +exactly equals the real rectangle unchanged, confirming `Empty` is a true union identity. + +#### CanvasNet-Geometry-Rect-IsEmpty: IsEmpty Distinguishes Empty From Real Rectangles + +**Test**: `Rect_IsEmpty_EmptySentinelVersusRealRectangle_ReturnsExpectedValue` + +Asserts `Rect.Empty.IsEmpty` is `true`, and that a real rectangle (including one with a zero +width or height, but not negative) reports `IsEmpty` as `false`. + +#### CanvasNet-Geometry-Rect-Contains: Contains Uses a Half-Open Interval + +**Test**: `Rect_Contains_BoundaryPoints_ReturnsExpectedValue` + +Tests points at each corner and edge of a rectangle and asserts the half-open containment +convention: left/top edges included, right/bottom edges excluded. + +#### CanvasNet-Geometry-Rect-Union: Union of Two Overlapping Rectangles + +**Test**: `Rect_Union_TwoOverlappingRectangles_ReturnsSmallestEnclosingRectangle` + +Unions two overlapping rectangles (via both the instance and static overloads) and asserts the +result exactly equals the hand-computed smallest enclosing rectangle. + +#### CanvasNet-Geometry-Rect-Intersect: Intersect of Overlapping and Disjoint Rectangles + +**Tests**: `Rect_Intersect_TwoOverlappingRectangles_ReturnsOverlapRegion`, +`Rect_Intersect_DisjointRectangles_ReturnsEmpty`, +`Rect_Intersect_RectanglesTouchingAlongVerticalEdge_ReturnsEmpty`, +`Rect_Intersect_RectanglesTouchingAlongHorizontalEdge_ReturnsEmpty` + +Intersects two overlapping rectangles and asserts the result exactly equals the hand-computed +overlap region. Separately, intersects two disjoint rectangles and asserts the result exactly +equals `Rect.Empty`. Two further regression tests intersect rectangles that merely touch along a +vertical or horizontal edge (zero-width/zero-height overlap) and assert `Empty` is returned rather +than a non-`Empty` zero-extent `Rect`, since edge-only contact contains no points. + +#### CanvasNet-Geometry-Rect-Transform: Transform Considers All Four Corners + +**Tests**: `Rect_Transform_Translation_OffsetsPosition`, +`Rect_Transform_Rotation90DegreesAboutOrigin_ReturnsHandComputedAabb` + +Transforms a rectangle by a pure translation matrix and asserts the result is offset by exactly +that translation with unchanged size. Separately, transforms a rectangle by a 90-degree rotation +matrix about the origin and asserts the result exactly equals the hand-computed axis-aligned +bounding box of the four rotated corners - a scenario that would fail under a naive +two-corner-only transform implementation. + +#### CanvasNet-Geometry-Rect-Equality: Value Equality and ToString + +**Tests**: `Rect_Equals_SameAndDifferentValues_ReturnsExpectedResult`, +`Rect_ToString_ReturnsStringContainingFieldValues` + +Compares pairs of `Rect` values with identical and with differing field values via `Equals`, +`GetHashCode`, `==`, and `!=`, asserting the expected result for each. Separately, calls +`ToString()` on a `Rect` with known field values and asserts the returned string contains each +field's value. + +### Acceptance Criteria + +A unit test run passes when every scenario above passes without error or unexpected exception. diff --git a/docs/verification/canvas-net/geometry/svg-arc-converter.md b/docs/verification/canvas-net/geometry/svg-arc-converter.md new file mode 100644 index 00000000..bce1be77 --- /dev/null +++ b/docs/verification/canvas-net/geometry/svg-arc-converter.md @@ -0,0 +1,92 @@ +## SvgArcConverter Unit Verification Design + +This document describes the unit-level verification strategy for the `SvgArcConverter` class. + +### Verification Approach + +The `SvgArcConverter` unit is verified through unit tests exercising both documented degenerate +cases, a true semicircle golden scenario, all four `largeArc`/`sweep` flag combinations on a +circular arc, and a rotated elliptical arc. + +Verification against sampled points uses an independently written, double-precision +implementation of the SVG endpoint-to-center parameterization (reimplemented directly from the +SVG 1.1 specification text, not by reading `SvgArcConverter`'s own implementation) as an oracle, +so that a shared bug in the algorithm under test cannot also hide itself in the verification. This +oracle computes the arc's center, start angle, and angular sweep in `double` precision; test +assertions then check that every sampled point along the resulting Bezier chain lies within a +small tolerance of the corresponding circle/ellipse, and that the chain connects continuously +from the declared start point to the declared end point. + +Unit tests reside in `SvgArcConverterTests.cs` within the +`DemaConsulting.CanvasNet.Tests.Geometry` project namespace. + +### Test Environment + +- **Framework**: xUnit v3 running under the .NET SDK +- **Execution**: `dotnet test` invoked by `build.ps1` and the CI pipeline +- **Mocking**: None required; `SvgArcConverter` has no injectable dependencies + +### Unit-Level Test Scenarios + +#### CanvasNet-Geometry-SvgArcConverter-StartEqualsEnd: Zero-Length Arc Emits Nothing + +**Test**: `SvgArcConverter_ToBeziers_StartEqualsEnd_EmitsNoSegments` + +Calls `ToBeziers` with `start` equal to `end` and asserts the output list remains empty, per the +SVG specification's documented zero-length-arc case. + +#### CanvasNet-Geometry-SvgArcConverter-ZeroRadius: Zero Radius Emits a Synthetic Straight-Line Cubic + +**Tests**: `SvgArcConverter_ToBeziers_ZeroXRadius_EmitsSingleStraightLineEquivalentCubic`, +`SvgArcConverter_ToBeziers_ZeroYRadius_EmitsSingleStraightLineEquivalentCubic` + +Calls `ToBeziers` with a zero x-radius, and separately a zero y-radius, and asserts exactly one +cubic Bezier segment is emitted, whose control points lie exactly at one-third and two-thirds +along the `start`-`end` chord and whose end point equals the declared `end`. + +#### CanvasNet-Geometry-SvgArcConverter-ToBeziers: Golden Semicircle, All Flag Combinations, and a Rotated Ellipse + +**Test**: `SvgArcConverter_ToBeziers_Semicircle_ProducesContinuousChainOnExpectedCircle` + +Converts a true semicircular arc (a chord length exactly equal to the diameter, so the arc spans +exactly 180 degrees for any flag combination), asserts the resulting Bezier chain connects +continuously from `start` to `end`, that the independently computed center is equidistant from +both endpoints, that every densely sampled point along the chain lies within a small tolerance of +that circle, and that the independently computed angular sweep is exactly 180 degrees. + +**Test**: `SvgArcConverter_ToBeziers_AllFourFlagCombinations_ProducesContinuousChainOnExpectedCircle` + +For each of the four `largeArc`/`sweep` combinations, on a circular arc where two candidate +circles exist (so each combination produces a distinct, well-defined arc), asserts the chain +connects continuously, every sampled point lies on the independently computed circle, and the +independently computed angular sweep is at least 180 degrees when `largeArc` is `true` and at +most 180 degrees when `largeArc` is `false`. + +**Test**: `SvgArcConverter_ToBeziers_RotatedEllipticalArc_ConnectsAndMatchesIndependentEllipse` + +Converts a rotated, non-circular elliptical arc (`rx != ry`, 30-degree x-axis rotation), asserts +the chain connects continuously from `start` to `end`, and that the independently computed +ellipse's point at the expected end angle matches the arc's declared end point. + +**Test**: `SvgArcConverter_ToBeziers_RadiiSmallerThanChord_ScalesUpAndReachesEndpoint` + +Converts an arc whose requested radii are smaller than the half-chord distance between `start` +and `end` (`rx=20`, `ry=10` for a 100-unit chord along the x-axis), forcing the SVG specification's +`lambda > 1` radius scale-up correction. Asserts the resulting chain connects continuously to the +declared `end` point, and that every sampled point along the chain lies on the mathematically +derived, corrected ellipse (`rx=50`, `ry=25` - the analytically computed `sqrt(lambda) = 2.5` +scale factor applied to the requested radii), proving the scale-up correction was actually applied +rather than the original, too-small radii. This is a regression test: the existing golden +scenarios above all use radii already larger than their chord's half-distance, so none of them +previously exercised this correction path. + +#### CanvasNet-Geometry-SvgArcConverter-NeverThrows: Never Throws for SVG-Valid Input + +Covered by every test above: none of them expects or catches an exception, and each supplies +SVG-valid input (including both degenerate cases, every flag combination, and out-of-range radii +requiring the scale-up correction), so a passing test run is itself evidence that `ToBeziers` +never throws for these inputs. + +### Acceptance Criteria + +A unit test run passes when every scenario above passes without error or unexpected exception. diff --git a/requirements.yaml b/requirements.yaml index d9eb1d43..84d52e98 100644 --- a/requirements.yaml +++ b/requirements.yaml @@ -16,6 +16,11 @@ includes: - docs/reqstream/canvas-net/codecs/png-codec.yaml - docs/reqstream/canvas-net/codecs/tiff-codec.yaml - docs/reqstream/canvas-net/codecs/jpeg-codec.yaml + - docs/reqstream/canvas-net/geometry.yaml + - docs/reqstream/canvas-net/geometry/rect.yaml + - docs/reqstream/canvas-net/geometry/path.yaml + - docs/reqstream/canvas-net/geometry/bezier-flattening.yaml + - docs/reqstream/canvas-net/geometry/svg-arc-converter.yaml - docs/reqstream/canvas-net/platform-requirements.yaml - docs/reqstream/ots/xunit.yaml - docs/reqstream/ots/reqstream.yaml diff --git a/src/DemaConsulting.CanvasNet/Geometry/BezierFlattening.cs b/src/DemaConsulting.CanvasNet/Geometry/BezierFlattening.cs new file mode 100644 index 00000000..09a1f930 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Geometry/BezierFlattening.cs @@ -0,0 +1,201 @@ +using System.Numerics; + +namespace DemaConsulting.CanvasNet.Geometry; + +/// +/// Provides adaptive flattening of quadratic and cubic Bezier curves into polylines within a +/// caller-specified tolerance. +/// +/// +/// Both methods write into a caller-supplied rather than allocating and +/// returning a fresh list, because a future rasterizer/stroker will call these in hot loops +/// across many path segments, and repeated per-call allocation would be wasteful; the caller +/// owns buffer reuse. Neither method writes the curve's start point (p0) to the output +/// list - each writes only the interior-and-final points of the curve, always writing the +/// final point last - so that a caller building a continuous polyline for an entire subpath +/// can chain repeated calls (each curve's start point is implicitly the previous call's last +/// written point) without duplicating vertices at segment boundaries. +/// +public static class BezierFlattening +{ + /// + /// The maximum recursive subdivision depth for both flattening methods. + /// + /// + /// + /// This is a resource/termination safety bound, not a per-input guarantee that the + /// returned polyline is within tolerance of the true curve. It guarantees the + /// algorithm always terminates, with a bounded output size, even for pathological input + /// (near-coincident control points at floating-point precision limits, or degenerate + /// near-zero-length curves combined with an extremely tight tolerance) that might + /// otherwise never satisfy the flatness test at any depth. Once this depth is reached, the + /// current sub-curve is accepted and its end point is appended regardless of whether it + /// actually passes the flatness test. + /// + /// + /// This is the same design used by Anti-Grain Geometry (AGG) - the reference + /// implementation most 2D vector graphics libraries derive their curve-flattening approach from - + /// whose curve_recursion_limit (documented default of 32) is likewise an explicit + /// resource/pathological-input safety valve rather than a tolerance guarantee, and whose + /// implementation likewise emits the segment once the limit is hit regardless of flatness. + /// + /// + /// This value deliberately keeps 20 rather than matching AGG's 32 verbatim. Both values + /// are equally "never reached" for any well-formed curve, so the only thing that + /// distinguishes them is the worst-case bound they place on a genuinely pathological or + /// adversarial input that never converges under the flatness test - and that is precisely + /// the scenario this limit exists to bound. At depth 20, that worst case is 2^20 (about + /// one million) output points per input curve: large, but a single flattening call still + /// completes in a bounded, modest amount of time and memory. At depth 32, the same + /// worst case is 2^32 (over four billion) output points per input curve - for a + /// pathological or adversarially crafted input, that is no longer a "safety valve" at all; + /// it is a multi-gigabyte allocation and a multi-minute-or-worse hang, i.e. the exact + /// resource-exhaustion outcome this limit is meant to prevent. AGG's 32 default was chosen + /// for its own historical/precision context and is not a reason to accept a worst case four + /// thousand times larger here purely for the sake of matching it; 20 is kept as the + /// deliberately tighter, still-astronomically-generous bound. + /// + /// + private const int MaxRecursionDepth = 20; + + /// + /// Flattens a cubic Bezier curve into a polyline within , + /// appending the resulting points to . + /// + /// The curve's start point. Never written to . + /// The curve's first control point. + /// The curve's second control point. + /// The curve's end point. Always the last point written to . + /// + /// The maximum allowed perpendicular deviation, in the same units as the input points, + /// between the flattened polyline and the true curve. Must be greater than zero. See + /// 's remarks for the pathological-input safety-valve + /// caveat to this guarantee. + /// + /// The list to append the flattened points to. + /// + /// Thrown when is less than or equal to zero. + /// + /// + /// Every combination of control points is mathematically valid, including coincident or + /// collinear ones, so no input other than a non-positive + /// causes an exception; degenerate curves are simply accepted as "flat enough" immediately + /// by the same flatness test used for well-formed curves. + /// + public static void FlattenCubic(Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3, float tolerance, IList output) + { + if (tolerance <= 0) + { + throw new ArgumentOutOfRangeException(nameof(tolerance), tolerance, "Tolerance must be greater than zero."); + } + + FlattenCubicRecursive(p0, p1, p2, p3, tolerance, output, 0); + } + + /// + /// Flattens a quadratic Bezier curve into a polyline within , + /// appending the resulting points to . + /// + /// The curve's start point. Never written to . + /// The curve's single control point. + /// The curve's end point. Always the last point written to . + /// + /// The maximum allowed perpendicular deviation, in the same units as the input points, + /// between the flattened polyline and the true curve. Must be greater than zero. See + /// 's remarks for the pathological-input safety-valve + /// caveat to this guarantee. + /// + /// The list to append the flattened points to. + /// + /// Thrown when is less than or equal to zero. + /// + /// + /// Every combination of control points is mathematically valid, including coincident or + /// collinear ones, so no input other than a non-positive + /// causes an exception; degenerate curves are simply accepted as "flat enough" immediately + /// by the same flatness test used for well-formed curves. + /// + public static void FlattenQuadratic(Vector2 p0, Vector2 p1, Vector2 p2, float tolerance, IList output) + { + if (tolerance <= 0) + { + throw new ArgumentOutOfRangeException(nameof(tolerance), tolerance, "Tolerance must be greater than zero."); + } + + FlattenQuadraticRecursive(p0, p1, p2, tolerance, output, 0); + } + + /// + /// Recursively subdivides a cubic Bezier curve, appending flattened points to . + /// + private static void FlattenCubicRecursive(Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3, float tolerance, IList output, int depth) + { + // Flatness test: distance of each interior control point from the finite chord segment + // p0-p3 (not the infinite line through it) must be within tolerance + if (depth >= MaxRecursionDepth || IsFlatEnough(p0, p1, p3, tolerance) && IsFlatEnough(p0, p2, p3, tolerance)) + { + output.Add(p3); + return; + } + + // De Casteljau midpoint split at t = 0.5: repeatedly average adjacent points to obtain + // the two control polygons for the left and right halves of the curve + var p01 = (p0 + p1) / 2; + var p12 = (p1 + p2) / 2; + var p23 = (p2 + p3) / 2; + var p012 = (p01 + p12) / 2; + var p123 = (p12 + p23) / 2; + var mid = (p012 + p123) / 2; + + FlattenCubicRecursive(p0, p01, p012, mid, tolerance, output, depth + 1); + FlattenCubicRecursive(mid, p123, p23, p3, tolerance, output, depth + 1); + } + + /// + /// Recursively subdivides a quadratic Bezier curve, appending flattened points to . + /// + private static void FlattenQuadraticRecursive(Vector2 p0, Vector2 p1, Vector2 p2, float tolerance, IList output, int depth) + { + if (depth >= MaxRecursionDepth || IsFlatEnough(p0, p1, p2, tolerance)) + { + output.Add(p2); + return; + } + + // De Casteljau midpoint split at t = 0.5 + var p01 = (p0 + p1) / 2; + var p12 = (p1 + p2) / 2; + var mid = (p01 + p12) / 2; + + FlattenQuadraticRecursive(p0, p01, mid, tolerance, output, depth + 1); + FlattenQuadraticRecursive(mid, p12, p2, tolerance, output, depth + 1); + } + + /// + /// Tests whether control point lies within + /// of the finite chord segment from + /// to . + /// + private static bool IsFlatEnough(Vector2 chordStart, Vector2 control, Vector2 chordEnd, float tolerance) + { + var chord = chordEnd - chordStart; + var toControl = control - chordStart; + + var chordLengthSquared = chord.LengthSquared(); + if (chordLengthSquared <= float.Epsilon) + { + // A zero-length chord (p0 == pEnd) has no well-defined projection - fall back to the + // straight-line distance from the chord point to the control point + return toControl.Length() <= tolerance; + } + + // Distance to the finite chord *segment*, not the infinite line through it: project + // control onto the chord and clamp the parameter to [0, 1] before measuring distance to + // that clamped point. Measuring against the infinite line alone would accept a control + // point whose projection falls beyond chordEnd as "flat" even though the curve it belongs + // to travels well outside the [chordStart, chordEnd] segment. + var t = Math.Clamp(Vector2.Dot(toControl, chord) / chordLengthSquared, 0f, 1f); + var closest = chordStart + t * chord; + return Vector2.Distance(control, closest) <= tolerance; + } +} diff --git a/src/DemaConsulting.CanvasNet/Geometry/NamespaceDoc.cs b/src/DemaConsulting.CanvasNet/Geometry/NamespaceDoc.cs new file mode 100644 index 00000000..784992cd --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Geometry/NamespaceDoc.cs @@ -0,0 +1,17 @@ +namespace DemaConsulting.CanvasNet.Geometry; + +/// +/// The namespace provides resolution-independent vector +/// geometry building blocks: an axis-aligned bounding box (), an immutable +/// vector path representation with a fluent builder ( and +/// ), adaptive Bezier curve flattening (), +/// and SVG elliptical-arc-to-Bezier conversion (). This namespace +/// deliberately reuses and +/// directly for points, vectors, and transforms, rather than inventing custom wrapper types, +/// since both are already available in-box on every target framework. These types are pure, +/// allocation-conscious building blocks with no dependency on any rasterizer or pixel format; +/// future drawing/rendering functionality will consume this namespace, not the other way around. +/// +internal static class NamespaceDoc +{ +} diff --git a/src/DemaConsulting.CanvasNet/Geometry/Path.cs b/src/DemaConsulting.CanvasNet/Geometry/Path.cs new file mode 100644 index 00000000..5d1322b6 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Geometry/Path.cs @@ -0,0 +1,184 @@ +using System.Collections.ObjectModel; +using System.Numerics; + +namespace DemaConsulting.CanvasNet.Geometry; + +/// +/// Represents an immutable vector path: an ordered collection of independent +/// contours, each an ordered sequence of drawing commands. +/// +/// +/// has no public constructor - every instance is produced by +/// , which is the only component responsible for guaranteeing +/// every subpath is well-formed (starts with a real move-derived , +/// never contains a dangling/empty subpath). This makes an invalid +/// unrepresentable rather than merely undocumented. +/// +public sealed class Path +{ + /// + /// The singleton empty path, containing zero subpaths. + /// + /// + /// on this instance returns , matching + /// 's union-identity convention: an empty path contributes nothing to + /// any bounds it is combined with. + /// + public static readonly Path Empty = new(new List()); + + /// + /// The ordered collection of independent subpaths making up this path. + /// + public IReadOnlyList Subpaths { get; } + + /// + /// Initializes a new immutable path snapshot. Used only by + /// (and this type's own singleton), which is the sole component + /// responsible for guaranteeing every subpath is well-formed. + /// + /// The ordered collection of independent subpaths. + /// + /// Wraps in a rather than + /// exposing it directly: only hides mutating members from + /// the compile-time API, but a caller could still downcast back to + /// (its underlying implements it) and mutate + /// a supposedly-immutable in place. + /// closes that hole - its own mutating members throw + /// regardless of how it is cast. + /// + internal Path(IList subpaths) + { + Subpaths = new ReadOnlyCollection(subpaths); + } + + /// + /// Computes an axis-aligned bounding box enclosing this entire path. + /// + /// + /// When less than or equal to zero (the default), the bounds are computed conservatively + /// and cheaply from each command's raw control points and endpoints - including + /// commands, which are converted to Bezier curves via + /// first, since arcs have no control points of their own - + /// relying on the convex-hull property of Bezier curves (every point on a quadratic or + /// cubic Bezier curve lies within the convex hull of its control points and endpoints, so + /// enclosing every control point and endpoint always encloses the whole curve, though + /// possibly more loosely than the curve's true bounds). When greater than zero, every + /// curve is first flattened to a polyline within the given tolerance via + /// , and the bounds are computed from the flattened points + /// instead - this is more expensive but produces a tighter bound, since a curve's flattened + /// polyline hugs the true curve far more closely than its control-point convex hull does. + /// + /// + /// The smallest axis-aligned rectangle (conservative, or tighter under a positive + /// ) enclosing this path, or if + /// this path has zero subpaths. + /// + public Rect GetBounds(float flattenTolerance = 0) + { + var bounds = Rect.Empty; + + foreach (var subpath in Subpaths) + { + var current = subpath.Start; + bounds = bounds.Union(PointRect(current)); + + foreach (var command in subpath.Commands) + { + bounds = AccumulateCommandBounds(current, command, flattenTolerance, ref bounds); + + // Close never carries an EndPoint of its own (it always returns to Start); every + // other command type's EndPoint becomes the new "current point" for the next + // command in sequence + if (command.Type != PathCommandType.Close) + { + current = command.EndPoint; + } + } + } + + return bounds; + } + + /// + /// Folds one command's contribution into , choosing between the + /// conservative control-point convex-hull bound and the flattening-based tighter bound + /// according to . + /// + private static Rect AccumulateCommandBounds(Vector2 current, PathCommand command, float flattenTolerance, ref Rect bounds) + { + switch (command.Type) + { + case PathCommandType.LineTo: + return bounds.Union(PointRect(command.EndPoint)); + + case PathCommandType.QuadraticBezierTo: + if (flattenTolerance > 0) + { + var points = new List(); + BezierFlattening.FlattenQuadratic(current, command.Control1, command.EndPoint, flattenTolerance, points); + foreach (var point in points) + { + bounds = bounds.Union(PointRect(point)); + } + + return bounds; + } + + return bounds.Union(PointRect(command.Control1)).Union(PointRect(command.EndPoint)); + + case PathCommandType.CubicBezierTo: + if (flattenTolerance > 0) + { + var points = new List(); + BezierFlattening.FlattenCubic(current, command.Control1, command.Control2, command.EndPoint, flattenTolerance, points); + foreach (var point in points) + { + bounds = bounds.Union(PointRect(point)); + } + + return bounds; + } + + return bounds.Union(PointRect(command.Control1)).Union(PointRect(command.Control2)).Union(PointRect(command.EndPoint)); + + case PathCommandType.ArcTo: + // Arcs carry no control points of their own - convert to the equivalent cubic + // Bezier segments first (via SvgArcConverter), then apply the same convex-hull or + // flattening treatment as any other curve + var segments = new List<(Vector2 Control1, Vector2 Control2, Vector2 End)>(); + SvgArcConverter.ToBeziers(current, command.Radius, command.RotationDegrees, command.LargeArc, command.Sweep, command.EndPoint, segments); + + var segmentStart = current; + foreach (var segment in segments) + { + if (flattenTolerance > 0) + { + var points = new List(); + BezierFlattening.FlattenCubic(segmentStart, segment.Control1, segment.Control2, segment.End, flattenTolerance, points); + foreach (var point in points) + { + bounds = bounds.Union(PointRect(point)); + } + } + else + { + bounds = bounds.Union(PointRect(segment.Control1)).Union(PointRect(segment.Control2)).Union(PointRect(segment.End)); + } + + segmentStart = segment.End; + } + + return bounds; + + case PathCommandType.Close: + default: + return bounds; + } + } + + /// + /// Returns a zero-size located at , suitable + /// for folding into a running accumulation. + /// + private static Rect PointRect(Vector2 point) => new(point.X, point.Y, 0, 0); +} diff --git a/src/DemaConsulting.CanvasNet/Geometry/PathBuilder.cs b/src/DemaConsulting.CanvasNet/Geometry/PathBuilder.cs new file mode 100644 index 00000000..4252322d --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Geometry/PathBuilder.cs @@ -0,0 +1,221 @@ +using System.Numerics; + +namespace DemaConsulting.CanvasNet.Geometry; + +/// +/// A mutable, fluent, reusable builder for constructing an immutable from a +/// sequence of move/draw/close operations. +/// +/// +/// Every drawing method returns to support fluent chaining +/// (builder.MoveTo(a).LineTo(b).Close()). is the sole +/// component responsible for the invariants / rely on: +/// every subpath starts with a real , no drawing command is ever recorded +/// before the first , and no drawing command is ever recorded after +/// without an intervening . Not thread-safe: a single +/// instance must not be used concurrently from multiple threads. +/// +public sealed class PathBuilder +{ + /// + /// Completed subpaths accumulated so far by previous /close cycles. + /// + private readonly List _subpaths = []; + + /// + /// Commands accumulated for the subpath currently being built, since the most recent + /// . + /// + private List _currentCommands = []; + + /// + /// The point the current subpath started at, from the most recent . + /// Only meaningful when is . + /// + private Vector2 _currentStart; + + /// + /// if a subpath has been started (via ) and not + /// yet closed; before the first , and again + /// immediately after until the next . + /// + private bool _hasCurrentSubpath; + + /// + /// Starts a new subpath at . + /// + /// The point the new subpath starts at. + /// This builder, for fluent chaining. + /// + /// If a previous subpath is still open (not d), it is committed as-is + /// (open) before the new subpath begins; never implicitly closes the + /// previous subpath, matching SVG path-data semantics. + /// + public PathBuilder MoveTo(Vector2 point) + { + CommitCurrentSubpath(isClosed: false); + + _currentStart = point; + _currentCommands = []; + _hasCurrentSubpath = true; + return this; + } + + /// + /// Appends a straight line segment from the current point to . + /// + /// The point the line segment draws to. + /// This builder, for fluent chaining. + /// + /// Thrown when called before the first , or after + /// without an intervening . + /// + public PathBuilder LineTo(Vector2 point) + { + EnsureCanDraw(); + _currentCommands.Add(PathCommand.LineTo(point)); + return this; + } + + /// + /// Appends a quadratic Bezier curve from the current point to . + /// + /// The curve's single control point. + /// The point the curve draws to. + /// This builder, for fluent chaining. + /// + /// Thrown when called before the first , or after + /// without an intervening . + /// + public PathBuilder QuadraticBezierTo(Vector2 control, Vector2 end) + { + EnsureCanDraw(); + _currentCommands.Add(PathCommand.QuadraticBezierTo(control, end)); + return this; + } + + /// + /// Appends a cubic Bezier curve from the current point to . + /// + /// The curve's first control point. + /// The curve's second control point. + /// The point the curve draws to. + /// This builder, for fluent chaining. + /// + /// Thrown when called before the first , or after + /// without an intervening . + /// + public PathBuilder CubicBezierTo(Vector2 control1, Vector2 control2, Vector2 end) + { + EnsureCanDraw(); + _currentCommands.Add(PathCommand.CubicBezierTo(control1, control2, end)); + return this; + } + + /// + /// Appends an SVG-style elliptical arc from the current point to . + /// + /// The arc's x- and y-radii (rx, ry). + /// The arc's x-axis rotation, in degrees. + /// The SVG arc "large-arc-flag". + /// The SVG arc "sweep-flag". + /// The point the arc draws to. + /// This builder, for fluent chaining. + /// + /// Thrown when called before the first , or after + /// without an intervening . + /// + /// + /// The raw SVG parameters are stored exactly as supplied, with no validation or + /// conversion: this builder never pre-inspects them (for example, to special-case a zero + /// radius), leaving all SVG-spec interpretation, including degenerate cases, to + /// for the consumer that eventually needs Bezier segments. + /// + public PathBuilder ArcTo(Vector2 radius, float rotationDegrees, bool largeArc, bool sweep, Vector2 end) + { + EnsureCanDraw(); + _currentCommands.Add(PathCommand.ArcTo(radius, rotationDegrees, largeArc, sweep, end)); + return this; + } + + /// + /// Closes the current subpath with a straight line back to its start point. + /// + /// This builder, for fluent chaining. + /// + /// Thrown when called before the first , or after a previous + /// without an intervening . + /// + public PathBuilder Close() + { + EnsureCanDraw(); + _currentCommands.Add(PathCommand.Close()); + CommitCurrentSubpath(isClosed: true); + return this; + } + + /// + /// Produces an immutable snapshot of every subpath built so far + /// (including the current, possibly still-open, subpath). + /// + /// + /// A new instance independent of this builder's internal state; the + /// builder remains fully usable afterward, and further mutation of the builder never + /// affects a previously built . + /// + public Path Build() + { + var subpaths = new List(_subpaths); + if (_hasCurrentSubpath) + { + subpaths.Add(new Subpath(_currentStart, [.. _currentCommands], isClosed: false)); + } + + return subpaths.Count == 0 ? Path.Empty : new Path(subpaths); + } + + /// + /// Resets this builder to its initial, empty state, ready to build an unrelated path. + /// + /// + /// Provided so repeated builds can reuse the same instance + /// without allocating a new one each time; the internal backing lists are cleared rather + /// than discarded where practical, avoiding reallocation for the common case of building + /// many similarly sized paths in a loop. + /// + public void Clear() + { + _subpaths.Clear(); + _currentCommands.Clear(); + _hasCurrentSubpath = false; + _currentStart = default; + } + + /// + /// Validates that a drawing command may currently be recorded, throwing if no subpath is + /// open. + /// + private void EnsureCanDraw() + { + if (!_hasCurrentSubpath) + { + throw new InvalidOperationException( + "A drawing command was issued before the first MoveTo, or after Close without an intervening MoveTo."); + } + } + + /// + /// Moves the in-progress subpath (if any) into and clears the + /// "current subpath" state. + /// + private void CommitCurrentSubpath(bool isClosed) + { + if (!_hasCurrentSubpath) + { + return; + } + + _subpaths.Add(new Subpath(_currentStart, [.. _currentCommands], isClosed)); + _hasCurrentSubpath = false; + } +} diff --git a/src/DemaConsulting.CanvasNet/Geometry/PathCommand.cs b/src/DemaConsulting.CanvasNet/Geometry/PathCommand.cs new file mode 100644 index 00000000..90c31d44 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Geometry/PathCommand.cs @@ -0,0 +1,160 @@ +using System.Numerics; + +namespace DemaConsulting.CanvasNet.Geometry; + +/// +/// Represents a single non-move drawing operation within a , as a tagged +/// union of every field any might need. +/// +/// +/// A tagged union (rather than a small class hierarchy) is used because +/// is expected to be walked in tight loops by a future +/// rasterizer/stroker, and a single flat, non-nullable value type keeps that walk allocation- +/// free with no virtual dispatch. Not every field is meaningful for every ; +/// see each field's own documentation for which command types populate it. Instances are +/// produced only by 's internal factory methods, so a +/// list is guaranteed to only ever contain well-formed +/// commands. +/// +public readonly struct PathCommand +{ + /// + /// The kind of drawing operation this command represents. + /// + public PathCommandType Type { get; } + + /// + /// The point this command draws to. Meaningful for , + /// , , + /// and . Unused (default) for + /// , whose endpoint is always . + /// + public Vector2 EndPoint { get; } + + /// + /// The first (or only) Bezier control point. Meaningful for + /// (the curve's single control point) and + /// (the curve's first control point). Unused + /// (default) for every other command type. + /// + public Vector2 Control1 { get; } + + /// + /// The second Bezier control point. Meaningful only for + /// (the curve's second control point). Unused + /// (default) for every other command type. + /// + public Vector2 Control2 { get; } + + /// + /// The elliptical arc's x- and y-radii (rx, ry). Meaningful only for + /// . Unused (default) for every other command type. + /// + public Vector2 Radius { get; } + + /// + /// The elliptical arc's x-axis rotation, in degrees, matching the units used directly by + /// the SVG path data "A"/"a" command. Meaningful only for + /// . Unused (default, zero) for every other command + /// type. + /// + public float RotationDegrees { get; } + + /// + /// The SVG arc "large-arc-flag": selects the arc sweep of 180 + /// degrees or greater between the two candidate ellipses satisfying the endpoint + /// constraints. Meaningful only for . Unused (default, + /// ) for every other command type. + /// + public bool LargeArc { get; } + + /// + /// The SVG arc "sweep-flag": selects the arc drawn in the + /// "positive-angle" direction. Meaningful only for . + /// Unused (default, ) for every other command type. + /// + public bool Sweep { get; } + + /// + /// Initializes every field of the tagged union directly. Private because only this + /// struct's own internal factory methods below construct instances, keeping every field + /// combination that is ever produced consistent with its . + /// + private PathCommand( + PathCommandType type, + Vector2 endPoint, + Vector2 control1, + Vector2 control2, + Vector2 radius, + float rotationDegrees, + bool largeArc, + bool sweep) + { + Type = type; + EndPoint = endPoint; + Control1 = control1; + Control2 = control2; + Radius = radius; + RotationDegrees = rotationDegrees; + LargeArc = largeArc; + Sweep = sweep; + } + + /// + /// Creates a command. Used only by + /// . + /// + /// The point the line segment draws to. + /// The constructed command. + internal static PathCommand LineTo(Vector2 end) => + new(PathCommandType.LineTo, end, default, default, default, 0f, false, false); + + /// + /// Creates a command. Used only by + /// . + /// + /// The curve's single control point. + /// The point the curve draws to. + /// The constructed command. + internal static PathCommand QuadraticBezierTo(Vector2 control, Vector2 end) => + new(PathCommandType.QuadraticBezierTo, end, control, default, default, 0f, false, false); + + /// + /// Creates a command. Used only by + /// . + /// + /// The curve's first control point. + /// The curve's second control point. + /// The point the curve draws to. + /// The constructed command. + internal static PathCommand CubicBezierTo(Vector2 control1, Vector2 control2, Vector2 end) => + new(PathCommandType.CubicBezierTo, end, control1, control2, default, 0f, false, false); + + /// + /// Creates an command, storing the raw SVG-style + /// endpoint-parameterized arc arguments exactly as supplied. Used only by + /// . + /// + /// The arc's x- and y-radii (rx, ry). + /// The arc's x-axis rotation, in degrees. + /// The SVG arc "large-arc-flag". + /// The SVG arc "sweep-flag". + /// The point the arc draws to. + /// The constructed command. + /// + /// No conversion to Bezier curves happens here: and this + /// factory always store the raw SVG parameters, deferring conversion to + /// , which a consumer invokes only when it needs the + /// flattening tolerance/segment shape that conversion requires. + /// + internal static PathCommand ArcTo(Vector2 radius, float rotationDegrees, bool largeArc, bool sweep, Vector2 end) => + new(PathCommandType.ArcTo, end, default, default, radius, rotationDegrees, largeArc, sweep); + + /// + /// Creates a command. Used only by + /// . + /// + /// The constructed command. + internal static PathCommand Close() => + new(PathCommandType.Close, default, default, default, default, 0f, false, false); +} diff --git a/src/DemaConsulting.CanvasNet/Geometry/PathCommandType.cs b/src/DemaConsulting.CanvasNet/Geometry/PathCommandType.cs new file mode 100644 index 00000000..9ec3bb26 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Geometry/PathCommandType.cs @@ -0,0 +1,47 @@ +namespace DemaConsulting.CanvasNet.Geometry; + +/// +/// Identifies the kind of drawing operation a represents within a +/// . +/// +/// +/// A "MoveTo" operation is deliberately excluded from this enumeration. Every subpath always +/// begins with exactly one move (which starts a new subpath and can never appear anywhere +/// else within one), so captures it directly as +/// instead of as a command. This makes an entire class of invalid +/// states unrepresentable: a list can never be empty-then-a- +/// move, nor contain a move anywhere but logically "before" its first entry, because there is +/// no value that could encode one there in the first place. +/// +public enum PathCommandType +{ + /// + /// A straight line segment from the current point to . + /// + LineTo, + + /// + /// A quadratic Bezier curve from the current point to , + /// using as its single control point. + /// + QuadraticBezierTo, + + /// + /// A cubic Bezier curve from the current point to , using + /// and as its two + /// control points. + /// + CubicBezierTo, + + /// + /// An SVG-style elliptical arc from the current point to , + /// parameterized by , , + /// , and . + /// + ArcTo, + + /// + /// Closes the current subpath with a straight line back to . + /// + Close +} diff --git a/src/DemaConsulting.CanvasNet/Geometry/Rect.cs b/src/DemaConsulting.CanvasNet/Geometry/Rect.cs new file mode 100644 index 00000000..19029dfa --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Geometry/Rect.cs @@ -0,0 +1,311 @@ +using System.Numerics; + +namespace DemaConsulting.CanvasNet.Geometry; + +/// +/// Represents an axis-aligned bounding rectangle using the position-plus-size convention +/// (, , , ). +/// +/// +/// uses a WPF-style "union identity" sentinel +/// (X = Y = float.PositiveInfinity, Width = Height = float.NegativeInfinity) +/// rather than the more common all-zero "empty" convention (as used by, for example, +/// System.Drawing.RectangleF.Empty). This is a deliberate, load-bearing design choice: +/// it makes an identity operation over +/// (Union(Empty, r) == r for every rectangle r), which lets +/// fold over zero or more subpaths and +/// always get the mathematically correct answer (an empty path's bounds are +/// , and a one-subpath path's bounds are exactly that subpath's bounds) +/// with no special-casing for "no subpaths yet". An all-zero "empty" rectangle would instead +/// silently corrupt every union with a real rectangle containing the origin. +/// +public readonly struct Rect : IEquatable +{ + /// + /// The x-coordinate of the rectangle's top-left corner. + /// + public float X { get; } + + /// + /// The y-coordinate of the rectangle's top-left corner. + /// + public float Y { get; } + + /// + /// The width of the rectangle. May be negative only for the sentinel; + /// see . + /// + public float Width { get; } + + /// + /// The height of the rectangle. May be negative only for the sentinel; + /// see . + /// + public float Height { get; } + + /// + /// The empty rectangle, used as the identity element for . + /// + /// + /// Represented as X = Y = float.PositiveInfinity, Width = Height = + /// float.NegativeInfinity so that / are + /// and / are + /// - every real rectangle's bounds fall strictly + /// inside this "inverted" extent, so unioning with any real rectangle always yields + /// exactly that rectangle. See the type-level remarks for why this convention (rather than + /// an all-zero rectangle) was chosen. + /// + public static readonly Rect Empty = new( + float.PositiveInfinity, + float.PositiveInfinity, + float.NegativeInfinity, + float.NegativeInfinity); + + /// + /// The x-coordinate of the rectangle's left edge. Equal to . + /// + public float Left => X; + + /// + /// The y-coordinate of the rectangle's top edge. Equal to . + /// + public float Top => Y; + + /// + /// The x-coordinate of the rectangle's right edge. Equal to + . + /// + /// + /// Guards against the PositiveInfinity + NegativeInfinity == NaN IEEE 754 pitfall + /// that would otherwise occur for (whose is + /// and whose is + /// ): when is negative infinity, + /// the result is defined to be directly, matching the + /// documented "inverted extent" behavior relies on. + /// + public float Right => float.IsNegativeInfinity(Width) ? float.NegativeInfinity : X + Width; + + /// + /// The y-coordinate of the rectangle's bottom edge. Equal to + . + /// + /// + /// Guards against the same PositiveInfinity + NegativeInfinity == NaN pitfall as + /// ; see its remarks for details. + /// + public float Bottom => float.IsNegativeInfinity(Height) ? float.NegativeInfinity : Y + Height; + + /// + /// The rectangle's top-left corner position, as a vector. + /// + public Vector2 Location => new(X, Y); + + /// + /// The rectangle's size, as a vector. + /// + public Vector2 Size => new(Width, Height); + + /// + /// The rectangle's top-left corner. Equal to . + /// + public Vector2 TopLeft => new(X, Y); + + /// + /// The rectangle's bottom-right corner. + /// + public Vector2 BottomRight => new(Right, Bottom); + + /// + /// if this rectangle is the sentinel (or any + /// other rectangle with a negative ); otherwise, . + /// + /// + /// Only is tested (not ) because + /// 's construction always keeps both negative together; testing one + /// field is sufficient and matches the WPF convention this type follows. + /// + public bool IsEmpty => Width < 0; + + /// + /// Initializes a new rectangle with the specified position and size. + /// + /// The x-coordinate of the top-left corner. + /// The y-coordinate of the top-left corner. + /// The width. May be negative only to construct an empty-like sentinel. + /// The height. May be negative only to construct an empty-like sentinel. + public Rect(float x, float y, float width, float height) + { + X = x; + Y = y; + Width = width; + Height = height; + } + + /// + /// Determines whether the specified point lies within this rectangle, using a + /// half-open interval on both axes (the left/top edges are included; the right/bottom + /// edges are excluded). + /// + /// The point to test. + /// if the point lies within this rectangle; otherwise, . + /// + /// An (or any negative-size) rectangle contains no points: its + /// inverted extent ( greater than ) makes every + /// comparison below fail for any finite . + /// + public bool Contains(Vector2 point) => + point.X >= Left && point.X < Right && point.Y >= Top && point.Y < Bottom; + + /// + /// Returns the smallest rectangle containing both this rectangle and . + /// + /// The other rectangle to union with. + /// The smallest enclosing rectangle. + public Rect Union(Rect other) => Union(this, other); + + /// + /// Returns the smallest rectangle containing both and . + /// + /// The first rectangle. + /// The second rectangle. + /// + /// The smallest enclosing rectangle. If either input is , the result is + /// exactly the other input (see the type-level remarks for why this identity holds). + /// + public static Rect Union(Rect a, Rect b) + { + var left = Math.Min(a.Left, b.Left); + var top = Math.Min(a.Top, b.Top); + var right = Math.Max(a.Right, b.Right); + var bottom = Math.Max(a.Bottom, b.Bottom); + return new Rect(left, top, right - left, bottom - top); + } + + /// + /// Returns the intersection of this rectangle and . + /// + /// The other rectangle to intersect with. + /// + /// The overlapping region, or if the rectangles are disjoint or merely + /// touch along an edge (zero-area contact is treated as no overlap). + /// + public Rect Intersect(Rect other) => Intersect(this, other); + + /// + /// Returns the intersection of and . + /// + /// The first rectangle. + /// The second rectangle. + /// + /// The overlapping region, or if the rectangles are disjoint or merely + /// touch along an edge (zero-area contact is treated as no overlap). + /// + public static Rect Intersect(Rect a, Rect b) + { + var left = Math.Max(a.Left, b.Left); + var top = Math.Max(a.Top, b.Top); + var right = Math.Min(a.Right, b.Right); + var bottom = Math.Min(a.Bottom, b.Bottom); + + // A non-positive extent on either axis means the rectangles do not overlap - including + // the boundary case where they merely touch along an edge (right == left or + // bottom == top), which yields a zero-width or zero-height rectangle with no actual + // overlapping area. Return the canonical Empty sentinel rather than a rectangle with a + // nonsensical negative-but-not-canonical size (or a non-Empty rectangle with zero area). + return right <= left || bottom <= top ? Empty : new Rect(left, top, right - left, bottom - top); + } + + /// + /// Returns the smallest axis-aligned rectangle enclosing this rectangle after applying + /// . + /// + /// The transform to apply. + /// The transformed, re-enclosed rectangle. + /// + /// All four corners are transformed individually and then re-enclosed via + /// -equivalent min/max folding, rather than transforming only + /// the top-left and bottom-right corners. Transforming just two opposite corners is + /// mathematically incorrect for any matrix containing rotation or shear: the image of an + /// axis-aligned rectangle under such a transform is a parallelogram, not another + /// axis-aligned rectangle, so its true bounding box can only be recovered by considering + /// all four transformed corners. + /// + public Rect Transform(Matrix3x2 matrix) + { + // Empty's corners include +/-Infinity; transforming them directly can produce NaN (e.g. + // a zero matrix element multiplied by an infinite coordinate). Preserve Empty as an + // identity/no-op rather than letting it degrade into a NaN-filled rectangle. + if (IsEmpty) + { + return Empty; + } + + var p0 = Vector2.Transform(TopLeft, matrix); + var p1 = Vector2.Transform(new Vector2(Right, Top), matrix); + var p2 = Vector2.Transform(new Vector2(Left, Bottom), matrix); + var p3 = Vector2.Transform(BottomRight, matrix); + + var minX = Math.Min(Math.Min(p0.X, p1.X), Math.Min(p2.X, p3.X)); + var minY = Math.Min(Math.Min(p0.Y, p1.Y), Math.Min(p2.Y, p3.Y)); + var maxX = Math.Max(Math.Max(p0.X, p1.X), Math.Max(p2.X, p3.X)); + var maxY = Math.Max(Math.Max(p0.Y, p1.Y), Math.Max(p2.Y, p3.Y)); + + return new Rect(minX, minY, maxX - minX, maxY - minY); + } + + /// + /// Determines whether this instance and another have equal field values. + /// + /// The other to compare against. + /// if all four fields are equal; otherwise, . + /// + /// Compares the raw bit pattern of each field rather than using ==/tolerance-based + /// comparison: this is exact snapshot equality of stored field values (matching the + /// precedent set by 's exact byte + /// equality), not a comparison of independently computed floating-point results, so no + /// rounding tolerance is appropriate here. + /// + public bool Equals(Rect other) => + BitsEqual(X, other.X) && BitsEqual(Y, other.Y) && BitsEqual(Width, other.Width) && BitsEqual(Height, other.Height); + + /// + /// Determines whether this instance and a specified object are equal. + /// + /// The object to compare with the current instance. + /// + /// if is a with equal + /// field values; otherwise, . + /// + public override bool Equals(object? obj) => obj is Rect other && Equals(other); + + /// + /// Compares the raw bit patterns of two values for exact equality. + /// + private static bool BitsEqual(float a, float b) => BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b); + + /// + /// Returns a hash code for this instance based on its field values. + /// + /// A hash code for this instance. + public override int GetHashCode() => HashCode.Combine(X, Y, Width, Height); + + /// + /// Determines whether two instances have equal field values. + /// + /// The first instance to compare. + /// The second instance to compare. + /// if the instances are equal; otherwise, . + public static bool operator ==(Rect left, Rect right) => left.Equals(right); + + /// + /// Determines whether two instances have different field values. + /// + /// The first instance to compare. + /// The second instance to compare. + /// if the instances are different; otherwise, . + public static bool operator !=(Rect left, Rect right) => !left.Equals(right); + + /// + /// Returns a string representation of this rectangle's field values. + /// + /// A human-readable string in the form "{X=.., Y=.., Width=.., Height=..}". + public override string ToString() => $"{{X={X}, Y={Y}, Width={Width}, Height={Height}}}"; +} diff --git a/src/DemaConsulting.CanvasNet/Geometry/Subpath.cs b/src/DemaConsulting.CanvasNet/Geometry/Subpath.cs new file mode 100644 index 00000000..dda6b013 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Geometry/Subpath.cs @@ -0,0 +1,60 @@ +using System.Collections.ObjectModel; +using System.Numerics; + +namespace DemaConsulting.CanvasNet.Geometry; + +/// +/// Represents one contiguous, independently drawn contour of a : a starting +/// point plus an ordered sequence of drawing commands, and whether the contour is closed. +/// +/// +/// A may contain any number of subpaths (for example, a letter "O" rendered +/// as glyph outlines needs two: an outer and an inner contour). Command order is preserved +/// exactly as built, and subpaths are never merged or reordered, because a future rasterizer +/// needs both subpath boundaries and command order (which implies winding direction) to +/// evaluate a fill rule (nonzero or even-odd) - this type deliberately performs no winding- +/// rule computation itself, since that evaluation is out of scope for this namespace. +/// +public readonly struct Subpath +{ + /// + /// The point this subpath starts at, from the MoveTo call that opened it. + /// + public Vector2 Start { get; } + + /// + /// The ordered sequence of drawing commands following . Never contains + /// a "MoveTo" entry - see for why that case is + /// unrepresentable by construction. + /// + public IReadOnlyList Commands { get; } + + /// + /// if this subpath ends with an explicit + /// command; otherwise, for an + /// open subpath. + /// + public bool IsClosed { get; } + + /// + /// Initializes a new subpath snapshot. Used only by , which is + /// the sole component responsible for guaranteeing never + /// contains a "MoveTo" entry and that accurately reflects + /// whether the last command is a . + /// + /// The point this subpath starts at. + /// The ordered sequence of drawing commands following . + /// Whether this subpath ends with an explicit close command. + /// + /// Wraps in a rather than + /// exposing it directly, so a caller cannot downcast back to + /// and mutate it - see 's constructor remarks for + /// the same rationale applied there. + /// + internal Subpath(Vector2 start, IList commands, bool isClosed) + { + Start = start; + Commands = new ReadOnlyCollection(commands); + IsClosed = isClosed; + } +} diff --git a/src/DemaConsulting.CanvasNet/Geometry/SvgArcConverter.cs b/src/DemaConsulting.CanvasNet/Geometry/SvgArcConverter.cs new file mode 100644 index 00000000..7bc3b0b9 --- /dev/null +++ b/src/DemaConsulting.CanvasNet/Geometry/SvgArcConverter.cs @@ -0,0 +1,233 @@ +using System.Numerics; + +namespace DemaConsulting.CanvasNet.Geometry; + +/// +/// Converts SVG-style endpoint-parameterized elliptical arcs into one or more cubic Bezier +/// curves, following the SVG 1.1 Appendix F "Elliptical arc implementation notes" algorithm. +/// +/// +/// always stores the raw SVG arc parameters supplied by the +/// caller, without pre-inspecting or pre-converting them; this class is the only place in the +/// Geometry namespace responsible for SVG-spec fidelity, including its documented +/// degenerate cases, so a consumer that never needs Bezier segments (for example, a +/// hit-testing implementation with its own arc math) never pays for the conversion. +/// +public static class SvgArcConverter +{ + /// + /// The maximum angular span, in radians, converted to a single cubic Bezier segment + /// (90 degrees). Splitting larger sweeps into multiple sub-arcs of at most this size keeps + /// each individual cubic's approximation error negligible - a single cubic Bezier cannot + /// accurately approximate an elliptical arc much larger than a quarter turn. + /// + private const float MaxSegmentAngle = MathF.PI / 2f; + + /// + /// Converts an SVG-style elliptical arc, from to + /// , into a sequence of cubic Bezier curves appended to + /// . + /// + /// The arc's start point (the current point before the arc). + /// The arc's x- and y-radii (rx, ry), as supplied to the SVG "A"/"a" command. + /// The arc's x-axis rotation, in degrees. + /// The SVG arc "large-arc-flag". + /// The SVG arc "sweep-flag". + /// The arc's end point. + /// + /// The list each resulting cubic Bezier segment (as a (Control1, Control2, End) + /// tuple) is appended to, in end-to-end order so that a caller building a continuous + /// polyline/curve chain never needs to re-derive the implicit start point of each segment + /// (it is the previous segment's End, or for the first). + /// + /// + /// Handles both degenerate cases the SVG specification itself defines: if + /// equals , no segment is emitted (per + /// spec, a zero-length arc is not rendered); if either radius is zero, a single synthetic + /// straight-line-equivalent cubic Bezier is emitted instead (control points placed at 1/3 + /// and 2/3 along the - chord), since the SVG + /// specification itself defines this case as "treated as a straight line". Every other + /// SVG-valid input (including out-of-range radii, corrected per spec, and all four + /// / combinations) is converted without + /// throwing. + /// + public static void ToBeziers( + Vector2 start, + Vector2 radius, + float rotationDegrees, + bool largeArc, + bool sweep, + Vector2 end, + IList<(Vector2 Control1, Vector2 Control2, Vector2 End)> output) + { + // Degenerate case 1 (SVG spec): a zero-length arc renders nothing + if (start == end) + { + return; + } + + var rx = MathF.Abs(radius.X); + var ry = MathF.Abs(radius.Y); + + // Degenerate case 2 (SVG spec, Open Question #3 default): a zero radius on either axis is + // defined by the spec as "treated as a straight line" - emit one synthetic cubic whose + // controls lie on the chord, rather than attempting an ellipse with zero extent + if (rx == 0 || ry == 0) + { + var control1 = start + (end - start) / 3f; + var control2 = start + (end - start) * 2f / 3f; + output.Add((control1, control2, end)); + return; + } + + var phi = rotationDegrees * MathF.PI / 180f; + var cosPhi = MathF.Cos(phi); + var sinPhi = MathF.Sin(phi); + + // Step 1: compute (x1', y1') - the start point in a coordinate system where the ellipse's + // rotation is undone and the midpoint of start/end is the origin + var dx2 = (start.X - end.X) / 2f; + var dy2 = (start.Y - end.Y) / 2f; + var x1P = cosPhi * dx2 + sinPhi * dy2; + var y1P = -sinPhi * dx2 + cosPhi * dy2; + + // Step 2: correct out-of-range radii per the spec's Lambda scale-factor formula - if the + // requested radii are too small to reach between start and end at all, scale both up by + // the same factor so a valid ellipse exists + var lambda = x1P * x1P / (rx * rx) + y1P * y1P / (ry * ry); + if (lambda > 1) + { + var scale = MathF.Sqrt(lambda); + rx *= scale; + ry *= scale; + } + + // Step 3: compute the center (cx', cy') in the rotated/translated coordinate system, then + // transform back to the original coordinate system + var rxSq = rx * rx; + var rySq = ry * ry; + var x1PSq = x1P * x1P; + var y1PSq = y1P * y1P; + + var numerator = rxSq * rySq - rxSq * y1PSq - rySq * x1PSq; + var denominator = rxSq * y1PSq + rySq * x1PSq; + + // Floating-point round-trip through the Lambda correction above can leave numerator + // fractionally negative when it is mathematically exactly zero - clamp to zero rather + // than taking the square root of a negative number + var coefficient = numerator <= 0 ? 0f : MathF.Sqrt(numerator / denominator); + if (largeArc == sweep) + { + coefficient = -coefficient; + } + + var cxP = coefficient * rx * y1P / ry; + var cyP = -coefficient * ry * x1P / rx; + + var cx = cosPhi * cxP - sinPhi * cyP + (start.X + end.X) / 2f; + var cy = sinPhi * cxP + cosPhi * cyP + (start.Y + end.Y) / 2f; + + // Step 4: compute the start angle theta1 and angular sweep delta-theta + var ux = (x1P - cxP) / rx; + var uy = (y1P - cyP) / ry; + var vx = (-x1P - cxP) / rx; + var vy = (-y1P - cyP) / ry; + + var theta1 = SignedAngleBetween(1f, 0f, ux, uy); + var deltaTheta = SignedAngleBetween(ux, uy, vx, vy); + + if (!sweep && deltaTheta > 0) + { + deltaTheta -= 2 * MathF.PI; + } + else if (sweep && deltaTheta < 0) + { + deltaTheta += 2 * MathF.PI; + } + + // Step 5: split the total sweep into segments of at most 90 degrees each, converting each + // to a cubic Bezier via the standard kappa = 4/3 * tan(delta/4) control-point-distance + // formula, expressed on the unit circle then mapped through the ellipse radii, rotation, + // and center offset + var segmentCount = Math.Max(1, (int)MathF.Ceiling(MathF.Abs(deltaTheta) / MaxSegmentAngle)); + var segmentAngle = deltaTheta / segmentCount; + + var angle = theta1; + for (var i = 0; i < segmentCount; i++) + { + var nextAngle = angle + segmentAngle; + var segment = ConvertSegment(angle, nextAngle, rx, ry, cosPhi, sinPhi, cx, cy); + + // The final segment's End is forced to exactly the caller-supplied end point (rather + // than the value obtained by evaluating trigonometric functions at the target angle) + // so that a caller chaining segments into a continuous curve/polyline always lands + // exactly on the requested end point, with no residual floating-point drift from the + // angle computation above. + if (i == segmentCount - 1) + { + segment = (segment.Control1, segment.Control2, end); + } + + output.Add(segment); + angle = nextAngle; + } + } + + /// + /// Converts a single elliptical arc segment (angular span at most ) + /// from to into one cubic Bezier curve. + /// + private static (Vector2 Control1, Vector2 Control2, Vector2 End) ConvertSegment( + float theta1, float theta2, float rx, float ry, float cosPhi, float sinPhi, float cx, float cy) + { + var delta = theta2 - theta1; + var t = 4f / 3f * MathF.Tan(delta / 4f); + + var cosT1 = MathF.Cos(theta1); + var sinT1 = MathF.Sin(theta1); + var cosT2 = MathF.Cos(theta2); + var sinT2 = MathF.Sin(theta2); + + // Unit-circle control points: the standard cubic-Bezier-approximates-a-circular-arc + // construction, using the tangent direction (-sin, cos) at each endpoint + var u3 = new Vector2(cosT2, sinT2); + var u1 = new Vector2(cosT1 - t * sinT1, sinT1 + t * cosT1); + var u2 = new Vector2(cosT2 + t * sinT2, sinT2 - t * cosT2); + + return (MapUnitCircleToEllipse(u1, rx, ry, cosPhi, sinPhi, cx, cy), + MapUnitCircleToEllipse(u2, rx, ry, cosPhi, sinPhi, cx, cy), + MapUnitCircleToEllipse(u3, rx, ry, cosPhi, sinPhi, cx, cy)); + } + + /// + /// Maps a point on the unit circle to the corresponding point on the rotated, translated + /// ellipse: scale by the radii, rotate by the x-axis rotation, then translate by the + /// center. + /// + private static Vector2 MapUnitCircleToEllipse(Vector2 unitPoint, float rx, float ry, float cosPhi, float sinPhi, float cx, float cy) + { + var ex = rx * unitPoint.X; + var ey = ry * unitPoint.Y; + return new Vector2( + cosPhi * ex - sinPhi * ey + cx, + sinPhi * ex + cosPhi * ey + cy); + } + + /// + /// Computes the signed angle, in radians, from vector (ux, uy) to vector + /// (vx, vy), per the SVG specification's angle function. + /// + private static float SignedAngleBetween(float ux, float uy, float vx, float vy) + { + var dot = ux * vx + uy * vy; + var lengths = MathF.Sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)); + + // Clamp before acos to guard against floating-point round-off pushing the ratio very + // slightly outside [-1, 1], which would otherwise produce NaN + var cosAngle = Math.Clamp(dot / lengths, -1f, 1f); + var angle = MathF.Acos(cosAngle); + + var cross = ux * vy - uy * vx; + return cross < 0 ? -angle : angle; + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs b/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs index dd246d7b..2cde614c 100644 --- a/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs +++ b/test/DemaConsulting.CanvasNet.Tests/CanvasNetTests.cs @@ -1,5 +1,7 @@ +using System.Numerics; using DemaConsulting.CanvasNet.Canvas; using DemaConsulting.CanvasNet.Codecs; +using DemaConsulting.CanvasNet.Geometry; namespace DemaConsulting.CanvasNet.Tests; @@ -279,4 +281,45 @@ public void CanvasNet_SystemIntegration_PngCodecRoundTrip_BoundaryWidths_Returns } } } + + /// + /// Proves that the system can construct a Path via PathBuilder using a MoveTo/ + /// CubicBezierTo/ArcTo/Close sequence, flatten it to a polyline, and compute its + /// axis-aligned bounding Rect - exercising the Geometry namespace's public types end to + /// end through the public API. + /// + [Fact] + public void CanvasNet_SystemIntegration_BuildFlattenAndBoundPath_ReturnsExpectedBounds() + { + // Arrange: build a path with a move, a cubic Bezier, an SVG-style arc, and a close + var builder = new PathBuilder(); + var path = builder + .MoveTo(new Vector2(0, 0)) + .CubicBezierTo(new Vector2(0, 50), new Vector2(50, 50), new Vector2(50, 0)) + .ArcTo(new Vector2(25, 25), 0, largeArc: false, sweep: true, new Vector2(0, 0)) + .Close() + .Build(); + + // Act: flatten the cubic segment directly through the public API, and compute the path's + // conservative bounding rectangle + var flattened = new List(); + BezierFlattening.FlattenCubic( + new Vector2(0, 0), + new Vector2(0, 50), + new Vector2(50, 50), + new Vector2(50, 0), + 0.5f, + flattened); + var bounds = path.GetBounds(); + + // Assert: the system produces one closed subpath, a non-empty flattened polyline ending + // at the curve's true end point, and a bounding rectangle enclosing every command + var subpath = Assert.Single(path.Subpaths); + Assert.True(subpath.IsClosed); + Assert.NotEmpty(flattened); + Assert.Equal(new Vector2(50, 0), flattened[^1]); + Assert.False(bounds.IsEmpty); + Assert.True(bounds.Width > 0); + Assert.True(bounds.Height > 0); + } } diff --git a/test/DemaConsulting.CanvasNet.Tests/Geometry/BezierFlatteningTests.cs b/test/DemaConsulting.CanvasNet.Tests/Geometry/BezierFlatteningTests.cs new file mode 100644 index 00000000..1e9220ad --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Geometry/BezierFlatteningTests.cs @@ -0,0 +1,336 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Geometry; + +namespace DemaConsulting.CanvasNet.Tests.Geometry; + +/// +/// Unit tests for the BezierFlattening class. +/// +public class BezierFlatteningTests +{ + /// + /// Evaluates a cubic Bezier curve at parameter t, using the standard closed-form cubic + /// Bezier formula, independent of the flattening implementation under test. + /// + private static Vector2 EvaluateCubic(Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3, float t) + { + var u = 1 - t; + return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3; + } + + /// + /// Evaluates a quadratic Bezier curve at parameter t, using the standard closed-form + /// quadratic Bezier formula, independent of the flattening implementation under test. + /// + private static Vector2 EvaluateQuadratic(Vector2 p0, Vector2 p1, Vector2 p2, float t) + { + var u = 1 - t; + return u * u * p0 + 2 * u * t * p1 + t * t * p2; + } + + /// + /// Computes the minimum perpendicular distance from a point to any segment of a polyline. + /// + private static float DistanceToPolyline(Vector2 point, IReadOnlyList polyline) + { + var minDistance = float.PositiveInfinity; + for (var i = 0; i < polyline.Count - 1; i++) + { + var a = polyline[i]; + var b = polyline[i + 1]; + var segment = b - a; + var lengthSquared = segment.LengthSquared(); + float distance; + if (lengthSquared <= float.Epsilon) + { + distance = Vector2.Distance(point, a); + } + else + { + var t = Math.Clamp(Vector2.Dot(point - a, segment) / lengthSquared, 0f, 1f); + var closest = a + t * segment; + distance = Vector2.Distance(point, closest); + } + + minDistance = Math.Min(minDistance, distance); + } + + return minDistance; + } + + /// + /// Proves that FlattenCubic never writes the start point p0 to the output, and always + /// writes the end point p3 last. + /// + [Fact] + public void BezierFlattening_FlattenCubic_SimpleCurve_NeverWritesStartAndAlwaysWritesEndLast() + { + // Arrange + var p0 = new Vector2(0, 0); + var p1 = new Vector2(0, 10); + var p2 = new Vector2(10, 10); + var p3 = new Vector2(10, 0); + var output = new List(); + + // Act + BezierFlattening.FlattenCubic(p0, p1, p2, p3, 0.1f, output); + + // Assert + Assert.DoesNotContain(p0, output); + Assert.Equal(p3, output[^1]); + Assert.NotEmpty(output); + } + + /// + /// Proves that FlattenQuadratic never writes the start point p0 to the output, and always + /// writes the end point p2 last. + /// + [Fact] + public void BezierFlattening_FlattenQuadratic_SimpleCurve_NeverWritesStartAndAlwaysWritesEndLast() + { + // Arrange + var p0 = new Vector2(0, 0); + var p1 = new Vector2(5, 10); + var p2 = new Vector2(10, 0); + var output = new List(); + + // Act + BezierFlattening.FlattenQuadratic(p0, p1, p2, 0.1f, output); + + // Assert + Assert.DoesNotContain(p0, output); + Assert.Equal(p2, output[^1]); + Assert.NotEmpty(output); + } + + /// + /// Property-based tolerance-convergence test: for a table of cubic curves and a range of + /// tolerances, every point densely sampled along the true curve must lie within + /// `tolerance` of the flattened polyline (extended with p0 as the implicit first point), + /// proving the flattened polyline is a faithful approximation at the requested tolerance. + /// + [Theory] + [InlineData(1.0f)] + [InlineData(0.5f)] + [InlineData(0.1f)] + [InlineData(0.01f)] + public void BezierFlattening_FlattenCubic_VariousTolerances_SampledCurvePointsWithinTolerance(float tolerance) + { + // Arrange: an S-shaped cubic curve with a pronounced curvature + var p0 = new Vector2(0, 0); + var p1 = new Vector2(0, 50); + var p2 = new Vector2(100, -50); + var p3 = new Vector2(100, 0); + var output = new List { p0 }; + + // Act + BezierFlattening.FlattenCubic(p0, p1, p2, p3, tolerance, output); + + // Assert: sample 1000 points densely along the true curve and confirm each lies within a + // small multiple of tolerance of the flattened polyline (a small multiplier accounts for + // the flatness metric measuring control-point deviation, a conservative proxy for true + // curve deviation, not an exact bound) + for (var i = 0; i <= 1000; i++) + { + var t = i / 1000f; + var truePoint = EvaluateCubic(p0, p1, p2, p3, t); + var distance = DistanceToPolyline(truePoint, output); + Assert.True(distance <= tolerance * 2, $"t={t}: distance {distance} exceeded tolerance {tolerance}"); + } + } + + /// + /// Proves that the flattened segment count is monotonically non-increasing as tolerance + /// increases (a looser tolerance never requires more segments than a tighter one). + /// + [Fact] + public void BezierFlattening_FlattenCubic_IncreasingTolerance_SegmentCountIsMonotonicallyNonIncreasing() + { + // Arrange + var p0 = new Vector2(0, 0); + var p1 = new Vector2(0, 50); + var p2 = new Vector2(100, -50); + var p3 = new Vector2(100, 0); + float[] tolerances = [0.01f, 0.1f, 1f, 10f]; + + // Act + var counts = tolerances.Select(tolerance => + { + var output = new List(); + BezierFlattening.FlattenCubic(p0, p1, p2, p3, tolerance, output); + return output.Count; + }).ToArray(); + + // Assert: each successive (looser) tolerance produces no more segments than the previous + for (var i = 1; i < counts.Length; i++) + { + Assert.True(counts[i] <= counts[i - 1], $"Segment count increased from {counts[i - 1]} to {counts[i]} as tolerance loosened"); + } + } + + /// + /// Proves that flattening a curve with coincident control points (fully degenerate - + /// every control point equal to a single point) terminates and produces a valid, + /// non-empty output consisting of just that point. + /// + [Fact] + public void BezierFlattening_FlattenCubic_CoincidentControlPoints_TerminatesWithSinglePoint() + { + // Arrange: every control point is the same point + var p = new Vector2(5, 5); + var output = new List(); + + // Act + BezierFlattening.FlattenCubic(p, p, p, p, 0.01f, output); + + // Assert: terminates immediately (already flat) with just the end point + Assert.Single(output); + Assert.Equal(p, output[0]); + } + + /// + /// Proves that flattening a collinear (straight-line-equivalent) cubic terminates quickly + /// and produces points that are all itself collinear with the endpoints. + /// + [Fact] + public void BezierFlattening_FlattenCubic_CollinearControlPoints_TerminatesWithCollinearPoints() + { + // Arrange: all four points lie on the same line + var p0 = new Vector2(0, 0); + var p1 = new Vector2(3, 3); + var p2 = new Vector2(6, 6); + var p3 = new Vector2(10, 10); + var output = new List(); + + // Act + BezierFlattening.FlattenCubic(p0, p1, p2, p3, 0.01f, output); + + // Assert: a straight-line curve should flatten to essentially a single output segment, + // ending exactly at p3 + Assert.NotEmpty(output); + Assert.Equal(p3, output[^1]); + } + + /// + /// Proves that the flatness test measures distance to the finite chord *segment*, not the + /// infinite line through it: a control point whose projection onto the chord falls beyond + /// the chord's end (here, p1's projection lands well past p3) must still force + /// subdivision if its distance to the finite segment exceeds tolerance, even though its + /// distance to the infinite line is small. Regression test for a bug where such a control + /// point was wrongly judged "flat", causing the flattened polyline to deviate from the + /// true curve by far more than the requested tolerance. + /// + [Fact] + public void BezierFlattening_FlattenCubic_ControlPointProjectsBeyondChordEnd_StaysWithinTolerance() + { + // Arrange: p1's projection onto the p0-p3 chord falls beyond p3 (t > 1), so its distance + // to the infinite line through the chord (~0.4) is well within tolerance, but its + // distance to the finite chord segment (clamped to p3, ~10) is not - the true curve bulges + // past x=10 as a result (e.g. at t=0.4, the curve reaches about (10.72, 0.17), over 0.7 + // units from the p0-p3 segment) + var p0 = new Vector2(0, 0); + var p1 = new Vector2(20, 0.4f); + var p2 = new Vector2(5, 0); + var p3 = new Vector2(10, 0); + const float tolerance = 0.5f; + var output = new List { p0 }; + + // Act + BezierFlattening.FlattenCubic(p0, p1, p2, p3, tolerance, output); + + // Assert: the curve must have been subdivided (a single segment could not possibly stay + // within tolerance here), and every densely sampled true-curve point must lie within + // tolerance of the flattened polyline + Assert.True(output.Count > 2, $"Expected subdivision to occur, but only got {output.Count} output points"); + for (var i = 0; i <= 1000; i++) + { + var t = i / 1000f; + var truePoint = EvaluateCubic(p0, p1, p2, p3, t); + var distance = DistanceToPolyline(truePoint, output); + Assert.True(distance <= tolerance * 2, $"t={t}: distance {distance} exceeded tolerance {tolerance}"); + } + } + + /// + /// Regression test for the MaxRecursionDepth safety valve: proves that a + /// pathological cubic curve that can never satisfy the flatness test (two collapsed + /// control points positioned so subdivision cannot converge, combined with an extremely + /// tight tolerance) still terminates promptly and produces a bounded output point count, + /// rather than recursing indefinitely. This deliberately tests the documented safety-valve + /// behavior itself (bounded termination), not a tolerance violation - see + /// MaxRecursionDepth's remarks for why the guard does not guarantee tolerance is met + /// for input like this. + /// + [Fact] + public void BezierFlattening_FlattenCubic_PathologicalNonConvergingCurve_TerminatesWithBoundedOutput() + { + // Arrange: an extremely tight tolerance (1e-10, far below what float32 precision can + // resolve for a curve of this scale) that a well-formed, non-degenerate curve can never + // satisfy at any practical recursion depth, forcing the recursion guard to be exercised + var p0 = new Vector2(0, 0); + var p1 = new Vector2(0, 50); + var p2 = new Vector2(100, -50); + var p3 = new Vector2(100, 0); + const float tolerance = 1e-10f; + var output = new List(); + + // Act + BezierFlattening.FlattenCubic(p0, p1, p2, p3, tolerance, output); + + // Assert: the call must return (proving termination) with a bounded output size - at + // most 2^MaxRecursionDepth (2^20, documented as ~1,048,576) leaves are possible, and the + // final point must still be the curve's declared end point + Assert.NotEmpty(output); + Assert.True(output.Count <= 1_048_576, $"Expected a bounded output size, but got {output.Count} points"); + Assert.Equal(p3, output[^1]); + } + + /// + /// Proves that a non-positive tolerance throws ArgumentOutOfRangeException for both + /// FlattenCubic and FlattenQuadratic. + /// + [Theory] + [InlineData(0f)] + [InlineData(-1f)] + public void BezierFlattening_Flatten_NonPositiveTolerance_ThrowsArgumentOutOfRangeException(float tolerance) + { + // Arrange + var output = new List(); + + // Act & Assert + Assert.Throws(() => + BezierFlattening.FlattenCubic(Vector2.Zero, Vector2.Zero, Vector2.Zero, Vector2.Zero, tolerance, output)); + Assert.Throws(() => + BezierFlattening.FlattenQuadratic(Vector2.Zero, Vector2.Zero, Vector2.Zero, tolerance, output)); + } + + /// + /// Property-based tolerance-convergence test for FlattenQuadratic, mirroring the cubic + /// test above: every densely sampled true-curve point must lie within tolerance of the + /// flattened polyline. + /// + [Theory] + [InlineData(1.0f)] + [InlineData(0.1f)] + [InlineData(0.01f)] + public void BezierFlattening_FlattenQuadratic_VariousTolerances_SampledCurvePointsWithinTolerance(float tolerance) + { + // Arrange + var p0 = new Vector2(0, 0); + var p1 = new Vector2(50, 100); + var p2 = new Vector2(100, 0); + var output = new List { p0 }; + + // Act + BezierFlattening.FlattenQuadratic(p0, p1, p2, tolerance, output); + + // Assert + for (var i = 0; i <= 1000; i++) + { + var t = i / 1000f; + var truePoint = EvaluateQuadratic(p0, p1, p2, t); + var distance = DistanceToPolyline(truePoint, output); + Assert.True(distance <= tolerance * 2, $"t={t}: distance {distance} exceeded tolerance {tolerance}"); + } + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Geometry/PathBuilderTests.cs b/test/DemaConsulting.CanvasNet.Tests/Geometry/PathBuilderTests.cs new file mode 100644 index 00000000..84f89cc8 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Geometry/PathBuilderTests.cs @@ -0,0 +1,374 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Geometry; +using GeoPath = DemaConsulting.CanvasNet.Geometry.Path; + +namespace DemaConsulting.CanvasNet.Tests.Geometry; + +/// +/// Unit tests for the PathBuilder class, and the Path/Subpath/PathCommand types it produces. +/// +public class PathBuilderTests +{ + /// + /// Proves that each drawing command type is recorded in order, and that the subpath's + /// Start reflects the MoveTo call that opened it. + /// + [Fact] + public void PathBuilder_Build_EveryCommandType_RecordsCommandsInOrder() + { + // Arrange: a builder exercising every non-close command type in sequence + var builder = new PathBuilder(); + var start = new Vector2(0, 0); + var lineEnd = new Vector2(1, 0); + var quadControl = new Vector2(2, 1); + var quadEnd = new Vector2(2, 2); + var cubicControl1 = new Vector2(3, 2); + var cubicControl2 = new Vector2(3, 3); + var cubicEnd = new Vector2(4, 3); + var arcRadius = new Vector2(1, 1); + var arcEnd = new Vector2(5, 4); + + // Act + builder.MoveTo(start) + .LineTo(lineEnd) + .QuadraticBezierTo(quadControl, quadEnd) + .CubicBezierTo(cubicControl1, cubicControl2, cubicEnd) + .ArcTo(arcRadius, 30, largeArc: true, sweep: false, arcEnd); + var path = builder.Build(); + + // Assert: one subpath, starting at the MoveTo point, with four commands in order + var subpath = Assert.Single(path.Subpaths); + Assert.Equal(start, subpath.Start); + Assert.False(subpath.IsClosed); + Assert.Equal(4, subpath.Commands.Count); + + Assert.Equal(PathCommandType.LineTo, subpath.Commands[0].Type); + Assert.Equal(lineEnd, subpath.Commands[0].EndPoint); + + Assert.Equal(PathCommandType.QuadraticBezierTo, subpath.Commands[1].Type); + Assert.Equal(quadControl, subpath.Commands[1].Control1); + Assert.Equal(quadEnd, subpath.Commands[1].EndPoint); + + Assert.Equal(PathCommandType.CubicBezierTo, subpath.Commands[2].Type); + Assert.Equal(cubicControl1, subpath.Commands[2].Control1); + Assert.Equal(cubicControl2, subpath.Commands[2].Control2); + Assert.Equal(cubicEnd, subpath.Commands[2].EndPoint); + + Assert.Equal(PathCommandType.ArcTo, subpath.Commands[3].Type); + Assert.Equal(arcRadius, subpath.Commands[3].Radius); + Assert.Equal(30, subpath.Commands[3].RotationDegrees); + Assert.True(subpath.Commands[3].LargeArc); + Assert.False(subpath.Commands[3].Sweep); + Assert.Equal(arcEnd, subpath.Commands[3].EndPoint); + } + + /// + /// Proves that multiple subpaths (started via separate MoveTo calls) are preserved + /// independently, each with its own Start point and commands. + /// + [Fact] + public void PathBuilder_Build_MultipleSubpaths_PreservesEachIndependently() + { + // Arrange + var builder = new PathBuilder(); + + // Act: build two independent, unconnected subpaths + builder.MoveTo(new Vector2(0, 0)).LineTo(new Vector2(1, 0)); + builder.MoveTo(new Vector2(10, 10)).LineTo(new Vector2(11, 10)).LineTo(new Vector2(11, 11)); + var path = builder.Build(); + + // Assert + Assert.Equal(2, path.Subpaths.Count); + Assert.Equal(new Vector2(0, 0), path.Subpaths[0].Start); + Assert.Single(path.Subpaths[0].Commands); + Assert.Equal(new Vector2(10, 10), path.Subpaths[1].Start); + Assert.Equal(2, path.Subpaths[1].Commands.Count); + } + + /// + /// Proves that Close appends a Close command and marks the subpath as closed. + /// + [Fact] + public void PathBuilder_Close_OpenSubpath_MarksSubpathClosedWithCloseCommand() + { + // Arrange + var builder = new PathBuilder(); + + // Act + builder.MoveTo(new Vector2(0, 0)).LineTo(new Vector2(1, 0)).Close(); + var path = builder.Build(); + + // Assert + var subpath = Assert.Single(path.Subpaths); + Assert.True(subpath.IsClosed); + Assert.Equal(PathCommandType.Close, subpath.Commands[^1].Type); + } + + /// + /// Proves that a drawing command issued before the first MoveTo throws + /// InvalidOperationException. + /// + [Fact] + public void PathBuilder_LineTo_BeforeFirstMoveTo_ThrowsInvalidOperationException() + { + // Arrange + var builder = new PathBuilder(); + + // Act & Assert + Assert.Throws(() => builder.LineTo(new Vector2(1, 1))); + } + + /// + /// Proves that every drawing command type issued before the first MoveTo throws + /// InvalidOperationException. + /// + [Fact] + public void PathBuilder_EveryDrawingCommand_BeforeFirstMoveTo_ThrowsInvalidOperationException() + { + // Arrange & Act & Assert + Assert.Throws(() => new PathBuilder().QuadraticBezierTo(new Vector2(1, 1), new Vector2(2, 2))); + Assert.Throws(() => new PathBuilder().CubicBezierTo(new Vector2(1, 1), new Vector2(2, 2), new Vector2(3, 3))); + Assert.Throws(() => new PathBuilder().ArcTo(new Vector2(1, 1), 0, false, false, new Vector2(1, 1))); + Assert.Throws(() => new PathBuilder().Close()); + } + + /// + /// Proves that a drawing command issued after Close without an intervening MoveTo throws + /// InvalidOperationException. + /// + [Fact] + public void PathBuilder_LineTo_AfterCloseWithoutMoveTo_ThrowsInvalidOperationException() + { + // Arrange + var builder = new PathBuilder(); + builder.MoveTo(new Vector2(0, 0)).LineTo(new Vector2(1, 0)).Close(); + + // Act & Assert + Assert.Throws(() => builder.LineTo(new Vector2(2, 2))); + } + + /// + /// Proves that a MoveTo issued after Close is valid and starts a fresh subpath. + /// + [Fact] + public void PathBuilder_MoveTo_AfterClose_StartsNewSubpath() + { + // Arrange + var builder = new PathBuilder(); + builder.MoveTo(new Vector2(0, 0)).LineTo(new Vector2(1, 0)).Close(); + + // Act + builder.MoveTo(new Vector2(5, 5)).LineTo(new Vector2(6, 5)); + var path = builder.Build(); + + // Assert + Assert.Equal(2, path.Subpaths.Count); + Assert.True(path.Subpaths[0].IsClosed); + Assert.False(path.Subpaths[1].IsClosed); + } + + /// + /// Proves that MoveTo issued while a previous subpath is still open commits that subpath + /// as open (not implicitly closed) before starting the new one. + /// + [Fact] + public void PathBuilder_MoveTo_WhilePreviousSubpathOpen_CommitsPreviousSubpathAsOpen() + { + // Arrange + var builder = new PathBuilder(); + builder.MoveTo(new Vector2(0, 0)).LineTo(new Vector2(1, 0)); + + // Act: start a new subpath without closing the first + builder.MoveTo(new Vector2(5, 5)); + var path = builder.Build(); + + // Assert + Assert.Equal(2, path.Subpaths.Count); + Assert.False(path.Subpaths[0].IsClosed); + } + + /// + /// Proves that Build() produces an independent snapshot: mutating the builder after + /// Build() does not affect the previously built Path. + /// + [Fact] + public void PathBuilder_Build_ThenMutateBuilder_DoesNotAffectPreviouslyBuiltPath() + { + // Arrange + var builder = new PathBuilder(); + builder.MoveTo(new Vector2(0, 0)).LineTo(new Vector2(1, 0)); + var firstPath = builder.Build(); + + // Act: continue mutating the same builder after the first Build() + builder.LineTo(new Vector2(2, 0)); + var secondPath = builder.Build(); + + // Assert: the first snapshot is unaffected by the later mutation + Assert.Single(firstPath.Subpaths[0].Commands); + Assert.Equal(2, secondPath.Subpaths[0].Commands.Count); + } + + /// + /// Proves that a builder with no MoveTo calls at all produces the empty path. + /// + [Fact] + public void PathBuilder_Build_NoCommandsIssued_ReturnsPathWithNoSubpaths() + { + // Arrange + var builder = new PathBuilder(); + + // Act + var path = builder.Build(); + + // Assert + Assert.Empty(path.Subpaths); + Assert.Equal(Rect.Empty, path.GetBounds()); + } + + /// + /// Proves that Clear() resets the builder to its initial state, allowing it to build an + /// unrelated path with no leftover subpaths from a previous build. + /// + [Fact] + public void PathBuilder_Clear_AfterBuildingAPath_ResetsBuilderToEmptyState() + { + // Arrange + var builder = new PathBuilder(); + builder.MoveTo(new Vector2(0, 0)).LineTo(new Vector2(1, 0)).Close(); + + // Act + builder.Clear(); + builder.MoveTo(new Vector2(9, 9)).LineTo(new Vector2(10, 9)); + var path = builder.Build(); + + // Assert: only the post-Clear subpath is present + var subpath = Assert.Single(path.Subpaths); + Assert.Equal(new Vector2(9, 9), subpath.Start); + Assert.Single(subpath.Commands); + } + + /// + /// Proves that Path is truly immutable: even though Subpaths is typed as + /// IReadOnlyList<Subpath>, a caller cannot bypass that by casting it down to + /// IList<Subpath> and mutating in place - the underlying collection throws + /// NotSupportedException regardless of how it is cast. Regression test for a bug where + /// Subpaths was backed by a plain List<Subpath>, which was mutable via exactly that + /// downcast. + /// + [Fact] + public void Path_Subpaths_DowncastToIList_ThrowsNotSupportedExceptionOnMutation() + { + // Arrange + var builder = new PathBuilder(); + builder.MoveTo(new Vector2(0, 0)).LineTo(new Vector2(1, 0)); + var path = builder.Build(); + + // Act: cast the read-only view back down to a mutable interface + var mutable = Assert.IsAssignableFrom>(path.Subpaths); + + // Assert: every mutating member throws, regardless of the compile-time IReadOnlyList type + Assert.Throws(() => mutable.Add(default)); + Assert.Throws(() => mutable.RemoveAt(0)); + Assert.Throws(() => mutable.Clear()); + } + + /// + /// Proves that Subpath is truly immutable in the same way as : Commands + /// cannot be mutated via a downcast to IList<PathCommand>. Regression test for a bug + /// where Commands was backed by a plain List<PathCommand>. + /// + [Fact] + public void Subpath_Commands_DowncastToIList_ThrowsNotSupportedExceptionOnMutation() + { + // Arrange + var builder = new PathBuilder(); + builder.MoveTo(new Vector2(0, 0)).LineTo(new Vector2(1, 0)).LineTo(new Vector2(2, 0)); + var path = builder.Build(); + var subpath = path.Subpaths[0]; + + // Act + var mutable = Assert.IsAssignableFrom>(subpath.Commands); + + // Assert + Assert.Throws(() => mutable.Add(default)); + Assert.Throws(() => mutable.RemoveAt(0)); + Assert.Throws(() => mutable.Clear()); + } + + /// + /// Proves that Path.Empty has zero subpaths and its GetBounds() returns Rect.Empty. + /// + [Fact] + public void Path_Empty_HasNoSubpathsAndEmptyBounds() + { + // Arrange & Act & Assert + Assert.Empty(GeoPath.Empty.Subpaths); + Assert.Equal(Rect.Empty, GeoPath.Empty.GetBounds()); + } + + /// + /// Proves that GetBounds(), with the default (non-flattening) conservative mode, returns + /// the convex hull of every command's control points and endpoints, matching a + /// hand-computed expected rectangle for a cubic Bezier whose control points extend beyond + /// its endpoints. + /// + [Fact] + public void Path_GetBounds_ConservativeMode_CubicWithControlPointsOutsideChord_ReturnsControlPointConvexHull() + { + // Arrange: a cubic Bezier whose control points reach further up/down than either endpoint + var builder = new PathBuilder(); + builder.MoveTo(new Vector2(0, 0)) + .CubicBezierTo(new Vector2(0, -5), new Vector2(10, 5), new Vector2(10, 0)); + var path = builder.Build(); + + // Act + var bounds = path.GetBounds(); + + // Assert: hand-computed hull of (0,0),(0,-5),(10,5),(10,0) is X=0,Y=-5,Width=10,Height=10 + Assert.Equal(new Rect(0, -5, 10, 10), bounds); + } + + /// + /// Proves that GetBounds() with a positive flattenTolerance produces a tighter bound than + /// the conservative control-point convex hull, for a curve whose control points lie well + /// outside the true curve's extent. + /// + [Fact] + public void Path_GetBounds_FlattenMode_TighterThanConservativeMode_ForCurveWithWideControlPolygon() + { + // Arrange: a quadratic Bezier whose single control point is far above the curve itself + var builder = new PathBuilder(); + builder.MoveTo(new Vector2(0, 0)).QuadraticBezierTo(new Vector2(5, 100), new Vector2(10, 0)); + var path = builder.Build(); + + // Act + var conservative = path.GetBounds(); + var flattened = path.GetBounds(0.01f); + + // Assert: the true curve's highest point is at t=0.5, y = 0.25*100*2 = 50 (quadratic + // Bezier: (1-t)^2*p0 + 2t(1-t)*p1 + t^2*p2, at t=0.5 => 0.5*p1 + 0.25*(p0+p2)); the + // flattened bound must be close to 50, while the conservative bound must reach the full + // control point height of 100 + Assert.Equal(100, conservative.Height, 3); + Assert.True(flattened.Height < 60, $"Expected a tighter flattened height, got {flattened.Height}"); + } + + /// + /// Proves that GetBounds() folds every subpath together into one overall bounding box. + /// + [Fact] + public void Path_GetBounds_MultipleSubpaths_UnionsAllSubpaths() + { + // Arrange: two disjoint subpaths + var builder = new PathBuilder(); + builder.MoveTo(new Vector2(0, 0)).LineTo(new Vector2(1, 1)); + builder.MoveTo(new Vector2(10, 10)).LineTo(new Vector2(11, 11)); + var path = builder.Build(); + + // Act + var bounds = path.GetBounds(); + + // Assert: hand-computed union spans (0,0) to (11,11) + Assert.Equal(new Rect(0, 0, 11, 11), bounds); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Geometry/RectTests.cs b/test/DemaConsulting.CanvasNet.Tests/Geometry/RectTests.cs new file mode 100644 index 00000000..6e7d5698 --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Geometry/RectTests.cs @@ -0,0 +1,306 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Geometry; + +namespace DemaConsulting.CanvasNet.Tests.Geometry; + +/// +/// Unit tests for the Rect struct. +/// +public class RectTests +{ + /// + /// Proves that the constructor stores the supplied position and size on the corresponding + /// properties, and that the computed edge/corner properties derive correctly from them. + /// + [Fact] + public void Rect_Constructor_ValidValues_SetsPropertiesAndComputedEdges() + { + // Arrange & Act: construct a rectangle with known position and size + var rect = new Rect(10, 20, 30, 40); + + // Assert: stored fields and every computed edge/corner property + Assert.Equal(10, rect.X); + Assert.Equal(20, rect.Y); + Assert.Equal(30, rect.Width); + Assert.Equal(40, rect.Height); + Assert.Equal(10, rect.Left); + Assert.Equal(20, rect.Top); + Assert.Equal(40, rect.Right); + Assert.Equal(60, rect.Bottom); + Assert.Equal(new Vector2(10, 20), rect.Location); + Assert.Equal(new Vector2(30, 40), rect.Size); + Assert.Equal(new Vector2(10, 20), rect.TopLeft); + Assert.Equal(new Vector2(40, 60), rect.BottomRight); + } + + /// + /// Proves that a rectangle constructed with a positive width is not IsEmpty, and that + /// Rect.Empty is IsEmpty. + /// + [Fact] + public void Rect_IsEmpty_EmptySentinelVersusRealRectangle_ReturnsExpectedValue() + { + // Arrange + var real = new Rect(0, 0, 1, 1); + + // Act & Assert + Assert.False(real.IsEmpty); + Assert.True(Rect.Empty.IsEmpty); + } + + /// + /// Proves that unioning Rect.Empty with any real rectangle is an identity operation, + /// returning exactly the real rectangle - the load-bearing property that makes + /// Path.GetBounds() correct for a path with zero or more subpaths. + /// + [Fact] + public void Rect_Union_WithEmpty_ReturnsOtherRectangleUnchanged() + { + // Arrange + var real = new Rect(3, 4, 5, 6); + + // Act + var left = Rect.Union(Rect.Empty, real); + var right = Rect.Union(real, Rect.Empty); + + // Assert: Empty is a true identity element on both sides + Assert.Equal(real, left); + Assert.Equal(real, right); + } + + /// + /// Proves that Contains uses a half-open interval: the left/top edges are included, while + /// the right/bottom edges are excluded. + /// + [Fact] + public void Rect_Contains_BoundaryPoints_ReturnsExpectedValue() + { + // Arrange: a 10x10 rectangle at the origin + var rect = new Rect(0, 0, 10, 10); + + // Act & Assert: top-left corner is included, bottom-right corner is excluded + Assert.True(rect.Contains(new Vector2(0, 0))); + Assert.True(rect.Contains(new Vector2(9.999f, 9.999f))); + Assert.False(rect.Contains(new Vector2(10, 0))); + Assert.False(rect.Contains(new Vector2(0, 10))); + Assert.False(rect.Contains(new Vector2(10, 10))); + } + + /// + /// Proves that Union computes the smallest rectangle enclosing two overlapping + /// rectangles, matching a hand-computed expected result. + /// + [Fact] + public void Rect_Union_TwoOverlappingRectangles_ReturnsSmallestEnclosingRectangle() + { + // Arrange: two overlapping rectangles + var a = new Rect(0, 0, 10, 10); + var b = new Rect(5, 5, 10, 10); + + // Act + var union = a.Union(b); + + // Assert: hand-computed enclosing rectangle spans (0,0) to (15,15) + Assert.Equal(new Rect(0, 0, 15, 15), union); + } + + /// + /// Proves that Intersect computes the overlapping region of two overlapping rectangles, + /// matching a hand-computed expected result. + /// + [Fact] + public void Rect_Intersect_TwoOverlappingRectangles_ReturnsOverlapRegion() + { + // Arrange: two overlapping rectangles + var a = new Rect(0, 0, 10, 10); + var b = new Rect(5, 5, 10, 10); + + // Act + var intersection = a.Intersect(b); + + // Assert: hand-computed overlap spans (5,5) to (10,10) + Assert.Equal(new Rect(5, 5, 5, 5), intersection); + } + + /// + /// Proves that Intersect returns Rect.Empty for two disjoint rectangles that do not + /// overlap at all. + /// + [Fact] + public void Rect_Intersect_DisjointRectangles_ReturnsEmpty() + { + // Arrange: two rectangles that do not overlap + var a = new Rect(0, 0, 5, 5); + var b = new Rect(10, 10, 5, 5); + + // Act + var intersection = a.Intersect(b); + + // Assert + Assert.Equal(Rect.Empty, intersection); + } + + /// + /// Proves that Intersect returns Rect.Empty for two rectangles that merely touch along an + /// edge (share a boundary line but no interior area) on either axis, rather than a + /// zero-width or zero-height non-Empty rectangle. Regression test for a bug where the + /// disjoint comparisons used strict "<" instead of "<=", so edge-touching + /// rectangles produced a zero-extent rectangle that was not recognized as Empty. + /// + [Fact] + public void Rect_Intersect_RectanglesTouchingAlongVerticalEdge_ReturnsEmpty() + { + // Arrange: b's left edge exactly equals a's right edge (touching, not overlapping) + var a = new Rect(0, 0, 10, 10); + var b = new Rect(10, 0, 10, 10); + + // Act + var intersection = a.Intersect(b); + + // Assert + Assert.Equal(Rect.Empty, intersection); + Assert.True(intersection.IsEmpty); + } + + /// + /// Proves that Intersect returns Rect.Empty for two rectangles that merely touch along a + /// horizontal edge (b's top edge exactly equals a's bottom edge). + /// + [Fact] + public void Rect_Intersect_RectanglesTouchingAlongHorizontalEdge_ReturnsEmpty() + { + // Arrange: b's top edge exactly equals a's bottom edge (touching, not overlapping) + var a = new Rect(0, 0, 10, 10); + var b = new Rect(0, 10, 10, 10); + + // Act + var intersection = a.Intersect(b); + + // Assert + Assert.Equal(Rect.Empty, intersection); + Assert.True(intersection.IsEmpty); + } + + /// + /// Proves that Transform under a pure translation matrix simply offsets the rectangle's + /// position, leaving its size unchanged. + /// + [Fact] + public void Rect_Transform_Translation_OffsetsPosition() + { + // Arrange + var rect = new Rect(0, 0, 10, 10); + var matrix = Matrix3x2.CreateTranslation(5, 7); + + // Act + var transformed = rect.Transform(matrix); + + // Assert + Assert.Equal(new Rect(5, 7, 10, 10), transformed); + } + + /// + /// Proves that Transform under a 90-degree rotation about the origin correctly + /// re-encloses the four transformed corners into an axis-aligned bounding box, rather + /// than naively transforming just two opposite corners (which would produce a wrong + /// result for a rotated rectangle whose original width and height differ). + /// + [Fact] + public void Rect_Transform_Rotation90DegreesAboutOrigin_ReturnsHandComputedAabb() + { + // Arrange: a wide, short rectangle whose corners are (0,0),(10,0),(0,2),(10,2) + var rect = new Rect(0, 0, 10, 2); + var matrix = Matrix3x2.CreateRotation(MathF.PI / 2f); + + // Act: rotate 90 degrees about the origin + var transformed = rect.Transform(matrix); + + // Assert: rotating 90 degrees maps (x,y) -> (-y,x), so the four corners become + // (0,0), (0,10), (-2,0), (-2,10); the enclosing AABB is X=-2, Y=0, Width=2, Height=10 + Assert.Equal(-2, transformed.X, 3); + Assert.Equal(0, transformed.Y, 3); + Assert.Equal(2, transformed.Width, 3); + Assert.Equal(10, transformed.Height, 3); + } + + /// + /// Proves that transforming Rect.Empty returns Empty rather than a NaN-filled rectangle: + /// Empty's corners include +/-Infinity, and transforming them directly (e.g. via a matrix + /// with a zero off-diagonal element) can produce 0 * Infinity = NaN, which would then + /// poison the Min/Max re-enclosure fold. + /// + [Theory] + [InlineData(0, 0)] // Identity + [InlineData(1, 0)] // Translation + public void Rect_Transform_Empty_ReturnsEmpty(float tx, float ty) + { + // Arrange + var matrix = Matrix3x2.CreateTranslation(tx, ty); + + // Act + var transformed = Rect.Empty.Transform(matrix); + + // Assert + Assert.Equal(Rect.Empty, transformed); + Assert.True(transformed.IsEmpty); + } + + /// + /// Proves that transforming Rect.Empty under a rotation (which introduces zero + /// off-diagonal matrix elements) still returns Empty rather than NaN. + /// + [Fact] + public void Rect_Transform_Empty_UnderRotation_ReturnsEmpty() + { + // Arrange + var matrix = Matrix3x2.CreateRotation(MathF.PI / 4f); + + // Act + var transformed = Rect.Empty.Transform(matrix); + + // Assert + Assert.Equal(Rect.Empty, transformed); + Assert.True(transformed.IsEmpty); + } + + /// + /// Proves that two rectangles with identical field values compare equal via Equals and + /// the == / != operators, and that a differing rectangle compares unequal. + /// + [Fact] + public void Rect_Equals_SameAndDifferentValues_ReturnsExpectedResult() + { + // Arrange + var a = new Rect(1, 2, 3, 4); + var b = new Rect(1, 2, 3, 4); + var c = new Rect(1, 2, 3, 5); + + // Act & Assert + Assert.True(a.Equals(b)); + Assert.True(a == b); + Assert.False(a != b); + Assert.False(a.Equals(c)); + Assert.True(a != c); + Assert.Equal(a.GetHashCode(), b.GetHashCode()); + } + + /// + /// Proves that ToString produces a non-empty, human-readable representation containing + /// each field's value. + /// + [Fact] + public void Rect_ToString_ReturnsStringContainingFieldValues() + { + // Arrange + var rect = new Rect(1, 2, 3, 4); + + // Act + var text = rect.ToString(); + + // Assert + Assert.Contains("1", text); + Assert.Contains("2", text); + Assert.Contains("3", text); + Assert.Contains("4", text); + } +} diff --git a/test/DemaConsulting.CanvasNet.Tests/Geometry/SvgArcConverterTests.cs b/test/DemaConsulting.CanvasNet.Tests/Geometry/SvgArcConverterTests.cs new file mode 100644 index 00000000..95342acd --- /dev/null +++ b/test/DemaConsulting.CanvasNet.Tests/Geometry/SvgArcConverterTests.cs @@ -0,0 +1,387 @@ +using System.Numerics; +using DemaConsulting.CanvasNet.Geometry; + +namespace DemaConsulting.CanvasNet.Tests.Geometry; + +/// +/// Unit tests for the SvgArcConverter class. +/// +/// +/// Verification against sampled points uses an independently written, double-precision +/// implementation of the SVG endpoint-to-center parameterization ( +/// and below) as an oracle, rather than re-deriving or reusing +/// any part of itself, so that a shared bug in the algorithm +/// under test cannot also hide itself in the verification. +/// +public class SvgArcConverterTests +{ + /// + /// Independently computes the SVG endpoint-to-center arc parameterization in double + /// precision, following the SVG 1.1 Appendix F algorithm directly from the specification + /// text (not by reading 's implementation). + /// + private static (double Cx, double Cy, double Theta1, double DeltaTheta) ComputeCenter( + Vector2 start, Vector2 end, double rx, double ry, double rotationDegrees, bool largeArc, bool sweep) + { + var phi = rotationDegrees * Math.PI / 180.0; + var cosPhi = Math.Cos(phi); + var sinPhi = Math.Sin(phi); + + var dx2 = (start.X - end.X) / 2.0; + var dy2 = (start.Y - end.Y) / 2.0; + var x1P = cosPhi * dx2 + sinPhi * dy2; + var y1P = -sinPhi * dx2 + cosPhi * dy2; + + var lambda = x1P * x1P / (rx * rx) + y1P * y1P / (ry * ry); + if (lambda > 1) + { + var scale = Math.Sqrt(lambda); + rx *= scale; + ry *= scale; + } + + var num = rx * rx * ry * ry - rx * rx * y1P * y1P - ry * ry * x1P * x1P; + var den = rx * rx * y1P * y1P + ry * ry * x1P * x1P; + var co = num <= 0 ? 0 : Math.Sqrt(num / den); + if (largeArc == sweep) + { + co = -co; + } + + var cxP = co * rx * y1P / ry; + var cyP = -co * ry * x1P / rx; + + var cx = cosPhi * cxP - sinPhi * cyP + (start.X + end.X) / 2.0; + var cy = sinPhi * cxP + cosPhi * cyP + (start.Y + end.Y) / 2.0; + + var ux = (x1P - cxP) / rx; + var uy = (y1P - cyP) / ry; + var vx = (-x1P - cxP) / rx; + var vy = (-y1P - cyP) / ry; + + var theta1 = SignedAngle(1, 0, ux, uy); + var deltaTheta = SignedAngle(ux, uy, vx, vy); + if (!sweep && deltaTheta > 0) + { + deltaTheta -= 2 * Math.PI; + } + else if (sweep && deltaTheta < 0) + { + deltaTheta += 2 * Math.PI; + } + + return (cx, cy, theta1, deltaTheta); + } + + private static double SignedAngle(double ux, double uy, double vx, double vy) + { + var dot = ux * vx + uy * vy; + var lengths = Math.Sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy)); + var cosAngle = Math.Clamp(dot / lengths, -1, 1); + var angle = Math.Acos(cosAngle); + var cross = ux * vy - uy * vx; + return cross < 0 ? -angle : angle; + } + + /// + /// Evaluates the point on the true ellipse at angle theta, for the ellipse described by + /// center (cx,cy), radii (rx,ry), and x-axis rotation rotationDegrees. + /// + private static Vector2 EvaluateEllipse(double cx, double cy, double rx, double ry, double rotationDegrees, double theta) + { + var phi = rotationDegrees * Math.PI / 180.0; + var cosPhi = Math.Cos(phi); + var sinPhi = Math.Sin(phi); + var ex = rx * Math.Cos(theta); + var ey = ry * Math.Sin(theta); + return new Vector2( + (float)(cx + cosPhi * ex - sinPhi * ey), + (float)(cy + sinPhi * ex + cosPhi * ey)); + } + + /// + /// Evaluates a cubic Bezier curve at parameter t. + /// + private static Vector2 EvaluateCubic(Vector2 p0, Vector2 p1, Vector2 p2, Vector2 p3, float t) + { + var u = 1 - t; + return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3; + } + + /// + /// Proves that a zero-length arc (start equals end) emits no Bezier segments, per the + /// SVG specification's documented degenerate case. + /// + [Fact] + public void SvgArcConverter_ToBeziers_StartEqualsEnd_EmitsNoSegments() + { + // Arrange + var point = new Vector2(10, 10); + var output = new List<(Vector2 Control1, Vector2 Control2, Vector2 End)>(); + + // Act + SvgArcConverter.ToBeziers(point, new Vector2(5, 5), 0, false, false, point, output); + + // Assert + Assert.Empty(output); + } + + /// + /// Proves that a zero x-radius emits exactly one synthetic straight-line-equivalent cubic + /// whose control points lie at one-third and two-thirds along the start-end chord. + /// + [Fact] + public void SvgArcConverter_ToBeziers_ZeroXRadius_EmitsSingleStraightLineEquivalentCubic() + { + // Arrange + var start = new Vector2(0, 0); + var end = new Vector2(30, 0); + var output = new List<(Vector2 Control1, Vector2 Control2, Vector2 End)>(); + + // Act + SvgArcConverter.ToBeziers(start, new Vector2(0, 10), 0, false, false, end, output); + + // Assert: exactly one segment, with controls at 1/3 and 2/3 along the chord + var segment = Assert.Single(output); + Assert.Equal(new Vector2(10, 0), segment.Control1); + Assert.Equal(new Vector2(20, 0), segment.Control2); + Assert.Equal(end, segment.End); + } + + /// + /// Proves that a zero y-radius likewise emits exactly one synthetic straight-line + /// cubic, exercising the other half of the "either radius is zero" degenerate condition. + /// + [Fact] + public void SvgArcConverter_ToBeziers_ZeroYRadius_EmitsSingleStraightLineEquivalentCubic() + { + // Arrange + var start = new Vector2(0, 0); + var end = new Vector2(0, 30); + var output = new List<(Vector2 Control1, Vector2 Control2, Vector2 End)>(); + + // Act + SvgArcConverter.ToBeziers(start, new Vector2(10, 0), 0, false, false, end, output); + + // Assert + var segment = Assert.Single(output); + Assert.Equal(new Vector2(0, 10), segment.Control1); + Assert.Equal(new Vector2(0, 20), segment.Control2); + Assert.Equal(end, segment.End); + } + + /// + /// Proves that a true semicircular arc (chord length equal to the diameter, so the arc + /// spans exactly 180 degrees regardless of flag selection) produces a Bezier chain that + /// connects continuously from start to end and whose sampled points lie on the expected + /// circle. + /// + [Fact] + public void SvgArcConverter_ToBeziers_Semicircle_ProducesContinuousChainOnExpectedCircle() + { + // Arrange: a chord of length 200 with radius 100 - the chord equals the diameter, so this + // is a true semicircle (180 degree sweep) for any flag combination + var start = new Vector2(200, 200); + var end = new Vector2(400, 200); + var radius = new Vector2(100, 100); + var output = new List<(Vector2 Control1, Vector2 Control2, Vector2 End)>(); + + // Act + SvgArcConverter.ToBeziers(start, radius, 0, largeArc: true, sweep: false, end, output); + + // Assert: chain connects end-to-end from start to end + AssertChainConnects(start, end, output); + + // Assert: the independently computed center is equidistant (100) from both endpoints, + // and every sampled point on the Bezier chain lies within a small tolerance of that + // radius from the center + var (cx, cy, _, deltaTheta) = ComputeCenter(start, end, 100, 100, 0, largeArc: true, sweep: false); + Assert.Equal(100, Math.Sqrt((start.X - cx) * (start.X - cx) + (start.Y - cy) * (start.Y - cy)), 3); + AssertChainLiesOnCircle(start, output, cx, cy, 100, tolerance: 1.0); + + // A semicircle spans exactly 180 degrees + Assert.Equal(Math.PI, Math.Abs(deltaTheta), 3); + } + + /// + /// Proves, for every one of the four largeArc/sweep flag combinations, that the resulting + /// Bezier chain connects continuously and every sampled point lies on the circle computed + /// independently by . + /// + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void SvgArcConverter_ToBeziers_AllFourFlagCombinations_ProducesContinuousChainOnExpectedCircle(bool largeArc, bool sweep) + { + // Arrange: a circular arc where two candidate circles exist, so all four flag + // combinations produce a distinct, well-defined arc + var start = new Vector2(100, 0); + var end = new Vector2(0, 100); + var output = new List<(Vector2 Control1, Vector2 Control2, Vector2 End)>(); + + // Act + SvgArcConverter.ToBeziers(start, new Vector2(100, 100), 0, largeArc, sweep, end, output); + + // Assert: chain connects, and independently-computed center/radius match every sampled point + AssertChainConnects(start, end, output); + var (cx, cy, _, deltaTheta) = ComputeCenter(start, end, 100, 100, 0, largeArc, sweep); + AssertChainLiesOnCircle(start, output, cx, cy, 100, tolerance: 1.0); + + // largeArc selects a sweep of at least 180 degrees; otherwise, less than 180 degrees + if (largeArc) + { + Assert.True(Math.Abs(deltaTheta) >= Math.PI - 1e-6); + } + else + { + Assert.True(Math.Abs(deltaTheta) <= Math.PI + 1e-6); + } + } + + /// + /// Proves that a rotated, non-circular ellipse arc still connects continuously from start + /// to end, and that its final segment endpoint matches the independently computed + /// ellipse point at the expected end angle. + /// + [Fact] + public void SvgArcConverter_ToBeziers_RotatedEllipticalArc_ConnectsAndMatchesIndependentEllipse() + { + // Arrange: a rotated ellipse (rx != ry, 30 degree rotation) + var start = new Vector2(0, 0); + var end = new Vector2(80, 40); + var radius = new Vector2(60, 30); + var output = new List<(Vector2 Control1, Vector2 Control2, Vector2 End)>(); + + // Act + SvgArcConverter.ToBeziers(start, radius, 30, largeArc: false, sweep: true, end, output); + + // Assert: chain connects end-to-end + AssertChainConnects(start, end, output); + + // Assert: the independently computed ellipse's point at theta1 + deltaTheta (the end + // angle) matches the arc's declared end point + var (cx, cy, theta1, deltaTheta) = ComputeCenter(start, end, 60, 30, 30, largeArc: false, sweep: true); + var expectedEnd = EvaluateEllipse(cx, cy, 60, 30, 30, theta1 + deltaTheta); + Assert.Equal(expectedEnd.X, end.X, 1); + Assert.Equal(expectedEnd.Y, end.Y, 1); + } + + /// + /// Proves the SVG spec's radius scale-up path (endpoint-to-center parameterization + /// appendix F, the "lambda > 1" correction): when the supplied rx/ry are too small to + /// reach between start and end at all, both radii must be scaled up by the same factor + /// (sqrt(lambda)) so a valid ellipse exists, per the SVG specification. Regression test + /// for previously-untested radii (existing tests all use radii already larger than the + /// half-chord distance, so this scale-up branch was never exercised). + /// + [Fact] + public void SvgArcConverter_ToBeziers_RadiiSmallerThanChord_ScalesUpAndReachesEndpoint() + { + // Arrange: rx=20, ry=10 with a start/end 100 units apart along the x-axis (no rotation), + // so x1' = -50, y1' = 0 and lambda = 50^2/20^2 = 6.25 > 1, forcing the scale-up path. + // The mathematically-derived scale factor is sqrt(6.25) = 2.5, so the corrected radii + // are expected to be rx=50, ry=25 (derived independently here, not by reading + // SvgArcConverter's implementation) + var start = new Vector2(0, 0); + var end = new Vector2(100, 0); + var requestedRadius = new Vector2(20, 10); + const double expectedScale = 2.5; + const double expectedRx = 20 * expectedScale; + const double expectedRy = 10 * expectedScale; + var output = new List<(Vector2 Control1, Vector2 Control2, Vector2 End)>(); + + // Act + SvgArcConverter.ToBeziers(start, requestedRadius, 0, largeArc: false, sweep: true, end, output); + + // Assert: chain connects end-to-end and lands exactly on the declared end point + AssertChainConnects(start, end, output); + + // Assert: the independently computed center (which itself performs the same lambda + // correction from the SVG spec text) combined with the analytically-derived scaled radii + // above places every sampled point on the corrected (scaled-up) ellipse, proving the + // scale-up was applied rather than the original, too-small radii + var (cx, cy, _, _) = ComputeCenter(start, end, requestedRadius.X, requestedRadius.Y, 0, largeArc: false, sweep: true); + AssertChainLiesOnEllipse(start, output, cx, cy, expectedRx, expectedRy, rotationDegrees: 0, tolerance: 1.0); + } + + /// + /// Asserts that every sampled point along a Bezier chain lies within + /// of the given (possibly non-circular, possibly rotated) ellipse. + /// + private static void AssertChainLiesOnEllipse( + Vector2 start, + IReadOnlyList<(Vector2 Control1, Vector2 Control2, Vector2 End)> chain, + double cx, double cy, double rx, double ry, double rotationDegrees, double tolerance) + { + var phi = rotationDegrees * Math.PI / 180.0; + var cosPhi = Math.Cos(phi); + var sinPhi = Math.Sin(phi); + + var segmentStart = start; + foreach (var segment in chain) + { + for (var i = 0; i <= 20; i++) + { + var t = i / 20f; + var point = EvaluateCubic(segmentStart, segment.Control1, segment.Control2, segment.End, t); + + // Translate to the ellipse's center, then rotate back to axis-aligned, then normalize by the radii - + // a point exactly on the ellipse maps to a unit vector under this transform + var dx = point.X - cx; + var dy = point.Y - cy; + var ux = cosPhi * dx + sinPhi * dy; + var uy = -sinPhi * dx + cosPhi * dy; + var normalized = Math.Sqrt(ux * ux / (rx * rx) + uy * uy / (ry * ry)); + + Assert.True(Math.Abs(normalized - 1.0) <= tolerance / Math.Min(rx, ry), + $"Point {point} normalized to {normalized}, expected approximately 1 (on the ellipse)"); + } + + segmentStart = segment.End; + } + } + + /// + /// Asserts that a Bezier chain's segments connect end-to-end from + /// through to with no gaps. + /// + private static void AssertChainConnects(Vector2 start, Vector2 end, IReadOnlyList<(Vector2 Control1, Vector2 Control2, Vector2 End)> chain) + { + Assert.NotEmpty(chain); + Assert.Equal(end, chain[^1].End); + + var current = start; + foreach (var segment in chain) + { + // Each segment's implicit start is the previous segment's End (or the arc's overall + // start for the first segment) - there is no explicit "start" field to compare, so + // this loop only needs to track the running point for the next iteration + current = segment.End; + } + + Assert.Equal(end, current); + } + + /// + /// Asserts that every sampled point along a Bezier chain lies within + /// of the given circle. + /// + private static void AssertChainLiesOnCircle(Vector2 start, IReadOnlyList<(Vector2 Control1, Vector2 Control2, Vector2 End)> chain, double cx, double cy, double radius, double tolerance) + { + var segmentStart = start; + foreach (var segment in chain) + { + for (var i = 0; i <= 20; i++) + { + var t = i / 20f; + var point = EvaluateCubic(segmentStart, segment.Control1, segment.Control2, segment.End, t); + var distance = Math.Sqrt((point.X - cx) * (point.X - cx) + (point.Y - cy) * (point.Y - cy)); + Assert.True(Math.Abs(distance - radius) <= tolerance, $"Point {point} was distance {distance} from center, expected {radius}"); + } + + segmentStart = segment.End; + } + } +}