Files
autocad-mcp-pro/engineering/mech/dimension.py
T
Umut CANandClaude Opus 5 bd73b3a854 fix(task-6): a reachable ISO 286 route, a tolerance schema that refuses, a reference dimension
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
2026-09-22 23:04:25 +03:00

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)