Three review findings, all root causes in `engineering/mech/dimension.py`.
1. The ISO 286 fit path was unreachable from the module's own pipeline.
`resolve_tolerance` resolved `intent.fit` correctly, but nothing produced a
DimIntent carrying one: no feature and no segment has a fit, and
`dimension_intents` constructed every intent without `fit=`. Only a
hand-built DimIntent - which is what the tests used - reached the code, so
the spec's `mech_iso286_on_dimension` and the `mech_assembly` benchmark's
"ISO 286 fits on the bearing seats" had no route.
`dimension_intents(..., fits={key: code})` now stamps the code onto the
intent its key names, and the part's own diameters became addressable one
at a time through the new `segment_key(i, bore=...)` -> "segment[1]",
"segment[1].bore"; a feature's stay addressable by id. A key that names
nothing is refused with the names that exist, a malformed code is refused
where it is attached rather than at draw time, a fit on a reference
dimension or on one that already carries a tol is refused, and a fit on a
hole whose diameter moved into the hole table (which has no tolerance
column) is refused by name instead of vanishing. Task 7's planned
`dimension_part(..., fits=None)` passes straight through - the argument is
additive and keyword-only, so every call written against the pinned
signature keeps working.
The fit also now reaches the *text*: `intent_text` expands the `<>`
placeholder and appends the code, so `text_rects` measures the string that
is actually drawn and a hole that already names itself ("⌀6") keeps its own
text and gains the code.
2. An explicit tolerance was silently discarded when `tol` omitted "mode".
`mode = tol.get("mode", "none")` and `build_dim_override(0.05, 0.05,
"none", None)` returns `({}, None)` without raising, so a ±0.05 resolved,
echoed its own numbers back in the payload, and drew a dimension with no
DIMTOL/DIMTP/DIMTM at all. `DimIntent.tol` now has a schema - {"mode",
"upper", "lower"} and nothing else - validated by `_explicit_tol`: an
unknown key, a missing "mode", a non-dict, and deviations under a mode that
writes none are each refused by name. The mutual-exclusion guard reads
`tol is not None` rather than truthiness, so `tol={}` is refused too.
3. The ISO 129-1 "no measurement twice" test was vacuous and the property was
false. The old test built the key `_dedupe` uniquifies as the last step of
`dimension_intents`, so it could not fail; meanwhile `style="chain"` emitted
20, 40, 15 *and* 75 as four plain DIMENSIONs. `redundant_linear` now finds
the span that shorter ones tile end to end on the same axis and level, and
`dimension_intents` marks it as an auxiliary (reference) dimension - the new
`REFERENCE_FORMAT` "(<>)", which renders "(75)". The plan's own test still
holds (the 75 is present); it is no longer a fourth plain dimension. The
error is one-sided on purpose: a tiling that is found is real, a tiling the
greedy walk misses leaves the dimension plain, which is the direction the
repository's critique rule wants. Baseline and ordinate parenthesise
nothing, and two horizontal chains at different heights are not each other's
pieces.
Tests: the vacuous gate is replaced by four that fail against the pre-fix code
- `redundant_linear` on the shaft's plain intents returns (3,) there and ()
here; the parametrised "a tolerance is either refused or reaches the dimension
variables" gate resolves the mode-less shape to an empty DIMVAR override
there; and `fits=` is a TypeError there. `_dedupe` is now exercised through a
duplicate fed in via the view's own dims, which the collector does not
generate itself. 46 tests in tests/test_mech_dimension.py (was 23).
Live AutoCAD: no finding was measured live and no new ActiveX call was
written - this module is pure Python with no backend, no async and no DXF. A
DIMTOL probe against the running seat was attempted twice and
`GetActiveObject("AutoCAD.Application")` blocked past the ~90 s contention
window both times, so the live engine was unavailable; no scratch document was
created (the probe never got the application object) and none was left open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
651 lines
26 KiB
Python
651 lines
26 KiB
Python
"""Dimension intents for a view, laid out so no two texts overlap.
|
|
|
|
Pure: no backend, no async, no DXF. The features declare what they want
|
|
dimensioned; this module adds the part's own sizes, removes the duplicates
|
|
(ISO 129-1: each dimension appears once), resolves ISO 286 fits and ISO 129
|
|
tolerances through the modules that already own them, and then *places* the
|
|
texts.
|
|
|
|
The placement is not a matter of taste. Every intent's text is measured into a
|
|
rectangle by :func:`text_rects`, and an intent is put on the lowest free level
|
|
whose rectangle does not overlap one already placed on that level. Two
|
|
rectangles are disjoint when they are separated along *either* axis, so the
|
|
layout separates them along both in turn: within a level, along the axis the
|
|
level runs in; between levels, along the axis the levels stack in. The second
|
|
one is why the level pitch is clamped to the extent of the text *in the
|
|
stacking direction* - which is the text height for the row of dimensions under
|
|
the view, and the text **width** for the column of diameters beside it. A pitch
|
|
clamped only to the height would let two 9.45 mm wide diameter labels sit
|
|
8 mm apart on the same centreline, overlapping by 1.45 mm.
|
|
|
|
The test asserts pairwise disjointness with the same helper, which makes "no
|
|
overlapping dimension text" measured rather than claimed - and it is the same
|
|
property the repository's `dim_overlap` critique focus checks on the finished
|
|
drawing.
|
|
|
|
Three rules the module enforces rather than assumes:
|
|
|
|
*ISO 286 fits reach the pipeline.* :func:`dimension_intents` takes
|
|
``fits={key: code}`` and stamps the code onto the intent it names, so a bearing
|
|
seat gets its k6 without anyone hand-building a ``DimIntent``. The part's own
|
|
diameters are addressable by segment (``"segment[1]"``, ``"segment[1].bore"``);
|
|
a feature's are addressable by its id. An unknown key is refused by name - a fit
|
|
that lands nowhere is a manufacturing requirement lost in silence.
|
|
|
|
*An explicit tolerance is validated as a whole.* ``DimIntent.tol`` is
|
|
``{"mode", "upper", "lower"}`` and nothing else. ``mode`` is required, because
|
|
``build_dim_override`` reads a missing mode as ``"none"`` and returns an empty
|
|
override: a +-0.05 with no ``mode`` used to resolve, report its own numbers back
|
|
and then draw a dimension carrying no tolerance at all.
|
|
|
|
*ISO 129-1: each measurement appears once.* A closed chain plus the overall
|
|
states the overall twice. :func:`redundant_linear` finds the span that the
|
|
others tile end to end, and :func:`dimension_intents` marks it as an auxiliary
|
|
(reference) dimension - ``(<>)``, drawn in parentheses - rather than emitting a
|
|
fourth plain DIMENSION that contradicts nothing and adds nothing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
from engineering.fits import fit_lookup, parse_fit_code
|
|
from engineering.mech.features import Hole
|
|
from engineering.mech.part import (
|
|
PrismaticPart,
|
|
RevolvedPart,
|
|
outline_bbox,
|
|
part_length,
|
|
segment_bounds,
|
|
)
|
|
from engineering.mech.primitives import DimIntent
|
|
from engineering.tolerances import TOL_MODES, build_dim_override
|
|
|
|
_EPS = 1e-9
|
|
|
|
DIM_STYLES: tuple[str, ...] = ("chain", "baseline", "ordinate")
|
|
|
|
#: Mean glyph width as a fraction of the text height, for measuring a label
|
|
#: into a rectangle. Deliberately generous: over-estimating the width can only
|
|
#: push two dimensions further apart.
|
|
TEXT_WIDTH_FACTOR = 0.7
|
|
|
|
#: Padding added around a measured label, in text heights.
|
|
TEXT_PADDING = 0.6
|
|
|
|
#: How an auxiliary (reference) dimension is written - ISO 129-1 puts a
|
|
#: measurement that is already given elsewhere in parentheses. ``<>`` is the
|
|
#: DIMENSION text placeholder for the measured value, on both engines.
|
|
REFERENCE_FORMAT = "(<>)"
|
|
|
|
#: The only keys ``DimIntent.tol`` accepts.
|
|
TOL_KEYS: frozenset[str] = frozenset({"mode", "upper", "lower"})
|
|
|
|
#: Tolerance modes that write no deviation, so carrying upper/lower with them
|
|
#: would silently discard the numbers.
|
|
_NO_DEVIATION_MODES: frozenset[str] = frozenset({"none", "basic"})
|
|
|
|
|
|
def measured_value(intent: DimIntent) -> float:
|
|
"""What the dimension measures, in drawing units."""
|
|
p1, p2 = intent.p1, intent.p2
|
|
if intent.kind == "linear":
|
|
dx, dy = abs(p2[0] - p1[0]), abs(p2[1] - p1[1])
|
|
return dx if dx >= dy else dy
|
|
return math.dist(p1, p2)
|
|
|
|
|
|
def _format(value: float) -> str:
|
|
return f"{round(value, 3):g}"
|
|
|
|
|
|
def _with_fit(text: str, fit: str | None) -> str:
|
|
"""Append an ISO 286 code to a dimension's text, once.
|
|
|
|
A feature that already names itself (a hole's ``⌀6``) keeps its own text and
|
|
gains the code; without this the deviations would be written and the code
|
|
the inspector reads would not be on the sheet.
|
|
"""
|
|
code = (fit or "").strip()
|
|
if not code or code in text:
|
|
return text
|
|
return f"{text} {code}"
|
|
|
|
|
|
def _measured_text(intent: DimIntent) -> str:
|
|
if intent.kind == "diameter":
|
|
return f"⌀{_format(measured_value(intent))}"
|
|
if intent.kind == "radius":
|
|
return f"R{_format(measured_value(intent))}"
|
|
return _format(measured_value(intent))
|
|
|
|
|
|
def intent_text(intent: DimIntent) -> str:
|
|
"""The text a dimension will carry, as the engine will render it.
|
|
|
|
``<>`` is the DIMENSION placeholder for the measured value, so an override
|
|
of ``"(<>)"`` reads ``"(75)"`` and a resolved fit's ``"<> H7"`` reads
|
|
``"⌀40 H7"``. Expanding it here is what makes :func:`text_rects` measure the
|
|
string that is actually drawn - a label measured as ``"<> H7"`` and drawn as
|
|
``"⌀40 H7"`` is a layout computed against the wrong width.
|
|
"""
|
|
measured = _measured_text(intent)
|
|
base = intent.text_override.replace("<>", measured) if intent.text_override else measured
|
|
# The fit code is appended to the text by `resolve_tolerance`; the rectangle
|
|
# has to reserve room for it before the layout runs.
|
|
return _with_fit(base, intent.fit)
|
|
|
|
|
|
def is_reference(intent: DimIntent) -> bool:
|
|
"""True for an auxiliary (reference) dimension - ISO 129-1 parentheses."""
|
|
override = intent.text_override or ""
|
|
return override.startswith("(") and override.endswith(")")
|
|
|
|
|
|
def _text_size(intent: DimIntent, height: float) -> tuple[float, float]:
|
|
"""``(width, height)`` of the rectangle this intent's label occupies."""
|
|
label = intent_text(intent)
|
|
return (
|
|
(len(label) * TEXT_WIDTH_FACTOR + TEXT_PADDING) * height,
|
|
height * (1.0 + TEXT_PADDING),
|
|
)
|
|
|
|
|
|
def text_rects(intents, *, height: float = 3.5):
|
|
"""``(x0, y0, x1, y1)`` per placed intent, from its text and its ``text_at``."""
|
|
rects = []
|
|
for intent in intents:
|
|
if intent.text_at is None:
|
|
raise ValueError(
|
|
f"text_rects: intent {intent.feature or intent.kind!r} has no text_at; "
|
|
"run layout_dimensions first."
|
|
)
|
|
width, tall = _text_size(intent, height)
|
|
cx, cy = float(intent.text_at[0]), float(intent.text_at[1])
|
|
rects.append((cx - width / 2.0, cy - tall / 2.0, cx + width / 2.0, cy + tall / 2.0))
|
|
return tuple(rects)
|
|
|
|
|
|
# -- tolerances --------------------------------------------------------------
|
|
|
|
|
|
def _explicit_tol(intent: DimIntent) -> tuple[str, float | None, float | None]:
|
|
"""Validate ``intent.tol`` as a whole and return ``(mode, upper, lower)``.
|
|
|
|
``build_dim_override`` alone is not enough: it defaults a missing mode to
|
|
``"none"`` and answers with an empty override, so ``{"upper": 0.05,
|
|
"lower": 0.05}`` used to resolve, echo its own numbers back and draw a
|
|
dimension with no DIMTOL/DIMTP/DIMTM at all. A tolerance that disappears
|
|
between the spec and the sheet is the silent wrong value this repository
|
|
refuses, so the schema is checked here, by name.
|
|
"""
|
|
who = intent.feature or intent.kind
|
|
tol = intent.tol
|
|
if not isinstance(tol, dict):
|
|
raise ValueError(
|
|
f"{who}.tol: expected a dict with keys {sorted(TOL_KEYS)}, got {type(tol).__name__}."
|
|
)
|
|
unknown = set(tol) - TOL_KEYS
|
|
if unknown:
|
|
raise ValueError(
|
|
f"{who}.tol: unknown key(s) {sorted(unknown)}; a tolerance takes {sorted(TOL_KEYS)}."
|
|
)
|
|
if "mode" not in tol:
|
|
raise ValueError(
|
|
f"{who}.tol: needs an explicit 'mode' (one of {list(TOL_MODES)}). Without it the "
|
|
"deviations are carried in the payload and no tolerance reaches the drawing."
|
|
)
|
|
mode = str(tol["mode"]).strip().lower()
|
|
upper = tol.get("upper")
|
|
lower = tol.get("lower")
|
|
if mode in _NO_DEVIATION_MODES and (upper is not None or lower is not None):
|
|
raise ValueError(
|
|
f"{who}.tol: tol_mode={mode!r} writes no deviation, but upper={upper!r} / "
|
|
f"lower={lower!r} were given. Use 'symmetric', 'deviation' or 'limit', or drop "
|
|
"the values."
|
|
)
|
|
return mode, upper, lower
|
|
|
|
|
|
def resolve_tolerance(intent: DimIntent, nominal: float | None = None) -> dict:
|
|
"""Turn an intent's ``fit`` / ``tol`` into the backend dimension call's arguments.
|
|
|
|
Mirrors `server.py::_fit_to_tolerances`, including its sign convention:
|
|
``build_dim_override`` reads ``tol_lower`` as the *magnitude* of the minus
|
|
deviation, so a resolved fit passes ``-deviation.lower_mm``.
|
|
"""
|
|
has_tol = intent.tol is not None
|
|
if intent.fit and has_tol:
|
|
raise ValueError(
|
|
f"{intent.feature or intent.kind}: pass either fit=<ISO 286 code> or an explicit "
|
|
"tol, not both."
|
|
)
|
|
if intent.fit:
|
|
size = float(nominal) if nominal is not None else measured_value(intent)
|
|
if size <= _EPS:
|
|
raise ValueError(
|
|
f"{intent.feature or intent.kind}: fit {intent.fit!r} needs a nominal size, and "
|
|
f"the intent measures {size}. Pass nominal= explicitly."
|
|
)
|
|
deviation = fit_lookup(intent.fit, size)
|
|
return {
|
|
"tol_upper": deviation.upper_mm,
|
|
"tol_lower": -deviation.lower_mm,
|
|
"tol_mode": "deviation",
|
|
"text_override": _with_fit(intent.text_override or "<>", deviation.code),
|
|
}
|
|
if has_tol:
|
|
mode, upper, lower = _explicit_tol(intent)
|
|
build_dim_override(upper, lower, mode, intent.text_override) # validates, or raises
|
|
return {
|
|
"tol_upper": upper,
|
|
"tol_lower": lower,
|
|
"tol_mode": mode,
|
|
"text_override": intent.text_override,
|
|
}
|
|
return {
|
|
"tol_upper": None,
|
|
"tol_lower": None,
|
|
"tol_mode": "none",
|
|
"text_override": intent.text_override,
|
|
}
|
|
|
|
|
|
# -- collecting the intents --------------------------------------------------
|
|
|
|
|
|
def _dedupe(intents) -> tuple[DimIntent, ...]:
|
|
seen: set[tuple] = set()
|
|
out: list[DimIntent] = []
|
|
for intent in intents:
|
|
key = (
|
|
intent.kind,
|
|
round(float(intent.p1[0]), 6),
|
|
round(float(intent.p1[1]), 6),
|
|
round(float(intent.p2[0]), 6),
|
|
round(float(intent.p2[1]), 6),
|
|
)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(intent)
|
|
return tuple(out)
|
|
|
|
|
|
def _linear_span(intent: DimIntent) -> tuple[str, float, float, float] | None:
|
|
"""``(axis, level, lo, hi)`` of an axis-parallel linear dimension, else None.
|
|
|
|
``level`` is the coordinate the dimension does *not* run along, so two
|
|
horizontal dimensions at different heights are never compared with each
|
|
other. A skewed linear intent has no span: it is not part of a chain.
|
|
"""
|
|
if intent.kind != "linear":
|
|
return None
|
|
(ax, ay), (bx, by) = intent.p1, intent.p2
|
|
if abs(bx - ax) >= abs(by - ay):
|
|
if abs(by - ay) > _EPS:
|
|
return None
|
|
return ("x", round(float(ay), 6), round(min(ax, bx), 6), round(max(ax, bx), 6))
|
|
if abs(bx - ax) > _EPS:
|
|
return None
|
|
return ("y", round(float(ax), 6), round(min(ay, by), 6), round(max(ay, by), 6))
|
|
|
|
|
|
def redundant_linear(intents) -> tuple[int, ...]:
|
|
"""Indices of the linear intents that shorter ones already tile end to end.
|
|
|
|
ISO 129-1: a measurement appears once. A closed chain (20 + 40 + 15) that
|
|
also carries the overall (75) states the overall twice, and the second
|
|
statement has to be an auxiliary (reference) dimension or be left off.
|
|
|
|
The error is one-sided on purpose. A tiling that is found is a real one -
|
|
the pieces butt, in order, from ``lo`` to ``hi`` on the same axis and level
|
|
- so a dimension is never called redundant when it is not. A tiling that
|
|
the greedy walk misses simply leaves the dimension plain, which is the way
|
|
round the repository's critique rule wants: a check that misses a bad
|
|
drawing costs a review, a check that calls a good drawing bad gets
|
|
switched off.
|
|
"""
|
|
groups: dict[tuple[str, float], list[tuple[float, float, int]]] = {}
|
|
for index, intent in enumerate(intents):
|
|
span = _linear_span(intent)
|
|
if span is None:
|
|
continue
|
|
axis, level, lo, hi = span
|
|
groups.setdefault((axis, level), []).append((lo, hi, index))
|
|
|
|
out: list[int] = []
|
|
for group in groups.values():
|
|
for lo, hi, index in group:
|
|
pieces = sorted(
|
|
(a, b)
|
|
for a, b, other in group
|
|
if other != index
|
|
and a >= lo - 1e-6
|
|
and b <= hi + 1e-6
|
|
and (b - a) < (hi - lo) - 1e-6
|
|
)
|
|
cursor = lo
|
|
for a, b in pieces:
|
|
if abs(a - cursor) <= 1e-6:
|
|
cursor = b
|
|
if cursor > lo + 1e-6 and abs(cursor - hi) <= 1e-6:
|
|
out.append(index)
|
|
return tuple(sorted(out))
|
|
|
|
|
|
def _mark_references(intents: list[DimIntent]) -> list[DimIntent]:
|
|
"""Put the redundant measurements in parentheses, ISO 129-1."""
|
|
for index in redundant_linear(intents):
|
|
intent = intents[index]
|
|
if intent.text_override is None:
|
|
intents[index] = intent._replace(text_override=REFERENCE_FORMAT)
|
|
return intents
|
|
|
|
|
|
def _apply_fits(intents: list[DimIntent], fits, *, table_ids: frozenset[str]) -> list[DimIntent]:
|
|
"""Stamp each ``{key: ISO 286 code}`` onto the intent its key names.
|
|
|
|
Without this the ``fit`` branch of :func:`resolve_tolerance` is reachable
|
|
only from a hand-built ``DimIntent``: no feature and no segment carries a
|
|
fit, so the ISO 286 path would be real in the table and absent from every
|
|
drawing the module actually produces.
|
|
"""
|
|
if not fits:
|
|
return intents
|
|
if not isinstance(fits, dict):
|
|
raise ValueError(
|
|
f"fits: expected a dict of {{key: ISO 286 code}}, got {type(fits).__name__}."
|
|
)
|
|
|
|
available = {intent.feature for intent in intents if intent.feature}
|
|
by_key: dict[str, list[int]] = {}
|
|
for index, intent in enumerate(intents):
|
|
if intent.feature:
|
|
by_key.setdefault(intent.feature, []).append(index)
|
|
|
|
for key, code in fits.items():
|
|
name = str(key)
|
|
if name not in available:
|
|
if name in table_ids:
|
|
raise ValueError(
|
|
f"fits[{name!r}]: that hole's diameter moved into the hole table, which has "
|
|
"no tolerance column. Raise hole_table_threshold, or dimension the hole "
|
|
"directly."
|
|
)
|
|
raise ValueError(
|
|
f"fits[{name!r}]: nothing in this view is dimensioned under that name. "
|
|
f"Available: {sorted(available)}."
|
|
)
|
|
parse_fit_code(str(code)) # refuses a typo here, not at draw time
|
|
for index in by_key[name]:
|
|
intent = intents[index]
|
|
if is_reference(intent):
|
|
raise ValueError(
|
|
f"fits[{name!r}]: {intent_text(intent)} is an auxiliary (reference) "
|
|
"dimension - ISO 129-1 gives it no tolerance. Put the fit on the "
|
|
"dimension that states the size."
|
|
)
|
|
if intent.tol is not None:
|
|
raise ValueError(
|
|
f"fits[{name!r}]: that dimension already carries an explicit tol. Pass "
|
|
"either fit=<ISO 286 code> or an explicit tol, not both."
|
|
)
|
|
intents[index] = intent._replace(fit=str(code))
|
|
return intents
|
|
|
|
|
|
def _revolved_axial(part: RevolvedPart, style: str) -> list[DimIntent]:
|
|
stations = [0.0] + [x1 for _, x1 in segment_bounds(part)]
|
|
intents: list[DimIntent] = []
|
|
if style == "chain":
|
|
for a, b in zip(stations, stations[1:], strict=False):
|
|
intents.append(DimIntent(kind="linear", p1=(a, 0.0), p2=(b, 0.0), feature="part"))
|
|
intents.append(
|
|
DimIntent(kind="linear", p1=(0.0, 0.0), p2=(part_length(part), 0.0), feature="part")
|
|
)
|
|
else: # baseline and ordinate both measure from the left face
|
|
for station in stations[1:]:
|
|
intents.append(
|
|
DimIntent(kind="linear", p1=(0.0, 0.0), p2=(station, 0.0), feature="part")
|
|
)
|
|
return intents
|
|
|
|
|
|
def segment_key(index: int, *, bore: bool = False) -> str:
|
|
"""The ``fits`` key that names one segment's outer diameter, or its bore.
|
|
|
|
The part's own diameters have to be addressable one at a time: "ISO 286
|
|
fits on the bearing seats" means a code on *segment 1*, not on every
|
|
dimension the part emits.
|
|
"""
|
|
return f"segment[{int(index)}].bore" if bore else f"segment[{int(index)}]"
|
|
|
|
|
|
def _revolved_diameters(part: RevolvedPart) -> list[DimIntent]:
|
|
intents: list[DimIntent] = []
|
|
bounds = zip(segment_bounds(part), part.segments, strict=True)
|
|
for index, ((x0, x1), segment) in enumerate(bounds):
|
|
mid = (x0 + x1) / 2.0
|
|
radius = segment.d_outer / 2.0
|
|
intents.append(
|
|
DimIntent(
|
|
kind="diameter",
|
|
p1=(mid, radius),
|
|
p2=(mid, -radius),
|
|
feature=segment_key(index),
|
|
)
|
|
)
|
|
if segment.d_inner > _EPS:
|
|
bore = segment.d_inner / 2.0
|
|
intents.append(
|
|
DimIntent(
|
|
kind="diameter",
|
|
p1=(mid, bore),
|
|
p2=(mid, -bore),
|
|
feature=segment_key(index, bore=True),
|
|
)
|
|
)
|
|
return intents
|
|
|
|
|
|
def _hole_groups(part) -> dict[tuple, list[Hole]]:
|
|
groups: dict[tuple, list[Hole]] = {}
|
|
for feature in getattr(part, "features", ()):
|
|
if not isinstance(feature, Hole):
|
|
continue
|
|
key = (
|
|
round(float(feature.diameter), 6),
|
|
feature.thread or "",
|
|
None if feature.cbore_d is None else round(float(feature.cbore_d), 6),
|
|
None if feature.depth is None else round(float(feature.depth), 6),
|
|
)
|
|
groups.setdefault(key, []).append(feature)
|
|
return groups
|
|
|
|
|
|
def dimension_intents(
|
|
part,
|
|
view,
|
|
*,
|
|
style: str = "chain",
|
|
hole_table_threshold: int = 8,
|
|
fits: dict | None = None,
|
|
) -> tuple[DimIntent, ...]:
|
|
"""Every dimension this view wants: the features' own, plus the part's sizes.
|
|
|
|
``fits`` maps a dimension's name to an ISO 286 code - a feature id, or one
|
|
of the part's own diameters by :func:`segment_key` (``"segment[1]"``,
|
|
``"segment[1].bore"``). The code is stamped onto the matching intent, so
|
|
:func:`resolve_tolerance` resolves it and :func:`layout_dimensions` reserves
|
|
room for the widened text. A key that names nothing is refused with the
|
|
names that exist.
|
|
|
|
ISO 129-1 is applied last: a measurement the other dimensions already tile
|
|
end to end is marked as an auxiliary (reference) dimension, in parentheses,
|
|
instead of being drawn a second time as a plain DIMENSION.
|
|
"""
|
|
name = str(style or "").strip().lower()
|
|
if name not in DIM_STYLES:
|
|
raise ValueError(f"style: expected one of {DIM_STYLES}, got {style!r}.")
|
|
|
|
intents: list[DimIntent] = list(view.dims)
|
|
kind = "front" if view.kind == "section" else view.kind
|
|
|
|
if isinstance(part, RevolvedPart):
|
|
if kind == "front":
|
|
intents.extend(_revolved_axial(part, name))
|
|
intents.extend(_revolved_diameters(part))
|
|
elif kind == "side":
|
|
last = len(part.segments) - 1
|
|
near = part.segments[last]
|
|
radius = near.d_outer / 2.0
|
|
intents.append(
|
|
DimIntent(
|
|
kind="diameter",
|
|
p1=(-radius, 0.0),
|
|
p2=(radius, 0.0),
|
|
feature=segment_key(last),
|
|
)
|
|
)
|
|
if near.d_inner > _EPS:
|
|
bore = near.d_inner / 2.0
|
|
intents.append(
|
|
DimIntent(
|
|
kind="diameter",
|
|
p1=(-bore, 0.0),
|
|
p2=(bore, 0.0),
|
|
feature=segment_key(last, bore=True),
|
|
)
|
|
)
|
|
elif isinstance(part, PrismaticPart):
|
|
x0, y0, x1, y1 = outline_bbox(part)
|
|
if kind == "front":
|
|
intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x1, y0), feature="part"))
|
|
intents.append(DimIntent(kind="linear", p1=(x0, y0), p2=(x0, y1), feature="part"))
|
|
else:
|
|
# An edge view sees the thickness, measured at the near edge of
|
|
# whichever outline axis this view runs along.
|
|
u0 = y0 if kind == "side" else x0
|
|
intents.append(
|
|
DimIntent(
|
|
kind="linear",
|
|
p1=(u0, 0.0),
|
|
p2=(u0, float(part.thickness)),
|
|
feature="part",
|
|
)
|
|
)
|
|
|
|
groups = _hole_groups(part)
|
|
total_holes = sum(len(holes) for holes in groups.values())
|
|
table_ids: frozenset[str] = frozenset()
|
|
if total_holes > int(hole_table_threshold):
|
|
table_ids = frozenset(hole.id for holes in groups.values() for hole in holes)
|
|
intents = [i for i in intents if i.feature not in table_ids]
|
|
|
|
collected = _mark_references(list(_dedupe(intents)))
|
|
return tuple(_apply_fits(collected, fits, table_ids=table_ids))
|
|
|
|
|
|
def hole_table_rows(part, view, *, hole_table_threshold: int = 8) -> tuple[dict, ...]:
|
|
"""One row per hole, tagged and counted by group - empty under the threshold.
|
|
|
|
``view`` is part of the pinned signature and is accepted unused, so a later
|
|
view-specific filter (a hole that does not appear in this view) has one
|
|
place to go.
|
|
"""
|
|
del view
|
|
groups = _hole_groups(part)
|
|
total = sum(len(holes) for holes in groups.values())
|
|
if total <= int(hole_table_threshold):
|
|
return ()
|
|
rows: list[dict] = []
|
|
for index, key in enumerate(sorted(groups, key=lambda k: (k[0], k[1]))):
|
|
tag = chr(ord("A") + index)
|
|
holes = groups[key]
|
|
diameter, thread, cbore, depth = key
|
|
note_parts = []
|
|
if thread:
|
|
note_parts.append(str(thread))
|
|
if cbore is not None:
|
|
note_parts.append(f"c'bore ⌀{cbore:g}")
|
|
if depth is not None:
|
|
note_parts.append(f"deep {depth:g}")
|
|
note = " ".join(note_parts)
|
|
for hole in holes:
|
|
rows.append(
|
|
{
|
|
"tag": tag,
|
|
"x": float(hole.x),
|
|
"y": float(hole.y),
|
|
"d": float(diameter),
|
|
"qty": len(holes),
|
|
"note": note,
|
|
}
|
|
)
|
|
return tuple(rows)
|
|
|
|
|
|
# -- the layout --------------------------------------------------------------
|
|
|
|
|
|
def _axis_of(intent: DimIntent) -> str:
|
|
""" "bottom" (u = x, level moves -y), "left" (u = y, level moves -x) or "right"."""
|
|
if intent.kind in ("diameter", "radius", "angular"):
|
|
return "right"
|
|
dx = abs(intent.p2[0] - intent.p1[0])
|
|
dy = abs(intent.p2[1] - intent.p1[1])
|
|
return "bottom" if dx >= dy else "left"
|
|
|
|
|
|
def layout_dimensions(
|
|
view, intents, *, offset: float = 10.0, step: float = 8.0, height: float = 3.5
|
|
) -> tuple[DimIntent, ...]:
|
|
"""Give every intent a ``text_at`` such that no two text rectangles overlap."""
|
|
x0, y0, x1, y1 = view.bbox
|
|
del y1
|
|
|
|
# Measure first: a level's pitch has to clear the widest text that stacks
|
|
# across it, and that is the text WIDTH on the left/right axes, where the
|
|
# levels march in x, not the text height.
|
|
measured: list[tuple[DimIntent, str, float, float]] = []
|
|
across: dict[str, float] = {}
|
|
for intent in intents:
|
|
axis = _axis_of(intent)
|
|
width, tall = _text_size(intent, height)
|
|
# ``along`` is the extent parallel to the level, ``across`` the extent
|
|
# perpendicular to it - the one successive levels have to clear.
|
|
along, over = (width, tall) if axis == "bottom" else (tall, width)
|
|
measured.append((intent, axis, along, over))
|
|
across[axis] = max(across.get(axis, 0.0), over)
|
|
pitch = {axis: max(float(step), value + 1.0) for axis, value in across.items()}
|
|
|
|
occupied: dict[tuple[str, int], list[tuple[float, float]]] = {}
|
|
placed: list[DimIntent] = []
|
|
|
|
for intent, axis, along, _over in measured:
|
|
if axis == "bottom":
|
|
center = (float(intent.p1[0]) + float(intent.p2[0])) / 2.0
|
|
else:
|
|
center = (float(intent.p1[1]) + float(intent.p2[1])) / 2.0
|
|
span = (center - along / 2.0, center + along / 2.0)
|
|
|
|
level = 0
|
|
while True:
|
|
taken = occupied.setdefault((axis, level), [])
|
|
if all(span[1] <= a + 1e-9 or span[0] >= b - 1e-9 for a, b in taken):
|
|
taken.append(span)
|
|
break
|
|
level += 1
|
|
|
|
distance = float(offset) + level * pitch[axis]
|
|
if axis == "bottom":
|
|
text_at = (center, y0 - distance)
|
|
elif axis == "left":
|
|
text_at = (x0 - distance, center)
|
|
else:
|
|
text_at = (x1 + distance, center)
|
|
placed.append(intent._replace(text_at=text_at))
|
|
|
|
return tuple(placed)
|