@protoboardai/ext-docs 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 +18 -0
- package/README.md +78 -2
- package/evals/build-datasheet/case.json +147 -0
- package/fonts/KodeMono-Bold.ttf +0 -0
- package/fonts/KodeMono-Medium.ttf +0 -0
- package/fonts/KodeMono-Regular.ttf +0 -0
- package/fonts/KodeMono-SemiBold.ttf +0 -0
- package/fonts/OFL.txt +93 -0
- package/lib/blocks.ts +268 -0
- package/lib/bom.ts +126 -0
- package/lib/build.ts +202 -0
- package/lib/charts.ts +241 -0
- package/lib/context.ts +122 -0
- package/lib/derived.ts +181 -0
- package/lib/docs/assembly.ts +674 -0
- package/lib/docs/common.ts +81 -0
- package/lib/docs/datasheet.ts +875 -0
- package/lib/docs/product.ts +45 -0
- package/lib/figures.ts +195 -0
- package/lib/format.ts +221 -0
- package/lib/html.ts +135 -0
- package/lib/model.ts +312 -0
- package/lib/package/assembly.ts +268 -0
- package/lib/package/common.ts +109 -0
- package/lib/package/plan.ts +432 -0
- package/lib/package/spec.ts +203 -0
- package/lib/png.ts +91 -0
- package/lib/render/cdp.ts +5 -0
- package/lib/render/page/render.html +17 -0
- package/lib/render/page/render.js +390 -0
- package/lib/render/renderer.ts +161 -0
- package/lib/render/server.ts +105 -0
- package/lib/standin.ts +36 -0
- package/lib/standins/multirotor.ts +283 -0
- package/lib/templates/doc.css +247 -0
- package/lib/templates/flow.js +131 -0
- package/lib/testdata.ts +85 -0
- package/lib/types.ts +120 -0
- package/lib/values.ts +309 -0
- package/lib/verify.ts +276 -0
- package/ops/docs.ts +52 -0
- package/ops/package.ts +9 -0
- package/package.json +51 -4
- package/protoboard-extension.json +83 -0
- package/skills/uhd-tech-docs/SKILL.md +234 -0
- package/skills/uhd-tech-docs/references/docspec.md +56 -0
- package/skills/uhd-tech-docs/references/house-style.md +50 -0
- package/skills/uhd-tech-docs/references/style-study.md +270 -0
- package/skills/uhd-tech-docs/references/test-data.md +49 -0
- package/skills/uhd-tech-docs/references/value-queries.md +67 -0
- package/skills/uhd-tech-docs/templates/test-data.example.json +33 -0
- package/skills/uhd-tech-docs/templates/test-data.schema.json +213 -0
- package/skills/uhd-tech-docs-verify/SKILL.md +104 -0
- package/skills/uhd-tech-docs-verify/references/visual-review.md +43 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared pieces of ext-docs' Manufacturing Package sections (the spec sheet
|
|
3
|
+
* and assembly guide of each unit, @protoboardai/ext-release section
|
|
4
|
+
* contract): the document context of the project, plain-text model values,
|
|
5
|
+
* what one unit covers, and part thumbnails rendered through the release.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import type { ModuleDef } from "@deltarobotics/uhd";
|
|
9
|
+
import type { Project } from "@protoboardai/core";
|
|
10
|
+
import type { RenderedFigure, SectionContext, Unit } from "@protoboardai/ext-release";
|
|
11
|
+
import { buildContext, type BuiltContext } from "../context.ts";
|
|
12
|
+
import { ISO, isolated } from "../figures.ts";
|
|
13
|
+
import { formatValue } from "../format.ts";
|
|
14
|
+
import { category, featureNames, glbFor } from "../model.ts";
|
|
15
|
+
import type { StepScope } from "../docs/assembly.ts";
|
|
16
|
+
|
|
17
|
+
const contexts = new WeakMap<Project, Promise<BuiltContext>>();
|
|
18
|
+
|
|
19
|
+
/** The document context of the project (model, assembly, wiring, values), built once per loaded project. */
|
|
20
|
+
export function docContext(p: Project): Promise<BuiltContext> {
|
|
21
|
+
let c = contexts.get(p);
|
|
22
|
+
if (!c) {
|
|
23
|
+
c = buildContext(p);
|
|
24
|
+
contexts.set(p, c);
|
|
25
|
+
}
|
|
26
|
+
return c;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A model value as plain text, formatted as the technical documents format it. */
|
|
30
|
+
export const textValue = (ctx: BuiltContext) => (q: string, f?: string) => {
|
|
31
|
+
const r = ctx.resolve(q);
|
|
32
|
+
const text = r.status === "gap" && (r.value === undefined || r.value === null) ? "—" : formatValue(r.value, r.unit, f);
|
|
33
|
+
return text.replace(/ /g, " ");
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const parentOf = (id: string) => (id.includes("/") ? id.slice(0, id.lastIndexOf("/")) : "");
|
|
37
|
+
|
|
38
|
+
export interface UnitScope extends StepScope {
|
|
39
|
+
/** The representative instance path of one build ("" for the root). */
|
|
40
|
+
own: string;
|
|
41
|
+
/** An instance path in one build of the unit (sub-units included: they arrive built). */
|
|
42
|
+
inBuild: (path: string) => boolean;
|
|
43
|
+
/** An instance path inside a sub-unit (built in its own folder). */
|
|
44
|
+
inSub: (path: string) => boolean;
|
|
45
|
+
/** The sub-unit instance a path is in. */
|
|
46
|
+
subOf: (path: string) => string | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What one unit covers: the links declared by its own module and by the
|
|
51
|
+
* members folded into it (not those of sub-units, which are built first),
|
|
52
|
+
* and placements inside one build. A module unit built several times is
|
|
53
|
+
* described by its representative build. Links a board declares (its nets)
|
|
54
|
+
* are made by its copper, so they are not steps.
|
|
55
|
+
*/
|
|
56
|
+
export function unitScope(u: Unit, p?: Project): UnitScope {
|
|
57
|
+
const own = u.kind === "root" ? "" : u.representative;
|
|
58
|
+
// a board's links are its nets: the copper makes them, not an assembly step
|
|
59
|
+
const board = (def: string) => !!p?.capabilities.some((b) => b.capability === "circuit" && b.module === def && !b.problem);
|
|
60
|
+
const declarers = new Set([...(board(u.definition) ? [] : [own]), ...u.members.filter((m) => !m.unit && !board(m.definition)).map((m) => m.instance)]);
|
|
61
|
+
const subs = u.members.filter((m) => m.unit).map((m) => m.instance);
|
|
62
|
+
const under = (path: string, at: string) => at === "" || path === at || path.startsWith(at + "/");
|
|
63
|
+
const subOf = (path: string) => subs.find((s) => under(path, s));
|
|
64
|
+
const inBuild = (path: string) => under(path, own);
|
|
65
|
+
return {
|
|
66
|
+
own,
|
|
67
|
+
inBuild,
|
|
68
|
+
inSub: (path) => subOf(path) !== undefined,
|
|
69
|
+
subOf,
|
|
70
|
+
link: (id) => declarers.has(parentOf(id)),
|
|
71
|
+
// a unit is placed by the unit it goes into (a board seated in the case), not in its own build
|
|
72
|
+
place: (path) => inBuild(path) && path !== own && (subOf(path) === undefined || subs.includes(path)),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A module's body GLB as a served path, when its file is in this checkout. */
|
|
77
|
+
export function bodyGlb(ctx: BuiltContext, def: ModuleDef): string | undefined {
|
|
78
|
+
const glb = glbFor(def);
|
|
79
|
+
const file = glb?.filePath ? ctx.sys.file(def, glb.filePath) : undefined;
|
|
80
|
+
if (!glb?.filePath || !file || !existsSync(file)) return undefined;
|
|
81
|
+
return ctx.sys.served(def, glb.filePath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A thumbnail of a module on its own (line art), rendered once per release; undefined without a body file. */
|
|
85
|
+
export async function thumbnail(ctx: BuiltContext, s: SectionContext, def: ModuleDef): Promise<RenderedFigure | undefined> {
|
|
86
|
+
const served = bodyGlb(ctx, def);
|
|
87
|
+
if (!served) return undefined;
|
|
88
|
+
const glb = glbFor(def)!;
|
|
89
|
+
const cat = category(def);
|
|
90
|
+
return s.figure(`part-${def.id}`, {
|
|
91
|
+
width: 360,
|
|
92
|
+
height: 360,
|
|
93
|
+
mode: "lineart",
|
|
94
|
+
lineScale: 1.25,
|
|
95
|
+
bodies: isolated(def, served, featureNames(ctx.sys, def, glb), cat, glb.id.startsWith("cad_vendor")),
|
|
96
|
+
camera: { dir: cat === "prop" || cat === "frame" || cat === "top_plate" ? [0.8, -1, 1.4] : ISO, margin: 0.08 },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Why a module has no thumbnail (shown in its place). */
|
|
101
|
+
export function noImage(ctx: BuiltContext, def: ModuleDef): string {
|
|
102
|
+
const glb = glbFor(def);
|
|
103
|
+
if (def.kind === "harness") return "cable / lead";
|
|
104
|
+
if (glb?.filePath) return "CAD file not available";
|
|
105
|
+
return "no CAD";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Safe figure-name fragment from an id. */
|
|
109
|
+
export const slug = (s: string) => s.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "x";
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assembly plan of one Manufacturing Package unit: the technical
|
|
3
|
+
* documents' step planner (doc spec steps, then automatic steps for what the
|
|
4
|
+
* spec leaves out) scoped to the links the unit makes, with each step's
|
|
5
|
+
* parts, actions, tools, materials, torque, notes and connections as plain
|
|
6
|
+
* text. The unit's assembly guide draws it (./assembly.ts); the release's
|
|
7
|
+
* work order numbers the same steps as its assembly operations
|
|
8
|
+
* (`@protoboardai/ext-docs/plan`), so both documents follow one sequence.
|
|
9
|
+
*/
|
|
10
|
+
import type { ChildModuleRef, ModuleDef, WiringStep } from "@deltarobotics/uhd";
|
|
11
|
+
import { jointTorques, looseConnectors, propMounts, toolLabel, toolsFor } from "@deltarobotics/uhd";
|
|
12
|
+
import type { Project } from "@protoboardai/core";
|
|
13
|
+
import type { Unit } from "@protoboardai/ext-release";
|
|
14
|
+
import { leafOf, parseEnd } from "../blocks.ts";
|
|
15
|
+
import type { BuiltContext } from "../context.ts";
|
|
16
|
+
import { connectorOf, groupBy, instanceLabelOf, jointActions, planSteps, shortName, type StepPlan } from "../docs/assembly.ts";
|
|
17
|
+
import { docContext, textValue, unitScope, type UnitScope } from "./common.ts";
|
|
18
|
+
|
|
19
|
+
export interface PlannedPart {
|
|
20
|
+
/** Grouping key: definition id, or the instance path of an unplaced part. */
|
|
21
|
+
key: string;
|
|
22
|
+
def: ModuleDef;
|
|
23
|
+
count: number;
|
|
24
|
+
/** Letter in the parts strip and on the figure. */
|
|
25
|
+
letter: string;
|
|
26
|
+
/** Hardware (drawn in the accent colour). */
|
|
27
|
+
accent: boolean;
|
|
28
|
+
/** Figure anchor id (`body:<key>` or `hw:<key>`), for parts drawn in the figure. */
|
|
29
|
+
anchor?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Connection {
|
|
33
|
+
link: string;
|
|
34
|
+
name: string;
|
|
35
|
+
from: string;
|
|
36
|
+
to: string;
|
|
37
|
+
crossover: boolean;
|
|
38
|
+
termination: string;
|
|
39
|
+
state: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PlannedStep {
|
|
43
|
+
/** Number within the unit (1…). */
|
|
44
|
+
n: number;
|
|
45
|
+
/** The doc spec step id, or auto-… for a step the planner added. */
|
|
46
|
+
id: string;
|
|
47
|
+
title: string;
|
|
48
|
+
/**
|
|
49
|
+
* What the step mostly does: mates (with fastener stacks), placing parts, making connections, or preparing a
|
|
50
|
+
* part before it is fitted (soldering the connectors it ships with loose, such as a shield's header strips).
|
|
51
|
+
*/
|
|
52
|
+
kind: "mate" | "place" | "connect" | "prepare";
|
|
53
|
+
plan: StepPlan;
|
|
54
|
+
parts: PlannedPart[];
|
|
55
|
+
actions: string[];
|
|
56
|
+
tools: string[];
|
|
57
|
+
materials: string[];
|
|
58
|
+
/** Tightening torque of each part turned, from the fastener stacks ("" when no hardware). */
|
|
59
|
+
torque: string;
|
|
60
|
+
notes: { level: "tip" | "caution" | "warning"; text: string }[];
|
|
61
|
+
connections: Connection[];
|
|
62
|
+
/** Connections made by soldering (pads, bare leads). */
|
|
63
|
+
solder: boolean;
|
|
64
|
+
/** Links the step covers (mates, wiring) and the hardware placed. */
|
|
65
|
+
trace: string[];
|
|
66
|
+
/** Part of a doc spec step whose other links are made in another unit. */
|
|
67
|
+
split: boolean;
|
|
68
|
+
/** Instance paths of the cables the step's connections run through (harness instances). */
|
|
69
|
+
cables: string[];
|
|
70
|
+
/** Instance paths of the modules at the other ends of those connections. */
|
|
71
|
+
ends: string[];
|
|
72
|
+
/** Conductors the step's connections make (a link without a pin table counts once), in `links` links. */
|
|
73
|
+
conductors: number;
|
|
74
|
+
links: number;
|
|
75
|
+
/** "16 conductors in 2 links", the count the actions and the joint check both use. */
|
|
76
|
+
extent: string;
|
|
77
|
+
/**
|
|
78
|
+
* Why the step comes after the unit's bench test and configuration (the
|
|
79
|
+
* work order runs the test first and a final check after): it fits
|
|
80
|
+
* propellers, or the doc spec marks it `after: "test"`. Undefined for
|
|
81
|
+
* ordinary steps. Such steps are planned last.
|
|
82
|
+
*/
|
|
83
|
+
afterTest?: string;
|
|
84
|
+
/** The actions name a different variant per build (a unit built several times whose builds differ). */
|
|
85
|
+
perBuild: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One build of a unit built several times (a module unit), and what its instance sets: spin, overrides. */
|
|
89
|
+
export interface Build {
|
|
90
|
+
path: string;
|
|
91
|
+
name: string;
|
|
92
|
+
/** Field → value as text ("spin" → "CCW", "overrides.<key>" → JSON). */
|
|
93
|
+
fields: Record<string, string>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A field in which the builds of one unit differ. */
|
|
97
|
+
export interface BuildVariant {
|
|
98
|
+
field: string;
|
|
99
|
+
values: { build: string; path: string; value: string }[];
|
|
100
|
+
/** A step's actions name the value per build (propellers by spin). */
|
|
101
|
+
handled: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface UnitPlan {
|
|
105
|
+
ctx: BuiltContext;
|
|
106
|
+
scope: UnitScope;
|
|
107
|
+
steps: PlannedStep[];
|
|
108
|
+
/** The builds of a unit built several times (empty for one build). */
|
|
109
|
+
builds: Build[];
|
|
110
|
+
/** Where those builds differ; a variant no step handles needs checking per build. */
|
|
111
|
+
variants: BuildVariant[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
115
|
+
const SOLDER = /solder|bare_wire|pad/;
|
|
116
|
+
|
|
117
|
+
/** The unit's steps, in assembly order: the doc spec's order, with the steps that follow the bench test last. */
|
|
118
|
+
export async function unitPlan(p: Project, u: Unit): Promise<UnitPlan> {
|
|
119
|
+
const ctx = await docContext(p);
|
|
120
|
+
const scope = unitScope(u, p);
|
|
121
|
+
const plans = planSteps({ ctx }, scope);
|
|
122
|
+
const full = planSteps({ ctx });
|
|
123
|
+
const v = textValue(ctx);
|
|
124
|
+
const builds = buildsOf(p, u);
|
|
125
|
+
const described = plans.map((plan) => describe(ctx, plan, v, full, scope, builds));
|
|
126
|
+
const steps = [...prepareSteps(described), ...described.filter((x) => !x.afterTest), ...described.filter((x) => x.afterTest)];
|
|
127
|
+
steps.forEach((x, i) => (x.n = i + 1));
|
|
128
|
+
return { ctx, scope, steps, builds, variants: variantsOf(builds, steps) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const CONNECTOR_WORDS: Record<string, string> = {
|
|
132
|
+
pin_header: "header strips",
|
|
133
|
+
arduino_r3_shield_header: "header strips",
|
|
134
|
+
socket_header: "socket headers",
|
|
135
|
+
screw_terminal: "screw terminals",
|
|
136
|
+
terminal_block: "terminal blocks",
|
|
137
|
+
};
|
|
138
|
+
const connectorWords = (type: string) => CONNECTOR_WORDS[type] ?? (/^pin_header/.test(type) ? "pin headers" : `${type.replace(/_/g, " ")} connectors`);
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Parts that ship with connectors loose (UHD `connector` trait `supplied: "loose"`, a shield's header strips):
|
|
142
|
+
* one soldering step each, first in the unit (before any step fits the part), in the order the steps use them.
|
|
143
|
+
*/
|
|
144
|
+
function prepareSteps(steps: PlannedStep[]): PlannedStep[] {
|
|
145
|
+
const seen = new Set<string>();
|
|
146
|
+
const out: PlannedStep[] = [];
|
|
147
|
+
for (const st of steps)
|
|
148
|
+
for (const part of st.parts) {
|
|
149
|
+
if (seen.has(part.def.id)) continue;
|
|
150
|
+
seen.add(part.def.id);
|
|
151
|
+
const loose = looseConnectors(part.def);
|
|
152
|
+
if (!loose.length) continue;
|
|
153
|
+
const name = shortName(part.def);
|
|
154
|
+
const kinds = [...new Set(loose.map((l) => connectorWords(l.connectorType)))];
|
|
155
|
+
const what = kinds.length > 1 ? `${kinds.slice(0, -1).join(", ")} and ${kinds[kinds.length - 1]}` : kinds[0];
|
|
156
|
+
const notes = [...new Set(loose.map((l) => l.note?.replace(/\.$/, "")).filter((n): n is string => Boolean(n)))];
|
|
157
|
+
const id = `auto-prepare-${part.def.id}`;
|
|
158
|
+
const title = `Solder the ${what} to the ${name}`;
|
|
159
|
+
out.push({
|
|
160
|
+
n: 0,
|
|
161
|
+
id,
|
|
162
|
+
title,
|
|
163
|
+
kind: "prepare",
|
|
164
|
+
plan: { n: 0, spec: { id, title }, mates: [], wiring: [], moving: [], placed: [], hardware: [], extras: [] },
|
|
165
|
+
parts: [{ key: part.def.id, def: part.def, count: part.count, letter: "A", accent: false }],
|
|
166
|
+
actions: [
|
|
167
|
+
`The ${name} ships with its ${what} loose${notes.length ? ` (${notes.join("; ")})` : ""}: solder them before the part is fitted${part.count > 1 ? `, on each of the ${part.count}` : ""}.`,
|
|
168
|
+
`Seat each strip square in its holes from the side the part's drawing shows, solder one pin at each end, check the strip is square to the board, then solder the rest.`,
|
|
169
|
+
],
|
|
170
|
+
tools: ["soldering iron"],
|
|
171
|
+
materials: ["solder and flux"],
|
|
172
|
+
torque: "",
|
|
173
|
+
notes: [],
|
|
174
|
+
connections: [],
|
|
175
|
+
solder: true,
|
|
176
|
+
trace: [`part ${part.def.id}: connectors supplied loose (${[...new Set(loose.map((l) => l.iface.id))].length} interfaces)`],
|
|
177
|
+
split: false,
|
|
178
|
+
cables: [],
|
|
179
|
+
ends: [],
|
|
180
|
+
conductors: 0,
|
|
181
|
+
links: 0,
|
|
182
|
+
extent: `the ${what}`,
|
|
183
|
+
perBuild: false,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const parentPath = (path: string) => (path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "");
|
|
190
|
+
|
|
191
|
+
/** The child entry that declares an instance (in its parent's definition). */
|
|
192
|
+
function childRef(p: Project, path: string): ChildModuleRef | undefined {
|
|
193
|
+
const parent = parentPath(path);
|
|
194
|
+
const def = parent === "" ? p.rootDef : p.lookup(p.instances.find((i) => i.path === parent)?.definition ?? "");
|
|
195
|
+
const id = path.slice(path.lastIndexOf("/") + 1);
|
|
196
|
+
return def?.children?.find((c) => c.id === id);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The builds of a unit named by module id and built several times, with
|
|
201
|
+
* what each instance's context sets: its own overrides, and the spin it
|
|
202
|
+
* has or inherits from a group above it. The definition is the same, so
|
|
203
|
+
* these are the only ways two builds can differ.
|
|
204
|
+
*/
|
|
205
|
+
function buildsOf(p: Project, u: Unit): Build[] {
|
|
206
|
+
if (u.instances.length < 2) return [];
|
|
207
|
+
return u.instances.map((path) => {
|
|
208
|
+
const fields: Record<string, string> = {};
|
|
209
|
+
for (const [k, val] of Object.entries(childRef(p, path)?.overrides ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) fields[`overrides.${k}`] = JSON.stringify(val);
|
|
210
|
+
for (let at = path; at && !fields.spin; at = parentPath(at)) {
|
|
211
|
+
const spin = childRef(p, at)?.spin;
|
|
212
|
+
if (spin) fields.spin = spin.toUpperCase();
|
|
213
|
+
}
|
|
214
|
+
const inst = p.instances.find((i) => i.path === path);
|
|
215
|
+
return { path, name: inst?.name ?? childRef(p, path)?.name ?? path, fields };
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function variantsOf(builds: Build[], steps: PlannedStep[]): BuildVariant[] {
|
|
220
|
+
const fields = [...new Set(builds.flatMap((b) => Object.keys(b.fields)))];
|
|
221
|
+
return fields
|
|
222
|
+
.filter((f) => new Set(builds.map((b) => b.fields[f] ?? "(not set)")).size > 1)
|
|
223
|
+
.map((field) => ({
|
|
224
|
+
field,
|
|
225
|
+
values: builds.map((b) => ({ build: b.name, path: b.path, value: b.fields[field] ?? "(not set)" })),
|
|
226
|
+
handled: field === "spin" && steps.some((x) => x.perBuild),
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function describe(ctx: BuiltContext, p: StepPlan, v: (q: string, f?: string) => string, full: StepPlan[], scope: UnitScope, builds: Build[]): PlannedStep {
|
|
231
|
+
const s = p.spec;
|
|
232
|
+
const own = scope.own;
|
|
233
|
+
const rel = (id: string) => (own && id.startsWith(own + "/") ? id.slice(own.length + 1) : id);
|
|
234
|
+
// ---- parts: moving modules, placed modules, extras, hardware by part, cables of the wiring
|
|
235
|
+
const parts: PlannedPart[] = [];
|
|
236
|
+
for (const [id, bs] of groupBy([...p.moving, ...p.placed], (b) => b.def.id)) {
|
|
237
|
+
const main = bs.find((b) => !b.key.includes("#")) ?? bs[0];
|
|
238
|
+
parts.push({ key: id, def: bs[0].def, count: new Set(bs.map((b) => b.path.join("/"))).size, letter: "", anchor: `body:${main.key}`, accent: false });
|
|
239
|
+
}
|
|
240
|
+
for (const x of p.extras) parts.push({ key: x.path, def: x.def, count: 1, letter: "", accent: false, ...(x.def.kind === "harness" ? { anchor: `harness:${x.path}` } : {}) });
|
|
241
|
+
for (const [, hs] of groupBy(p.hardware, (h) => h.def.id)) parts.push({ key: hs[0].def.id, def: hs[0].def, count: hs.length, letter: "", anchor: `hw:${hs[0].key}`, accent: true });
|
|
242
|
+
for (const [h, ws] of groupBy(p.wiring.filter((w) => w.harness), (w) => w.harness!)) {
|
|
243
|
+
const def = ctx.instances.get(h);
|
|
244
|
+
if (!def) continue;
|
|
245
|
+
const have = parts.find((x) => x.def.id === def.id);
|
|
246
|
+
if (have) have.count = Math.max(have.count, 1);
|
|
247
|
+
else parts.push({ key: h, def, count: new Set(ws.map((w) => w.harness)).size, letter: "", anchor: `harness:${h}`, accent: false });
|
|
248
|
+
}
|
|
249
|
+
parts.forEach((x, i) => (x.letter = LETTERS[i] ?? String(i + 1)));
|
|
250
|
+
|
|
251
|
+
// ---- actions: joints in stacking order, props by handedness, placements, connections
|
|
252
|
+
const fillText = (t: string) => t.replace(/\{\{([^|}]+)(?:\|([^}]+))?\}\}/g, (_m, q: string, f?: string) => v(q.trim(), f?.trim()));
|
|
253
|
+
const actions = [...(s.before ?? []).map(fillText), ...jointActions({ ctx }, p, { v, e: (x) => x })];
|
|
254
|
+
const allMounts = propMounts(ctx.sys.system, ctx.sys.lookup);
|
|
255
|
+
const mounts = allMounts.filter((m) => p.mates.some((x) => x.linkId === m.linkId));
|
|
256
|
+
let perBuild = false;
|
|
257
|
+
for (const m of mounts) {
|
|
258
|
+
const prop = shortName(m.prop.def);
|
|
259
|
+
if (builds.length > 1) {
|
|
260
|
+
// one plan for every build: the propeller's hand comes from the position each build goes to
|
|
261
|
+
const per = builds.map((b) => ({ b, spin: allMounts.find((x) => x.linkId === `${b.path}/${rel(m.linkId)}`)?.motor.spin?.toUpperCase() ?? "?" }));
|
|
262
|
+
if (new Set(per.map((x) => x.spin)).size === 1) actions.push(`Every build: the motor spins ${per[0].spin} seen from above, so fit a ${per[0].spin} ${prop}.`);
|
|
263
|
+
else {
|
|
264
|
+
perBuild = true;
|
|
265
|
+
const counts = [...new Set(per.map((x) => x.spin))].map((sp) => `${per.filter((x) => x.spin === sp).length} with ${sp}`).join(", ");
|
|
266
|
+
actions.push(`The builds differ (${counts} propellers): the hand depends on the position each build goes to. Mark each build with its position, then fit its propeller:`);
|
|
267
|
+
for (const x of per) actions.push(`${x.b.name} (${x.b.path}): the motor spins ${x.spin} seen from above, so fit a ${x.spin} ${prop}.`);
|
|
268
|
+
}
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const arm = m.motor.setAt ?? m.motor.path.split("/")[0];
|
|
272
|
+
const armName = ctx.sys.system.children?.find((c) => c.id === arm.split("/")[0])?.name ?? arm;
|
|
273
|
+
const spin = v(`sys:system.children[id=${arm.split("/")[0]}].spin`, "upper");
|
|
274
|
+
actions.push(`${armName}: the motor spins ${spin} seen from above, so fit a ${spin} ${prop}.`);
|
|
275
|
+
}
|
|
276
|
+
for (const b of p.placed) actions.push(`Place the ${shortName(b.def)} as shown. It has no mechanical interface in the model; it is placed by ${b.via?.replace(/^hint:/, "") ?? "a hint"}.`);
|
|
277
|
+
for (const x of p.extras.filter(() => !p.wiring.length)) actions.push(`Fit the ${shortName(x.def)}.`);
|
|
278
|
+
for (const a of s.actions ?? []) actions.push(a);
|
|
279
|
+
|
|
280
|
+
const connections: Connection[] = [];
|
|
281
|
+
let solder = false;
|
|
282
|
+
let bare = false;
|
|
283
|
+
for (const w of p.wiring) {
|
|
284
|
+
const ea = parseEnd(w.a);
|
|
285
|
+
const eb = parseEnd(w.b);
|
|
286
|
+
const la = leafOf(ctx.sys.system, ctx.sys.lookup, ea.path, ea.iface);
|
|
287
|
+
const lb = leafOf(ctx.sys.system, ctx.sys.lookup, eb.path, eb.iface);
|
|
288
|
+
const types = [la && connectorOf(la.def, la.iface.id), lb && connectorOf(lb.def, lb.iface.id)].filter((x): x is string => Boolean(x));
|
|
289
|
+
if (types.some((t) => SOLDER.test(t))) solder = true;
|
|
290
|
+
if (types.some((t) => /bare_wire/.test(t))) bare = true;
|
|
291
|
+
const rows = w.connections.length ? w.connections : [{ from: `${w.a} ${la?.iface.pin ?? ""}`, to: `${w.b} ${lb?.iface.pin ?? ""}`, crossover: false }];
|
|
292
|
+
const pads = (sel: (c: { from: string; to: string }) => string) => rows.map((c) => parseEnd(sel(c)).pad || parseEnd(sel(c)).iface).join(" · ");
|
|
293
|
+
connections.push({
|
|
294
|
+
link: w.linkId,
|
|
295
|
+
name: w.name,
|
|
296
|
+
from: `${la ? instanceLabelOf({ ctx }, w, "a") : endLabel(ctx, w, "a")}: ${pads((c) => c.from)}`,
|
|
297
|
+
to: `${lb ? instanceLabelOf({ ctx }, w, "b") : endLabel(ctx, w, "b")}: ${pads((c) => c.to)}`,
|
|
298
|
+
crossover: rows.some((c) => c.crossover),
|
|
299
|
+
termination: types.join(" / ") || w.protocol,
|
|
300
|
+
state: w.state,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
for (const [h, ws] of groupBy(p.wiring, (w) => w.harness ?? "")) {
|
|
304
|
+
const hdef = h ? ctx.instances.get(h) : undefined;
|
|
305
|
+
actions.push(`${hdef ? `Using the ${shortName(hdef)}, connect` : "Connect"} ${ws.map((w) => w.name).join(", ")} as in the table (${extentOf(ws)}).`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ---- what the connections join: cables (harness instances) and the modules at their ends
|
|
309
|
+
const cables = new Set<string>(p.wiring.map((w) => w.harness).filter((h): h is string => Boolean(h)));
|
|
310
|
+
const ends = new Set<string>();
|
|
311
|
+
for (const w of p.wiring)
|
|
312
|
+
for (const e of ["a", "b"] as const) {
|
|
313
|
+
const at = endPath(ctx, w, e);
|
|
314
|
+
if (!at) continue;
|
|
315
|
+
if (ctx.instances.get(at)?.kind === "harness") cables.add(at);
|
|
316
|
+
else ends.add(at);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---- notes: the doc spec's, then the model's usage notes of the step's harnesses and modules
|
|
320
|
+
const notes: PlannedStep["notes"] = [];
|
|
321
|
+
const fill = (t: string) => t.replace(/\{\{([^|}]+)(?:\|([^}]+))?\}\}/g, (_m, q: string, f?: string) => v(q.trim(), f?.trim()));
|
|
322
|
+
for (const x of s.notes ?? []) notes.push({ level: x.kind, text: fill(x.text) });
|
|
323
|
+
const noteDefs = new Map<string, ModuleDef>();
|
|
324
|
+
for (const h of new Set([...p.hardware.map((x) => x.harness!), ...cables])) {
|
|
325
|
+
const d = ctx.instances.get(h);
|
|
326
|
+
if (d) noteDefs.set(d.id, d);
|
|
327
|
+
}
|
|
328
|
+
for (const b of [...p.moving, ...p.placed]) noteDefs.set(b.def.id, b.def);
|
|
329
|
+
for (const x of p.extras) noteDefs.set(x.def.id, x.def);
|
|
330
|
+
const materials = new Set<string>();
|
|
331
|
+
for (const d of noteDefs.values()) {
|
|
332
|
+
for (const t of (d.traits ?? []).filter((t) => t.type === "usage_note" && d.kind === "harness").slice(0, 2)) {
|
|
333
|
+
const tl = t.params?.threadlocker;
|
|
334
|
+
if (tl) materials.add(`threadlocker (${String(tl)} strength)`);
|
|
335
|
+
notes.push({ level: "tip", text: `${String(t.params?.note ?? "")} (${d.id})` });
|
|
336
|
+
}
|
|
337
|
+
const cooling = (d.traits ?? []).find((t) => t.type === "operating_conditions")?.params?.cooling;
|
|
338
|
+
if (cooling) notes.push({ level: "caution", text: `${String(cooling).split(". ").slice(0, 3).join(". ")}. (${d.id})` });
|
|
339
|
+
}
|
|
340
|
+
// the same note from several cables or modules once, naming each source
|
|
341
|
+
const merged: PlannedStep["notes"] = [];
|
|
342
|
+
for (const x of notes) {
|
|
343
|
+
const m = /^(.*) \(([^()]+)\)$/.exec(x.text);
|
|
344
|
+
const same = m ? merged.find((y) => y.level === x.level && y.text.startsWith(m[1] + " (")) : undefined;
|
|
345
|
+
if (same && m) same.text = same.text.replace(/\)$/, `, ${m[2]})`);
|
|
346
|
+
else merged.push({ ...x });
|
|
347
|
+
}
|
|
348
|
+
notes.splice(0, notes.length, ...merged);
|
|
349
|
+
if (solder) {
|
|
350
|
+
materials.add("solder and flux");
|
|
351
|
+
if (bare) materials.add("heat-shrink");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---- torque and tools
|
|
355
|
+
const harnessDefs = [...new Map(p.hardware.map((h) => ctx.instances.get(h.harness!)).filter((d): d is ModuleDef => Boolean(d)).map((d) => [d.id, d])).values()];
|
|
356
|
+
const torque = harnessDefs
|
|
357
|
+
.flatMap((hd) => {
|
|
358
|
+
const seen = new Set<number>();
|
|
359
|
+
return jointTorques(hd, ctx.sys.lookup)
|
|
360
|
+
.filter((t) => (seen.has(t.torque.torqueNm) ? false : (seen.add(t.torque.torqueNm), true)))
|
|
361
|
+
.map((t) => `${v(`def:${hd.id}:fastenerStack[${t.index}].torque.torqueNm`, "u=N·m")}${t.basis === "assumption" ? " (assumed)" : ""} ${shortName(t.part ?? hd)}${harnessDefs.length > 1 ? ` (${hd.name})` : ""}`);
|
|
362
|
+
})
|
|
363
|
+
.join("; ");
|
|
364
|
+
const tools = toolsFor([...new Map(p.hardware.map((h) => [h.def.id, h.def])).values()]).map(toolLabel);
|
|
365
|
+
if (solder) tools.push("soldering iron");
|
|
366
|
+
|
|
367
|
+
// a unit built several times: the links of one build, made in every build
|
|
368
|
+
const each = builds.length > 1 ? ` in each build (${builds.map((b) => b.path).join(", ")})` : "";
|
|
369
|
+
const ids = (xs: string[]) => (each ? xs.map(rel) : xs).join(", ");
|
|
370
|
+
const trace = [
|
|
371
|
+
p.mates.length ? `mates ${ids(p.mates.map((m) => m.linkId))}${each}` : "",
|
|
372
|
+
p.wiring.length ? `links ${ids(p.wiring.map((w) => w.linkId))}${each}` : "",
|
|
373
|
+
].filter(Boolean);
|
|
374
|
+
const whole = full.find((f) => f.spec.id === s.id);
|
|
375
|
+
const split = !!whole && (whole.mates.length !== p.mates.length || whole.wiring.length !== p.wiring.length || whole.placed.length !== p.placed.length);
|
|
376
|
+
const afterTest =
|
|
377
|
+
s.after === "test" ? "marked after: \"test\" in the doc spec" : mounts.length ? "it fits propellers, and the bench test runs the motors with the propellers off" : undefined;
|
|
378
|
+
return {
|
|
379
|
+
n: 0,
|
|
380
|
+
id: s.id,
|
|
381
|
+
title: s.title,
|
|
382
|
+
kind: p.mates.length ? "mate" : p.placed.length || (p.extras.length && !p.wiring.length) ? "place" : "connect",
|
|
383
|
+
plan: p,
|
|
384
|
+
parts,
|
|
385
|
+
actions,
|
|
386
|
+
tools,
|
|
387
|
+
materials: [...materials],
|
|
388
|
+
torque,
|
|
389
|
+
notes,
|
|
390
|
+
connections,
|
|
391
|
+
solder,
|
|
392
|
+
trace,
|
|
393
|
+
split,
|
|
394
|
+
cables: [...cables],
|
|
395
|
+
ends: [...ends],
|
|
396
|
+
conductors: p.wiring.reduce((t, w) => t + Math.max(1, w.connections.length), 0),
|
|
397
|
+
links: p.wiring.length,
|
|
398
|
+
extent: extentOf(p.wiring),
|
|
399
|
+
...(afterTest ? { afterTest } : {}),
|
|
400
|
+
perBuild,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** "16 conductors in 2 links": the pin rows of the links' tables (a link without one counts as one connection). */
|
|
405
|
+
export function extentOf(ws: WiringStep[]): string {
|
|
406
|
+
const n = ws.reduce((t, w) => t + Math.max(1, w.connections.length), 0);
|
|
407
|
+
const noun = ws.every((w) => w.connections.length) ? "conductor" : "connection";
|
|
408
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}${ws.length > 1 ? ` in ${ws.length} links` : ""}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Name of the module at one end of a connection, when the end is reached through a group's export. */
|
|
412
|
+
function endLabel(ctx: BuiltContext, w: WiringStep, end: "a" | "b"): string {
|
|
413
|
+
const at = endPath(ctx, w, end);
|
|
414
|
+
const def = at ? ctx.instances.get(at) : undefined;
|
|
415
|
+
return def ? `${shortName(def)} (${at})` : parseEnd(end === "a" ? w.a : w.b).path;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Instance path of one end of a connection: its leaf module, through group exports and sub-assembly prefixes. */
|
|
419
|
+
export function endPath(ctx: BuiltContext, w: WiringStep, end: "a" | "b"): string | undefined {
|
|
420
|
+
const e = parseEnd(end === "a" ? w.a : w.b);
|
|
421
|
+
const prefix = w.linkId.includes("/") ? w.linkId.slice(0, w.linkId.lastIndexOf("/") + 1) : "";
|
|
422
|
+
const leaf = leafOf(ctx.sys.system, ctx.sys.lookup, e.path, e.iface);
|
|
423
|
+
if (leaf) {
|
|
424
|
+
const at = leaf.path.join("/");
|
|
425
|
+
return prefix && !at.startsWith(prefix) ? prefix + at : at;
|
|
426
|
+
}
|
|
427
|
+
// a group exports its children's interfaces: the child below the path that has the interface
|
|
428
|
+
const hit = [...ctx.instances.entries()].find(([k, d]) => (k === e.path || k.startsWith(e.path + "/")) && !d.children?.length && d.interfaces.some((i) => i.id === e.iface));
|
|
429
|
+
return hit?.[0];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export type { WiringStep };
|