diff --git a/graphify/exporters/html.py b/graphify/exporters/html.py
index 59c0e52e3..bb884b3b7 100644
--- a/graphify/exporters/html.py
+++ b/graphify/exporters/html.py
@@ -73,6 +73,27 @@ def _hyperedge_script(hyperedges_json: str) -> str:
const hyperedges = {hyperedges_json};
// afterDrawing passes ctx already transformed to network coordinate space.
// Draw node positions raw — no manual pan/zoom/DPR math needed.
+
+// Andrew's monotone chain. Returns the hull in counter-clockwise order, which
+// is what the perimeter must be traced in. Collinear and duplicate points
+// collapse to the extremes, so degenerate member sets render as a segment
+// rather than a zero-area crossed path.
+function convexHull(pts) {{
+ const p = pts.slice().sort((a, b) => (a.x - b.x) || (a.y - b.y));
+ if (p.length < 3) return p;
+ const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
+ const build = seq => {{
+ const out = [];
+ for (const q of seq) {{
+ while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], q) <= 0) out.pop();
+ out.push(q);
+ }}
+ out.pop();
+ return out;
+ }};
+ const hull = build(p).concat(build(p.slice().reverse()));
+ return hull.length >= 3 ? hull : p;
+}}
network.on('afterDrawing', function(ctx) {{
hyperedges.forEach(h => {{
const positions = h.nodes
@@ -85,10 +106,14 @@ def _hyperedge_script(hyperedges_json: str) -> str:
ctx.strokeStyle = '#6366f1';
ctx.lineWidth = 2;
ctx.beginPath();
- // Centroid and expanded hull in network coordinates
+ // Centroid and expanded hull in network coordinates.
+ // The perimeter must follow hull order, not h.nodes order: tracing the
+ // raw member order self-intersects whenever the layout does not happen
+ // to place members in angular order, filling as crossed wedges.
const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
- const expanded = positions.map(p => ({{
+ const hull = convexHull(positions);
+ const expanded = hull.map(p => ({{
x: cx + (p.x - cx) * 1.15,
y: cy + (p.y - cy) * 1.15
}}));
diff --git a/tests/test_export.py b/tests/test_export.py
index 7b55780ea..d7abbc5b7 100644
--- a/tests/test_export.py
+++ b/tests/test_export.py
@@ -831,3 +831,81 @@ def test_existing_graph_node_count(tmp_path):
assert existing_graph_node_count(p) is MALFORMED_GRAPH # structurally wrong -> fail closed
p.write_text('{"nodes": [{"id": "a"}, {"id": "b"}], "links": []}', encoding="utf-8")
assert existing_graph_node_count(p) == 2 # valid
+
+
+def test_hyperedge_perimeter_uses_convex_hull_not_member_order():
+ """The hyperedge polygon must be traced in hull order. Tracing `h.nodes`
+ array order self-intersects whenever the layout does not place members in
+ angular order, so `fill()` paints crossed wedges instead of one region."""
+ from graphify.exporters.html import _hyperedge_script
+ script = _hyperedge_script("[]")
+ assert "function convexHull(pts)" in script
+ assert "const hull = convexHull(positions);" in script
+ # the traced ring must derive from the hull, never from raw member order
+ assert "const expanded = hull.map(" in script
+ assert "const expanded = positions.map(" not in script
+
+
+def test_hyperedge_convex_hull_js_is_geometrically_sound():
+ """Execute the emitted convexHull in node: the perimeter must be simple
+ (no self-intersection), convex, and contain every member point."""
+ import shutil
+ import subprocess
+ node = shutil.which("node")
+ if node is None:
+ import pytest
+ pytest.skip("node not available")
+ from graphify.exporters.html import _hyperedge_script
+ m = re.search(r"function convexHull\(pts\) \{.*?\n\}", _hyperedge_script("[]"), re.S)
+ assert m, "convexHull not found in emitted script"
+ harness = m.group(0) + r"""
+const cross = (p,q,r) => (q.x-p.x)*(r.y-p.y) - (q.y-p.y)*(r.x-p.x);
+const proper = (a,b,c,d) => {
+ const s = (p,q,r) => Math.sign(cross(p,q,r));
+ return s(a,b,c)*s(a,b,d) < 0 && s(c,d,a)*s(c,d,b) < 0;
+};
+function selfIntersects(poly){
+ const n = poly.length;
+ if (n < 4) return false;
+ for (let i=0;i (rng = (rng*1103515245+12345) & 0x7fffffff) / 0x7fffffff;
+let bad = 0;
+for (let t=0;t<2000;t++){
+ const n = 4 + Math.floor(rnd()*4); // real hyperedges carry 4-7 members
+ const pts = Array.from({length:n}, () => ({x: rnd()*1000-500, y: rnd()*1000-500}));
+ const h = convexHull(pts);
+ if (selfIntersects(h)) bad++;
+ for (let i=0;i Number.isFinite(p.x) && Number.isFinite(p.y))) bad++;
+}
+// the bow-tie ordering this fix exists for
+if (!selfIntersects([{x:-1,y:-1},{x:1,y:1},{x:-1,y:1},{x:1,y:-1}])) bad++;
+if (selfIntersects(convexHull([{x:-1,y:-1},{x:1,y:1},{x:-1,y:1},{x:1,y:-1}]))) bad++;
+console.log(bad);
+"""
+ with tempfile.TemporaryDirectory() as tmp:
+ js = Path(tmp) / "hull_check.js"
+ js.write_text(harness, encoding="utf-8")
+ proc = subprocess.run([node, str(js)], capture_output=True, text=True, timeout=60)
+ assert proc.returncode == 0, proc.stderr
+ assert proc.stdout.strip() == "0", f"geometry violations: {proc.stdout.strip()}"