Skip to content

Add an SVG backend, full layer attributes, and close the remaining rendering gaps - #5

Merged
albertwoo merged 130 commits into
slaveOftime:mainfrom
mubeda:mubeda/svg-support
Sep 7, 2026
Merged

albertwoo merged 130 commits into
slaveOftime:mainfrom
mubeda:mubeda/svg-support

Conversation

@mubeda

@mubeda mubeda commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Summary

This branch adds a hand-written SVG backend and full layer-attribute support to ACadSharp.Image, then works through every remaining rendering gap until no drawing in the maintainer's private test set produces a single "not implemented" notification. Along the way it uncovered and worked around a class of document-corruption bugs in ACadSharp 3.7.1 that silently empties geometry in the caller's own document whenever a block is rendered.

130 commits over ten reviewed implementation plans. 460 tests, all green under -warnaserror. Every commit was implemented against a written plan, passed a scoped review, and the branch as a whole passed two independent external code reviews (OpenAI Codex) plus two whole-branch reviews with fix waves.

Breaking change: net6.0 is dropped. This should ship as a new major version.


What was built

1. A drawing-surface abstraction and an SVG backend

Rendering was split into a backend-neutral IDrawingSurface with two implementations: the existing SixLabors.ImageSharp raster surface and a new SVG surface written directly with System.Xml.Linq (SkiaSharp was evaluated and rejected: no groups, no dash arrays, native dependencies).

The SVG is designed for React/web consumers:

  • One <g> per layer (id="layer-…" class="cad-layer" data-layer="…") so layers can be toggled client-side.
  • data-type, data-handle, data-parent and data-block attributes on every primitive for hit-testing and inspection.
  • A drawing-unit viewBox with no width/height by default; vector-effect="non-scaling-stroke" on by default.
  • Native <ellipse>, <path> Béziers and <text>/<tspan> where the raster tessellates and outlines.
  • Adaptive coordinate precision, a separate style-precision formatter, xml:space="preserve" scoped so pretty-print indentation is never drawn.
  • SvgOptions: NonScalingStroke, EmitEntityAttributes, EmitSize, IdPrefix, Precision.

Both backends are fed identical geometry and were verified never to disagree on it (see Testing).

2. Layer attributes

Colour, linetype, lineweight, transparency, plottable flag, on/off and frozen state are now honoured, with correct ByLayer/ByBlock inheritance through nested block references and header LTSCALE/CELTSCALE linetype scaling. Layer visibility is opt-in (LayerVisibilityMode.All is the default): Screen honours off/frozen, Plot also honours non-plottable. HideLayers/IncludeLayers filter by name. Filtering runs in the render loop and applies per entity, including block children, and the page frame follows what remains visible.

3. Entities that were missing or wrong

Added or fixed across the branch: hatches (solid and pattern, including on tilted OCS planes inside blocks), 3DFACE with invisible-edge flags, LEADER (straight and splined, default and custom arrowhead blocks), MLINE (offsets, fill, caps, MLEDIT cut segments), WIPEOUT (including inverted clips, drawn as even-odd paths), block ATTRIBs with ATTMODE (including multi-line attributes laid out from their embedded MTEXT), paper-space viewports interleaved in draw order, DRAWORDER tables, SOLID corners on non-world planes, and page-level draw order with correct painter ordering.

4. Text fidelity

