main
Drew Owens 0.10.10 source 66cb2da 5d ago
#!/usr/bin/env python3
"""dsl-live-check.py - prove the 0.10.x modelling ops on REAL SOLIDWORKS.

Every check builds a scratch part through swx_run_script, reads the result back with
swx_inspect_bodies / swx_mass_properties, and compares it with geometry computed HERE,
independently of the bridge and of its simulator (volumes from closed-form formulas,
bounding boxes from the documented conventions). Nothing is trusted that was not measured.

    python3 tools/dsl-live-check.py [--target adom_drew2] [--only T05,T12] [--keep]

It needs SOLIDWORKS running and ready on the target (swx_status: solidworks.ready). It only
ever touches documents it creates (new_part / swx_new_document), and closes them with
swx_reset_workspace after every check unless --keep is given. The report (every reply,
every expectation, pass/fail) is written to dsl-live-report.json next to where it runs.

Covers wiki issue adom/solidworks-bridge#1 (offset/face sketches, cut direction, multi-profile
sketches, appearance, conventions, fillet/chamfer/shell/patterns) and #2 (per-body mass,
run_script ownership), plus the LEGO 2x4 acceptance brick (examples/lego-2x4.json).
"""
import argparse, json, math, os, subprocess, sys, time

PI = math.pi
TARGET = "adom_drew2"
THREAD = "SolidWorks Bridge"
REUSE = {"path": None}   # --reuse: one bridge-made part, cleared between checks (clear_part, 0.10.6+)
# Checks that keep their own fresh part even with --reuse: they set a material or colour that
# would survive clear_part and contaminate later checks, or they assert feature names, whose
# numbering after a clear is unmeasured.
FRESH_ALWAYS = {"T15", "T16", "T22", "T24"}
REL_TOL = 2e-3          # volumes: 0.2 % (SOLIDWORKS mass properties are far tighter than this)
MM_TOL = 0.01           # bounding boxes: 0.01 mm


def ascii_reason(args):
    """MEASURED 2026-10-13 (ab 2.1.174): a non-ASCII character in `reason` makes ab fail the call
    before it reaches the bridge (adom/adom-bridge issue on header encoding). Keep reasons ASCII."""
    if isinstance(args, dict) and isinstance(args.get("reason"), str):
        args = dict(args, reason=args["reason"].encode("ascii", "replace").decode("ascii"))
    return args


def call(verb, args, timeout=240):
    """One bridge verb through adom-bridge; returns the reply dict (unwrapped)."""
    args = ascii_reason(args)
    try:
        r = subprocess.run(["adom-bridge", "--target", TARGET, "--ai-thread", THREAD, verb, json.dumps(args)],
                           capture_output=True, text=True, timeout=timeout + 60)
    except subprocess.TimeoutExpired:
        return {"status": "error", "errorCode": "no_answer", "error": f"{verb} did not return in {timeout + 60}s"}
    t = r.stdout
    i = t.find("{")
    if i < 0:
        return {"status": "error", "errorCode": "not_json", "error": (t + r.stderr)[:400]}
    try:
        d = json.loads(t[i:])
    except ValueError:
        return {"status": "error", "errorCode": "not_json", "error": t[i:i + 400]}
    return d["result"] if isinstance(d, dict) and isinstance(d.get("result"), dict) else d


def ok(reply):
    return reply.get("status") == "ok" and reply.get("success", True) is not False and not reply.get("errorCode")


# ── the geometry, computed here ──────────────────────────────────────────────

def box(x0, x1, y0, y1, z0, z1):
    return {"min": [x0, y0, z0], "max": [x1, y1, z1]}


def rounded_box_volume(a, b, c, r):
    """A box a x b x c with all 12 edges filleted r (spherical corners) = the Minkowski sum
    of the inner box with a sphere of radius r."""
    ai, bi, ci = a - 2 * r, b - 2 * r, c - 2 * r
    return ai * bi * ci + 2 * r * (ai * bi + bi * ci + ai * ci) + PI * r * r * (ai + bi + ci) + 4.0 / 3.0 * PI * r ** 3


def fillet_cylinder_volume(d, h, r):
    """A cylinder d x h with both circular edges filleted r."""
    R = d / 2.0
    area = (1 - PI / 4) * r * r
    xbar = r * (5.0 / 6.0 - PI / 4) / (1 - PI / 4)
    return PI * R * R * h - 2 * area * 2 * PI * (R - xbar)


def cyl(d, h):
    return PI * d * d / 4.0 * h


