Skip to content

Commit 2a09c42

Browse files
xuanruliclaude
andcommitted
fix(lint): reject phantom shallow-curvature ring nodes in connector audit
ringPathBox gated only on Kåsa residual normalized by radius, which a shallow-curvature arc passes by fitting an enormous phantom circle. Add a bbox-vs-radius sanity gate: the fitted diameter must stay within 3x the path's drawn bounding box, so a nearly-straight arc no longer registers as a false ring/hub node (and no longer creates phantom gauge-exclusions). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a773e08 commit 2a09c42

2 files changed

Lines changed: 104 additions & 0 deletions

File tree

‎packages/cli/src/commands/layout-audit.browser.js‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1771,6 +1771,11 @@
17711771
const CONNECTOR_RING_MIN_LEN = 120;
17721772
const CONNECTOR_RING_MIN_RADIUS = 20;
17731773
const CONNECTOR_RING_MAX_RESIDUAL_FRAC = 0.15;
1774+
// Phantom-radius guard: a real ring/hub's fitted diameter tracks its bounding
1775+
// box (full circle 1x, quarter arc ~2x). A shallow-curvature arc fits an
1776+
// enormous circle with a tiny normalized residual, so the diameter runs many×
1777+
// the box — reject beyond this factor.
1778+
const CONNECTOR_RING_MAX_DIAMETER_BBOX_FRAC = 3;
17741779

17751780
function isIndicatorConnector(line, svg) {
17761781
for (let node = line; node && node !== svg.parentElement; node = node.parentElement) {
@@ -1829,6 +1834,18 @@
18291834
if (!fit || fit.radius < CONNECTOR_RING_MIN_RADIUS) return null;
18301835
if (fit.residual > CONNECTOR_RING_MAX_RESIDUAL_FRAC * fit.radius) return null;
18311836
const rect = toRect(path.getBoundingClientRect());
1837+
// Reject the shallow-curvature phantom fit: normalized residual is small at
1838+
// any radius, so a nearly-straight arc masquerades as a huge ring. The
1839+
// fitted diameter must stay within a sane factor of the drawn bounding box.
1840+
const bboxSpan = Math.max(rect.width, rect.height);
1841+
if (bboxSpan <= 0 || fit.radius * 2 > CONNECTOR_RING_MAX_DIAMETER_BBOX_FRAC * bboxSpan) {
1842+
return null;
1843+
}
1844+
// Scoped-known seams (left as-is — narrow and not phantom-radius): a short
1845+
// genuine partial arc under-samples its parent circle's box so a real hub
1846+
// drawn as a sliver can still miss the span gate; and a curved connector
1847+
// that is itself near-circular can be read as its own ring node. Both are
1848+
// rare vs. the shallow-curvature false ring this gate closes.
18321849
const area = rectArea(rect);
18331850
if (area < CONNECTOR_NODE_MIN_DOT_AREA || area >= rectArea(rootRect) * 0.5) return null;
18341851
const fill = getComputedStyle(path).fill;

‎packages/cli/src/commands/layout-audit.browser.test.ts‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -877,6 +877,46 @@ describe("layout-audit.browser coordinate-frame findings", () => {
877877
// "knowledge-overflow" contains conn-family substrings only across word boundaries — no match.
878878
expect(runAudit().filter((issue) => issue.code === "connector_detached")).toEqual([]);
879879
});
880+
881+
it("rejects a shallow-curvature arc as a phantom ring node but keeps a genuine near-circular arc", () => {
882+
document.body.innerHTML = `
883+
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
884+
<svg id="dial-svg">
885+
<path id="genuine-ring" fill="none" d="ring" />
886+
<path id="shallow-arc" fill="none" d="arc" />
887+
</svg>
888+
</div>
889+
`;
890+
// A near-full circle (diameter tracks its bbox) vs a shallow arc on a huge
891+
// circle: the arc's Kåsa residual normalized by its enormous radius is tiny
892+
// (passes the residual gate) yet its fitted diameter is ~10x its drawn box.
893+
const genuine = { cx: 500, cy: 500, radius: 100, startDeg: 0, endDeg: 360 };
894+
const shallow = { cx: 500, cy: 2500, radius: 2000, startDeg: 264, endDeg: 276 };
895+
installGeometry(
896+
{
897+
root: rect({ left: 0, top: 0, width: 1920, height: 1080 }),
898+
"dial-svg": rect({ left: 200, top: 200, width: 700, height: 700 }),
899+
"genuine-ring": arcBBox(genuine),
900+
"shallow-arc": arcBBox(shallow),
901+
},
902+
{
903+
"genuine-ring": { fill: "none" },
904+
"shallow-arc": { fill: "none" },
905+
},
906+
);
907+
installArcSampling("genuine-ring", genuine);
908+
installArcSampling("shallow-arc", shallow);
909+
installAuditScript();
910+
911+
const sample = (
912+
window as unknown as {
913+
__hyperframesConnectorSample: () => { nodes: Array<{ selector: string }> };
914+
}
915+
).__hyperframesConnectorSample();
916+
const nodeSelectors = sample.nodes.map((node) => node.selector);
917+
expect(nodeSelectors).toContain("#genuine-ring");
918+
expect(nodeSelectors).not.toContain("#shallow-arc");
919+
});
880920
});
881921