SVG font-size is 4/3 of the CAD height; raster text is laid out at a fixed 72 dpi so Dpi affects only line weights. Multi-line blocks are anchored as a whole. MTEXT rectangle-width wrapping uses a greedy fit measured with SixLabors.Fonts (UAX #14 break opportunities shared by both backends). \U+XXXX and %% codes are decoded. Text placed by a block reference follows the transformed up axis for height and the transformed reading axis for width, so non-uniformly scaled inserts stretch text correctly. A font fallback chain (Liberation Sans, DejaVu Sans, Arial, Helvetica, Noto Sans, Segoe UI) covers Linux CI.

5. CLI

--format svg, --hide-layer, --only-layer, --layer-visibility {all|screen|plot}, --list-layers (prints the layer table with per-layer entity counts). Extra positional arguments are rejected instead of silently ignored. Output is routed through a testable Program.Run(args, output, error).

6. Robustness

Malformed entities (e.g. a bulge between coincident vertices, which makes ACadSharp throw) are skipped with a warning instead of aborting the export. Non-finite geometry is caught per entity on both backends. A block reference with no block, or one nested inside a resolvable block, is skipped with a warning rather than throwing out of the public entry point. Page framing and viewport culling use the same bounds the renderer actually draws (EntityBounds). Viewport culling uses a real XY overlap test — CSMath's BoundingBox.IsIn is a corner-containment test that dropped any entity enclosing or crossing a viewport without a corner inside it.


ACadSharp 3.7.1 behaviours this branch works around

These are all probe-verified against the shipped package and recorded in code comments, the spec and the README. They are the part of this branch most worth a second pair of eyes.

Document corruption through shared lists. MLine.Clone(), Leader.Clone() and Wipeout.Clone() share their vertex List objects with the source entity; Insert.Explode() and even the Insert(BlockRecord) constructor deep-clone the whole block graph, so rendering a drawing emptied MLINEs in the caller's own document. The renderer snapshots every shared list reachable from a block before anything clones and heals them in place (Clear + AddRange, never reassignment, because clone and source are literally the same object). Six paths were found and closed, each with a RED-verified regression test measuring 2 vertices → 0 without the fix:

  • nested Insert.Block
  • all four DimensionStyle block properties (ArrowBlock, DimArrow1, DimArrow2, LeaderArrow), reached from both Leader and Dimension
  • Dimension.Block, the anonymous picture block
  • DrawDimension's top-level UpdateBlock() call

One shared ReferencedBlocks(Entity) enumerator feeds all three walks (snapshot collection, needs-snapshot scan, cycle guard) so the edge set cannot drift. Consequence for callers: a CadDocument must not be rendered concurrently by two exporters (documented in ImageExporter.Render and the README).

Uncatchable stack overflow on circular block graphs. A block that references itself makes Explode() and GetBoundingBox() recurse until the stack dies, and a StackOverflowException cannot be caught in .NET. A dedicated depth-first walk with an on-path set (so a block reused from two places is a diamond, not a cycle) refuses such graphs with a warning before anything recursive runs, on both the draw path and the bounds path. Verified by reverting the guard: the test host dies.

Insert transform diverges from AutoCAD. Insert.GetTransform() computes R·S·p + (InsertPoint − BasePoint); AutoCAD specifies InsertPoint + R·S·(p − BasePoint). They agree only when rotation and scale are identity, so a block with a non-zero base point placed with rotation or scale lands differently than in AutoCAD. Latent (no sample hits it), but the custom-arrowhead code builds a transient Insert on purpose and compensates by measuring where the base point lands and correcting — a method that stays correct under either formula.

Explode leaves text and hatches wrong. Explode() never transforms a TEXT AlignmentPoint or an MTEXT X axis, hands back TEXT/HATCH clones with world points but a mirrored normal, transforms a hatch's raw OCS boundary as if it were world data, and transforms a wipeout's U/V vectors as points (so a translation contaminates them). Text, MTEXT, hatches, wipeouts, leaders and non-world solids inside blocks are therefore drawn from the original entity through the insert transform (UsesOriginalGeometry is the canonical list), with ordinal original/clone pairing — the only identity ACadSharp offers, since clones carry no handle. A count mismatch warns.

Smaller quirks: IPolyline.GetPoints, BoundaryPath.GetPoints and Hatch.ExplodePattern return raw OCS coordinates; ExplodePattern is eager; CSMath.Matrix3.ArbitraryAxis is not orthonormal for tilted normals (the renderer has its own OcsTransform); Ellipse.MajorAxis is a full length; the SpaceLineTypeScaling enum names are swapped relative to the stored DXF value; DimensionStyle.Clone() deep-clones its arrow blocks; Viewport.SelectEntities() enumerates Document.Entities unguarded.


Public API changes

  • New: RenderedPage (abstract, IDisposable), RenderedImagePage, RenderedSvgPage, SvgOptions, LayerVisibilityMode, ImageExportFormat.Svg.
  • ImageConfiguration: LayerVisibility, HideLayers(...), IncludeLayers(...), Svg options, FontFamilyName, MaxHatchLines, SetPadding; Dpi now documented as affecting line weights only.
  • ImageExporter.Render() returns RenderedPages carrying the requested format; Render(ImageExportFormat).
  • Notifications carry NotificationType (Warning, NotImplemented, …) and the shape [{SubclassMarker}] Handle {X}: ….
  • Removed dead overloads (ImageStyleResolver.Resolve, ImagePage-based SVG context overloads).
  • net6.0 target dropped.

Testing

460 tests, byte-compared PNG baselines and SVG goldens under ACadSharp.Image.Tests/Baselines/ (16 files). Goldens are regenerated only with a documented cause in the commit body and only via a scoped filter, never over the whole suite. Coverage after plan 04 was 90.5% lines / 84.4% branches.

Synthetic golden fixtures render every added feature through both real backends with structural assertions that run after the byte comparison (so regenerating a baseline cannot erase them) and that each fail against the pre-feature behaviour: features.model.01, entities.model.01, fidelity.model.01, plus a code-built paper-space viewport sheet round-tripped through DxfWriter/DxfReader. The fidelity golden also asserts no Warning/NotImplemented fires, which is the check that proves no feature silently fell back.

Real-drawing parity. Against the maintainer's private set of seven production DWGs (not in the repository, never named in it), SVG and PNG output were rasterised to a common size and compared by mutual ink containment with a 5 px dilation: 99.8–100% both ways on every drawing where the comparison is meaningful, unchanged across the last three plans. The same harness was run in five layer-selection modes (all, screen, plot, isolate one layer, hide one layer — 70 renders): parity held in every mode and the SVG layer-group count matched the selection exactly. Every "not implemented" notification on those drawings is gone; the only remaining warnings are 18 malformed polylines that are a defect in the drawing data itself.

Review process. Each plan ran as subagent-driven development: one implementer per task, a scoped spec-and-quality review per task, fix rounds with scoped re-reviews, then a whole-branch review and one fix wave. Two independent read-only reviews by OpenAI Codex were processed with verification-first (findings were probed against ACadSharp before being accepted; declined findings and the reasons are recorded in the plan headers). Notably, every task in plan 10 found an error in its own plan's test expectations, and in each case the reviewer independently upheld the implementer over the plan.


Documentation

  • Specs: docs/superpowers/specs/2026-09-02-layers-and-svg-design.md (binding design, amended per plan) and 2026-09-04-remaining-limitations-design.md (with an As implemented section recording every divergence).
  • Plans: docs/superpowers/plans/2026-09-02-01 … 2026-09-04-10.
  • Research: docs/research/layers-and-svg-support.md, remaining-rendering-limitations.md, remaining-limitations-design-options.md (the Codex consultation this work argues from).
  • README: supported entities, layer handling, SVG structure and options, thread-safety note, CLI flags.
  • CI: path filters now include Directory.Packages.props, Directory.Build.props and the solution; an explicit -warnaserror build step precedes the test step; a DejaVu font install step for text tests.

Known limitations (all documented in README)

  • MLINE cut positions are read as absolute distances from the element start. The DXF reference reads that way, but ezdxf reads the same values as relative dash/gap lengths and no implementation settles it; the only real-world sample available has its break at the segment end and renders identically either way. A drawing with more than one cut per element may differ from AutoCAD. MLINE fill cuts (group 42) are notified, not drawn.
  • A custom arrowhead inside a non-uniformly scaled block reference falls back to the default triangle (an Insert cannot express shear); an empty or self-referencing arrow block does too, with a warning.
  • Wipeouts need an opaque background; on a translucent background they are skipped with a warning.
  • In SVG, layer grouping takes precedence over painter order, so a wipeout cannot mask a later entity on an older layer group. Exact cross-layer painter order would need per-entity groups.
  • An attribute on an insert nested inside another block is laid out in that block's own coordinates.
  • Block contents are drawn in stored order at the first nesting level and in handle order below it (BlockRecord.Clone() enumerates GetSortedEntities()).
  • TEXT on a non-default plane is drawn with readable glyphs on the mirrored extent; AutoCAD mirrors the glyphs. A deliberate readability choice, not a parity guarantee.

Notes for the reviewer

  • CI has not yet run on GitHub for this branch; it was validated locally (dotnet test ACadSharp.Image.sln --configuration Release -warnaserror, 460/460) at the final commit.
  • Three small follow-ups were deliberately left out and are recorded in the final review: a circular block inside a viewport reports the bounds message rather than the clearer "references itself" one (it is culled before drawing); Leader.Style is dereferenced directly in two places and null-conditionally in two others; EntityRenderDispatcher.cs is ~1,700 lines and the block-graph walks (ReferencedBlocks, CollectSharedVertexLists, ScanBlockSubtree, BlockGraphIsCircular) are a clean, drawing-free module that could be extracted.
  • The rendering behaviour that depends on ACadSharp's clone/explode quirks has tripwire tests that will start failing if a package upgrade fixes them upstream (ACustomArrowHonoursANonZeroBlockBasePoint, the …LeavesAnMLineInsideItIntact family, RenderingADimensionThroughTheExporterLeavesAnMLineInsideItsArrowBlockIntact). That is intentional: they should be revisited, not silenced.

🤖 Generated with Claude Code

mubeda and others added 30 commits September 2, 2026 16:48
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Small, non-behavioural fixes from the whole-branch review of the
drawing-surface abstraction:

1. README: bump prerequisite to .NET 8.0 SDK; document upcoming
   Render()/RenderedPage, RenderedImagePage ctor, net8/net10 targeting,
   and ACadSharp 3.7.1 changes under Migration Notes.
2. RenderedImagePage: fix CS1574 by replacing a dangling cref to the
   not-yet-existing ImageExportFormat.Svg with <c>Svg</c>.
3. EntityRenderInfo: document the Handle==0 invariant for Insert.Explode()
   clones; pin it with a new assertion in
   NestedEntityOnLayerZeroInheritsInsertLayer.
4. ImageRenderContext.CreateViewportContext / ImagePageRenderer.DrawViewport:
   thread the real viewport width into SurfaceWidth instead of hardcoding 0.
5. IDrawingSurface: document BeginEntity/EndEntity scope nesting and that
   the raster backend ignores DrawCubicBezier's closed flag.
6. EntityRenderDispatcher: replace GetEffectiveLayerName(string) with
   GetEffectiveLayer(Layer?) so colour/width are resolved from the same
   effective layer as the name; thread Layer? through Draw/DrawDimension/
   DrawBlockContents. New test EffectiveLayerReturnsParentLayerObjectForLayerZero;
   extended NestedEntityOnLayerZeroInheritsInsertLayer to assert inherited
   layer colour.
7. CI: install fonts-dejavu-core before the parity tests run.
8. ImagePageRenderer: fix RenderTo's doc (raster page context, not backend
   neutral) and dispose the canvas on failure in Render to stop it leaking.
