"""Build TRUSS v4 dual-MID360 ground scanner.

The chassis, rounded thin-wall equipment shell, tubular spider trusses,
LiDAR mounts and cable guards are one connected B-rep solid.  The display
carrier is necessarily a second moving part and rotates on a real M6
three-knuckle friction hinge.  Four identical M12x1.75 leveling feet are
separate threaded parts.
"""

import csv
import hashlib
import json
import math
import os

import FreeCAD as App
import Import
import Mesh
import MeshPart
import Part


VERSION = "4.0.0-TRUSS"
MNEMONIC = "TRUSS"
TILT_DEG = 60.0                   # LiDAR mounting plane from ground plane
AXIS_SEPARATION_DEG = 120.0
SCREEN_POSE_DEG = 20.0
THREAD_PITCH = 1.75
WALL_T = 3.0

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out_v4")
PARTS = os.path.join(OUT, "parts")
VIEW = os.path.join(OUT, "viewer_meshes")
VENDOR_LIVOX = os.path.join(ROOT, "vendor", "livox-mid360-official.stp")
for directory in (OUT, PARTS, VIEW):
    os.makedirs(directory, exist_ok=True)

COLORS = {
    "main": (0.10, 0.33, 0.37),
    "carrier": (0.18, 0.48, 0.52),
    "feet": (0.62, 0.66, 0.69),
    "lidar": (0.93, 0.94, 0.95),
    "jacket": (0.08, 0.09, 0.10),
    "battery": (0.08, 0.10, 0.12),
    "compute": (0.48, 0.52, 0.54),
    "screen": (0.01, 0.22, 0.26),
    "power": (0.04, 0.68, 0.72),
    "fan": (0.36, 0.18, 0.10),
    "hardware": (0.70, 0.72, 0.74),
}


def feature(doc, group, name, label, shape, color, fabricated, part_number):
    obj = doc.addObject("PartDesign::Feature", name)
    obj.Label = label
    obj.Shape = shape
    obj.addProperty("App::PropertyString", "DesignVersion", "Identity")
    obj.DesignVersion = VERSION
    obj.addProperty("App::PropertyString", "PartNumber", "Identity")
    obj.PartNumber = part_number
    obj.addProperty("App::PropertyBool", "FabricatedPart", "Manufacturing")
    obj.FabricatedPart = fabricated
    obj.addProperty("App::PropertyString", "DisplayColorRGB", "Identity")
    obj.DisplayColorRGB = "%.3f,%.3f,%.3f" % color
    try:
        obj.ViewObject.ShapeColor = color
        obj.ViewObject.LineColor = (0.03, 0.04, 0.05)
    except Exception:
        pass
    group.addObject(obj)
    return obj


def centered_box(dx, dy, dz, center):
    cx, cy, cz = center
    return Part.makeBox(dx, dy, dz, App.Vector(cx-dx/2, cy-dy/2, cz-dz/2))


def rounded_box(dx, dy, dz, base, radius):
    """Robust XY rounded prism (flat top/bottom, rounded vertical corners)."""
    x, y, z = base
    shapes = [
        Part.makeBox(dx-2*radius, dy, dz, App.Vector(x+radius, y, z)),
        Part.makeBox(dx, dy-2*radius, dz, App.Vector(x, y+radius, z)),
    ]
    for cx in (x+radius, x+dx-radius):
        for cy in (y+radius, y+dy-radius):
            shapes.append(Part.makeCylinder(radius, dz, App.Vector(cx, cy, z)))
    result = shapes[0]
    for shape in shapes[1:]:
        result = result.fuse(shape)
    return result.removeSplitter()


def rounded_xz_box(dx, dy, dz, base, radius):
    """Rounded rectangle in XZ, extruded along Y for the display carrier."""
    x, y, z = base
    shapes = [
        Part.makeBox(dx-2*radius, dy, dz, App.Vector(x+radius, y, z)),
        Part.makeBox(dx, dy, dz-2*radius, App.Vector(x, y, z+radius)),
    ]
    for cx in (x+radius, x+dx-radius):
        for cz in (z+radius, z+dz-radius):
            shapes.append(Part.makeCylinder(radius, dy, App.Vector(cx, y, cz), App.Vector(0, 1, 0)))
    result = shapes[0]
    for shape in shapes[1:]:
        result = result.fuse(shape)
    return result.removeSplitter()


def place(shape, base=(0, 0, 0), axis=(0, 0, 1), angle=0):
    result = shape.copy()
    global_placement = App.Placement(App.Vector(*base), App.Rotation(App.Vector(*axis), angle))
    result.Placement = global_placement.multiply(result.Placement)
    return result


def rotate_y_point(point, origin, angle_deg):
    x, y, z = point
    ox, oy, oz = origin
    a = math.radians(angle_deg)
    return (ox + math.cos(a)*x + math.sin(a)*z,
            oy + y,
            oz - math.sin(a)*x + math.cos(a)*z)


def cylinder_between(p1, p2, radius, extend=0.0):
    a = App.Vector(*p1)
    b = App.Vector(*p2)
    v = b.sub(a)
    length = v.Length
    if length <= 0:
        raise ValueError("zero-length cylinder")
    u = v.normalize()
    start = a.sub(App.Vector(u.x*extend, u.y*extend, u.z*extend))
    return Part.makeCylinder(radius, length + 2*extend, start, u)


