main
John Lauer Publish 0.2.19 81ffca7
"""Issue #89 (reply), offline (synthetic step_meta, a mocked service; no network): `adom-aiflow-etch outline
--mode etched|composed|auto`. The etched mode draws the name from the etched model's own mark edges (read from
the STEP text, posed with the etch's quarter turns, projected with iso() and the body drawing's calibrate()),
and the outline gate (mark_check.check_outline) passes it; auto falls back to composed when the etched STEP
has no mark or its mark fails the gate; etched refuses without a readable mark.
Run: python3 -m unittest -v test_outline_modes   (from tools/laser-etch)"""
import os, re, sys, tempfile, unittest

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import mark_geom as MG
import mark_outline as MO
import step_text as ST
import test_mark_plan_rows as T

STROKE = ' fill="none" stroke="#8b949e" stroke-width="1"'


class FakeService:
    """/outline draws the posed body as one polyline through its projected vertices (the extents are what
    calibrate() reads); a named request (the composed name) answers `named`; /step-meta answers `meta`."""
    def __init__(self, meta, named=None):
        self.meta, self.named, self.calls = meta, named or {}, []

    def outline(self, text, name=None, rot=None, suffix=None, line2=None, job=""):
        self.calls.append(("outline", name))
        if name:
            return {"outlines": self.named}
        P = [MG.iso(v) for v in MG.vertices(ST.Step(text))]
        pts = " ".join("%.3f,%.3f" % (160 + 40 * x, 160 + 40 * y) for x, y in P)
        return {"outlines": {"dark": f'<svg viewBox="0 0 320 320"><polyline points="{pts}"{STROKE}/></svg>'}}

    def step_meta(self, text, job=""):
        self.calls.append(("meta", job))
        return self.meta

    def __enter__(self):
        self._o = (MO.SVC.outline, MO.SVC.step_meta)
        MO.SVC.outline, MO.SVC.step_meta = self.outline, self.step_meta
        return self

    def __exit__(self, *a):
        MO.SVC.outline, MO.SVC.step_meta = self._o


def letters(plan, cap):
    """Two lines of glyph boxes on the planned band in the etch frame (reading +X), centred on the band."""
    b = plan["band"]["regionMm"]; z = plan["band"]["zMm"]; qz = plan["etch"]["poseQuarterTurnsZ"]
    pts = [MO.MP._rot([x, y, 0], "z", qz) for x, y in ((b[0], b[1]), (b[2], b[3]))]
    cx = (min(p[0] for p in pts) + max(p[0] for p in pts)) / 2; cy = (min(p[1] for p in pts) + max(p[1] for p in pts)) / 2
    out = []
    for row, n in ((1, 4), (-1, 5)):
        y0 = cy + row * 0.6 * cap - cap / 2
        for i in range(n):
            x0 = cx + (i - n / 2) * 0.7 * cap
            w = 0.55 * cap
            out.append([[x0, y0, z], [x0 + w, y0, z], [x0 + w, y0 + cap, z], [x0, y0 + cap, z], [x0, y0, z]])
    return out