9. Cheap minors: rename sagitta->apothem with NaN-guard remarks in
   CurveTessellation.BulgeArc; document ImageStyle's invisible default;
   pass clamped width to PatternPen in RasterDrawingSurface.CreatePen;
   reword LineTypeScale/OriginY docs; make DrawPolyline static; uncomment
   *.png binary in .gitattributes; ignore .codegraph/.

All 46 existing tests plus 1 new test pass (47 total); the 4-case
SampleParityTests pixel-parity theory is untouched, no baselines changed.
Build is warning-free.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Non-finite geometry no longer reaches the markup: the dispatcher skips
entities whose defining geometry carries NaN or infinity with a warning
(Samples/6-57-1119.dxf has an ARC with an infinite radius that used to be
written as rx="Infinity"), the SVG surface drops such values as a backstop,
and page bounds ignore non-finite bounding boxes.

- POINT dots are sized in pixels; convert them into surface units through
  ImageRenderContext.PixelsPerSurfaceUnit so SVG gets drawing units.
- Make sanitised element ids unique within the document.
- Return a single live XDocument from ToDocument instead of deep-cloning.
- Format rotation degrees with their own 4-decimal formatter, take the
  absolute value of radii, and reject a foreign viewport in EndViewport.
- Rename the CLI option field to SvgNoScalingStroke to match its flag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
LineTypeDashResolver turns an entity's active linetype into an alternating
dash/gap array in surface units (dots become stroke-width dashes, text and
shape segments count as gaps, adjacent same-kind entries merge, the pattern
starts with a dash and has an even length). Patterns shorter than
MinimumDashPixels are drawn solid, but only where stroke sizes are pixels.
Viewport contexts get their own linetype scale so PSLTSCALE=0 keeps model
linetypes at the viewport's own scale.