def tube_between(p1, p2, outer_radius, inner_radius, extend=1.5):
    outer = cylinder_between(p1, p2, outer_radius, extend)
    inner = cylinder_between(p1, p2, inner_radius, extend + 1.0)
    return outer.cut(inner)


def regular_prism(sides, radius, z0, height):
    pts = [App.Vector(radius*math.cos(2*math.pi*i/sides),
                      radius*math.sin(2*math.pi*i/sides), z0)
           for i in range(sides)]
    pts.append(pts[0])
    return Part.Face(Part.makePolygon(pts)).extrude(App.Vector(0, 0, height))


def fuse_connected(shapes):
    result = shapes[0]
    for idx, shape in enumerate(shapes[1:], 1):
        print("FUSE_STEP", idx, "result_null", result.isNull(), "shape_null", shape.isNull(),
              "result_solids", len(result.Solids), "shape_solids", len(shape.Solids), flush=True)
        result = result.fuse(shape)
        if len(result.Solids) != 1:
            print("FUSE_MULTI_SOLID", idx, len(result.Solids), flush=True)
    return result.removeSplitter()


def helical_ridge(r_root, r_crest, z0, height, pitch=THREAD_PITCH):
    helix_height = height - pitch
    helix = Part.makeHelix(pitch, helix_height, r_root, 0, False)
    helix.translate(App.Vector(0, 0, z0))
    pts = [
        App.Vector(r_root-0.08, 0, z0),
        App.Vector(r_crest, 0, z0+pitch/2),
        App.Vector(r_root-0.08, 0, z0+pitch),
        App.Vector(r_root-0.08, 0, z0),
    ]
    profile = Part.makePolygon(pts)
    return Part.Wire(helix.Edges).makePipeShell([Part.Wire(profile.Edges)], True, True)


def m12_internal_cutters(x, y, z0, height):
    minor_bore = Part.makeCylinder(5.10, height+2, App.Vector(x, y, z0-1))
    ridge = helical_ridge(5.10, 6.15, z0-1, height+2)
    ridge.translate(App.Vector(x, y, 0))
    lead = Part.makeCone(6.6, 5.10, 2.2, App.Vector(x, y, z0))
    return minor_bore, ridge, lead


def make_leveling_foot():
    pad = Part.makeCylinder(27, 6, App.Vector(0, 0, 0))
    pad = pad.fuse(Part.makeCone(22, 15, 4, App.Vector(0, 0, 6)))
    handwheel = regular_prism(12, 20, 8, 8)
    core = Part.makeCylinder(4.90, 60, App.Vector(0, 0, 12))
    thread = helical_ridge(4.90, 5.85, 12, 60)
    tip = Part.makeCone(4.90, 4.2, 2.5, App.Vector(0, 0, 72))
    return fuse_connected([pad, handwheel, core, thread, tip])


def load_livox_corrected():
    if not os.path.exists(VENDOR_LIVOX):
        raise RuntimeError("missing official Livox STEP: " + VENDOR_LIVOX)
    tmp = App.newDocument("OfficialMID360NativeV4")
    Import.insert(VENDOR_LIVOX, tmp.Name)
    tmp.recompute()
    candidates = []
    for obj in tmp.Objects:
        if hasattr(obj, "Shape") and not obj.Shape.isNull() and obj.Shape.Volume > 1:
            candidates.append((obj.Shape.Volume, obj.Shape.copy(), obj.Name, obj.Label))
    if not candidates:
        raise RuntimeError("official Livox STEP contains no solid assembly")
    candidates.sort(key=lambda item: item[0], reverse=True)
    volume, shape, source_name, source_label = candidates[0]
    native = shape.BoundBox
    shape.rotate(App.Vector(0, 0, 0), App.Vector(1, 0, 0), 90)
    rotated = shape.BoundBox
    shape.translate(App.Vector(0, 0, -rotated.ZMin))
    corrected = shape.BoundBox
    info = {
        "source_object": source_name,
        "source_label": source_label,
        "native_bbox_mm": [round(v, 3) for v in
                           (native.XMin, native.XMax, native.YMin, native.YMax,
                            native.ZMin, native.ZMax)],
        "native_optical_axis": "+Y",
        "native_mount_bottom_y_mm": round(native.YMin, 3),
        "orientation_correction": "+90 deg about native X; +Y optical mapped to local +Z",
        "corrected_bbox_mm": [round(v, 3) for v in
                              (corrected.XMin, corrected.XMax, corrected.YMin,
                               corrected.YMax, corrected.ZMin, corrected.ZMax)],
        "solid_count": len(shape.Solids),
        "volume_mm3": round(volume, 3),
    }
    App.closeDocument(tmp.Name)
    return shape, info


