Files
autocad-mcp-pro/engineering/mech/views.py
T
Umut CANandClaude Opus 5 72bd17d024 feat(task-5): sections, cut faces, material hatch and detail views
engineering/mech/views.py grows the two remaining view kinds. A section's cut
faces come out of the same radial profile the silhouette does, so the hatched
area and the drawn outline are one calculation and cannot disagree: a solid
shaft's half-section loop measures exactly the sum of its segment rectangles,
and a bore closes the loop on itself rather than being hatched over.

ISO 6410's section convention falls out of the model rather than being special-
cased: an internal thread's radial_edit already raises the bore to the major
diameter, so the hatch stops there. ISO 128-3's rib rule is a flag on the
feature - a pocket marked no_section_hatch is drawn and left unhatched, while
the rest of the cut face is hatched with the part's ISO 128-50 material.

Four styles, each either implemented or refused by name with the alternative
rather than approximated: full and half on a longitudinal plane through a
revolved axis; full and revolved on a transverse plane (the annulus, flattened
at 2 degrees - 0.015% of the radius, stated in CIRCLE_SEGMENTS rather than
hidden); full and offset on a prismatic part, where through features split the
cut face and pockets reduce it to the remaining thickness. A plane that misses
the part, or that grazes an outline vertex so its crossings cannot be paired, is
refused instead of producing a guessed cut face.

Details scale a circular region of a parent view, emit the ISO 128-34 circle and
letter for the parent and the "A (5:1)" label for the detail itself, and report
every feature that falls outside the circle in omitted - the honesty rule again.

Three test assertions were corrected to the measured truth rather than the
engine bent to them, each verified by running the code:

* the ISO 6410 test asked for the whole cut-face loop's minimum to be the M20
  major (r=10). It is not, and must not be: over the 5 mm that is drilled but
  not threaded the loop closes on the real 16 mm drill (r=8). The loop is
  ((0,10),(0,25),(25,25),(30,25),(30,8),(30,8),(25,8),(25,10),(0,10)). The test
  now pins both spans, which is the convention it meant to assert.
