Files
mechanical-compiler/tests/test_geom_primitives.py
TheRON dfd02a4fd8 geom: port the pure-geometry half of sb-geom
Vectors, GEO and MEMBER records, member placement, sleeve and cavity
paths, exact polyline distance, corner-radius derivation, and the
monotone solver. Direct translation of legacy/openscad/lib/sb-geom.scad
at rev 8.0.0.

Angles stay in degrees, matching OpenSCAD, so every expression reads the
same as its source line. The 44 solver iterations, the 0.999 and 0.98
scale factors, the 0.05/179.95 cutoffs and the 1e9 sentinel are
reproduced exactly: they shaped the frozen oracle.

Region operations are not included -- they need a 2D boolean kernel and
follow with the Shapely layer.

49 unit tests, none of which touch the oracle. Harness proven by
mutation: radians for degrees, a shortened solver, a dropped scale
factor, a skipped crossing test and a flipped offset sign are each
caught. Oracle acceptance still skips; 236 unchanged.
2026-08-19 02:58:35 -05:00

435 lines
15 KiB
Python

"""
Unit tests for the ported ``sb-geom`` primitives.
These do not touch the oracle. Every expected value here is derived by hand or
from elementary geometry, so a test passing means the primitive agrees with
something outside itself rather than with its own implementation. The oracle
remains the acceptance criterion for the port as a whole; this file exists so
that when the oracle disagrees later, the primitives are not the first suspect.
Sign conventions and the arbitrary scale factors are asserted explicitly,
because those are the parts most likely to be quietly "improved" during a
refactor.
"""
from __future__ import annotations
import math
import pytest
from mechcomp.geom import (
SB_FACE_BOTH_OUT,
SB_FACE_MINUS_IN,
SB_FACE_PLUS_IN,
Geo,
Member,
cavity_path,
cos_d,
face_toward,
member_on_edge,
member_radial,
place,
sb_ccw,
sb_corner_radii,
sb_dist,
sb_line_isect,
sb_path_gap,
sb_path_max_round,
sb_paths_cross,
sb_pt_path_dist,
sb_pt_seg_dist,
sb_region_min_gap,
sb_signed_area,
sb_segs_cross,
sb_solvable,
sb_solve,
sin_d,
sleeve_path,
strap_layer_paths,
strap_path,
tan_d,
vector_angle,
)
UNIT_SQUARE = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
# ----------------------------------------------------------------------------
# Degrees, not radians
# ----------------------------------------------------------------------------
def test_trig_is_in_degrees():
"""The single most likely porting error. Asserted first, deliberately."""
assert cos_d(0.0) == pytest.approx(1.0)
assert cos_d(90.0) == pytest.approx(0.0, abs=1e-12)
assert sin_d(90.0) == pytest.approx(1.0)
assert sin_d(30.0) == pytest.approx(0.5)
assert tan_d(45.0) == pytest.approx(1.0)
def test_vector_angle_is_degrees_and_unsigned():
right = vector_angle((1.0, 0.0), (0.0, 0.0), (0.0, 1.0))
assert right == pytest.approx(90.0)
# Mirroring the third point must not change the magnitude.
assert vector_angle((1.0, 0.0), (0.0, 0.0), (0.0, -1.0)) == pytest.approx(90.0)
straight = vector_angle((-1.0, 0.0), (0.0, 0.0), (1.0, 0.0))
assert straight == pytest.approx(180.0)
# ----------------------------------------------------------------------------
# Vector helpers
# ----------------------------------------------------------------------------
def test_signed_area_sign_convention():
"""Positive means counter-clockwise. Reversing flips the sign."""
assert sb_signed_area(UNIT_SQUARE) == pytest.approx(1.0)
assert sb_signed_area(list(reversed(UNIT_SQUARE))) == pytest.approx(-1.0)
def test_ccw_normalises_winding():
assert sb_signed_area(sb_ccw(list(reversed(UNIT_SQUARE)))) > 0
assert sb_signed_area(sb_ccw(UNIT_SQUARE)) > 0
def test_line_isect_axes():
p = sb_line_isect((0.0, 5.0), (1.0, 0.0), (3.0, 0.0), (0.0, 1.0))
assert p == pytest.approx((3.0, 5.0))
def test_line_isect_parallel_is_none():
assert sb_line_isect((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 0.0)) is None
# ----------------------------------------------------------------------------
# Exact polyline distance
# ----------------------------------------------------------------------------
def test_pt_seg_dist_perpendicular_foot():
assert sb_pt_seg_dist((0.0, 0.0), (1.0, -1.0), (1.0, 1.0)) == pytest.approx(1.0)
def test_pt_seg_dist_clamps_to_endpoint():
"""Beyond the segment, the nearest point is the endpoint, not the line."""
assert sb_pt_seg_dist((0.0, 0.0), (1.0, 1.0), (2.0, 2.0)) == pytest.approx(math.sqrt(2.0))
def test_pt_seg_dist_degenerate_segment():
assert sb_pt_seg_dist((3.0, 4.0), (0.0, 0.0), (0.0, 0.0)) == pytest.approx(5.0)
def test_pt_path_dist_uses_nearest_edge():
assert sb_pt_path_dist((0.5, 3.0), UNIT_SQUARE) == pytest.approx(2.0)
def test_segs_cross_true_and_false():
assert sb_segs_cross((0.0, 0.0), (1.0, 1.0), (0.0, 1.0), (1.0, 0.0))
assert not sb_segs_cross((0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0))
def test_segs_cross_parallel_is_false():
"""Collinear overlap reports false: the reference guards on the determinant."""
assert not sb_segs_cross((0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (3.0, 0.0))
def test_path_gap_between_disjoint_squares():
far = [(3.0, 0.0), (4.0, 0.0), (4.0, 1.0), (3.0, 1.0)]
assert sb_path_gap(UNIT_SQUARE, far) == pytest.approx(2.0)
def test_path_gap_reports_zero_on_crossing():
"""
The case the naive vertex test gets wrong: a cross with no vertex of either
path near the other's boundary.
"""
horizontal = [(-5.0, 0.4), (5.0, 0.4), (5.0, 0.6), (-5.0, 0.6)]
vertical = [(0.4, -5.0), (0.6, -5.0), (0.6, 5.0), (0.4, 5.0)]
assert sb_paths_cross(horizontal, vertical)
assert sb_path_gap(horizontal, vertical) == 0.0
def test_path_gap_treats_nesting_as_wall_thickness():
"""A hole inside an outer boundary is the normal case, not an overlap."""
outer = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
hole = [(2.0, 2.0), (8.0, 2.0), (8.0, 8.0), (2.0, 8.0)]
assert sb_path_gap(outer, hole) == pytest.approx(2.0)
def test_region_min_gap_sentinel_below_two_paths():
assert sb_region_min_gap([]) == 1e9
assert sb_region_min_gap([UNIT_SQUARE]) == 1e9
def test_region_min_gap_picks_the_closest_pair():
a = UNIT_SQUARE
b = [(3.0, 0.0), (4.0, 0.0), (4.0, 1.0), (3.0, 1.0)]
c = [(1.5, 0.0), (2.0, 0.0), (2.0, 1.0), (1.5, 1.0)]
assert sb_region_min_gap([a, b, c]) == pytest.approx(0.5)
# ----------------------------------------------------------------------------
# Corner rounding
# ----------------------------------------------------------------------------
def test_path_max_round_on_a_square():
"""
Square of side 10: at each vertex the angle is 90 degrees and both arms are
10, so the limit is 10/2 * tan(45) = 5, scaled by the reference's 0.999.
"""
square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
assert sb_path_max_round(square) == pytest.approx(4.995)
def test_path_max_round_ignores_flat_vertices():
"""A collinear midpoint contributes the 1e9 sentinel, not a zero."""
with_flat = [(0.0, 0.0), (5.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]
assert sb_path_max_round(with_flat) > 1.0
def test_path_max_round_degenerate_path():
assert sb_path_max_round([(0.0, 0.0), (1.0, 1.0)]) == 0.0
def test_corner_radii_zero_on_a_convex_path():
"""No reflex vertices means no roundover anywhere, whatever r is asked for."""
assert sb_corner_radii(UNIT_SQUARE, 10.0) == [0.0, 0.0, 0.0, 0.0]
def test_corner_radii_rounds_only_the_reflex_vertex():
"""
An L, counter-clockwise. Exactly one vertex turns the wrong way, and only
that one takes a radius.
"""
ell = [(0.0, 0.0), (10.0, 0.0), (10.0, 4.0),
(4.0, 4.0), (4.0, 10.0), (0.0, 10.0)]
radii = sb_corner_radii(ell, 1.0)
assert sum(1 for r in radii if r > 0) == 1
assert radii[3] == pytest.approx(1.0)
def test_corner_radii_clamped_by_the_shorter_arm():
"""
At the reflex vertex the arms are 1 and 6 and the angle is 90 degrees, so
the fit limit is 0.98 * 1 / 2 * tan(45) = 0.49, below the requested 5.
"""
ell = [(0.0, 0.0), (10.0, 0.0), (10.0, 4.0),
(5.0, 4.0), (5.0, 5.0), (0.0, 5.0)]
radii = sb_corner_radii(ell, 5.0)
assert max(radii) == pytest.approx(0.49)
def test_corner_radii_winding_independent():
"""Reflex is a property of the shape, not of the direction it is written."""
ell = [(0.0, 0.0), (10.0, 0.0), (10.0, 4.0),
(4.0, 4.0), (4.0, 10.0), (0.0, 10.0)]
assert sorted(sb_corner_radii(ell, 1.0)) == \
sorted(sb_corner_radii(list(reversed(ell)), 1.0))
# ----------------------------------------------------------------------------
# Monotone solver
# ----------------------------------------------------------------------------
def test_solve_finds_the_root_of_a_linear_function():
assert sb_solve(lambda x: x, 0.0, 10.0, 3.0) == pytest.approx(3.0, abs=1e-9)
def test_solve_on_a_nonlinear_monotone_function():
assert sb_solve(lambda x: x * x, 0.0, 10.0, 2.0) == \
pytest.approx(math.sqrt(2.0), abs=1e-9)
def test_solve_iteration_count_is_fixed_not_adaptive():
"""
44 halvings of the bracket and no convergence test. Counting the calls is
the only way to catch a well-meaning early return, which would change the
last digits of every solved placement in the oracle.
"""
calls = []
def f(x):
calls.append(x)
return x
sb_solve(f, 0.0, 1.0, 0.5)
assert len(calls) == 44
def test_solve_clamps_to_the_bracket_when_target_is_out_of_range():
assert sb_solve(lambda x: x, 0.0, 1.0, 99.0) == pytest.approx(1.0, abs=1e-9)
def test_solvable_reports_feasibility():
assert sb_solvable(lambda x: x, 10.0, 3.0)
assert not sb_solvable(lambda x: x, 1.0, 3.0)
# ----------------------------------------------------------------------------
# GEO record
# ----------------------------------------------------------------------------
def geo(**kw) -> Geo:
base = dict(width=15.875, strap_t=0.508, count=1, clearance=0.2,
wall_inside=1.0, wall_outside=1.6, wall_edge=1.2, min_wall=0.8)
base.update(kw)
return Geo(**base)
def test_cavity_is_the_bundle_grown_on_all_four_faces():
g = geo()
assert g.bundle_t == pytest.approx(0.508)
assert g.cavity_w == pytest.approx(15.875 + 0.4)
assert g.cavity_t == pytest.approx(0.508 + 0.4)
def test_bundle_thickness_scales_with_count():
assert geo(count=3).bundle_t == pytest.approx(1.524)
assert geo(count=3).cavity_t == pytest.approx(1.524 + 0.4)
def test_declared_web_is_plastic_not_cavity_spacing():
"""A stated 1.2 mm web must be 1.2 mm of PLA, so the strap gap is larger."""
g = geo()
assert g.web_to_strap_gap(1.2) == pytest.approx(1.2 + 0.4)
def test_reach_depends_on_which_face_encloses():
g = geo()
half = g.cavity_t / 2.0
assert g.reach_plus(SB_FACE_PLUS_IN) == pytest.approx(half + 1.0)
assert g.reach_minus(SB_FACE_PLUS_IN) == pytest.approx(half + 1.6)
assert g.reach_plus(SB_FACE_MINUS_IN) == pytest.approx(half + 1.6)
assert g.reach_minus(SB_FACE_MINUS_IN) == pytest.approx(half + 1.0)
def test_a_member_with_no_interior_is_symmetric():
"""
Design rule 4: asymmetric wall settings must never make a symmetric profile
chiral, so both faces take the outside wall.
"""
g = geo()
assert g.reach_plus(SB_FACE_BOTH_OUT) == \
pytest.approx(g.reach_minus(SB_FACE_BOTH_OUT))
# ----------------------------------------------------------------------------
# MEMBER record
# ----------------------------------------------------------------------------
def test_member_axis_and_normal_are_orthonormal():
m = Member(0.0, 0.0, 37.0)
ax, ay = m.axis
nx, ny = m.normal
assert math.hypot(ax, ay) == pytest.approx(1.0)
assert ax * nx + ay * ny == pytest.approx(0.0, abs=1e-12)
def test_member_axis_follows_the_angle_in_degrees():
assert Member(0.0, 0.0, 90.0).axis == pytest.approx((0.0, 1.0), abs=1e-12)
def test_face_toward_picks_the_side_containing_the_target():
"""Angle 0 means normal is +Y, so a target above is the plus face."""
assert face_toward((0.0, 0.0), 0.0, (0.0, 5.0)) == SB_FACE_PLUS_IN
assert face_toward((0.0, 0.0), 0.0, (0.0, -5.0)) == SB_FACE_MINUS_IN
assert face_toward((0.0, 0.0), 0.0, None) == SB_FACE_BOTH_OUT
def test_inside_dir_is_none_without_an_interior():
assert Member(0.0, 0.0, 0.0, SB_FACE_BOTH_OUT).inside_dir is None
def test_member_on_edge_sits_at_the_midpoint():
m = member_on_edge((0.0, 0.0), (10.0, 0.0), None)
assert (m.cx, m.cy) == pytest.approx((5.0, 0.0))
assert m.angle == pytest.approx(0.0)
def test_member_on_edge_shift_moves_along_its_own_axis():
m = member_on_edge((0.0, 0.0), (0.0, 10.0), None, shift=2.0)
assert (m.cx, m.cy) == pytest.approx((0.0, 7.0), abs=1e-12)
def test_member_radial_places_the_centre_on_the_ray():
m = member_radial(4.0, 90.0)
assert (m.cx, m.cy) == pytest.approx((0.0, 4.0), abs=1e-12)
assert m.angle == pytest.approx(90.0)
def test_offset_out_moves_away_from_the_interior():
"""
Face +Y encloses the interior, so the outward move must be towards -Y.
Getting this sign backwards would push every sleeve into the cavity.
"""
m = Member(0.0, 0.0, 0.0, SB_FACE_PLUS_IN)
assert m.offset_out(2.0).cy == pytest.approx(-2.0)
assert Member(0.0, 0.0, 0.0, SB_FACE_MINUS_IN).offset_out(2.0).cy == \
pytest.approx(2.0)
assert Member(0.0, 0.0, 0.0, SB_FACE_BOTH_OUT).offset_out(2.0).cy == \
pytest.approx(2.0)
# ----------------------------------------------------------------------------
# Member paths
# ----------------------------------------------------------------------------
def test_place_rotates_then_translates():
moved = place(Member(3.0, 4.0, 90.0), [(1.0, 0.0)])
assert moved[0] == pytest.approx((3.0, 5.0), abs=1e-12)
def test_strap_path_dimensions():
g = geo()
path = strap_path(Member(0.0, 0.0, 0.0), g)
xs = [p[0] for p in path]
ys = [p[1] for p in path]
assert max(xs) - min(xs) == pytest.approx(g.width)
assert max(ys) - min(ys) == pytest.approx(g.bundle_t)
def test_cavity_is_larger_than_the_strap_it_holds():
g = geo()
m = Member(0.0, 0.0, 0.0)
cav = cavity_path(m, g)
xs = [p[0] for p in cav]
assert max(xs) - min(xs) == pytest.approx(g.cavity_w)
assert g.cavity_w > g.width and g.cavity_t > g.bundle_t
def test_sleeve_encloses_the_cavity_on_every_face():
g = geo()
m = Member(0.0, 0.0, 0.0, SB_FACE_BOTH_OUT)
cav = cavity_path(m, g)
sleeve = sleeve_path(m, g)
for v in cav:
assert sb_pt_path_dist(v, sleeve) > 0.0
sx = [p[0] for p in sleeve]
assert max(sx) - min(sx) == pytest.approx(g.cavity_w + 2 * g.wall_edge)
def test_strap_layers_partition_the_bundle():
g = geo(count=3)
layers = strap_layer_paths(Member(0.0, 0.0, 0.0), g)
assert len(layers) == 3
ys = [p[1] for layer in layers for p in layer]
assert max(ys) - min(ys) == pytest.approx(g.bundle_t)
def test_paths_are_counter_clockwise():
"""Downstream boolean operations assume a consistent winding."""
g = geo()
m = Member(0.0, 0.0, 30.0)
for path in (strap_path(m, g), cavity_path(m, g), sleeve_path(m, g)):
assert sb_signed_area(path) > 0
def test_rotated_member_preserves_its_own_dimensions():
"""Rotation must not change the section, only where it sits."""
g = geo()
flat = sleeve_path(Member(0.0, 0.0, 0.0), g)
tilted = sleeve_path(Member(2.0, -3.0, 37.0), g)
for i in range(4):
a_flat = sb_dist(flat[i], flat[(i + 1) % 4])
a_tilt = sb_dist(tilted[i], tilted[(i + 1) % 4])
assert a_flat == pytest.approx(a_tilt)