Files
autocad-mcp-pro/engineering/mech/dimension.py
T
Umut CANandClaude Opus 5.5 898e775f93 bench: matrix v5 adds mech_assembly; the correctness suite grows with the mechanical and sheet checks
The evidence for tracks B and G, in the shape tracks A and E used.

- benchmarks/tasks_v5.py: 18 tasks, DEFAULT_MATRIX = "v5"; v4, v3 and v2 stay
  addressable so an earlier report reproduces exactly rather than being only
  described. mech_assembly is roadmap criterion 3 - a flange-coupling sheet
  built from the part model and the sheet standard, through the code the
  tools call (apply_titleblock, draw_part/add_view/dimension_part,
  insert_std_part, extract_records -> draw_bom_table, add_balloon), gated on
  two numbers that cannot be negotiated: drawing_critique(focus=None) returns
  zero issues and the finalize score is at least 90 (it scores 100, 18/18 on
  the v5 matrix). The adapter returns the focuses that fired, so a
  regression names itself, and a test drops one balloon to prove the gate
  fails and names mech_bom_balloon_mismatch.
- Seven correctness checks (32 -> 39). mech_section_hatch_area is the one
  that cannot be faked: a sleeve of length 60, outside diameter 40 and bore
  20 cuts two faces totalling 1200 mm^2 exactly, read back out of the drawing
  through entity_measure. sheet_frame_iso5457_a3 pins the A3 frame (20,10 to
  410,287 inside the 420 x 297 trimmed sheet); mech_iso286_on_dimension pins
  40 H7 at +0.025/0 through the layout; std_part_iso4014_m12 pins s = 18 and
  k = 7.5 by value; mech_thread_unrepresented_is_caught is the seventh: a
  quality gate that never fires is not a gate.
- scripts/smoke_mech_com.py executed every new COM path once on AutoCAD 2026
  (25.1s): frame, title block, a sectioned shaft with 12 dimensions, an ISO
  4014 block with 1 ACADMCP_MECH chunk, a weld symbol, the parts list read off
  the drawing, a linked balloon, an xref attached / listed / detached, a
  31687-byte .dwg (AC1032) through ActiveX SaveAs, critique 0 issues, exit 0,
  every scratch document closed. It retries RPC_E_CALL_REJECTED (measured:
  the first call after Documents.Add is refused) and tracks its documents by
  the document list, since SaveAs renames them.
- Defects the evidence found, each fixed with a test that fails on the old
  code:
  * ezdxf dimension_diameter put the text beyond the SECOND chord point;
    ActiveX AddDimDiametric puts it beyond the first (measured live: a d50
    from (x,170) to (x,220), leader 10, text at (x,160)). Headlessly every
    front-view diameter stood on its own chain dimension. ezdxf now follows
    ActiveX.
  * draw_part's anchor goes on MECH; ActiveX refuses entity.Layer on a
    missing layer (measured -0x7ffdfff7 'Key not found'). draw.py ensures
    every target layer before the first entity.
  * bom.extract_records filtered on INSERT; ActiveX names it BLOCKREFERENCE
    (ObjectName AcDbBlockReference) - the live parts list was empty.
  * COM _entity_info reported no dimension text_position: ModelSpace.Item
    narrows to IAcadEntity, which has no TextPosition (measured
    AttributeError); read through _com_unnarrow (TextPosition measured on
    AcDbDiametricDimension, WCS). dim_overlap compared bbox centres and a
    diameter collided with its own bore.
  * pid graph raised KeyError 'start' mid-critique on a LINE whose point read
    AutoCAD refused while busy; such a line is left out.
  * the validator read "ISO 4014 - M12x60" as fake dimension text.
- The published A/B report is re-run over the grown suite rather than
  re-typed (26/39 -> 39/39, 13 fixed, 0 regressed), check_doc_numbers derives
  the task count from the v5 matrix, the token table is remeasured with a
  TOOL_PACKS=core,mech row (193 tools / 61,673 tokens), and the README's
  collected-tests figure is retaken (3990).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-23 13:30:02 +03:00

658 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]:
"""Each segment's outside diameter and bore, measured across the axis.
``p1`` is the upper chord end: a diameter's text stands beyond ``p1`` (the
ActiveX ``AddDimDiametric`` ChordPoint rule, which both engines follow), so
every front-view diameter is annotated above the view, clear of the axial
chain rows below it.
"""
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)