Baselines: HSK80AHCP16190M_BMG.model.01.{png,svg} regenerated. The DWG
defines a non-continuous "Center" linetype and a dashed one; the SVG golden
is byte-identical apart from 230 added stroke-dasharray attributes
(228 "14.713 3.678" dash/gap, 2 "29.426 3.678 7.357 3.678" centre lines),
no geometry moved. In the PNG the red hidden-detail outlines of the chuck
bore and internals are now dashed instead of solid. The other three sample
cases are unchanged: 6-57-1119 only has a *layer* named HIDDEN whose linetype
is Continuous, and the Subaru and paper-space cases use no dashed linetypes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
ACadSharp 3.7.1's SpaceLineTypeScaling stores the raw $PSLTSCALE value but its
member names are swapped relative to AutoCAD semantics (Viewport = 0,
Normal = 1), so branching on the name inverted the behaviour for real drawings:
Samples/6-57-1119.dxf has $PSLTSCALE 1 and reads as Normal. The decision now
lives in ImagePageRenderer.ResolveViewportLineTypeScale, which branches on the
raw value - 1 (also the default when there is no header) keeps the page
linetype scale so dashes are uniform on the sheet, 0 multiplies it by the
viewport scale factor - and documents the name/value mismatch. New
ImagePageRendererTests covers all three cases, and BuildPattern's all-dash
linetype is now covered by AllDashPatternIsSolid.

No baseline changed: no parity sample renders a dashed linetype inside a
viewport.

Correction to the previous commit's message: the dasharrays added to the
HSK80AHCP16190M_BMG model golden come from the AM_ISO02W050 (6,-1.5) and
AM_ISO08W050 (12,-1.5,3,-1.5) linetypes at a common scale of 2.4522
(fit scale x LTSCALE 1), not from the "Center" linetype I inferred from the
strings in the DWG. The evidence for the regeneration is unchanged: the golden
is byte-identical apart from the added stroke-dasharray attributes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Adds a Hatch case to EntityRenderDispatcher.Draw: solid/SolidFill hatches
fill their boundary rings via IDrawingSurface.FillPath (even-odd), and
PatternFill hatches draw ACadSharp's already-clipped ExplodePattern() line
segments via DrawLine with dashes stripped (style with { DashPattern = null }),
capped at ImageConfiguration.MaxHatchLines with a single Warning
notification when exceeded. Guards Pattern == null with a Warning instead
of calling ExplodePattern.

Also includes an uncommitted docs fix carried over from the layer-attributes
work: clarifies the PSLTSCALE note in
docs/superpowers/specs/2026-09-02-layers-and-svg-design.md to describe the
raw $PSLTSCALE header value instead of the (differently-named)
SpaceLineTypeScaling enum member, and notes ACadSharp 3.7.1's enum names
are swapped relative to AutoCAD semantics.