# The base block every "add a feature" check starts from: 20 x 10 x 20 mm, centred on Top Plane,
# extruded +Y: X -10..10, Y 0..10, Z -10..10, volume 4000.
BASE = [
    {"op": "new_part"},
    {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 20, "heightMm": 20, "anchor": "center"},
    {"op": "extrude", "depthMm": 10},
]
BASE_BOX = box(-10, 10, 0, 10, -10, 10)


def checks():
    """(id, title, issue, script, expectations). Expectation keys:
    volume (mm^3), bbox, bodyCount, faceCount, appearance [r,g,b], massKg, densityKgM3,
    features [names], expectError (the run must fail at that step index, never succeed silently)."""
    lego = json.load(open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "examples", "lego-2x4.json")))
    lego_volume = (31.8 * 15.8 * 9.6) - (29.4 * 13.4 * 8.6) + 3 * (cyl(6.51, 8.6) - cyl(4.8, 8.6)) + 8 * cyl(4.8, 1.7)
    return [
        ("T01", "rectangle anchor: xMm/yMm is the CORNER by default", "#1.5",
         [{"op": "new_part"}, {"op": "sketch_rect", "plane": "Front Plane", "xMm": 0, "yMm": 0, "widthMm": 10, "heightMm": 20},
          {"op": "extrude", "depthMm": 5}],
         {"volume": 1000.0, "bbox": box(0, 10, 0, 20, 0, 5)}),
        ("T02", "Top Plane: sketch +Y is model -Z", "#1.5",
         [{"op": "new_part"}, {"op": "sketch_rect", "plane": "Top Plane", "xMm": 0, "yMm": 0, "widthMm": 10, "heightMm": 20},
          {"op": "extrude", "depthMm": 5}],
         {"volume": 1000.0, "bbox": box(0, 10, 0, 5, -20, 0)}),
        ("T03", "anchor center", "#1.5",
         [{"op": "new_part"}, {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 20, "anchor": "center"},
          {"op": "extrude", "depthMm": 5}],
         {"volume": 1000.0, "bbox": box(-5, 5, -10, 10, 0, 5)}),
        ("T04", "sketch on a plane offset +10 mm from Top Plane", "#1.1",
         BASE + [{"op": "sketch", "plane": {"offsetFrom": "Top Plane", "offsetMm": 10}, "entities": [{"circle": {"centerMm": [0, 0], "diameterMm": 10}}]},
                 {"op": "extrude", "depthMm": 5}],
         {"volume": 4000 + cyl(10, 5), "bbox": box(-10, 10, 0, 15, -10, 10)}),
        ("T05", "sketch on a NEGATIVE offset plane (-5 mm)", "#1.1",
         BASE + [{"op": "sketch", "plane": {"offsetFrom": "Top Plane", "offsetMm": -5}, "entities": [{"rect": {"widthMm": 20, "heightMm": 20, "anchor": "center"}}]},
                 {"op": "extrude", "depthMm": 5}],
         {"volume": 6000.0, "bbox": box(-10, 10, -5, 10, -10, 10)}),
        ("T06", "sketch on a FACE picked by a point (the block's top face)", "#1.1",
         BASE + [{"op": "sketch", "face": {"atPointMm": [0, 10, 0]}, "entities": [{"circle": {"centerMm": [0, 0], "diameterMm": 6}}]},
                 {"op": "extrude", "depthMm": 4}],
         {"volume": 4000 + cyl(6, 4), "bbox": box(-10, 10, 0, 14, -10, 10)}),
        ("T07", "Michael's case: a default cut from Top Plane with the body on +Y must FAIL LOUDLY, never succeed silently", "#1.2",
         BASE + [{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"},
                 {"op": "cut", "depthMm": 5}],
         {"expectError": 4, "orVolumeChanged": 4000.0}),
        ("T08", "cut reverse:true removes material on +Y", "#1.2",
         BASE + [{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"},
                 {"op": "cut", "depthMm": 5, "reverse": True}],
         {"volume": 3500.0, "bbox": BASE_BOX}),
        ("T09", "cut throughAll + reverse", "#1.2",
         BASE + [{"op": "sketch_rect", "plane": "Top Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"},
                 {"op": "cut", "throughAll": True, "reverse": True}],
         {"volume": 3000.0, "bbox": BASE_BOX}),
        ("T10", "extrude direction both (5 mm one way, 3 mm the other)", "#1.2",
         [{"op": "new_part"}, {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"},
          {"op": "extrude", "depthMm": 5, "direction": "both", "depth2Mm": 3}],
         {"volume": 800.0, "bboxZSpan": 8.0, "bboxXY": [[-5, 5], [-5, 5]]}),
        ("T11", "extrude midPlane", "#1.2",
         [{"op": "new_part"}, {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"},
          {"op": "extrude", "depthMm": 10, "midPlane": True}],
         {"volume": 1000.0, "bbox": box(-5, 5, -5, 5, -5, 5)}),
        ("T12", "cut midPlane through the block", "#1.2",
         BASE + [{"op": "sketch_rect", "plane": "Front Plane", "widthMm": 4, "heightMm": 4, "anchor": "center", "yMm": 5},
                 {"op": "cut", "depthMm": 6, "midPlane": True}],
         {"volume": 4000 - 96.0, "bbox": BASE_BOX}),
        ("T13", "two concentric circles in ONE sketch extrude as a RING", "#1.3",
         [{"op": "new_part"}, {"op": "sketch", "plane": "Top Plane", "entities": [{"circle": {"centerMm": [0, 0], "diameterMm": 10}}, {"circle": {"centerMm": [0, 0], "diameterMm": 6}}]},
          {"op": "extrude", "depthMm": 10}],
         {"volume": cyl(10, 10) - cyl(6, 10), "faceCount": 4, "bodyCount": 1}),
        ("T14", "two disjoint rectangles in ONE sketch: one feature, two bodies", "#1.3",
         [{"op": "new_part"}, {"op": "sketch", "plane": "Front Plane", "entities": [{"rect": {"xMm": 0, "yMm": 0, "widthMm": 5, "heightMm": 5}}, {"rect": {"xMm": 10, "yMm": 0, "widthMm": 5, "heightMm": 5}}]},
          {"op": "extrude", "depthMm": 5}],
         {"volume": 250.0, "bodyCount": 2}),
        ("T15", "set_appearance on the part", "#1.4",
         BASE + [{"op": "set_appearance", "rgb": [200, 0, 0]}],
         {"volume": 4000.0, "appearance": [200, 0, 0]}),
        ("T16", "set_appearance on one face picked by a point", "#1.4",
         BASE + [{"op": "set_appearance", "rgb": [0, 0, 255], "target": {"faceAtPointMm": [0, 10, 0]}}],
         {"volume": 4000.0, "stepReadBack": 3}),
        ("T17", "fillet every edge of the block, r = 1", "#1.6",
         BASE + [{"op": "fillet", "radiusMm": 1, "edges": {"allOfFeature": "Boss-Extrude1"}}],
         {"volume": rounded_box_volume(20, 10, 20, 1), "bbox": BASE_BOX}),
        ("T18", "chamfer ONE edge picked by a point, 1 mm", "#1.6",
         BASE + [{"op": "chamfer", "distanceMm": 1, "edges": {"atPointsMm": [[0, 10, 10]]}}],
         {"volume": 4000 - 0.5 * 1 * 1 * 20, "bbox": BASE_BOX}),
        ("T19", "shell 1 mm, top face removed", "#1.6",
         BASE + [{"op": "shell", "thicknessMm": 1, "removeFacesAtPointsMm": [[0, 10, 0]]}],
         {"volume": 4000 - 18 * 18 * 9, "bbox": BASE_BOX}),
        ("T20", "linear pattern: one boss -> 3 along X, 6 mm apart", "#1.6",
         BASE + [{"op": "sketch", "face": {"atPointMm": [0, 10, 0]}, "entities": [{"circle": {"centerMm": [-6, 0], "diameterMm": 4}}]},
                 {"op": "extrude", "depthMm": 5},
                 {"op": "linear_pattern", "feature": "Boss-Extrude2", "directions": [{"axis": "x", "count": 3, "spacingMm": 6}]}],
         {"volume": 4000 + 3 * cyl(4, 5), "bbox": box(-10, 10, 0, 15, -10, 10)}),
        ("T21", "circular pattern: one boss -> 6 around Y", "#1.6",
         BASE + [{"op": "sketch", "face": {"atPointMm": [0, 10, 0]}, "entities": [{"circle": {"centerMm": [7, 0], "diameterMm": 3}}]},
                 {"op": "extrude", "depthMm": 4},
                 {"op": "circular_pattern", "feature": "Boss-Extrude2", "axis": "y", "count": 6}],
         {"volume": 4000 + 6 * cyl(3, 4)}),
        ("T22", "issue #2: body mass uses the material's density (ABS 1020 kg/m^3)", "#2.a",
         BASE + [{"op": "set_material", "name": "ABS"}],
         {"volume": 4000.0, "massKg": 4000e-9 * 1020, "densityKgM3": 1020.0, "massMatchesMassProperties": True}),
        ("T25", "audit #1/#5: sketch on the BOTTOM face (hidden in an iso view, normal -Y) extrudes OUTWARD", "#1.1",
         BASE + [{"op": "sketch", "face": {"atPointMm": [0, 0, 0]}, "entities": [{"circle": {"centerMm": [0, 0], "diameterMm": 6}}]},
                 {"op": "extrude", "depthMm": 3}],
         {"volume": 4000 + cyl(6, 3), "bbox": box(-10, 10, -3, 10, -10, 10)}),
        ("T26", "audit unverified #1: a NEGATIVE offset plane keeps the base plane's axes (asymmetric sketch, separate body)", "#1.1",
         BASE + [{"op": "sketch", "plane": {"offsetFrom": "Top Plane", "offsetMm": -5}, "entities": [{"rect": {"xMm": 0, "yMm": 0, "widthMm": 4, "heightMm": 6}}]},
                 {"op": "extrude", "depthMm": 2}],
         {"volume": 4000 + 48.0, "bodyCount": 2, "bodyBoxes": [BASE_BOX, box(0, 4, -5, -3, -6, 0)]}),
        ("T27", "audit #6: an ASYMMETRIC two-way cut: depthMm goes along the cut's own direction (-normal), depth2Mm the other way", "#1.2",
         [{"op": "new_part"}, {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 10, "anchor": "center"},
          {"op": "extrude", "depthMm": 10},
          {"op": "sketch_rect", "plane": "Front Plane", "widthMm": 4, "heightMm": 4, "anchor": "center"},
          {"op": "cut", "depthMm": 6, "direction": "both", "depth2Mm": 2}],
         {"volume": 1000 - 4 * 4 * 2, "bbox": box(-5, 5, -5, 5, 0, 10)}),
        ("T28", "audit unverified #2: linear pattern with NEGATIVE spacing goes -X (instances stay on the block)", "#1.6",
         BASE + [{"op": "sketch", "face": {"atPointMm": [0, 10, 0]}, "entities": [{"circle": {"centerMm": [6, 0], "diameterMm": 4}}]},
                 {"op": "extrude", "depthMm": 5},
                 {"op": "linear_pattern", "feature": "Boss-Extrude2", "directions": [{"axis": "x", "count": 3, "spacingMm": -6}]}],
         {"volume": 4000 + 3 * cyl(4, 5), "bodyCount": 1, "bbox": box(-10, 10, 0, 15, -10, 10)}),
        ("T29", "audit #1: shell removing the BOTTOM face (hidden in an iso view) removes that face, not a side", "#1.6",
         BASE + [{"op": "shell", "thicknessMm": 1, "removeFacesAtPointsMm": [[0, 0, 0]]}],
         {"volume": 4000 - 18 * 18 * 9, "bbox": BASE_BOX}),
        ("T30", "audit #3: fillet ONE feature's edges by allOfFeature on a cylinder (2 circular edges)", "#1.6",
         [{"op": "new_part"}, {"op": "sketch_circle", "plane": "Top Plane", "radiusMm": 5}, {"op": "extrude", "depthMm": 10},
          {"op": "fillet", "radiusMm": 1, "edges": {"allOfFeature": "Boss-Extrude1"}}],
         # a 10 mm-dia cylinder 10 tall with both circular edges rounded r=1. Each edge removes the
         # "spandrel" (square corner minus quarter disc), area (1 - pi/4) r^2, swept round the axis;
         # Pappus with its centroid r(5/6 - pi/4)/(1 - pi/4) in from the wall (Monte Carlo agrees).
         {"volume": fillet_cylinder_volume(10, 10, 1)}),
        ("T33", "REVERSED boss: extrude reverse:true from Top Plane goes -Y (Michael's own workaround style)", "#1.2",
         [{"op": "new_part"}, {"op": "sketch_rect", "plane": "Top Plane", "widthMm": 20, "heightMm": 20, "anchor": "center"},
          {"op": "extrude", "depthMm": 10, "reverse": True}],
         {"volume": 4000.0, "bbox": box(-10, 10, -10, 0, -10, 10)}),
        ("T34", "boss throughAll (one way) from a plane below the block runs up through all of it", "#1.2",
         BASE + [{"op": "sketch", "plane": {"offsetFrom": "Top Plane", "offsetMm": -5}, "entities": [{"circle": {"centerMm": [0, 0], "diameterMm": 4}}]},
                 {"op": "extrude", "throughAll": True}],
         {"volume": 4000 + cyl(4, 5), "bodyCount": 1, "bbox": box(-10, 10, -5, 10, -10, 10)}),
        ("T35", "Right Plane axes: sketch +X is model -Z, +Y is model +Y, extrude +X", "#1.5",
         [{"op": "new_part"}, {"op": "sketch_rect", "plane": "Right Plane", "xMm": 0, "yMm": 0, "widthMm": 10, "heightMm": 20},
          {"op": "extrude", "depthMm": 5}],
         {"volume": 1000.0, "bbox": box(0, 5, 0, 20, -10, 0)}),
        ("T36", "sketch on a SIDE face (+X) extrudes outward along +X (face sketch keeps Right Plane axes: origin projects to model Y=0)", "#1.1",
         BASE + [{"op": "sketch", "face": {"atPointMm": [10, 5, 0]}, "entities": [{"circle": {"centerMm": [0, 5], "diameterMm": 4}}]},
                 {"op": "extrude", "depthMm": 3}],
         {"volume": 4000 + cyl(4, 3), "bodyCount": 1, "bbox": box(-10, 13, 0, 10, -10, 10)}),
        ("T37", "sketch on the -Z face extrudes outward along -Z", "#1.1",
         BASE + [{"op": "sketch", "face": {"atPointMm": [0, 5, -10]}, "entities": [{"circle": {"centerMm": [0, 5], "diameterMm": 4}}]},
                 {"op": "extrude", "depthMm": 3}],
         {"volume": 4000 + cyl(4, 3), "bodyCount": 1, "bbox": box(-10, 10, 0, 10, -13, 10)}),
        ("T38", "a plane offset +12 mm from FRONT Plane (separate body at z 12..14)", "#1.1",
         BASE + [{"op": "sketch", "plane": {"offsetFrom": "Front Plane", "offsetMm": 12}, "entities": [{"rect": {"widthMm": 4, "heightMm": 4, "anchor": "center"}}]},
                 {"op": "extrude", "depthMm": 2}],
         {"volume": 4000 + 32.0, "bodyCount": 2, "bodyBoxes": [BASE_BOX, box(-2, 2, -2, 2, 12, 14)]}),
        ("T39", "linear pattern in TWO directions (X +12, Z -12): 4 bosses, all on the block", "#1.6",
         BASE + [{"op": "sketch", "face": {"atPointMm": [0, 10, 0]}, "entities": [{"circle": {"centerMm": [-6, -6], "diameterMm": 3}}]},
                 {"op": "extrude", "depthMm": 3},
                 {"op": "linear_pattern", "feature": "Boss-Extrude2", "directions": [{"axis": "x", "count": 2, "spacingMm": 12}, {"axis": "z", "count": 2, "spacingMm": -12}]}],
         {"volume": 4000 + 4 * cyl(3, 3), "bodyCount": 1, "bbox": box(-10, 10, 0, 13, -10, 10)}),
        ("T42", "shell OUTWARD 1 mm, top face removed: the original block becomes the cavity", "#1.6",
         BASE + [{"op": "shell", "thicknessMm": 1, "outward": True, "removeFacesAtPointsMm": [[0, 10, 0]]}],
         {"volume": 22 * 11 * 22 - 4000.0, "bbox": box(-11, 11, -1, 10, -11, 11)}),
        ("T24", "the LEGO 2x4 acceptance brick (examples/lego-2x4.json)", "#1 acceptance",
         lego["script"],
         {"volume": lego_volume, "bbox": box(-15.9, 15.9, 0, 11.3, -7.9, 7.9), "appearance": [200, 0, 0],
          "massKg": lego_volume * 1e-9 * 1020, "densityKgM3": 1020.0, "massMatchesMassProperties": True,
          "features": ["Boss-Extrude1", "Cut-Extrude1", "Boss-Extrude2", "Boss-Extrude3"]}),
    ]


# ── comparing ────────────────────────────────────────────────────────────────

def rel_ok(got, want):
    return got is not None and abs(got - want) <= REL_TOL * max(1.0, abs(want))


def union_bbox(bodies):
    mins = [b["bbox"]["minMm"] for b in bodies if b.get("bbox")]
    maxs = [b["bbox"]["maxMm"] for b in bodies if b.get("bbox")]
    if not mins:
        return None
    return {"min": [min(m[i] for m in mins) for i in range(3)], "max": [max(m[i] for m in maxs) for i in range(3)]}


def run_check(cid, title, issue, script, exp):
    rec = {"id": cid, "title": title, "issue": issue, "expect": exp, "fails": []}
    t0 = time.time()
    req = {"script": script, "purpose": "bridge", "reason": f"dsl-live-check {cid}: {title}"}
    if REUSE["path"] and cid not in FRESH_ALWAYS and script and script[0].get("op") == "new_part":
        req["reuseDocument"] = REUSE["path"]
        rec["reused"] = True
    r = call("swx_run_script", req)
    rec["runScript"] = r
    if rec.get("reused"):
        st0 = ((r.get("steps") or [{}])[0].get("result") or {})
        rec["clearPasses"] = st0.get("passes")
    steps = r.get("steps") or []
    doc = r.get("documentName")
    if "expectError" in exp:
        if ok(r):
            # Succeeding is only acceptable if the cut really removed material.
            ib = call("swx_inspect_bodies", {"targetDocument": doc}) if doc else {}
            vol = sum((b.get("volumeMm3") or 0) for b in ib.get("bodies") or [])
            rec["inspect"] = ib
            if rel_ok(vol, exp["orVolumeChanged"]):
                rec["fails"].append(f"the cut reported success but removed nothing (volume still {vol}) - a silent no-op")
        else:
            msg = json.dumps({k: r.get(k) for k in ("errorCode", "error", "failedStep", "step")})
            rec["observedError"] = msg
            if str(exp["expectError"]) not in msg and "cut" not in msg.lower():
                rec["fails"].append(f"failed, but the error does not name the cut step: {msg[:200]}")
        rec["seconds"] = round(time.time() - t0, 1)
        return rec
    if not ok(r):
        rec["fails"].append(f"swx_run_script failed: {r.get('errorCode')} {str(r.get('error'))[:300]}")
        rec["seconds"] = round(time.time() - t0, 1)
        if r.get("errorCode") in ("user_present", "low_commit_memory", "stopped_by_dialog"):
            rec["stopBattery"] = r.get("errorCode")
        return rec
    for st in steps:
        res = st.get("result") or {}
        if isinstance(res, dict) and res.get("feature") is None and st.get("op") in ("extrude", "cut", "fillet", "chamfer", "shell", "linear_pattern", "circular_pattern"):
            rec["fails"].append(f"step {st.get('step')} ({st.get('op')}) returned no feature")
    if "features" in exp:
        made = [((st.get("result") or {}).get("feature")) for st in steps if st.get("op") in ("extrude", "cut")]
        if made != exp["features"]:
            rec["fails"].append(f"features {made} != {exp['features']}")
    if "stepReadBack" in exp:
        res = (steps[exp["stepReadBack"]].get("result") or {}) if len(steps) > exp["stepReadBack"] else {}
        rb = res.get("readBack") if isinstance(res, dict) else None
        rec["appearanceStep"] = res
        if rb is None or res.get("applied") is False:
            rec["fails"].append(f"set_appearance on a face has no confirming read-back: {json.dumps(res)[:200]}")
    ib = call("swx_inspect_bodies", {"targetDocument": doc} if doc else {})
    rec["inspect"] = ib
    bodies = ib.get("bodies") or []
    if not ok(ib) or not bodies:
        rec["fails"].append(f"swx_inspect_bodies: {ib.get('errorCode')} {str(ib.get('error'))[:200]} (bodies {len(bodies)})")
    else:
        vol = sum((b.get("volumeMm3") or 0) for b in bodies)
        rec["measured"] = {"volumeMm3": vol, "bodyCount": len(bodies), "bbox": union_bbox(bodies),
                           "faceCount": [b.get("faceCount") for b in bodies],
                           "appearanceRgb": [b.get("appearanceRgb") for b in bodies],
                           "massKg": [b.get("massKg") for b in bodies], "densityKgM3": [b.get("densityKgM3") for b in bodies]}
        if "volume" in exp and not rel_ok(vol, exp["volume"]):
            rec["fails"].append(f"volume {vol:.4f} mm^3, expected {exp['volume']:.4f}")
        if "bodyCount" in exp and len(bodies) != exp["bodyCount"]:
            rec["fails"].append(f"{len(bodies)} bodies, expected {exp['bodyCount']}")
        if "faceCount" in exp and bodies[0].get("faceCount") != exp["faceCount"]:
            rec["fails"].append(f"faceCount {bodies[0].get('faceCount')}, expected {exp['faceCount']}")
        bb = union_bbox(bodies)
        if "bbox" in exp and bb:
            for k in ("min", "max"):
                for i in range(3):
                    if abs(bb[k][i] - exp["bbox"][k][i]) > MM_TOL:
                        rec["fails"].append(f"bbox {k}[{'XYZ'[i]}] = {bb[k][i]}, expected {exp['bbox'][k][i]}")
        if "bboxZSpan" in exp and bb:
            span = bb["max"][2] - bb["min"][2]
            if abs(span - exp["bboxZSpan"]) > MM_TOL:
                rec["fails"].append(f"Z span {span}, expected {exp['bboxZSpan']} (direction both)")
            for i, (lo, hi) in enumerate(exp["bboxXY"]):
                if abs(bb["min"][i] - lo) > MM_TOL or abs(bb["max"][i] - hi) > MM_TOL:
                    rec["fails"].append(f"bbox {'XY'[i]} = [{bb['min'][i]}, {bb['max'][i]}], expected [{lo}, {hi}]")
        if "bodyBoxes" in exp:
            got = [b.get("bbox") for b in bodies]
            unmatched = []
            for want in exp["bodyBoxes"]:
                hit = None
                for gi, g in enumerate(got):
                    if g and all(abs(g["minMm"][i] - want["min"][i]) <= MM_TOL and abs(g["maxMm"][i] - want["max"][i]) <= MM_TOL for i in range(3)):
                        hit = gi
                        break
                if hit is None:
                    unmatched.append(want)
                else:
                    got.pop(hit)
            if unmatched:
                rec["fails"].append(f"no body with bbox {unmatched} (bodies: {[b.get('bbox') for b in bodies]})")
        if "appearance" in exp:
            got = bodies[0].get("appearanceRgb")
            if not got or any(abs(a - b) > 2 for a, b in zip(got, exp["appearance"])):
                rec["fails"].append(f"appearanceRgb {got}, expected {exp['appearance']}")
        if "massKg" in exp:
            m = sum((b.get("massKg") or 0) for b in bodies)
            if not rel_ok(m, exp["massKg"]):
                rec["fails"].append(f"inspect_bodies massKg {m}, expected {exp['massKg']}")
            d = bodies[0].get("densityKgM3")
            if d is None or abs(d - exp["densityKgM3"]) > 0.5:
                rec["fails"].append(f"densityKgM3 {d}, expected {exp['densityKgM3']}")
        if exp.get("massMatchesMassProperties"):
            mp = call("swx_mass_properties", {"targetDocument": doc} if doc else {})
            rec["massProperties"] = mp
            m = sum((b.get("massKg") or 0) for b in bodies)
            if not rel_ok(m, mp.get("massKg") or -1):
                rec["fails"].append(f"inspect_bodies mass {m} != swx_mass_properties mass {mp.get('massKg')}")
    rec["seconds"] = round(time.time() - t0, 1)
    return rec


def ownership_check():
    """#2.b: a part made by swx_new_document, then scripted by PATH, is the bridge's own."""
    rec = {"id": "T23", "title": "issue #2: run_script on the bridge's own new part (addressed by path) says bridgeCreatedDocument:true", "issue": "#2.b", "fails": []}
    nd = call("swx_new_document", {"type": "part", "purpose": "bridge", "reason": "dsl-live-check T23"})
    rec["newDocument"] = nd
    path = (nd.get("created") or {}).get("path") or (nd.get("documentIdentity") or {}).get("path") or nd.get("path")
    title = (nd.get("created") or {}).get("title") or nd.get("title")
    if not ok(nd):
        rec["fails"].append(f"swx_new_document failed: {nd.get('errorCode')} {str(nd.get('error'))[:200]}")
        return rec
    target = {"path": path} if path else {"targetDocument": title}
    r = call("swx_run_script", dict(target, script=[{"op": "sketch_rect", "plane": "Front Plane", "widthMm": 10, "heightMm": 10}, {"op": "extrude", "depthMm": 2}],
                                    reason="dsl-live-check T23"))
    rec["runScript"] = r
    if not ok(r):
        rec["fails"].append(f"swx_run_script failed: {r.get('errorCode')} {str(r.get('error'))[:200]}")
    elif r.get("bridgeCreatedDocument") is not True:
        rec["fails"].append(f"bridgeCreatedDocument = {r.get('bridgeCreatedDocument')} for the bridge's own part (addressed by {'path' if path else 'title'})")
    return rec


def main():
    global TARGET
    ap = argparse.ArgumentParser()
    ap.add_argument("--target", default=TARGET)
    ap.add_argument("--only", default="")
    ap.add_argument("--keep", action="store_true", help="leave the scratch parts open")
    ap.add_argument("--report", default="dsl-live-report.json")
    ap.add_argument("--reuse", action="store_true", help="run every check that allows it in ONE bridge-made part (clear_part between checks) instead of a fresh part each")
    a = ap.parse_args()
    TARGET = a.target
    st = call("swx_status", {"reason": "dsl-live-check: is SOLIDWORKS ready"})
    sw = st.get("solidworks") or {}
    print(f"bridge {st.get('bridgeVersion')} | SOLIDWORKS running={sw.get('running')} ready={sw.get('ready')} "
          f"version={sw.get('majorVersion')} edition={sw.get('edition')} | waitingOn={st.get('waitingOn')}")
    if not sw.get("running"):
        print("SOLIDWORKS is not running on the target: start it (swx_start) and run again. Nothing was done.")
        return 2
    only = {x.strip() for x in a.only.split(",") if x.strip()}
    results = []
    plan = checks()
    if a.reuse:
        nd = call("swx_new_document", {"type": "part", "purpose": "bridge", "reason": "dsl-live-check --reuse: the one part every check that allows it runs in"})
        REUSE["path"] = (nd.get("documentIdentity") or {}).get("path") or (nd.get("created") or {}).get("path")
        print(f"reuse part: {'created' if REUSE['path'] else 'FAILED (' + str(nd.get('errorCode')) + '), running with fresh parts'}")
    for cid, title, issue, script, exp in plan:
        if only and cid not in only:
            continue
        rec = run_check(cid, title, issue, script, exp)
        results.append(rec)
        print(f"{'PASS' if not rec['fails'] else 'FAIL'}  {cid}  {title}  ({rec.get('seconds')} s)")
        for f in rec["fails"]:
            print(f"        - {f}")
        if rec.get("stopBattery"):
            print(f"STOP  the bridge refused to create ({rec['stopBattery']}); no more parts will be created. Re-run when the user is idle / memory is back / the dialog is answered.")
            break
        if not a.keep and not rec.get("reused"):
            doc = (rec.get("runScript") or {}).get("documentName")
            if REUSE["path"] and doc:
                # MEASURED 2026-10-15: swx_reset_workspace closes EVERY bridge-made unsaved part, the shared
                # reuse part included; 19 later checks then failed "clear_part needs a document". Close only
                # this check's own part.
                rw = call("swx_close_doc", {"targetDocument": doc, "save": False, "reason": f"dsl-live-check: close the fresh part of {cid} (reuse mode keeps the shared part)"})
                if not ok(rw):
                    rw = call("swx_reset_workspace", {"reason": f"dsl-live-check: close the scratch part of {cid} (close_doc refused: {rw.get('errorCode')})"})
                    nd = call("swx_new_document", {"type": "part", "purpose": "bridge", "reason": "dsl-live-check --reuse: re-create the shared part after a workspace reset"})
                    REUSE["path"] = (nd.get("documentIdentity") or {}).get("path") or (nd.get("created") or {}).get("path")
            else:
                rw = call("swx_reset_workspace", {"reason": f"dsl-live-check: close the scratch part of {cid}"})
            if not ok(rw):
                # MEASURED 2026-10-14: 17 closes in a row were refused user_present_refusal while the
                # user worked at the keyboard, and the battery kept creating parts: 18 windows piled up
                # on his screen, SOLIDWORKS reached 15 GB. A refused close STOPS the battery.
                print(f"STOP  the close after {cid} was refused ({rw.get('errorCode')}: {str(rw.get('error'))[:140]}); "
                      f"no more parts will be created. Re-run when the user is away from the keyboard.")
                results.append({"id": "STOP", "title": "battery stopped: a close was refused", "fails": [f"{rw.get('errorCode')}: {str(rw.get('error'))[:200]}"]})
                break
    if REUSE["path"] and not a.keep:
        rw = call("swx_reset_workspace", {"reason": "dsl-live-check --reuse: close the shared part"})
        if not ok(rw):
            print(f"WARN  the shared part could not be closed ({rw.get('errorCode')}); it is the bridge's own unsaved scratch part — swx_reset_workspace closes it once the user is idle")
    if not only or "T23" in only:
        rec = ownership_check()
        results.append(rec)
        print(f"{'PASS' if not rec['fails'] else 'FAIL'}  T23  {rec['title']}")
        for f in rec["fails"]:
            print(f"        - {f}")
        if not a.keep:
            call("swx_reset_workspace", {"reason": "dsl-live-check: close the scratch part of T23"})
    passed = sum(1 for r in results if not r["fails"])
    report = {"when": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "target": TARGET,
              "bridgeVersion": st.get("bridgeVersion"), "solidworks": {k: sw.get(k) for k in ("majorVersion", "edition", "versionSupport")},
              "passed": passed, "failed": len(results) - passed, "reuse": bool(REUSE["path"]),
              "reusedChecks": sum(1 for r in results if r.get("reused")), "checks": results}
    json.dump(report, open(a.report, "w"), indent=1)
    print(f"\n{passed}/{len(results)} passed. Full evidence: {a.report}")
    return 0 if passed == len(results) else 1


if __name__ == "__main__":
    sys.exit(main())