From 88084bc63dc62139ce4d323ce20e9b01e451a9c3 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 13 Jul 2026 20:07:21 -0400 Subject: [PATCH 1/4] Fix port label collisions and Skia/SVG text-position mismatch Three related port-rendering bugs, all rooted in LayeredLayoutAlgorithm's box-sizing/label-clearance logic and the SVG/Skia renderers' port-label offset formulas: 1. Labeled multi-port bottom collision: a titled/labeled node's bottom-most port label could run past the box's own bottom edge because the downward-shift compensation term was not scaled per anchor slice. 2. Unlabeled multi-port growth-floor gap: a face with 2+ anchors skipped its entire growth-floor computation whenever none of the anchors carried a label, so boxes with many unlabeled ports never grew to give them room and the ports visibly bunched together. Fixed for both the Left/Right height floor and the Top/Bottom width floor (scaled by anchor count). 3. Boundary-port dual-label collision: a boundary port's ExternalLabel is drawn on the same outward face its external approach edge's arrowhead marker occupies, and the fixed port-glyph offset did not account for the marker's own length, causing the label text and arrowhead to visually collide. Fixed in both SvgRenderer and SkiaRasterRenderer by adding NotationMetrics.EndMarkerLength to the boundary-port label offset, and widening ResolveContentInsets' title clearance to match. Also fixed a PNG/SVG text-position mismatch for port labels: Skia's DrawText treats Y as a baseline while SVG's dominant-baseline="middle" treats the same Y as the text's vertical center, so identical offset formulas rendered visibly different text positions between renderers. SkiaRasterRenderer.DrawPortLabel now derives the correct baseline from the font's own ascent/descent metrics so both renderers agree. Adds a new PortsShowcaseUnlabeledFanOut gallery scene and regression tests for the growth-floor fix (Left/Right and Top/Bottom), verified via git-stash fail/pass toggling. Regenerated docs/gallery to reflect all fixes; visually cross-checked SVG output via a throwaway Puppeteer-based browser renderer against the Skia PNG output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/gallery/README.md | 5 + .../boundary-ports-showcase-deep-chain.png | Bin 8406 -> 8361 bytes .../boundary-ports-showcase-deep-chain.svg | 8 +- .../boundary-ports-showcase-horizontal.png | Bin 6914 -> 6780 bytes .../boundary-ports-showcase-horizontal.svg | 4 +- .../boundary-ports-showcase-vertical.png | Bin 7164 -> 7149 bytes .../boundary-ports-showcase-vertical.svg | 6 +- docs/gallery/parallel-edges-preserved.png | Bin 3838 -> 3523 bytes docs/gallery/parallel-edges-preserved.svg | 22 +-- docs/gallery/ports-showcase-horizontal.png | Bin 5292 -> 5315 bytes ...ts-showcase-multi-connector-horizontal.png | Bin 9613 -> 9553 bytes ...ts-showcase-multi-connector-horizontal.svg | 28 +-- ...orts-showcase-multi-connector-vertical.png | Bin 7901 -> 8005 bytes ...orts-showcase-multi-connector-vertical.svg | 26 +-- .../ports-showcase-unlabeled-fan-out.png | Bin 0 -> 11255 bytes .../ports-showcase-unlabeled-fan-out.svg | 54 +++++ docs/gallery/ports-showcase-vertical.png | Bin 4184 -> 4247 bytes docs/gallery/ports-showcase-vertical.svg | 16 +- .../LayeredLayoutAlgorithm.cs | 65 +++++- .../SkiaRasterRenderer.cs | 21 +- .../SvgRenderer.cs | 14 +- .../GalleryCatalog.cs | 8 + .../GalleryDiagrams.cs | 38 ++++ .../GalleryShowcaseTests.cs | 27 +++ .../LayeredLayoutAlgorithmTests.cs | 187 ++++++++++++++++++ 25 files changed, 462 insertions(+), 67 deletions(-) create mode 100644 docs/gallery/ports-showcase-unlabeled-fan-out.png create mode 100644 docs/gallery/ports-showcase-unlabeled-fan-out.svg diff --git a/docs/gallery/README.md b/docs/gallery/README.md index 8bf7ba4..c7cdcf5 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -153,6 +153,11 @@ either side. The companion top/bottom case: two ports per face spread horizontally instead of vertically, proving the same crowding and title-collision protection when PortDistributor works along the cross axis of a downward flow. +![A titled hub node with several unlabeled ports fanning out to five other boxes](ports-showcase-unlabeled-fan-out.svg) + +A face with several ports carrying no label or text at all still grows the box tall enough to keep them from bunching +together — the growth floor applies unconditionally to any face with two or more anchors, not only labelled ones. + ## Boundary and delegation ports The hierarchical engine's support for boundary (delegation) ports: a container may expose a named port carrying BOTH an diff --git a/docs/gallery/boundary-ports-showcase-deep-chain.png b/docs/gallery/boundary-ports-showcase-deep-chain.png index 01235c85935203cd95a83a23f10779a57de0aa2b..c8801a8879e0fa4aeb352030024a8445ba4c60b7 100644 GIT binary patch literal 8361 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 diff --git a/docs/gallery/boundary-ports-showcase-deep-chain.svg b/docs/gallery/boundary-ports-showcase-deep-chain.svg index c3e0ad0..ea0dd0c 100644 --- a/docs/gallery/boundary-ports-showcase-deep-chain.svg +++ b/docs/gallery/boundary-ports-showcase-deep-chain.svg @@ -42,9 +42,9 @@ - handle - relay + handle + relay - route - request + route + request diff --git a/docs/gallery/boundary-ports-showcase-horizontal.png b/docs/gallery/boundary-ports-showcase-horizontal.png index 22e543260d146baae257b8190903f93118c53109..9430e3a20ed46fa71ea1c5f548da87b5ce43965e 100644 GIT binary patch literal 6780 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 F@>jInvMK-o diff --git a/docs/gallery/boundary-ports-showcase-horizontal.svg b/docs/gallery/boundary-ports-showcase-horizontal.svg index f5bb09c..08a1620 100644 --- a/docs/gallery/boundary-ports-showcase-horizontal.svg +++ b/docs/gallery/boundary-ports-showcase-horizontal.svg @@ -42,6 +42,6 @@ - dispatch - command + dispatch + command diff --git a/docs/gallery/boundary-ports-showcase-vertical.png b/docs/gallery/boundary-ports-showcase-vertical.png index 3a8c27a08fe21ebb2ec575b804dbdd72748cd838..68ada593d0189350415acb8f69b05192a4672cd9 100644 GIT binary patch literal 7149 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�e-|aEa~lJH>iFS5)X=}LsekghBIuXP<<*xZdF+gXRwwe&IPUbA zs`;0|_-%jZb1iOcys3pJ^h^N97|dLG_qyZzfS$NDjCneO)AZ+h5i^@BR(tDm3BdK1 z0$&Eyzk#_Fzu4LVxZatQKVx*qd_HTlhCah0D{W&u^|>VARe{nb%>$UlKA0kaP53hsnG1d?4~DwiaossLPiWMm}ywcL?| zPw2}hK_p9Ti(bKgVrPZoer*brLS?d=7e2UyA`qtQ1Y z44dNA^!$e&XFNJ?e^44n;wB~(mK6Z;<1VzyfdU8CFK6H+K)nxFo0L4q%Wd&@b=wyU8w1=Q%Lc2N(gp=RfJx}^ zh-O)Atjz6HAJ{}wE0FOH!^THaN_D3r(Ee)9FovuM9 zUh$s!4KqcK>>yLO>YNRNjsGyleB$wr8}Ge?W_&$5z;|Jg_6`mN!o5$$DDC{4IT|Y{ zV6|z%_f`)os?5%;@v>VBk?kcV&7?B)9njoKJR5z^0DvT@GXN@pT;W`ODK18m^%{-5 zS^bsC7sBWJc9d3IPOE}+s`7S9TIfSMf<0kxC2Sp0Rb_@^;`Z+N`X+(C6x)YxZ#zn* z8kc|*@Y(<`?8^q;C8 z82%*c!wu;EV_luDJEpY+m|~D*zG)m$ za$wE3LJBdLH_|7bDOBSG9Ye0Odrp=z^Se*God;RCH?Thm|9j;5%s@kUEocZj@SQt9 zjFAK8(w1I}(dqt?Mtk)(B_*YXsS2#*!jb>OBV>rQ?{h`5g%{>*!bSa-N&rFz!Q`g- z2_!5<@9cBv*ReOsFh)ILb8XfY7rwLUy@JCobOV`mQ25irTz6711DD~7+atGVK-cFC zOgmmoUK@L3rQz=(JP>+K@@Ez25g3Kl(z-cQN$z(HS+hnmrckgel4E+{s`I9q#%b9r zW7TnJGiOnV&L}tx9m8I$U-jm5#zl*VeFWP&#q}w!D#*Dn{R(LPV$ed$K-|FdKzG-H z&t>?xSy7vYiZ&8tPNIF`=T7OwS^f*a!3Rs_&a8gFR2eN_B2qX;Iz6Yh3o5vmC+F*=r4BiWo%O<#c$G5Sz2mA zK~VLSRaN62i$?Q`S^@(P2pN4b?x>I$1RX@9)1{IPyD5y)qN01C_L}Jvq~UL?>j$!0 z9)79GRk-y)sy&K2QA^+nh*Du2pe%=iPoGXqAcB%W#T%G|?rY4=2LF|b;#9>80tfe7 z*$M-tX$uH!frtg1Bo>$HTnlIf$5eK^%j8Uw6tctiG5`>2iy}Do9l_KmpN`5 zKDEN3;~md)wjOkXfoTILt?t}Orr(p|XHz}^+?zSfvc+A3DYt}yDo&{l>9tCqcA_rs z8P9f_n#m-a0|ov-zDPgMCQ+aEvVrsE3E@k3O-&PSWLLGaRw8P|u&tq`cPky#m#O~b z$qK?y4?nLXWlA*=w%E7AMqD$gGC=C@WNpg=5$KG8zv5^RlIqEQ1fnA;swJYm=;Z0y zGBPq!40_zUxyo<}0Av71fGF{Ibpa~U=v`h`S_`qHR1YC~?FEluDI2Ud);jerZ8wRB zthUC+#zJS~@K#_OVE9zf#3T|31}zi-#By$5yEI?$oTXE<_T<>@bb#l_wgsK23%6x1 zq-BG?Xa27**?tx7H`fJmd(cr2`qh)Fk`iDs73dnLRF9~nvzHtgI`g&%X1`to^N!kj zg_J`9_!5_wziqA+;+23##q*4%Mwh%gF6-^;2`&V>G(VEJ@d(&v87Z~UrEz?gIcVAh zuhij-fp5b&;M<;u`=IMx`zvZY9YWR)ux7PyDSOZKlB1s=i+LKL!u^^bg04D#ag*>e zMHA!N9o3=@wz3aB7KoTdh!f`(-G!vJI{%3d`Oj#W*2jZ7z!(3u0y*!S+1cd;B8ET( zUTj}TY@@99gCAXhzlsP%B$1Q?UxL!bHFH%)bG-ke{yzY-Y Operator - Controller + Controller Driver - dispatch - command + dispatch + command diff --git a/docs/gallery/parallel-edges-preserved.png b/docs/gallery/parallel-edges-preserved.png index f5107f4097fedc85dabb2bd71fedeafa38fcb269..bd5a9558fc14c6b03d86137bedd5264288156768 100644 GIT binary patch literal 3523 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 mm+sW`BeaRAKUsLwPSZ3_T5@E88 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 ZY4UxWWsm`$N?@P`>FXLp>uxze{ue50gC76@ diff --git a/docs/gallery/parallel-edges-preserved.svg b/docs/gallery/parallel-edges-preserved.svg index c31fa06..6b5a6e9 100644 --- a/docs/gallery/parallel-edges-preserved.svg +++ b/docs/gallery/parallel-edges-preserved.svg @@ -1,4 +1,4 @@ - + @@ -30,14 +30,14 @@ - - Producer - - Consumer - - - - primary - retry - audit + + Producer + + Consumer + + + + primary + retry + audit diff --git a/docs/gallery/ports-showcase-horizontal.png b/docs/gallery/ports-showcase-horizontal.png index 59ffca51289ca2a566f0c95a3a9ab63f10746858..8f3ce1ffd00d6beb42c8b426d9104ac2bf958499 100644 GIT binary patch literal 5315 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%0D9z!C#LtN@A2Gqthh&i|-@WE?;^m{NRl5uTG&p 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 SensorB - - Hub + + Hub ActuatorA ActuatorB - - - a rather long telemetry feed - - - heartbeat - - - primary drive command - - - diag + + + a rather long telemetry feed + + + heartbeat + + + primary drive command + + + diag diff --git a/docs/gallery/ports-showcase-multi-connector-vertical.png b/docs/gallery/ports-showcase-multi-connector-vertical.png index c0ffa7a902bd4cdbff736d0f6319d12749e1a9a5..db9f272cb6af33f034cae98b4804781e44aad6e4 100644 GIT binary patch literal 8005 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 literal 7901 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 C4Y#)d diff --git a/docs/gallery/ports-showcase-multi-connector-vertical.svg b/docs/gallery/ports-showcase-multi-connector-vertical.svg index 5d3231b..71001c7 100644 --- a/docs/gallery/ports-showcase-multi-connector-vertical.svg +++ b/docs/gallery/ports-showcase-multi-connector-vertical.svg @@ -1,4 +1,4 @@ - + @@ -34,22 +34,22 @@ MonitorA MonitorB - - Hub - - ControlA - - ControlB + + Hub + + ControlA + + ControlB a rather long status feed alert - - - primary control command - - - backup + + + primary control command + + + backup diff --git a/docs/gallery/ports-showcase-unlabeled-fan-out.png b/docs/gallery/ports-showcase-unlabeled-fan-out.png new file mode 100644 index 0000000000000000000000000000000000000000..ad69869dfa08d85b1561f305919ade66da0ae199 GIT binary patch literal 11255 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + board : Motherboard + + cpu : Cpu + + memory : Ram + + storage : Ssd + + network : NetworkCard + + graphics : Gpu + + + + + + + + + + diff --git a/docs/gallery/ports-showcase-vertical.png b/docs/gallery/ports-showcase-vertical.png index d9f88f3f7fda777f3fc8150082428a40c1a5e8b0..6b15e7de30247e2fdee7515ae5340158223c3c97 100644 GIT binary patch literal 4247 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 literal 4184 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 diff --git a/docs/gallery/ports-showcase-vertical.svg b/docs/gallery/ports-showcase-vertical.svg index d8607c3..a85839b 100644 --- a/docs/gallery/ports-showcase-vertical.svg +++ b/docs/gallery/ports-showcase-vertical.svg @@ -1,4 +1,4 @@ - + @@ -32,14 +32,14 @@ Monitor - - Hub - - Control + + Hub + + Control status - - - ctrl + + + ctrl diff --git a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs index 2fc9122..e105a1e 100644 --- a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs @@ -363,6 +363,14 @@ void RecordAnchor(int nodeIndex, Point2D anchor, string? label) // trigger this floor and could render its stacked port labels crowded/overlapping — the // exact same faceAnchors-vs-sidePorts distinction as hasLabeledLeftOrRightAnchor above. // bySide (from sidePorts) is reused here for the same reason. + // + // Any face with 2+ anchors — labeled or not — still needs enough physical room to keep + // adjacent port glyphs and their outgoing connector routing from visually crowding each + // other; a box is allowed to say "I have enough ports on me that I need to spread them + // out a bit" even when none of those ports carry any text. Only the label-driven terms + // below (labelHeight/labelWidth scaling, and the downward-shift compensation) are gated + // on actual labels being present, since they have no meaning otherwise; the baseline + // per-anchor connector-clearance floor applies unconditionally whenever Total >= 2. if (faceAnchors.TryGetValue(i, out var byFace)) { var labelHeight = ConnectorLabelPlacer.EstimateLabelHeight(assumedFontSize); @@ -371,8 +379,9 @@ void RecordAnchor(int nodeIndex, Point2D anchor, string? label) List? sideLabels = null; bySide?.TryGetValue(side, out sideLabels); var sidePortLabeled = sideLabels != null && sideLabels.Exists(l => !string.IsNullOrEmpty(l)); + var anyLabeled = faceInfo.AnyLabeled || sidePortLabeled; - if (faceInfo.Total < 2 || (!faceInfo.AnyLabeled && !sidePortLabeled)) + if (faceInfo.Total < 2) { continue; } @@ -385,9 +394,36 @@ void RecordAnchor(int nodeIndex, Point2D anchor, string? label) // old endpoint-inclusive linear formula gave) — require that per-slice height // to be at least the label's own line height, with no separate outer-edge // buffer term needed since the outermost slice's own half-height already gives - // proportional corner clearance. - var minHeightCandidate = Math.Max(labelHeight * faceInfo.Total, 2.0 * LayeredLayoutMetrics.ConnectorClearance) + // proportional corner clearance. When no label is present, fall back to a + // per-anchor connector-clearance floor so unlabeled ports still get enough + // absolute room to avoid crowding (scaled by Total, unlike the historical flat + // 2 * ConnectorClearance floor, which never bound in practice for the labeled + // case anyway and was too weak on its own for several unlabeled ports). + var labelBasedHeight = anyLabeled ? labelHeight * faceInfo.Total : 0.0; + var clearanceBasedHeight = 2.0 * LayeredLayoutMetrics.ConnectorClearance * faceInfo.Total; + var minHeightCandidate = Math.Max(labelBasedHeight, clearanceBasedHeight) + effectiveTopReserve + insetBottom; + + // Same asymmetric-downward-label-shift compensation as the single-port + // minHeight floor above (SvgRenderer.EmitPortLabel draws a Left/Right port's + // ExternalLabel at CentreY + FontSizeBody/2, not centred on the port row) — + // without it, this multi-port candidate can win the Math.Max below yet still + // leave no slack for the bottom-most labeled port's downward-shifted text, + // which then runs past the box's bottom edge exactly as the single-port case + // would without this term. PortDistributor.DistributePorts centers each of + // faceInfo.Total ports within its own equal-height slice of the added band, so + // a flat addition here is only fully absorbed by the bottom-most slice's own + // half-height when there is exactly one slice (Total == 1); for Total >= 2 a + // flat addend is instead divided across every slice's half-height (each slice + // gains only addend / (2 * Total)), silently eroding the intended margin as + // Total grows. Scale by faceInfo.Total so every slice's half-height still gains + // the full assumedFontSize / 2 the single-port case relies on, regardless of + // how many ports share the face. + if (sidePortLabeled) + { + minHeightCandidate += assumedFontSize / 2.0 * faceInfo.Total; + } + minHeight = Math.Max(minHeight, minHeightCandidate); } else @@ -411,9 +447,16 @@ void RecordAnchor(int nodeIndex, Point2D anchor, string? label) // Same equal-area-slice reasoning as the Left/Right branch above, but along // the width axis: each port's slice must be at least as wide as the widest // same-side label so a Top/Bottom label (rendered centred on its port) never - // overlaps its neighbor's label or overflows past the box's own edge. + // overlaps its neighbor's label or overflows past the box's own edge. When no + // label is present, fall back to a per-anchor connector-clearance floor (scaled + // by Total, matching the Left/Right branch's clearanceBasedHeight) so unlabeled + // Top/Bottom ports still get enough absolute room to avoid crowding — a flat, + // unscaled floor here would reproduce the same bunching bug the Left/Right + // branch was fixed for, just rotated 90 degrees. var maxLabelWidth = Math.Max(faceInfo.MaxLabelWidth, sidePortMaxLabelWidth); - var minWidthCandidate = Math.Max(maxLabelWidth * faceInfo.Total, 2.0 * LayeredLayoutMetrics.ConnectorClearance); + var labelBasedWidth = anyLabeled ? maxLabelWidth * faceInfo.Total : 0.0; + var clearanceBasedWidth = 2.0 * LayeredLayoutMetrics.ConnectorClearance * faceInfo.Total; + var minWidthCandidate = Math.Max(labelBasedWidth, clearanceBasedWidth); minWidth = Math.Max(minWidth, minWidthCandidate); } } @@ -698,8 +741,14 @@ double SideWidth(PortSide side) // A renderer draws a top/bottom port's label roughly (PortLabelClearance + 1.5 x // assumedFontSize) away from the box edge, regardless of the inset value; the box title // starts immediately after the inset, so the inset itself must be at least that deep (with - // margin) for a box that has both a title and a top/bottom port to avoid overlap. - var titleClearance = (2.0 * assumedFontSize) + (2.0 * PortLabelClearance); + // margin) for a box that has both a title and a top/bottom port to avoid overlap. A + // boundary port (one with both an InternalLabel and an ExternalLabel) additionally offsets + // its InternalLabel by NotationMetrics.EndMarkerLength beyond a plain port's offset (see + // SvgRenderer.EmitPortLabel's remarks), pushing the label that much further toward the + // title; this function only sees the aggregated per-side label text (not whether any of + // it came from a boundary port), so the clearance is unconditionally widened by that same + // amount to safely cover the worst case. + var titleClearance = (2.0 * assumedFontSize) + (2.0 * PortLabelClearance) + NotationMetrics.EndMarkerLength; if (top > 0.0) { top = Math.Max(top, titleClearance); @@ -816,3 +865,5 @@ private static double ResolveNodeSpacing(LayoutGraph graph, LayoutOptions option _ => LayoutDirection.Right, }; } + + diff --git a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs index 7f4c9fe..671ec24 100644 --- a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs +++ b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs @@ -1301,7 +1301,11 @@ private static void RenderPort(SKCanvas canvas, LayoutPort port, RenderOptions o /// Draws one port label offset from the port centre using the interior-side formula for /// (the port's own side for an inward label, the opposite side for an /// outward one), so an inward and an outward label on one boundary port sit symmetrically about the - /// port centre. + /// port centre. A boundary port (one carrying both an and an + /// ) reserves extra clearance beyond the plain port-glyph + /// offset — see SvgRenderer.EmitPortLabel's matching remarks for why: its external label is + /// deliberately drawn on the same outward face its external approach edge may terminate with an + /// end-marker glyph, which would otherwise visually collide with the label text. /// private static void DrawPortLabel(SKCanvas canvas, LayoutPort port, string text, PortSide offsetSide, RenderOptions options) { @@ -1310,7 +1314,8 @@ private static void DrawPortLabel(SKCanvas canvas, LayoutPort port, string text, var strokeColor = SKColor.Parse(theme.StrokeColor); // Offset the label far enough from the port square so it does not overlap. - var offset = NotationMetrics.PortHalfSize + theme.LabelPadding; + var offset = NotationMetrics.PortHalfSize + theme.LabelPadding + + (port.InternalLabel != null ? NotationMetrics.EndMarkerLength : 0.0); var (labelX, labelY, align) = offsetSide switch { PortSide.Top => (port.CentreX, port.CentreY + offset + theme.FontSizeBody, SKTextAlign.Center), @@ -1323,7 +1328,17 @@ private static void DrawPortLabel(SKCanvas canvas, LayoutPort port, string text, using var font = CreateFont((float)theme.FontSizeBody * scale, bold: false, italic: false); var maxLabelWidth = (float)(port.MaxLabelWidth * scale); font.Size = FitFontSize(font, text, maxLabelWidth, font.Size); - canvas.DrawText(text, (float)(labelX * scale), (float)(labelY * scale), align, font, textPaint); + + // labelY is a *center* Y, matching SvgRenderer.EmitPortLabel's dominant-baseline="middle" + // semantics (the same offset formula is shared by both renderers). SKCanvas.DrawText instead + // takes a baseline Y, so drawing at labelY directly would float the glyph body above that + // center (an ascent-heavy font renders mostly above its baseline) instead of straddling it — + // exactly the PNG/SVG divergence visible when comparing a port label against its connecting + // line. Recover the equivalent baseline from the font's own ascent/descent metrics so both + // renderers place the glyph body identically relative to labelY. + var metrics = font.Metrics; + var baselineY = (float)(labelY * scale) - ((metrics.Ascent + metrics.Descent) / 2.0f); + canvas.DrawText(text, (float)(labelX * scale), baselineY, align, font, textPaint); } /// diff --git a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs index 62800bc..06b628c 100644 --- a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs +++ b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs @@ -1014,11 +1014,21 @@ private static void RenderPort(StringBuilder sb, LayoutPort port, Theme theme, d /// Emits one port <text> label offset from the port centre toward the interior of the /// box on (which equals the port's own side for an inward label and the /// opposite side for an outward one), using the same offset formula for both so an inward and an - /// outward label on one boundary port sit symmetrically about the port centre. + /// outward label on one boundary port sit symmetrically about the port centre. A boundary port (one + /// carrying both an and an ) + /// reserves extra clearance beyond the plain port-glyph offset: unlike a plain port's single label — + /// always drawn on the side away from wherever its own connecting edge's arrowhead could + /// land — a boundary port's external label is deliberately drawn on the outward face, the + /// same side its external approach edge may terminate with an end-marker glyph + /// ( long). Without this extra clearance the label text + /// and that marker visually collide (see the "command"/"dispatch" gallery regression). Applying the + /// same widened offset to both the internal and external label keeps them symmetric about the port + /// centre even though only the external side is ever actually at risk in practice. /// private static void EmitPortLabel(StringBuilder sb, LayoutPort port, string text, PortSide offsetSide, Theme theme, double scale) { - var offset = NotationMetrics.PortHalfSize + theme.LabelPadding; + var offset = NotationMetrics.PortHalfSize + theme.LabelPadding + + (port.InternalLabel != null ? NotationMetrics.EndMarkerLength : 0.0); var (labelX, labelY, anchor) = offsetSide switch { PortSide.Top => (port.CentreX, port.CentreY + offset + theme.FontSizeBody, TextAnchorMiddle), diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs index 866d2ce..c447302 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs @@ -53,6 +53,7 @@ internal static class GalleryCatalog public const string PortsShowcaseVerticalSvg = "ports-showcase-vertical.svg"; public const string PortsShowcaseMultiConnectorHorizontalSvg = "ports-showcase-multi-connector-horizontal.svg"; public const string PortsShowcaseMultiConnectorVerticalSvg = "ports-showcase-multi-connector-vertical.svg"; + public const string PortsShowcaseUnlabeledFanOutSvg = "ports-showcase-unlabeled-fan-out.svg"; public const string BoundaryPortsShowcaseHorizontalSvg = "boundary-ports-showcase-horizontal.svg"; public const string BoundaryPortsShowcaseVerticalSvg = "boundary-ports-showcase-vertical.svg"; public const string BoundaryPortsShowcaseDeepChainSvg = "boundary-ports-showcase-deep-chain.svg"; @@ -60,6 +61,7 @@ internal static class GalleryCatalog public const string PortsShowcaseVerticalPng = "ports-showcase-vertical.png"; public const string PortsShowcaseMultiConnectorHorizontalPng = "ports-showcase-multi-connector-horizontal.png"; public const string PortsShowcaseMultiConnectorVerticalPng = "ports-showcase-multi-connector-vertical.png"; + public const string PortsShowcaseUnlabeledFanOutPng = "ports-showcase-unlabeled-fan-out.png"; public const string ParallelEdgesMergedPng = "parallel-edges-merged.png"; public const string ParallelEdgesPreservedPng = "parallel-edges-preserved.png"; public const string ParallelEdgesPreservedVerticalPng = "parallel-edges-preserved-vertical.png"; @@ -241,6 +243,12 @@ internal static class GalleryCatalog "The companion top/bottom case: two ports per face spread horizontally instead of " + "vertically, proving the same crowding and title-collision protection when " + "PortDistributor works along the cross axis of a downward flow."), + new GalleryImage( + PortsShowcaseUnlabeledFanOutSvg, + "A titled hub node with several unlabeled ports fanning out to five other boxes", + "A face with several ports carrying no label or text at all still grows the box " + + "tall enough to keep them from bunching together — the growth floor applies " + + "unconditionally to any face with two or more anchors, not only labelled ones."), ]), new GallerySection( "Boundary and delegation ports", diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs index a314162..c9dc4d5 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs @@ -566,6 +566,44 @@ public static LayoutGraph PortsShowcaseMultiConnectorVertical() return graph; } + /// + /// A titled hub node with several unlabeled ports fanning out to unevenly-spaced + /// targets — regression coverage (and visual evidence) for the growth-floor fix ensuring a face + /// with 2+ anchors still reserves adequate per-port clearance even when none of them carry any + /// text at all. Mirrors the externally reported "Motherboard" bug: a box with a pile of plain, + /// unlabeled connectors used to never grow past its caller-supplied size to give them room, + /// visually bunching several connector exit points close together near the face's centre. + /// + /// A six-node graph whose central hub has five unlabeled outgoing ports. + public static LayoutGraph PortsShowcaseUnlabeledFanOut() + { + var graph = new LayoutGraph(); + + var board = graph.AddNode("board", 140, 50); + board.Label = "board : Motherboard"; + + var cpu = AddLabelled(graph, "cpu", "cpu : Cpu"); + var memory = AddLabelled(graph, "memory", "memory : Ram"); + var storage = AddLabelled(graph, "storage", "storage : Ssd"); + var network = AddLabelled(graph, "network", "network : NetworkCard"); + var graphics = AddLabelled(graph, "graphics", "graphics : Gpu"); + + var cpuPort = board.Ports.AddPort("cpu-link"); + Connect(graph, "board-cpu", cpuPort, cpu, null); + Connect(graph, "cpu-memory", cpu, memory, null); + + var storagePort = board.Ports.AddPort("storage-link"); + Connect(graph, "board-storage", storagePort, storage, null); + + var networkPort = board.Ports.AddPort("network-link"); + Connect(graph, "board-network", networkPort, network, null); + + var graphicsPort = board.Ports.AddPort("graphics-link"); + Connect(graph, "board-graphics", graphicsPort, graphics, null); + + return graph; + } + /// /// A sibling node joined to a container's boundary (delegation) port, which in turn /// delegates inward to two nested children — the canonical boundary-port diagram, laid out diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs index 7f72c3c..7f65915 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryShowcaseTests.cs @@ -336,6 +336,20 @@ public void Gallery_PortsShowcaseMultiConnectorVertical_RendersSvg() Themes.Dark); } + /// + /// Renders the unlabeled-port-fan-out showcase to SVG, proving a titled hub node with several + /// unlabeled ports on one face still grows tall enough to spread them apart instead of bunching + /// them together — regression coverage for the reported "Motherboard" clustering bug. + /// + [Fact] + public void Gallery_PortsShowcaseUnlabeledFanOut_RendersSvg() + { + GalleryWriter.Svg( + GalleryCatalog.PortsShowcaseUnlabeledFanOutSvg, + GalleryDiagrams.PortsShowcaseUnlabeledFanOut(), + Themes.Dark); + } + /// /// Renders the horizontal boundary-ports showcase to SVG, proving a container's boundary /// (delegation) port emits one shared anchor carrying both an outward @@ -433,6 +447,19 @@ public void Gallery_PortsShowcaseMultiConnectorVertical_RendersPng() Themes.Dark); } + /// + /// Renders the unlabeled-port-fan-out showcase to PNG, proving the raster path also grows a + /// titled hub with several unlabeled ports tall enough to spread them apart. + /// + [Fact] + public void Gallery_PortsShowcaseUnlabeledFanOut_RendersPng() + { + GalleryWriter.Png( + GalleryCatalog.PortsShowcaseUnlabeledFanOutPng, + GalleryDiagrams.PortsShowcaseUnlabeledFanOut(), + Themes.Dark); + } + /// /// Renders the parallel-edges-merged diagram to PNG, proving the raster path handles merged /// parallel connectors. diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs index 3829560..00994e2 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs @@ -857,6 +857,193 @@ public void Apply_NodeWithLongLeftAndRightPortLabels_BothSidesAvoidSqueeze() $"Expected right MaxLabelWidth >= {measuredRightWidth}, was {outLayoutPort.MaxLabelWidth}."); } + /// + /// Proves that a titled node with two labeled ports stacked on its left (or right) face reserves + /// enough bottom clearance for the bottom-most port's downward-shifted label + /// (SvgRenderer.EmitPortLabel and the Skia renderer's equivalent draw a Left/Right port's + /// at CentreY + FontSizeBody / 2, not centred + /// on the port row). Regression coverage for the gallery bug where "Hub"'s bottom-most labeled + /// port ("heartbeat"/"diag") rendered its label past the box's own bottom border: the growth + /// floor's per-port compensation for this downward shift was a flat addend divided across every + /// port's own equal-height slice (PortDistributor.DistributePorts centers each port within its + /// own equal-height slice of the face), so it was fully absorbed only when a face carried + /// exactly one port — a face with 2+ labeled ports lost most of that compensation to the other + /// slices. Asserts the bottom-most port's own margin to the box's bottom edge covers both the + /// downward shift itself and the shifted label's own half-height (2 * FontSizeBody / 2 = + /// assumedFontSize), matching the margin the single-port case already achieves comfortably. + /// + [Fact] + public void Apply_TitledNodeWithTwoLeftPortLabels_BottomPortRetainsDownwardShiftMargin() + { + const double assumedFontSize = 12.0; // CoreOptions.AssumedFontSize default + var graph = new LayoutGraph(); + var sourceA = graph.AddNode("sourceA", 80, 30); + var sourceB = graph.AddNode("sourceB", 80, 30); + var target = graph.AddNode("target", 80, 30); + var hub = graph.AddNode("hub", 200, 60); + hub.Label = "Hub"; + var topLeftPort = hub.Ports.AddPort("telemetry"); + topLeftPort.ExternalLabel = "telemetry"; + var bottomLeftPort = hub.Ports.AddPort("heartbeat"); + bottomLeftPort.ExternalLabel = "heartbeat"; + graph.AddEdge("e1", sourceA, topLeftPort); + graph.AddEdge("e2", sourceB, bottomLeftPort); + graph.AddEdge("e3", hub, target); + + var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + var hubBox = tree.Nodes.OfType().Single(b => b.Label == "Hub"); + var ports = tree.Nodes.OfType() + .Where(p => p.Side == PortSide.Left) + .OrderBy(p => p.CentreY) + .ToList(); + Assert.Equal(2, ports.Count); + + var boxBottom = hubBox.Y + hubBox.Height; + var bottomPortMargin = boxBottom - ports[^1].CentreY; + Assert.True( + bottomPortMargin >= assumedFontSize, + $"Expected bottom-most left port's margin to box bottom ({bottomPortMargin}) to be at least " + + $"{assumedFontSize} — enough room for SvgRenderer.EmitPortLabel's own " + + "CentreY + FontSizeBody / 2 downward shift plus the shifted label's own half-height."); + } + + /// + /// Proves that a node with several unlabeled outgoing edges fanning out from one face + /// still grows tall enough to give each connector a minimum clearance slice, even though none + /// of the edges or ports carry any label. Regression coverage for the reported "Motherboard" + /// bug: a face with 2+ anchors used to skip the entire growth floor whenever + /// !faceInfo.AnyLabeled && !sidePortLabeled, so a box with a pile of unlabeled + /// ports never grew to give them room — PortDistributor.DistributePorts still + /// spread the anchors proportionally, but within whatever (possibly tiny) caller-supplied + /// height happened to already exist, visually bunching several connector exit points close + /// together. Asserts the box grows past its small caller-supplied height to at least + /// 2 * ConnectorClearance per anchor (mirroring the labeled-face floor's baseline term), + /// and that adjacent anchor spacing on the fanned-out face is at least that same clearance. + /// + [Fact] + public void Apply_NodeWithSeveralUnlabeledOutgoingEdges_TooSmall_AutoGrowsHeight() + { + const double connectorClearance = 10.0; // LayeredLayoutMetrics.ConnectorClearance + var graph = new LayoutGraph(); + var board = graph.AddNode("board", 140, 20); + var cpu = graph.AddNode("cpu", 80, 30); + cpu.Label = "cpu"; + var storage = graph.AddNode("storage", 80, 30); + storage.Label = "storage"; + var network = graph.AddNode("network", 80, 30); + network.Label = "network"; + var graphics = graph.AddNode("graphics", 80, 30); + graphics.Label = "graphics"; + graph.AddEdge("e1", board, cpu); + graph.AddEdge("e2", board, storage); + graph.AddEdge("e3", board, network); + graph.AddEdge("e4", board, graphics); + + var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + var boardBox = tree.Nodes.OfType().Single(b => b.Label == null); + Assert.True( + boardBox.Height >= 4 * connectorClearance, + $"Expected board height to grow to at least {4 * connectorClearance} for its 4 unlabeled " + + $"outgoing anchors, was {boardBox.Height}."); + } + + /// + /// Top/Bottom-face mirror of : + /// proves the growth-floor fix applies along the width axis too. The Left/Right branch's + /// unlabeled-face floor was fixed to scale by faceInfo.Total + /// (clearanceBasedHeight), but the Top/Bottom branch's width floor was initially left + /// using the old flat 2 * ConnectorClearance term regardless of port count — reproducing + /// the exact same bunching bug rotated 90 degrees for several unlabeled ports fanning out from + /// a node's bottom face (forced here via ). Asserts the + /// board grows wide enough to give each of 4 unlabeled anchors at least + /// 2 * ConnectorClearance of its own width. + /// + [Fact] + public void Apply_NodeWithSeveralUnlabeledOutgoingEdges_TooNarrow_AutoGrowsWidth() + { + const double connectorClearance = 10.0; // LayeredLayoutMetrics.ConnectorClearance + var graph = new LayoutGraph(); + graph.Set(CoreOptions.Direction, LayoutFlowDirection.Down); + var board = graph.AddNode("board", 20, 20); + var cpu = graph.AddNode("cpu", 30, 30); + cpu.Label = "cpu"; + var storage = graph.AddNode("storage", 30, 30); + storage.Label = "storage"; + var network = graph.AddNode("network", 30, 30); + network.Label = "network"; + var graphics = graph.AddNode("graphics", 30, 30); + graphics.Label = "graphics"; + graph.AddEdge("e1", board, cpu); + graph.AddEdge("e2", board, storage); + graph.AddEdge("e3", board, network); + graph.AddEdge("e4", board, graphics); + + var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + var boardBox = tree.Nodes.OfType().Single(b => b.Label == null); + Assert.True( + boardBox.Width >= 4 * connectorClearance, + $"Expected board width to grow to at least {4 * connectorClearance} for its 4 unlabeled " + + $"outgoing anchors on the Bottom face, was {boardBox.Width}."); + } + + /// + /// Proves that a titled node with several unlabeled named ports on one face still + /// excludes the title band from port placement — even though nothing rendered at those anchors + /// could ever collide with the title text, PortDistributor.TitleReserveFor keys + /// its exclusion off LayerNode.TitleReserveTop, which is populated whenever the + /// node — regardless of whether any individual port + /// carries an — so a titled box's unlabeled ports + /// are placed no differently than labeled ones with respect to the title. Combines this with + /// the growth-floor regression above: uses 6 unlabeled ports (rather than 4) so the + /// 2 * ConnectorClearance-per-port floor (120) clearly exceeds the height a titled box + /// would already reach from its title band alone — otherwise the growth assertion would pass + /// even without the fix, since a title reserves height on its own regardless of port count. + /// + [Fact] + public void Apply_TitledNodeWithSeveralUnlabeledPorts_PortsAvoidTitleBandAndAutoGrowsHeight() + { + const double connectorClearance = 10.0; // LayeredLayoutMetrics.ConnectorClearance + const int portCount = 6; + var graph = new LayoutGraph(); + var board = graph.AddNode("board", 140, 20); + board.Label = "board : Motherboard"; + + var ports = new List(); + for (var k = 0; k < portCount; k++) + { + var target = graph.AddNode($"target{k}", 80, 30); + var port = board.Ports.AddPort($"p{k}"); // no ExternalLabel set — carries no text at all + graph.AddEdge($"e{k}", port, target); + ports.Add(port); + } + + var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); + + var boardBox = tree.Nodes.OfType().Single(b => b.Label == "board : Motherboard"); + Assert.True( + boardBox.Height >= portCount * connectorClearance, + $"Expected board height to grow to at least {portCount * connectorClearance} for its " + + $"{portCount} unlabeled ports, was {boardBox.Height}."); + + var layoutPorts = tree.Nodes.OfType().Where(p => p.Side == PortSide.Right).ToList(); + Assert.Equal(portCount, layoutPorts.Count); + + // The title band sits at the very top of the box; every port must be placed strictly below it + // (using a generous lower bound of one title-sized band, matching ResolveTitleReserveTop's + // "roughly double assumedFontSize" comment) even though none of them carry any text. + const double assumedFontSize = 12.0; // CoreOptions.AssumedFontSize default + var titleBandBottom = boardBox.Y + (2.0 * assumedFontSize); + Assert.All( + layoutPorts, + p => Assert.True( + p.CentreY > titleBandBottom, + $"Expected port at CentreY {p.CentreY} to sit below the title band (bottom " + + $"{titleBandBottom}), even though it carries no label.")); + } + /// /// Proves that a caller-supplied node size already large enough to fit its title and port /// insets is left completely unchanged — the auto-grow floor never shrinks a node, and it does From 30b3fd24ac3505fdc9b7c3d4b6ff69bd9b95a4d1 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 13 Jul 2026 20:37:54 -0400 Subject: [PATCH 2/4] Address PR review feedback: tighten test thresholds, fix gallery docs - Test assertions for the unlabeled-port growth-floor fix asserted a weaker threshold (Total * ConnectorClearance) than the algorithm's actual floor (2 * ConnectorClearance * Total); tightened all three affected tests to match. - PortsShowcaseUnlabeledFanOut's doc comment, gallery caption, and README alt text said the hub has 5 outgoing ports; it actually has 4 (memory is reached via cpu, not directly from the hub). Corrected the wording in GalleryDiagrams.cs and GalleryCatalog.cs; docs/gallery/README.md regenerated via gallery.ps1 to pick up the corrected caption. - Fixed US English spelling (labelled -> labeled) in the new gallery caption text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/gallery/README.md | 4 ++-- .../GalleryCatalog.cs | 4 ++-- .../GalleryDiagrams.cs | 3 ++- .../LayeredLayoutAlgorithmTests.cs | 21 +++++++++++-------- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/docs/gallery/README.md b/docs/gallery/README.md index c7cdcf5..27680f7 100644 --- a/docs/gallery/README.md +++ b/docs/gallery/README.md @@ -153,10 +153,10 @@ either side. The companion top/bottom case: two ports per face spread horizontally instead of vertically, proving the same crowding and title-collision protection when PortDistributor works along the cross axis of a downward flow. -![A titled hub node with several unlabeled ports fanning out to five other boxes](ports-showcase-unlabeled-fan-out.svg) +![A titled hub node with several unlabeled ports fanning out to four other boxes](ports-showcase-unlabeled-fan-out.svg) A face with several ports carrying no label or text at all still grows the box tall enough to keep them from bunching -together — the growth floor applies unconditionally to any face with two or more anchors, not only labelled ones. +together — the growth floor applies unconditionally to any face with two or more anchors, not only labeled ones. ## Boundary and delegation ports diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs index c447302..29b2ecb 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryCatalog.cs @@ -245,10 +245,10 @@ internal static class GalleryCatalog + "PortDistributor works along the cross axis of a downward flow."), new GalleryImage( PortsShowcaseUnlabeledFanOutSvg, - "A titled hub node with several unlabeled ports fanning out to five other boxes", + "A titled hub node with several unlabeled ports fanning out to four other boxes", "A face with several ports carrying no label or text at all still grows the box " + "tall enough to keep them from bunching together — the growth floor applies " - + "unconditionally to any face with two or more anchors, not only labelled ones."), + + "unconditionally to any face with two or more anchors, not only labeled ones."), ]), new GallerySection( "Boundary and delegation ports", diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs index c9dc4d5..98789e7 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs @@ -574,7 +574,8 @@ public static LayoutGraph PortsShowcaseMultiConnectorVertical() /// unlabeled connectors used to never grow past its caller-supplied size to give them room, /// visually bunching several connector exit points close together near the face's centre. /// - /// A six-node graph whose central hub has five unlabeled outgoing ports. + /// A six-node graph whose central hub has four unlabeled outgoing ports (a fifth node, + /// memory, hangs off cpu rather than the hub directly). public static LayoutGraph PortsShowcaseUnlabeledFanOut() { var graph = new LayoutGraph(); diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs index 00994e2..804182b 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs @@ -943,10 +943,11 @@ public void Apply_NodeWithSeveralUnlabeledOutgoingEdges_TooSmall_AutoGrowsHeight var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); var boardBox = tree.Nodes.OfType().Single(b => b.Label == null); + var expectedFloor = 2.0 * connectorClearance * 4; Assert.True( - boardBox.Height >= 4 * connectorClearance, - $"Expected board height to grow to at least {4 * connectorClearance} for its 4 unlabeled " - + $"outgoing anchors, was {boardBox.Height}."); + boardBox.Height >= expectedFloor, + $"Expected board height to grow to at least {expectedFloor} (2 * ConnectorClearance * " + + $"Total) for its 4 unlabeled outgoing anchors, was {boardBox.Height}."); } /// @@ -983,10 +984,11 @@ public void Apply_NodeWithSeveralUnlabeledOutgoingEdges_TooNarrow_AutoGrowsWidth var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); var boardBox = tree.Nodes.OfType().Single(b => b.Label == null); + var expectedFloor = 2.0 * connectorClearance * 4; Assert.True( - boardBox.Width >= 4 * connectorClearance, - $"Expected board width to grow to at least {4 * connectorClearance} for its 4 unlabeled " - + $"outgoing anchors on the Bottom face, was {boardBox.Width}."); + boardBox.Width >= expectedFloor, + $"Expected board width to grow to at least {expectedFloor} (2 * ConnectorClearance * " + + $"Total) for its 4 unlabeled outgoing anchors on the Bottom face, was {boardBox.Width}."); } /// @@ -1023,10 +1025,11 @@ public void Apply_TitledNodeWithSeveralUnlabeledPorts_PortsAvoidTitleBandAndAuto var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); var boardBox = tree.Nodes.OfType().Single(b => b.Label == "board : Motherboard"); + var expectedFloor = 2.0 * connectorClearance * portCount; Assert.True( - boardBox.Height >= portCount * connectorClearance, - $"Expected board height to grow to at least {portCount * connectorClearance} for its " - + $"{portCount} unlabeled ports, was {boardBox.Height}."); + boardBox.Height >= expectedFloor, + $"Expected board height to grow to at least {expectedFloor} (2 * ConnectorClearance * " + + $"Total) for its {portCount} unlabeled ports, was {boardBox.Height}."); var layoutPorts = tree.Nodes.OfType().Where(p => p.Side == PortSide.Right).ToList(); Assert.Equal(portCount, layoutPorts.Count); From 2e75fd6592708deda54719320018225c91775cb8 Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 13 Jul 2026 21:07:51 -0400 Subject: [PATCH 3/4] Address second round of PR review feedback - SvgRenderer/SkiaRasterRenderer: gate the EndMarkerLength offset on true boundary ports (both InternalLabel and ExternalLabel present), not just InternalLabel alone - LayeredLayoutAlgorithm: correct comment wording (height, not half-height, is preserved per slice) and trim trailing blank lines at EOF - Tests: reference LayeredLayoutMetrics.ConnectorClearance directly instead of duplicating the literal constant; add real anchor-spacing assertions to the two growth-floor tests; remove unused 'ports' list in the titled unlabeled-ports test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LayeredLayoutAlgorithm.cs | 14 +++--- .../SkiaRasterRenderer.cs | 2 +- .../SvgRenderer.cs | 2 +- .../LayeredLayoutAlgorithmTests.cs | 44 +++++++++++++++---- 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs index e105a1e..bd4d371 100644 --- a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs @@ -413,12 +413,12 @@ void RecordAnchor(int nodeIndex, Point2D anchor, string? label) // would without this term. PortDistributor.DistributePorts centers each of // faceInfo.Total ports within its own equal-height slice of the added band, so // a flat addition here is only fully absorbed by the bottom-most slice's own - // half-height when there is exactly one slice (Total == 1); for Total >= 2 a - // flat addend is instead divided across every slice's half-height (each slice - // gains only addend / (2 * Total)), silently eroding the intended margin as - // Total grows. Scale by faceInfo.Total so every slice's half-height still gains - // the full assumedFontSize / 2 the single-port case relies on, regardless of - // how many ports share the face. + // height when there is exactly one slice (Total == 1); for Total >= 2 a flat + // addend is instead divided across every slice's height (each slice gains only + // addend / Total), silently eroding the intended margin as Total grows. Scale + // by faceInfo.Total so every slice's height still gains the full + // assumedFontSize / 2 the single-port case relies on, regardless of how many + // ports share the face. if (sidePortLabeled) { minHeightCandidate += assumedFontSize / 2.0 * faceInfo.Total; @@ -865,5 +865,3 @@ private static double ResolveNodeSpacing(LayoutGraph graph, LayoutOptions option _ => LayoutDirection.Right, }; } - - diff --git a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs index 671ec24..a599585 100644 --- a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs +++ b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs @@ -1315,7 +1315,7 @@ private static void DrawPortLabel(SKCanvas canvas, LayoutPort port, string text, // Offset the label far enough from the port square so it does not overlap. var offset = NotationMetrics.PortHalfSize + theme.LabelPadding - + (port.InternalLabel != null ? NotationMetrics.EndMarkerLength : 0.0); + + (port.InternalLabel != null && port.ExternalLabel != null ? NotationMetrics.EndMarkerLength : 0.0); var (labelX, labelY, align) = offsetSide switch { PortSide.Top => (port.CentreX, port.CentreY + offset + theme.FontSizeBody, SKTextAlign.Center), diff --git a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs index 06b628c..25c030e 100644 --- a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs +++ b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs @@ -1028,7 +1028,7 @@ private static void RenderPort(StringBuilder sb, LayoutPort port, Theme theme, d private static void EmitPortLabel(StringBuilder sb, LayoutPort port, string text, PortSide offsetSide, Theme theme, double scale) { var offset = NotationMetrics.PortHalfSize + theme.LabelPadding - + (port.InternalLabel != null ? NotationMetrics.EndMarkerLength : 0.0); + + (port.InternalLabel != null && port.ExternalLabel != null ? NotationMetrics.EndMarkerLength : 0.0); var (labelX, labelY, anchor) = offsetSide switch { PortSide.Top => (port.CentreX, port.CentreY + offset + theme.FontSizeBody, TextAnchorMiddle), diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs index 804182b..dcc0ae7 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs @@ -4,6 +4,7 @@ using DemaConsulting.Rendering; using DemaConsulting.Rendering.Abstractions; +using DemaConsulting.Rendering.Layout.Engine.Layered; namespace DemaConsulting.Rendering.Layout.Tests; @@ -924,7 +925,6 @@ public void Apply_TitledNodeWithTwoLeftPortLabels_BottomPortRetainsDownwardShift [Fact] public void Apply_NodeWithSeveralUnlabeledOutgoingEdges_TooSmall_AutoGrowsHeight() { - const double connectorClearance = 10.0; // LayeredLayoutMetrics.ConnectorClearance var graph = new LayoutGraph(); var board = graph.AddNode("board", 140, 20); var cpu = graph.AddNode("cpu", 80, 30); @@ -943,11 +943,27 @@ public void Apply_NodeWithSeveralUnlabeledOutgoingEdges_TooSmall_AutoGrowsHeight var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); var boardBox = tree.Nodes.OfType().Single(b => b.Label == null); - var expectedFloor = 2.0 * connectorClearance * 4; + var expectedFloor = 2.0 * LayeredLayoutMetrics.ConnectorClearance * 4; Assert.True( boardBox.Height >= expectedFloor, $"Expected board height to grow to at least {expectedFloor} (2 * ConnectorClearance * " + $"Total) for its 4 unlabeled outgoing anchors, was {boardBox.Height}."); + + // Assert: the fix's user-visible goal — the 4 anchors on the board's own face are actually + // spread apart by at least one ConnectorClearance each, not merely bunched within a taller box. + // (All 4 lines in this graph originate from board, so no extra source-filtering is needed.) + var anchorYs = tree.Nodes.OfType() + .Select(l => l.Waypoints[0].Y) + .OrderBy(y => y) + .ToList(); + Assert.Equal(4, anchorYs.Count); + for (var i = 1; i < anchorYs.Count; i++) + { + Assert.True( + anchorYs[i] - anchorYs[i - 1] >= LayeredLayoutMetrics.ConnectorClearance - 1e-6, + $"Expected adjacent unlabeled anchors to be spaced at least {LayeredLayoutMetrics.ConnectorClearance}px " + + $"apart, was {anchorYs[i] - anchorYs[i - 1]}."); + } } /// @@ -964,7 +980,6 @@ public void Apply_NodeWithSeveralUnlabeledOutgoingEdges_TooSmall_AutoGrowsHeight [Fact] public void Apply_NodeWithSeveralUnlabeledOutgoingEdges_TooNarrow_AutoGrowsWidth() { - const double connectorClearance = 10.0; // LayeredLayoutMetrics.ConnectorClearance var graph = new LayoutGraph(); graph.Set(CoreOptions.Direction, LayoutFlowDirection.Down); var board = graph.AddNode("board", 20, 20); @@ -984,11 +999,27 @@ public void Apply_NodeWithSeveralUnlabeledOutgoingEdges_TooNarrow_AutoGrowsWidth var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); var boardBox = tree.Nodes.OfType().Single(b => b.Label == null); - var expectedFloor = 2.0 * connectorClearance * 4; + var expectedFloor = 2.0 * LayeredLayoutMetrics.ConnectorClearance * 4; Assert.True( boardBox.Width >= expectedFloor, $"Expected board width to grow to at least {expectedFloor} (2 * ConnectorClearance * " + $"Total) for its 4 unlabeled outgoing anchors on the Bottom face, was {boardBox.Width}."); + + // Assert: the fix's user-visible goal — the 4 anchors on the board's own face are actually + // spread apart by at least one ConnectorClearance each, not merely bunched within a wider box. + // (All 4 lines in this graph originate from board, so no extra source-filtering is needed.) + var anchorXs = tree.Nodes.OfType() + .Select(l => l.Waypoints[0].X) + .OrderBy(x => x) + .ToList(); + Assert.Equal(4, anchorXs.Count); + for (var i = 1; i < anchorXs.Count; i++) + { + Assert.True( + anchorXs[i] - anchorXs[i - 1] >= LayeredLayoutMetrics.ConnectorClearance - 1e-6, + $"Expected adjacent unlabeled anchors to be spaced at least {LayeredLayoutMetrics.ConnectorClearance}px " + + $"apart, was {anchorXs[i] - anchorXs[i - 1]}."); + } } /// @@ -1007,25 +1038,22 @@ public void Apply_NodeWithSeveralUnlabeledOutgoingEdges_TooNarrow_AutoGrowsWidth [Fact] public void Apply_TitledNodeWithSeveralUnlabeledPorts_PortsAvoidTitleBandAndAutoGrowsHeight() { - const double connectorClearance = 10.0; // LayeredLayoutMetrics.ConnectorClearance const int portCount = 6; var graph = new LayoutGraph(); var board = graph.AddNode("board", 140, 20); board.Label = "board : Motherboard"; - var ports = new List(); for (var k = 0; k < portCount; k++) { var target = graph.AddNode($"target{k}", 80, 30); var port = board.Ports.AddPort($"p{k}"); // no ExternalLabel set — carries no text at all graph.AddEdge($"e{k}", port, target); - ports.Add(port); } var tree = new LayeredLayoutAlgorithm().Apply(graph, new LayoutOptions()); var boardBox = tree.Nodes.OfType().Single(b => b.Label == "board : Motherboard"); - var expectedFloor = 2.0 * connectorClearance * portCount; + var expectedFloor = 2.0 * LayeredLayoutMetrics.ConnectorClearance * portCount; Assert.True( boardBox.Height >= expectedFloor, $"Expected board height to grow to at least {expectedFloor} (2 * ConnectorClearance * " From fa36e884f1197ef2c1af16aec5d5e08566e7e3da Mon Sep 17 00:00:00 2001 From: Malcolm Nixon Date: Mon, 13 Jul 2026 22:06:26 -0400 Subject: [PATCH 4/4] Use US English spelling in doc comments (centre -> center, centred -> centered) Fixes flagged in PR review: LayeredLayoutAlgorithm.cs, SvgRenderer.cs, SkiaRasterRenderer.cs, LayeredLayoutAlgorithmTests.cs, and GalleryDiagrams.cs comments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../LayeredLayoutAlgorithm.cs | 4 ++-- src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs | 4 ++-- src/DemaConsulting.Rendering.Svg/SvgRenderer.cs | 6 +++--- test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs | 2 +- .../LayeredLayoutAlgorithmTests.cs | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs index bd4d371..7b6ea31 100644 --- a/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs +++ b/src/DemaConsulting.Rendering.Layout/LayeredLayoutAlgorithm.cs @@ -406,7 +406,7 @@ void RecordAnchor(int nodeIndex, Point2D anchor, string? label) // Same asymmetric-downward-label-shift compensation as the single-port // minHeight floor above (SvgRenderer.EmitPortLabel draws a Left/Right port's - // ExternalLabel at CentreY + FontSizeBody/2, not centred on the port row) — + // ExternalLabel at CentreY + FontSizeBody/2, not centered on the port row) — // without it, this multi-port candidate can win the Math.Max below yet still // leave no slack for the bottom-most labeled port's downward-shifted text, // which then runs past the box's bottom edge exactly as the single-port case @@ -446,7 +446,7 @@ void RecordAnchor(int nodeIndex, Point2D anchor, string? label) // Same equal-area-slice reasoning as the Left/Right branch above, but along // the width axis: each port's slice must be at least as wide as the widest - // same-side label so a Top/Bottom label (rendered centred on its port) never + // same-side label so a Top/Bottom label (rendered centered on its port) never // overlaps its neighbor's label or overflows past the box's own edge. When no // label is present, fall back to a per-anchor connector-clearance floor (scaled // by Total, matching the Left/Right branch's clearanceBasedHeight) so unlabeled diff --git a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs index a599585..ac89356 100644 --- a/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs +++ b/src/DemaConsulting.Rendering.Skia/SkiaRasterRenderer.cs @@ -1298,10 +1298,10 @@ private static void RenderPort(SKCanvas canvas, LayoutPort port, RenderOptions o }; /// - /// Draws one port label offset from the port centre using the interior-side formula for + /// Draws one port label offset from the port center using the interior-side formula for /// (the port's own side for an inward label, the opposite side for an /// outward one), so an inward and an outward label on one boundary port sit symmetrically about the - /// port centre. A boundary port (one carrying both an and an + /// port center. A boundary port (one carrying both an and an /// ) reserves extra clearance beyond the plain port-glyph /// offset — see SvgRenderer.EmitPortLabel's matching remarks for why: its external label is /// deliberately drawn on the same outward face its external approach edge may terminate with an diff --git a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs index 25c030e..b438256 100644 --- a/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs +++ b/src/DemaConsulting.Rendering.Svg/SvgRenderer.cs @@ -1011,10 +1011,10 @@ private static void RenderPort(StringBuilder sb, LayoutPort port, Theme theme, d }; /// - /// Emits one port <text> label offset from the port centre toward the interior of the + /// Emits one port <text> label offset from the port center toward the interior of the /// box on (which equals the port's own side for an inward label and the /// opposite side for an outward one), using the same offset formula for both so an inward and an - /// outward label on one boundary port sit symmetrically about the port centre. A boundary port (one + /// outward label on one boundary port sit symmetrically about the port center. A boundary port (one /// carrying both an and an ) /// reserves extra clearance beyond the plain port-glyph offset: unlike a plain port's single label — /// always drawn on the side away from wherever its own connecting edge's arrowhead could @@ -1023,7 +1023,7 @@ private static void RenderPort(StringBuilder sb, LayoutPort port, Theme theme, d /// ( long). Without this extra clearance the label text /// and that marker visually collide (see the "command"/"dispatch" gallery regression). Applying the /// same widened offset to both the internal and external label keeps them symmetric about the port - /// centre even though only the external side is ever actually at risk in practice. + /// center even though only the external side is ever actually at risk in practice. /// private static void EmitPortLabel(StringBuilder sb, LayoutPort port, string text, PortSide offsetSide, Theme theme, double scale) { diff --git a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs index 98789e7..2f58d41 100644 --- a/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs +++ b/test/DemaConsulting.Rendering.Gallery/GalleryDiagrams.cs @@ -572,7 +572,7 @@ public static LayoutGraph PortsShowcaseMultiConnectorVertical() /// with 2+ anchors still reserves adequate per-port clearance even when none of them carry any /// text at all. Mirrors the externally reported "Motherboard" bug: a box with a pile of plain, /// unlabeled connectors used to never grow past its caller-supplied size to give them room, - /// visually bunching several connector exit points close together near the face's centre. + /// visually bunching several connector exit points close together near the face's center. /// /// A six-node graph whose central hub has four unlabeled outgoing ports (a fifth node, /// memory, hangs off cpu rather than the hub directly). diff --git a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs index dcc0ae7..ef934fe 100644 --- a/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs +++ b/test/DemaConsulting.Rendering.Layout.Tests/LayeredLayoutAlgorithmTests.cs @@ -862,7 +862,7 @@ public void Apply_NodeWithLongLeftAndRightPortLabels_BothSidesAvoidSqueeze() /// Proves that a titled node with two labeled ports stacked on its left (or right) face reserves /// enough bottom clearance for the bottom-most port's downward-shifted label /// (SvgRenderer.EmitPortLabel and the Skia renderer's equivalent draw a Left/Right port's - /// at CentreY + FontSizeBody / 2, not centred + /// at CentreY + FontSizeBody / 2, not centered /// on the port row). Regression coverage for the gallery bug where "Hub"'s bottom-most labeled /// port ("heartbeat"/"diag") rendered its label past the box's own bottom border: the growth /// floor's per-port compensation for this downward shift was a flat addend divided across every