No baseline changes: none of the three Samples/ files contain HATCH
entities. Verified by enumerating each document's distinct entity types
with ACadSharp 3.7.1 (Entities plus BlockRecord.Entities):
- 6-57-1119.dxf: Arc, DimensionAngular3Pt, Line, TextEntity
- HSK80AHCP16190M_BMG.dwg: Arc, Circle, DimensionLinear, Line, MText,
  Point, Solid, Viewport
- Subaru Logo Vector Free Wrap.dxf: Spline
A case-insensitive grep for "HATCH" across both DXF samples also returned
zero matches. SampleParityTests (PNG parity + SVG goldens) passed
unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
mubeda and others added 26 commits September 4, 2026 11:30
Addresses the review's minor findings on top of 157becd:
- <summary> docs on SyntheticSamples' two new private helpers
  (MLineVertex and WithHandle).
- WithHandle is now one internal method on SyntheticSamples;
  ImagePageTests and EntityRenderDispatcherTests forward to it
  instead of duplicating the reflection.
- EntityGoldenTests: assert the straight leader's own <polyline>
  (data-type="LEADER") the way the comment already claimed, tighten
  the 3DFACE point count to exactly 8 tokens, and note the no-filter
  precondition the occlusion test's reconstructed fit relies on.
- The synthetic MLINE's vertices now carry their actual segment
  Direction instead of a hard-coded (1,0,0); both goldens are
  unchanged byte-for-byte, confirming the renderer does not read
  MLine.Vertex.Direction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
The design was written before plan 09's later tasks landed. Quote the
signatures that now exist, build the block pairing on the existing
UsesOriginalGeometry relation instead of proposing a second one, and
replace the draw-time recursion guard with a pre-check on the original
block graph: a guard keyed on BlockRecord identity cannot see nested
levels, whose inserts hold deep-cloned records, and Insert.Explode()
deep-clones the graph before any drawing, so a cycle has to be caught by
the scan that already reports truncation. Also record which ACadSharp
clones share their lists with the source, and name all three consumers of
the wipeout boundary helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Closes the outstanding items from the whole-branch review at
.superpowers/sdd/2026-09-04-09-codex-review-fixes/final-review.md:

- C1 (Critical): EntityBounds.TryGet now catches NullReferenceException,
  so a block reference nested inside a resolvable block whose own
  reference is unresolved no longer crashes ImageExporter.Add /
  ImagePage.UpdateLayoutSize; it degrades the outer insert out of the
  frame/viewport instead.
- I1 (Important): the per-block MLINE/LEADER heal cache is now a
  ConditionalWeakTable instead of a Dictionary, so it no longer retains
  every one-shot deep-cloned BlockRecord (and its subtree) for the
  duration of a page render.
- I2 (Important): reconciled four statements of the same behaviour
  (block draw order, SVG xml:space placement, EntityBounds framing
  scope, source/placement nullability) with the code they describe.
- M1: dropped a stale parenthetical in ImagePageRenderer's remarks.
- M2: a ClipMode.Inside wipeout inside a viewport now raises the same
  NotImplemented notification DrawWipeout gives at the page level,
  instead of vanishing silently.
- M3: a NaN entity bound inside a viewport now raises a Warning instead
  of being culled silently by OverlapsInPlane (infinite bounds are left
  alone deliberately; they already compare correctly).
- M4: RasterDrawingSurface.DrawText now rejects a non-finite or
  non-positive WidthScale, matching the SVG backend's existing guard.

M5 (file split) and M6 (pre-existing notification shapes) are
deliberately unchanged, per the review's own scoping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Eight tasks: insert placement helpers, multi-line attributes, hatches drawn
from the original in their own OCS, a cycle check before exploding a block
graph, custom arrowhead blocks, inverted wipeout clips, MLINE cut segments,
and a golden exercising all five features.

Records two probe-verified ACadSharp 3.7.1 facts the plan depends on: the
insert transform diverges from AutoCAD's documented semantics whenever a
block has a non-zero base point and is rotated or scaled, which the arrow
task compensates for; and the MLINE cut interpretation cannot be settled
from the available data, so it ships flagged as unconfirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Constructing a CSMath Transform directly would pin an argument order the
tests have no business asserting, and a wrong guess would fail every
similarity case confusingly. Build them the way production does, from an
Insert, and add the case a length-only similarity check misses: a 3:1
scale turned 45 degrees leaves both axes the same length but not at right
angles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
…ulings

The cycle detector cannot reuse the heal scan: that scan stops at the first
MLINE or LEADER and returns early on a cache hit, so a cycle hiding behind
either is missed. Replace it with a dedicated depth-first walk that tracks
blocks on the current path, and guard the bounds path as well as the draw
path, because framing reaches Insert.GetBoundingBox() first and a stack
overflow cannot be caught.

The arrow task now tests the outer placement with TryGetPlanarSimilarity
instead of comparing axis lengths, which a non-uniform scale turned 45
degrees would have passed, and derives its insertion point by measuring
where the base point actually lands rather than by inverting ACadSharp's
formula, so it stays correct if a later package fixes that divergence.