def coverage_fraction(samples=240000):
    low = math.sin(math.radians(-7.0))
    high = math.sin(math.radians(53.0))
    half = math.radians(AXIS_SEPARATION_DEG/2)
    axes = [(math.sin(half), 0, math.cos(half)),
            (-math.sin(half), 0, math.cos(half))]
    golden = math.pi*(3-math.sqrt(5))
    one = union = overlap = 0
    for i in range(samples):
        z = 1-2*(i+0.5)/samples
        r = math.sqrt(max(0, 1-z*z))
        phi = golden*i
        p = (r*math.cos(phi), r*math.sin(phi), z)
        hits = [low <= sum(a*b for a, b in zip(axis, p)) <= high for axis in axes]
        one += hits[0]
        union += any(hits)
        overlap += all(hits)
    return {
        "method": "equal-area Fibonacci sphere; ideal optical envelope only",
        "samples": samples,
        "vertical_scan_range_deg": [-7.0, 53.0],
        "mounting_plane_from_ground_deg": TILT_DEG,
        "optical_axis_from_vertical_deg": TILT_DEG,
        "axis_separation_deg": AXIS_SEPARATION_DEG,
        "single_percent": round(100*one/samples, 4),
        "ideal_union_percent": round(100*union/samples, 4),
        "ideal_overlap_percent": round(100*overlap/samples, 4),
        "ideal_blind_percent": round(100*(samples-union)/samples, 4),
    }


def export_mesh(objects, path, linear=0.18, angular=0.28):
    merged = Mesh.Mesh()
    for obj in objects:
        mesh = MeshPart.meshFromShape(Shape=obj.Shape, LinearDeflection=linear,
                                      AngularDeflection=angular, Relative=False)
        merged.addMesh(mesh)
    merged.write(path)


doc = App.newDocument("TRUSS_Dual_MID360_Scanner")
g_fab = doc.addObject("App::Part", "FabricatedParts")
g_fab.Label = "FABRICATED — integrated chassis, moving screen carrier, four identical feet"
g_buy = doc.addObject("App::Part", "PurchasedComponents")
g_buy.Label = "PURCHASED COMPONENTS — official geometry or documented envelope"
g_hinge = doc.addObject("App::Part", "HingeHardware")
g_hinge.Label = "M6 FRICTION HINGE HARDWARE"

sheet = doc.addObject("Spreadsheet::Sheet", "Parameters")
sheet.Label = "TRUSS 4.0 design parameters"
params = [
    ("A1", "version"), ("B1", VERSION),
    ("A2", "wall thickness mm"), ("B2", str(WALL_T)),
    ("A3", "MID360 mounting plane from ground deg"), ("B3", str(TILT_DEG)),
    ("A4", "optical axis separation deg"), ("B4", str(AXIS_SEPARATION_DEG)),
    ("A5", "level foot thread"), ("B5", "M12x1.75 modeled"),
    ("A6", "display hinge"), ("B6", "M6 three-knuckle friction, 10..45 deg"),
    ("A7", "LiDAR plate"), ("B7", "circular D116x6, 10568 mm2"),
    ("A8", "fabricated unique parts"), ("B8", "3"),
]
for cell, value in params:
    sheet.set(cell, value)

# Rounded 3 mm closed shell with a large rear service opening.
outer = rounded_box(260, 220, 200, (-130, -110, 65), 20)
inner = rounded_box(254, 214, 194, (-127, -107, 68), 17)
shell = outer.cut(inner)
service_opening = rounded_box(210, 16, 160, (-105, 102, 82), 6)
shell = shell.cut(service_opening)

integral = [shell]
hole_cutters = []

# Integral rear cylindrical cable raceway, open through its full height.
race_outer = Part.makeCylinder(23, 170, App.Vector(94, 102, 78))
race_inner = Part.makeCylinder(16.5, 174, App.Vector(94, 102, 76))
raceway = race_outer.cut(race_inner)
integral.append(raceway)

# Four lightweight tubular spider trusses.  Every boss has a lower tie and an
# upper diagonal brace, both penetrating solid shell hardpoints.
foot_xy = [(-190, -155), (190, -155), (-190, 155), (190, 155)]
body_xy = [(-118, -98), (118, -98), (-118, 98), (118, 98)]
for (bx, by), (fx, fy) in zip(body_xy, foot_xy):
    integral.append(Part.makeCylinder(15, 94, App.Vector(bx, by, 61)))
    threaded_boss = Part.makeCylinder(17, 50, App.Vector(fx, fy, 27))
    boss_bore, boss_thread, boss_lead = m12_internal_cutters(fx, fy, 26, 53)
    threaded_boss = threaded_boss.cut(boss_bore).cut(boss_thread).cut(boss_lead).removeSplitter()
    if not threaded_boss.isValid() or len(threaded_boss.Solids) != 1:
        raise RuntimeError("pre-machined M12 boss is invalid at %.1f, %.1f" % (fx, fy))
    integral.append(threaded_boss)
    radial = App.Vector(bx-fx, by-fy, 0)
    radial.normalize()
    # Join the outside of the threaded boss, never its central M12 bore.
    lower_join = (fx + radial.x*18.0, fy + radial.y*18.0, 48)
    upper_join = (fx + radial.x*18.0, fy + radial.y*18.0, 67)
    integral.append(tube_between((bx, by, 84), lower_join, 9.0, 5.5, 1.5))
    integral.append(tube_between((bx, by, 145), upper_join, 7.5, 4.5, 1.5))

# Circular sensor deck and central cylindrical mast.
integral.append(Part.makeCylinder(102, 9, App.Vector(0, 0, 262)))
integral.append(Part.makeCylinder(22, 62, App.Vector(0, 0, 265)))

