@protoboardai/ext-frame-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/frames.ts +25 -0
- package/checks/hardware.ts +26 -0
- package/evals/regenerate-after-vtx-move/case.json +175 -0
- package/lib/alias.ts +57 -0
- package/lib/artifacts.ts +185 -0
- package/lib/hardware-cad.ts +127 -0
- package/lib/hardware.ts +100 -0
- package/lib/params.ts +255 -0
- package/lib/plate.ts +233 -0
- package/lib/run.ts +24 -0
- package/ops/cad.ts +78 -0
- package/ops/frame-cad.ts +46 -0
- package/ops/hardware-cad.ts +54 -0
- package/ops/package.ts +96 -0
- package/ops/rebind.ts +94 -0
- package/package.json +42 -4
- package/protoboard-extension.json +187 -0
- package/py/frame_cad.py +343 -0
- package/py/hardware_cad.py +111 -0
- package/skills/frame-cad/SKILL.md +279 -0
- package/skills/frame-cad/references/generator.md +110 -0
- package/skills/frame-cad/references/manufacturing.md +42 -0
- package/skills/frame-cad-verify/SKILL.md +117 -0
- package/skills/frame-cad-verify/scripts/dxf_report.py +124 -0
- package/README.md +0 -3
package/lib/artifacts.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The files the build123d generators write, as UHD artifacts
|
|
3
|
+
* (`@protoboardai/ext-frame-cad/artifacts`). A module's source declares them
|
|
4
|
+
* with UHD's pure helpers (`withGeometry`, `feature`, `own`, `procedural`);
|
|
5
|
+
* the layout itself is this extension's knowledge, so it lives here, not in
|
|
6
|
+
* UHD (PB-801).
|
|
7
|
+
*
|
|
8
|
+
* The common layout, per generated body:
|
|
9
|
+
* <name>.step body with each interface as a named sub-shape (D1)
|
|
10
|
+
* <name>.glb the same, tessellated, for viewers
|
|
11
|
+
* <name>.manifest.json feature name -> signature (area, centroid, normal)
|
|
12
|
+
* interfaces/<id>.glb one artifact per interface (D2)
|
|
13
|
+
* and nothing for D3 (procedural), which viewers derive from parameters.
|
|
14
|
+
*
|
|
15
|
+
* Plate modules use the `cad` capability's layout instead (PB-822,
|
|
16
|
+
* tool-integrations §4): the same bodies as standard outputs in
|
|
17
|
+
* `<dir>/out/` (body.step, body.glb, body.manifest.json, body.dxf,
|
|
18
|
+
* interfaces/<id>.glb) and the native design in `<dir>/frame-cad/`.
|
|
19
|
+
*/
|
|
20
|
+
import type { ArtifactDef } from "@deltarobotics/uhd";
|
|
21
|
+
|
|
22
|
+
export const FRAME_CAD_GENERATOR = "@protoboardai/ext-frame-cad py/frame_cad.py";
|
|
23
|
+
export const HARDWARE_CAD_GENERATOR = "@protoboardai/ext-frame-cad py/hardware_cad.py";
|
|
24
|
+
|
|
25
|
+
export interface CadOptions {
|
|
26
|
+
/** Project-relative directory holding the generated files. */
|
|
27
|
+
dir: string;
|
|
28
|
+
/** Base name of the generated body files. */
|
|
29
|
+
name: string;
|
|
30
|
+
/**
|
|
31
|
+
* The generator: a project-relative script (recorded as the source artifact's
|
|
32
|
+
* file), or a package-qualified name such as FRAME_CAD_GENERATOR (recorded
|
|
33
|
+
* as provenance only, with `generatorNote`).
|
|
34
|
+
*/
|
|
35
|
+
generator: string;
|
|
36
|
+
tool: string;
|
|
37
|
+
/** Interfaces that have a per-interface artifact (D2). */
|
|
38
|
+
interfaces?: string[];
|
|
39
|
+
/** Extra id prefix when a module has several bodies (e.g. "camera"). */
|
|
40
|
+
prefix?: string;
|
|
41
|
+
/** Description of a generator that is not a project file (it ships in a package). */
|
|
42
|
+
generatorNote?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const inPackage = (generator: string) => generator.startsWith("@") || generator.includes(" ");
|
|
46
|
+
|
|
47
|
+
/** Body file paths of one generated body. */
|
|
48
|
+
interface BodyFiles {
|
|
49
|
+
step: string;
|
|
50
|
+
glb: string;
|
|
51
|
+
manifest: string;
|
|
52
|
+
iface: (id: string) => string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Source, STEP body, GLB body, manifest and per-interface GLBs for one generated body. */
|
|
56
|
+
export function cadArtifacts(o: CadOptions): ArtifactDef[] {
|
|
57
|
+
return bodyArtifacts(o, { step: `${o.dir}/${o.name}.step`, glb: `${o.dir}/${o.name}.glb`, manifest: `${o.dir}/${o.name}.manifest.json`, iface: (id) => `${o.dir}/interfaces/${id}.glb` });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function bodyArtifacts(o: Omit<CadOptions, "dir">, f: BodyFiles): ArtifactDef[] {
|
|
61
|
+
const p = o.prefix ? `${o.prefix}_` : "";
|
|
62
|
+
const provenance = { generatedFrom: `cad_${p}source`, tool: o.tool };
|
|
63
|
+
const packaged = inPackage(o.generator);
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
id: `cad_${p}source`,
|
|
67
|
+
name: `${o.name} generator`,
|
|
68
|
+
type: "cad",
|
|
69
|
+
role: "source",
|
|
70
|
+
format: o.generator.split(".").pop(),
|
|
71
|
+
...(packaged ? { description: o.generatorNote ?? `Generated by ${o.generator}.` } : { filePath: o.generator }),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: `cad_${p}step`,
|
|
75
|
+
name: `${o.name} (STEP)`,
|
|
76
|
+
type: "3d_model",
|
|
77
|
+
role: "body",
|
|
78
|
+
format: "step",
|
|
79
|
+
units: "mm",
|
|
80
|
+
filePath: f.step,
|
|
81
|
+
provenance,
|
|
82
|
+
description: "Body plus one named sub-shape per interface feature; features and signatures in the manifest.",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: `cad_${p}glb`,
|
|
86
|
+
name: `${o.name} (GLB)`,
|
|
87
|
+
type: "3d_model",
|
|
88
|
+
role: "body",
|
|
89
|
+
format: "glb",
|
|
90
|
+
units: "m",
|
|
91
|
+
filePath: f.glb,
|
|
92
|
+
provenance: { ...provenance, generatedFrom: `cad_${p}step` },
|
|
93
|
+
description: "Viewer mesh of the STEP body (glTF metres, Y-up).",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: `cad_${p}manifest`,
|
|
97
|
+
name: `${o.name} feature manifest`,
|
|
98
|
+
type: "cad",
|
|
99
|
+
role: "source",
|
|
100
|
+
format: "json",
|
|
101
|
+
filePath: f.manifest,
|
|
102
|
+
provenance: { ...provenance, generatedFrom: `cad_${p}step` },
|
|
103
|
+
},
|
|
104
|
+
...(o.interfaces ?? []).map(
|
|
105
|
+
(id): ArtifactDef => ({
|
|
106
|
+
id: `cad_if_${id}`,
|
|
107
|
+
name: `${id} geometry`,
|
|
108
|
+
type: "3d_model",
|
|
109
|
+
role: "interface",
|
|
110
|
+
interfaceId: id,
|
|
111
|
+
format: "glb",
|
|
112
|
+
units: "m",
|
|
113
|
+
filePath: f.iface(id),
|
|
114
|
+
provenance: { ...provenance, generatedFrom: `cad_${p}step` },
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The native folder of this provider in a module's provider directory (`native` in the manifest). */
|
|
121
|
+
export const FRAME_CAD_NATIVE = "frame-cad";
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Everything the `cad` capability writes for a plate module whose
|
|
125
|
+
* `cad.config.ts` is in `dir` (`protoboard run cad:generate` and
|
|
126
|
+
* `cad:export`, or the `frame-cad` alias): the standard outputs in
|
|
127
|
+
* `<dir>/out/` (body, manifest, per-interface GLBs, the cutting DXF) and the
|
|
128
|
+
* native design in `<dir>/frame-cad/` (the parameters the generator ran on
|
|
129
|
+
* and the frames it generated).
|
|
130
|
+
*/
|
|
131
|
+
export function plateCadArtifacts(o: { dir: string; name: string; interfaces: string[]; tool?: string }): ArtifactDef[] {
|
|
132
|
+
const tool = o.tool ?? "build123d 0.13.0";
|
|
133
|
+
const out = `${o.dir}/out`;
|
|
134
|
+
const native = `${o.dir}/${FRAME_CAD_NATIVE}`;
|
|
135
|
+
return [
|
|
136
|
+
...bodyArtifacts(
|
|
137
|
+
{
|
|
138
|
+
name: o.name,
|
|
139
|
+
generator: FRAME_CAD_GENERATOR,
|
|
140
|
+
tool,
|
|
141
|
+
interfaces: o.interfaces,
|
|
142
|
+
generatorNote: `Generated by ${FRAME_CAD_GENERATOR} from ${native}/params.json (protoboard run cad:generate, then cad:export).`,
|
|
143
|
+
},
|
|
144
|
+
{ step: `${out}/body.step`, glb: `${out}/body.glb`, manifest: `${out}/body.manifest.json`, iface: (id) => `${out}/interfaces/${id}.glb` },
|
|
145
|
+
),
|
|
146
|
+
{ id: "cad_params", name: `${o.name} generator parameters`, type: "cad", role: "source", format: "json", filePath: `${native}/params.json`, description: "Bolt patterns, frames and design values read from this module's interfaces and its cad.config.ts constraints." },
|
|
147
|
+
{ id: "cad_frames", name: `${o.name} generated frames`, type: "cad", role: "source", format: "json", filePath: `${native}/frames.json`, provenance: { generatedFrom: "cad_source", tool } },
|
|
148
|
+
{
|
|
149
|
+
id: "cad_dxf",
|
|
150
|
+
name: `${o.name} (DXF)`,
|
|
151
|
+
type: "cad",
|
|
152
|
+
role: "drawing",
|
|
153
|
+
format: "dxf",
|
|
154
|
+
units: "mm",
|
|
155
|
+
filePath: `${out}/body.dxf`,
|
|
156
|
+
provenance: { generatedFrom: "cad_step", tool },
|
|
157
|
+
description: "Manufacturing profiles for CNC routing: the plate outline with every hole (layer plate) and any side-clamp plates (layer clamp_<interface>).",
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Everything the `cad` capability writes for a fastener module (screw, nut,
|
|
164
|
+
* spacer, standoff) whose `cad.config.ts` is in `dir` (`protoboard run
|
|
165
|
+
* cad:generate` and `cad:export`, or the `hardware-cad` alias): its nominal
|
|
166
|
+
* body as standard outputs in `<dir>/out/` and the parameters it was built
|
|
167
|
+
* from in `<dir>/frame-cad/params.json`.
|
|
168
|
+
*/
|
|
169
|
+
export function hardwareCadArtifacts(o: { dir: string; name: string; tool?: string }): ArtifactDef[] {
|
|
170
|
+
const tool = o.tool ?? "build123d 0.13.0";
|
|
171
|
+
const out = `${o.dir}/out`;
|
|
172
|
+
const native = `${o.dir}/${FRAME_CAD_NATIVE}`;
|
|
173
|
+
return [
|
|
174
|
+
...bodyArtifacts(
|
|
175
|
+
{
|
|
176
|
+
name: o.name,
|
|
177
|
+
generator: HARDWARE_CAD_GENERATOR,
|
|
178
|
+
tool,
|
|
179
|
+
generatorNote: `Generated by ${HARDWARE_CAD_GENERATOR} from ${native}/params.json (protoboard run cad:generate, then cad:export; or hardware-cad).`,
|
|
180
|
+
},
|
|
181
|
+
{ step: `${out}/body.step`, glb: `${out}/body.glb`, manifest: `${out}/body.manifest.json`, iface: (id) => `${out}/interfaces/${id}.glb` },
|
|
182
|
+
),
|
|
183
|
+
{ id: "cad_params", name: `${o.name} generator parameters`, type: "cad", role: "source", format: "json", filePath: `${native}/params.json`, description: "Kind, diameter, length and head or outer size read from this module." },
|
|
184
|
+
];
|
|
185
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mounting hardware as a `cad` provider (tool-integrations §2–§4, PB-836):
|
|
3
|
+
* a local fastener module (a screw, nut, standoff or spacer) whose
|
|
4
|
+
* `cad.config.ts` names @protoboardai/ext-frame-cad gets its nominal body the
|
|
5
|
+
* same way a plate does.
|
|
6
|
+
*
|
|
7
|
+
* generate module sizes → native design <dir>/frame-cad/params.json
|
|
8
|
+
* export params.json → build123d (py/hardware_cad.py) → standard
|
|
9
|
+
* outputs <dir>/out/body.step, body.glb, body.manifest.json
|
|
10
|
+
* extract the module's artifacts that exist (no interface frames: a
|
|
11
|
+
* fastener's position comes from the fastener stack)
|
|
12
|
+
* check params still what the module says; out/ exported from them
|
|
13
|
+
*
|
|
14
|
+
* `protoboard run hardware-cad` is the alias of generate then export for
|
|
15
|
+
* every bound hardware module, as `frame-cad` is for plates.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import type { ArtifactDef, ModuleDef } from "@deltarobotics/uhd";
|
|
21
|
+
import { sha256, stableStringify, type CapabilityBinding, type ProjectDiagnostic } from "@protoboardai/core";
|
|
22
|
+
import { hardwareCadArtifacts } from "./artifacts.ts";
|
|
23
|
+
import { buildHardwareParams, type HardwareParams } from "./hardware.ts";
|
|
24
|
+
import { python, type Writer } from "./plate.ts";
|
|
25
|
+
import { runAsync } from "./run.ts";
|
|
26
|
+
|
|
27
|
+
export const HARDWARE_GENERATOR = fileURLToPath(new URL("../py/hardware_cad.py", import.meta.url));
|
|
28
|
+
|
|
29
|
+
/** A hardware module and its provider directory. */
|
|
30
|
+
export interface HardwareTarget {
|
|
31
|
+
def: ModuleDef;
|
|
32
|
+
binding: Pick<CapabilityBinding, "dir" | "native" | "out" | "config">;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Native params and the standard outputs' manifest of a bound hardware module. */
|
|
36
|
+
export function hardwareFiles(b: Pick<CapabilityBinding, "native" | "out">) {
|
|
37
|
+
return { params: `${b.native}/params.json`, manifest: `${b.out}/body.manifest.json` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Outcome<T> = { ok: true; value: T } | { ok: false; summary: string; problems?: string[] };
|
|
41
|
+
|
|
42
|
+
/** generate: the generator parameters from the module's sizes, into the native folder. */
|
|
43
|
+
export function generateHardware(ctx: Writer, t: HardwareTarget): Outcome<{ module: string; kind: string; native: string; params: HardwareParams }> {
|
|
44
|
+
const { def, binding: b } = t;
|
|
45
|
+
const { params, problems } = buildHardwareParams(def, def.id);
|
|
46
|
+
if (!params || problems.length) return { ok: false, summary: problems[0], problems };
|
|
47
|
+
const files = hardwareFiles(b);
|
|
48
|
+
ctx.writeOutput(files.params, stableStringify(params), `${def.id}: generator parameters from its sizes`);
|
|
49
|
+
ctx.log(`${def.id}: ${params.part.kind}; native design in ${b.native}`);
|
|
50
|
+
return { ok: true, value: { module: def.id, kind: params.part.kind, native: b.native!, params } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** export: build the nominal body from the native params and write the standard outputs. */
|
|
54
|
+
export async function exportHardware(ctx: Writer, t: HardwareTarget): Promise<Outcome<{ module: string; out: string; volume_mm3: number; previousVolume_mm3: number | null; toolVersion: string }>> {
|
|
55
|
+
const p = ctx.project;
|
|
56
|
+
const { def, binding: b } = t;
|
|
57
|
+
const files = hardwareFiles(b);
|
|
58
|
+
if (!existsSync(join(p.root, files.params))) return { ok: false, summary: `${def.id} has no native design (${files.params}); run \`protoboard run cad:generate --module ${def.id}\` first` };
|
|
59
|
+
const paramsBytes = readFileSync(join(p.root, files.params));
|
|
60
|
+
const params = JSON.parse(paramsBytes.toString("utf8")) as HardwareParams;
|
|
61
|
+
const before = readJson<{ volume_mm3?: number }>(join(p.root, files.manifest))?.volume_mm3 ?? null;
|
|
62
|
+
const scratch = join(p.root, ".protoboard", "cache", "hardware-cad", params.name);
|
|
63
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
64
|
+
mkdirSync(scratch, { recursive: true });
|
|
65
|
+
const run = await runAsync(python(), [HARDWARE_GENERATOR, join(p.root, files.params), scratch], { timeout: 300000, env: { ...process.env, PYTHONHASHSEED: "0" } });
|
|
66
|
+
if (!run.ok) {
|
|
67
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
68
|
+
return { ok: false, summary: `${def.id}: generator failed: ${run.error}` };
|
|
69
|
+
}
|
|
70
|
+
const summary = JSON.parse(run.stdout.trim().split("\n").pop()!) as { toolVersion: string; volume_mm3: number };
|
|
71
|
+
const n = params.name;
|
|
72
|
+
ctx.writeOutput(`${b.out}/body.step`, readFileSync(join(scratch, `${n}.step`)), `${def.id}: nominal body`);
|
|
73
|
+
ctx.writeOutput(`${b.out}/body.glb`, readFileSync(join(scratch, `${n}.glb`)), `${def.id}: viewer mesh`);
|
|
74
|
+
const manifest = JSON.parse(readFileSync(join(scratch, `${n}.manifest.json`), "utf8")) as Record<string, unknown>;
|
|
75
|
+
const standard = { ...manifest, artifact: "body", params: `../${b.native!.split("/").pop()}/params.json`, exportedFrom: { file: files.params, digest: sha256(paramsBytes) } };
|
|
76
|
+
ctx.writeOutput(files.manifest, JSON.stringify(standard, null, 2) + "\n", `${def.id}: geometry manifest (volume)`);
|
|
77
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
78
|
+
ctx.log(`${def.id}: ${summary.volume_mm3} mm³ in ${b.out}`);
|
|
79
|
+
return { ok: true, value: { module: def.id, out: b.out, volume_mm3: summary.volume_mm3, previousVolume_mm3: before, toolVersion: summary.toolVersion } };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const readJson = <T>(file: string): T | undefined => (existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as T) : undefined);
|
|
83
|
+
|
|
84
|
+
/** extract: no frames (a fastener is placed by its fastener stack), and the module's artifacts that exist. */
|
|
85
|
+
export function extractHardware(root: string, t: HardwareTarget): Outcome<{ frames: Record<string, never>; artifacts: ArtifactDef[] }> {
|
|
86
|
+
const { def, binding: b } = t;
|
|
87
|
+
if (!existsSync(join(root, hardwareFiles(b).params))) return { ok: false, summary: `${def.id} has no native design (${hardwareFiles(b).params}); run \`protoboard run cad:generate --module ${def.id}\` first` };
|
|
88
|
+
const artifacts = hardwareCadArtifacts({ dir: b.dir, name: def.id }).filter((a) => a.filePath && existsSync(join(root, a.filePath)));
|
|
89
|
+
return { ok: true, value: { frames: {}, artifacts } };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* check: the params the body was made from are still the module's sizes
|
|
94
|
+
* (otherwise the body, and the mass computed from its volume, are stale),
|
|
95
|
+
* and out/ was exported from those params.
|
|
96
|
+
*/
|
|
97
|
+
export function hardwareDiagnostics(root: string, t: HardwareTarget, source: ProjectDiagnostic["source"]): Omit<ProjectDiagnostic, "validator">[] {
|
|
98
|
+
const { def, binding: b } = t;
|
|
99
|
+
const files = hardwareFiles(b);
|
|
100
|
+
const entity = { kind: "definition" as const, id: def.id };
|
|
101
|
+
const out: Omit<ProjectDiagnostic, "validator">[] = [];
|
|
102
|
+
const { params, problems } = buildHardwareParams(def, def.id);
|
|
103
|
+
if (!params) {
|
|
104
|
+
out.push({ code: "HARDWARE_CAD_PARAMS", severity: "error", entity, message: `${def.id}: ${problems.join("; ")}`, source });
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
const paramsFile = join(root, files.params);
|
|
108
|
+
if (!existsSync(paramsFile)) {
|
|
109
|
+
out.push({ code: "HARDWARE_CAD_NOT_GENERATED", severity: "info", entity, message: `${def.id} has no native design (${files.params}); run \`protoboard run cad:generate --module ${def.id}\`, then cad:export`, source });
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
const bytes = readFileSync(paramsFile);
|
|
113
|
+
if (stableStringify(params) !== stableStringify(JSON.parse(bytes.toString("utf8")))) {
|
|
114
|
+
out.push({
|
|
115
|
+
code: "HARDWARE_CAD_STALE",
|
|
116
|
+
severity: "warning",
|
|
117
|
+
entity,
|
|
118
|
+
message: `${def.id}: its sizes changed since its CAD was generated (${files.params}); run \`protoboard run cad:generate --module ${def.id}\`, then cad:export (or \`protoboard run hardware-cad --input module=${def.id}\`)`,
|
|
119
|
+
source,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
const manifest = readJson<{ exportedFrom?: { digest?: string } }>(join(root, files.manifest));
|
|
123
|
+
if (manifest && manifest.exportedFrom?.digest !== sha256(bytes)) {
|
|
124
|
+
out.push({ code: "HARDWARE_CAD_EXPORT_STALE", severity: "warning", entity, message: `${def.id}: ${b.out}/ was exported from other parameters than ${files.params}; run \`protoboard run cad:export --module ${def.id}\``, source });
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
package/lib/hardware.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UHD fastener module → hardware-cad parameters.
|
|
3
|
+
*
|
|
4
|
+
* A hardware module is a local definition tagged `fastener` and one of
|
|
5
|
+
* `screw`, `nut`, `standoff`, `spacer` (the tags UHD's fastener stacks use).
|
|
6
|
+
* It is designed through the `cad` capability (lib/hardware-cad.ts) when a
|
|
7
|
+
* `cad.config.ts` in its folder names this provider. Its thread or bore interface states `fastener_diameter` and `length`; its
|
|
8
|
+
* mechanical metadata states the rest: `head_diameter_mm` and
|
|
9
|
+
* `head_height_mm` for a screw, `outer_mm` (across flats, or the tube
|
|
10
|
+
* diameter) for the others, and `bore_mm` when the maker states the bore of
|
|
11
|
+
* a nut, standoff or spacer (otherwise the generator's nominal bore). Nothing
|
|
12
|
+
* else is read, and nothing is assumed: a missing size is a problem, not a
|
|
13
|
+
* default.
|
|
14
|
+
*/
|
|
15
|
+
import type { ModuleDef } from "@deltarobotics/uhd";
|
|
16
|
+
import type { CapabilityBinding, Project } from "@protoboardai/core";
|
|
17
|
+
import { mechanical } from "./params.ts";
|
|
18
|
+
|
|
19
|
+
export const HARDWARE_PARAMS_SCHEMA = "protoboard.hardware-cad.params/v0";
|
|
20
|
+
|
|
21
|
+
export const HARDWARE_KINDS = ["screw", "nut", "standoff", "spacer"] as const;
|
|
22
|
+
export type HardwareKind = (typeof HARDWARE_KINDS)[number];
|
|
23
|
+
|
|
24
|
+
export interface HardwareParams {
|
|
25
|
+
schema: typeof HARDWARE_PARAMS_SCHEMA;
|
|
26
|
+
module: string;
|
|
27
|
+
name: string;
|
|
28
|
+
part: {
|
|
29
|
+
kind: HardwareKind;
|
|
30
|
+
diameter: number;
|
|
31
|
+
length: number;
|
|
32
|
+
head_diameter: number | null;
|
|
33
|
+
head_height: number | null;
|
|
34
|
+
outer: number | null;
|
|
35
|
+
/** The bore the maker states (nut, standoff, spacer); absent: the generator's nominal bore. */
|
|
36
|
+
bore?: number;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined);
|
|
41
|
+
|
|
42
|
+
export function hardwareKind(def: ModuleDef): HardwareKind | undefined {
|
|
43
|
+
const tags = def.tags ?? [];
|
|
44
|
+
return tags.includes("fastener") ? HARDWARE_KINDS.find((k) => tags.includes(k)) : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Local, used definitions that are hardware modules (optionally only the named ones). */
|
|
48
|
+
export function hardwareModules(p: Project, only?: string[]): { def: ModuleDef; problems: string[] }[] {
|
|
49
|
+
if (only?.length) {
|
|
50
|
+
return only.map((id) => {
|
|
51
|
+
const rec = p.definitions.get(id);
|
|
52
|
+
if (!rec) return { def: { id, interfaces: [] } as unknown as ModuleDef, problems: [`no definition "${id}" in the project`] };
|
|
53
|
+
if (rec.origin.kind !== "local") return { def: rec.def, problems: [`${id} is a ${rec.origin.kind} definition; its CAD comes with it (hardware-cad generates local modules only)`] };
|
|
54
|
+
if (!hardwareKind(rec.def)) return { def: rec.def, problems: [`${id} is not tagged fastener plus one of ${HARDWARE_KINDS.join(", ")}`] };
|
|
55
|
+
return { def: rec.def, problems: [] };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const used = new Set(p.instances.map((i) => i.definition));
|
|
59
|
+
return [...p.definitions.entries()]
|
|
60
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
61
|
+
.filter(([id, rec]) => used.has(id) && rec.origin.kind === "local" && hardwareKind(rec.def))
|
|
62
|
+
.map(([, rec]) => ({ def: rec.def, problems: [] }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The module's cad binding to this provider (its `cad.config.ts`), when it has a usable one. */
|
|
66
|
+
export function hardwareBinding(p: Project, id: string): CapabilityBinding | undefined {
|
|
67
|
+
return p.capabilities.find((b) => b.capability === "cad" && b.module === id && b.provider === "@protoboardai/ext-frame-cad" && !b.problem && b.native);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildHardwareParams(def: ModuleDef, name: string): { params?: HardwareParams; problems: string[] } {
|
|
71
|
+
const kind = hardwareKind(def);
|
|
72
|
+
if (!kind) return { problems: [`${def.id}: not a hardware module (tags fastener plus ${HARDWARE_KINDS.join("|")})`] };
|
|
73
|
+
const param = (id: string) =>
|
|
74
|
+
def.interfaces
|
|
75
|
+
.map((i) => i.parameters?.find((x) => x.id === id))
|
|
76
|
+
.find((x) => x?.value !== undefined)?.value;
|
|
77
|
+
const meta = mechanical(def);
|
|
78
|
+
const diameter = num(param("fastener_diameter"));
|
|
79
|
+
const length = num(param("length"));
|
|
80
|
+
const headDiameter = num(meta.head_diameter_mm);
|
|
81
|
+
const headHeight = num(meta.head_height_mm);
|
|
82
|
+
const outer = num(meta.outer_mm);
|
|
83
|
+
const bore = num(meta.bore_mm);
|
|
84
|
+
const problems: string[] = [];
|
|
85
|
+
if (diameter === undefined) problems.push(`${def.id}: no interface states fastener_diameter`);
|
|
86
|
+
if (length === undefined) problems.push(`${def.id}: no interface states length`);
|
|
87
|
+
if (kind === "screw" && (headDiameter === undefined || headHeight === undefined)) problems.push(`${def.id}: a screw needs mechanical metadata head_diameter_mm and head_height_mm`);
|
|
88
|
+
if (kind !== "screw" && outer === undefined) problems.push(`${def.id}: a ${kind} needs mechanical metadata outer_mm`);
|
|
89
|
+
if (bore !== undefined && (kind === "screw" || bore >= outer!)) problems.push(`${def.id}: bore_mm ${bore} ${kind === "screw" ? "does not apply to a screw" : `is not inside outer_mm ${outer}`}`);
|
|
90
|
+
if (problems.length) return { problems };
|
|
91
|
+
return {
|
|
92
|
+
problems,
|
|
93
|
+
params: {
|
|
94
|
+
schema: HARDWARE_PARAMS_SCHEMA,
|
|
95
|
+
module: def.id,
|
|
96
|
+
name,
|
|
97
|
+
part: { kind, diameter: diameter!, length: length!, head_diameter: headDiameter ?? null, head_height: headHeight ?? null, outer: outer ?? null, ...(bore !== undefined ? { bore } : {}) },
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
package/lib/params.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UHD module → frame-cad parameters.
|
|
3
|
+
*
|
|
4
|
+
* A "plate module" is a local custom structure whose mechanical domain
|
|
5
|
+
* metadata states `plate_thickness_mm`. Its bolt-pattern interfaces with
|
|
6
|
+
* frames are the whole input: where each pattern sits (frame origin and axes,
|
|
7
|
+
* module coordinates, mm, Z up, origin on the plate underside) and what it is
|
|
8
|
+
* (shape, spacing, hole count, fastener). Optional design values are the
|
|
9
|
+
* constraints in the module's `cad.config.ts` (see DEFAULTS); they are this
|
|
10
|
+
* provider's, so they stay out of the UHD module. Nothing about a particular
|
|
11
|
+
* vehicle is hard-coded; the defaults are the reference quadcopter's design
|
|
12
|
+
* values.
|
|
13
|
+
*/
|
|
14
|
+
import type { InterfaceDef, ModuleDef } from "@deltarobotics/uhd";
|
|
15
|
+
import type { CapabilityBinding, Project } from "@protoboardai/core";
|
|
16
|
+
|
|
17
|
+
export const PARAMS_SCHEMA = "protoboard.frame-cad.params/v0";
|
|
18
|
+
|
|
19
|
+
type Vec3 = [number, number, number];
|
|
20
|
+
|
|
21
|
+
export type Placement = "holes_top" | "holes_bottom" | "pad" | "clamp";
|
|
22
|
+
|
|
23
|
+
export interface PlateInterface {
|
|
24
|
+
id: string;
|
|
25
|
+
shape: "square" | "rectangle" | "circle";
|
|
26
|
+
spacing: number;
|
|
27
|
+
spacing_y: number | null;
|
|
28
|
+
hole_count: number;
|
|
29
|
+
fastener_d: number;
|
|
30
|
+
placement: Placement;
|
|
31
|
+
/** Frame origin XY as declared (module coordinates, mm); Z follows from the placement and height. */
|
|
32
|
+
origin: [number, number];
|
|
33
|
+
normal: Vec3;
|
|
34
|
+
x_axis: Vec3;
|
|
35
|
+
symmetry: number | null;
|
|
36
|
+
/** pad: pad height above the plate top; clamp: frame origin height above the plate top. */
|
|
37
|
+
height: number;
|
|
38
|
+
/** World XY hole centres for plate patterns (UHD boltPatternHoles convention). */
|
|
39
|
+
holes: [number, number][];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PlateParams {
|
|
43
|
+
schema: typeof PARAMS_SCHEMA;
|
|
44
|
+
module: string;
|
|
45
|
+
name: string;
|
|
46
|
+
plate: {
|
|
47
|
+
thickness: number;
|
|
48
|
+
margin: number;
|
|
49
|
+
corner_radius: number;
|
|
50
|
+
arm_width: number;
|
|
51
|
+
arm_slot_width: number;
|
|
52
|
+
hub_extra: number;
|
|
53
|
+
pad_margin: number;
|
|
54
|
+
pad_center_relief: number;
|
|
55
|
+
hole_clearance: number;
|
|
56
|
+
clamp_wall: number;
|
|
57
|
+
clamp_margin: number;
|
|
58
|
+
outline: { x: [number, number]; y: [number, number] } | null;
|
|
59
|
+
slots: { center: [number, number]; size: [number, number] }[];
|
|
60
|
+
};
|
|
61
|
+
interfaces: PlateInterface[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Design defaults (mm) and the `cad.config.ts` constraints that override them.
|
|
66
|
+
* Holes are fastener diameter + 2 × hole_clearance (M3 → Ø3.2).
|
|
67
|
+
*/
|
|
68
|
+
export const DEFAULTS = {
|
|
69
|
+
margin: 3, // outline margin around core holes and clamps (margin_mm)
|
|
70
|
+
corner_radius: 1.5, // vertical edge fillet (corner_radius_mm)
|
|
71
|
+
arm_width: 14, // arm to each raised pad (arm_width_mm)
|
|
72
|
+
arm_slot_width: 5, // lightening slot down each arm; 0 = none (arm_slot_width_mm)
|
|
73
|
+
hub_extra: 1, // hub disc radius beyond the pad (hub_extra_mm)
|
|
74
|
+
pad_margin: 3, // pad radius beyond the pattern (pad_margin_mm)
|
|
75
|
+
pad_height: 0.5, // raised pad: its own planar mating face (pad_height_mm)
|
|
76
|
+
pad_center_relief: 3.5, // centre hole in circle pads (shaft/circlip) (pad_center_relief_mm)
|
|
77
|
+
hole_clearance: 0.1, // radial clearance over the fastener (hole_clearance_mm)
|
|
78
|
+
clamp_wall: 2, // side-plate thickness (clamp_wall_mm)
|
|
79
|
+
clamp_margin: 4, // side-plate width = spacing + margin; top = origin + spacing/2 + margin (clamp_margin_mm)
|
|
80
|
+
} as const;
|
|
81
|
+
|
|
82
|
+
const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
|
83
|
+
|
|
84
|
+
export function mechanical(def: ModuleDef): Record<string, unknown> {
|
|
85
|
+
return ((def.domains ?? []).find((d) => d.domain === "mechanical")?.metadata ?? {}) as Record<string, unknown>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const isPlateModule = (def: ModuleDef) => num(mechanical(def).plate_thickness_mm) !== undefined;
|
|
89
|
+
|
|
90
|
+
/** Local, used definitions that are plate modules (optionally only the named ones). */
|
|
91
|
+
export function plateModules(p: Project, only?: string[]): { def: ModuleDef; problems: string[] }[] {
|
|
92
|
+
const used = new Set(p.instances.map((i) => i.definition));
|
|
93
|
+
const out: { def: ModuleDef; problems: string[] }[] = [];
|
|
94
|
+
if (only?.length) {
|
|
95
|
+
for (const id of only) {
|
|
96
|
+
const rec = p.definitions.get(id);
|
|
97
|
+
if (!rec) out.push({ def: { id, interfaces: [] } as unknown as ModuleDef, problems: [`no definition "${id}" in the project`] });
|
|
98
|
+
else if (rec.origin.kind !== "local") out.push({ def: rec.def, problems: [`${id} is a ${rec.origin.kind} definition; frame-cad generates local custom modules only (derive a local variant)`] });
|
|
99
|
+
else if (!isPlateModule(rec.def)) out.push({ def: rec.def, problems: [`${id} has no mechanical metadata plate_thickness_mm, so it is not a plate module`] });
|
|
100
|
+
else out.push({ def: rec.def, problems: [] });
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
for (const [id, rec] of [...p.definitions.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
105
|
+
if (used.has(id) && rec.origin.kind === "local" && isPlateModule(rec.def)) out.push({ def: rec.def, problems: [] });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const PROVIDER = "@protoboardai/ext-frame-cad";
|
|
111
|
+
|
|
112
|
+
/** The module's cad binding to this provider (its `cad.config.ts`), when it has a usable one. */
|
|
113
|
+
export function plateBinding(p: Project, id: string): CapabilityBinding | undefined {
|
|
114
|
+
return p.capabilities.find((b) => b.capability === "cad" && b.module === id && b.provider === PROVIDER && !b.problem && b.native);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Where a bound plate module's files are (tool-integrations §4): the native
|
|
119
|
+
* design in `<dir>/frame-cad/` (params.json, frames.json), the standard
|
|
120
|
+
* outputs in `<dir>/out/` (body.step, body.glb, body.manifest.json, body.dxf,
|
|
121
|
+
* interfaces/<id>.glb).
|
|
122
|
+
*/
|
|
123
|
+
export function plateFiles(b: Pick<CapabilityBinding, "native" | "out">) {
|
|
124
|
+
return { params: `${b.native}/params.json`, frames: `${b.native}/frames.json`, manifest: `${b.out}/body.manifest.json` };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const param = (i: InterfaceDef, id: string) => {
|
|
128
|
+
const v = i.parameters?.find((x) => x.id === id);
|
|
129
|
+
return v?.value ?? (v?.range ? (v.range[0] + v.range[1]) / 2 : undefined);
|
|
130
|
+
};
|
|
131
|
+
const norm = (v: Vec3): Vec3 => {
|
|
132
|
+
const l = Math.hypot(...v);
|
|
133
|
+
return [v[0] / l, v[1] / l, v[2] / l];
|
|
134
|
+
};
|
|
135
|
+
const cross = (a: Vec3, b: Vec3): Vec3 => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
136
|
+
const r6 = (n: number) => Math.round(n * 1e6) / 1e6 + 0;
|
|
137
|
+
|
|
138
|
+
/** Local hole positions of a pattern (same convention as UHD boltPatternHoles). */
|
|
139
|
+
function localHoles(shape: string, spacing: number, sy: number, count: number): [number, number][] {
|
|
140
|
+
if (shape === "circle") {
|
|
141
|
+
return Array.from({ length: count }, (_, k) => {
|
|
142
|
+
const a = (2 * Math.PI * k) / count;
|
|
143
|
+
return [(Math.cos(a) * spacing) / 2, (Math.sin(a) * spacing) / 2];
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return [
|
|
147
|
+
[-spacing / 2, -sy / 2],
|
|
148
|
+
[spacing / 2, -sy / 2],
|
|
149
|
+
[spacing / 2, sy / 2],
|
|
150
|
+
[-spacing / 2, sy / 2],
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Generator parameters from the module's interfaces and its config's constraints (design values). */
|
|
155
|
+
export function buildParams(def: ModuleDef, name: string, constraints: Record<string, unknown> = {}): { params?: PlateParams; problems: string[] } {
|
|
156
|
+
const meta = mechanical(def);
|
|
157
|
+
const fc = constraints;
|
|
158
|
+
const T = num(meta.plate_thickness_mm);
|
|
159
|
+
const problems: string[] = [];
|
|
160
|
+
if (T === undefined || T <= 0) return { problems: [`${def.id}: mechanical metadata plate_thickness_mm must be a positive number`] };
|
|
161
|
+
const opt = (key: keyof typeof DEFAULTS) => num(fc[`${key}_mm`]) ?? DEFAULTS[key];
|
|
162
|
+
const padIds = Array.isArray(fc.pads) ? (fc.pads as string[]) : undefined;
|
|
163
|
+
const clampHeight = num(fc.clamp_height_mm);
|
|
164
|
+
|
|
165
|
+
const interfaces: PlateInterface[] = [];
|
|
166
|
+
for (const i of def.interfaces) {
|
|
167
|
+
const trait = i.traits?.find((t) => t.type === "bolt_pattern");
|
|
168
|
+
const isPattern = trait || i.protocols.some((p) => p.type === "bolt_pattern");
|
|
169
|
+
if (!isPattern) continue;
|
|
170
|
+
const frame = i.geometry?.frame;
|
|
171
|
+
if (!frame) {
|
|
172
|
+
problems.push(`${def.id}.${i.id}: bolt pattern has no frame, so it cannot be placed (add geometry.frame)`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const shape = (trait?.params?.shape as PlateInterface["shape"]) ?? "square";
|
|
176
|
+
const spacing = param(i, "hole_spacing");
|
|
177
|
+
const d = param(i, "fastener_diameter");
|
|
178
|
+
if (spacing === undefined || d === undefined) {
|
|
179
|
+
problems.push(`${def.id}.${i.id}: needs hole_spacing and fastener_diameter parameters`);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const sy = param(i, "hole_spacing_y") ?? null;
|
|
183
|
+
const count = param(i, "hole_count") ?? 4;
|
|
184
|
+
const n = norm(frame.normal);
|
|
185
|
+
let x = norm(frame.xAxis ?? (Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]));
|
|
186
|
+
const y = cross(n, x);
|
|
187
|
+
x = cross(y, n);
|
|
188
|
+
const o = frame.origin;
|
|
189
|
+
let placement: Placement;
|
|
190
|
+
let height = 0;
|
|
191
|
+
if (Math.abs(n[2]) > 0.999) {
|
|
192
|
+
const pad = padIds ? padIds.includes(i.id) : shape === "circle" && n[2] > 0;
|
|
193
|
+
if (pad) {
|
|
194
|
+
placement = "pad";
|
|
195
|
+
height = num(fc.pad_height_mm) ?? DEFAULTS.pad_height;
|
|
196
|
+
} else placement = n[2] > 0 ? "holes_top" : "holes_bottom";
|
|
197
|
+
} else if (Math.abs(n[2]) < 1e-3 && Math.abs(x[2]) < 1e-3) {
|
|
198
|
+
placement = "clamp";
|
|
199
|
+
height = clampHeight ?? o[2] - T;
|
|
200
|
+
if (!(sy && sy > 0)) problems.push(`${def.id}.${i.id}: a side clamp (horizontal normal) needs hole_spacing_y, the clamped width`);
|
|
201
|
+
} else {
|
|
202
|
+
problems.push(`${def.id}.${i.id}: frame normal [${n.map((v) => v.toFixed(2))}] is neither vertical nor horizontal; frame-cad generates plates and vertical side clamps only`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const holes = placement === "clamp" ? [] : localHoles(shape, spacing, sy ?? spacing, count).map(([hx, hy]) => [r6(o[0] + hx * x[0] + hy * y[0]), r6(o[1] + hx * x[1] + hy * y[1])] as [number, number]);
|
|
206
|
+
interfaces.push({
|
|
207
|
+
id: i.id,
|
|
208
|
+
shape,
|
|
209
|
+
spacing,
|
|
210
|
+
spacing_y: sy,
|
|
211
|
+
hole_count: count,
|
|
212
|
+
fastener_d: d,
|
|
213
|
+
placement,
|
|
214
|
+
origin: [o[0], o[1]],
|
|
215
|
+
normal: [r6(n[0]), r6(n[1]), r6(n[2])],
|
|
216
|
+
x_axis: [r6(x[0]), r6(x[1]), r6(x[2])],
|
|
217
|
+
symmetry: frame.symmetryDeg ?? null,
|
|
218
|
+
height: r6(height),
|
|
219
|
+
holes,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (!interfaces.length) problems.push(`${def.id}: no bolt-pattern interfaces with frames; nothing to generate`);
|
|
223
|
+
const outline = fc.outline_mm as { x: [number, number]; y: [number, number] } | undefined;
|
|
224
|
+
const slots = (fc.slots_mm ?? []) as { center: [number, number]; size: [number, number] }[];
|
|
225
|
+
return {
|
|
226
|
+
problems,
|
|
227
|
+
params: {
|
|
228
|
+
schema: PARAMS_SCHEMA,
|
|
229
|
+
module: def.id,
|
|
230
|
+
name,
|
|
231
|
+
plate: {
|
|
232
|
+
thickness: T,
|
|
233
|
+
margin: opt("margin"),
|
|
234
|
+
corner_radius: opt("corner_radius"),
|
|
235
|
+
arm_width: opt("arm_width"),
|
|
236
|
+
arm_slot_width: opt("arm_slot_width"),
|
|
237
|
+
hub_extra: opt("hub_extra"),
|
|
238
|
+
pad_margin: opt("pad_margin"),
|
|
239
|
+
pad_center_relief: opt("pad_center_relief"),
|
|
240
|
+
hole_clearance: opt("hole_clearance"),
|
|
241
|
+
clamp_wall: opt("clamp_wall"),
|
|
242
|
+
clamp_margin: opt("clamp_margin"),
|
|
243
|
+
outline: outline ?? null,
|
|
244
|
+
slots,
|
|
245
|
+
},
|
|
246
|
+
interfaces,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Frames as the generator writes them: interface id → frame. */
|
|
252
|
+
export type FramesFile = Record<string, { origin: Vec3; normal: Vec3; xAxis?: Vec3; symmetryDeg?: number }>;
|
|
253
|
+
|
|
254
|
+
export const sameVec = (a: number[] | undefined, b: number[] | undefined, tol = 0.01) =>
|
|
255
|
+
!!a && !!b && a.length === b.length && a.every((v, k) => Math.abs(v - b[k]) <= tol);
|