Rulings recorded rather than implemented: attributes on inserts nested
inside another block stay in block-local coordinates, and a hatch whose
ordinal pairing fails falls back to a clone with an unnormalised normal.
Both are documented limitations, not silent behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
…them

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
…ed placement

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
…entity guard

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
…rt transform

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Add a test locking in the pattern-hatch mirror-angle fix (the largest
undisclosed improvement the review found), correct the two "never" claims
about the exploded clone to account for the ordinal-pairing-failure
fallback, extend UsesOriginalGeometry's summary to cover HATCH, fix the
stale "clone's points are already world" comment on the neighbouring
mirrored-hatch test, and cross-reference the block-path rule from spec
section 4.5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
A block that contains an insert of itself makes ACadSharp's Insert.Explode()
and Insert.GetBoundingBox() deep-clone/recurse the graph until the stack
overflows, before the renderer or the page-framing pass sees anything, and a
StackOverflowException cannot be caught. BlockGraphIsCircular walks a block's
own graph (no cache, no short-circuit, tracked per-path so a diamond is not
mistaken for a cycle) and is checked before DrawBlockContents explodes an
insert and before EntityBounds.TryGet asks ACadSharp for its bounding box;
either path now skips the block with a Warning/error instead of crashing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
A leader whose dimension style names an arrowhead block now draws that block
at the tip instead of falling back to the default triangle: the block's base
point goes to the tip, its local +X axis turns to the outward direction, and
it is scaled by ArrowSize * ScaleFactor composed with the placement of any
block reference around the leader. The block is placed by handing a transient
Insert to the ordinary block-content path, because most entity types are drawn
from their own stored points and only Insert.Explode() transforms an arbitrary
block's contents correctly; the insertion point is measured and corrected so
the base point lands on the tip under ACadSharp 3.7.1's own insert formula.

An empty block, a self-referencing one, a composed transform that is not a
planar similarity, and a degenerate size each fall back to the default
triangle with a Warning. The block cycle walk now follows a leader's arrowhead
block as well as nested inserts, because Leader.Clone() deep-clones its
dimension style and with it that style's arrowhead block, so a leader inside
its own arrowhead block would exhaust the stack inside Explode().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Task 4's review found the shipped cycle tests only ever put the closing
Insert as the first or second entity in a plain two-block mutual cycle, so
nothing regression-tests the two properties that motivated the dedicated
scanner in the first place: that it does not stop at the first MLINE/LEADER
like ScanBlockSubtree does, and that it tracks blocks per-path rather than
globally so a block reused from two places (a diamond) is never mistaken for
a cycle. Adds both, plus a direct single-block self-reference as a cheap
extra case, using the same construction-order workaround as the existing
tests (build the Insert while its target block is still acyclic, close the
cycle afterwards through List<Entity>.Add) since ACadSharp 3.7.1's own
Insert(BlockRecord) constructor recurses through the block and overflows the
stack if the block is already cyclic when the constructor runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Cloning a LEADER clones its dimension style, and ACadSharp 3.7.1's
DimensionStyle.Clone() deep-clones that style's arrowhead block, so an MLINE
inside a custom arrowhead is reached by a clone that never names it and has
the vertex list it shares with its source emptied. The heal scan only followed
nested inserts, so nothing restored it and the caller's document was left
corrupted by a render.

The scan now follows a leader's arrowhead block along the same edge the cycle
walk already had. Insert(BlockRecord) also clones a document-owned block's
entities, which means merely constructing the transient insert that places an
arrowhead empties those lists before the block-content path — which only ever
sees an insert that already exists — can snapshot them; DrawArrowBlock
therefore takes its own snapshot first, heals immediately after the
constructor so the block-content path snapshots intact lists, and heals again
in a finally. Heal moves out of DrawBlockContents so both call sites share it.

ScanBlockSubtree deliberately keeps to insert edges: an arrowhead block is
only reachable through a LEADER, which already answers that a subtree needs
healing, so the extra edge could not change an answer.

Also tightens two arrowhead fallback assertions, and corrects the comment on
the rotated non-uniform fallback test: an Insert's transform maps the plane's
axes orthogonally however it is rotated or tilted, so that test reaches the
gate's length branch, not its orthogonality branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
… off

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Pin the two-ring inverted-clip fixture to a boundary strictly inside the
frame (not equal to it) and assert both rings' actual point sets, and pin
the clipping-off test's single ring to the frame corners, instead of
asserting counts only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
…te cases

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
FidelityGoldenTests renders a new synthetic block (FidelityBlock) through
both real backends: a multi-line attribute, a hatch on a tilted plane
inside a block, a leader with a custom arrowhead block, an inverted
wipeout over a line, and a two-element MLINE cut in both elements.