plate_local = Part.makeCylinder(58, 6, App.Vector(0, 0, 0))
plate_specs = []
for side, sign in (("LEFT", -1), ("RIGHT", 1)):
    angle = sign*TILT_DEG
    origin = (sign*50.0, 0.0, 340.0)
    plate = plate_local.copy()

    # M3 normal-fit holes plus ISO counterbores from the rear face.
    for hx in (-24, 24):
        for hy in (-18, 18):
            through = Part.makeCylinder(1.70, 8, App.Vector(hx, hy, -1))
            cbore = Part.makeCylinder(3.25, 4.0, App.Vector(hx, hy, -1))
            plate = plate.cut(through).cut(cbore)

    # M12 plug/jacket guard: circular tube, oriented outboard, with 2 mm
    # nominal radial clearance around a 21 mm cable jacket.
    cable_sign = sign
    # The official connector/housing ends at corrected local |X|=40.3 mm.
    # Begin the guard at 43 mm so the tube surrounds the cable jacket without
    # intersecting the LiDAR body; its 21 mm radius still overlaps the D116
    # mounting plate and therefore remains integral.
    guard_base_x = cable_sign*43.0
    guard_dir = App.Vector(cable_sign, 0, 0)
    guard_outer = Part.makeCylinder(21.0, 40.0, App.Vector(guard_base_x, 0, 20.5), guard_dir)
    guard_inner = Part.makeCylinder(16.0, 42.0,
                                    App.Vector(guard_base_x-cable_sign, 0, 20.5), guard_dir)
    guard = guard_outer.cut(guard_inner)
    plate = plate.fuse(guard).removeSplitter()
    global_plate = place(plate, origin, (0, 1, 0), angle)
    integral.append(global_plate)

    # Two round-tube back braces per plate form the pyramid head.
    # Tube centerlines terminate 5 mm behind the mounting plane.  Their radii
    # overlap the plate underside by 2 mm but never protrude through the 6 mm
    # plate into the sensor keep-out envelope.
    inner_a = rotate_y_point((-34*sign, -29, -5.0), origin, angle)
    inner_b = rotate_y_point((-34*sign, 29, -5.0), origin, angle)
    outer_a = rotate_y_point((30*sign, -33, -5.0), origin, angle)
    outer_b = rotate_y_point((30*sign, 33, -5.0), origin, angle)
    integral.append(tube_between((sign*12, -24, 318), inner_a, 7.0, 4.0, 2.5))
    integral.append(tube_between((sign*12, 24, 318), inner_b, 7.0, 4.0, 2.5))
    integral.append(tube_between((sign*58, -34, 267), outer_a, 6.5, 3.7, 2.5))
    integral.append(tube_between((sign*58, 34, 267), outer_b, 6.5, 3.7, 2.5))

    normal = (math.sin(math.radians(angle)), 0, math.cos(math.radians(angle)))
    plate_specs.append((side, sign, angle, origin, normal))

# Real three-knuckle display hinge, axis along global X.  The two outer
# knuckles and their round support arms belong to the integral chassis.
hinge_y, hinge_z = -139.0, 160.0
for x0 in (-86.0, 60.0):
    lug = Part.makeCylinder(12.0, 26.0, App.Vector(x0, hinge_y, hinge_z), App.Vector(1, 0, 0))
    bore = Part.makeCylinder(3.2, 28.0, App.Vector(x0-1, hinge_y, hinge_z), App.Vector(1, 0, 0))
    integral.append(lug.cut(bore))
for x in (-73.0, 73.0):
    integral.append(cylinder_between((x, -106, 145), (x, hinge_y, hinge_z), 9.0, 3.0))
    integral.append(cylinder_between((x, -108, 185), (x, hinge_y, hinge_z), 7.0, 2.0))

main = fuse_connected(integral)
print("MAIN_BEFORE_THREADS", main.isValid(), len(main.Solids), round(main.Volume, 3), flush=True)
main = main.removeSplitter()

if not main.isValid() or len(main.Solids) != 1:
    raise RuntimeError("integral chassis must be one valid solid; valid=%s solids=%d" %
                       (main.isValid(), len(main.Solids)))
print("MAIN_TRUSS_OK", len(main.Solids), round(main.Volume, 3), flush=True)

main_obj = feature(doc, g_fab, "Integral_TRUSS_Chassis",
                   "ONE-PIECE rounded shell + tubular spider + sensor head",
                   main, COLORS["main"], True, "TRUSS-CHASSIS-001")
main_obj.addProperty("App::PropertyInteger", "SolidCount", "Validation")
main_obj.SolidCount = len(main.Solids)
main_obj.addProperty("App::PropertyLength", "NominalWallThickness", "Manufacturing")
main_obj.NominalWallThickness = WALL_T
main_obj.addProperty("App::PropertyString", "Construction", "Manufacturing")
main_obj.Construction = "one connected AlSi10Mg AM/post-machined solid; 3 mm shell; hollow round trusses"
main_obj.addProperty("App::PropertyString", "DirectMountHoles", "Manufacturing")
main_obj.DirectMountHoles = "8x M3 normal d3.4 with d6.5x3 counterbores; 4x M12x1.75 female"
main_obj.addProperty("App::PropertyString", "CableGuards", "Manufacturing")
main_obj.CableGuards = "2x integral circular OD42/ID32 tubes aligned with outward MID360 M12 jackets"