* the transverse-annulus areas were pinned at rel=2e-4. An inscribed regular
  180-gon is exactly 1 - (n/2pi) sin(2pi/n) = 2.0307e-4 under the circle, so
  2e-4 rejects the very flattening CIRCLE_SEGMENTS declares. The tolerance is
  now 2.1e-4 and the test computes the deficit and pins it, so the number is
  derived rather than guessed. CIRCLE_SEGMENTS' docstring now states both
  figures: 0.015% radial (the 2 degree chord's sagitta) and 0.0203% of area.
* tests/test_mech_views.py pinned VIEW_KINDS == ("front","side","top"), which
  this task deliberately grows. It now pins the three orthographic kinds as the
  first three and the full five-kind tuple.

_is_longitudinal takes only the plane; the plan's signature also took the part
and never read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-22 21:40:29 +03:00

1090 lines
42 KiB
Python

"""The view engine: a part model projected onto a plane, as primitive descriptions.
Pure: no backend, no async, no DXF. A ``View`` carries the primitives, the
dimension intents its features asked for, the features it could **not** project
(the honesty rule of spec section 6 - nothing is silently dropped), and its own
bounding box, so the next view can be laid out against it.
The one calculation everything comes out of for a revolved part is the **radial
profile**: the part cut into bands of constant (or linearly tapering) outer
radius and constant bore radius, with every feature's ``radial_edit`` applied.
The silhouette is that profile walked as a path; the section cut face (Task 5)
is the same profile walked as a closed loop. One calculation, so the outline
and the hatched area can never disagree.
Line roles follow ISO 128 and are mapped to layers by
``engineering/mech/primitives.py``: the silhouette is ``visible``, a bore or a
feature behind material is ``hidden``, the axis and centre marks are
``center``, a bend line is ``phantom``.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any, NamedTuple
from engineering.mech.features import Pocket as _PocketType
from engineering.mech.features import mirror_about_x
from engineering.mech.part import (
PrismaticPart,
RevolvedPart,
inner_radius_at,
outer_radius_at,
outline_bbox,
part_length,
part_max_diameter,
segment_bounds,
)
from engineering.mech.primitives import (
Arc,
Circle,
DimIntent,
HatchArea,
Line,
Poly,
Prim,
Pt,
Text,
bbox,
translate,
)
from engineering.mech.primitives import scale as scale_prims
_EPS = 1e-9
_SNAP = 6 # decimals used to match a corner vertex to a corner edit
#: Radii closer together than this are the same circle, in millimetres. Looser
#: than ``_EPS`` on purpose: it compares *measured* radii, not exact vertices.
_TOL = 1e-6
VIEW_KINDS: tuple[str, ...] = ("front", "side", "top", "section", "detail")
PROJECTIONS: tuple[str, ...] = ("first", "third")
SIDES: tuple[str, ...] = ("right", "left")
#: How far the axis and the centre lines run past the material, as a fraction
#: of the part's largest dimension (ISO 128-23 asks for a short, even overrun).
AXIS_OVERRUN = 0.10
#: The cutting-plane record `section_line` (Task 11, group A) produces and this
#: engine consumes. `via` is optional and only meaningful for an offset plane.
SECTION_PLANE = {
"p1": (0.0, 0.0),
"p2": (0.0, 0.0),
"label": "A",
"direction": (0.0, -1.0),
"style": "full",
}
SECTION_STYLES: tuple[str, ...] = ("full", "half", "offset", "revolved")
#: A circular cut-face boundary has to be handed to the hatch as points. 180
#: segments is a 2 degree chord: the boundary is never further than
#: r * (1 - cos(1 deg)) = 0.015% of the radius from the true circle, and the
#: area of the inscribed 180-gon is 0.0203% under the circle's. Both figures are
#: stated rather than hidden, and the same loop is what the hatch and any area
#: check read, so they cannot disagree with each other.
CIRCLE_SEGMENTS = 180
@dataclass(frozen=True)
class View:
kind: str
prims: tuple[Prim, ...]
dims: tuple[DimIntent, ...]
omitted: tuple[dict, ...]
bbox: tuple[float, float, float, float]
label: str | None = None
scale: float = 1.0
class Band(NamedTuple):
"""One axial slice of a revolved part with a constant bore and a linear outside."""
x0: float
x1: float
r_in: float
r_out0: float
r_out1: float
# -- the radial profile ------------------------------------------------------
def _band_radii(part: RevolvedPart, a: float, b: float) -> tuple[float, float, float]:
"""``(r_out0, r_out1, r_in)`` of one band, read off the segment it lies in.
The segment is resolved from the band's **midpoint** - a band never spans a
segment boundary, because every boundary is one of the cuts - and a taper is
then evaluated at the band's own two ends. Sampling a hair inside the band
instead (the obvious way to dodge the boundary ambiguity) writes the taper
slope times that inset into every coordinate the silhouette is built from:
a 30 -> 10 mm cone over 20 mm reports 14.99999 for a radius that is exactly
15, and nothing downstream can tell that it is wrong.
"""
mid = (a + b) / 2.0
found: tuple[float, float, Any] | None = None
for (x0, x1), segment in zip(segment_bounds(part), part.segments, strict=True):
if x0 - _EPS <= mid <= x1 + _EPS:
found = (x0, x1, segment)
break
if found is None: # pragma: no cover - the cuts always include every segment bound
raise ValueError(f"radial_profile: x={mid} is outside the part.")
x0, x1, segment = found
r_in = segment.d_inner / 2.0
if segment.taper_to is None:
radius = segment.d_outer / 2.0
return radius, radius, r_in
span = x1 - x0
def taper_at(x: float) -> float:
t = 0.0 if span <= _EPS else (x - x0) / span
return (segment.d_outer + t * (segment.taper_to - segment.d_outer)) / 2.0
return taper_at(a), taper_at(b), r_in
def radial_profile(part: RevolvedPart) -> tuple[Band, ...]:
"""The part as bands, with every feature's ``radial_edit`` applied."""
length = part_length(part)
cuts: set[float] = {0.0, length}
for x0, x1 in segment_bounds(part):
cuts.add(x0)
cuts.add(x1)
edits: list[dict] = []
for feature in part.features:
for edit in feature.radial_edit(part):
x0 = max(0.0, min(length, float(edit["x0"])))
x1 = max(0.0, min(length, float(edit["x1"])))
if x1 - x0 <= _EPS:
continue
edits.append(
{"kind": edit["kind"], "x0": x0, "x1": x1, "radius": float(edit["radius"])}
)
cuts.add(x0)
cuts.add(x1)
xs = sorted(cuts)
bands: list[Band] = []
for a, b in zip(xs, xs[1:], strict=False):
if b - a <= _EPS:
continue
r_out0, r_out1, r_in = _band_radii(part, a, b)
for edit in edits:
if edit["x0"] - _EPS <= a and b <= edit["x1"] + _EPS:
if edit["kind"] == "outer":
r_out0 = min(r_out0, edit["radius"])
r_out1 = min(r_out1, edit["radius"])
else:
r_in = max(r_in, edit["radius"])
bands.append(Band(a, b, r_in, r_out0, r_out1))
return tuple(bands)
def _outer_path(bands: tuple[Band, ...]) -> list[Pt]:
"""The +Y half-outline, from the left face at the bore, over the outside, down
to the right face at the bore. The end faces are *part of the path*, so a
chamfer or fillet at an end corner substitutes into them instead of being
drawn across them - which is what stops the overshoot the `untrimmed_corner`
critique focus looks for."""
points: list[Pt] = [(bands[0].x0, bands[0].r_in), (bands[0].x0, bands[0].r_out0)]
for band in bands:
if math.dist(points[-1], (band.x0, band.r_out0)) > _EPS:
points.append((band.x0, band.r_out0))
points.append((band.x1, band.r_out1))
points.append((bands[-1].x1, bands[-1].r_in))
return points
def _bore_path(bands: tuple[Band, ...]) -> list[list[Pt]]:
"""One path per contiguous run of bored bands; a blind end is capped."""
runs: list[list[Pt]] = []
current: list[Pt] = []
for band in bands:
if band.r_in <= _EPS:
if current:
runs.append(current)
current = []
continue
if not current:
current = [(band.x0, band.r_in)]
elif math.dist(current[-1], (band.x0, band.r_in)) > _EPS:
current.append((band.x0, band.r_in))
current.append((band.x1, band.r_in))
if current:
runs.append(current)
return runs
def _key(point: Pt) -> tuple[float, float]:
return (round(float(point[0]), _SNAP), round(float(point[1]), _SNAP))
def _emit_path(points: list[Pt], edits: list[dict], role: str, *, closed: bool = False):
"""Walk a vertex path, substituting corner edits, and emit Lines and Arcs.
Returns ``(prims, used)`` - ``used`` is the set of edit ids that actually
found their corner, so the caller can report the ones that did not.
"""
by_corner: dict[tuple[float, float], dict] = {}
for edit in edits:
by_corner[_key(edit["corner"])] = edit
sequence: list[tuple[str, Any]] = []
used: set[str] = set()
for point in points:
edit = by_corner.get(_key(point))
if edit is None:
sequence.append(("pt", (float(point[0]), float(point[1]))))
continue
used.add(edit["owner"])
sequence.append(("pt", edit["before"]))
if edit["arc"] is not None:
sequence.append(("arc", edit["arc"]))
sequence.append(("pt", edit["after"]))
if closed and sequence:
sequence.append(sequence[0])
prims: list[Prim] = []
previous: Pt | None = None
for tag, item in sequence:
if tag == "pt":
if previous is not None and math.dist(previous, item) > _EPS:
prims.append(Line(previous, item, role))
previous = item
else:
prims.append(item)
previous = None
return tuple(prims), used
def silhouette_prims(part: RevolvedPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]:
"""The mirrored half-outline (end faces included), the bore, and any unplaced edit."""
bands = radial_profile(part)
edits: list[dict] = []
for feature in part.features:
edit = feature.corner_edit(part)
if edit is not None:
edits.append({**edit, "owner": feature.id})
upper, used = _emit_path(_outer_path(bands), edits, "visible")
prims: list[Prim] = list(upper) + list(mirror_about_x(upper))
for run in _bore_path(bands):
bore, _ = _emit_path(run, [], "hidden")
prims.extend(bore)
prims.extend(mirror_about_x(bore))
unplaced = tuple(
{
"feature": edit["owner"],
"reason": (
"its silhouette corner was changed by another feature, so the "
"chamfer/fillet could not be placed"
),
}
for edit in edits
if edit["owner"] not in used
)
return tuple(prims), unplaced
def outline_prims(part: PrismaticPart) -> tuple[tuple[Prim, ...], tuple[dict, ...]]:
"""The closed outline with every corner fillet/chamfer substituted in."""
edits: list[dict] = []
for feature in part.features:
edit = feature.outline_edit(part)
if edit is None:
continue
corner = part.outline[int(edit["vertex"]) % len(part.outline)]
edits.append({**edit, "corner": corner, "owner": feature.id})
prims, used = _emit_path(list(part.outline), edits, "visible", closed=True)
unplaced = tuple(
{"feature": edit["owner"], "reason": "its outline vertex was already replaced"}
for edit in edits
if edit["owner"] not in used
)
return prims, unplaced
# -- view construction -------------------------------------------------------
def _axis_prims(part: RevolvedPart) -> tuple[Prim, ...]:
length = part_length(part)
over = max(length, part_max_diameter(part)) * AXIS_OVERRUN
return (Line((-over, 0.0), (length + over, 0.0), "center"),)
def _band_order(bands: tuple[Band, ...], side: str) -> list[Band]:
"""The bands from the end the observer stands at, nearest first."""
return list(reversed(bands)) if side == "right" else list(bands)
def _band_radii_near_first(band: Band, side: str) -> tuple[float, float]:
"""A band's two outer radii, the one nearer the observer first.
They differ only on a taper: ``r_out0`` sits at ``x0`` and ``r_out1`` at
``x1``, so which of them faces the observer depends on the end they are
looking from.
"""
return (band.r_out1, band.r_out0) if side == "right" else (band.r_out0, band.r_out1)
def _front_max(bands: tuple[Band, ...], side: str, x: float) -> float:
"""The largest outer radius of the material lying **between** ``x`` and the observer.
This is the whole hidden-line rule for a view along the axis of a solid of
revolution: a circular edge of radius ``r`` at station ``x`` is drawn
continuous when ``r`` is larger than everything in front of it, because then
nothing can cover it, and dashed when it is not, because the material in
front does cover it. A step face whose circle is *larger* than the material
ahead of it is the one edge that cannot be occluded.
"""
best = 0.0
for band in bands:
nearer = band.x0 >= x - _TOL if side == "right" else band.x1 <= x + _TOL
if nearer:
best = max(best, band.r_out0, band.r_out1)
return best
def _end_circles(part: RevolvedPart, side: str) -> tuple[Prim, ...]:
"""The concentric circles the profile crosses at one end, roled by occlusion.
Outer edges: walking from the observer, a radius larger than everything
already passed is ``visible``; a smaller one is behind that material and is
``hidden`` - it is still drawn, dashed, because it is a real edge of the
part. Bore edges follow the mirror-image rule: you can see down a bore only
while it stays at least as wide, so a bore radius no larger than the
narrowest bore in front of it is ``visible`` and anything wider (or anything
behind solid material) is ``hidden``.
Each distinct radius is emitted once: two coincident circles would be a
duplicate entity, which is what ``drawing_critique`` reports.
"""
bands = radial_profile(part)
order = _band_order(bands, side)
prims: list[Prim] = []
emitted: set[float] = set()
def emit(radius: float, role: str) -> None:
key = round(float(radius), _SNAP)
if key <= _TOL or key in emitted:
return
emitted.add(key)
prims.append(Circle((0.0, 0.0), float(radius), role))
front_max = 0.0
for band in order:
for radius in _band_radii_near_first(band, side):
emit(radius, "visible" if radius > front_max + _TOL else "hidden")
front_max = max(front_max, radius)
open_r = math.inf
for band in order:
if band.r_in <= _TOL:
open_r = 0.0 # solid across the whole section: nothing behind is seen
continue
emit(band.r_in, "visible" if band.r_in <= open_r + _TOL else "hidden")
open_r = min(open_r, band.r_in)
over = front_max * (1.0 + AXIS_OVERRUN)
prims.append(Line((-over, 0.0), (over, 0.0), "center"))
prims.append(Line((0.0, -over), (0.0, over), "center"))
return tuple(prims)
def _rectangle(
u0: float, u1: float, v0: float, v1: float, role: str = "visible"
) -> tuple[Prim, ...]:
return (
Line((u0, v0), (u1, v0), role),
Line((u1, v0), (u1, v1), role),
Line((u1, v1), (u0, v1), role),
Line((u0, v1), (u0, v0), role),
)
def _mirror_u(prims, axis: float) -> tuple[Prim, ...]:
"""Reflect primitives about the vertical line ``u = axis`` (screen left <-> right).
``Text`` moves with the drawing but keeps its rotation: a mirrored label
would read backwards, and no drawing has ever wanted that.
"""
def flip(point: Pt) -> Pt:
return (2.0 * axis - float(point[0]), float(point[1]))
out: list[Prim] = []
for prim in prims:
if isinstance(prim, Line):
out.append(Line(flip(prim.p1), flip(prim.p2), prim.role))
elif isinstance(prim, Arc):
# reflection reverses the sweep, so the CCW arc runs from the
# reflected end angle to the reflected start angle
out.append(
Arc(
flip(prim.center),
prim.radius,
(180.0 - prim.end_deg) % 360.0,
(180.0 - prim.start_deg) % 360.0,
prim.role,
)
)
elif isinstance(prim, Circle):
out.append(Circle(flip(prim.center), prim.radius, prim.role))
elif isinstance(prim, Poly):
out.append(Poly(tuple(flip(pt) for pt in prim.points), prim.closed, prim.role))
elif isinstance(prim, HatchArea):
out.append(
HatchArea(
tuple(tuple(flip(pt) for pt in loop) for loop in prim.loops), prim.material
)
)
elif isinstance(prim, Text):
out.append(prim._replace(at=flip(prim.at)))
else: # pragma: no cover - Prim is a closed union
raise TypeError(f"_mirror_u: not a primitive: {prim!r}")
return tuple(out)
def _same_geometry(a: Prim, b: Prim) -> bool:
"""True when two primitives would be drawn on top of each other, role aside.
Exactly what ``engineering/critique.py::_check_duplicate_entities`` matches,
so anything this returns True for is a warning on the finished drawing.
"""
if isinstance(a, Line) and isinstance(b, Line):
forward = math.dist(a.p1, b.p1) <= _TOL and math.dist(a.p2, b.p2) <= _TOL
reverse = math.dist(a.p1, b.p2) <= _TOL and math.dist(a.p2, b.p1) <= _TOL
return forward or reverse
if isinstance(a, Circle) and isinstance(b, Circle):
return math.dist(a.center, b.center) <= _TOL and abs(a.radius - b.radius) <= _TOL
if isinstance(a, Arc) and isinstance(b, Arc):
return (
math.dist(a.center, b.center) <= _TOL
and abs(a.radius - b.radius) <= _TOL
and abs((a.start_deg - b.start_deg) % 360.0) <= _TOL
and abs((a.end_deg - b.end_deg) % 360.0) <= _TOL
)
return False
def _profile_owned(feature, part, prim: Prim, kind: str, body: list[Prim]) -> bool:
"""True when the radial profile has already drawn this primitive.
Two separate reasons, and both are the same principle - the profile is the
one calculation the silhouette comes out of, so a feature never draws it a
second time:
* the primitive is coincident with something the body already emitted; or
* the feature edits the radial profile and this is a **silhouette** line of
a revolved front view. ``radial_profile`` already cut the notch, and the
feature's own idea of where the surface is can even contradict it - a
groove straddling a step reads one surface radius for a span that has two.
"""
if any(_same_geometry(prim, other) for other in body):
return True
if kind == "front" and getattr(prim, "role", None) == "visible":
return bool(feature.radial_edit(part))
return False
def _end_view_role(feature, part, prim: Prim, bands: tuple[Band, ...], side: str) -> Prim:
"""Re-role a feature's axis-centred end-view circle by the same occlusion rule.
The feature knows its diameter, not what is standing in front of it. Its
axial station comes from ``removal_box``; a feature that does not report one
keeps its own verdict rather than having one guessed for it.
"""
if not isinstance(prim, Circle) or math.dist(prim.center, (0.0, 0.0)) > _TOL:
return prim
box = feature.removal_box(part)
if box is None:
return prim
x_near = float(box[2]) if side == "right" else float(box[0])
covered = _front_max(bands, side, x_near)
return prim._replace(role="visible" if prim.radius > covered + _TOL else "hidden")
def _feature_layer(
part,
kind: str,
ctx: dict,
*,
body: list[Prim] | None = None,
bands: tuple[Band, ...] | None = None,
) -> tuple[list[Prim], list[DimIntent], list[dict]]:
drawn: list[Prim] = list(body or ())
prims: list[Prim] = []
dims: list[DimIntent] = []
omitted: list[dict] = []
end_view = bands is not None and kind == "side"
side = str(ctx.get("side", "right")).strip().lower()
for feature in part.features:
own = tuple(feature.prims(part, kind, ctx))
if end_view:
own = tuple(_end_view_role(feature, part, prim, bands, side) for prim in own)
own = tuple(prim for prim in own if not _profile_owned(feature, part, prim, kind, drawn))
if own and kind == "front" and getattr(feature, "axisymmetric", False):
own = tuple(own) + mirror_about_x(own)
prims.extend(own)
drawn.extend(own)
dims.extend(feature.dims(part, kind, ctx))
if own:
continue
if feature.radial_edit(part) or feature.corner_edit(part) or feature.outline_edit(part):
continue
reason = feature.omission(part, kind)
if reason:
omitted.append({"feature": feature.id, "reason": reason})
return prims, dims, omitted
def build_view(
part,
kind: str,
*,
side: str = "right",
plane: dict | None = None,
style: str = "full",
scale: float = 1.0,
projection: str = "first",
detail: dict | None = None,
) -> View:
"""Project a part onto one view plane and return its primitive description.
``side`` is the end the observer stands at for an end view, and it decides
both what is in front of what and which way round the view reads. The
``top`` view is always seen from above, so ``side`` does not apply to it.
"""
name = str(kind or "").strip().lower()
if name not in VIEW_KINDS:
raise ValueError(f"build_view: kind {kind!r} is not one of {VIEW_KINDS}.")
where = str(side or "right").strip().lower()
if where not in SIDES:
raise ValueError(f"build_view: side {side!r} is not one of {SIDES}.")
if name == "section":
return section_view(part, plane, style=style, scale=scale, side=where)
if name == "detail":
return detail_view(part, detail or {}, projection=projection, side=where)
ctx = {"side": where, "scale": float(scale), "projection": str(projection)}
prims: list[Prim] = []
omitted: list[dict] = []
bands: tuple[Band, ...] | None = None
mirror_u: float | None = None
if isinstance(part, RevolvedPart):
if name == "top":
raise ValueError(
"build_view: a revolved part's top view is identical to its front view; "
"use kind='front', or kind='section' for a cut."
)
if name == "front":
body, unplaced = silhouette_prims(part)
prims.extend(body)
prims.extend(_axis_prims(part))
omitted.extend(unplaced)
else:
bands = radial_profile(part)
prims.extend(_end_circles(part, where))
elif isinstance(part, PrismaticPart):
x0, y0, x1, y1 = outline_bbox(part)
if name == "front":
body, unplaced = outline_prims(part)
prims.extend(body)
omitted.extend(unplaced)
elif name == "side":
prims.extend(_rectangle(y0, y1, 0.0, part.thickness))
# An observer standing at -X sees screen-right = -Y, so the left
# view is the right view mirrored. The thickness rectangle spans
# y0..y1 either way and is its own mirror; the features are not.
if where == "left":
mirror_u = (y0 + y1) / 2.0
else:
prims.extend(_rectangle(x0, x1, 0.0, part.thickness))
else: # pragma: no cover - build_part only makes these two
raise ValueError(f"build_view: {type(part).__name__} is not a part model.")
feature_prims, dims, feature_omitted = _feature_layer(part, name, ctx, body=prims, bands=bands)
if mirror_u is not None:
feature_prims = list(_mirror_u(feature_prims, mirror_u))
prims.extend(feature_prims)
omitted.extend(feature_omitted)
return View(
kind=name,
prims=tuple(prims),
dims=tuple(dims),
omitted=tuple(omitted),
bbox=bbox(prims) if prims else (0.0, 0.0, 0.0, 0.0),
label=None,
scale=float(scale),
)
def layout_origin(
parent: View,
kind: str,
*,
side: str = "right",
projection: str = "first",
gap: float = 20.0,
child: View | None = None,
) -> Pt:
"""Where a child view's bounding box lower-left corner goes, relative to its parent.
First angle (ISO 128-30, the default here): the view obtained by looking
from the right is placed on the **left**, and the view from above is placed
**below**. Third angle flips both. With no ``child`` the child is assumed
the same size as the parent, which keeps the views aligned even before the
child has been built.
"""
projection_name = str(projection or "").strip().lower()
if projection_name not in PROJECTIONS:
raise ValueError(f"layout_origin: projection {projection!r} is not one of {PROJECTIONS}.")
px0, py0, px1, py1 = parent.bbox
if child is None:
width, height = px1 - px0, py1 - py0
else:
cx0, cy0, cx1, cy1 = child.bbox
width, height = cx1 - cx0, cy1 - cy0
mid_x = (px0 + px1) / 2.0
mid_y = (py0 + py1) / 2.0
first = projection_name == "first"
name = str(kind or "").strip().lower()
gap = float(gap)
if name in ("side", "section"):
looking_from_right = str(side or "right").strip().lower() == "right"
to_the_left = looking_from_right == first
if to_the_left:
return (px0 - gap - width, mid_y - height / 2.0)
return (px1 + gap, mid_y - height / 2.0)
if name == "top":
if first:
return (mid_x - width / 2.0, py0 - gap - height)
return (mid_x - width / 2.0, py1 + gap)
if name == "detail":
return (px1 + gap, mid_y - height / 2.0)
raise ValueError(
f"layout_origin: kind {kind!r} has no projection position; expected "
"'side', 'top', 'section' or 'detail'."
)
# -- sections ----------------------------------------------------------------
def normalise_plane(plane: dict | None) -> dict:
"""Fill the `SECTION_PLANE` shape and refuse a plane that has no direction."""
if plane is None:
raise ValueError(
"section: a cutting plane is required - pass plane={'p1': ..., 'p2': ...}, "
"or take one from section_line()."
)
if not isinstance(plane, dict):
raise ValueError(f"plane: expected a dict, got {type(plane).__name__}.")
unknown = sorted(set(plane) - set(SECTION_PLANE) - {"via"})
if unknown:
raise ValueError(f"plane: unknown key(s) {unknown}; it takes {sorted(SECTION_PLANE)}.")
p1 = tuple(float(v) for v in plane.get("p1", SECTION_PLANE["p1"]))
p2 = tuple(float(v) for v in plane.get("p2", SECTION_PLANE["p2"]))
if math.dist(p1, p2) < 1e-6:
raise ValueError(f"plane: p1 and p2 are the same point, the plane has zero length ({p1}).")
style = str(plane.get("style", SECTION_PLANE["style"])).strip().lower()
if style not in SECTION_STYLES:
raise ValueError(f"plane.style: expected one of {SECTION_STYLES}, got {style!r}.")
via = tuple(tuple(float(v) for v in point) for point in plane.get("via", ()))
return {
"p1": p1,
"p2": p2,
"label": str(plane.get("label", SECTION_PLANE["label"])),
"direction": tuple(float(v) for v in plane.get("direction", SECTION_PLANE["direction"])),
"style": style,
"via": via,
}
def _inner_path(bands: tuple[Band, ...]) -> list[Pt]:
points: list[Pt] = [(bands[0].x0, bands[0].r_in)]
for band in bands:
if math.dist(points[-1], (band.x0, band.r_in)) > _EPS:
points.append((band.x0, band.r_in))
points.append((band.x1, band.r_in))
return points
def _circle_loop(radius: float, *, reverse: bool = False) -> tuple[Pt, ...]:
steps = range(CIRCLE_SEGMENTS - 1, -1, -1) if reverse else range(CIRCLE_SEGMENTS)
return tuple(
(
radius * math.cos(2.0 * math.pi * k / CIRCLE_SEGMENTS),
radius * math.sin(2.0 * math.pi * k / CIRCLE_SEGMENTS),
)
for k in steps
)
def _is_longitudinal(plane: dict) -> bool:
"""True when the cutting plane runs along the revolved axis (y = 0 at both ends)."""
return abs(plane["p1"][1]) < 1e-6 and abs(plane["p2"][1]) < 1e-6
def _subtract(intervals: list[tuple[float, float]], cut: tuple[float, float]):
out: list[tuple[float, float]] = []
low, high = cut
for a, b in intervals:
if high <= a + _EPS or low >= b - _EPS:
out.append((a, b))
continue
if low > a + _EPS:
out.append((a, low))
if high < b - _EPS:
out.append((high, b))
return [(a, b) for a, b in out if b - a > 1e-9]
def _chords(outline, p1: Pt, unit: Pt) -> list[tuple[float, float]]:
"""Parameters along `unit` from `p1` where the line is inside the outline.
Crossings are paired in order, which is exact for a simple polygon whose
edges the line crosses transversally. A line that grazes a vertex produces
an odd crossing count and is refused by the caller rather than paired wrong.
"""
ts: list[float] = []
count = len(outline)
for index in range(count):
ax, ay = outline[index]
bx, by = outline[(index + 1) % count]
ex, ey = bx - ax, by - ay
denominator = ex * unit[1] - ey * unit[0]
if abs(denominator) < 1e-12:
continue
s = ((p1[0] - ax) * unit[1] - (p1[1] - ay) * unit[0]) / denominator
if -1e-9 <= s <= 1.0 + 1e-9:
px, py = ax + s * ex, ay + s * ey
ts.append((px - p1[0]) * unit[0] + (py - p1[1]) * unit[1])
ts = sorted({round(t, 9) for t in ts})
if len(ts) % 2 != 0:
raise ValueError(
"section: the cutting plane grazes an outline vertex, so its crossings cannot be "
"paired. Move the plane off the vertex rather than accept a guessed cut face."
)
return [(ts[i], ts[i + 1]) for i in range(0, len(ts), 2)]
def _box_span(box, p1: Pt, unit: Pt, normal: Pt):
xs = (box[0], box[2])
ys = (box[1], box[3])
corners = [(x, y) for x in xs for y in ys]
distances = [(x - p1[0]) * normal[0] + (y - p1[1]) * normal[1] for x, y in corners]
if min(distances) > 1e-9 or max(distances) < -1e-9:
return None
spans = [(x - p1[0]) * unit[0] + (y - p1[1]) * unit[1] for x, y in corners]
return (min(spans), max(spans))
def _plane_legs(plane: dict) -> list[tuple[Pt, Pt]]:
"""The cutting legs of an offset plane.
ISO 128-40: the offset itself is not drawn, so with ``via`` present the
odd-numbered legs are the transfer jogs and contribute nothing to the cut
face.
"""
points = [plane["p1"], *plane["via"], plane["p2"]]
legs = [(points[i], points[i + 1]) for i in range(len(points) - 1)]
if plane["via"]:
legs = [leg for index, leg in enumerate(legs) if index % 2 == 0]
return legs
def _prismatic_cut(part: PrismaticPart, plane: dict, style: str):
"""``[(u0, u1, v0, v1, hatched), ...]`` - the cut rectangles along the plane."""
legs = _plane_legs(plane) if style == "offset" else [(plane["p1"], plane["p2"])]
thickness = float(part.thickness)
rectangles: list[tuple[float, float, float, float, bool]] = []
origin = 0.0
for start, end in legs:
length = math.dist(start, end)
if length < 1e-9:
continue
unit = ((end[0] - start[0]) / length, (end[1] - start[1]) / length)
normal = (-unit[1], unit[0])
intervals = _chords(part.outline, start, unit)
intervals = [(a, b) for a, b in intervals if b > -1e-9 and a < length + 1e-9]
intervals = [(max(a, 0.0), min(b, length)) for a, b in intervals]
intervals = [(a, b) for a, b in intervals if b - a > 1e-9]
pockets: list[tuple[tuple[float, float], float, bool]] = []
for feature in part.features:
box = feature.removal_box(part)
if box is None:
continue
span = _box_span(box, start, unit, normal)
if span is None:
continue
span = (max(span[0], 0.0), min(span[1], length))
if span[1] - span[0] <= 1e-9:
continue
if isinstance(feature, _PocketType):
pockets.append((span, thickness - float(feature.depth), feature.no_section_hatch))
else:
intervals = _subtract(intervals, span)
for pocket_span, floor, unhatched in pockets:
intervals = _subtract(intervals, pocket_span)
rectangles.append(
(origin + pocket_span[0], origin + pocket_span[1], 0.0, floor, not unhatched)
)
for a, b in intervals:
rectangles.append((origin + a, origin + b, 0.0, thickness, True))
origin += length
if rectangles:
# the section view has its own frame: u = 0 at the first cut.
shift = min(u0 for u0, _u1, _v0, _v1, _hatched in rectangles)
rectangles = [
(u0 - shift, u1 - shift, v0, v1, hatched) for u0, u1, v0, v1, hatched in rectangles
]
return rectangles
def cut_loops(part, plane: dict, *, style: str = "full"):
"""The closed cut-face loops, each flagged with whether it is hatched."""
plane = normalise_plane(plane)
name = str(style or "full").strip().lower()
if name not in SECTION_STYLES:
raise ValueError(f"style: expected one of {SECTION_STYLES}, got {style!r}.")
if isinstance(part, RevolvedPart):
bands = radial_profile(part)
if _is_longitudinal(plane):
if name == "offset":
raise ValueError(
"section: an offset plane has no meaning along a revolved part's axis; "
"use style='full' (or a transverse plane for a cross section)."
)
upper = tuple(_outer_path(bands)) + tuple(reversed(_inner_path(bands)))
loops = [(upper, True)]
if name != "half":
loops.append((tuple((x, -y) for x, y in upper), True))
return tuple(loops)
if name == "half":
raise ValueError(
"section: a half section is defined on a longitudinal plane through the axis; "
"a transverse plane takes style='full' or 'revolved'."
)
station = plane["p1"][0]
r_out = outer_radius_at(part, station)
r_in = inner_radius_at(part, station)
loops = [(_circle_loop(r_out), True)]
if r_in > _EPS:
loops.append((_circle_loop(r_in, reverse=True), True))
return tuple(loops)
if name in ("half", "revolved"):
raise ValueError(
f"section: a {name} section is defined on a revolved part; a prismatic part takes "
"style='full' or 'offset'."
)
rectangles = _prismatic_cut(part, plane, name)
if not rectangles:
raise ValueError(
f"section: the plane {plane['p1']} -> {plane['p2']} does not cut {part.name!r}."
)
return tuple(
(((u0, v0), (u1, v0), (u1, v1), (u0, v1)), hatched)
for u0, u1, v0, v1, hatched in rectangles
)
def section_view(
part, plane: dict, *, style: str = "full", scale: float = 1.0, side: str = "right"
) -> View:
"""A cut view: the cut faces as hatch areas, plus the geometry behind them."""
from engineering.mech.standards.materials import hatch_for
plane = normalise_plane(plane)
name = str(style or plane["style"]).strip().lower()
hatch_for(part.material) # refuse an unknown material before anything is built
ctx = {"side": side, "scale": float(scale), "projection": "first", "plane": plane}
loops = cut_loops(part, plane, style=name)
prims: list[Prim] = []
omitted: list[dict] = []
dims: list[DimIntent] = []
if isinstance(part, RevolvedPart) and _is_longitudinal(plane):
body, unplaced = silhouette_prims(part)
prims.extend(body)
prims.extend(_axis_prims(part))
omitted.extend(unplaced)
for feature in part.features:
own = feature.cut_prims(part, plane, ctx)
if own and getattr(feature, "axisymmetric", False):
own = tuple(own) + mirror_about_x(own)
prims.extend(own)
dims.extend(feature.dims(part, "front", ctx))
elif isinstance(part, RevolvedPart):
role = "phantom" if name == "revolved" else "visible"
station = plane["p1"][0]
r_out = outer_radius_at(part, station)
r_in = inner_radius_at(part, station)
prims.append(Circle((0.0, 0.0), r_out, role))
if r_in > _EPS:
prims.append(Circle((0.0, 0.0), r_in, role))
over = r_out * (1.0 + AXIS_OVERRUN)
prims.append(Line((-over, 0.0), (over, 0.0), "center"))
prims.append(Line((0.0, -over), (0.0, over), "center"))
else:
for points, _hatched in loops:
xs = [p[0] for p in points]
ys = [p[1] for p in points]
prims.extend(_rectangle(min(xs), max(xs), min(ys), max(ys)))
hatched = [points for points, flag in loops if flag]
if isinstance(part, RevolvedPart) and not _is_longitudinal(plane):
# a transverse cut face is one region with the bore as its island
if hatched:
prims.append(HatchArea(tuple(hatched), part.material))
else:
for points in hatched:
prims.append(HatchArea((points,), part.material))
label = f"{plane['label']}-{plane['label']}"
return View(
kind="section",
prims=tuple(prims),
dims=tuple(dims),
omitted=tuple(omitted),
bbox=bbox(prims) if prims else (0.0, 0.0, 0.0, 0.0),
label=label,
scale=float(scale),
)
# -- detail views ------------------------------------------------------------
def _prim_bbox(prim) -> tuple[float, float, float, float]:
return bbox([prim])
def _touches(prim, center: Pt, radius: float) -> bool:
x0, y0, x1, y1 = _prim_bbox(prim)
nearest_x = min(max(center[0], x0), x1)
nearest_y = min(max(center[1], y0), y1)
return math.dist((nearest_x, nearest_y), center) <= radius + 1e-9
def _clip_segment(p1: Pt, p2: Pt, center: Pt, radius: float):
"""The part of the segment inside the circle, or None."""
dx, dy = p2[0] - p1[0], p2[1] - p1[1]
fx, fy = p1[0] - center[0], p1[1] - center[1]
a = dx * dx + dy * dy
if a < 1e-18:
return (p1, p2) if math.dist(p1, center) <= radius + 1e-9 else None
b = 2.0 * (fx * dx + fy * dy)
c = fx * fx + fy * fy - radius * radius
discriminant = b * b - 4.0 * a * c
if discriminant <= 0.0:
return None
root = math.sqrt(discriminant)
t0 = max(0.0, (-b - root) / (2.0 * a))
t1 = min(1.0, (-b + root) / (2.0 * a))
if t1 - t0 <= 1e-9:
return None
return ((p1[0] + t0 * dx, p1[1] + t0 * dy), (p1[0] + t1 * dx, p1[1] + t1 * dy))
def _clip_to_circle(prim, center: Pt, radius: float) -> tuple[Prim, ...]:
"""Straight geometry is trimmed to the detail circle; curved geometry is kept
whole when it touches it.
Trimming an arc would mean solving its angular range against the circle, and
a detail that silently re-cuts an arc is worse than one that carries a
slightly long arc into the enlargement - so the rule is stated rather than
approximated, and a detail is meant to be drawn inside its circle.
"""
if isinstance(prim, Line):
clipped = _clip_segment(prim.p1, prim.p2, center, radius)
return (Line(clipped[0], clipped[1], prim.role),) if clipped else ()
if isinstance(prim, Poly):
points = list(prim.points)
if prim.closed and points:
points.append(points[0])
out: list[Prim] = []
for index in range(len(points) - 1):
clipped = _clip_segment(points[index], points[index + 1], center, radius)
if clipped:
out.append(Line(clipped[0], clipped[1], prim.role))
return tuple(out)
return (prim,) if _touches(prim, center, radius) else ()
def detail_marker_prims(detail: dict) -> tuple[Prim, ...]:
"""ISO 128-34 detail circle and its letter, for the **parent** view."""
center = tuple(float(v) for v in detail["center"])
radius = float(detail["radius"])
label = str(detail.get("label", "A"))
return (
Circle(center, radius, "visible"),
Text((center[0] + radius + 2.0, center[1] + radius + 2.0), label, 5.0, 0.0, "text"),
)
def detail_view(part, detail: dict, *, projection: str = "first", side: str = "right") -> View:
"""A circular region of a parent view, enlarged, with the ISO 128-34 labels.
Straight geometry is trimmed to the circle and curved geometry that touches
it is kept whole (see :func:`_clip_to_circle`); every feature whose own
primitives all fall outside is reported in ``omitted``.
"""
if not isinstance(detail, dict):
raise ValueError(f"detail: expected a dict, got {type(detail).__name__}.")
if "radius" not in detail:
raise ValueError("detail.radius: a detail needs the radius of its circle.")
center = tuple(float(v) for v in detail.get("center", (0.0, 0.0)))
radius = float(detail["radius"])
if radius <= _EPS:
raise ValueError(f"detail.radius: must be greater than zero, got {radius}.")
factor = float(detail.get("scale", 5.0))
if factor <= _EPS:
raise ValueError(f"detail.scale: must be greater than zero, got {factor}.")
label = str(detail.get("label", "A"))
of = str(detail.get("of", "front")).strip().lower()
parent = build_view(part, of, side=side, projection=projection)
kept: list[Prim] = []
for prim in parent.prims:
kept.extend(_clip_to_circle(prim, center, radius))
kept = tuple(kept)
if not kept:
raise ValueError(
f"detail: the circle at {center} r={radius} contains no geometry of the {of} view."
)
moved = translate(kept, -center[0], -center[1])
enlarged = scale_prims(moved, factor, (0.0, 0.0))
omitted = list(parent.omitted)
for feature in part.features:
own = feature.prims(part, of, {"side": side, "scale": 1.0, "projection": projection})
if own and not any(_touches(prim, center, radius) for prim in own):
omitted.append({"feature": feature.id, "reason": f"outside the detail circle {label}"})
return View(
kind="detail",
prims=tuple(enlarged),
dims=(),
omitted=tuple(omitted),
bbox=bbox(enlarged),
label=f"{label} ({factor:g}:1)",
scale=factor,
)