Add an SVG backend, full layer attributes, and close the remaining rendering gaps - #5
Conversation
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
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
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
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
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
left a comment
There was a problem hiding this comment.
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> |
| [](https://www.nuget.org/packages/ACadSharp.Image) | ||
| [](LICENSE) | ||
| [](https://dotnet.microsoft.com/download) | ||
| [](https://dotnet.microsoft.com/download) |
|
|
||
| ### Prerequisites | ||
|
|
||
| - [.NET 6.0 SDK](https://dotnet.microsoft.com/download) or later |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
yes, please restore net6 support. some of my projects still need it and cannot be upgraded for now. thanks.
|
I will merge and clean something and test net6 with my projects. Thanks for the contribution. |
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.0is 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
IDrawingSurfacewith two implementations: the existing SixLabors.ImageSharp raster surface and a new SVG surface written directly withSystem.Xml.Linq(SkiaSharp was evaluated and rejected: no groups, no dash arrays, native dependencies).The SVG is designed for React/web consumers:
<g>per layer (id="layer-…" class="cad-layer" data-layer="…") so layers can be toggled client-side.data-type,data-handle,data-parentanddata-blockattributes on every primitive for hit-testing and inspection.viewBoxwith nowidth/heightby default;vector-effect="non-scaling-stroke"on by default.<ellipse>,<path>Béziers and<text>/<tspan>where the raster tessellates and outlines.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/CELTSCALElinetype scaling. Layer visibility is opt-in (LayerVisibilityMode.Allis the default):Screenhonours off/frozen,Plotalso honours non-plottable.HideLayers/IncludeLayersfilter 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-sizeis 4/3 of the CAD height; raster text is laid out at a fixed 72 dpi soDpiaffects 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+XXXXand%%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 testableProgram.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'sBoundingBox.IsInis 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()andWipeout.Clone()share their vertexListobjects with the source entity;Insert.Explode()and even theInsert(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:Insert.BlockDimensionStyleblock properties (ArrowBlock,DimArrow1,DimArrow2,LeaderArrow), reached from bothLeaderandDimensionDimension.Block, the anonymous picture blockDrawDimension's top-levelUpdateBlock()callOne shared
ReferencedBlocks(Entity)enumerator feeds all three walks (snapshot collection, needs-snapshot scan, cycle guard) so the edge set cannot drift. Consequence for callers: aCadDocumentmust not be rendered concurrently by two exporters (documented inImageExporter.Renderand the README).Uncatchable stack overflow on circular block graphs. A block that references itself makes
Explode()andGetBoundingBox()recurse until the stack dies, and aStackOverflowExceptioncannot 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()computesR·S·p + (InsertPoint − BasePoint); AutoCAD specifiesInsertPoint + 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 transientInserton 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 TEXTAlignmentPointor 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 (UsesOriginalGeometryis 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.GetPointsandHatch.ExplodePatternreturn raw OCS coordinates;ExplodePatternis eager;CSMath.Matrix3.ArbitraryAxisis not orthonormal for tilted normals (the renderer has its ownOcsTransform);Ellipse.MajorAxisis a full length; theSpaceLineTypeScalingenum names are swapped relative to the stored DXF value;DimensionStyle.Clone()deep-clones its arrow blocks;Viewport.SelectEntities()enumeratesDocument.Entitiesunguarded.Public API changes
RenderedPage(abstract,IDisposable),RenderedImagePage,RenderedSvgPage,SvgOptions,LayerVisibilityMode,ImageExportFormat.Svg.ImageConfiguration:LayerVisibility,HideLayers(...),IncludeLayers(...),Svgoptions,FontFamilyName,MaxHatchLines,SetPadding;Dpinow documented as affecting line weights only.ImageExporter.Render()returnsRenderedPages carrying the requested format;Render(ImageExportFormat).NotificationType(Warning,NotImplemented, …) and the shape[{SubclassMarker}] Handle {X}: ….ImageStyleResolver.Resolve,ImagePage-based SVG context overloads).net6.0target 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 throughDxfWriter/DxfReader. The fidelity golden also asserts noWarning/NotImplementedfires, 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
docs/superpowers/specs/2026-09-02-layers-and-svg-design.md(binding design, amended per plan) and2026-09-04-remaining-limitations-design.md(with an As implemented section recording every divergence).docs/superpowers/plans/2026-09-02-01 … 2026-09-04-10.docs/research/layers-and-svg-support.md,remaining-rendering-limitations.md,remaining-limitations-design-options.md(the Codex consultation this work argues from).Directory.Packages.props,Directory.Build.propsand the solution; an explicit-warnaserrorbuild step precedes the test step; a DejaVu font install step for text tests.Known limitations (all documented in README)
Insertcannot express shear); an empty or self-referencing arrow block does too, with a warning.BlockRecord.Clone()enumeratesGetSortedEntities()).Notes for the reviewer
dotnet test ACadSharp.Image.sln --configuration Release -warnaserror, 460/460) at the final commit.Leader.Styleis dereferenced directly in two places and null-conditionally in two others;EntityRenderDispatcher.csis ~1,700 lines and the block-graph walks (ReferencedBlocks,CollectSharedVertexLists,ScanBlockSubtree,BlockGraphIsCircular) are a clean, drawing-free module that could be extracted.ACustomArrowHonoursANonZeroBlockBasePoint, the…LeavesAnMLineInsideItIntactfamily,RenderingADimensionThroughTheExporterLeavesAnMLineInsideItsArrowBlockIntact). That is intentional: they should be revisited, not silenced.🤖 Generated with Claude Code