# Moving rounded screen carrier, local hinge axis at (0,0,0), then posed 20 deg.
frame_outer = rounded_xz_box(164, 14, 100, (-82, -7, 16), 6)
frame_inner = rounded_xz_box(146, 18, 76, (-73, -9, 28), 4)
frame_local = frame_outer.cut(frame_inner)
center_lug = Part.makeCylinder(10.5, 112, App.Vector(-56, 0, 0), App.Vector(1, 0, 0))
center_bore = Part.makeCylinder(3.2, 114, App.Vector(-57, 0, 0), App.Vector(1, 0, 0))
frame_local = frame_local.fuse(center_lug.cut(center_bore))
for x in (-44, 44):
    frame_local = frame_local.fuse(cylinder_between((x, 0, 0), (x, 0, 19), 7.0, 1.0))

# Eight direct M2.5 normal-fit holes around the glass opening.
for hx in (-66, -22, 22, 66):
    for hz in (22, 110):
        cutter = Part.makeCylinder(1.45, 18, App.Vector(hx, -9, hz), App.Vector(0, 1, 0))
        frame_local = frame_local.cut(cutter)
frame_local = frame_local.removeSplitter()
if not frame_local.isValid() or len(frame_local.Solids) != 1:
    raise RuntimeError("screen carrier must be one valid solid")

carrier_shape = place(frame_local, (0, hinge_y, hinge_z), (1, 0, 0), SCREEN_POSE_DEG)
carrier_obj = feature(doc, g_fab, "Adjustable_Screen_Carrier",
                      "Rounded 5.5-inch screen carrier — real M6 friction hinge",
                      carrier_shape, COLORS["carrier"], True, "TRUSS-SCREEN-CARRIER-001")
carrier_obj.addProperty("App::PropertyString", "Joint", "Kinematics")
carrier_obj.Joint = "revolute about global X at (0,-139,160); M6 shoulder pin"
carrier_obj.addProperty("App::PropertyAngle", "CurrentAngle", "Kinematics")
carrier_obj.CurrentAngle = SCREEN_POSE_DEG
carrier_obj.addProperty("App::PropertyString", "AdjustmentRange", "Kinematics")
carrier_obj.AdjustmentRange = "10 to 45 deg; friction set by paired Belleville washers and M6 nyloc"

# Four identical fully threaded leveling feet.
foot_master = make_leveling_foot()
if not foot_master.isValid() or len(foot_master.Solids) != 1:
    raise RuntimeError("leveling foot must be one valid solid")
foot_objects = []
for idx, (x, y) in enumerate(foot_xy, 1):
    foot_shape = foot_master.copy()
    foot_shape.translate(App.Vector(x, y, 0))
    obj = feature(doc, g_fab, "Threaded_Foot_%d" % idx,
                  "M12x1.75 threaded leveling foot %d" % idx,
                  foot_shape, COLORS["feet"], True, "TRUSS-FOOT-M12-001")
    obj.addProperty("App::PropertyString", "ExternalThread", "Manufacturing")
    obj.ExternalThread = "fully modeled M12x1.75; crest d11.70; root d9.80; 60 mm thread"
    foot_objects.append(obj)

# Official MID360 assemblies, correctly bottom-mounted.  LEFT is yawed 180 deg
# in its corrected local frame so both M12 connectors exit outboard.
livox_local, livox_info = load_livox_corrected()
lidar_objects = []
jacket_objects = []
for side, sign, angle, origin, normal in plate_specs:
    sensor_local = livox_local.copy()
    if side == "LEFT":
        sensor_local.rotate(App.Vector(0, 0, 0), App.Vector(0, 0, 1), 180)
    mount_point = (origin[0] + normal[0]*6.25,
                   origin[1] + normal[1]*6.25,
                   origin[2] + normal[2]*6.25)
    sensor = place(sensor_local, mount_point, (0, 1, 0), angle)
    obj = feature(doc, g_buy, "Livox_MID360_"+side,
                  "Livox MID-360 %s — official full STEP, dome outward" % side.lower(),
                  sensor, COLORS["lidar"], False, "LIVOX-MID360")
    obj.addProperty("App::PropertyString", "OrientationProof", "Mounting")
    obj.OrientationProof = "official +Y optical corrected to +Z; bottom mounting face seats on circular plate"
    obj.addProperty("App::PropertyAngle", "MountingPlaneFromGround", "Mounting")
    obj.MountingPlaneFromGround = TILT_DEG
    obj.addProperty("App::PropertyString", "ConnectorDirection", "Mounting")
    obj.ConnectorDirection = "outboard %s local X into circular cable guard" % ("-" if sign < 0 else "+")
    lidar_objects.append(obj)

    jacket_dir = App.Vector(sign, 0, 0)
    jacket_local = Part.makeCylinder(10.5, 39.0, App.Vector(sign*40.5, 0, 20.5), jacket_dir)
    jacket = place(jacket_local, origin, (0, 1, 0), angle)
    jacket_obj = feature(doc, g_buy, "MID360_Cable_Jacket_"+side,
                         "MID360 M12 plug jacket clearance envelope %s" % side.lower(),
                         jacket, COLORS["jacket"], False, "LIVOX-M12-CABLE-JACKET")
    jacket_objects.append(jacket_obj)

# Real equipment envelopes in the actual thin-wall cavity.
battery_obj = feature(doc, g_buy, "CSBattery_LFP24V10", "CSBattery LFP24V10",
                      centered_box(181, 170, 77, (0, -9.0, 108.5)),
                      COLORS["battery"], False, "LFP24V10")