SquarePath (the rectangular hatch boundary helper) and DarkestPixelNear
(the occlusion pixel-sampling helper) each had a private duplicate; both
are moved to shared homes (SyntheticSamples and GoldenAssert
respectively) and their original call sites repointed, rather than
adding a second copy for the new tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
…xture

FidelitySvgMatchesGoldenAndContainsEveryFeature now asserts no Warning
or NotImplemented notification fires, mirroring EntityGoldenTests. This
fixture deliberately walks four arrowhead fallback paths, so without the
guard a silent fallback to the default triangle would still satisfy the
existing geometry assertions and go unnoticed; the guard is also the
only proof that this render is warning-free at all.

The wipeout's clip boundary is now inset in both axes (previously only
in x, so its y extent equalled the frame's), so a regression that
clamped only the vertical component of the clip boundary to the frame
would no longer produce byte-identical output. The masked line now runs
past the wipeout's own frame on both sides (previously its endpoints
coincided exactly with the frame's edges), removing an anti-aliasing
remnant where line and mask shared a fractional pixel and making the
fixture's intent unambiguous.

Both baselines are regenerated (only these two changed; verified with
git status --short against Baselines/) and the regenerated PNG was
re-inspected: all five features still render correctly, and the masked
line now shows three clean segments (its two tails outside the wipeout's
own frame plus the visible middle band) with no stray remnant pixel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
DimensionStyle has four block-valued properties (ArrowBlock, DimArrow1,
DimArrow2, LeaderArrow) and both Leader and Dimension carry a style, but the
heal snapshot, the heal scan and the cycle walk each hand-spelled a single
edge, Leader.Style.LeaderArrow. ACadSharp 3.7.1 deep-clones all four on both
entity types, so an MLINE inside any of the other blocks was emptied by a
render and written back out by a caller who then saved: measured through the
public dispatcher API, a leader with DimArrow1 and a dimension with ArrowBlock
both went 2 -> 0 vertices, where LeaderArrow stayed 2 -> 2.

One private static ReferencedBlocks(Entity) now supplies the edge set to all
three walks. ScanBlockSubtree's trigger widens to MLine or Leader or Dimension
in the same change: its old correctness argument was that any arrow block is
reached through a LEADER, which already answers yes, and following a Dimension
edge breaks it -- a block holding only a dimension would answer "clean", take
no snapshot, and the widened enumerator would never run.

BlockGraphIsCircular also gains a per-call "already proven acyclic" set beside
the on-path set, so a heavily shared block DAG is no longer walked
exponentially; cycle detection still uses the on-path set alone.

Documentation, all previously enumerated in four disagreeing places:
UsesOriginalGeometry's doc becomes the canonical list of the types drawn from
their original and gains the ATTRIB/ATTDEF case; DrawBlockContents and the
private Draw overload point at it instead of re-listing; the count-mismatch
warning says "geometry drawn from originals" rather than only "text", since a
failed pairing now also affects hatches, wipeouts and leaders. EntityBounds
records that the reported exception may be one it constructed itself, and
VisibleRuns records that its non-finite guard is a backstop for direct callers.

Tests: two regressions mirroring the existing LeaderArrow pair (a leader with
DimArrow1, a dimension with ArrowBlock), both red at 0 vertices before this
change. The fidelity golden's raster test gains the warning-free guard the
vector one had, and both now subscribe before the page is added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
…rage

The plan-10 design was never amended although the plan names it as binding, so
it gains an "As implemented (2026-09-04)" section with the four divergences:
the placement helper has no Compose and its similarity test is
TryGetPlanarSimilarity(Transform?, out scale, out rotation, out mirrored);
MapOcsPoint takes (Transform?, OcsTransform?, double elevation, XYZ) with the
OCS frame built once per entity by the caller; the Circle-to-Ellipse pairing
conversion and the per-entity mismatch warning were deliberately not built, so
the count mismatch stays the only signal; and the transient arrow insert
carries no normal because the surface projection drops Z, making only the XY
map observable. The section also records the per-task corrections applied to
the plan's test snippets during execution, since the plan file still quotes
them as first written and the adjudications otherwise live only outside the
repository. The plan gains a one-line pointer to that section.

The base spec's MLINE bullet claimed the arrow-block heal and the cycle guard
follow "a LEADER's arrowhead block"; both now say what the code does -- nested
inserts plus all four dimension-style arrowhead blocks of a LEADER or a
DIMENSION, from one shared enumerator, with a DIMENSION treated as needing a
snapshot. Section 5.3 gains the wipeout pairing-failure fallback beside the
hatch one.

README gains the three user-visible behaviours it was missing: a
self-referencing block is skipped with a warning, an inverted wipeout clip is
masked, and an empty, self-referencing or degenerately sized arrow block falls
back to the default triangle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Dimension.Clone() deep-clones Dimension.Block -- the anonymous block holding
the picture ACadSharp generates for the dimension -- exactly as
DimensionStyle.Clone() clones the four arrowheads. Measured through the public
dispatcher API, a two-vertex MLINE inside a picture block went 2 -> 0 across a
render of the block containing the dimension, so the caller's document was
corrupted on an ordinary render: unlike the arrowheads, DrawDimension already
draws through this block, so no exotic file is needed to reach it.

