@structupath/pi-steel 0.1.0 → 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/README.md CHANGED
@@ -28,9 +28,23 @@ Ask your agent things like:
28
28
  > "What's the lightest W-shape with depth ≥ 18" and Ix ≥ 1000?"
29
29
  > "Total tonnage on this BOM with 12% connections"
30
30
 
31
+ ### `steel-nest` — plate nesting & burn-table DXF
32
+
33
+ The plate-layout step CAM software does, minus the CAM seat: MaxRects bin-packing of parts onto stock plates with kerf/gap/edge-margin spacing, holes and rectangular cutouts, yield/scrap/reusable-drop numbers, and material cost. Outputs a labeled layout (PDF + PNG per plate), a cut list, and **one DXF per sheet for the burn table** (part outlines on `PROFILE`, holes on `HOLES`, origin at sheet corner — ready for ProNest/FastCAM/SigmaNEST import).
34
+
35
+ Honest about its limits: rectangular parts nest exactly; irregular parts nest by bounding box (flagged, never hidden); it deliberately does **not** emit G-code — kerf comp, lead-ins, and pierce points belong to your table's real post-processor.
36
+
37
+ > "How many sheets does this job need?"
38
+ > "Nest these parts on 96×48 plate and give me the yield"
39
+ > "Lay this out for the burn table"
40
+
41
+ Requires `ezdxf`, `matplotlib`, `numpy` (`pip install ezdxf matplotlib numpy`).
42
+
31
43
  ### `steel-rfq` — vendor quote requests
32
44
 
33
- Turns a steel estimate/takeoff spreadsheet into a standardized vendor RFQ (.xlsx): materials grouped the way vendors stock them (W-shapes / plate / flat bar), yellow fill-in pricing columns, nesting/drop reference, and terms & conditions — branded with **your** company profile.
45
+ Turns a steel estimate/takeoff spreadsheet into a standardized vendor RFQ (.xlsx): materials grouped the way vendors stock them (W-shapes / plate / flat bar), yellow fill-in pricing columns, nesting/drop reference, and terms & conditions — branded with **your** company profile. When `steel-nest` has run for the job, its cutting plan flows straight into the RFQ's nesting table.
46
+
47
+ The three skills chain into a full estimating pipeline: **takeoff → nest → RFQ**.
34
48
 
35
49
  One-time setup: copy `skills/steel-rfq/assets/company-profile.example.json` to `company-profile.json` and put in your company name, city, and payment terms. The skill will ask and offer to save it if you skip this.
36
50
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@structupath/pi-steel",
3
- "version": "0.1.0",
4
- "description": "Structural steel estimating skills for the Pi coding agent AISC takeoffs, bills of material, tonnage calcs, and vendor RFQ generation.",
3
+ "version": "0.2.0",
4
+ "description": "Structural steel estimating skills for the Pi coding agent \u2014 AISC takeoffs, bills of material, tonnage calcs, plate nesting with burn-table DXF output, and vendor RFQ generation.",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "pi-package",
@@ -14,20 +14,26 @@
14
14
  "bom",
15
15
  "rfq",
16
16
  "construction",
17
- "fabrication"
17
+ "fabrication",
18
+ "nesting",
19
+ "plate-nesting",
20
+ "dxf",
21
+ "cam"
18
22
  ],
19
23
  "author": "StructuPath (https://structupath.ai)",
20
24
  "license": "MIT",
21
25
  "repository": {
22
26
  "type": "git",
23
- "url": "https://github.com/StructuPath/pi-steel.git"
27
+ "url": "git+https://github.com/StructuPath/pi-steel.git"
24
28
  },
25
29
  "homepage": "https://structupath.ai",
26
30
  "engines": {
27
31
  "node": ">=20.6.0"
28
32
  },
29
33
  "pi": {
30
- "skills": ["./skills"]
34
+ "skills": [
35
+ "./skills"
36
+ ]
31
37
  },
32
38
  "files": [
33
39
  "skills",
@@ -0,0 +1,87 @@
1
+ ---
2
+ name: steel-nest
3
+ description: "Nest steel parts onto stock plates and estimate material — the plate-layout / cutting step that CAM software (SigmaNEST, Hypertherm, FANUC) does. Use this skill whenever someone mentions nesting, plate layout, plate optimization, cut list, cutting plan, yield, drop/remnant, how many sheets/plates a job needs, how much plate to buy, or laying parts out on a sheet. Also trigger when a new order/SO comes in and someone asks 'how much material', 'how many plates', 'what's the yield', or 'lay these parts out' — even if they don't say the word 'nest'. Produces a nesting layout (PDF + PNG), a cut list, yield/scrap/remnant numbers, material cost, and a DXF of the nest. Rectangular parts nest exactly; irregular parts nest by bounding box."
4
+ ---
5
+
6
+ # Steel Plate Nesting & Estimate
7
+
8
+ ## What This Skill Does
9
+
10
+ This is the in-house version of the plate-nesting step a CAM package (SigmaNEST / Hypertherm / FANUC) performs: it takes a list of parts and the plate stock on hand, packs the parts onto as few plates as possible, and tells you the yield, the drops you can reuse, the material weight, and the cost. It also draws the layout and exports a DXF.
11
+
12
+ It exists so that the moment an order lands, anyone in the shop can get a fast, repeatable material number for quoting and a layout the cutter can follow — without waiting on the CAM seat.
13
+
14
+ ## What It Does Well vs. What It Doesn't
15
+
16
+ Be honest with the user about the boundary — it protects the shop from over-trusting the output.
17
+
18
+ **Reliable:**
19
+ - Rectangular / plate-blank parts nest **exactly** (MaxRects bin-packing with rotation).
20
+ - Multiple plate sizes, kerf + gap spacing, edge margin (grip/clamp keep-out).
21
+ - **Holes and rectangular cutouts** on any part — subtracted from weight/cost, rotated with the part, and cut as real geometry in the output.
22
+ - Yield %, scrap weight, largest reusable **drop** per plate.
23
+ - Material weight and cost (by $/lb — which also values scrap — or by $/sheet).
24
+ - Labeled layout (PDF + one PNG per plate).
25
+ - **Burn-table files**: one DXF **per sheet** (`burn_plate_N.dxf`) with part outlines on layer `PROFILE` and holes on layer `HOLES`, origin at the sheet corner — ready to import into the table's CAM. Plus `nest.dxf`, an all-sheets overview.
26
+
27
+ **Approximate — always flag it:**
28
+ - **Irregular parts** (gussets, brackets, curved profiles, parts with holes) are nested by their **bounding box**, not true shape. Real yield is a little better than reported. For exact weight/cost on those, get the true cut area (in²) into the part's `area` field. This is NOT true-shape nesting like a dedicated CAM engine.
29
+
30
+ **Do NOT pretend to do:**
31
+ - **Machine-ready G-code / NC** with kerf compensation, pierce points, and lead-ins for a specific controller. That is machine-specific and safety-critical and must come from the real post-processor. The burn DXF from this skill is a geometry/import file — the table's CAM (Hypertherm ProNest, FastCAM, SigmaNEST, Lantek, or the controller's own importer) applies kerf comp, lead-ins and pierce. Say so plainly if asked for G-code; offer the DXF as the correct hand-off.
32
+
33
+ ## Inputs to Gather
34
+
35
+ Everything drives a single job JSON (schema in `references/job_template.json`; a worked example in `references/example_job.json`). Build that JSON from whatever the user gives you — a typed part list in chat, a takeoff/BOM spreadsheet, or a `steel-rfq` estimate file.
36
+
37
+ Gather three things:
38
+
39
+ 1. **Parts** — for each unique part: name, width × height (inches; use the bounding box for odd shapes), quantity, whether it's `rect` or `irregular`, and whether rotation is allowed (`rotatable: false` locks grain/rolling direction for anisotropic material or directional finish). If a part has **holes or cutouts** and you want them cut in the burn file (and netted out of weight), add a `holes` list — each hole's `x,y` is its center from the part's lower-left corner: round = `{"dia":, "x":, "y":}`, rectangular cutout = `{"w":, "h":, "x":, "y":}`. Holes are optional; skip them if you only need the layout/estimate.
40
+ 2. **Stock** — plate size(s) on hand (width × height × thickness), how many sheets are available (or `unlimited` to buy as needed), and price (`cost_per_lb` preferred; `cost_per_sheet` works too).
41
+ 3. **Cut settings** — kerf, part gap, edge margin, material density. Sensible defaults are in the template; only ask if the user hasn't implied them. Common kerf: plasma ~0.06", oxy-fuel ~0.10", laser ~0.02", waterjet ~0.03".
42
+
43
+ If a spreadsheet is provided, read it with pandas first, map columns to the part fields, and confirm your interpretation before nesting. Do not silently guess quantities or dimensions.
44
+
45
+ ### Defaults (only override when the user gives you a reason)
46
+ - kerf 0.06", part gap 0.25", edge margin 0.5"
47
+ - A36 mild steel density 0.2836 lb/in³ (aluminum 0.098, 304 stainless 0.289)
48
+ - Standard sheet sizes to offer if they don't specify: 96×48, 120×48, 144×48, 240×96 in
49
+
50
+ ## How to Run
51
+
52
+ Write the job JSON, then run the engine:
53
+
54
+ ```bash
55
+ python3 scripts/nest.py --job <job.json> --out <outdir>
56
+ ```
57
+
58
+ Outputs land in `<outdir>/`:
59
+ - `layout.pdf` — every plate drawn (holes shown) + a summary page (the main deliverable)
60
+ - `plate_1.png`, `plate_2.png`, … — one image per plate
61
+ - `burn_plate_1.dxf`, `burn_plate_2.dxf`, … — **one DXF per sheet for the burn table** (PROFILE + HOLES layers, origin at sheet corner)
62
+ - `nest.dxf` — all sheets side-by-side, one overview file
63
+ - `rfq_nesting.json` — the Material / Nesting Plan / Drop Notes block for the `steel-rfq` hand-off (see below)
64
+ - `report.txt` — the text report
65
+ - `result.json` — structured result (plates, placements, holes, yield, cost) for downstream use
66
+
67
+ The engine has no third-party build dependencies beyond `ezdxf`, `matplotlib`, and `numpy`. Install once if missing: `pip install --break-system-packages ezdxf matplotlib numpy`.
68
+
69
+ ## What to Deliver
70
+
71
+ Always deliver the **PDF layout** and give the headline numbers in the message: plates used, overall yield %, total material cost, and any parts that **did not fit** (the report flags these — never hide them; it means they need more or bigger stock). Offer the DXF and per-plate PNGs. If the parts were irregular, restate the bounding-box caveat so the quote isn't over-trusted.
72
+
73
+ Verify before presenting: the engine already checks that no parts overlap and all fit in-bounds, but sanity-check the yield and cost against the plate count (e.g., cost = plates × sheet cost, or plate weight × $/lb). If a plate shows very low yield, mention it — it's usually the tail plate and may be worth holding parts for the next order.
74
+
75
+ ## Integration with steel-rfq
76
+
77
+ The `steel-rfq` skill has a "Nesting / Drop Reference" table. This engine writes exactly that data to `rfq_nesting.json` on every run — one row per plate material with `material`, `nesting_plan`, `drop_notes`, plus `sheets_needed` (for cross-checking the estimate's assumed sheet count) and `total_cost`. When an RFQ involves plate, `steel-rfq` builds a nest job from the plate parts, runs this engine, and reads `rfq_nesting.json` straight into its table. Keep this JSON shape stable — the RFQ skill depends on those field names.
78
+
79
+ ## Common Variations
80
+
81
+ **Mixed thickness / grade in one order** — nest each thickness as its own job (parts of different thickness can't share a plate). Run the engine once per thickness and combine the numbers in the summary.
82
+
83
+ **"Just tell me how many sheets"** — still run it; the plate count is the answer, and you get the yield and cost for free. Don't estimate sheet count by dividing areas — packing loss makes that wrong.
84
+
85
+ **Remnant/drop reuse** — add the leftover drop from a previous job as another `stock` entry (its size, `qty: 1`) so the engine tries to consume it first-ish. Note: with multiple stock sizes the engine fills them in the order listed, so list drops/offcuts first if you want them used up.
86
+
87
+ **Grain / directional material** — set `rotatable: false` on those parts so the nester won't spin them 90°.
@@ -0,0 +1,47 @@
1
+ {
2
+ "job_name": "SO-4471 Conveyor Frame Brackets",
3
+ "customer": "Rocky Mtn Aggregate",
4
+ "settings": {
5
+ "kerf_in": 0.06,
6
+ "part_gap_in": 0.25,
7
+ "edge_margin_in": 0.5,
8
+ "thickness_in": 0.5,
9
+ "density_lb_in3": 0.2836
10
+ },
11
+ "stock": [
12
+ {
13
+ "name": "A36 Plate 1/2\"",
14
+ "width": 96,
15
+ "height": 48,
16
+ "thickness": 0.5,
17
+ "qty": 6,
18
+ "cost_per_lb": 0.92
19
+ }
20
+ ],
21
+ "parts": [
22
+ { "name": "Base Plate BP1", "width": 14, "height": 10, "qty": 6, "shape": "rect",
23
+ "holes": [
24
+ { "dia": 0.875, "x": 2, "y": 2 },
25
+ { "dia": 0.875, "x": 12, "y": 2 },
26
+ { "dia": 0.875, "x": 2, "y": 8 },
27
+ { "dia": 0.875, "x": 12, "y": 8 }
28
+ ] },
29
+ { "name": "Column Cap CC1", "width": 16, "height": 16, "qty": 4, "shape": "rect",
30
+ "holes": [
31
+ { "dia": 1.0625, "x": 3, "y": 3 },
32
+ { "dia": 1.0625, "x": 13, "y": 3 },
33
+ { "dia": 1.0625, "x": 3, "y": 13 },
34
+ { "dia": 1.0625, "x": 13, "y": 13 }
35
+ ] },
36
+ { "name": "Stiffener ST1", "width": 9, "height": 6, "qty": 20, "shape": "rect" },
37
+ { "name": "Gusset G1", "width": 12, "height": 12, "qty": 12, "shape": "irregular", "area": 72 },
38
+ { "name": "Flat Bar FB1", "width": 30, "height": 4, "qty": 10, "shape": "rect" },
39
+ { "name": "Cover CV1", "width": 24, "height": 18, "qty": 3, "shape": "rect",
40
+ "holes": [
41
+ { "w": 6, "h": 4, "x": 12, "y": 9 }
42
+ ] },
43
+ { "name": "Clip CL1", "width": 5, "height": 3, "qty": 24, "shape": "rect",
44
+ "holes": [ { "dia": 0.5625, "x": 2.5, "y": 1.5 } ] },
45
+ { "name": "Web Plate WP1", "width": 40, "height": 12, "qty": 4, "shape": "rect" }
46
+ ]
47
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "job_name": "SO-XXXX Short description",
3
+ "customer": "Customer name",
4
+
5
+ "_comment_settings": "Cut/gap in inches. kerf = torch cut width (plasma ~0.06, oxy ~0.1, laser ~0.02). part_gap = clearance between parts on top of kerf. edge_margin = keep-out from plate edge (clamp/grip zone). density lb/in^3: A36 steel = 0.2836, aluminum = 0.098, stainless 304 = 0.289.",
6
+ "settings": {
7
+ "kerf_in": 0.06,
8
+ "part_gap_in": 0.25,
9
+ "edge_margin_in": 0.5,
10
+ "thickness_in": 0.5,
11
+ "density_lb_in3": 0.2836
12
+ },
13
+
14
+ "_comment_stock": "One entry per plate size you have on hand. qty = how many sheets available (or set \"unlimited\": true to buy as many as needed). Price EITHER by cost_per_lb OR cost_per_sheet — cost_per_lb is preferred because it also values the scrap.",
15
+ "stock": [
16
+ {
17
+ "name": "A36 Plate 1/2\"",
18
+ "width": 96,
19
+ "height": 48,
20
+ "thickness": 0.5,
21
+ "qty": 4,
22
+ "cost_per_lb": 0.92
23
+ }
24
+ ],
25
+
26
+ "_comment_parts": "One entry per unique part. width/height in inches (bounding box for irregular parts). qty = pieces needed. rotatable=false to lock grain/rolling direction. shape: \"rect\" nests exactly; \"irregular\" nests by bounding box — add \"area\" (true cut area in^2) for exact weight/cost on those.",
27
+ "_comment_holes": "Optional per-part \"holes\": each hole's x,y is its CENTER measured from the part's lower-left corner. Round hole = {\"dia\": D, \"x\":, \"y\":}. Rectangular cutout = {\"w\":, \"h\":, \"x\":, \"y\":}. Holes are subtracted from weight/cost, rotate with the part, and are cut as real geometry on the HOLES layer of the burn-table DXF.",
28
+ "parts": [
29
+ { "name": "Part A", "width": 12, "height": 8, "qty": 6, "shape": "rect",
30
+ "holes": [
31
+ { "dia": 0.875, "x": 2, "y": 2 },
32
+ { "dia": 0.875, "x": 10, "y": 6 }
33
+ ] },
34
+ { "name": "Part B", "width": 10, "height": 10, "qty": 4, "shape": "irregular", "area": 62 },
35
+ { "name": "Part C", "width": 24, "height": 3, "qty": 10, "shape": "rect", "rotatable": false,
36
+ "holes": [ { "w": 3, "h": 1, "x": 12, "y": 1.5 } ] }
37
+ ]
38
+ }
@@ -0,0 +1,652 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Steel plate nesting engine (rectangular / bounding-box)
4
+ =========================================================
5
+ A self-contained nesting + estimating tool for a fabrication shop.
6
+
7
+ Reliable:
8
+ * Nests parts onto stock plates with a MaxRects bin-packing algorithm
9
+ (rotation, kerf + gap spacing, edge margin, multiple plate sizes,
10
+ greedy multi-plate fill). Rectangular parts nest exactly.
11
+ * Parts can carry HOLES (round) and rectangular CUTOUTS -- subtracted
12
+ from weight/cost, rotated with the part, drawn in the layout, and cut
13
+ as real geometry in the DXF output.
14
+ * Yield / scrap / largest reusable drop, part weight, material cost.
15
+ * Labeled layout (PNG per plate + combined PDF).
16
+ * DXF outputs:
17
+ - nest.dxf all plates side-by-side (overview/reference)
18
+ - burn_plate_N.dxf ONE FILE PER SHEET for the burn table:
19
+ part profiles on layer PROFILE, holes on
20
+ layer HOLES, origin at the sheet corner.
21
+
22
+ Deliberately NOT done:
23
+ * True-shape nesting of irregular parts (they nest by BOUNDING BOX,
24
+ clearly flagged). Supply a part `area` for exact weight on those.
25
+ * Machine-ready G-code / NC with kerf comp, pierce points and lead-ins
26
+ for a specific controller. The burn-table DXF is an import file; the
27
+ machine's own CAM/post applies those (that is where they belong).
28
+
29
+ Usage:
30
+ python3 nest.py --job job.json --out out/
31
+ """
32
+
33
+ import argparse
34
+ import json
35
+ import math
36
+ import os
37
+ from dataclasses import dataclass, field
38
+
39
+ STEEL_DENSITY = 0.2836 # lb/in^3, A36 mild steel
40
+
41
+
42
+ # --------------------------------------------------------------------------
43
+ # Geometry primitives
44
+ # --------------------------------------------------------------------------
45
+ @dataclass
46
+ class FreeRect:
47
+ x: float
48
+ y: float
49
+ w: float
50
+ h: float
51
+
52
+
53
+ @dataclass
54
+ class Placement:
55
+ part_id: str
56
+ label: str
57
+ x: float # placed lower-left, usable (post-margin) coords
58
+ y: float
59
+ w: float # placed footprint (post-rotation)
60
+ h: float
61
+ rotated: bool
62
+ shape: str # "rect" | "irregular"
63
+ ow: float # original (unrotated) part width
64
+ oh: float # original (unrotated) part height
65
+ holes: list = field(default_factory=list) # in original part coords
66
+ base_area: float = 0.0 # gross area (bbox for rect, declared area for irregular)
67
+ holes_area: float = 0.0 # total area removed by holes/cutouts
68
+
69
+
70
+ class MaxRectsBin:
71
+ """One stock plate packed with MaxRects (Best-Short-Side-Fit)."""
72
+
73
+ EPS = 1e-9
74
+
75
+ def __init__(self, width, height):
76
+ self.width = width
77
+ self.height = height
78
+ self.free = [FreeRect(0.0, 0.0, width, height)]
79
+ self.placements = []
80
+
81
+ def insert(self, w, h, allow_rotate=True):
82
+ best = self._find(w, h, allow_rotate)
83
+ if best is None:
84
+ return None
85
+ x, y, pw, ph, rotated = best
86
+ self._place_and_split(x, y, pw, ph)
87
+ return best
88
+
89
+ def _find(self, w, h, allow_rotate):
90
+ best_short = math.inf
91
+ best_long = math.inf
92
+ best = None
93
+ candidates = [(w, h, False)]
94
+ if allow_rotate:
95
+ candidates.append((h, w, True))
96
+ for fr in self.free:
97
+ for cw, ch, rot in candidates:
98
+ if fr.w + self.EPS >= cw and fr.h + self.EPS >= ch:
99
+ lh = abs(fr.w - cw)
100
+ lv = abs(fr.h - ch)
101
+ short = min(lh, lv)
102
+ long_ = max(lh, lv)
103
+ if (short < best_short - self.EPS or
104
+ (abs(short - best_short) <= self.EPS and long_ < best_long)):
105
+ best_short, best_long = short, long_
106
+ best = (fr.x, fr.y, cw, ch, rot)
107
+ return best
108
+
109
+ def _place_and_split(self, x, y, w, h):
110
+ placed = FreeRect(x, y, w, h)
111
+ new_free = []
112
+ for fr in self.free:
113
+ if self._overlaps(fr, placed):
114
+ new_free.extend(self._split(fr, placed))
115
+ else:
116
+ new_free.append(fr)
117
+ self.free = new_free
118
+ self._prune()
119
+
120
+ @classmethod
121
+ def _overlaps(cls, a, b):
122
+ return not (b.x >= a.x + a.w - cls.EPS or b.x + b.w <= a.x + cls.EPS or
123
+ b.y >= a.y + a.h - cls.EPS or b.y + b.h <= a.y + cls.EPS)
124
+
125
+ @classmethod
126
+ def _split(cls, fr, placed):
127
+ out = []
128
+ if placed.x > fr.x + cls.EPS:
129
+ out.append(FreeRect(fr.x, fr.y, placed.x - fr.x, fr.h))
130
+ if placed.x + placed.w < fr.x + fr.w - cls.EPS:
131
+ out.append(FreeRect(placed.x + placed.w, fr.y,
132
+ fr.x + fr.w - (placed.x + placed.w), fr.h))
133
+ if placed.y > fr.y + cls.EPS:
134
+ out.append(FreeRect(fr.x, fr.y, fr.w, placed.y - fr.y))
135
+ if placed.y + placed.h < fr.y + fr.h - cls.EPS:
136
+ out.append(FreeRect(fr.x, placed.y + placed.h, fr.w,
137
+ fr.y + fr.h - (placed.y + placed.h)))
138
+ return out
139
+
140
+ def _prune(self):
141
+ n = len(self.free)
142
+ dead = [False] * n
143
+ for i in range(n):
144
+ if dead[i]:
145
+ continue
146
+ for j in range(n):
147
+ if i == j or dead[j]:
148
+ continue
149
+ if self._contains(self.free[j], self.free[i]):
150
+ dead[i] = True
151
+ break
152
+ self.free = [f for k, f in enumerate(self.free)
153
+ if not dead[k] and f.w > 1e-6 and f.h > 1e-6]
154
+
155
+ @classmethod
156
+ def _contains(cls, outer, inner):
157
+ return (inner.x >= outer.x - cls.EPS and inner.y >= outer.y - cls.EPS and
158
+ inner.x + inner.w <= outer.x + outer.w + cls.EPS and
159
+ inner.y + inner.h <= outer.y + outer.h + cls.EPS)
160
+
161
+ def largest_free(self):
162
+ return max(self.free, key=lambda r: r.w * r.h) if self.free else None
163
+
164
+
165
+ # --------------------------------------------------------------------------
166
+ # Holes
167
+ # --------------------------------------------------------------------------
168
+ def hole_area(hole):
169
+ """Area removed by a single hole/cutout (in^2)."""
170
+ if hole.get("dia") is not None:
171
+ return math.pi * (float(hole["dia"]) / 2.0) ** 2
172
+ if hole.get("w") is not None and hole.get("h") is not None: # rect cutout
173
+ return float(hole["w"]) * float(hole["h"])
174
+ return 0.0
175
+
176
+
177
+ def hole_local(pc, hole):
178
+ """
179
+ Hole center in the PLACED part's local frame (0..pw, 0..ph),
180
+ accounting for a 90-degree rotation. `pc` is a placement dict.
181
+ """
182
+ hx, hy = float(hole["x"]), float(hole["y"])
183
+ if pc["rotated"]:
184
+ # 90deg CCW: (x,y) -> (oh - y, x) inside placed box (oh wide, ow tall)
185
+ return pc["oh"] - hy, hx
186
+ return hx, hy
187
+
188
+
189
+ # --------------------------------------------------------------------------
190
+ # Job runner
191
+ # --------------------------------------------------------------------------
192
+ def run_job(job):
193
+ s = job.get("settings", {})
194
+ kerf = float(s.get("kerf_in", 0.06))
195
+ gap = float(s.get("part_gap_in", 0.25))
196
+ margin = float(s.get("edge_margin_in", 0.5))
197
+ density = float(s.get("density_lb_in3", STEEL_DENSITY))
198
+ spacing = kerf + gap
199
+
200
+ # expand parts, largest first
201
+ units = []
202
+ for p in job["parts"]:
203
+ holes = p.get("holes", []) or []
204
+ h_area = sum(hole_area(h) for h in holes)
205
+ base = float(p["width"]) * float(p["height"])
206
+ if p.get("shape") == "irregular" and p.get("area"):
207
+ base = float(p["area"])
208
+ for _ in range(int(p.get("qty", 1))):
209
+ units.append({
210
+ "part_id": p["name"], "label": p["name"],
211
+ "w": float(p["width"]), "h": float(p["height"]),
212
+ "rotatable": bool(p.get("rotatable", True)),
213
+ "shape": p.get("shape", "rect"),
214
+ "holes": holes, "base_area": base, "holes_area": h_area,
215
+ })
216
+ units.sort(key=lambda u: u["w"] * u["h"], reverse=True)
217
+
218
+ stock_types = []
219
+ for st in job["stock"]:
220
+ stock_types.append({
221
+ "name": st.get("name", "Plate"),
222
+ "W": float(st["width"]), "H": float(st["height"]),
223
+ "thickness": float(st.get("thickness", s.get("thickness_in", 0.5))),
224
+ "qty": math.inf if st.get("unlimited") else int(st.get("qty", 1)),
225
+ "cost_per_lb": st.get("cost_per_lb"),
226
+ "cost_per_sheet": st.get("cost_per_sheet"),
227
+ "used": 0,
228
+ })
229
+
230
+ plates = []
231
+
232
+ def open_plate(fit=None):
233
+ for stype in stock_types:
234
+ if stype["used"] >= stype["qty"]:
235
+ continue
236
+ uw, uh = stype["W"] - 2 * margin, stype["H"] - 2 * margin
237
+ if fit is not None:
238
+ fw, fh = fit["w"] + spacing, fit["h"] + spacing
239
+ ok = (fw <= uw + 1e-9 and fh <= uh + 1e-9)
240
+ if fit["rotatable"]:
241
+ ok = ok or (fh <= uw + 1e-9 and fw <= uh + 1e-9)
242
+ if not ok:
243
+ continue
244
+ stype["used"] += 1
245
+ plates.append({"stock": stype, "bin": MaxRectsBin(uw, uh), "placements": []})
246
+ return plates[-1]
247
+ return None
248
+
249
+ def fits_any(u):
250
+ fw, fh = u["w"] + spacing, u["h"] + spacing
251
+ for stype in stock_types:
252
+ uw, uh = stype["W"] - 2 * margin, stype["H"] - 2 * margin
253
+ if (fw <= uw + 1e-9 and fh <= uh + 1e-9) or \
254
+ (u["rotatable"] and fh <= uw + 1e-9 and fw <= uh + 1e-9):
255
+ return True
256
+ return False
257
+
258
+ def commit(pl, u, res):
259
+ x, y, rw, rh, rot = res
260
+ pl["placements"].append(Placement(
261
+ u["part_id"], u["label"], x, y, rw - spacing, rh - spacing, rot,
262
+ u["shape"], u["w"], u["h"], u["holes"], u["base_area"], u["holes_area"]))
263
+
264
+ unplaced = []
265
+ for u in units:
266
+ if not fits_any(u):
267
+ unplaced.append(u)
268
+ continue
269
+ fw, fh = u["w"] + spacing, u["h"] + spacing
270
+ placed = False
271
+ for pl in plates:
272
+ res = pl["bin"].insert(fw, fh, u["rotatable"])
273
+ if res:
274
+ commit(pl, u, res)
275
+ placed = True
276
+ break
277
+ if not placed:
278
+ newpl = open_plate(fit=u)
279
+ if newpl is not None:
280
+ res = newpl["bin"].insert(fw, fh, u["rotatable"])
281
+ if res:
282
+ commit(newpl, u, res)
283
+ placed = True
284
+ if not placed:
285
+ unplaced.append(u)
286
+
287
+ used_plates = [pl for pl in plates if pl["placements"]]
288
+ for i, pl in enumerate(used_plates, 1):
289
+ pl["index"] = i
290
+
291
+ return _summarize(job, used_plates, unplaced, density, margin, kerf, gap)
292
+
293
+
294
+ def _summarize(job, used_plates, unplaced, density, margin, kerf, gap):
295
+ plate_reports = []
296
+ tot_plate_area = tot_part_area_bbox = 0.0
297
+ tot_plate_wt = tot_part_wt = 0.0
298
+ tot_cost = 0.0
299
+ cost_known = True
300
+ part_net = {}
301
+
302
+ for pl in used_plates:
303
+ stype = pl["stock"]
304
+ W, H, t = stype["W"], stype["H"], stype["thickness"]
305
+ plate_area = W * H
306
+ plate_wt = plate_area * t * density
307
+
308
+ p_area_bbox = p_wt = 0.0
309
+ n_holes = 0
310
+ parts_on = {}
311
+ for pc in pl["placements"]:
312
+ bbox = pc.w * pc.h
313
+ net_area = max(0.0, pc.base_area - pc.holes_area)
314
+ p_area_bbox += bbox
315
+ wt = net_area * t * density
316
+ p_wt += wt
317
+ n_holes += len(pc.holes)
318
+ parts_on[pc.label] = parts_on.get(pc.label, 0) + 1
319
+ if stype.get("cost_per_lb") is not None:
320
+ part_net[pc.label] = part_net.get(pc.label, 0.0) + wt * float(stype["cost_per_lb"])
321
+
322
+ if stype.get("cost_per_sheet") is not None:
323
+ plate_cost = float(stype["cost_per_sheet"])
324
+ elif stype.get("cost_per_lb") is not None:
325
+ plate_cost = plate_wt * float(stype["cost_per_lb"])
326
+ else:
327
+ plate_cost = None
328
+ cost_known = False
329
+
330
+ lf = pl["bin"].largest_free()
331
+ remnant = (round(lf.w, 2), round(lf.h, 2)) if lf else None
332
+
333
+ plate_reports.append({
334
+ "index": pl["index"], "stock": stype["name"],
335
+ "size": f"{_fmt(W)} x {_fmt(H)} x {_fmt(t)}",
336
+ "W": W, "H": H, "thickness": t,
337
+ "parts": parts_on, "num_parts": len(pl["placements"]), "num_holes": n_holes,
338
+ "yield_pct": round(100 * p_area_bbox / plate_area, 1),
339
+ "plate_weight_lb": round(plate_wt, 1),
340
+ "part_weight_lb": round(p_wt, 1),
341
+ "scrap_weight_lb": round(plate_wt - p_wt, 1),
342
+ "plate_cost": None if plate_cost is None else round(plate_cost, 2),
343
+ "largest_remnant": remnant,
344
+ "placements": [vars(pc) for pc in pl["placements"]],
345
+ })
346
+
347
+ tot_plate_area += plate_area
348
+ tot_part_area_bbox += p_area_bbox
349
+ tot_plate_wt += plate_wt
350
+ tot_part_wt += p_wt
351
+ if plate_cost is not None:
352
+ tot_cost += plate_cost
353
+
354
+ overall_yield = round(100 * tot_part_area_bbox / tot_plate_area, 1) if tot_plate_area else 0.0
355
+
356
+ res = {
357
+ "meta": {
358
+ "job_name": job.get("job_name", "Nesting job"),
359
+ "customer": job.get("customer", ""),
360
+ "kerf_in": kerf, "part_gap_in": gap, "edge_margin_in": margin,
361
+ "density_lb_in3": density,
362
+ },
363
+ "plates_used": len(used_plates),
364
+ "overall_yield_pct": overall_yield,
365
+ "total_plate_weight_lb": round(tot_plate_wt, 1),
366
+ "total_part_weight_lb": round(tot_part_wt, 1),
367
+ "total_scrap_weight_lb": round(tot_plate_wt - tot_part_wt, 1),
368
+ "total_material_cost": None if not cost_known else round(tot_cost, 2),
369
+ "cost_known": cost_known,
370
+ "total_holes": sum(pr["num_holes"] for pr in plate_reports),
371
+ "part_net_cost": {k: round(v, 2) for k, v in part_net.items()},
372
+ "plate_reports": plate_reports,
373
+ "unplaced": [{"label": u["label"], "size": f'{_fmt(u["w"])} x {_fmt(u["h"])}'}
374
+ for u in unplaced],
375
+ "has_irregular": any(p.get("shape") == "irregular" for p in job["parts"]),
376
+ }
377
+ res["rfq_nesting"] = rfq_nesting_block(res)
378
+ return res
379
+
380
+
381
+ def _fmt(v):
382
+ return f"{v:g}"
383
+
384
+
385
+ # --------------------------------------------------------------------------
386
+ # RFQ hand-off block (feeds steel-rfq "Nesting / Drop Reference" table)
387
+ # --------------------------------------------------------------------------
388
+ def rfq_nesting_block(res):
389
+ """Group plates by stock material -> Material | Nesting Plan | Drop Notes rows."""
390
+ from collections import defaultdict
391
+ groups = defaultdict(list)
392
+ for pr in res["plate_reports"]:
393
+ groups[pr["stock"]].append(pr)
394
+
395
+ blocks = []
396
+ for name, prs in groups.items():
397
+ sheets = len(prs)
398
+ W, H = prs[0]["W"], prs[0]["H"]
399
+ pa = sum(pr["yield_pct"] / 100 * pr["W"] * pr["H"] for pr in prs)
400
+ ta = sum(pr["W"] * pr["H"] for pr in prs)
401
+ yld = round(100 * pa / ta, 1) if ta else 0.0
402
+ parts = sum(pr["num_parts"] for pr in prs)
403
+ drops = sorted([pr["largest_remnant"] for pr in prs if pr["largest_remnant"]],
404
+ key=lambda d: d[0] * d[1], reverse=True)[:3]
405
+ drop_txt = "; ".join(f"{_fmt(d[0])}x{_fmt(d[1])}" for d in drops) or "minimal"
406
+ cost = sum((pr["plate_cost"] or 0) for pr in prs)
407
+ blocks.append({
408
+ "material": name,
409
+ "sheets_needed": sheets,
410
+ "sheet_size": f"{_fmt(W)}x{_fmt(H)}",
411
+ "yield_pct": yld,
412
+ "nesting_plan": f"{sheets} x {_fmt(W)}x{_fmt(H)} sheet(s) - {yld}% yield, {parts} parts",
413
+ "drop_notes": f"Largest reusable drops: {drop_txt} in",
414
+ "total_cost": round(cost, 2) if res["cost_known"] else None,
415
+ })
416
+ return blocks
417
+
418
+
419
+ # --------------------------------------------------------------------------
420
+ # Text report
421
+ # --------------------------------------------------------------------------
422
+ def render_text(res):
423
+ m = res["meta"]
424
+ L = ["=" * 64, f" NEST REPORT — {m['job_name']}"]
425
+ if m.get("customer"):
426
+ L.append(f" Customer: {m['customer']}")
427
+ L.append("=" * 64)
428
+ L.append(f" Kerf {m['kerf_in']}\" | Part gap {m['part_gap_in']}\" | "
429
+ f"Edge margin {m['edge_margin_in']}\" | {m['density_lb_in3']} lb/in^3")
430
+ L.append("")
431
+ L.append(f" Plates used ............ {res['plates_used']}")
432
+ L.append(f" Overall yield .......... {res['overall_yield_pct']}%")
433
+ L.append(f" Holes / cutouts ........ {res['total_holes']}")
434
+ L.append(f" Total plate weight ..... {res['total_plate_weight_lb']} lb")
435
+ L.append(f" Net part weight ........ {res['total_part_weight_lb']} lb (holes removed)")
436
+ L.append(f" Scrap weight ........... {res['total_scrap_weight_lb']} lb")
437
+ if res["cost_known"]:
438
+ L.append(f" Material cost (plates) . ${res['total_material_cost']:,.2f}")
439
+ else:
440
+ L.append(" Material cost .......... (add cost_per_lb or cost_per_sheet to stock)")
441
+ L.append("")
442
+ L.append(" " + "-" * 60)
443
+ for pr in res["plate_reports"]:
444
+ parts_str = ", ".join(f"{lbl} x{n}" for lbl, n in pr["parts"].items())
445
+ L.append(f" PLATE {pr['index']} — {pr['stock']} ({pr['size']} in)")
446
+ L.append(f" Parts: {parts_str}")
447
+ L.append(f" Holes: {pr['num_holes']}")
448
+ L.append(f" Yield: {pr['yield_pct']}% "
449
+ f"Part wt {pr['part_weight_lb']} lb / plate {pr['plate_weight_lb']} lb "
450
+ f"Scrap {pr['scrap_weight_lb']} lb")
451
+ if pr["plate_cost"] is not None:
452
+ L.append(f" Cost: ${pr['plate_cost']:,.2f}")
453
+ if pr["largest_remnant"]:
454
+ rw, rh = pr["largest_remnant"]
455
+ L.append(f" Biggest usable drop: {_fmt(rw)} x {_fmt(rh)} in")
456
+ L.append("")
457
+ if res["part_net_cost"]:
458
+ L.append(" Net material cost per part (metal in part, before markup):")
459
+ for lbl, c in res["part_net_cost"].items():
460
+ L.append(f" {lbl:<22} ${c:,.2f}")
461
+ L.append("")
462
+ if res["unplaced"]:
463
+ L.append(" " + "!" * 60)
464
+ L.append(" DID NOT FIT (need more/larger stock):")
465
+ for u in res["unplaced"]:
466
+ L.append(f" - {u['label']} ({u['size']} in)")
467
+ L.append("")
468
+ if res["has_irregular"]:
469
+ L.append(" NOTE: irregular parts are nested by BOUNDING BOX. Supply a")
470
+ L.append(" true `area` per irregular part for exact weight/cost.")
471
+ L.append("=" * 64)
472
+ return "\n".join(L)
473
+
474
+
475
+ # --------------------------------------------------------------------------
476
+ # Visual layout (PNG per plate + combined PDF), holes drawn
477
+ # --------------------------------------------------------------------------
478
+ def render_layout(res, outdir):
479
+ import matplotlib
480
+ matplotlib.use("Agg")
481
+ import matplotlib.pyplot as plt
482
+ import matplotlib.patches as mpatches
483
+ from matplotlib.backends.backend_pdf import PdfPages
484
+
485
+ margin = res["meta"]["edge_margin_in"]
486
+ pdf_path = os.path.join(outdir, "layout.pdf")
487
+ png_paths = []
488
+
489
+ with PdfPages(pdf_path) as pdf:
490
+ for pr in res["plate_reports"]:
491
+ W, H = pr["W"], pr["H"]
492
+ fig, ax = plt.subplots(figsize=(11, max(4.0, 11 * H / W)))
493
+ ax.add_patch(mpatches.Rectangle((0, 0), W, H, fill=False, lw=2.0, ec="#222"))
494
+ ax.add_patch(mpatches.Rectangle((margin, margin), W - 2 * margin, H - 2 * margin,
495
+ fill=False, lw=0.8, ec="#bbb", ls="--"))
496
+ for pc in pr["placements"]:
497
+ x, y = pc["x"] + margin, pc["y"] + margin
498
+ w, h = pc["w"], pc["h"]
499
+ irregular = pc["shape"] == "irregular"
500
+ face = "#f4c9a0" if irregular else "#a9c8e8"
501
+ ax.add_patch(mpatches.Rectangle((x, y), w, h, facecolor=face,
502
+ edgecolor="#1a3b5c", lw=1.2,
503
+ hatch="///" if irregular else None, alpha=0.9))
504
+ for hole in pc.get("holes", []):
505
+ lx, ly = hole_local(pc, hole)
506
+ cx, cy = x + lx, y + ly
507
+ if hole.get("dia") is not None:
508
+ ax.add_patch(mpatches.Circle((cx, cy), float(hole["dia"]) / 2.0,
509
+ facecolor="white", edgecolor="#8a1c1c", lw=1.0))
510
+ elif hole.get("w") is not None:
511
+ hw, hh = float(hole["w"]), float(hole["h"])
512
+ if pc["rotated"]:
513
+ hw, hh = hh, hw
514
+ ax.add_patch(mpatches.Rectangle((cx - hw / 2, cy - hh / 2), hw, hh,
515
+ facecolor="white", edgecolor="#8a1c1c", lw=1.0))
516
+ lbl = pc["label"] + ("*" if irregular else "") + (" ⟳" if pc["rotated"] else "")
517
+ ax.text(x + w / 2, y + h / 2, lbl, ha="center", va="center",
518
+ fontsize=7, color="#0c2233")
519
+ ax.set_xlim(-1, W + 1)
520
+ ax.set_ylim(-1, H + 1)
521
+ ax.set_aspect("equal")
522
+ ax.set_title(f"PLATE {pr['index']} — {pr['stock']} ({pr['size']} in) "
523
+ f"Yield {pr['yield_pct']}% Holes {pr['num_holes']}",
524
+ fontsize=12, fontweight="bold")
525
+ ax.set_xlabel("inches")
526
+ ax.grid(True, lw=0.3, color="#eee")
527
+ png = os.path.join(outdir, f"plate_{pr['index']}.png")
528
+ fig.savefig(png, dpi=110, bbox_inches="tight")
529
+ png_paths.append(png)
530
+ pdf.savefig(fig, bbox_inches="tight")
531
+ plt.close(fig)
532
+
533
+ fig = plt.figure(figsize=(11, 8.5))
534
+ fig.text(0.5, 0.94, "NEST SUMMARY", ha="center", fontsize=18, fontweight="bold")
535
+ fig.text(0.06, 0.88, render_text(res), family="monospace", fontsize=8.0, va="top")
536
+ pdf.savefig(fig)
537
+ plt.close(fig)
538
+
539
+ return pdf_path, png_paths
540
+
541
+
542
+ # --------------------------------------------------------------------------
543
+ # DXF: overview (all plates) + one burn file per sheet
544
+ # --------------------------------------------------------------------------
545
+ def _draw_part_dxf(msp, pc, x0, y0, profile_layer, holes_layer, notes_layer, label=True):
546
+ import ezdxf
547
+ x, y = x0 + pc["x"], y0 + pc["y"]
548
+ w, h = pc["w"], pc["h"]
549
+ msp.add_lwpolyline([(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)],
550
+ dxfattribs={"layer": profile_layer, "closed": True})
551
+ for hole in pc.get("holes", []):
552
+ lx, ly = hole_local(pc, hole)
553
+ cx, cy = x + lx, y + ly
554
+ if hole.get("dia") is not None:
555
+ msp.add_circle((cx, cy), float(hole["dia"]) / 2.0, dxfattribs={"layer": holes_layer})
556
+ elif hole.get("w") is not None:
557
+ hw, hh = float(hole["w"]), float(hole["h"])
558
+ if pc["rotated"]:
559
+ hw, hh = hh, hw
560
+ msp.add_lwpolyline(
561
+ [(cx - hw / 2, cy - hh / 2), (cx + hw / 2, cy - hh / 2),
562
+ (cx + hw / 2, cy + hh / 2), (cx - hw / 2, cy + hh / 2), (cx - hw / 2, cy - hh / 2)],
563
+ dxfattribs={"layer": holes_layer, "closed": True})
564
+ if label:
565
+ msp.add_text(pc["label"], height=min(1.0, max(0.25, min(w, h) * 0.18)),
566
+ dxfattribs={"layer": notes_layer}).set_placement(
567
+ (x + w / 2, y + h / 2), align=ezdxf.enums.TextEntityAlignment.MIDDLE_CENTER)
568
+
569
+
570
+ def render_dxf_overview(res, outdir):
571
+ import ezdxf
572
+ margin = res["meta"]["edge_margin_in"]
573
+ doc = ezdxf.new("R2010")
574
+ doc.units = ezdxf.units.IN
575
+ msp = doc.modelspace()
576
+ for lyr, col in [("PLATE", 5), ("PROFILE", 3), ("HOLES", 1), ("NOTES", 7)]:
577
+ if lyr not in doc.layers:
578
+ doc.layers.add(lyr, color=col)
579
+ x_off = 0.0
580
+ for pr in res["plate_reports"]:
581
+ W, H = pr["W"], pr["H"]
582
+ msp.add_lwpolyline([(x_off, 0), (x_off + W, 0), (x_off + W, H), (x_off, H), (x_off, 0)],
583
+ dxfattribs={"layer": "PLATE", "closed": True})
584
+ for pc in pr["placements"]:
585
+ _draw_part_dxf(msp, pc, x_off + margin, margin, "PROFILE", "HOLES", "NOTES")
586
+ x_off += W + 10.0
587
+ path = os.path.join(outdir, "nest.dxf")
588
+ doc.saveas(path)
589
+ return path
590
+
591
+
592
+ def render_burn_dxfs(res, outdir):
593
+ """One DXF per sheet for the burn table. Origin at sheet corner."""
594
+ import ezdxf
595
+ margin = res["meta"]["edge_margin_in"]
596
+ paths = []
597
+ for pr in res["plate_reports"]:
598
+ doc = ezdxf.new("R2010")
599
+ doc.units = ezdxf.units.IN
600
+ msp = doc.modelspace()
601
+ for lyr, col in [("PLATE", 5), ("PROFILE", 3), ("HOLES", 1), ("NOTES", 7)]:
602
+ doc.layers.add(lyr, color=col)
603
+ W, H = pr["W"], pr["H"]
604
+ # sheet outline for reference (delete on the table if not wanted)
605
+ msp.add_lwpolyline([(0, 0), (W, 0), (W, H), (0, H), (0, 0)],
606
+ dxfattribs={"layer": "PLATE", "closed": True})
607
+ for pc in pr["placements"]:
608
+ _draw_part_dxf(msp, pc, margin, margin, "PROFILE", "HOLES", "NOTES")
609
+ path = os.path.join(outdir, f"burn_plate_{pr['index']}.dxf")
610
+ doc.saveas(path)
611
+ paths.append(path)
612
+ return paths
613
+
614
+
615
+ # --------------------------------------------------------------------------
616
+ # Main
617
+ # --------------------------------------------------------------------------
618
+ def main():
619
+ ap = argparse.ArgumentParser(description="Steel plate nesting engine")
620
+ ap.add_argument("--job", required=True)
621
+ ap.add_argument("--out", default="out")
622
+ ap.add_argument("--no-render", action="store_true", help="Skip PDF/PNG/DXF")
623
+ args = ap.parse_args()
624
+
625
+ with open(args.job) as f:
626
+ job = json.load(f)
627
+ os.makedirs(args.out, exist_ok=True)
628
+
629
+ res = run_job(job)
630
+
631
+ report = render_text(res)
632
+ print(report)
633
+ with open(os.path.join(args.out, "report.txt"), "w") as f:
634
+ f.write(report)
635
+ with open(os.path.join(args.out, "result.json"), "w") as f:
636
+ json.dump(res, f, indent=2)
637
+ with open(os.path.join(args.out, "rfq_nesting.json"), "w") as f:
638
+ json.dump(res["rfq_nesting"], f, indent=2)
639
+
640
+ if not args.no_render:
641
+ pdf, pngs = render_layout(res, args.out)
642
+ overview = render_dxf_overview(res, args.out)
643
+ burns = render_burn_dxfs(res, args.out)
644
+ print(f"\nWrote: {pdf}")
645
+ print(f" {overview} (overview)")
646
+ for b in burns:
647
+ print(f" {b} (burn table — one per sheet)")
648
+ print(f" {len(pngs)} PNG(s), report.txt, result.json, rfq_nesting.json")
649
+
650
+
651
+ if __name__ == "__main__":
652
+ main()
@@ -163,11 +163,12 @@ Output file: `[ProjectName]_RFQ_Material_List.xlsx`
163
163
  5. Group remaining items by material type (W-shapes → Plates → Flat Bar)
164
164
  6. If there's a stock purchase section, use those for plates/flat bar instead of individual pieces
165
165
  7. Build the RFQ spreadsheet using openpyxl following the format above
166
- 8. Add formulas for totals and verify the SUM ranges cover exactly the data rows (formulas calculate when the vendor opens the file in Excel)
167
- 9. Add nesting/drop reference from the estimate notes
166
+ 8. Add formulas for totals and verify the SUM ranges cover exactly the data rows
167
+ 9. Add nesting/drop reference from the estimate notes — or, if the `steel-nest` skill has been run for this job, read its `rfq_nesting.json` output straight into the table
168
168
  10. Add standard terms & conditions with profile values substituted
169
169
  11. Insert logo if configured
170
- 12. Save the file and present it to the user
170
+ 12. Save the file, then run `python3 scripts/recalc.py <output.xlsx>` so formula values are computed (openpyxl writes formulas but never calculates them)
171
+ 13. Verify no formula errors and present the file to the user
171
172
 
172
173
  ## Common Variations
173
174
 
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Recalculate an .xlsx so formula results are correct/current.
4
+
5
+ openpyxl writes formulas but never computes them, so a freshly built
6
+ workbook has stale or empty cached values. This does two things:
7
+
8
+ 1. Flags the workbook to fully recalculate when opened (Excel / LibreOffice
9
+ honor this), so values are always correct on open.
10
+ 2. If LibreOffice is installed, does a headless round-trip so the computed
11
+ values are baked into the file immediately (needed when the file is
12
+ read programmatically or previewed without opening in Excel).
13
+
14
+ Usage: python3 recalc.py <workbook.xlsx>
15
+ Exit code is 0 on success; the file is edited in place.
16
+ """
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ import tempfile
22
+
23
+
24
+ def flag_recalc_on_load(path):
25
+ import openpyxl
26
+ wb = openpyxl.load_workbook(path)
27
+ try:
28
+ wb.calculation.fullCalcOnLoad = True
29
+ except Exception:
30
+ pass # older openpyxl — the LibreOffice bake below still fixes values
31
+ wb.save(path)
32
+
33
+
34
+ def libreoffice_bake(path):
35
+ """Round-trip through LibreOffice headless to write computed values in place."""
36
+ soffice = shutil.which("soffice") or shutil.which("libreoffice")
37
+ if not soffice:
38
+ return False
39
+ with tempfile.TemporaryDirectory() as td:
40
+ try:
41
+ subprocess.run(
42
+ [soffice, "--headless", "--calc", "--convert-to", "xlsx",
43
+ "--outdir", td, path],
44
+ capture_output=True, timeout=120, check=True,
45
+ )
46
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
47
+ return False
48
+ produced = os.path.join(td, os.path.splitext(os.path.basename(path))[0] + ".xlsx")
49
+ if os.path.exists(produced):
50
+ shutil.copyfile(produced, path)
51
+ return True
52
+ return False
53
+
54
+
55
+ def main():
56
+ if len(sys.argv) < 2:
57
+ sys.exit("usage: python3 recalc.py <workbook.xlsx>")
58
+ path = sys.argv[1]
59
+ if not os.path.exists(path):
60
+ sys.exit(f"file not found: {path}")
61
+ # Set recalc-on-open FIRST so LibreOffice honors it, then bake. Do NOT
62
+ # reload with openpyxl afterward — that would strip the cached values
63
+ # LibreOffice just computed.
64
+ flag_recalc_on_load(path)
65
+ baked = libreoffice_bake(path)
66
+ if baked:
67
+ print(f"recalc: values computed and baked via LibreOffice — {path}")
68
+ else:
69
+ print(f"recalc: recalc-on-open set (open in Excel to compute values) — {path}")
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()