882922
describe("layout-audit.browser content overlap", () => {
@@ -1979,6 +2019,53 @@ function installConnectorGeometry(translate: CtmTranslate): void {
19792019
}
19802020
}
19812021

2022+
interface ArcSpec {
2023+
cx: number;
2024+
cy: number;
2025+
radius: number;
2026+
startDeg: number;
2027+
endDeg: number;
2028+
}
2029+
2030+
// The point ringPathBox reads at arc-length `length` (local SVG user units).
2031+
function arcPointAt(spec: ArcSpec, length: number, total: number): { x: number; y: number } {
2032+
const frac = total === 0 ? 0 : length / total;
2033+
const rad = ((spec.startDeg + (spec.endDeg - spec.startDeg) * frac) * Math.PI) / 180;
2034+
return { x: spec.cx + spec.radius * Math.cos(rad), y: spec.cy + spec.radius * Math.sin(rad) };
2035+
}
2036+
2037+
function arcTotalLength(spec: ArcSpec): number {
2038+
return (spec.radius * Math.abs(spec.endDeg - spec.startDeg) * Math.PI) / 180;
2039+
}
2040+
2041+
// The bounding box of the 16 samples ringPathBox takes (i/16 of total, i=0..15).
2042+
function arcBBox(spec: ArcSpec): DOMRect {
2043+
const total = arcTotalLength(spec);
2044+
const xs: number[] = [];
2045+
const ys: number[] = [];
2046+
for (let i = 0; i < 16; i++) {
2047+
const point = arcPointAt(spec, (total * i) / 16, total);
2048+
xs.push(point.x);
2049+
ys.push(point.y);
2050+
}
2051+
const left = Math.min(...xs);
2052+
const top = Math.min(...ys);
2053+
return rect({ left, top, width: Math.max(...xs) - left, height: Math.max(...ys) - top });
2054+
}
2055+
2056+
// happy-dom has no SVG path geometry: mock getTotalLength/getPointAtLength so
2057+
// ringPathBox samples the given circular arc in local user units.
2058+
function installArcSampling(pathId: string, spec: ArcSpec): void {
2059+
const path = document.getElementById(pathId);
2060+
if (!path) throw new Error(`no path #${pathId}`);
2061+
const total = arcTotalLength(spec);
2062+
Object.defineProperty(path, "getTotalLength", { value: () => total, configurable: true });
2063+
Object.defineProperty(path, "getPointAtLength", {
2064+
value: (length: number) => arcPointAt(spec, length, total),
2065+
configurable: true,
2066+
});
2067+
}
2068+
19822069
function installAuditScript(): void {
19832070
window.eval(script);
19842071
}

0 commit comments

Comments
 (0)