compute_obj = feature(doc, g_buy, "Seeed_reComputer_J4012", "Seeed reComputer J4012",
                      centered_box(130, 120, 58.5, (0, -10, 190)),
                      COLORS["compute"], False, "RECOMPUTER-J4012")
converter_obj = feature(doc, g_buy, "Vicor_DCM3623T36G13C2T00", "Vicor DCM3623T36G13C2T00",
                        centered_box(38.72, 22.80, 7.21, (0, 73, 185)),
                        COLORS["power"], False, "DCM3623T36G13C2T00")
fan_obj = feature(doc, g_buy, "Noctua_NF_A6x25_5V_PWM", "Noctua NF-A6x25 5V PWM",
                  centered_box(60, 25, 60, (0, 89, 225)),
                  COLORS["fan"], False, "NF-A6X25-5V-PWM")

glass_local = centered_box(140.4, 8.7, 74.76, (0, 0, 66))
glass_shape = place(glass_local, (0, hinge_y, hinge_z), (1, 0, 0), SCREEN_POSE_DEG)
glass_obj = feature(doc, g_buy, "Waveshare_5_5_AMOLED", "Waveshare 5.5-inch AMOLED",
                    glass_shape, COLORS["screen"], False, "5.5-HDMI-AMOLED")

# M6 shoulder pin, two conical Belleville friction washers and nyloc nut.
pin_shape = Part.makeCylinder(3.0, 178, App.Vector(-89, hinge_y, hinge_z), App.Vector(1, 0, 0))
pin_shape = pin_shape.fuse(Part.makeCylinder(6.0, 5, App.Vector(-94, hinge_y, hinge_z), App.Vector(1, 0, 0)))
pin_obj = feature(doc, g_hinge, "M6_Shoulder_Pin", "M6 shoulder pin d6 x 178",
                  pin_shape, COLORS["hardware"], False, "ISO-M6-SHOULDER-PIN")

washer_objects = []
for label, x0, direction in (("LEFT", -59.6, 1), ("RIGHT", 59.6, -1)):
    outer_w = Part.makeCone(9.0, 8.0, 3.2, App.Vector(x0, hinge_y, hinge_z),
                            App.Vector(direction, 0, 0))
    inner_w = Part.makeCylinder(3.2, 3.6, App.Vector(x0-0.2*direction, hinge_y, hinge_z),
                                App.Vector(direction, 0, 0))
    washer = outer_w.cut(inner_w)
    washer_objects.append(feature(doc, g_hinge, "Belleville_Washer_"+label,
                                   "M6 Belleville friction washer %s" % label.lower(),
                                   washer, COLORS["hardware"], False, "DIN2093-M6"))

nut_local = regular_prism(6, 6.8, 0, 6)
nut_local = nut_local.cut(Part.makeCylinder(3.1, 7, App.Vector(0, 0, -0.5)))
nut_shape = nut_local.copy()
nut_shape.rotate(App.Vector(0, 0, 0), App.Vector(0, 1, 0), 90)
nut_shape.translate(App.Vector(91, hinge_y, hinge_z))
nut_obj = feature(doc, g_hinge, "M6_Nyloc_Nut", "M6 nyloc locknut",
                  nut_shape, COLORS["hardware"], False, "DIN985-M6")

purchased = lidar_objects + jacket_objects + [battery_obj, compute_obj, converter_obj, fan_obj, glass_obj]
hinge_hardware = [pin_obj] + washer_objects + [nut_obj]
doc.recompute()


def bbox_overlaps(a, b, tol=0.0):
    return not (a.XMax < b.XMin-tol or b.XMax < a.XMin-tol or
                a.YMax < b.YMin-tol or b.YMax < a.YMin-tol or
                a.ZMax < b.ZMin-tol or b.ZMax < a.ZMin-tol)


collision_checks = []
def audit_shapes(name_a, shape_a, name_b, shape_b, category):
    broad = bbox_overlaps(shape_a.BoundBox, shape_b.BoundBox)
    if broad:
        common = shape_a.common(shape_b)
        volume = common.Volume if not common.isNull() else 0.0
        common_bbox = ([round(common.BoundBox.XMin, 3), round(common.BoundBox.XMax, 3),
                        round(common.BoundBox.YMin, 3), round(common.BoundBox.YMax, 3),
                        round(common.BoundBox.ZMin, 3), round(common.BoundBox.ZMax, 3)]
                       if not common.isNull() else None)
    else:
        volume = 0.0
        common_bbox = None
    row = {"a": name_a, "b": name_b, "category": category,
           "aabb_overlap": broad, "common_volume_mm3": round(volume, 6),
           "common_bbox_mm": common_bbox,
           "pass": volume < 0.001}
    collision_checks.append(row)
    if not row["pass"]:
        raise RuntimeError("interference %s / %s = %.6f mm3 bbox=%s" %
                           (name_a, name_b, volume, common_bbox))


for obj in purchased:
    audit_shapes(main_obj.Name, main_obj.Shape, obj.Name, obj.Shape, "chassis-vs-purchased")
audit_shapes(main_obj.Name, main_obj.Shape, carrier_obj.Name, carrier_obj.Shape, "hinge-chassis-vs-carrier")
for foot in foot_objects:
    audit_shapes(main_obj.Name, main_obj.Shape, foot.Name, foot.Shape, "thread-clearance")
