Files
autocad-mcp-pro/engineering/pid/symbols_vessels.py
T
Umut CANandClaude Opus 5 8a3b8735b9 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
2026-09-15 16:26:00 +03:00

270 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# engineering/pid/symbols_vessels.py
"""Parametric vessels (ISO 10628-2): size and nozzles come from the caller.
Unlike the fixed-size catalogue, a vessel's block definition depends on its
parameters, so the block name carries an 8-hex hash of the validated parameter
set (spec 4.5): identical parameters share one definition, different ones get
their own. Nozzles become stub lines plus named ports; the default set is
``N1`` top, ``N2`` bottom, ``N3`` left (0.7 up the wall), ``N4`` right (0.3).
A nozzle's port sits ``STUB`` outside the vessel's bounding box on the side it
was asked for; its stub is drawn from the port inward to the point where that
axis-aligned line meets the *drawn* outline (a dished head, a hopper's sloped
wall, a cone roof), so no stub ever floats off the vessel or pierces it.
"""
from __future__ import annotations
from .geometry import arc, line, polyline, scan_hits
from .symbols import Port, SymbolSpec, make_spec, params_hash, register, tag_attdefs
VESSELS = ("vertical_vessel", "horizontal_vessel", "tank", "column", "reactor", "hopper")
SIDES = ("top", "bottom", "left", "right")
DEFAULT_NOZZLES = [
{"name": "N1", "side": "top", "fraction": 0.5},
{"name": "N2", "side": "bottom", "fraction": 0.5},
{"name": "N3", "side": "left", "fraction": 0.7},
{"name": "N4", "side": "right", "fraction": 0.3},
]
_DEFAULTS: dict[str, dict] = {
"vertical_vessel": {"width": 20.0, "height": 40.0},
"horizontal_vessel": {"width": 40.0, "height": 20.0},
"tank": {"width": 24.0, "height": 24.0, "roof": "flat"},
"column": {"width": 16.0, "height": 60.0, "trays": 0},
"reactor": {"width": 24.0, "height": 36.0, "jacketed": False},
"hopper": {"width": 24.0, "height": 24.0},
}
#: Capsule bodies (cylinder with two dished heads) whose axis is vertical.
_VERTICAL_CAPSULES = ("vertical_vessel", "column", "reactor")
PARAMS_SCHEMA = {
"width": "float > 0 (mm); vertical_vessel/column/reactor need width < height",
"height": "float > 0 (mm); horizontal_vessel needs height < width; cone tank height > width/6",
"nozzles": "[{name, side: top|bottom|left|right, fraction: 0..1}]",
"roof": "tank only: flat | cone",
"trays": "column only: int >= 0",
"jacketed": "reactor only: bool",
}
#: Distance of a nozzle port beyond the vessel's bounding box (symbol-local mm);
#: the stub runs from there in to the drawn outline, so it is at least this long.
STUB = 3.0
#: Gap between a jacketed reactor's wall and its jacket line.
JACKET = 2.0
def _is_number(value) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def validate_vessel_params(symbol: str, params: dict | None) -> dict:
"""Merge ``params`` over the vessel's defaults and validate every key.
Raises ``ValueError`` naming the offending key (and nozzle index) before
anything is built; the returned dict is the canonical parameter set the
block name is hashed from.
"""
if symbol not in VESSELS:
raise ValueError(f"unknown vessel {symbol!r}; valid: {', '.join(VESSELS)}")
merged = dict(_DEFAULTS[symbol])
merged["nozzles"] = [dict(n) for n in DEFAULT_NOZZLES]
if symbol == "hopper":
merged["nozzles"] = [dict(n) for n in DEFAULT_NOZZLES[:2]]
if params is not None and not isinstance(params, dict):
raise ValueError(f"{symbol}: params must be a dict")
for key, value in (params or {}).items():
if key not in merged:
raise ValueError(
f"{symbol}: unknown parameter {key!r}; valid: {', '.join(sorted(merged))}"
)
merged[key] = value
for key in ("width", "height"):
v = merged[key]
if not _is_number(v) or v <= 0:
raise ValueError(f"{symbol}: {key} must be a number > 0")
merged[key] = float(v)
w, h = merged["width"], merged["height"]
if symbol in _VERTICAL_CAPSULES and h <= w:
raise ValueError(
f"{symbol}: height {h} must exceed width {w} — the dished heads are half-circles "
"of the width, so a shorter axis folds them through each other"
)
if symbol == "horizontal_vessel" and w <= h:
raise ValueError(
f"{symbol}: width {w} must exceed height {h} — the dished heads are half-circles "
"of the height, so a shorter axis folds them through each other"
)
if "roof" in merged and merged["roof"] not in ("flat", "cone"):
raise ValueError(f"{symbol}: roof must be 'flat' or 'cone'")
if merged.get("roof") == "cone" and h <= w / 6:
raise ValueError(
f"{symbol}: height {h} must exceed width/6 ({w / 6}) — the cone roof is that tall"
)
if "trays" in merged:
trays = merged["trays"]
if isinstance(trays, bool) or not isinstance(trays, int) or trays < 0:
raise ValueError(f"{symbol}: trays must be an int >= 0")
if "jacketed" in merged and not isinstance(merged["jacketed"], bool):
raise ValueError(f"{symbol}: jacketed must be a bool")
nozzles = merged["nozzles"]
if not isinstance(nozzles, list):
raise ValueError(f"{symbol}: nozzles must be a list")
names: set[str] = set()
clean = []
for i, n in enumerate(nozzles):
if not isinstance(n, dict) or not isinstance(n.get("name"), str) or not n["name"]:
raise ValueError(f"{symbol}: nozzles[{i}].name must be a non-empty string")
if n["name"] in names:
raise ValueError(f"{symbol}: nozzles[{i}].name {n['name']!r} is repeated")
names.add(n["name"])
for key in n:
if key not in ("name", "side", "fraction"):
raise ValueError(
f"{symbol}: nozzles[{i}]: unknown key {key!r}; valid: fraction, name, side"
)
if n.get("side") not in SIDES:
raise ValueError(f"{symbol}: nozzles[{i}].side must be one of {', '.join(SIDES)}")
f = n.get("fraction", 0.5)
if not _is_number(f) or not 0.0 <= f <= 1.0:
raise ValueError(f"{symbol}: nozzles[{i}].fraction must be within 0..1")
clean.append({"name": n["name"], "side": n["side"], "fraction": float(f)})
merged["nozzles"] = clean
return merged
def _nozzles(
nozzles: list[dict], shell: list[dict], half_w: float, half_h: float, extra: float = 0.0
) -> tuple[list[dict], list[Port]]:
"""Nozzle stub primitives and ports for a vessel whose outline is ``shell``.
The port sits ``STUB + extra`` outside the ±half_w × ±half_h box (``extra``
is a jacketed reactor's jacket, which the stub passes through so the port
still sits on the symbol's outer edge); the stub starts where the port's
axis-aligned line meets the outermost point of the drawn outline.
"""
prims: list[dict] = []
ports: list[Port] = []
stub = STUB + extra
for n in nozzles:
side, f = n["side"], n["fraction"]
if side in ("top", "bottom"):
x = -half_w + f * 2 * half_w
hits = scan_hits(shell, "x", x)
if not hits:
raise RuntimeError(f"nozzle {n['name']!r}: x={x} meets no outline")
if side == "top":
y0, y1, d = max(hits), half_h + stub, 90.0
else:
y0, y1, d = min(hits), -half_h - stub, 270.0
prims.append(line(x, y0, x, y1))
ports.append(Port(n["name"], x, y1, d))
else:
y = -half_h + f * 2 * half_h
hits = scan_hits(shell, "y", y)
if not hits:
raise RuntimeError(f"nozzle {n['name']!r}: y={y} meets no outline")
if side == "left":
x0, x1, d = min(hits), -half_w - stub, 180.0
else:
x0, x1, d = max(hits), half_w + stub, 0.0
prims.append(line(x0, y, x1, y))
ports.append(Port(n["name"], x1, y, d))
return prims, ports
def _capsule_vertical(w: float, h: float) -> list[dict]:
"""Cylinder with dished ends, axis vertical: two walls and two half-circle heads."""
r = w / 2
body = h / 2 - r
return [
line(-r, -body, -r, body),
line(r, -body, r, body),
arc(0, body, r, 0, 180),
arc(0, -body, r, 180, 360),
]
def _capsule_horizontal(w: float, h: float) -> list[dict]:
r = h / 2
body = w / 2 - r
return [
line(-body, -r, body, -r),
line(-body, r, body, r),
arc(body, 0, r, 270, 90),
arc(-body, 0, r, 90, 270),
]
def build_vessel(symbol: str = "vertical_vessel", params: dict | None = None) -> SymbolSpec:
p = validate_vessel_params(symbol, params)
w, h = p["width"], p["height"]
half_w, half_h = w / 2, h / 2
extra = 0.0
if symbol in _VERTICAL_CAPSULES:
shell = _capsule_vertical(w, h)
elif symbol == "horizontal_vessel":
shell = _capsule_horizontal(w, h)
elif symbol == "tank":
roof = w / 6 if p["roof"] == "cone" else 0.0
shell = [
polyline(
[
(-half_w, -half_h),
(half_w, -half_h),
(half_w, half_h - roof),
(-half_w, half_h - roof),
]
)
]
if roof:
shell.append(
polyline(
[(-half_w, half_h - roof), (0, half_h), (half_w, half_h - roof)],
closed=False,
)
)
else: # hopper: flat top, walls converging to a narrow outlet
shell = [
polyline([(-half_w, half_h), (half_w, half_h), (w / 6, -half_h), (-w / 6, -half_h)])
]
prims = list(shell)
if symbol == "column":
trays = p["trays"]
for i in range(trays):
y = -half_h + (i + 1) * h / (trays + 1)
prims.append(line(-half_w + 1, y, half_w - 1, y))
elif symbol == "reactor" and p["jacketed"]:
body = half_h - half_w
prims += [
line(-half_w - JACKET, -body, -half_w - JACKET, body),
line(half_w + JACKET, -body, half_w + JACKET, body),
]
extra = JACKET
nozzle_prims, ports = _nozzles(p["nozzles"], shell, half_w, half_h, extra)
prims += nozzle_prims
xs = [-half_w - extra, half_w + extra] + [pt.x for pt in ports]
ys = [-half_h, half_h] + [pt.y for pt in ports]
box = (min(xs), min(ys), max(xs), max(ys))
return make_spec(
f"PID_VESSEL_{symbol.upper()}_{params_hash(p)}",
"vessel",
symbol,
None,
prims,
tag_attdefs(box[3]),
ports,
"ISO 10628-2:2012 vessels",
bbox=box,
parametric=True,
params=p,
)
def _vessel_builder(symbol: str):
def build(params: dict | None = None) -> SymbolSpec:
return build_vessel(symbol, params)
return build
for _symbol in VESSELS:
register(_symbol, "vessel", _vessel_builder(_symbol), params_schema=PARAMS_SCHEMA)