Files
TheRON 0545b79674 stl: export a member as a sealed mesh
prism_mesh takes a region and a length, not a Result. A member, its support, or
the support alone are then three calls with different regions rather than three
special cases inside one function -- support is a designed part of an artifact
where no printable orientation exists, not something a slicer adds, and
flattening everything into one mesh now would be expensive to undo later.

Named and documented as a MEMBER exporter. ROADMAP section 2 has three artifact
classes: members are prismatic and exist, nodes are non-prismatic and do not.
A prism sweep will never produce a node and no amount of extending this should
be attempted. A module called stl.py would invite exactly that.

No CAD kernel. Caps are a constrained Delaunay triangulation of the section
with its holes, walls are a quad strip per ring. That is why STL costs no new
dependency while STEP would.

Winding is forced, never assumed. region_parts returns each part outer-clockwise
with holes counter-clockwise because region_area depends on that to make holes
subtract; a prism swept along +Z needs the opposite, since traversing the outer
boundary counter-clockwise puts material on the left and the wall normal on the
right, which is outward. Both are reversed here.

The identifiers in the 80-byte header are PROVENANCE, NOT A CHECKSUM. Principle
4: mesh bytes are not reproducible across toolchain versions, so two exports
carrying the same build_id may differ byte for byte and both be correct -- a
GEOS release can move a triangulation without moving the geometry, which is the
F-034 mechanism applied to tessellation. The docstring says so where someone
diffing two exports will find it. Facet normals are written as zeros: every
consumer recomputes from vertex order, and a stored normal would be a second
source of truth for which way a face points.

A section failing is_region_simple is refused rather than exported. Its own
docstring names the case: an outline that measures perfectly, touches itself at
a point, and cannot be sealed. Emitting it would produce a file that opens in a
slicer and prints wrong.

member_stl defaults to the report's LENGTH_MM, the value the record's
VOLUME_MM3 and MASS_G were computed against. Any other sweep makes the record
describe a different object than the file beside it.

61 tests across all eleven profiles in both families, four properties each,
every negative assertion with a positive control. Edge pairing catches a missing
wall and inconsistent winding; Euler catches a filled or invented hole; area
overlap catches a bore paved over; volume against region_area times length
catches inverted normals and a wrong length together.

Two test defects found by mutation and fixed before landing, both worth
recording because both passed while being wrong.

The hole check originally tested triangle centroids. Ignoring holes at
triangulation time slipped through it -- two triangles can pave a bore with
both centroids outside it. Replaced with intersection area, which catches it.

The sweep-length test built at the family defaults, where preview_length_mm is
100, so a mutation hardcoding 100.0 passed the entire suite: the assertion was
comparing a value against the constant that had replaced it. Now parametrised
over 37.5 mm and 3048 mm, and proven against hardcoded mutations at both
values, each caught by the other case.

Mutation results against the real sections: holes not reversed fails 23,
exterior-only triangulation fails 45, outer ring not forced CCW fails 25,
simplicity precondition removed fails exactly 1 -- the refusal test written for
it. Restoration verified by checksum rather than by a green run, after a
same-length mutation plus a cached .pyc produced a false clean reading earlier
in the session. PYTHONDONTWRITEBYTECODE=1 throughout, per HANDOFF section 8.

Suite 627 passed. None of the 566 moved.
2026-09-12 10:40:35 -05:00

335 lines
12 KiB
Python