for i, obj_a in enumerate(purchased):
    for obj_b in purchased[i+1:]:
        audit_shapes(obj_a.Name, obj_a.Shape, obj_b.Name, obj_b.Shape, "purchased-vs-purchased")

# Motion clearance at both specified display angle limits.
motion_checks = []
for pose in (10.0, 20.0, 45.0):
    posed = place(frame_local, (0, hinge_y, hinge_z), (1, 0, 0), pose)
    common = main.common(posed)
    volume = common.Volume if not common.isNull() else 0.0
    row = {"pose_deg": pose, "common_volume_mm3": round(volume, 6), "pass": volume < 0.001}
    motion_checks.append(row)
    if not row["pass"]:
        raise RuntimeError("screen motion interference at %.1f deg = %.6f" % (pose, volume))

print("INTERFERENCE_AUDIT_OK", len(collision_checks), flush=True)
print("HINGE_MOTION_OK", json.dumps(motion_checks), flush=True)

# STEP is the primary interchange artifact; FCStd remains fully editable.
fcstd = os.path.join(OUT, "TRUSS_dual_mid360_scanner_v4.0.0.FCStd")
step = os.path.join(OUT, "TRUSS_dual_mid360_scanner_v4.0.0.step")
assembly_stl = os.path.join(OUT, "TRUSS_dual_mid360_scanner_v4.0.0.stl")
doc.saveAs(fcstd)
all_objects = [main_obj, carrier_obj] + foot_objects + purchased + hinge_hardware
Import.export(all_objects, step)
export_mesh(all_objects, assembly_stl, 0.28, 0.34)
export_mesh([main_obj], os.path.join(PARTS, "truss-chassis-one-piece.stl"), 0.14, 0.24)
export_mesh([carrier_obj], os.path.join(PARTS, "adjustable-screen-carrier.stl"), 0.12, 0.22)
export_mesh([foot_objects[0]], os.path.join(PARTS, "leveling-foot-M12x1.75.stl"), 0.08, 0.18)

export_mesh([main_obj], os.path.join(VIEW, "main-truss-chassis.stl"), 0.38, 0.40)
export_mesh([carrier_obj], os.path.join(VIEW, "screen-carrier.stl"), 0.24, 0.30)
export_mesh(foot_objects, os.path.join(VIEW, "threaded-feet-4x.stl"), 0.18, 0.28)
export_mesh(lidar_objects, os.path.join(VIEW, "mid360-corrected-2x.stl"), 0.36, 0.40)
export_mesh(jacket_objects, os.path.join(VIEW, "cable-jackets-2x.stl"), 0.30, 0.35)
export_mesh([battery_obj, compute_obj, converter_obj, fan_obj],
            os.path.join(VIEW, "internal-components.stl"), 0.40, 0.42)
export_mesh([glass_obj], os.path.join(VIEW, "operator-screen.stl"), 0.30, 0.35)
export_mesh(hinge_hardware, os.path.join(VIEW, "hinge-hardware.stl"), 0.20, 0.28)

# Reduce only the web preview of the complex official STEP; production exports
# above remain untouched.
preview_path = os.path.join(VIEW, "mid360-corrected-2x.stl")
preview = Mesh.Mesh(preview_path)
preview_before = preview.CountFacets
preview_dims = (preview.BoundBox.XLength, preview.BoundBox.YLength, preview.BoundBox.ZLength)
preview.decimate(0.35, 0.72)
preview_after = preview.CountFacets
preview.write(preview_path)
preview_check = Mesh.Mesh(preview_path)
preview_after_dims = (preview_check.BoundBox.XLength, preview_check.BoundBox.YLength,
                      preview_check.BoundBox.ZLength)
if preview_after >= preview_before or max(abs(a-b) for a, b in zip(preview_dims, preview_after_dims)) > 0.5:
    raise RuntimeError("MID360 web preview decimation changed physical envelope")

coverage = coverage_fraction()
boxes = [obj.Shape.BoundBox for obj in all_objects]
xmin, xmax = min(b.XMin for b in boxes), max(b.XMax for b in boxes)
ymin, ymax = min(b.YMin for b in boxes), max(b.YMax for b in boxes)
zmin, zmax = min(b.ZMin for b in boxes), max(b.ZMax for b in boxes)

