gokul/usb3-sd-hub
Engineering draft: four-layer, computer-powered USB 3 hub with one USB-C host socket, two USB-C and two USB-A accessory sockets, and a full-size SD slot. Board and three schematic previews are available. Routing and production checks are incomplete; not fabrication ready.
- Version
- 0.1.5
- License
- MIT
- Stars
- 0
cad/export-board.py
"""Export review-only mechanical CAD from a tscircuit Circuit JSON snapshot.
Run: python export-board.py INPUT.circuit.json OUTPUT_DIRECTORY
Requires cadquery 2.6.1 and ezdxf. No network access or fabrication ordering.
"""
import hashlib
import json
import math
import os
import sys
from urllib.parse import urlparse
from pathlib import Path
import cadquery as cq
import ezdxf
def vector(point):
return (point.get("x", 0), point.get("y", 0), point.get("z", 0))
def solid_from_plan(plan):
kind = plan["type"]
if kind in ("cuboid", "roundedCuboid"):
shape = cq.Workplane("XY").box(*plan["size"])
if kind == "roundedCuboid":
shape = shape.edges().fillet(plan["roundRadius"])
return shape.val().translate(tuple(plan.get("center", (0, 0, 0))))
if kind == "cylinder":
return cq.Workplane("XY").circle(plan["radius"]).extrude(plan["height"]).translate((0,0,-plan["height"]/2)).val().translate(tuple(plan.get("center",(0,0,0))))
if kind == "colorize":
return solid_from_plan(plan["shape"])
if kind in ("union", "subtract"):
shapes = [solid_from_plan(child) for child in plan["shapes"]]
if kind == "union":
# Preserve disjoint contacts as individual solids.
return cq.Compound.makeCompound(shapes)
result = shapes[0]
for cutter in shapes[1:]:
result = result.cut(cutter)
return result
raise ValueError(f"Unsupported solid operation: {kind}")
def drill_profile(hole):
kind = hole.get("hole_shape", hole.get("shape", "circle"))
if kind == "circle":
diameter = hole.get("hole_diameter")
if diameter is None:
raise ValueError(f"Missing drill diameter: {hole}")
return cq.Workplane("XY").circle(diameter / 2)
width = hole.get("hole_width")
height = hole.get("hole_height")
if not width or not height:
raise ValueError(f"Missing slot dimensions: {hole}")
angle = hole.get("ccw_rotation", 0) + (90 if height > width else 0)
return cq.Workplane("XY").slot2D(max(width, height), min(width, height), angle)
def generic_package(footprint):
# Nominal standard package envelopes, rather than solder-land bounding boxes.
for key, size in {
"0402": (1.0, 0.5, 0.5), "0603": (1.6, 0.8, 0.8),
"0805": (2.0, 1.25, 1.25), "sot23": (2.9, 1.3, 1.1),
"soic8": (4.9, 3.9, 1.75), "crystal_w3.2mm": (3.2, 2.5, 0.8),
}.items():
if key in footprint:
return cq.Workplane().box(*size).val().translate((0, 0, size[2] / 2))
return None
def export_board(snapshot_path, output_dir):
output_dir.mkdir(parents=True, exist_ok=True)
snapshot_bytes = snapshot_path.read_bytes()
circuit = json.loads(snapshot_bytes)
board = next(row for row in circuit if row["type"] == "pcb_board")
source_components = {row["source_component_id"]: row for row in circuit if row["type"] == "source_component"}
pcb_components = {row["pcb_component_id"]: row for row in circuit if row["type"] == "pcb_component"}
thickness = board["thickness"]
outline = board.get("outline")
if outline:
pcb = cq.Workplane("XY").polyline([(p["x"], p["y"]) for p in outline]).close().extrude(thickness).translate((0, 0, -thickness / 2))
else:
pcb = cq.Workplane().box(board["width"], board["height"], thickness).translate((*vector(board["center"])[:2], 0))
drills = [row for row in circuit if row["type"] in ("pcb_hole", "pcb_plated_hole", "pcb_via")]
for hole in drills:
cutter = drill_profile(hole).extrude(thickness + 2).translate((hole["x"], hole["y"], -thickness / 2 - 1))
pcb = pcb.cut(cutter)
if not pcb.val().isValid():
raise ValueError("PCB solid failed OpenCascade validity check")
cq.exporters.export(pcb, str(output_dir / "board-mechanical.step"))
assembly = cq.Assembly(name="usb3-sd-hub-engineering-draft")
assembly.add(pcb, name="PCB", color=cq.Color(0.08, 0.1, 0.09))
omitted = []
nominal = []
models = []
shared_shapes = {}
for cad in [row for row in circuit if row["type"] == "cad_component"]:
source = source_components.get(cad.get("source_component_id"), {})
component = pcb_components.get(cad.get("pcb_component_id"), {})
if component.get("do_not_place"):
continue
name = source.get("name", cad["cad_component_id"])
if cad.get("model_jscad"):
shape_key = json.dumps(cad["model_jscad"], sort_keys=True)
if shape_key not in shared_shapes:
shared_shapes[shape_key] = solid_from_plan(cad["model_jscad"])
shape = shared_shapes[shape_key]
model_kind = "authored_nominal_model"
else:
supplier_directory = Path(os.environ.get("USB_HUB_SUPPLIER_CAD_DIR", snapshot_path.parent / "supplier-cad-models"))
supplier_path = supplier_directory / Path(urlparse(cad.get("model_step_url", "")).path).name
if cad.get("model_step_url") and supplier_path.is_file():
shape_key = str(supplier_path)
if shape_key not in shared_shapes:
shared_shapes[shape_key] = cq.importers.importStep(str(supplier_path)).val()
shape = shared_shapes[shape_key]
model_kind = "existing_supplier_step_model"
else:
shape_key = cad.get("footprinter_string", "")
if shape_key not in shared_shapes:
shared_shapes[shape_key] = generic_package(shape_key)
shape = shared_shapes[shape_key]
model_kind = "nominal_standard_package_envelope"
if shape is None:
omitted.append({"reference": name, "reason": "supplier model omitted from portable mechanical export; see native GLB"})
continue
if model_kind == "nominal_standard_package_envelope":
nominal.append(name)
rotation = cad.get("rotation", {})
location = cq.Location(cq.Vector(*vector(cad["position"])))
for axis, direction in [("z", (0,0,1)),("y", (0,1,0)),("x", (1,0,0))]:
if rotation.get(axis):
location = location * cq.Location(cq.Vector(),cq.Vector(*direction),rotation[axis])
origin = vector(cad.get("model_origin_position", {})) if model_kind != "nominal_standard_package_envelope" else (0,0,0)
location = location * cq.Location(cq.Vector(*[-coordinate for coordinate in origin]))
if not shape.isValid():
raise ValueError(f"Invalid component solid: {name}")
assembly.add(shape, name=name, loc=location, color=cq.Color(0.55,0.57,0.6) if name.startswith("J_") else cq.Color(0.16,0.17,0.19))
bounds = shape.moved(location).BoundingBox()
models.append({"reference": name, "model_kind": model_kind, "bounds_mm": {"min": [bounds.xmin, bounds.ymin, bounds.zmin], "max": [bounds.xmax, bounds.ymax, bounds.zmax]}})
assembly.save(str(output_dir / "assembly-mechanical-draft.step"))
# The datum/drawing uses board-centre XY=0 and z=0 at the board mid-plane.
drawing = ezdxf.new("R2010")
drawing.units = ezdxf.units.MM
for name in ("BOARD_OUTLINE", "NPTH", "PTH", "VIA", "CONNECTOR_BODY"):
drawing.layers.new(name=name)
space = drawing.modelspace()
if outline:
polygon = [(p["x"], p["y"]) for p in outline]
else:
x, y = board["center"]["x"], board["center"]["y"]
w, h = board["width"] / 2, board["height"] / 2
polygon = [(x-w,y-h),(x+w,y-h),(x+w,y+h),(x-w,y+h)]
space.add_lwpolyline(polygon, close=True, dxfattribs={"layer": "BOARD_OUTLINE"})
for hole in drills:
layer = {"pcb_hole": "NPTH", "pcb_plated_hole": "PTH", "pcb_via": "VIA"}[hole["type"]]
kind = hole.get("hole_shape", hole.get("shape", "circle"))
if kind == "circle":
space.add_circle((hole["x"],hole["y"]),hole["hole_diameter"]/2,dxfattribs={"layer":layer})
else:
# Export the exact capsule outline as arc/line edges from OpenCascade.
profile = drill_profile(hole).val().translate((hole["x"],hole["y"],0))
for edge in profile.Edges():
if edge.geomType()=="CIRCLE":
center=edge.arcCenter(); start=edge.startPoint(); end=edge.endPoint()
start_angle=math.degrees(math.atan2(start.y-center.y,start.x-center.x))
end_angle=math.degrees(math.atan2(end.y-center.y,end.x-center.x))
middle=edge.positionAt(0.5)
middle_angle=(math.degrees(math.atan2(middle.y-center.y,middle.x-center.x))-start_angle)%360
if middle_angle > (end_angle-start_angle)%360:
start_angle,end_angle=end_angle,start_angle
space.add_arc((center.x,center.y),edge.radius(),start_angle,end_angle,dxfattribs={"layer":layer})
else:
start,end=edge.startPoint(),edge.endPoint()
space.add_line((start.x,start.y),(end.x,end.y),dxfattribs={"layer":layer})
drawing.saveas(output_dir / "board-outline-and-drills.dxf")
imported = cq.importers.importStep(str(output_dir / "board-mechanical.step"))
if not imported.val().isValid():
raise ValueError("Re-imported board STEP is invalid")
assembly_import = cq.importers.importStep(str(output_dir / "assembly-mechanical-draft.step"))
if any(not solid.isValid() for solid in assembly_import.val().Solids()):
raise ValueError("Re-imported assembly has invalid solids")
report = {"source_snapshot_sha256": hashlib.sha256(snapshot_bytes).hexdigest(), "units":"mm", "board_size_mm":[board["width"],board["height"],thickness],"board_step_reimport_valid":True,"assembly_step_reimport_valid":True,"drill_count":len(drills),"included_components":len(models),"nominal_standard_packages":nominal,"omitted_components":omitted,"models":models,"fabrication_ready":False,"limitations":["Copper, solder and internal contact springs omitted", "Authored connector solids and standard package bodies are nominal, not tolerance-qualified", "Supplier models are included only when available in supplier-cad-models beside the snapshot", "Final routed manufacturing release and mechanical qualification remain incomplete"]}
(output_dir / "cad-check.json").write_text(json.dumps(report,indent=2)+"\n")
print(json.dumps({"board_valid":True,"assembly_valid":True,"included_components":len(models),"omitted_components":len(omitted),"drills":len(drills)}))
if __name__ == "__main__":
export_board(Path(sys.argv[1]).resolve(),Path(sys.argv[2]).resolve())