@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 +15 -1
- package/package.json +11 -5
- package/skills/steel-nest/SKILL.md +87 -0
- package/skills/steel-nest/references/example_job.json +47 -0
- package/skills/steel-nest/references/job_template.json +38 -0
- package/skills/steel-nest/scripts/__pycache__/nest.cpython-312.pyc +0 -0
- package/skills/steel-nest/scripts/nest.py +652 -0
- package/skills/steel-rfq/SKILL.md +4 -3
- package/skills/steel-rfq/scripts/__pycache__/recalc.cpython-312.pyc +0 -0
- package/skills/steel-rfq/scripts/recalc.py +73 -0
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.
|
|
4
|
-
"description": "Structural steel estimating skills for the Pi coding agent
|
|
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": [
|
|
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
|
+
}
|
|
Binary file
|
|
@@ -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
|
|
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
|
|
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
|
|
|
Binary file
|
|
@@ -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()
|