ReferencedBlocks now yields it alongside the four style properties, which
carries it to all three walks at once. A null Block means the picture has not
been generated yet, so there is nothing to clone and nothing to walk.

The cycle walk follows it too, and that cannot refuse a legitimate drawing: a
picture block is geometry generated from the dimension's own definition points
and never places the dimension's container in it, so a cycle there is a file
that would otherwise recurse through Dimension.Clone() until the stack dies,
uncatchably. A picture block shared by two dimensions is a diamond, which the
on-path set already tells apart from a cycle.

Tests: a dimension whose picture holds a multiline keeps its vertices across a
render (red at 0 before this change), and a block whose dimension picture
places that same block is skipped with the self-reference warning instead of
exploding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz
Dimension.UpdateBlock() is the second site in the renderer, after
DrawArrowBlock, that makes ACadSharp construct an Insert of a block the caller
owns: generating the picture for a linear or aligned dimension builds one of
each of the style's arrow blocks, and ACadSharp 3.7.1's Insert(BlockRecord)
constructor clones a document-owned block's entities, so the call empties the
vertex list of any MLINE inside one of them. Measured through the dispatcher, a
top-level dimension whose DimArrow1 block held a two-vertex multiline left it
at none.

A dimension inside a block reference was already covered: DrawBlockContents
snapshots the whole subtree before Explode() and heals in a finally. The
top-level path had neither, because Draw's entity-type switch routes straight
to DrawDimension. The snapshot now happens there, over the blocks
ReferencedBlocks(dimension) yields -- the enumerator makes this one call rather
than a design -- and the heal is in a finally, so a throw while generating the
picture cannot leave the caller's document broken. Page framing does not get
there first: Dimension.GetBoundingBox() was probed to leave Block null, so
EntityBounds never reaches the constructor.

The call is also cycle-guarded, and not for symmetry: the constructor's clone
is the same deep clone Explode() performs, so an arrow block reachable from
itself exhausts the stack inside ACadSharp before UpdateBlock() returns and a
StackOverflowException cannot be caught. The dimension's own picture block is
not among the blocks checked, since that branch only runs when there is not
one yet.

ReferencedBlocks folds the picture edge and the style edges into one Dimension
match, so the pattern variable is named for what it matches; the block comment
above the heal walk no longer describes the edge set as nested inserts alone.

Tests: a top-level dimension keeps the multiline inside its arrow block across
a render, both through the dispatcher and through the whole public exporter
path (red at 0 before this change), and one whose arrow block places itself is
skipped with the self-reference warning instead of overflowing the stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz

@albertwoo albertwoo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

For docs, please also only keep the final spec for the implementation, limitations/issues, idea for future etc. Do not put too much. Make them simple and clean for human to follow.


<PropertyGroup>
<TargetFrameworks Condition="'$(PublishAot)' != 'true'">net6.0;net8.0;net10.0</TargetFrameworks>
<TargetFrameworks Condition="'$(PublishAot)' != 'true'">net8.0;net10.0</TargetFrameworks>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please keep net6.0

Comment thread README.md
[![NuGet downloads](https://img.shields.io/nuget/dt/ACadSharp.Image?logo=nuget&label=downloads)](https://www.nuget.org/packages/ACadSharp.Image)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![.NET](https://img.shields.io/badge/.NET-6.0%20%7C%208.0%20%7C%2010.0-512bd4)](https://dotnet.microsoft.com/download)
[![.NET](https://img.shields.io/badge/.NET-8.0%20%7C%2010.0-512bd4)](https://dotnet.microsoft.com/download)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please keep net6.0

Comment thread README.md

### Prerequisites

- [.NET 6.0 SDK](https://dotnet.microsoft.com/download) or later

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Keep net6

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I checked whether we can keep .NET 6. The branch was updated to ACadSharp 3.7.1, which brings in CodePages 9.0.6.
That dependency warns that .NET 6 isn’t officially supported, although my initial tests on .NET 6 passed.

The implementation has been developed and tested against ACadSharp 3.7.1; I haven’t established whether that
upgrade is strictly required, so reverting it would need a separate compatibility check.

Would you prefer that I restore .NET 6 and run further CAD reading and rendering tests, accepting the dependency
warning for now, or keep the minimum at .NET 8 until the dependency issue is resolved upstream?

I’ll also simplify the docs to cover the final implementation, known limitations, and future improvements.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

yes, please restore net6 support. some of my projects still need it and cannot be upgraded for now. thanks.

@albertwoo

Copy link
Copy Markdown
Contributor

I will merge and clean something and test net6 with my projects. Thanks for the contribution.

@albertwoo
albertwoo merged commit 2ffaa64 into slaveOftime:main Sep 7, 2026
1 of 2 checks passed
@mubeda
mubeda deleted the mubeda/svg-support branch September 8, 2026 13:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants