napkin2factory 0.1.0__py3-none-any.whl

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.
Files changed (201) hide show
  1. napkin2factory/__init__.py +3 -0
  2. napkin2factory/bom/__init__.py +1 -0
  3. napkin2factory/bom/files.py +313 -0
  4. napkin2factory/bom/rollups.py +297 -0
  5. napkin2factory/bom/validation.py +136 -0
  6. napkin2factory/catalog/__init__.py +5 -0
  7. napkin2factory/catalog/common.yaml +76 -0
  8. napkin2factory/cli/__init__.py +1 -0
  9. napkin2factory/cli/__main__.py +5 -0
  10. napkin2factory/cli/app.py +62 -0
  11. napkin2factory/cli/build.py +115 -0
  12. napkin2factory/cli/check.py +260 -0
  13. napkin2factory/cli/common.py +76 -0
  14. napkin2factory/cli/doctor.py +124 -0
  15. napkin2factory/cli/ecr.py +195 -0
  16. napkin2factory/cli/graph.py +62 -0
  17. napkin2factory/cli/init.py +114 -0
  18. napkin2factory/cli/release.py +97 -0
  19. napkin2factory/cli/serve.py +36 -0
  20. napkin2factory/cli/snapshot.py +63 -0
  21. napkin2factory/cli/validate.py +88 -0
  22. napkin2factory/core/__init__.py +1 -0
  23. napkin2factory/core/checks/__init__.py +1 -0
  24. napkin2factory/core/checks/calcs.py +139 -0
  25. napkin2factory/core/checks/engine.py +358 -0
  26. napkin2factory/core/checks/evaluators.py +268 -0
  27. napkin2factory/core/checks/evidence.py +103 -0
  28. napkin2factory/core/checks/interface.py +160 -0
  29. napkin2factory/core/checks/interference.py +153 -0
  30. napkin2factory/core/checks/results.py +100 -0
  31. napkin2factory/core/checks/stage.py +212 -0
  32. napkin2factory/core/errors.py +118 -0
  33. napkin2factory/core/expr.py +183 -0
  34. napkin2factory/core/graph/__init__.py +1 -0
  35. napkin2factory/core/graph/export.py +98 -0
  36. napkin2factory/core/graph/hashing.py +53 -0
  37. napkin2factory/core/graph/impact.py +546 -0
  38. napkin2factory/core/graph/manifest.py +84 -0
  39. napkin2factory/core/graph/rules.py +263 -0
  40. napkin2factory/core/graph/runner.py +317 -0
  41. napkin2factory/core/graph/runs.py +249 -0
  42. napkin2factory/core/graph/values.py +152 -0
  43. napkin2factory/core/lint.py +255 -0
  44. napkin2factory/core/loader.py +836 -0
  45. napkin2factory/core/model/__init__.py +65 -0
  46. napkin2factory/core/model/assemblies.py +66 -0
  47. napkin2factory/core/model/base.py +149 -0
  48. napkin2factory/core/model/decisions.py +32 -0
  49. napkin2factory/core/model/docs.py +23 -0
  50. napkin2factory/core/model/ecr.py +92 -0
  51. napkin2factory/core/model/interfaces.py +129 -0
  52. napkin2factory/core/model/manifest.py +57 -0
  53. napkin2factory/core/model/parameters.py +37 -0
  54. napkin2factory/core/model/parts.py +131 -0
  55. napkin2factory/core/model/requirements.py +101 -0
  56. napkin2factory/core/model/signoff.py +23 -0
  57. napkin2factory/core/units.py +158 -0
  58. napkin2factory/core/uris.py +142 -0
  59. napkin2factory/core/vcs.py +283 -0
  60. napkin2factory/core/yamlio.py +60 -0
  61. napkin2factory/docs/__init__.py +1 -0
  62. napkin2factory/docs/engine.py +199 -0
  63. napkin2factory/docs/html.py +138 -0
  64. napkin2factory/docs/outputs.py +213 -0
  65. napkin2factory/docs/typst.py +418 -0
  66. napkin2factory/drawings/__init__.py +1 -0
  67. napkin2factory/drawings/dxf.py +99 -0
  68. napkin2factory/drawings/engine.py +173 -0
  69. napkin2factory/drawings/sheet.py +747 -0
  70. napkin2factory/drawings/svg.py +456 -0
  71. napkin2factory/drawings/views.py +174 -0
  72. napkin2factory/ecr/__init__.py +1 -0
  73. napkin2factory/ecr/diff.py +372 -0
  74. napkin2factory/ecr/lifecycle.py +436 -0
  75. napkin2factory/ecr/release.py +338 -0
  76. napkin2factory/geometry/__init__.py +1 -0
  77. napkin2factory/geometry/accessors.py +146 -0
  78. napkin2factory/geometry/assembly.py +282 -0
  79. napkin2factory/geometry/build.py +613 -0
  80. napkin2factory/geometry/exporters.py +162 -0
  81. napkin2factory/geometry/features.py +215 -0
  82. napkin2factory/geometry/glb.py +230 -0
  83. napkin2factory/geometry/kit/__init__.py +102 -0
  84. napkin2factory/geometry/kit/_common.py +63 -0
  85. napkin2factory/geometry/kit/_sheet.py +254 -0
  86. napkin2factory/geometry/kit/block.py +83 -0
  87. napkin2factory/geometry/kit/bracket_l.py +242 -0
  88. napkin2factory/geometry/kit/enclosure.py +369 -0
  89. napkin2factory/geometry/kit/frame.py +259 -0
  90. napkin2factory/geometry/kit/hole_pattern.py +396 -0
  91. napkin2factory/geometry/kit/panel.py +284 -0
  92. napkin2factory/geometry/kit/plate.py +126 -0
  93. napkin2factory/geometry/kit/tube.py +135 -0
  94. napkin2factory/geometry/materials.py +51 -0
  95. napkin2factory/geometry/render.py +214 -0
  96. napkin2factory/report/__init__.py +1 -0
  97. napkin2factory/report/builder.py +524 -0
  98. napkin2factory/report/inline.py +89 -0
  99. napkin2factory/report/site.py +22 -0
  100. napkin2factory/report/templates/diff.html.j2 +78 -0
  101. napkin2factory/report/templates/report.html.j2 +241 -0
  102. napkin2factory/report/vendor/three/BufferGeometryUtils.js +1373 -0
  103. napkin2factory/report/vendor/three/GLTFLoader.js +4727 -0
  104. napkin2factory/report/vendor/three/OrbitControls.js +1523 -0
  105. napkin2factory/report/vendor/three/three.module.js +54571 -0
  106. napkin2factory/report/viewer.js +236 -0
  107. napkin2factory/rulepacks/__init__.py +5 -0
  108. napkin2factory/rulepacks/checks.py +201 -0
  109. napkin2factory/rulepacks/general-v0.yaml +29 -0
  110. napkin2factory/rulepacks/runner.py +136 -0
  111. napkin2factory/rulepacks/sheet-metal-v0.yaml +44 -0
  112. napkin2factory/templates/__init__.py +11 -0
  113. napkin2factory/templates/_common/.claude/agents/n2f-impact-analyst.md +17 -0
  114. napkin2factory/templates/_common/.claude/agents/n2f-verifier.md +27 -0
  115. napkin2factory/templates/_common/.claude/skills/n2f-design/SKILL.md +59 -0
  116. napkin2factory/templates/_common/.claude/skills/n2f-pm/SKILL.md +77 -0
  117. napkin2factory/templates/_common/.github/workflows/build.yml +18 -0
  118. napkin2factory/templates/_common/.github/workflows/release.yml +26 -0
  119. napkin2factory/templates/_common/.gitignore +4 -0
  120. napkin2factory/templates/_common/AGENTS.md +55 -0
  121. napkin2factory/templates/_common/CLAUDE.md +55 -0
  122. napkin2factory/templates/blank/CHANGELOG.md +3 -0
  123. napkin2factory/templates/blank/changes/.gitkeep +0 -0
  124. napkin2factory/templates/blank/model/assemblies/.gitkeep +0 -0
  125. napkin2factory/templates/blank/model/calcs/.gitkeep +0 -0
  126. napkin2factory/templates/blank/model/decisions.yaml +2 -0
  127. napkin2factory/templates/blank/model/docs/safety.md +23 -0
  128. napkin2factory/templates/blank/model/geometry/.gitkeep +0 -0
  129. napkin2factory/templates/blank/model/intake.md +5 -0
  130. napkin2factory/templates/blank/model/interfaces/.gitkeep +0 -0
  131. napkin2factory/templates/blank/model/manufacturers/.gitkeep +0 -0
  132. napkin2factory/templates/blank/model/parameters.yaml +2 -0
  133. napkin2factory/templates/blank/model/parts/.gitkeep +0 -0
  134. napkin2factory/templates/blank/model/requirements.yaml +3 -0
  135. napkin2factory/templates/blank/model/signoffs/.gitkeep +0 -0
  136. napkin2factory/templates/blank/n2f.yaml +7 -0
  137. napkin2factory/templates/blank/operator.yaml +5 -0
  138. napkin2factory/templates/blank/reports/.gitkeep +0 -0
  139. napkin2factory/templates/bracket/.claude/agents/n2f-impact-analyst.md +17 -0
  140. napkin2factory/templates/bracket/.claude/agents/n2f-verifier.md +27 -0
  141. napkin2factory/templates/bracket/.claude/skills/n2f-design/SKILL.md +59 -0
  142. napkin2factory/templates/bracket/.claude/skills/n2f-pm/SKILL.md +77 -0
  143. napkin2factory/templates/bracket/.github/workflows/build.yml +18 -0
  144. napkin2factory/templates/bracket/.github/workflows/release.yml +26 -0
  145. napkin2factory/templates/bracket/.gitignore +4 -0
  146. napkin2factory/templates/bracket/AGENTS.md +55 -0
  147. napkin2factory/templates/bracket/CHANGELOG.md +3 -0
  148. napkin2factory/templates/bracket/CLAUDE.md +55 -0
  149. napkin2factory/templates/bracket/changes/.gitkeep +0 -0
  150. napkin2factory/templates/bracket/model/assemblies/bracket.yaml +7 -0
  151. napkin2factory/templates/bracket/model/calcs/.gitkeep +0 -0
  152. napkin2factory/templates/bracket/model/decisions.yaml +13 -0
  153. napkin2factory/templates/bracket/model/docs/installation.md +22 -0
  154. napkin2factory/templates/bracket/model/docs/safety.md +20 -0
  155. napkin2factory/templates/bracket/model/geometry/bracket.py +14 -0
  156. napkin2factory/templates/bracket/model/intake.md +7 -0
  157. napkin2factory/templates/bracket/model/interfaces/.gitkeep +0 -0
  158. napkin2factory/templates/bracket/model/manufacturers/.gitkeep +0 -0
  159. napkin2factory/templates/bracket/model/parameters.yaml +12 -0
  160. napkin2factory/templates/bracket/model/parts/l-bracket.yaml +26 -0
  161. napkin2factory/templates/bracket/model/requirements.yaml +27 -0
  162. napkin2factory/templates/bracket/model/signoffs/.gitkeep +0 -0
  163. napkin2factory/templates/bracket/n2f.yaml +8 -0
  164. napkin2factory/templates/bracket/operator.yaml +5 -0
  165. napkin2factory/templates/bracket/reports/.gitkeep +0 -0
  166. napkin2factory/templates/enclosure/.claude/agents/n2f-impact-analyst.md +17 -0
  167. napkin2factory/templates/enclosure/.claude/agents/n2f-verifier.md +27 -0
  168. napkin2factory/templates/enclosure/.claude/skills/n2f-design/SKILL.md +59 -0
  169. napkin2factory/templates/enclosure/.claude/skills/n2f-pm/SKILL.md +77 -0
  170. napkin2factory/templates/enclosure/.github/workflows/build.yml +18 -0
  171. napkin2factory/templates/enclosure/.github/workflows/release.yml +26 -0
  172. napkin2factory/templates/enclosure/.gitignore +4 -0
  173. napkin2factory/templates/enclosure/AGENTS.md +55 -0
  174. napkin2factory/templates/enclosure/CHANGELOG.md +3 -0
  175. napkin2factory/templates/enclosure/CLAUDE.md +55 -0
  176. napkin2factory/templates/enclosure/changes/.gitkeep +0 -0
  177. napkin2factory/templates/enclosure/model/assemblies/enclosure.yaml +11 -0
  178. napkin2factory/templates/enclosure/model/calcs/.gitkeep +0 -0
  179. napkin2factory/templates/enclosure/model/decisions.yaml +19 -0
  180. napkin2factory/templates/enclosure/model/docs/assembly.md +17 -0
  181. napkin2factory/templates/enclosure/model/docs/safety.md +20 -0
  182. napkin2factory/templates/enclosure/model/geometry/enclosure.py +32 -0
  183. napkin2factory/templates/enclosure/model/intake.md +6 -0
  184. napkin2factory/templates/enclosure/model/interfaces/lid-base-mount.yaml +10 -0
  185. napkin2factory/templates/enclosure/model/manufacturers/.gitkeep +0 -0
  186. napkin2factory/templates/enclosure/model/parameters.yaml +22 -0
  187. napkin2factory/templates/enclosure/model/parts/base.yaml +24 -0
  188. napkin2factory/templates/enclosure/model/parts/bracket.yaml +21 -0
  189. napkin2factory/templates/enclosure/model/parts/lid.yaml +19 -0
  190. napkin2factory/templates/enclosure/model/parts/m4x10-screw.yaml +11 -0
  191. napkin2factory/templates/enclosure/model/requirements.yaml +59 -0
  192. napkin2factory/templates/enclosure/model/signoffs/.gitkeep +0 -0
  193. napkin2factory/templates/enclosure/n2f.yaml +8 -0
  194. napkin2factory/templates/enclosure/operator.yaml +5 -0
  195. napkin2factory/templates/enclosure/reports/.gitkeep +0 -0
  196. napkin2factory/templates/sync.py +80 -0
  197. napkin2factory-0.1.0.dist-info/METADATA +162 -0
  198. napkin2factory-0.1.0.dist-info/RECORD +201 -0
  199. napkin2factory-0.1.0.dist-info/WHEEL +4 -0
  200. napkin2factory-0.1.0.dist-info/entry_points.txt +2 -0
  201. napkin2factory-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """napkin2factory: compile a text-only product model into a manufacturable package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Bill of materials: rollups, validation tiers, CSV/XLSX, curated catalog."""
