Metadata-Version: 2.4
Name: jke
Version: 0.1.2
Summary: JKE - the joint kinetic engine: joint validation for build123d assemblies
Author: Steve Zeng
License: Apache-2.0
Keywords: cad,build123d,assembly,joints,threads,validation,dfa
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Manufacturing
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: cad
Requires-Dist: build123d>=0.9; extra == "cad"
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: build123d>=0.9; extra == "dev"
Dynamic: license-file

# JKE

**Joint Kinetic Engine** — a hardware-language-agnostic validation layer for assemblies. Ships with adapters for [build123d](https://github.com/gumyr/build123d), [OpenSCAD](https://openscad.org) and plain STL/OBJ meshes; [write your own](docs/adapters.md) for anything else.

Part files label their mating features (threads, bond pads, bores, dowel holes). An `assembly-jke.py` file says how those features go together. JKE solves the placements, then runs 34 rules covering thread compatibility, engagement, torque, adhesive bond lines, fits, galvanic pairs, interference, tool access and assembly order — and tells you, with a stable `JKE-…` code, what will not build.

```
$ jke validate examples/bad-assembly-jke.py

  base~screw_l
    x JKE-S008  cover.hole_l is 4 mm across but the M5x0.8-6g fastener is 5 mm; it will not pass through
        -> ISO 273 normal fit is 5.5 mm
    x JKE-T003  M5x0.5-6H does not accept M5x0.8-6g: pitch differs: 0.5 mm vs 0.8 mm
    x JKE-T013  the fastener reaches 15 mm past the stack but the blind hole is only threaded 4 mm deep;
                it bottoms out 11 mm before the head seats, so the joint never sees preload
    x JKE-A001  'shroud' sits in the 8.5 mm x 28 mm column the folded hex key needs above screw_m5x20.thread
    x JKE-M001  MAGNESIUM-AZ31 against STEEL-ZINC-PLATED is a 0.50 V galvanic couple ...
```

## Install

```sh
pip install -e ".[dev]"      # build123d + pytest
pip install -e .             # engine only; geometric rules report themselves as skipped
```

## Part files

A part file stays an ordinary script in whatever language you model in. With build123d: Add one import and declare the features that mate:

```python
from build123d import *
import jke

with BuildPart() as bracket:
    Box(40, 20, 10)
    with Locations((0, 0, 5)):
        Hole(radius=2.1, depth=8)          # M5 tap drill

p = jke.part("bracket", bracket, material="AL-6061-T6")
p.thread("mount_a", "M5x0.8-6H", face=p.bore(diameter=4.2))   # depth, axis, blind-ness measured
p.bond_face("pad", face=p.face(normal=(0, 0, -1)), adhesive="3M-DP420", gap=0.2)
```

Or mark the face inline and let `jke.part` scan for it:

```python
jke.mark(bracket.faces().sort_by(Axis.Z)[-1], "bond", "lid_pad", adhesive="DP420", gap=0.2)
```

Or from OpenSCAD (rendered to a mesh behind the scenes), or a bare STL:

```python
from jke.backends.openscad import Model
cover = jke.part("cover", Model("cover.scad", params={"thickness": 6}), material="ABS")
cover.seat("bottom", face=cover.face(normal=(0, 0, -1)))     # planar facets are found on meshes
cover.clearance("hole_a", for_thread="M4", thickness=6,
                at=jke.Frame.from_axis((-30, -20, 0), (0, 0, -1)))   # cylinders are declared

plate = jke.part("plate", "plate.stl", material="steel")
```

Every declaration accepts either a `face=` to measure from or explicit numbers (`at=`, `depth=`, `diameter=`…). Where both are given, the declaration wins and `JKE-D00x` flags any disagreement — so a model can be checked before the geometry is finished, or from a language whose backend cannot measure at all.

| declaration | what it labels |
| --- | --- |
| `thread(name, spec, …)` | tapped hole or external thread — `"M5x0.8-6H"`, `"1/4-20 UNC-2B"`, `"G1/4"`, `"1/4-18 NPT"` |
| `clearance(name, for_thread="M5", thickness=…)` | a through hole a fastener passes through (sized from ISO 273) |
| `bond_face(name, …)` / `seat(name, …)` | an adhesive pad / a face that seats on another |
| `bore_fit` / `shaft_fit` / `dowel_hole` / `dowel_pin` | cylinders entering a fit — `iso_fit="H7"` fills in ISO 286 deviations |

## Assembly files

```python
import jke
from parts import plate, cover, hardware

screw = hardware.socket_screw("m4x12", "M4x0.7-6g", length=12)

asm = jke.Assembly("sensor_head", environment=jke.Environment("harsh", temp_max=85))
asm.add(plate.plate, "plate")                     # first part in is the datum
asm.add(cover.lid, "cover")
asm.add(screw, "screw_a")

asm.bolt("screw_a.thread", into="plate.mount_a", through=["cover.hole_a"],
         engagement=9.0, torque=2.2, threadlocker="LOCTITE-243")
asm.bond("cover.pad", "pcb.underside", adhesive="SIL-RTV-732", gap=0.6)
asm.press_fit("plate.seat", "bushing.outer")
asm.dowel("plate.dowel_l", "pin_l.lower")

asm.validate().raise_for_errors()
```

### The solver

Nothing has to be pinned. Every unpinned part is a rigid body with six degrees of freedom; every join contributes geometric constraints (coaxial for threads, fits, dowels and each clearance hole in a fastener stack; planar for seats; footprint-coincident for bonds and welds). A breadth-first propagation gives a starting pose, then a Levenberg–Marquardt fit satisfies all the constraints at once. What comes out:

- **Over-constrained** — constraints that will not go to zero are reported with their residual (`JKE-G001`–`G004`, `G009`). Two plates whose hole patterns don't match produce exactly this: one bolt lets them slide into line, two or more can't all be satisfied, and the report names each hole and by how much it misses (net of the clearance the hole forgives).
- **Under-constrained** — the Jacobian's null space lists motions nothing prevents: a plate on a flat face with one bolt "can rotate about z" (`JKE-G011`). A screw spinning about its own axis is filtered out.
- **Pinned parts** — `asm.add(..., at=(x, y, z))` removes a body from the variables; joins touching it become checks on the declared placement.

```
$ jke validate examples/pattern-mismatch-jke.py      # 1/4" vs 1/8" corner insets
  one_corner    0 errors          (the plates simply slide 1/8")
  two_corners   x JKE-G009  upper.ne is 4.365 mm off the axis of lower.ne (a 6.6 mm hole
                            forgives 0.125 mm); the hole patterns on 'upper' and 'lower' do not match
```

Pure Python; no numpy.

## Declarative files and generation

The same assembly as a `.jke` file — no Python, and parameters overridable from the CLI:

```
assembly sensor_head
param screw_len = 12
use parts.hardware as hw
part plate   = parts.base_plate:plate
part screw_a = hw:socket_screw("m4", "M4x0.7-6g", length={screw_len})
ground plate
bolt screw_a.thread -> plate.mount_a through cover.hole_a engagement=9 torque=2.2
bond cover.sensor_pad ~ pcb.underside adhesive=SIL-RTV-732 gap=0.6
```

```sh
jke validate sensor-assembly.jke screw_len=16
jke depict   sensor-assembly.jke --bom
jke generate sensor-assembly.jke -o head.step -o head.stl -o head.png --depict head.md
```

`generate` exports the **solved** assembly — every instance where the joins put it — as STL/OBJ (any mix of backends), STEP/BREP/glTF (all-build123d), or a PNG rendered by a built-in software rasteriser. `depict` prints the connectivity tree, Mermaid, DOT or a BOM:

```
lego_tower  (16 instances, 88 joins)
[base_a:lego_plate_2x8] ABS-LEGO
  ^ [c1_a:lego_brick_2x4] ABS-LEGO  snap x8 (base_a.s00 into c1_a.a00, ...)
    ^ [t_a:lego_brick_2x4] ABS-LEGO  snap x4 (c1_a.s10 into t_a.a00, ...)
      ^ [c1_c:lego_brick_2x4] ABS-LEGO  snap x4 (c1_c.s10 into t_a.a20, ...)
```

`jke instructions` turns the same join graph into a build manual — a parts list, numbered steps with the new parts lifted along their insertion axis and arrows to their seats, what's already built ghosted, multiples marked "4x", and a final view:

![LEGO tower build instructions](examples/lego/tower_instructions.png)

A real mechanism works the same way — `examples/gearbox/gearbox-assembly.jke` is a 16-instance idler-shaft stage (pressed bushing, slip-fit shaft, press-fit pulley with a radial set screw, dowelled and bolted lid, bolted bracket, bonded sight window) that builds in ten steps:

![Idler stage build instructions](examples/gearbox/gearbox_instructions.png)

Every arrow is labelled with *how* it attaches (`M5`, `PRESS`, `DOWEL`, `BOND`, amber `ALIGN` for a clearance hole lining up), and each panel's caption spells it out: thread and head style, engagement, torque, fit classes, adhesive and bond line. Rendering is pluggable — register a `jke.render.Renderer` (or a `jke.renderers` entry point) and your OCP viewer, Blender or ray tracer receives the placed native shapes, ghost flags, arrows and captions; see [docs/adapters.md](docs/adapters.md#bringing-your-own-renderer).

The order is derived from who supports whom (a screw after its target and the plates it passes through, a socket after its stud, a bore-holder after the pin it drops over), lowest course first, so the same command works for a bolted sensor head (`examples/sensor_instructions.png`).

### SKUs: what you buy, not what you declared

Forty screws declared one handle each would be forty rows in that parts list. Give them a SKU and they are one row, one thumbnail, one purchase line — and the engine checks they really are the same thing (`JKE-S014`: same material, volume and ports).

```python
jke.part("m5x12_d1a", shape, material="ss304", sku="DIN912-M5x12-A2")     # on any part
body.label = jke.label("sku", "91292A123", mpn="91292A123", manufacturer="McMaster")
jke.part("screw", body)                    # or on the solid itself -- survives a STEP round trip

@jke.sku("DIN912-M5x12-A2", description="M5x12 SHCS A2-70", unit_cost=0.11, source="order")
def m5x12(name="m5x12"): ...              # a catalogue factory; jke.sku_part("DIN912-M5x12-A2")
jke.register_sku("BUSH-PB-12x16x16", source="inventory:stk_bush01", unit_cost=1.20)
```

In a `.jke` file: `part s1 = sku:DIN912-M5x12-A2`, `sku ID key=value …` to add purchase data, `sku ID for PART …` to tag declared parts. Then:

```
jke bom gearbox/gearbox-assembly.jke              # one line per SKU, with parts/instances, mass, cost
jke bom gearbox/gearbox-assembly.jke --purchase --format csv   # only what has to be bought or pulled
```

`Generator.bom()` / `purchase_list()` / `bom_csv()` / `bom_json()` give the same rows to code.

That tower is `examples/lego/tower.jke`: `jke.contrib.lego` builds bricks as real solids with stud/socket ports, and `put NAME x y level` snaps each one to whatever studs are under it — connectivity is discovered from geometry, then the solver holds every brick by its joins alone. A brick one plate too high is reported disconnected; a brick overlapping another is reported as studs claimed twice plus 1350 mm³ of interference. See [docs/jke-files.md](docs/jke-files.md).

## Backends

JKE never touches geometry directly; it asks a **backend** a small set of questions (bounding box, is-this-point-inside, which faces are planes/cylinders, how much do these two solids overlap) and every rule is written against the answers. Each backend declares the **capability tiers** it supports and the rules degrade accordingly — a backend with containment but no booleans gets Monte-Carlo interference checks, a backend with nothing gets only the numeric rules.

| backend | object | capabilities |
| --- | --- | --- |
| `build123d` | `BuildPart`, `Part`, `Solid`, … | everything: exact booleans, cylinder/plane extraction, labels |
| `openscad` | `jke.backends.openscad.Model("x.scad", params=…)` or a `.scad` path | renders via the `openscad` binary → mesh |
| `mesh` | `.stl` / `.obj` path, or a `Mesh` | mass, containment, planar facets, transforms (no dependencies) |

Backends are picked per object, so one assembly can mix languages. Adapters for other languages register with `jke.backends.register(...)` or a `jke.backends` entry point, and `jke conform reference.stl` runs the conformance suite against them. See [docs/adapters.md](docs/adapters.md).

## Running

```sh
jke validate assembly-jke.py                  # exit 1 if any error
jke validate assembly-jke.py --strict --format json -o report.json
jke validate assembly-jke.py --select JKE-T --ignore JKE-S002
jke validate assembly-jke.py --severity JKE-M001=info --environment controlled
jke inspect parts/base_plate.py               # list ports with frames
jke thread "1/4-20 UNC-2B"                    # dump the numbers behind a designation
jke rules                                     # the catalogue
jke generate x.jke -o x.step -o x.png        # export the solved assembly / render it
jke instructions x.jke -o build.png           # LEGO-style step-by-step sheet (--pages DIR for one per step)
jke depict x.jke --format mermaid             # connectivity as a graph
jke conform reference.scad                    # check a backend against the reference solid
jke doctor                                    # installed backends and their capabilities
```

`python assembly-jke.py` works too — `asm.validate()` returns a `Report` with `.errors`, `.warnings`, `.to_text()`, `.to_markdown()`, `.to_json()`.

## What gets checked

See [docs/rules.md](docs/rules.md) for the full table. In outline:

| family | covers |
| --- | --- |
| **T** threads | standard / diameter / pitch / hand / starts / taper match, tolerance-class genders and allowance, engagement vs material (1×D steel … 3×D printed plastic), bottoming in blind holes, protrusion through tapped holes, stripping length, torque vs proof load and vs thread shear, threadlocker suitability, tapping plastics, stainless galling, sealing on parallel threads |
| **S** structure | fastener passes through its clearance stack, head bearing / counterbore, grip length vs shank, ports used once, unused labels, disconnected instances, unplaceable parts, wrong port kinds |
| **B** bonding | adhesive named, bond-line inside the qualified window (incl. fixed-thickness tapes), substrate qualification and surface energy, service temperature, shear stress vs allowable, peel/cleavage loading, CTE-mismatch strain |
| **N** snaps | a snap/clutch feature has interference, but not enough to yield |
| **F** fits | a 10 mm shaft does not go in a 4 mm bore (and a 4 mm shaft rattles in a 10 mm one), ISO 286 classes vs intent (press / slip / transition), Lamé hoop stress in the hub, loss of interference at tolerance extremes or temperature, press force estimate, polymer relaxation, over-doweling |
| **G** geometry | axial / radial / angular residuals on every join and every clearance hole in a stack (hole-pattern mismatch), under-constrained motions, faces that don't oppose, port normals pointing into their own part, solid interference net of what each join legitimately explains |
| **A** access | driver envelope and swing room above each head, counterbore admits the tool, insertion corridor, a topological assembly order exists, permanent joints in a serviceable assembly |
| **M** materials | galvanic couples vs environment, service-temperature limits, thermal preload change, polymers under clamp load |
| **D** declaration | tap-drill sanity (modelled vs minor diameter), declared vs measured lengths, partial cylindrical faces, through-hole mouth ambiguity, missing solids / materials |

Rules never raise: anything that can't be evaluated (no kernel, missing material) becomes a `skipped` finding. Add your own with `@jke.rules.register` on a `JoinRule` or `AssemblyRule` subclass.

## Layout

```
jke/
  part.py, assembly.py        authoring API
  ports.py, joins.py          the model the rules see
  solver.py, constraints.py,  rigid-body constraint solver (LM + null-space analysis);
  numeric.py, _math.py          dependency-free linear algebra and 3D math
  geometry.py                 backend-neutral geometry queries (+ sampled booleans)
  backends/                   the adapter contract, registry, conformance suite, and the
                                build123d / openscad / mesh adapters
  threads.py, fits.py,        ISO 68/965/261, ASME B1.1, ISO 286, ISO 273,
  fasteners.py, materials.py,   MIL-STD-889 anodic indices, adhesive windows
  adhesives.py
  rules/                      one file per family; each rule has a stable code
  generate.py, raster.py      solved-assembly export, depiction, BOM; PNG software renderer
  render.py                   the SceneSpec / Renderer contract for external renderers
  instructions.py             step-by-step build sheets (order from the join graph)
  jkefile.py                  the .jke declarative format
  contrib/lego.py             LEGO-compatible elements and grid placement
  report.py, config.py, cli.py
examples/                     a clean assembly (.py and .jke), a deliberately broken one,
                                the mismatched-hole-pattern plates, a build123d + OpenSCAD
                                mixed assembly, a 16-piece LEGO tower, and a 16-part gearbox stage
tests/                        148 tests; CAD-dependent ones skip without build123d
```

Units are millimetres, degrees, newtons, MPa. Strings like `"0.25in"` or `"1/4 in"` are accepted wherever a length is.
