Three defects, all of them the same mistake in three places: the cut face was built from something other than the geometry it claims to show. 1. A prismatic cut face removed a feature by its BOUNDING BOX. `Hole.removal_box` is a square, so the gap punched in the cut face was the full diameter wherever the plane passed. On the plan's 80x50x8 plate with a Ho10 hole at (40, 25): a plane at y=29.9 grazes the hole (true chord 1.990) and the section still showed an 8 mm gap through solid metal - 560.0 mm2 hatched against a true 624.08, a 64 mm2 error in the hatched area AND a wrong drawn outline. `Slot` had the same obround-as-box. Features now answer `cut_spans(part, p1, unit)` with their own exact chord: `_circle_chord` for a hole, the union of two circles and a rotated rectangle for a slot, the polygon's own crossings for a pocket, and an exact slab clip of `removal_box` as the default. `_box_span` (the corner projection, which is the box's shadow and not a chord at all) is gone. 2. A transverse revolved section ignored every feature and the honesty rule never fired. A cross section through a DIN 6885-1 keyway came out as a plain hatched disc with `omitted == ()` - the commonest mechanical cross section there is, hatched straight over the 12x5 keyway. The face is now built by `_transverse_face`: its radii come from `radial_profile` (so a groove, an undercut, a bore or an ISO 6410 internal thread is in the face already, which the raw segment diameters could not report), a non-axisymmetric feature is asked for its own `cross_section` notch record, and one the plane really passes through with no such record is listed in `omitted` with its reason rather than hatched over. `Keyway._notch` is the one calculation the end view and the cut face both come out of. 3. A section drew its own cut boundary as a HIDDEN (dashed) line, because the longitudinal branch reused `silhouette_prims` and the silhouette draws the bore dashed - correct for an un-cut view, wrong for a section, where that line IS the hatch loop's boundary. ISO 128-50 bounds a surface lying in the cutting plane with a continuous wide line and ISO 128-3 leaves hidden detail out of a sectional view; via ROLE_LAYER['hidden'] this would also have put the cut boundary on the HIDDEN layer in Task 7. New `section_profile_prims` emits the cut outline all-continuous, and `half=True` keeps the dashed bore on the -Y half only, which is the half a half section does not cut. And the claim the last commit made without earning it: the hatched area and the drawn outline are now literally one calculation. The longitudinal loop is the *emitted* outline flattened (`_flatten_path`), so a chamfer or fillet that substitutes a silhouette corner is cut out of the hatch exactly where it is drawn - a 2 mm 45 deg chamfer takes its 2 mm2 off the 1000 mm2 half face instead of being hatched over. Eleven new tests, every one of them red on the parent commit: the hole chord at 0/4/4.9 mm off centre, a tangent plane leaving the face whole, the 45 deg slot, the keyway cross section against its own analytic area, the feature a cross section cannot project, the feature the plane misses, the groove read off the profile, the continuous cut boundary, the half section's dashed half, and the chamfered cut face. No new ActiveX member is called: all three fixes are pure geometry in the backend-free `engineering/mech` package. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
1285 lines
50 KiB
Python
1285 lines
50 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,
|
|
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 _corner_edits(part: RevolvedPart) -> list[dict]:
|
|
"""Every feature's silhouette corner substitution, tagged with its owner."""
|
|
edits: list[dict] = []
|
|
for feature in part.features:
|
|
edit = feature.corner_edit(part)
|
|
if edit is not None:
|
|
edits.append({**edit, "owner": feature.id})
|
|
return edits
|
|
|
|
|
|
def _unplaced(edits: list[dict], used: set[str]) -> tuple[dict, ...]:
|
|
return 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
|
|
)
|
|
|
|
|
|
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 = _corner_edits(part)
|
|
|
|
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))
|
|
|
|
return tuple(prims), _unplaced(edits, used)
|
|
|
|
|
|
def section_profile_prims(
|
|
part: RevolvedPart, *, half: bool = False
|
|
) -> tuple[tuple[Prim, ...], tuple[dict, ...]]:
|
|
"""The longitudinal **cut** outline of a revolved part.
|
|
|
|
Every edge of this outline lies in the cutting plane, so every edge is a
|
|
continuous line: ISO 128-50 bounds a surface lying in the cutting plane
|
|
with a continuous wide line, and ISO 128-3 leaves hidden detail out of a
|
|
sectional view altogether. The bore wall is the case that matters - the
|
|
un-cut silhouette draws it dashed because material hides it, but in a
|
|
section it *is* the cut face's own boundary, and drawing the hatch loop and
|
|
a dashed line on the same coordinates is the one thing a section must not
|
|
do. ``half=True`` keeps the dashed bore on the -Y half, which is the half a
|
|
half section does not cut.
|
|
"""
|
|
bands = radial_profile(part)
|
|
edits = _corner_edits(part)
|
|
|
|
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, [], "visible")
|
|
prims.extend(bore)
|
|
mirrored = mirror_about_x(bore)
|
|
if half:
|
|
mirrored = tuple(prim._replace(role="hidden") for prim in mirrored)
|
|
prims.extend(mirrored)
|
|
|
|
return tuple(prims), _unplaced(edits, used)
|
|
|
|
|
|
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 _arc_loop_points(
|
|
radius: float, start_deg: float, end_deg: float, *, center: Pt = (0.0, 0.0)
|
|
) -> tuple[Pt, ...]:
|
|
"""An arc as points, at the same 2 degree chord ``CIRCLE_SEGMENTS`` states.
|
|
|
|
Endpoints are exact, so a loop built from an arc plus a polyline closes on
|
|
the polyline's own corners.
|
|
"""
|
|
sweep = (float(end_deg) - float(start_deg)) % 360.0
|
|
if sweep <= _EPS:
|
|
sweep = 360.0
|
|
count = max(1, math.ceil(sweep / (360.0 / CIRCLE_SEGMENTS) - 1e-9))
|
|
out: list[Pt] = []
|
|
for step in range(count + 1):
|
|
angle = math.radians(float(start_deg) + sweep * step / count)
|
|
out.append((center[0] + radius * math.cos(angle), center[1] + radius * math.sin(angle)))
|
|
return tuple(out)
|
|
|
|
|
|
def _flatten_path(prims) -> tuple[Pt, ...]:
|
|
"""A contiguous run of ``Line``/``Arc`` primitives as a point path.
|
|
|
|
This is what makes the drawn outline and the hatched loop **one**
|
|
calculation rather than two that agree by inspection: the loop is the
|
|
emitted outline, flattened, so a chamfer or a fillet that substitutes a
|
|
silhouette corner substitutes it in the cut face too.
|
|
"""
|
|
points: list[Pt] = []
|
|
|
|
def push(point: Pt) -> None:
|
|
pt = (float(point[0]), float(point[1]))
|
|
if not points or math.dist(points[-1], pt) > _EPS:
|
|
points.append(pt)
|
|
|
|
for prim in prims:
|
|
if isinstance(prim, Line):
|
|
push(prim.p1)
|
|
push(prim.p2)
|
|
elif isinstance(prim, Arc):
|
|
for point in _arc_loop_points(
|
|
prim.radius, prim.start_deg, prim.end_deg, center=prim.center
|
|
):
|
|
push(point)
|
|
else: # pragma: no cover - only an outline path is ever flattened
|
|
raise TypeError(f"_flatten_path: {type(prim).__name__} is not a path primitive.")
|
|
return tuple(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 _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)
|
|
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:
|
|
# the feature's own chord, never the box around it: a box punches
|
|
# the full width of a hole or an angled slot into the cut face
|
|
# wherever the plane passes, which is a wrong hatched area and a
|
|
# wrong drawn outline at the same time.
|
|
for span in feature.cut_spans(part, start, unit):
|
|
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 _band_at(bands: tuple[Band, ...], station: float) -> Band | None:
|
|
for band in bands:
|
|
if band.x0 - _TOL <= station <= band.x1 + _TOL:
|
|
return band
|
|
return None
|
|
|
|
|
|
def _represented_by_the_profile(feature, part, station: float) -> bool:
|
|
"""True when the radial profile already carries this feature at ``station``.
|
|
|
|
A groove, an undercut, an axial bore or an ISO 6410 internal thread edits
|
|
the profile, and the transverse cut face is read off that profile - so the
|
|
feature is in the face already and must not be reported missing from it.
|
|
"""
|
|
for edit in feature.radial_edit(part):
|
|
if float(edit["x0"]) - _TOL <= station <= float(edit["x1"]) + _TOL:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _transverse_face(part: RevolvedPart, station: float) -> dict:
|
|
"""One transverse cut face: its boundary primitives, its loops, its omissions.
|
|
|
|
The radii come from ``radial_profile``, the same one calculation the
|
|
silhouette and the longitudinal cut face come out of, so a groove or an
|
|
internal thread at that station is in the face without being asked for.
|
|
A feature that is *not* axisymmetric is asked for its own ``cross_section``
|
|
notch; one that the plane really passes through and that has no such record
|
|
is reported rather than hatched over - a keyed shaft whose cross section
|
|
came out as a plain hatched disc is exactly the drawing the honesty rule
|
|
exists to prevent.
|
|
"""
|
|
bands = radial_profile(part)
|
|
band = _band_at(bands, station)
|
|
if band is None:
|
|
raise ValueError(
|
|
f"section: the plane at x={station:g} is outside {part.name!r} "
|
|
f"(0 .. {part_length(part):g} mm)."
|
|
)
|
|
span = band.x1 - band.x0
|
|
fraction = 0.0 if span <= _EPS else (station - band.x0) / span
|
|
r_out = band.r_out0 + fraction * (band.r_out1 - band.r_out0)
|
|
r_in = band.r_in
|
|
|
|
notch: dict | None = None
|
|
omitted: list[dict] = []
|
|
for feature in part.features:
|
|
record = feature.cross_section(part, station)
|
|
if record is not None:
|
|
if notch is not None:
|
|
omitted.append(
|
|
{
|
|
"feature": feature.id,
|
|
"reason": (
|
|
"the cut face already carries another feature's notch; this "
|
|
"engine cuts one notch per transverse face"
|
|
),
|
|
}
|
|
)
|
|
continue
|
|
measured = float(record["radius"])
|
|
if abs(measured - r_out) > _TOL:
|
|
omitted.append(
|
|
{
|
|
"feature": feature.id,
|
|
"reason": (
|
|
f"its notch was measured on a {2.0 * measured:g} mm surface but "
|
|
f"the cut face at x={station:g} is {2.0 * r_out:g} mm, so the "
|
|
"notch would not reach it"
|
|
),
|
|
}
|
|
)
|
|
continue
|
|
notch = record
|
|
continue
|
|
if _represented_by_the_profile(feature, part, station):
|
|
continue
|
|
box = feature.removal_box(part)
|
|
if box is not None and float(box[0]) - _TOL <= station <= float(box[2]) + _TOL:
|
|
omitted.append(
|
|
{
|
|
"feature": feature.id,
|
|
"reason": (
|
|
f"the cutting plane at x={station:g} passes through it, but a "
|
|
f"{type(feature).__name__} has no transverse cut-face projection"
|
|
),
|
|
}
|
|
)
|
|
else:
|
|
omitted.append(
|
|
{
|
|
"feature": feature.id,
|
|
"reason": f"it is not on the cutting plane at x={station:g}",
|
|
}
|
|
)
|
|
|
|
if notch is None:
|
|
loops: list[tuple[tuple[Pt, ...], bool]] = [(_circle_loop(r_out), True)]
|
|
prims: list[Prim] = [Circle((0.0, 0.0), r_out, "visible")]
|
|
else:
|
|
path = tuple((float(x), float(y)) for x, y in notch["path"])
|
|
start, end = float(notch["start_deg"]), float(notch["end_deg"])
|
|
# the material arc plus the notch walls: one boundary, and the loop is
|
|
# that same boundary sampled, so the hatch cannot cover the notch. The
|
|
# arc ends where the path ends, so the path is walked backwards to
|
|
# close the loop, and both of its endpoints are already on the arc.
|
|
closing = tuple(reversed(path))[1:-1]
|
|
loops = [(_arc_loop_points(r_out, start, end) + closing, True)]
|
|
prims = [Arc((0.0, 0.0), r_out, start, end, "visible")]
|
|
prims.extend(
|
|
Line(path[index], path[index + 1], "visible") for index in range(len(path) - 1)
|
|
)
|
|
if r_in > _EPS:
|
|
loops.append((_circle_loop(r_in, reverse=True), True))
|
|
prims.append(Circle((0.0, 0.0), r_in, "visible"))
|
|
|
|
return {
|
|
"r_out": r_out,
|
|
"r_in": r_in,
|
|
"loops": tuple(loops),
|
|
"prims": tuple(prims),
|
|
"omitted": tuple(omitted),
|
|
}
|
|
|
|
|
|
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)."
|
|
)
|
|
# the loop is the *emitted* outline flattened, so a chamfer or a
|
|
# fillet is cut out of the hatched area exactly where it is drawn
|
|
outline, _used = _emit_path(_outer_path(bands), _corner_edits(part), "visible")
|
|
upper = _flatten_path(outline) + 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'."
|
|
)
|
|
return _transverse_face(part, plane["p1"][0])["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 = section_profile_prims(part, half=name == "half")
|
|
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"
|
|
face = _transverse_face(part, plane["p1"][0])
|
|
prims.extend(prim._replace(role=role) for prim in face["prims"])
|
|
omitted.extend(face["omitted"])
|
|
over = face["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,
|
|
)
|