@@ -0,0 +1,313 @@
1
+ """BOM files: validation (D15), flat CSV and a formatted XLSX workbook (D18, D17).
2
+
3
+ The rollup numbers are written as values, never formulas, so readers that do not
4
+ calculate (CSV importers, ``openpyxl`` with ``data_only``) see the same totals as
5
+ ``rollups.json``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import csv
11
+ import json
12
+ from collections.abc import Iterator
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ if TYPE_CHECKING:
17
+ from napkin2factory.core.graph.rules import RunContext
18
+
19
+ COLUMNS = [
20
+ "level",
21
+ "uri",
22
+ "id",
23
+ "rev",
24
+ "name",
25
+ "kind",
26
+ "qty",
27
+ "total_qty",
28
+ "process",
29
+ "material",
30
+ "finish",
31
+ "tolerance_class",
32
+ "unit_cost_usd",
33
+ "ext_cost_usd",
34
+ "lead_time_days",
35
+ "mass_kg",
36
+ "sourcing_status",
37
+ "source",
38
+ "manufacturer",
39
+ ]
40
+
41
+ TOTALS = [
42
+ ("total_cost_usd", "total_cost"),
43
+ ("total_mass_kg", "total_mass"),
44
+ ("part_count", "part_count"),
45
+ ("custom_count", "custom_count"),
46
+ ("custom_ratio", "custom_ratio"),
47
+ ("max_lead_time_days", "max_lead_time"),
48
+ ("cost_complete", "cost_complete"),
49
+ ("mass_complete", "mass_complete"),
50
+ ]
51
+
52
+ SOURCING_NOTE = (
53
+ "Unverified sources are marked in sourcing_status; costs are declared estimates unless "
54
+ "basis says quote"
55
+ )
56
+
57
+ _WIDTHS = {
58
+ "level": 6,
59
+ "uri": 24,
60
+ "id": 16,
61
+ "rev": 6,
62
+ "name": 32,
63
+ "kind": 10,
64
+ "qty": 6,
65
+ "total_qty": 9,
66
+ "process": 16,
67
+ "material": 22,
68
+ "finish": 18,
69
+ "tolerance_class": 14,
70
+ "unit_cost_usd": 13,
71
+ "ext_cost_usd": 13,
72
+ "lead_time_days": 14,
73
+ "mass_kg": 10,
74
+ "sourcing_status": 15,
75
+ "source": 28,
76
+ "manufacturer": 60,
77
+ }
78
+
79
+
80
+ def iter_lines(rollups: dict[str, Any]) -> Iterator[dict[str, Any]]:
81
+ """Every BOM line, depth-first through sub-assemblies, for each top assembly.
82
+
83
+ Args:
84
+ rollups: The ``rollups.json`` payload.
85
+
86
+ Yields:
87
+ Line dicts (as in ``rollups["structure"]``) in display order.
88
+ """
89
+
90
+ def walk(lines: list[dict[str, Any]]) -> Iterator[dict[str, Any]]:
91
+ for ln in lines:
92
+ yield ln
93
+ yield from walk(ln.get("children") or [])
94
+
95
+ for top in rollups.get("top_assemblies", []):
96
+ yield from walk(rollups.get("structure", {}).get(top, []))
97
+
98
+
99
+ def line_row(ln: dict[str, Any]) -> dict[str, Any]:
100
+ """Map a rollup line dict onto the :data:`COLUMNS` (``None`` for unknown numbers).
101
+
102
+ Args:
103
+ ln: One line dict from ``rollups["structure"]``.
104
+
105
+ Returns:
106
+ A dict keyed by the BOM column names.
107
+ """
108
+ return {
109
+ "level": ln.get("level", 0),
110
+ "uri": ln.get("uri", ""),
111
+ "id": ln.get("id", ""),
112
+ "rev": ln.get("rev", ""),
113
+ "name": ln.get("name", ""),
114
+ "kind": ln.get("kind", ""),
115
+ "qty": ln.get("qty"),
116
+ "total_qty": ln.get("total_qty"),
117
+ "process": ln.get("process", ""),
118
+ "material": ln.get("material", ""),
119
+ "finish": ln.get("finish", ""),
120
+ "tolerance_class": ln.get("tolerance_class", ""),
121
+ "unit_cost_usd": ln.get("unit_cost"),
122
+ "ext_cost_usd": None if ln.get("ext_cost") is None else round(ln["ext_cost"], 6),
123
+ "lead_time_days": ln.get("lead_time_days"),
124
+ "mass_kg": ln.get("mass_kg"),
125
+ "sourcing_status": ln.get("sourcing_status", ""),
126
+ "source": ln.get("source", ""),
127
+ "manufacturer": ln.get("manufacturer", ""),
128
+ }
129
+
130
+
131
+ def _csv_value(value: Any) -> Any:
132
+ if value is None:
133
+ return ""
134
+ if isinstance(value, bool):
135
+ return "true" if value else "false"
136
+ return value
137
+
138
+
139
+ def write_csv(path: Path, rollups: dict[str, Any]) -> Path:
140
+ """Write the flat ``bom.csv`` (one row per line, then a blank row and the totals).
141
+
142
+ Args:
143
+ path: Destination file.
144
+ rollups: The ``rollups.json`` payload.
145
+
146
+ Returns:
147
+ ``path``.
148
+ """
149
+ with path.open("w", encoding="utf-8", newline="") as fh:
150
+ writer = csv.writer(fh, lineterminator="\n")
151
+ writer.writerow(COLUMNS)
152
+ for ln in iter_lines(rollups):
153
+ row = line_row(ln)
154
+ writer.writerow([_csv_value(row[c]) for c in COLUMNS])
155
+ writer.writerow([])
156
+ for label, key in TOTALS:
157
+ writer.writerow([label, _csv_value(rollups.get(key))])
158
+ writer.writerow(
159
+ [
160
+ "note",
161
+ "This design was produced with an AI system and has not been reviewed "
162
+ "by a licensed engineer.",
163
+ ]
164
+ )
165
+ return path
166
+
167
+
168
+ def write_xlsx(path: Path, rollups: dict[str, Any]) -> Path:
169
+ """Write ``bom.xlsx`` with the BOM, Rollups, Parts and Manufacturer sheets.
170
+
171
+ Args:
172
+ path: Destination file.
173
+ rollups: The ``rollups.json`` payload.
174
+
175
+ Returns:
176
+ ``path``.
177
+ """
178
+ from openpyxl import Workbook
179
+ from openpyxl.styles import Alignment, Font
180
+ from openpyxl.utils import get_column_letter
181
+
182
+ bold = Font(bold=True)
183
+ wb = Workbook()
184
+ ws = wb.active
185
+ ws.title = "BOM"
186
+ ws.append(COLUMNS)
187
+ for cell in ws[1]:
188
+ cell.font = bold
189
+ money = {COLUMNS.index(c) + 1 for c in ("unit_cost_usd", "ext_cost_usd")}
190
+ mass_col = COLUMNS.index("mass_kg") + 1
191
+ name_col = COLUMNS.index("name") + 1
192
+ for ln in iter_lines(rollups):
193
+ row = line_row(ln)
194
+ row["name"] = " " * int(row["level"] or 0) + str(row["name"])
195
+ ws.append([row[c] for c in COLUMNS])
196
+ r = ws.max_row
197
+ for col in money:
198
+ ws.cell(r, col).number_format = "0.00"
199
+ ws.cell(r, mass_col).number_format = "0.000"
200
+ ws.cell(r, name_col).alignment = Alignment(indent=int(row["level"] or 0))
201
+ ws.freeze_panes = "A2"
202
+ for i, col in enumerate(COLUMNS, start=1):
203
+ ws.column_dimensions[get_column_letter(i)].width = _WIDTHS.get(col, 12)
204
+
205
+ ws.append([])
206
+ ws.append(["Totals"])
207
+ ws.cell(ws.max_row, 1).font = bold
208
+ for label, key in TOTALS:
209
+ ws.append([label, rollups.get(key)])
210
+ r = ws.max_row
211
+ ws.cell(r, 1).font = bold
212
+ if key == "total_cost":
213
+ ws.cell(r, 2).number_format = "0.00"
214
+ elif key == "total_mass":
215
+ ws.cell(r, 2).number_format = "0.000"
216
+ ws.append([])
217
+ ws.append([SOURCING_NOTE])
218
+ ws.cell(ws.max_row, 1).font = Font(italic=True)
219
+
220
+ rs = wb.create_sheet("Rollups")
221
+ rs.append(["key", "value"])
222
+ for cell in rs[1]:
223
+ cell.font = bold
224
+ for key in (
225
+ "total_cost",
226
+ "total_cost_unit",
227
+ "cost_complete",
228
+ "total_mass",
229
+ "total_mass_unit",
230
+ "mass_complete",
231
+ "part_count",
232
+ "distinct_parts",
233
+ "custom_count",
234
+ "custom_ratio",
235
+ "max_lead_time",
236
+ "max_lead_time_unit",
237
+ ):
238
+ if key in rollups:
239
+ rs.append([key, rollups[key]])
240
+ rs.append(["top_assemblies", ", ".join(rollups.get("top_assemblies", []))])
241
+ rs.append(["orphans", ", ".join(rollups.get("orphans", []))])
242
+ rs.column_dimensions["A"].width = 20
243
+ rs.column_dimensions["B"].width = 30
244
+
245
+ ps = wb.create_sheet("Parts")
246
+ part_cols = [
247
+ "uri",
248
+ "name",
249
+ "kind",
250
+ "total_qty",
251
+ "unit_cost",
252
+ "lead_time_days",
253
+ "mass_kg",
254
+ "sourcing_status",
255
+ ]
256
+ ps.append(part_cols)
257
+ for cell in ps[1]:
258
+ cell.font = bold
259
+ for part in rollups.get("parts", {}).values():
260
+ ps.append([part.get(c) for c in part_cols])
261
+ r = ps.max_row
262
+ ps.cell(r, part_cols.index("unit_cost") + 1).number_format = "0.00"
263
+ ps.cell(r, part_cols.index("mass_kg") + 1).number_format = "0.000"
264
+ for i, w in enumerate((24, 32, 10, 9, 10, 14, 10, 15), start=1):
265
+ ps.column_dimensions[get_column_letter(i)].width = w
266
+ ps.freeze_panes = "A2"
267
+
268
+ ms = wb.create_sheet("Manufacturer")
269
+ ms.append(["uri", "name", "kind", "total_qty", "instruction"])
270
+ for cell in ms[1]:
271
+ cell.font = bold
272
+ seen: set[str] = set()
273
+ for ln in iter_lines(rollups):
274
+ if ln.get("kind") == "assembly" or ln.get("uri") in seen:
275
+ continue
276
+ seen.add(str(ln.get("uri")))
277
+ total = (
278
+ rollups.get("parts", {}).get(ln.get("uri"), {}).get("total_qty", ln.get("total_qty"))
279
+ )
280
+ ms.append(
281
+ [ln.get("uri"), ln.get("name"), ln.get("kind"), total, ln.get("manufacturer", "")]
282
+ )
283
+ ms.cell(ms.max_row, 5).alignment = Alignment(wrap_text=True, vertical="top")
284
+ for i, w in enumerate((24, 32, 10, 9, 80), start=1):
285
+ ms.column_dimensions[get_column_letter(i)].width = w
286
+ ms.freeze_panes = "A2"
287
+
288
+ wb.save(path)
289
+ return path
290
+
291
+
292
+ def write_bom_files(ctx: RunContext, rollups: dict[str, Any]) -> list[Path]:
293
+ """Write ``validation.json``, ``bom.csv`` and ``bom.xlsx``; return the files written.
294
+
295
+ Args:
296
+ ctx: The build context.
297
+ rollups: The ``rollups.json`` payload from ``compute_rollups``.
298
+
299
+ Returns:
300
+ ``[validation.json, bom.csv, bom.xlsx]``.
301
+ """
302
+ from napkin2factory.bom.validation import validate_bom
303
+
304
+ out_dir = ctx.build_dir / "bom"
305
+ out_dir.mkdir(parents=True, exist_ok=True)
306
+ validation = validate_bom(ctx.product, ctx.build_dir)
307
+ path = out_dir / "validation.json"
308
+ path.write_text(json.dumps(validation, indent=2, sort_keys=True) + "\n", encoding="utf-8")
309
+ return [
310
+ path,
311
+ write_csv(out_dir / "bom.csv", rollups),
312
+ write_xlsx(out_dir / "bom.xlsx", rollups),
313
+ ]
@@ -0,0 +1,297 @@
1
+ """BOM structure and rollups (D18): quantities through sub-assemblies, cost, mass, counts.
2
+
3
+ Every declared value is read through recording selectors so the ``bom`` rule's
4
+ dependencies are exact (``part://x#cost.unit``, ``asm://y#children``, ``part://x#props.mass``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from napkin2factory.core.loader import Product
14
+ from napkin2factory.core.model import Part
15
+ from napkin2factory.core.units import format_quantity, parse_quantity
16
+ from napkin2factory.geometry.accessors import ReadLog
17
+
18
+
19
+ @dataclass
20
+ class BomLine:
21
+ """One indented BOM row."""
22
+
23
+ level: int
24
+ uri: str
25
+ id: str
26
+ name: str
27
+ kind: str
28
+ qty: int # quantity at this position (per parent)
29
+ total_qty: int # rolled-up quantity in the top assembly
30
+ process: str = ""
31
+ material: str = ""
32
+ finish: str = ""
33
+ tolerance_class: str = ""
34
+ unit_cost: float | None = None
35
+ cost_basis: str = ""
36
+ lead_time_days: float | None = None
37
+ mass_kg: float | None = None
38
+ mass_source: str = ""
39
+ source: str = ""
40
+ sourcing_status: str = ""
41
+ manufacturer: str = ""
42
+ rev: str = ""
43
+ children: list[BomLine] = field(default_factory=list)
44
+
45
+ @property
46
+ def ext_cost(self) -> float | None:
47
+ """Extended cost (unit × total quantity)."""
48
+ return None if self.unit_cost is None else self.unit_cost * self.total_qty
49
+
50
+
51
+ def _props_mass(build_dir: Path | None, part: Part) -> tuple[float | None, str]:
52
+ if part.mass is not None:
53
+ return float(part.mass.to("kg").magnitude), "declared"
54
+ if build_dir is None:
55
+ return None, "not built"
56
+ p = build_dir / "parts" / part.id / "props.json"
57
+ if not p.is_file():
58
+ return None, "not built"
59
+ import json
60
+
61
+ data = json.loads(p.read_text(encoding="utf-8"))
62
+ return float(data["mass"]), str(data.get("mass_source", "computed"))
63
+
64
+
65
+ def manufacturer_column(part: Part, qty: int) -> str:
66
+ """The "what to tell the manufacturer" text for a row."""
67
+ if part.kind == "reference":
68
+ return f"supplied by the owner / already exists: {part.name} ({qty}×); not quoted"
69
+ if part.kind == "cots":
70
+ src = part.source
71
+ return f"buy {qty}× {part.name}" + (f" ({src.vendor} {src.mpn})" if src else "")
72
+ bits = [part.process]
73
+ if part.material:
74
+ bits.append(part.material.spec or part.material.name)
75
+ if part.finish:
76
+ bits.append(part.finish)
77
+ if part.tolerance_class:
78
+ bits.append(part.tolerance_class)
79
+ bits.append(f"qty {qty}")
80
+ if part.manufacturer_notes:
81
+ bits.append(part.manufacturer_notes)
82
+ return "; ".join(b for b in bits if b)
83
+
84
+
85
+ def sourcing_status(part: Part) -> str:
86
+ """Complete | unverified | missing — before the URL/catalog tiers run (M4 refines)."""
87
+ if part.kind == "reference":
88
+ return "reference"
89
+ if part.kind != "cots":
90
+ return "custom"
91
+ if part.source is None:
92
+ return "missing"
93
+ if not part.source.url:
94
+ return "no-url"
95
+ return "verified" if part.source.verified else "unverified"
96
+
97
+
98
+ def build_lines(
99
+ product: Product,
100
+ asm_id: str,
101
+ log: ReadLog,
102
+ build_dir: Path | None,
103
+ level: int = 0,
104
+ multiplier: int = 1,
105
+ ) -> list[BomLine]:
106
+ """Indented lines for one assembly, recursing into sub-assemblies."""
107
+ asm = product.assemblies[asm_id]
108
+ log.add(f"asm://{asm_id}#children")
109
+ lines: list[BomLine] = []
110
+ for child in asm.children:
111
+ if child.part is not None:
112
+ part = product.parts[child.part.id]
113
+ for sel in (
114
+ "name",
115
+ "kind",
116
+ "process",
117
+ "material",
118
+ "finish",
119
+ "tolerance_class",
120
+ "cost",
121
+ "lead_time",
122
+ "source",
123
+ "rev",
124
+ "mass",
125
+ "manufacturer_notes",
126
+ ):
127
+ log.add(f"{part.uri}#{sel}")
128
+ mass, mass_src = _props_mass(build_dir, part)
129
+ log.add(f"{part.uri}#geometry")
130
+ if part.mass is None and part.has_geometry:
131
+ log.add(f"{part.uri}#props.mass")
132
+ lines.append(
133
+ BomLine(
134
+ level=level,
135
+ uri=part.uri,
136
+ id=part.id,
137
+ name=part.name,
138
+ kind=part.kind,
139
+ qty=child.qty,
140
+ total_qty=child.qty * multiplier,
141
+ process=part.process,
142
+ material=(part.material.spec or part.material.name) if part.material else "",
143
+ finish=part.finish or "",
144
+ tolerance_class=part.tolerance_class or "",
145
+ unit_cost=float(part.cost.unit.to("USD").magnitude) if part.cost else None,
146
+ cost_basis=part.cost.basis if part.cost else "",
147
+ lead_time_days=float(part.lead_time.to("day").magnitude)
148
+ if part.lead_time
149
+ else None,
150
+ mass_kg=mass,
151
+ mass_source=mass_src,
152
+ source=f"{part.source.vendor} {part.source.mpn}" if part.source else "",
153
+ sourcing_status=sourcing_status(part),
154
+ manufacturer=manufacturer_column(part, child.qty * multiplier),
155
+ rev=part.rev,
156
+ )
157
+ )
158
+ elif child.asm is not None:
159
+ sub = product.assemblies[child.asm.id]
160
+ log.add(f"{sub.uri}#name")
161
+ log.add(f"{sub.uri}#rev")
162
+ line = BomLine(
163
+ level=level,
164
+ uri=sub.uri,
165
+ id=sub.id,
166
+ name=sub.name,
167
+ kind="assembly",
168
+ qty=child.qty,
169
+ total_qty=child.qty * multiplier,
170
+ rev=sub.rev,
171
+ )
172
+ line.children = build_lines(
173
+ product, sub.id, log, build_dir, level + 1, multiplier * child.qty
174
+ )
175
+ lines.append(line)
176
+ return lines
177
+
178
+
179
+ def flatten_lines(lines: list[BomLine]) -> list[BomLine]:
180
+ """Depth-first list including nested children."""
181
+ out: list[BomLine] = []
182
+ for ln in lines:
183
+ out.append(ln)
184
+ out.extend(flatten_lines(ln.children))
185
+ return out
186
+
187
+
188
+ def top_assemblies(product: Product) -> list[str]:
189
+ """Assemblies no other assembly contains."""
190
+ used = {c.asm.id for a in product.assemblies.values() for c in a.children if c.asm is not None}
191
+ return sorted(a for a in product.assemblies if a not in used)
192
+
193
+
194
+ def compute_rollups(
195
+ product: Product, build_dir: Path | None = None, log: ReadLog | None = None
196
+ ) -> dict[str, Any]:
197
+ """The ``rollups.json`` payload: lines per top assembly plus totals."""
198
+ log = log if log is not None else ReadLog()
199
+ log.add("set://parts")
200
+ log.add("set://asms")
201
+ tops = top_assemblies(product)
202
+ per_part: dict[str, dict[str, Any]] = {}
203
+ structure: dict[str, list[dict[str, Any]]] = {}
204
+ for top in tops:
205
+ lines = build_lines(product, top, log, build_dir)
206
+ structure[f"asm://{top}"] = [line_to_dict(ln) for ln in lines]
207
+ for ln in flatten_lines(lines):
208
+ if ln.kind == "assembly":
209
+ continue
210
+ agg = per_part.setdefault(
211
+ ln.uri,
212
+ {
213
+ "uri": ln.uri,
214
+ "name": ln.name,
215
+ "kind": ln.kind,
216
+ "total_qty": 0,
217
+ "unit_cost": ln.unit_cost,
218
+ "lead_time_days": ln.lead_time_days,
219
+ "mass_kg": ln.mass_kg,
220
+ "sourcing_status": ln.sourcing_status,
221
+ },
222
+ )
223
+ agg["total_qty"] += ln.total_qty
224
+ total_cost = 0.0
225
+ cost_known = True
226
+ total_mass = 0.0
227
+ mass_known = True
228
+ max_lead = 0.0
229
+ custom = 0
230
+ part_count = 0
231
+ for agg in per_part.values():
232
+ part_count += agg["total_qty"]
233
+ if agg["kind"] == "custom":
234
+ custom += agg["total_qty"]
235
+ if agg["kind"] == "reference":
236
+ pass # not bought, not made: contributes mass but no cost
237
+ elif agg["unit_cost"] is None:
238
+ cost_known = False
239
+ else:
240
+ total_cost += agg["unit_cost"] * agg["total_qty"]
241
+ if agg["mass_kg"] is None:
242
+ mass_known = False
243
+ else:
244
+ total_mass += agg["mass_kg"] * agg["total_qty"]
245
+ if agg["lead_time_days"] is not None:
246
+ max_lead = max(max_lead, agg["lead_time_days"])
247
+ # Parts in no assembly still count for sourcing but not for totals.
248
+ orphans = sorted(p.uri for p in product.parts.values() if p.uri not in per_part)
249
+ return {
250
+ "top_assemblies": [f"asm://{t}" for t in tops],
251
+ "structure": structure,
252
+ "parts": dict(sorted(per_part.items())),
253
+ "orphans": orphans,
254
+ "total_cost": round(total_cost, 2),
255
+ "total_cost_unit": "USD",
256
+ "cost_complete": cost_known,
257
+ "total_mass": round(total_mass, 4),
258
+ "total_mass_unit": "kg",
259
+ "mass_complete": mass_known,
260
+ "part_count": part_count,
261
+ "distinct_parts": len(per_part),
262
+ "custom_count": custom,
263
+ "custom_ratio": round(custom / part_count, 4) if part_count else 0.0,
264
+ "max_lead_time": max_lead,
265
+ "max_lead_time_unit": "d",
266
+ "reads": log.keys,
267
+ }
268
+
269
+
270
+ def line_to_dict(ln: BomLine) -> dict[str, Any]:
271
+ """JSON form of a line (recursive)."""
272
+ d = {k: v for k, v in ln.__dict__.items() if k != "children"}
273
+ d["ext_cost"] = ln.ext_cost
274
+ d["children"] = [line_to_dict(c) for c in ln.children]
275
+ return d
276
+
277
+
278
+ def rollup_value(rollups: dict[str, Any], selector: str) -> Any:
279
+ """``bom://rollups#total_cost`` → a pint quantity (or plain number) for the checks."""
280
+ units = {"total_cost": "USD", "total_mass": "kg", "max_lead_time": "d"}
281
+ if selector not in rollups:
282
+ raise KeyError(
283
+ f"bom://rollups has no {selector!r}; available: total_cost, total_mass, "
284
+ "part_count, custom_ratio, max_lead_time"
285
+ )
286
+ val = rollups[selector]
287
+ return parse_quantity(val, units.get(selector)) if isinstance(val, (int, float)) else val
288
+
289
+
290
+ __all__ = [
291
+ "BomLine",
292
+ "compute_rollups",
293
+ "flatten_lines",
294
+ "format_quantity",
295
+ "rollup_value",
296
+ "top_assemblies",
297
+ ]