@protoboardai/ext-harness-cad 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/checks/routes.ts +31 -0
- package/evals/reroute-after-vtx-move/case.json +47 -0
- package/lib/artifacts.ts +121 -0
- package/lib/connectors.ts +58 -0
- package/lib/job.ts +273 -0
- package/lib/route.ts +216 -0
- package/ops/cad.ts +45 -0
- package/ops/harness-cad.ts +91 -0
- package/ops/package.ts +108 -0
- package/package.json +43 -4
- package/protoboard-extension.json +106 -0
- package/py/glb.py +171 -0
- package/py/harness_cad.py +773 -0
- package/py/route.py +534 -0
- package/skills/harness-cad/SKILL.md +170 -0
- package/README.md +0 -3
package/ops/package.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contributions to the Manufacturing Package (@protoboardai/ext-release section
|
|
3
|
+
* contract) for the cables this provider routes: their preparation in the
|
|
4
|
+
* unit's work order (cut length against the supplied length, strip length,
|
|
5
|
+
* gauge, lay, and how each end is terminated and onto what), and routing
|
|
6
|
+
* checks in the test plan (bend radius, clearance, slack). Values come from
|
|
7
|
+
* the cable's UHD facts (cut list) and the routed standard output
|
|
8
|
+
* (out/body.manifest.json).
|
|
9
|
+
*/
|
|
10
|
+
import type { CapabilityBinding, Project } from "@protoboardai/core";
|
|
11
|
+
import type { Block, SectionContext, SectionFn } from "@protoboardai/ext-release";
|
|
12
|
+
import { cutList, perBuild, providerModules, readOutput } from "@protoboardai/ext-release/helpers";
|
|
13
|
+
|
|
14
|
+
const PROVIDER = "@protoboardai/ext-harness-cad";
|
|
15
|
+
|
|
16
|
+
interface RouteManifest {
|
|
17
|
+
length_mm?: number;
|
|
18
|
+
route_length_mm?: number;
|
|
19
|
+
length_fit?: { slack_mm?: number; method?: string; worst_clearance_mm?: number };
|
|
20
|
+
bundle?: { kind?: string; conductors?: number; bundle_od_mm?: number; twist_pitch_mm?: number };
|
|
21
|
+
bend_radius_mm?: { target?: number; minimum?: number; centreline?: number };
|
|
22
|
+
clearance_mm?: { min_conductor_surface?: number };
|
|
23
|
+
ends?: Record<string, { counterpart?: string; termination?: string; link?: string }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// manifests write null for a value the router could not measure (callers check with != null)
|
|
27
|
+
const n = (v: number, d = 1) => String(Number(v.toFixed(d)));
|
|
28
|
+
|
|
29
|
+
/** How an end is finished, by the termination the route records. */
|
|
30
|
+
const TERMINATION: Record<string, string> = {
|
|
31
|
+
solder: "strip, tin, solder to the pads; keep each joint clear of its neighbours, and fix the lead near the pad (tie or tape) so the joint carries no strain",
|
|
32
|
+
plug: "plugs in (pre-terminated housing); check the latch",
|
|
33
|
+
integral: "fixed to the part (its own leads): nothing to terminate",
|
|
34
|
+
crimp: "crimp the contacts and insert them in the housing positions of the cut list",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function cables(p: Project, unit: SectionContext["unit"]) {
|
|
38
|
+
return providerModules(p, unit, PROVIDER).map((b: CapabilityBinding) => {
|
|
39
|
+
const def = p.definitions.get(b.module!)!.def;
|
|
40
|
+
return { b, def, cut: cutList(def), m: readOutput<RouteManifest>(p, b, "body.manifest.json") ?? {}, per: perBuild(unit, def.id) };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const workOrder: SectionFn = ({ project: p, unit, release }: SectionContext) => {
|
|
45
|
+
const cs = cables(p, unit);
|
|
46
|
+
if (!cs.length) return undefined;
|
|
47
|
+
const builds = unit.perProduct * release.build;
|
|
48
|
+
const blocks: Block[] = [
|
|
49
|
+
{ kind: "paragraph", text: `${cs.length} cable${cs.length === 1 ? "" : "s"} routed in this unit. Cut each to its installed length before fitting; the routed length is the centreline the CAD found, with the slack it bows into. Per-conductor rows (positions, colours) are in files/harnesses/<cable>/cut-list.csv.`, keep: true },
|
|
50
|
+
{
|
|
51
|
+
kind: "table",
|
|
52
|
+
columns: [
|
|
53
|
+
{ key: "cable", label: "Cable" },
|
|
54
|
+
{ key: "qty", label: "Qty", align: "right" },
|
|
55
|
+
{ key: "cut", label: "Cut to" },
|
|
56
|
+
{ key: "strip", label: "Strip" },
|
|
57
|
+
{ key: "wire", label: "Conductors" },
|
|
58
|
+
{ key: "lay", label: "Lay" },
|
|
59
|
+
{ key: "bend", label: "Min bend" },
|
|
60
|
+
],
|
|
61
|
+
rows: cs.map(({ def, cut, m, per }) => {
|
|
62
|
+
const c = cut[0];
|
|
63
|
+
return {
|
|
64
|
+
cable: def.name ?? def.id,
|
|
65
|
+
qty: per * builds,
|
|
66
|
+
cut: c?.length_mm ? `${c.length_mm} mm${c.supplied_length_mm && c.supplied_length_mm !== c.length_mm ? ` (of ${c.supplied_length_mm})` : ""}` : "as supplied",
|
|
67
|
+
strip: c?.strip_mm ? `${c.strip_mm} mm` : "—",
|
|
68
|
+
wire: [String(cut.length || m.bundle?.conductors || "?"), c?.gauge_awg ? `${c.gauge_awg} AWG` : ""].filter(Boolean).join(" × "),
|
|
69
|
+
lay: c?.lay || m.bundle?.kind || "",
|
|
70
|
+
bend: m.bend_radius_mm?.minimum ? `${n(m.bend_radius_mm.minimum)} mm` : "",
|
|
71
|
+
};
|
|
72
|
+
}),
|
|
73
|
+
caption: "Cable preparation",
|
|
74
|
+
},
|
|
75
|
+
{ kind: "heading", text: "Terminations" },
|
|
76
|
+
{
|
|
77
|
+
kind: "table",
|
|
78
|
+
columns: [{ key: "cable", label: "Cable" }, { key: "end", label: "End" }, { key: "onto", label: "Onto" }, { key: "how", label: "How" }],
|
|
79
|
+
rows: cs.flatMap(({ def, m }) =>
|
|
80
|
+
Object.entries(m.ends ?? {}).map(([id, e]) => ({ cable: def.name ?? def.id, end: def.interfaces.find((i) => i.id === id)?.name ?? id, onto: e.counterpart ?? "", how: TERMINATION[e.termination ?? ""] ?? e.termination ?? "" })),
|
|
81
|
+
),
|
|
82
|
+
caption: "Terminations",
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
return { title: "Cables", order: 40, blocks };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const testPlan: SectionFn = ({ project: p, unit }: SectionContext) => {
|
|
89
|
+
const cs = cables(p, unit).filter(({ m }) => m.bend_radius_mm || m.clearance_mm || m.length_fit);
|
|
90
|
+
if (!cs.length) return undefined;
|
|
91
|
+
return {
|
|
92
|
+
title: "Cable routing",
|
|
93
|
+
order: 40,
|
|
94
|
+
blocks: [
|
|
95
|
+
{ kind: "paragraph", text: "After final assembly, each cable as the routed CAD lays it: compare with the assembly guide's figures.", keep: true },
|
|
96
|
+
{
|
|
97
|
+
kind: "table",
|
|
98
|
+
columns: [{ key: "cable", label: "Cable" }, { key: "bend", label: "Bend radius" }, { key: "clear", label: "Clearance" }, { key: "slack", label: "Slack" }],
|
|
99
|
+
rows: cs.map(({ def, m }) => ({
|
|
100
|
+
cable: def.name ?? def.id,
|
|
101
|
+
bend: m.bend_radius_mm?.minimum ? `no bend tighter than ${n(m.bend_radius_mm.minimum)} mm` : "",
|
|
102
|
+
clear: m.clearance_mm?.min_conductor_surface != null ? `clear of every part (routed ${n(m.clearance_mm.min_conductor_surface, 2)} mm); nothing rubs on an edge or a moving part` : "clear of moving parts",
|
|
103
|
+
slack: m.length_fit?.slack_mm != null ? `${n(m.length_fit.slack_mm)} mm taken up${m.length_fit.method ? ` (${m.length_fit.method})` : ""}; no tension at either end` : "no tension at either end",
|
|
104
|
+
})),
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
};
|
|
108
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protoboardai/ext-harness-cad",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Protoboard extension: routed 3D geometry for wire harnesses, generated from the assembled UHD model (obstacle-aware routing, swept conductors, connector and solder bodies) with build123d; a provider of the cad capability",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Delta Robotics, Inc.",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Delta-Robotics-Inc/protoboard-stack.git",
|
|
10
|
+
"directory": "packages/ext-harness-cad"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
"./artifacts": "./lib/artifacts.ts",
|
|
15
|
+
"./job": "./lib/job.ts",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"protoboard-extension.json",
|
|
20
|
+
"py",
|
|
21
|
+
"lib",
|
|
22
|
+
"ops",
|
|
23
|
+
"checks",
|
|
24
|
+
"skills",
|
|
25
|
+
"evals",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"NOTICE"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@protoboardai/core": "^0.2.0",
|
|
37
|
+
"@deltarobotics/uhd": ">=0.3.0",
|
|
38
|
+
"@protoboardai/ext-release": "^0.2.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"@protoboardai/ext-release": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "protoboard.extension/v0",
|
|
3
|
+
"id": "@protoboardai/ext-harness-cad",
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"description": "Routed wire-harness CAD (a provider of the cad capability): each cable's 3D geometry (conductors swept along an obstacle-free route, twisted where the cable is, connector housings and solder joints) generated from the assembled UHD model and bound to the frames its ends land on.",
|
|
6
|
+
"compatibility": {
|
|
7
|
+
"protoboard": "^0.2.0",
|
|
8
|
+
"uhd": ">=0.3.0"
|
|
9
|
+
},
|
|
10
|
+
"skills": [
|
|
11
|
+
{
|
|
12
|
+
"id": "harness-cad",
|
|
13
|
+
"summary": "Generate and bind a wire harness's routed 3D geometry from the assembled model; read the route report (length fit, bend radius, clearance) and fix what it finds",
|
|
14
|
+
"path": "skills/harness-cad/SKILL.md"
|
|
15
|
+
}
|
|
16
|
+
],
|
|
17
|
+
"tools": [
|
|
18
|
+
{
|
|
19
|
+
"id": "python-build123d",
|
|
20
|
+
"purpose": "Python with build123d, numpy and scipy (voxel routing, sweeps, STEP/GLB export)",
|
|
21
|
+
"probe": [
|
|
22
|
+
"sh",
|
|
23
|
+
"-c",
|
|
24
|
+
"exec \"${PROTOBOARD_PYTHON:-python3}\" -c 'import build123d, numpy, scipy; print(\"build123d \" + build123d.__version__)'"
|
|
25
|
+
],
|
|
26
|
+
"install": "protoboard tools install cad (a project venv with these pins), or point PROTOBOARD_PYTHON at an interpreter with build123d==0.13.0 numpy==2.5.3 scipy==1.18.1",
|
|
27
|
+
"runtime": {
|
|
28
|
+
"id": "cad",
|
|
29
|
+
"kind": "python",
|
|
30
|
+
"packages": [
|
|
31
|
+
"build123d==0.13.0",
|
|
32
|
+
"numpy==2.5.3",
|
|
33
|
+
"scipy==1.18.1"
|
|
34
|
+
],
|
|
35
|
+
"env": "PROTOBOARD_PYTHON",
|
|
36
|
+
"python": ">=3.11,<3.15"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"capabilities": [
|
|
41
|
+
{
|
|
42
|
+
"id": "cad",
|
|
43
|
+
"summary": "Routed wire harnesses: an obstacle-free route through the assembled model, swept conductors, plug housings and solder joints (build123d)",
|
|
44
|
+
"native": "harness-cad",
|
|
45
|
+
"operations": {
|
|
46
|
+
"generate": {
|
|
47
|
+
"entry": "./ops/cad.ts#generate",
|
|
48
|
+
"requires": [
|
|
49
|
+
"python-build123d"
|
|
50
|
+
],
|
|
51
|
+
"summary": "Route the harness through the assembled model, around the cables routed before it in the system's order, and write harness-cad/route.json (conductor paths, length fit, bend limits, clearance)"
|
|
52
|
+
},
|
|
53
|
+
"export": {
|
|
54
|
+
"entry": "./ops/cad.ts#exportOutputs",
|
|
55
|
+
"requires": [
|
|
56
|
+
"python-build123d"
|
|
57
|
+
],
|
|
58
|
+
"summary": "Build the bodies from harness-cad/route.json and write out/body.glb, body.step and body.manifest.json (end frames, lengths, bend radii, clearance)"
|
|
59
|
+
},
|
|
60
|
+
"extract": {
|
|
61
|
+
"entry": "./ops/cad.ts#extract",
|
|
62
|
+
"summary": "The end frames the route was generated for, and the harness's artifacts"
|
|
63
|
+
},
|
|
64
|
+
"check": {
|
|
65
|
+
"entry": "./ops/cad.ts#check",
|
|
66
|
+
"summary": "The route is not older than the model, the export not older than the route; the cable fits its length, routed clear, and every end still meets its part"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
"operations": [
|
|
72
|
+
{
|
|
73
|
+
"id": "harness-cad",
|
|
74
|
+
"summary": "Alias of cad:generate then cad:export for every wire harness (or the named ones), each bound by its cad.config.ts, in one routing run",
|
|
75
|
+
"entry": "./ops/harness-cad.ts#harnessCad",
|
|
76
|
+
"effect": "export",
|
|
77
|
+
"requires": [
|
|
78
|
+
"python-build123d"
|
|
79
|
+
],
|
|
80
|
+
"inputs": {
|
|
81
|
+
"harness": {
|
|
82
|
+
"description": "comma-separated harness definition ids or instance paths (default: every wire harness with a cable_build trait)"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"id": "package:work-order",
|
|
88
|
+
"summary": "Manufacturing Package: preparation of each routed cable in its unit's work order (cut length, strip, conductors, lay, bend radius, terminations)",
|
|
89
|
+
"entry": "./ops/package.ts#workOrder",
|
|
90
|
+
"effect": "pure"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"id": "package:test-plan",
|
|
94
|
+
"summary": "Manufacturing Package: routing checks of each cable (bend radius, clearance, slack)",
|
|
95
|
+
"entry": "./ops/package.ts#testPlan",
|
|
96
|
+
"effect": "pure"
|
|
97
|
+
}
|
|
98
|
+
],
|
|
99
|
+
"checks": [
|
|
100
|
+
{
|
|
101
|
+
"id": "routes",
|
|
102
|
+
"summary": "Wire harnesses that no cad.config.ts binds to a cad provider (each bound harness's route report is its cad:check)",
|
|
103
|
+
"entry": "./checks/routes.ts#routes"
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
package/py/glb.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Minimal binary glTF (GLB) reader and writer for the harness router.
|
|
2
|
+
|
|
3
|
+
Reading: every triangle primitive of every mesh node, with node transforms
|
|
4
|
+
applied, in glTF coordinates (metres, Y up). Only what the build123d
|
|
5
|
+
exports and vendor conversions in this repo contain is supported: float32
|
|
6
|
+
positions, uint16/uint32 indices, TRS or matrix node transforms.
|
|
7
|
+
|
|
8
|
+
Writing: named meshes with PBR materials (base colour, metallic, roughness),
|
|
9
|
+
one node per mesh under named group nodes.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import struct
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
_COMPONENT = {5120: np.int8, 5121: np.uint8, 5122: np.int16, 5123: np.uint16, 5125: np.uint32, 5126: np.float32}
|
|
20
|
+
_COUNT = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _quat_matrix(q):
|
|
24
|
+
x, y, z, w = q
|
|
25
|
+
return np.array(
|
|
26
|
+
[
|
|
27
|
+
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
|
28
|
+
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
|
29
|
+
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
|
|
30
|
+
]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _node_matrix(node) -> np.ndarray:
|
|
35
|
+
if "matrix" in node:
|
|
36
|
+
return np.array(node["matrix"], dtype=float).reshape(4, 4).T # column-major
|
|
37
|
+
m = np.eye(4)
|
|
38
|
+
s = np.array(node.get("scale", [1, 1, 1]), dtype=float)
|
|
39
|
+
r = _quat_matrix(node.get("rotation", [0, 0, 0, 1]))
|
|
40
|
+
m[:3, :3] = r * s
|
|
41
|
+
m[:3, 3] = node.get("translation", [0, 0, 0])
|
|
42
|
+
return m
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_glb(path: str | Path) -> list[tuple[str, np.ndarray, np.ndarray]]:
|
|
46
|
+
"""(node name, vertices (N,3) in glTF world, triangles (M,3)) per primitive; an unnamed node is named after its nearest named ancestor."""
|
|
47
|
+
data = Path(path).read_bytes()
|
|
48
|
+
magic, _version, _length = struct.unpack_from("<III", data, 0)
|
|
49
|
+
if magic != 0x46546C67:
|
|
50
|
+
raise ValueError(f"{path}: not a GLB")
|
|
51
|
+
off = 12
|
|
52
|
+
doc, binary = None, b""
|
|
53
|
+
while off < len(data):
|
|
54
|
+
clen, ctype = struct.unpack_from("<II", data, off)
|
|
55
|
+
chunk = data[off + 8 : off + 8 + clen]
|
|
56
|
+
if ctype == 0x4E4F534A:
|
|
57
|
+
doc = json.loads(chunk)
|
|
58
|
+
elif ctype == 0x004E4942:
|
|
59
|
+
binary = chunk
|
|
60
|
+
off += 8 + clen
|
|
61
|
+
assert doc is not None
|
|
62
|
+
|
|
63
|
+
def accessor(i):
|
|
64
|
+
a = doc["accessors"][i]
|
|
65
|
+
bv = doc["bufferViews"][a["bufferView"]]
|
|
66
|
+
dtype = _COMPONENT[a["componentType"]]
|
|
67
|
+
n = _COUNT[a["type"]]
|
|
68
|
+
start = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
|
|
69
|
+
stride = bv.get("byteStride", 0)
|
|
70
|
+
item = np.dtype(dtype).itemsize * n
|
|
71
|
+
if stride and stride != item:
|
|
72
|
+
raw = np.frombuffer(binary, dtype=np.uint8, count=stride * a["count"], offset=start).reshape(a["count"], stride)[:, :item]
|
|
73
|
+
return np.frombuffer(raw.tobytes(), dtype=dtype).reshape(a["count"], n)
|
|
74
|
+
return np.frombuffer(binary, dtype=dtype, count=a["count"] * n, offset=start).reshape(a["count"], n)
|
|
75
|
+
|
|
76
|
+
out = []
|
|
77
|
+
|
|
78
|
+
def visit(i, parent, inherited=""):
|
|
79
|
+
node = doc["nodes"][i]
|
|
80
|
+
m = parent @ _node_matrix(node)
|
|
81
|
+
# an unnamed node takes its nearest named ancestor's name (a UHD body's interface feature
|
|
82
|
+
# marker is an unnamed mesh under a node named after the interface)
|
|
83
|
+
name = node.get("name") or inherited
|
|
84
|
+
if "mesh" in node:
|
|
85
|
+
for prim in doc["meshes"][node["mesh"]]["primitives"]:
|
|
86
|
+
if prim.get("mode", 4) != 4:
|
|
87
|
+
continue
|
|
88
|
+
v = accessor(prim["attributes"]["POSITION"]).astype(float)
|
|
89
|
+
f = accessor(prim["indices"]).reshape(-1, 3).astype(np.int64) if "indices" in prim else np.arange(len(v)).reshape(-1, 3)
|
|
90
|
+
v = v @ m[:3, :3].T + m[:3, 3]
|
|
91
|
+
out.append((name, v, f))
|
|
92
|
+
for c in node.get("children", []):
|
|
93
|
+
visit(c, m, name)
|
|
94
|
+
|
|
95
|
+
scene = doc["scenes"][doc.get("scene", 0)]
|
|
96
|
+
for n in scene["nodes"]:
|
|
97
|
+
visit(n, np.eye(4))
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def to_cad_mm(v_gltf: np.ndarray) -> np.ndarray:
|
|
102
|
+
"""glTF (m, Y up) -> CAD (mm, Z up): the viewer's cadWrapper (rotate +90 deg about X, x1000)."""
|
|
103
|
+
return np.stack([v_gltf[:, 0], -v_gltf[:, 2], v_gltf[:, 1]], axis=1) * 1000.0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def to_gltf_m(v_mm: np.ndarray) -> np.ndarray:
|
|
107
|
+
return np.stack([v_mm[:, 0], v_mm[:, 2], -v_mm[:, 1]], axis=1) / 1000.0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _hex_rgba(c: str) -> list[float]:
|
|
111
|
+
c = c.lstrip("#")
|
|
112
|
+
r, g, b = (int(c[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
|
|
113
|
+
# sRGB -> linear for glTF baseColorFactor
|
|
114
|
+
lin = lambda x: x / 12.92 if x <= 0.04045 else ((x + 0.055) / 1.055) ** 2.4
|
|
115
|
+
return [round(lin(r), 5), round(lin(g), 5), round(lin(b), 5), 1.0]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def write_glb(path: str | Path, groups: dict[str, list[dict]]) -> None:
|
|
119
|
+
"""groups: {group name: [{name, vertices (mm, CAD), faces, color, metallic, roughness}]}."""
|
|
120
|
+
doc = {"asset": {"version": "2.0", "generator": "protoboard ext-harness-cad"}, "scene": 0, "scenes": [{"nodes": []}], "nodes": [], "meshes": [], "materials": [], "accessors": [], "bufferViews": [], "buffers": []}
|
|
121
|
+
blob = bytearray()
|
|
122
|
+
materials: dict[tuple, int] = {}
|
|
123
|
+
|
|
124
|
+
def view(arr: np.ndarray, target: int) -> int:
|
|
125
|
+
while len(blob) % 4:
|
|
126
|
+
blob.append(0)
|
|
127
|
+
off = len(blob)
|
|
128
|
+
blob.extend(arr.tobytes())
|
|
129
|
+
doc["bufferViews"].append({"buffer": 0, "byteOffset": off, "byteLength": arr.nbytes, "target": target})
|
|
130
|
+
return len(doc["bufferViews"]) - 1
|
|
131
|
+
|
|
132
|
+
for gname, meshes in groups.items():
|
|
133
|
+
children = []
|
|
134
|
+
for m in meshes:
|
|
135
|
+
v = to_gltf_m(np.asarray(m["vertices"], dtype=float)).astype(np.float32)
|
|
136
|
+
f = np.asarray(m["faces"], dtype=np.uint32).reshape(-1)
|
|
137
|
+
# flat-ish normals per vertex (area weighted)
|
|
138
|
+
tri = v[f.reshape(-1, 3)]
|
|
139
|
+
fn = np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0])
|
|
140
|
+
vn = np.zeros_like(v)
|
|
141
|
+
for k in range(3):
|
|
142
|
+
np.add.at(vn, f.reshape(-1, 3)[:, k], fn)
|
|
143
|
+
vn /= np.maximum(np.linalg.norm(vn, axis=1, keepdims=True), 1e-12)
|
|
144
|
+
key = (m["color"], m.get("metallic", 0.0), m.get("roughness", 0.6))
|
|
145
|
+
if key not in materials:
|
|
146
|
+
doc["materials"].append({"name": m.get("material", m["color"]), "pbrMetallicRoughness": {"baseColorFactor": _hex_rgba(m["color"]), "metallicFactor": key[1], "roughnessFactor": key[2]}})
|
|
147
|
+
materials[key] = len(doc["materials"]) - 1
|
|
148
|
+
pv = view(v, 34962)
|
|
149
|
+
doc["accessors"].append({"bufferView": pv, "componentType": 5126, "count": len(v), "type": "VEC3", "min": v.min(axis=0).tolist(), "max": v.max(axis=0).tolist()})
|
|
150
|
+
pa = len(doc["accessors"]) - 1
|
|
151
|
+
nv = view(vn.astype(np.float32), 34962)
|
|
152
|
+
doc["accessors"].append({"bufferView": nv, "componentType": 5126, "count": len(vn), "type": "VEC3"})
|
|
153
|
+
na = len(doc["accessors"]) - 1
|
|
154
|
+
iv = view(f, 34963)
|
|
155
|
+
doc["accessors"].append({"bufferView": iv, "componentType": 5125, "count": len(f), "type": "SCALAR"})
|
|
156
|
+
ia = len(doc["accessors"]) - 1
|
|
157
|
+
doc["meshes"].append({"name": m["name"], "primitives": [{"attributes": {"POSITION": pa, "NORMAL": na}, "indices": ia, "material": materials[key]}]})
|
|
158
|
+
doc["nodes"].append({"name": m["name"], "mesh": len(doc["meshes"]) - 1})
|
|
159
|
+
children.append(len(doc["nodes"]) - 1)
|
|
160
|
+
doc["nodes"].append({"name": gname, "children": children})
|
|
161
|
+
doc["scenes"][0]["nodes"].append(len(doc["nodes"]) - 1)
|
|
162
|
+
while len(blob) % 4:
|
|
163
|
+
blob.append(0)
|
|
164
|
+
doc["buffers"].append({"byteLength": len(blob)})
|
|
165
|
+
js = json.dumps(doc, separators=(",", ":")).encode()
|
|
166
|
+
while len(js) % 4:
|
|
167
|
+
js += b" "
|
|
168
|
+
out = struct.pack("<III", 0x46546C67, 2, 12 + 8 + len(js) + 8 + len(blob))
|
|
169
|
+
out += struct.pack("<II", len(js), 0x4E4F534A) + js
|
|
170
|
+
out += struct.pack("<II", len(blob), 0x004E4942) + bytes(blob)
|
|
171
|
+
Path(path).write_bytes(out)
|