"""n3xd_ocp.helix — OCCT 8.0's TKHelix builder. The toolkit is new in 8.0 and has no upstream binding to compare against, so these are property checks rather than a parity gate: the wire has to be a real helix of the requested pitch and turn count, and the failure modes have to surface as Python exceptions rather than a silently empty shape. """ from __future__ import annotations import math import pytest import n3xd_ocp from OCP.BRepGProp import BRepGProp from OCP.GeomAbs import GeomAbs_C1, GeomAbs_C2 from OCP.gp import gp_Ax3, gp_Dir, gp_Pnt from OCP.GProp import GProp_GProps from OCP.TopAbs import TopAbs_EDGE from OCP.TopExp import TopExp from OCP.TopTools import TopTools_IndexedMapOfShape AXIS = gp_Ax3(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1), gp_Dir(1, 0, 0)) def _length(shape) -> float: props = GProp_GProps() BRepGProp.LinearProperties_s(shape, props) return props.Mass() def _edge_count(shape) -> int: edges = TopTools_IndexedMapOfShape() TopExp.MapShapes_s(shape, TopAbs_EDGE, edges) return edges.Extent() def test_single_segment_has_the_right_arc_length(): """One turn of a helix is sqrt(circumference^2 + pitch^2) long.""" diameter, pitch, turns = 8.0, 1.25, 1.0 wire, reached = n3xd_ocp.helix.pure_helix(AXIS, diameter, [pitch], [turns]) expected = turns * math.hypot(math.pi * diameter, pitch) assert _length(wire) == pytest.approx(expected, rel=1e-4) assert reached > 0.0 assert _edge_count(wire) >= 1 @pytest.mark.parametrize("turns", [0.5, 3.0, 24.0]) def test_arc_length_scales_with_the_turn_count(turns): diameter, pitch = 8.0, 1.25 wire, _reached = n3xd_ocp.helix.pure_helix(AXIS, diameter, [pitch], [turns]) expected = turns * math.hypot(math.pi * diameter, pitch) assert _length(wire) == pytest.approx(expected, rel=1e-4) def test_variable_pitch_segments_compose(): """The array form is the reason this is bound at all — a spring whose pitch changes partway is one wire, not two.""" diameter = 10.0 wire, _reached = n3xd_ocp.helix.pure_helix( AXIS, diameter, [2.0, 5.0], [3.0, 2.0] ) expected = 3.0 * math.hypot(math.pi * diameter, 2.0) + 2.0 * math.hypot( math.pi * diameter, 5.0 ) assert _length(wire) == pytest.approx(expected, rel=1e-3) def test_tapered_diameters_via_the_builder(): """Two segments, so three boundary diameters: 10 -> 8 tapering, then 8 -> 8 straight. A tapered thread (NPT) is the reason the array form is bound.""" builder = n3xd_ocp.helix.BuilderHelix() builder.set_parameters(AXIS, [10.0, 8.0, 8.0], [2.0, 2.0], [2.0, 2.0]) builder.set_approx_parameters(1.0e-4, 8, GeomAbs_C1) builder.perform() assert builder.error_status() == 0 # The straight half is exact; the tapered half is longer than a cylinder of # its smaller diameter and shorter than one of its larger. straight = 2.0 * math.hypot(math.pi * 8.0, 2.0) largest = 2.0 * math.hypot(math.pi * 10.0, 2.0) total = _length(builder.shape()) assert straight * 2 < total < straight + largest def test_the_n_plus_one_diameter_rule_is_enforced(): """OCCT wants N+1 diameters for N segments and raises Standard_ConstructionError otherwise; the binding checks it first so the message names the shape it wanted.""" builder = n3xd_ocp.helix.BuilderHelix() with pytest.raises(ValueError, match="N\\+1 diameters"): builder.set_parameters(AXIS, [8.0], [1.25], [1.0]) builder.set_parameters(AXIS, [8.0, 8.0], [1.25], [1.0]) # accepted def test_continuity_is_selectable(): wire_c1, _ = n3xd_ocp.helix.pure_helix( AXIS, 8.0, [1.25], [2.0], continuity=GeomAbs_C1 ) wire_c2, _ = n3xd_ocp.helix.pure_helix( AXIS, 8.0, [1.25], [2.0], continuity=GeomAbs_C2 ) assert _length(wire_c1) == pytest.approx(_length(wire_c2), rel=1e-3) def test_mismatched_segment_arrays_are_rejected(): with pytest.raises(ValueError): n3xd_ocp.helix.pure_helix(AXIS, 8.0, [1.0, 2.0], [1.0]) with pytest.raises(ValueError): n3xd_ocp.helix.pure_helix(AXIS, 8.0, [], []) def test_builder_rejects_ragged_parameters(): builder = n3xd_ocp.helix.BuilderHelix() with pytest.raises(ValueError): builder.set_parameters(AXIS, [8.0, 8.0], [1.0], [1.0, 2.0]) with pytest.raises(ValueError): builder.set_parameters(AXIS, [8.0, 8.0], [], [])