fix(task-5): cut faces on the real chord, cross sections that see features, a section with no dashed cut edge
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
72bd17d024
commit
978c1075ad
@@ -83,6 +83,7 @@ from .views import (
|
||||
normalise_plane,
|
||||
outline_prims,
|
||||
radial_profile,
|
||||
section_profile_prims,
|
||||
section_view,
|
||||
silhouette_prims,
|
||||
)
|
||||
@@ -155,6 +156,7 @@ __all__ = [
|
||||
"radial_profile",
|
||||
"rotate",
|
||||
"scale",
|
||||
"section_profile_prims",
|
||||
"section_view",
|
||||
"segment_bounds",
|
||||
"silhouette_prims",
|
||||
|
||||
+215
-16
@@ -121,6 +121,111 @@ def mirror_about_x(prims: Iterable[Prim]) -> tuple[Prim, ...]:
|
||||
return tuple(out)
|
||||
|
||||
|
||||
# -- cutting-line chords -----------------------------------------------------
|
||||
#
|
||||
# A section plane meets a feature along a *chord*, not along the feature's
|
||||
# bounding box. The difference is not cosmetic: a plane 4.9 mm off the centre
|
||||
# of a 10 mm hole cuts a 1.990 mm chord, and the box would punch the whole
|
||||
# 10 mm out of the cut face - a wrong hatched area and a wrong drawn outline at
|
||||
# the same time. Every function here returns parameters measured along ``unit``
|
||||
# from ``p1``, on the infinite line; the caller clips them to its own leg.
|
||||
|
||||
|
||||
def _box_around(center, radius: float) -> tuple[float, float, float, float]:
|
||||
"""The axis-aligned box of a circle - what ``removal_box`` reports, and
|
||||
exactly why a cut face may not be built from it (see ``cut_spans``)."""
|
||||
x, y = float(center[0]), float(center[1])
|
||||
return (x - radius, y - radius, x + radius, y + radius)
|
||||
|
||||
|
||||
def _merge_spans(spans) -> tuple[tuple[float, float], ...]:
|
||||
"""Sorted, non-overlapping spans. A union, so a shape built from several
|
||||
pieces (a slot is two circles and a rectangle) reports one interval."""
|
||||
items = sorted((float(a), float(b)) for a, b in spans if float(b) - float(a) > _EPS)
|
||||
out: list[list[float]] = []
|
||||
for a, b in items:
|
||||
if out and a <= out[-1][1] + _EPS:
|
||||
out[-1][1] = max(out[-1][1], b)
|
||||
else:
|
||||
out.append([a, b])
|
||||
return tuple((a, b) for a, b in out)
|
||||
|
||||
|
||||
def _box_chord(box, p1, unit) -> tuple[tuple[float, float], ...]:
|
||||
"""The exact chord of an axis-aligned rectangle (a slab clip, not a corner
|
||||
projection: projecting the four corners onto the line reports the box's
|
||||
whole shadow, which is only the chord when the line is parallel to a side)."""
|
||||
if box is None:
|
||||
return ()
|
||||
low, high = -math.inf, math.inf
|
||||
for axis in (0, 1):
|
||||
direction = float(unit[axis])
|
||||
lo, hi = float(box[axis]), float(box[axis + 2])
|
||||
origin = float(p1[axis])
|
||||
if abs(direction) < 1e-12:
|
||||
if origin < lo - _EPS or origin > hi + _EPS:
|
||||
return ()
|
||||
continue
|
||||
a, b = (lo - origin) / direction, (hi - origin) / direction
|
||||
if a > b:
|
||||
a, b = b, a
|
||||
low, high = max(low, a), min(high, b)
|
||||
return ((low, high),) if high - low > _EPS else ()
|
||||
|
||||
|
||||
def _circle_chord(center, radius, p1, unit) -> tuple[tuple[float, float], ...]:
|
||||
"""``2*sqrt(r**2 - d**2)`` about the foot of the perpendicular - exact, and
|
||||
empty when the line only grazes the circle."""
|
||||
dx, dy = float(center[0]) - float(p1[0]), float(center[1]) - float(p1[1])
|
||||
along = dx * unit[0] + dy * unit[1]
|
||||
off = -dx * unit[1] + dy * unit[0]
|
||||
inside = float(radius) * float(radius) - off * off
|
||||
if inside <= _EPS:
|
||||
return ()
|
||||
half = math.sqrt(inside)
|
||||
return ((along - half, along + half),)
|
||||
|
||||
|
||||
def _rotated_box_chord(center, half_u, half_v, angle_deg, p1, unit):
|
||||
"""The exact chord of a rectangle rotated about its own centre: the line is
|
||||
taken into the rectangle's frame and slab-clipped there."""
|
||||
theta = math.radians(float(angle_deg))
|
||||
cos, sin = math.cos(theta), math.sin(theta)
|
||||
dx, dy = float(p1[0]) - float(center[0]), float(p1[1]) - float(center[1])
|
||||
origin = (dx * cos + dy * sin, -dx * sin + dy * cos)
|
||||
direction = (unit[0] * cos + unit[1] * sin, -unit[0] * sin + unit[1] * cos)
|
||||
return _box_chord((-half_u, -half_v, half_u, half_v), origin, direction)
|
||||
|
||||
|
||||
def _polygon_chord(points, p1, unit, *, what: str = "the removed outline"):
|
||||
"""The intervals of the line that lie inside a simple polygon.
|
||||
|
||||
Crossings are paired in order, exact for a polygon the line crosses
|
||||
transversally. A line through a vertex gives an odd count and is refused
|
||||
rather than paired wrong - the same rule the outline itself follows.
|
||||
"""
|
||||
ts: list[float] = []
|
||||
count = len(points)
|
||||
for index in range(count):
|
||||
ax, ay = points[index]
|
||||
bx, by = points[(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(
|
||||
f"section: the cutting plane grazes a vertex of {what}, so its crossings cannot "
|
||||
"be paired. Move the plane off the vertex rather than accept a guessed cut face."
|
||||
)
|
||||
return tuple((ts[i], ts[i + 1]) for i in range(0, len(ts), 2))
|
||||
|
||||
|
||||
def _number(value: Any, path: str, *, positive: bool = True) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
@@ -289,6 +394,38 @@ class _Base:
|
||||
def removal_box(self, part) -> tuple[float, float, float, float] | None:
|
||||
return None
|
||||
|
||||
def cut_spans(self, part, p1, unit) -> tuple[tuple[float, float], ...]:
|
||||
"""Where a cutting line lies inside the material this feature removes.
|
||||
|
||||
Parameters along ``unit`` measured from ``p1``, in the front view's own
|
||||
plane. The default is the exact chord of ``removal_box``, which is the
|
||||
truth for a feature whose removal really is a rectangle. A feature
|
||||
whose removal is a circle or an obround **must** override this: the
|
||||
chord of a hole shrinks to nothing as the plane leaves its centre,
|
||||
while its bounding box stays the full diameter wide, so a box-based cut
|
||||
face shows an 8 mm gap through solid material where the plane merely
|
||||
grazes the hole.
|
||||
"""
|
||||
return _box_chord(self.removal_box(part), p1, unit)
|
||||
|
||||
def cross_section(self, part, station: float) -> dict | None:
|
||||
"""This feature's shape in a **transverse** cut face at ``station``.
|
||||
|
||||
``{"start_deg", "end_deg", "path", "radius"}`` - the outer boundary of
|
||||
the cut face keeps its arc from ``start_deg`` counter-clockwise to
|
||||
``end_deg`` (the material that survives the feature) and is closed by
|
||||
``path``, a polyline running from the point at ``start_deg`` to the
|
||||
point at ``end_deg``, which the engine walks backwards to close the
|
||||
loop. ``radius`` is the outside radius the record was built on, so the
|
||||
engine can refuse to graft it onto a face of another size rather than
|
||||
draw a notch that does not reach the surface.
|
||||
|
||||
``None`` means the feature has no transverse projection here. If the
|
||||
plane really passes through it, the honesty rule reports it instead of
|
||||
hatching over it.
|
||||
"""
|
||||
return None
|
||||
|
||||
def radial_edit(self, part) -> tuple[dict, ...]:
|
||||
return ()
|
||||
|
||||
@@ -559,6 +696,27 @@ class Keyway(_Base):
|
||||
f"depth {table['depth_shaft']} mm is not smaller than the radius {radius} mm."
|
||||
)
|
||||
|
||||
def _notch(self, part) -> dict:
|
||||
"""The notch the slot cuts out of the round section, once.
|
||||
|
||||
The end view draws it and a transverse cut face closes on it, so both
|
||||
come out of this one calculation and cannot disagree. ``Arc`` is CCW
|
||||
from start to end, so the shaft that SURVIVES the slot runs from the
|
||||
left notch wall the long way round to the right one; emitting
|
||||
(right -> left) would draw the ~31 degree cap over the opening instead
|
||||
of the ~329 degrees of material.
|
||||
"""
|
||||
_xa, _xb, radius, width, depth = self._span(part)
|
||||
half = width / 2.0
|
||||
top = math.sqrt(max(radius * radius - half * half, 0.0))
|
||||
floor = radius - depth
|
||||
return {
|
||||
"radius": radius,
|
||||
"start_deg": math.degrees(math.atan2(top, -half)) % 360.0,
|
||||
"end_deg": math.degrees(math.atan2(top, half)) % 360.0,
|
||||
"path": ((-half, top), (-half, floor), (half, floor), (half, top)),
|
||||
}
|
||||
|
||||
def prims(self, part, view: str, ctx: dict) -> tuple[Prim, ...]:
|
||||
part = _require_revolved(part, "Keyway")
|
||||
xa, xb, radius, width, depth = self._span(part)
|
||||
@@ -570,23 +728,22 @@ class Keyway(_Base):
|
||||
Line((xb, floor), (xb, radius), "visible"),
|
||||
)
|
||||
if view == "side":
|
||||
half = width / 2.0
|
||||
top = math.sqrt(max(radius * radius - half * half, 0.0))
|
||||
floor = radius - depth
|
||||
right = math.degrees(math.atan2(top, half)) % 360.0
|
||||
left = math.degrees(math.atan2(top, -half)) % 360.0
|
||||
# `Arc` is CCW from start to end, so the shaft that SURVIVES the slot
|
||||
# runs from the left notch wall the long way round to the right one.
|
||||
# Emitting (right -> left) would draw the ~31 degree cap over the
|
||||
# opening instead of the ~329 degrees of material.
|
||||
notch = self._notch(part)
|
||||
path = notch["path"]
|
||||
return (
|
||||
Arc((0.0, 0.0), radius, left, right, "visible"),
|
||||
Line((-half, top), (-half, floor), "visible"),
|
||||
Line((-half, floor), (half, floor), "visible"),
|
||||
Line((half, floor), (half, top), "visible"),
|
||||
Arc((0.0, 0.0), radius, notch["start_deg"], notch["end_deg"], "visible"),
|
||||
*(Line(path[index], path[index + 1], "visible") for index in range(len(path) - 1)),
|
||||
)
|
||||
return ()
|
||||
|
||||
def cross_section(self, part, station: float) -> dict | None:
|
||||
"""The notch, when the transverse plane falls inside the slot's length."""
|
||||
part = _require_revolved(part, "Keyway")
|
||||
xa, xb, _radius, _width, _depth = self._span(part)
|
||||
if not xa - _EPS <= float(station) <= xb + _EPS:
|
||||
return None
|
||||
return self._notch(part)
|
||||
|
||||
def removal_box(self, part):
|
||||
xa, xb, radius, _, depth = self._span(part)
|
||||
return (xa, radius - depth, xb, radius)
|
||||
@@ -1543,10 +1700,26 @@ class Hole(_Base):
|
||||
return ()
|
||||
|
||||
def removal_box(self, part):
|
||||
x, y = float(self.x), float(self.y)
|
||||
return _box_around(self._centre(), self._outer_radius())
|
||||
|
||||
def _centre(self) -> tuple[float, float]:
|
||||
return (float(self.x), float(self.y))
|
||||
|
||||
def _outer_radius(self) -> float:
|
||||
"""The widest circle the plane can meet: the drill, or a counterbore or
|
||||
countersink that is wider than it."""
|
||||
extras = [float(v) / 2.0 for v in (self.cbore_d, self.csink_d) if v is not None]
|
||||
outer = max([float(self.diameter) / 2.0, *extras])
|
||||
return (x - outer, y - outer, x + outer, y + outer)
|
||||
return max([float(self.diameter) / 2.0, *extras])
|
||||
|
||||
def cut_spans(self, part, p1, unit):
|
||||
"""The true chord of the drilled circle, not the square that bounds it.
|
||||
|
||||
On an 80x50x8 plate with a 10 mm hole at (40, 25): a plane at y=29.9
|
||||
grazes the hole and cuts a 1.990 mm chord, leaving 624.08 mm2 of cut
|
||||
face. The bounding box reports 10 mm at every offset and hatches
|
||||
560 mm2 - a 64 mm2 error, with an 8 mm gap drawn through solid metal.
|
||||
"""
|
||||
return _circle_chord(self._centre(), self._outer_radius(), p1, unit)
|
||||
|
||||
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
|
||||
if view != "front":
|
||||
@@ -1659,6 +1832,22 @@ class Slot(_Base):
|
||||
ys = (c1[1] - half, c1[1] + half, c2[1] - half, c2[1] + half)
|
||||
return (min(xs), min(ys), max(xs), max(ys))
|
||||
|
||||
def cut_spans(self, part, p1, unit):
|
||||
"""The true chord of the obround: the union of its two end circles and
|
||||
the rectangle between them. The bounding box of an angled slot is far
|
||||
wider than the slot itself, so the box would erase material the slot
|
||||
never touches."""
|
||||
(c1, c2, half) = self._ends()
|
||||
middle = ((c1[0] + c2[0]) / 2.0, (c1[1] + c2[1]) / 2.0)
|
||||
run = math.dist(c1, c2)
|
||||
return _merge_spans(
|
||||
[
|
||||
*_circle_chord(c1, half, p1, unit),
|
||||
*_circle_chord(c2, half, p1, unit),
|
||||
*_rotated_box_chord(middle, run / 2.0, half, float(self.angle), p1, unit),
|
||||
]
|
||||
)
|
||||
|
||||
def dims(self, part, view: str, ctx: dict) -> tuple[DimIntent, ...]:
|
||||
if view != "front":
|
||||
return ()
|
||||
@@ -1740,6 +1929,16 @@ class Pocket(_Base):
|
||||
)
|
||||
return ()
|
||||
|
||||
def cut_spans(self, part, p1, unit):
|
||||
"""The chord of the pocket's own outline, not of the box around it: a
|
||||
pocket is only rectangular when its outline is."""
|
||||
return _polygon_chord(
|
||||
tuple((float(x), float(y)) for x, y in self.outline),
|
||||
p1,
|
||||
unit,
|
||||
what=f"pocket {self.id!r}",
|
||||
)
|
||||
|
||||
def removal_box(self, part):
|
||||
xs = [float(x) for x, _ in self.outline]
|
||||
ys = [float(y) for _, y in self.outline]
|
||||
|
||||
+250
-55
@@ -29,8 +29,6 @@ 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,
|
||||
@@ -259,24 +257,18 @@ def _emit_path(points: list[Pt], edits: list[dict], role: str, *, closed: bool =
|
||||
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)
|
||||
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
|
||||
|
||||
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(
|
||||
def _unplaced(edits: list[dict], used: set[str]) -> tuple[dict, ...]:
|
||||
return tuple(
|
||||
{
|
||||
"feature": edit["owner"],
|
||||
"reason": (
|
||||
@@ -287,7 +279,54 @@ def silhouette_prims(part: RevolvedPart) -> tuple[tuple[Prim, ...], tuple[dict,
|
||||
for edit in edits
|
||||
if edit["owner"] not in used
|
||||
)
|
||||
return tuple(prims), unplaced
|
||||
|
||||
|
||||
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, ...]]:
|
||||
@@ -720,6 +759,54 @@ def _inner_path(bands: tuple[Band, ...]) -> list[Pt]:
|
||||
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(
|
||||
@@ -779,17 +866,6 @@ def _chords(outline, p1: Pt, unit: Pt) -> list[tuple[float, float]]:
|
||||
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.
|
||||
|
||||
@@ -815,7 +891,6 @@ def _prismatic_cut(part: PrismaticPart, plane: dict, style: str):
|
||||
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]
|
||||
@@ -823,19 +898,20 @@ def _prismatic_cut(part: PrismaticPart, plane: dict, style: str):
|
||||
|
||||
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)
|
||||
# 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)
|
||||
@@ -854,6 +930,131 @@ def _prismatic_cut(part: PrismaticPart, plane: dict, style: str):
|
||||
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)
|
||||
@@ -869,7 +1070,10 @@ def cut_loops(part, plane: dict, *, style: str = "full"):
|
||||
"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)))
|
||||
# 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))
|
||||
@@ -879,13 +1083,7 @@ def cut_loops(part, plane: dict, *, style: str = "full"):
|
||||
"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)
|
||||
return _transverse_face(part, plane["p1"][0])["loops"]
|
||||
|
||||
if name in ("half", "revolved"):
|
||||
raise ValueError(
|
||||
@@ -920,7 +1118,7 @@ def section_view(
|
||||
dims: list[DimIntent] = []
|
||||
|
||||
if isinstance(part, RevolvedPart) and _is_longitudinal(plane):
|
||||
body, unplaced = silhouette_prims(part)
|
||||
body, unplaced = section_profile_prims(part, half=name == "half")
|
||||
prims.extend(body)
|
||||
prims.extend(_axis_prims(part))
|
||||
omitted.extend(unplaced)
|
||||
@@ -932,13 +1130,10 @@ def section_view(
|
||||
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)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user