class OutlineModes(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.plan = T.run_plan(T.tssop16_meta(), T.tssop16_pads())
        # the body: the proxy box sized to the TSSOP-16 body the plan was made for
        self.step = os.path.join(self.tmp.name, "part.step")
        ST.Step(os.path.join(HERE, "proxy_box.step")).scale_axes(-2.2, -2.5, 0.0, 4.4, 5.0, 1.1).write(self.step)
        self.etched = os.path.join(self.tmp.name, "part-etched.step")
        ST.Step(self.step).write(self.etched)
        self._edges = MG.mark_edges

    def tearDown(self):
        MG.mark_edges = self._edges
        self.tmp.cleanup()

    def run_mode(self, mode, edges, named=None, etched=True):
        MG.mark_edges = lambda step, name=None, tol=1e-3: edges
        logs = []
        with FakeService(T.tssop16_meta(), named) as svc:
            r = MO.outlines(self.step, self.plan, self.etched if etched else None, self.tmp.name, ("dark",), "P", log=logs.append, mode=mode)
        return r, logs, svc

    def test_etched_mode_draws_the_mark_edges_and_passes_the_gate(self):
        r, logs, svc = self.run_mode("etched", letters(self.plan, 0.73))
        o = r["outlines"][0]
        self.assertEqual(o["gate"]["status"], "PASS", (o["gate"], logs))
        self.assertTrue(r["mode"].startswith("etched"))
        svg = open(o["path"]).read()
        self.assertIn('data-source="etched-step-mark-edges"', svg)
        self.assertIn('data-lines="74HC|595PW"', svg)
        self.assertEqual(len(re.findall(r"<polyline", re.search(r'<g id="adom-mark-name".*?</g>', svg, re.S).group(0))), 9)
        self.assertIn('stroke="#8b949e"', svg.split('id="adom-mark-name"')[1])     # the body's own stroke style
        self.assertNotIn(("outline", "74HC"), svc.calls)                            # no composed name was drawn
        self.assertGreater(o["gate"]["capHeightPx320"], 6.0)

    def test_auto_uses_the_etched_mark(self):
        r, _, _ = self.run_mode("auto", letters(self.plan, 0.73))
        self.assertEqual(r["outlines"][0]["mode"], MO.ETCHED_MODE)

    def test_auto_without_a_mark_composes(self):
        r, logs, svc = self.run_mode("auto", [])
        self.assertIn(("outline", "74HC"), svc.calls)          # the composed name was asked for
        self.assertTrue(any("no readable mark edges" in m for m in logs), logs)

    def test_auto_falls_back_when_the_mark_fails_the_gate(self):
        r, logs, svc = self.run_mode("auto", letters(self.plan, 0.05))    # a mark too small to read in the widget
        self.assertTrue(any("fail the outline gate" in m for m in logs), logs)
        self.assertIn(("outline", "74HC"), svc.calls)

    def test_composed_mode_never_reads_the_mark(self):
        r, logs, svc = self.run_mode("composed", letters(self.plan, 0.73))
        self.assertIn(("outline", "74HC"), svc.calls)
        self.assertFalse(any(o["mode"] == MO.ETCHED_MODE for o in r["outlines"]))

    def test_etched_mode_needs_a_readable_etched_step(self):
        with self.assertRaises(SystemExit) as e:
            self.run_mode("etched", letters(self.plan, 0.73), etched=False)
        self.assertIn("--etched", str(e.exception))
        with self.assertRaises(SystemExit) as e:
            self.run_mode("etched", [])
        self.assertIn("no readable mark edges", str(e.exception))


class StepEdges(unittest.TestCase):
    """Edge points read from STEP text: a line's two vertices, a B-spline evaluated (plain and rational)."""
    TEXT = """ISO-10303-21;
HEADER;
ENDSEC;
DATA;
#1=CARTESIAN_POINT('',(0.,0.,1.));
#2=VERTEX_POINT('',#1);
#3=CARTESIAN_POINT('',(1.,0.,1.));
#4=VERTEX_POINT('',#3);
#5=LINE('',#1,#6);
#6=VECTOR('',#7,1.);
#7=DIRECTION('',(1.,0.,0.));
#8=EDGE_CURVE('',#2,#4,#5,.T.);
#9=CARTESIAN_POINT('',(1.,1.,1.));
#10=CARTESIAN_POINT('',(0.,1.,1.));
#11=VERTEX_POINT('',#10);
#12=B_SPLINE_CURVE_WITH_KNOTS('',2,(#3,#9,#10),
  .UNSPECIFIED.,.F.,.F.,(3,3),(0.,1.),.PIECEWISE_BEZIER_KNOTS.);
#13=SURFACE_CURVE('',#12,(#99),.PCURVE_S1.);
#14=EDGE_CURVE('',#4,#11,#13,.T.);
#15=( BOUNDED_CURVE() B_SPLINE_CURVE(2,(#3,#9,#10),.UNSPECIFIED.,.F.,.F.) B_SPLINE_CURVE_WITH_KNOTS((3,3),(0.,1.),.PIECEWISE_BEZIER_KNOTS.) CURVE() GEOMETRIC_REPRESENTATION_ITEM() RATIONAL_B_SPLINE_CURVE((1.,0.7071067811865476,1.)) REPRESENTATION_ITEM('') );
#16=EDGE_CURVE('',#4,#11,#15,.T.);
#99=CARTESIAN_POINT('',(0.,0.,0.));
ENDSEC;
END-ISO-10303-21;
"""

    def test_line_and_splines(self):
        s = ST.Step(self.TEXT)
        self.assertEqual(MG.edge_points(s, 8), [[0.0, 0.0, 1.0], [1.0, 0.0, 1.0]])
        q = MG.edge_points(s, 14)
        self.assertEqual(q[0], [1.0, 0.0, 1.0]); self.assertEqual(q[-1], [0.0, 1.0, 1.0])
        mid = q[len(q) // 2]
        self.assertAlmostEqual(mid[0], 0.75, 2); self.assertAlmostEqual(mid[1], 0.75, 2)      # the parabola, not the control point
        r = MG.edge_points(s, 16)                                                            # a quarter circle about (0,0)
        for p in r:
            self.assertAlmostEqual((p[0] ** 2 + p[1] ** 2) ** 0.5, 1.0, 6)


if __name__ == "__main__":
    unittest.main()