"""
The member mesh: sealed, correctly oriented, and the right size.
WHY THESE FOUR CHECKS AND NOT A VISUAL ONE
A bad mesh opens in a slicer. That is the whole problem: inverted normals,
a hole filled in, a wall missing or the wrong sweep length all produce a
file that looks plausible and prints wrong, and the failure is discovered
in plastic hours later.
So each property is asserted arithmetically, and each is chosen to fail
loudly for a different defect:
edge pairing a missing or duplicated wall, and inconsistent winding
Euler a hole that was filled, or one invented
centroid-in-hole a bore tessellated over -- prints solid through the conduit
volume inverted normals and a wrong length, in one number
Every negative assertion has a positive control, per ROADMAP principle 11.
"""
from __future__ import annotations
import struct
import pytest
stl = pytest.importorskip("mechcomp.stl")
profiles = pytest.importorskip("mechcomp.profiles")
region = pytest.importorskip("mechcomp.geom.region")
LENGTH = 40.0
def every_profile():
"""Each catalogue entry of each family, at its family defaults."""
for name, family in profiles.FAMILIES.items():
for profile in sorted(family.catalogue):
yield name, profile
def built(family="3x", profile="Y", **params):
return profiles.build(family, profile, params)
ALL = list(every_profile())
def ids(pair):
return "%s/%s" % pair
# ---------------------------------------------------------------------------
# Sealed and consistently wound
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("pair", ALL, ids=ids)
def test_every_directed_edge_appears_exactly_once(pair):
"""
Manifoldness and consistent orientation in one assertion.
In a closed, consistently oriented surface each directed edge (a, b) occurs
once and its reverse (b, a) occurs once. A duplicated directed edge means
two faces wind the same way across a shared edge; a missing reverse means a
hole in the surface.
"""
mesh = stl.prism_mesh(built(*pair).section, LENGTH)
seen = set()
for i, j, k in mesh.triangles:
for edge in ((i, j), (j, k), (k, i)):
assert edge not in seen, "directed edge %r twice" % (edge,)
seen.add(edge)
for a, b in seen:
assert (b, a) in seen, "edge %r has no opposite" % ((a, b),)
def test_the_edge_check_can_fail():
"""
The positive control. Without it, a mesh with no triangles at all would
satisfy the test above vacuously.
"""
mesh = stl.prism_mesh(built().section, LENGTH)
assert len(mesh.triangles) > 0
broken = stl.Mesh(mesh.vertices, mesh.triangles[:-1])
seen = set()
for i, j, k in broken.triangles:
for edge in ((i, j), (j, k), (k, i)):
seen.add(edge)
assert any((b, a) not in seen for a, b in seen)
# ---------------------------------------------------------------------------
# The right number of holes, still holes
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("pair", ALL, ids=ids)
def test_euler_characteristic_matches_the_section(pair):
"""
V - E + F = 2P - 2H.
A prism over a part with H holes is a genus-H handlebody, so its boundary
surface has characteristic 2 - 2H, and P disjoint parts sum. Filling a bore
would raise the left side; inventing a tunnel would lower it.
"""
result = built(*pair)
section = result.section
parts = region.region_parts(section)
p = len(parts)
h = sum(len(part) - 1 for part in parts)
mesh = stl.prism_mesh(section, LENGTH)
edges = {frozenset((a, b))
for i, j, k in mesh.triangles
for a, b in ((i, j), (j, k), (k, i))}
chi = len(mesh.vertices) - len(edges) + len(mesh.triangles)
assert chi == 2 * p - 2 * h, (
"chi=%d for %d part(s) and %d hole(s)" % (chi, p, h))
def test_at_least_one_profile_actually_has_a_hole():
"""
The positive control for the Euler test and the one below it: if no
section in the catalogue had holes, both would be asserting the easy case
and a triangulator that ignored holes would pass everything.
"""
with_holes = 0
for pair in ALL:
parts = region.region_parts(built(*pair).section)
with_holes += sum(len(part) - 1 for part in parts)
assert with_holes > 0
# ---------------------------------------------------------------------------
# Nothing tessellated across a bore
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("pair", ALL, ids=ids)
def test_no_cap_triangle_sits_inside_a_hole(pair):
"""
The check that matters most in practice.
A triangulator that filled the bores produces a mesh that is sealed,
correctly wound, passes Euler if the holes vanish consistently -- and
prints solid where the strap has to pass. Only the volume test and this one
would notice.
"""
result = built(*pair)
section = result.section
mesh = stl.prism_mesh(section, LENGTH)
holes = [hole for part in region.region_parts(section) for hole in part[1:]]
if not holes:
pytest.skip("this section has no holes")
caps = [t for t in mesh.triangles
if len({mesh.vertices[i][2] for i in t}) == 1]
assert caps, "no cap triangles found"
from shapely.geometry import Polygon
# Overlapping AREA, not the centroid. A centroid test passes by luck on a
# coarse triangulation: two triangles covering a square with a central bore
# can both have their centroids outside it while jointly paving it over.
# Found by mutation -- ignoring the holes at triangulation time slipped
# through a centroid check and is caught by this one.
hole_polys = [Polygon(h) for h in holes]
for i, j, k in caps:
tri = Polygon([(mesh.vertices[v][0], mesh.vertices[v][1])
for v in (i, j, k)])
if tri.area <= 0:
continue
for hp in hole_polys:
assert tri.intersection(hp).area <= 1e-9 * tri.area, (
"a cap triangle paves over a hole -- the part would print "
"solid where the stock has to pass")
# ---------------------------------------------------------------------------
# The right size, the right way out
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("pair", ALL, ids=ids)
def test_volume_equals_area_times_length(pair):
"""
Catches inverted normals and a wrong sweep length together.
Compared against ``region_area`` at full precision rather than against the
report's ``SECTION_AREA_MM2``, which is rounded to six significant figures
and would cap this test's resolution at the oracle's rather than the
geometry's.
"""
result = built(*pair)
expected = region.area(result.section) * LENGTH
actual = stl.prism_mesh(result.section, LENGTH).volume
assert actual > 0, "negative volume means the normals face inward"
assert abs(actual - expected) <= 1e-9 * abs(expected)
def test_volume_agrees_with_the_report_too():
"""
The same quantity through the published numbers, at the precision they are
published to. Six significant figures, so the bound is loose on purpose.
"""
result = built()
length = result.report["LENGTH_MM"]
expected = result.report["SECTION_AREA_MM2"] * length
actual = stl.prism_mesh(result.section, length).volume
assert abs(actual - expected) <= 1e-5 * abs(expected)
def test_volume_scales_with_length():
"""Positive control: a volume that ignored its length would be constant."""
section = built().section
assert stl.prism_mesh(section, 80.0).volume == pytest.approx(
2.0 * stl.prism_mesh(section, 40.0).volume)
def test_reversing_a_wall_would_be_caught():
"""
Positive control for the sign: a mesh with every triangle flipped has
negative volume, so ``actual > 0`` above is a real assertion.
"""
mesh = stl.prism_mesh(built().section, LENGTH)
flipped = stl.Mesh(mesh.vertices, [(k, j, i) for i, j, k in mesh.triangles])
assert flipped.volume < 0
# ---------------------------------------------------------------------------
# The length that ships
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("params,expected", [
({"preview_length_mm": 37.5}, 37.5),
({"length_view": "Full Length", "member_length_ft": 10}, 3048.0),
])
def test_member_stl_sweeps_the_reported_length(params, expected):
"""
The record's VOLUME_MM3 and MASS_G are computed against LENGTH_MM. Sweeping
anything else would make the record describe a different object than the
file beside it.
NEITHER CASE IS 100 mm, AND THAT IS THE POINT.
An earlier version of this test built at the family defaults, where
``preview_length_mm`` is 100. A mutation replacing the lookup with a
hardcoded 100.0 passed the whole suite -- the assertion compared a
value against the constant that had replaced it. Both cases here differ
from 100, and the second differs by two orders of magnitude, so no
plausible fixed length satisfies them.
"""
result = built(**params)
assert result.report["LENGTH_MM"] == expected
data = stl.member_stl(result)
count = struct.unpack("<I", data[80:84])[0]
zs = set()
for n in range(count):
base = 84 + n * 50 + 12
for v in range(3):
zs.add(round(struct.unpack("<3f", data[base + v * 12:base + v * 12 + 12])[2], 4))
assert max(zs) == pytest.approx(expected, rel=1e-6)
assert min(zs) == 0.0
def test_full_length_export_is_not_a_preview():
"""The defect that motivated the length_view control, asserted end to end."""
preview = built(length_view="Preview")
full = built(length_view="Full Length", member_length_ft=10)
assert full.report["LENGTH_MM"] == 3048
assert (stl.prism_mesh(full.section, full.report["LENGTH_MM"]).volume
> 30 * stl.prism_mesh(preview.section, preview.report["LENGTH_MM"]).volume)
# ---------------------------------------------------------------------------
# The file
# ---------------------------------------------------------------------------
def test_binary_stl_is_exactly_the_right_size():
mesh = stl.prism_mesh(built().section, LENGTH)
data = stl.binary_stl(mesh)
assert len(data) == 84 + 50 * len(mesh.triangles)
assert struct.unpack("<I", data[80:84])[0] == len(mesh.triangles)
def test_the_header_is_eighty_bytes_and_carries_both_ids():
result = built()
head = stl.stl_header(result.record.input_id, result.record.build_id)
assert len(head) == stl.HEADER_BYTES
text = head.rstrip(b"\0").decode("ascii")
assert result.record.input_id in text
assert result.record.build_id in text
def test_the_header_survives_having_no_record():
assert len(stl.stl_header()) == stl.HEADER_BYTES
def test_the_filename_identifies_the_design():
result = built()
name = stl.stl_filename(result)
assert result.record.input_id in name
assert name.endswith(".stl")
# ---------------------------------------------------------------------------
# Refusals
# ---------------------------------------------------------------------------
def test_an_empty_section_is_refused():
with pytest.raises(stl.ExportRefused):
stl.prism_mesh([], LENGTH)
@pytest.mark.parametrize("bad", [0.0, -1.0])
def test_a_non_positive_length_is_refused(bad):
with pytest.raises(stl.ExportRefused):
stl.prism_mesh(built().section, bad)
def test_a_section_that_touches_itself_is_refused():
"""
Two squares meeting at a single corner: a valid outline, correct area, and
not extrudable. ``is_region_simple`` exists for exactly this, and the
exporter must refuse rather than emit a mesh that opens in a slicer.
"""
figure_eight = [
[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)],
[(10.0, 10.0), (20.0, 10.0), (20.0, 20.0), (10.0, 20.0)],
]
assert not region.is_region_simple(figure_eight)
with pytest.raises(stl.ExportRefused):
stl.prism_mesh(figure_eight, LENGTH)
def test_a_well_formed_section_is_not_refused():
"""Positive control: the refusal is not simply rejecting everything."""
assert stl.prism_mesh(built().section, LENGTH).triangles