manifest = {
    "version": VERSION,
    "mnemonic": MNEMONIC,
    "freecad_version": ".".join(map(str, App.Version()[:3])),
    "architecture": {
        "integral_chassis_count": 1,
        "integral_chassis_solid_count": len(main.Solids),
        "moving_screen_carrier_count": 1,
        "identical_threaded_feet": 4,
        "unique_fabricated_part_numbers": [
            "TRUSS-CHASSIS-001", "TRUSS-SCREEN-CARRIER-001", "TRUSS-FOOT-M12-001"
        ],
        "adjustable_hinge_is_real": True,
        "hinge_hardware": "M6 shoulder pin + 2 Belleville washers + DIN985 nyloc",
    },
    "shell": {"outer_mm": [260, 220, 200], "corner_radius_mm": 20,
              "nominal_wall_mm": WALL_T, "rear_service_opening": True,
              "rear_cylindrical_cable_raceway_od_id_mm": [46, 33]},
    "spider": {"leg_count": 4, "lower_tube_od_id_mm": [18, 11],
               "upper_tube_od_id_mm": [15, 9], "triangulated": True},
    "mid360_orientation": livox_info,
    "mid360_mount": {"mounting_plane_from_ground_deg": TILT_DEG,
                     "axis_separation_deg": AXIS_SEPARATION_DEG,
                     "plate": "circular D116 x 6 mm",
                     "plate_area_mm2": round(math.pi*58*58, 3),
                     "pattern_mm": [48, 36],
                     "holes": "4 per sensor; M3 normal d3.4; counterbore d6.5 x 3",
                     "bottom_to_plate_gap_mm": 0.25,
                     "connectors_face_outboard": True,
                     "cable_guard_od_id_mm": [42, 32],
                     "cable_jacket_diameter_mm": 21,
                     "nominal_jacket_radial_clearance_mm": 5.5},
    "hinge": {"axis_world_mm": [0, hinge_y, hinge_z], "pin_clearance_diameter_mm": 6.4,
              "pin_diameter_mm": 6.0, "current_pose_deg": SCREEN_POSE_DEG,
              "tested_range_deg": [10, 45], "motion_checks": motion_checks},
    "threads": {"designation": "M12x1.75", "modeled_geometry": True,
                "internal_minor_diameter_mm": 10.20, "internal_groove_major_diameter_mm": 12.30,
                "external_root_diameter_mm": 9.80, "external_crest_diameter_mm": 11.70},
    "coverage": coverage,
    "assembly_bbox_mm": [round(xmax-xmin, 3), round(ymax-ymin, 3), round(zmax-zmin, 3)],
    "collision_audit": {"pair_count": len(collision_checks),
                        "failures": sum(not row["pass"] for row in collision_checks),
                        "maximum_common_volume_mm3": max(row["common_volume_mm3"] for row in collision_checks)},
    "web_preview": {"mid360_facets_before": preview_before,
                    "mid360_facets_after": preview_after,
                    "production_exports_untouched": True},
    "assumptions": [
        "User angle interpreted as each circular mounting plane 60 deg from horizontal ground.",
        "Adjustability physically requires the screen carrier to be a second moving fabricated part.",
        "Enclosure and truss dimensions are first-pass engineering assumptions, not structural certification.",
    ],
}
with open(os.path.join(OUT, "design-manifest.json"), "w", encoding="utf-8") as handle:
    json.dump(manifest, handle, indent=2, ensure_ascii=False)
with open(os.path.join(OUT, "interference-report.json"), "w", encoding="utf-8") as handle:
    json.dump({"version": VERSION, "checks": collision_checks,
               "hinge_motion": motion_checks}, handle, indent=2)

bom = [
    ["1", "TRUSS-CHASSIS-001", "integral scanner chassis", "one B-rep solid; 3 mm shell; round trusses", "fabricate"],
    ["1", "TRUSS-SCREEN-CARRIER-001", "moving display carrier", "M6 three-knuckle friction hinge", "fabricate"],
    ["4", "TRUSS-FOOT-M12-001", "leveling foot", "modeled M12x1.75 external thread", "fabricate"],
    ["2", "Livox MID-360", "LiDAR", "official full STEP; bottom mount; connectors outboard", "purchase"],
    ["1", "Seeed reComputer J4012", "compute", "130x120x58.5 envelope", "purchase"],
    ["1", "Waveshare 5.5-inch HDMI AMOLED", "display", "140.4x74.76x8.7 envelope", "purchase"],
    ["1", "CSBattery LFP24V10", "battery", "181x170x77 envelope", "purchase"],
    ["1", "Vicor DCM3623T36G13C2T00", "power conversion", "38.72x22.80x7.21 envelope", "purchase"],
    ["1", "Noctua NF-A6x25 5V PWM", "fan", "60x60x25 envelope", "purchase"],
    ["1", "ISO-M6-SHOULDER-PIN", "hinge pin", "d6 x 178", "purchase"],
    ["2", "DIN2093-M6", "friction spring washer", "conical Belleville", "purchase"],
    ["1", "DIN985-M6", "hinge locknut", "nyloc", "purchase"],
]
with open(os.path.join(OUT, "BOM.csv"), "w", newline="", encoding="utf-8-sig") as handle:
    writer = csv.writer(handle)
    writer.writerow(["qty", "part_number", "function", "design_basis", "make_buy"])
    writer.writerows(bom)

hash_targets = [fcstd, step, assembly_stl,
                os.path.join(PARTS, "truss-chassis-one-piece.stl"),
                os.path.join(PARTS, "adjustable-screen-carrier.stl"),
                os.path.join(PARTS, "leveling-foot-M12x1.75.stl"),
                VENDOR_LIVOX]
hashes = {}
for path in hash_targets:
    with open(path, "rb") as handle:
        digest = hashlib.sha256(handle.read()).hexdigest()
    hashes[os.path.relpath(path, ROOT).replace("\\", "/")] = digest
with open(os.path.join(OUT, "SHA256SUMS.txt"), "w", encoding="ascii") as handle:
    for path, digest in hashes.items():
        handle.write("%s  %s\n" % (digest, path))

print(json.dumps(manifest, indent=2, ensure_ascii=False), flush=True)
print("BUILD_COMPLETE", fcstd, flush=True)
