fix(task-10): nozzle stubs meet the drawn outline, impossible capsules and nozzle typos refused

Nozzle stubs started at the bounding-box edge, so on a dished head, a
hopper's sloped wall or a cone roof they floated off the vessel (default
horizontal_vessel N3/N4 by 0.8 mm, reactor N3/N4 by 0.06 mm, unbounded for
user fractions). The port now stays STUB outside the bbox and the stub is
drawn from there in to where its axis-aligned line meets the outline, via a
new geometry.scan_hits helper; jacketed reactors' stubs pass through the
jacket to the wall.

validate_vessel_params refuses height <= width for vertical capsules
(vertical_vessel, column, reactor), width <= height for horizontal_vessel
and height <= width/6 for a cone-roof tank: those folded the heads through
each other and produced geometry outside the declared bbox. Unknown keys
inside a nozzle dict are refused naming nozzles[i] and the key, like the
top-level parameters already were.

Tests: a fuzz over every accepted vessel shape x 28 nozzle positions checks
the declared bbox and that each stub's inner end lies on the outline (exact
point-to-arc/segment distance); the default-symbol repros and the refusals
are pinned. 137 of the new cases fail against the previous module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-15 16:26:00 +03:00
co-authored by Claude Opus 5
parent 661258004f
commit 8a3b8735b9
3 changed files with 269 additions and 43 deletions
+136
View File
@@ -426,3 +426,139 @@ def test_vessel_params_are_validated(params, key):
symbol = "column" if "trays" in params else ("tank" if "roof" in params else "vertical_vessel")
with pytest.raises(ValueError, match=key):
validate_vessel_params(symbol, params)
def _distance_to_outline(x: float, y: float, primitives) -> float:
"""Exact distance from a point to the nearest drawn line, arc or polyline edge."""
def to_segment(x1, y1, x2, y2):
dx, dy = x2 - x1, y2 - y1
if dx == 0.0 == dy:
return math.hypot(x - x1, y - y1)
t = max(0.0, min(1.0, ((x - x1) * dx + (y - y1) * dy) / (dx * dx + dy * dy)))
return math.hypot(x - (x1 + t * dx), y - (y1 + t * dy))
best = math.inf
for p in primitives:
kind = p["type"]
if kind == "line":
best = min(best, to_segment(p["x1"], p["y1"], p["x2"], p["y2"]))
elif kind == "arc":
cx, cy, r = p["cx"], p["cy"], p["r"]
sweep = (p["end_deg"] - p["start_deg"]) % 360.0 or 360.0
rel = (math.degrees(math.atan2(y - cy, x - cx)) - p["start_deg"]) % 360.0
if rel <= sweep + 1e-9:
best = min(best, abs(math.hypot(x - cx, y - cy) - r))
for deg in (p["start_deg"], p["end_deg"]):
a = math.radians(deg)
best = min(best, math.hypot(x - (cx + r * math.cos(a)), y - (cy + r * math.sin(a))))
elif kind == "polyline":
pts = list(p["points"]) + ([p["points"][0]] if p["closed"] else [])
for (x1, y1), (x2, y2) in zip(pts, pts[1:], strict=False):
best = min(best, to_segment(x1, y1, x2, y2))
return best
def _nozzle_stubs(spec):
"""(stub line, port) pairs — the stubs are the last primitives, one per port."""
stubs = list(spec.primitives[-len(spec.ports) :])
return list(zip(stubs, spec.ports, strict=True))
def _vessel_fuzz():
fractions = (0.0, 0.1, 0.25, 0.5, 0.75, 0.9, 1.0)
nozzles = [
{"name": f"{side[0].upper()}{i}", "side": side, "fraction": f}
for side in ("top", "bottom", "left", "right")
for i, f in enumerate(fractions)
]
cases = []
for symbol in VESSELS:
for w in (4.0, 10.0, 20.0, 40.0):
for h in (2.0, 10.0, 25.0, 40.0):
for extra in ({}, {"roof": "cone"}, {"jacketed": True}, {"trays": 3}):
params = {"width": w, "height": h, "nozzles": nozzles, **extra}
try:
validate_vessel_params(symbol, params)
except ValueError:
continue
cases.append(pytest.param(symbol, params, id=f"{symbol}-{w:g}x{h:g}-{extra}"))
return cases
@pytest.mark.parametrize("symbol, params", _vessel_fuzz())
def test_every_accepted_vessel_shape_is_drawn_inside_its_bbox_with_stubs_on_the_wall(
symbol, params
):
spec = build_vessel(symbol, params)
# The shell comes first: two walls + two heads, a box (+ cone roof), or the hopper.
n_shell = {"tank": 2 if params.get("roof") == "cone" else 1, "hopper": 1}.get(symbol, 4)
body = list(spec.primitives[:n_shell])
xmin, ymin, xmax, ymax = bbox_of(spec.primitives)
dxmin, dymin, dxmax, dymax = spec.bbox
assert dxmin - EPS <= xmin and dymin - EPS <= ymin
assert xmax <= dxmax + EPS and ymax <= dymax + EPS
for stub, port in _nozzle_stubs(spec):
assert stub["type"] == "line"
outer = {(stub["x1"], stub["y1"]), (stub["x2"], stub["y2"])}
assert (port.x, port.y) in outer, f"{port.name}: stub does not end at its port"
(ix, iy) = next(pt for pt in outer if pt != (port.x, port.y))
assert _distance_to_outline(ix, iy, body) < 1e-6, (
f"{symbol} {params['width']:g}x{params['height']:g} {port.name}: stub inner end "
f"({ix:.3f}, {iy:.3f}) floats {_distance_to_outline(ix, iy, body):.3f} mm off the outline"
)
assert math.hypot(port.x - ix, port.y - iy) >= 3.0 - EPS, "stub shorter than STUB"
def test_default_vessel_stubs_start_on_the_dished_head_not_the_bbox():
# horizontal_vessel N3 (left, 0.7): y = 4 on a head of r = 10 centred (-10, 0).
n3 = next(s for s, p in _nozzle_stubs(build_vessel("horizontal_vessel")) if p.name == "N3")
assert n3["y1"] == n3["y2"] == pytest.approx(4.0)
assert min(n3["x1"], n3["x2"]) == pytest.approx(-23.0), "port stays STUB outside the bbox"
assert max(n3["x1"], n3["x2"]) == pytest.approx(-10.0 - math.sqrt(100.0 - 16.0))
# reactor N3/N4 land on the heads too (|y| = 7.2 > cylindrical half-length 6).
for stub, port in _nozzle_stubs(build_vessel("reactor")):
if port.name in ("N3", "N4"):
inner = min(abs(stub["x1"]), abs(stub["x2"]))
assert inner == pytest.approx(math.sqrt(144.0 - (7.2 - 6.0) ** 2))
# A jacketed reactor's stub passes through the jacket to the vessel wall.
n1 = next(
s for s, p in _nozzle_stubs(build_vessel("reactor", {"jacketed": True})) if p.name == "N1"
)
assert sorted((n1["y1"], n1["y2"])) == [pytest.approx(18.0), pytest.approx(23.0)]
# Hopper side nozzle meets the sloped wall; cone-roof top nozzle meets the roof.
hopper = build_vessel("hopper", {"nozzles": [{"name": "L", "side": "left", "fraction": 0.5}]})
assert max(hopper.primitives[-1]["x1"], hopper.primitives[-1]["x2"]) == pytest.approx(-8.0)
cone = build_vessel(
"tank", {"roof": "cone", "nozzles": [{"name": "T", "side": "top", "fraction": 0.25}]}
)
assert min(cone.primitives[-1]["y1"], cone.primitives[-1]["y2"]) == pytest.approx(10.0)
@pytest.mark.parametrize(
"symbol, params, key",
[
("vertical_vessel", {"width": 20, "height": 5}, "height"),
("vertical_vessel", {"width": 20, "height": 20}, "height"),
("column", {"width": 16, "height": 10}, "height"),
("reactor", {"width": 24, "height": 12}, "height"),
("horizontal_vessel", {"width": 20, "height": 40}, "width"),
("tank", {"roof": "cone", "width": 24, "height": 4}, "height"),
(
"vertical_vessel",
{"nozzles": [{"name": "F", "side": "right", "fractoin": 0.25}]},
"fractoin",
),
(
"hopper",
{"nozzles": [{"name": "A", "side": "top", "fraction": 0.5, "colour": 1}]},
r"nozzles\[0\]",
),
],
)
def test_vessel_shapes_that_cannot_be_drawn_and_nozzle_typos_are_refused(symbol, params, key):
with pytest.raises(ValueError, match=key):
validate_vessel_params(symbol, params)
# Flat-roof and hopper proportions are free; the capsule limit is strict.
assert validate_vessel_params("tank", {"width": 24, "height": 4})["height"] == 4.0
assert validate_vessel_params("hopper", {"width": 40, "height": 4})["height"] == 4.0