@structupath/pi-steel 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/DATA_PROVENANCE.json +23 -0
  2. package/DATA_PROVENANCE.md +35 -0
  3. package/PUBLIC_DATA_POLICY.md +52 -0
  4. package/README.md +114 -33
  5. package/docs/assets/pi-steel-demo.gif +0 -0
  6. package/docs/assets/pi-steel-gallery.webp +0 -0
  7. package/package.json +35 -4
  8. package/pyproject.toml +28 -0
  9. package/requirements-dev.txt +5 -0
  10. package/requirements-render.txt +1 -0
  11. package/requirements-tested.txt +9 -0
  12. package/requirements.txt +7 -0
  13. package/scripts/check-data-provenance.py +140 -0
  14. package/scripts/check-public-data.py +401 -0
  15. package/scripts/doctor.py +127 -0
  16. package/skills/_shared/bootstrap.py +32 -0
  17. package/skills/_shared/pi_steel/__init__.py +47 -0
  18. package/skills/_shared/pi_steel/cli.py +167 -0
  19. package/skills/_shared/pi_steel/contracts.py +87 -0
  20. package/skills/_shared/pi_steel/geometry_verify.py +243 -0
  21. package/skills/_shared/pi_steel/parsing.py +205 -0
  22. package/skills/_shared/pi_steel/run_manifest.py +317 -0
  23. package/skills/_shared/pi_steel/validation.py +597 -0
  24. package/skills/_shared/schemas/estimate-package.schema.json +424 -0
  25. package/skills/_shared/schemas/nest-result.schema.json +443 -0
  26. package/skills/_shared/schemas/run-manifest.schema.json +145 -0
  27. package/skills/steel-estimate/SKILL.md +119 -0
  28. package/skills/steel-estimate/references/estimate-package-example.json +91 -0
  29. package/skills/steel-estimate/references/output-contract.md +47 -0
  30. package/skills/steel-estimate/scripts/acknowledge-finding.py +193 -0
  31. package/skills/steel-estimate/scripts/build-estimate-package.py +836 -0
  32. package/skills/steel-nest/SKILL.md +50 -23
  33. package/skills/steel-nest/references/FIXTURE_PROVENANCE.md +12 -0
  34. package/skills/steel-nest/references/example_job.json +21 -25
  35. package/skills/steel-nest/references/job_template.json +11 -9
  36. package/skills/steel-nest/scripts/nest.py +1276 -250
  37. package/skills/steel-rfq/SKILL.md +93 -175
  38. package/skills/steel-rfq/assets/company-profile.example.json +11 -5
  39. package/skills/steel-rfq/references/rfq-input.md +58 -0
  40. package/skills/steel-rfq/scripts/generate-rfq.py +1221 -0
  41. package/skills/steel-rfq/scripts/recalc.py +33 -10
  42. package/skills/steel-takeoff/SKILL.md +12 -8
  43. package/skills/steel-takeoff/assets/bom-template.csv +1 -1
  44. package/skills/steel-takeoff/references/takeoff-procedures.md +3 -1
  45. package/skills/steel-takeoff/scripts/calculate-weight.sh +5 -10
  46. package/skills/steel-takeoff/scripts/lookup-member.sh +2 -2
  47. package/skills/steel-takeoff/scripts/validate-bom.py +151 -196
  48. package/skills/steel-rfq/scripts/__pycache__/recalc.cpython-312.pyc +0 -0
@@ -8,16 +8,18 @@ Reliable:
8
8
  * Nests parts onto stock plates with a MaxRects bin-packing algorithm
9
9
  (rotation, kerf + gap spacing, edge margin, multiple plate sizes,
10
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.
11
+ * Rectangular parts can carry HOLES (round) and rectangular CUTOUTS --
12
+ subtracted from weight/cost, rotated with the part, drawn in the layout,
13
+ and emitted as cut geometry only after the complete job passes its gate.
14
+ * Separate packing utilization and net material yield, part/plate weight,
15
+ optional reconciled cost, and unverified remnant candidates.
15
16
  * Labeled layout (PNG per plate + combined PDF).
16
17
  * 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.
18
+ - reference_nest.dxf all plates side-by-side (reference only)
19
+ - reference_plate_N.dxf one reference-only file per used plate
20
+ - burn_plate_N.dxf ONE FILE PER SHEET for the burn table when
21
+ every part is rectangular, every required part
22
+ fits, and supported holes stay inside the part.
21
23
 
22
24
  Deliberately NOT done:
23
25
  * True-shape nesting of irregular parts (they nest by BOUNDING BOX,
@@ -27,16 +29,59 @@ Deliberately NOT done:
27
29
  machine's own CAM/post applies those (that is where they belong).
28
30
 
29
31
  Usage:
30
- python3 nest.py --job job.json --out out/
32
+ python3 nest.py --job job.json --out published/
33
+
34
+ The output root receives isolated runs/<run-id>/ directories plus a
35
+ latest-run.json pointer. Exit 0 is ready, 2 requires review, and 3 is blocked.
31
36
  """
32
37
 
33
38
  import argparse
39
+ import importlib.util
34
40
  import json
35
41
  import math
36
42
  import os
43
+ import sys
44
+ from collections import defaultdict
37
45
  from dataclasses import dataclass, field
46
+ from pathlib import Path
47
+
48
+
49
+ SHARED_ROOT = Path(__file__).resolve().parents[2] / "_shared"
50
+ if str(SHARED_ROOT) not in sys.path:
51
+ sys.path.insert(0, str(SHARED_ROOT))
52
+ from bootstrap import bootstrap_shared # noqa: E402
53
+
54
+ bootstrap_shared(__file__)
55
+ from pi_steel import ( # noqa: E402
56
+ NEST_RESULT_VERSION,
57
+ RunPublisher,
58
+ StageArgumentParser,
59
+ canonical_json_bytes,
60
+ item_id_for,
61
+ outcome_exit_code,
62
+ package_version,
63
+ placement_ids,
64
+ publish_failure_diagnostic,
65
+ sha256_bytes,
66
+ )
67
+ from pi_steel.contracts import content_hash, fallback_source_id, instance_ids # noqa: E402
68
+ from pi_steel.geometry_verify import ( # noqa: E402
69
+ SUPPORTED_SHAPES,
70
+ finite_positive,
71
+ hole_within_bounds,
72
+ verify_nest_placements,
73
+ )
38
74
 
39
75
  STEEL_DENSITY = 0.2836 # lb/in^3, A36 mild steel
76
+ NEST_ALGORITHM_VERSION = "maxrects-bssf-u3"
77
+
78
+
79
+ def _valid_hash(value):
80
+ return (
81
+ isinstance(value, str)
82
+ and len(value) == 64
83
+ and all(character in "0123456789abcdef" for character in value)
84
+ )
40
85
 
41
86
 
42
87
  # --------------------------------------------------------------------------
@@ -53,6 +98,11 @@ class FreeRect:
53
98
  @dataclass
54
99
  class Placement:
55
100
  part_id: str
101
+ source_id: str
102
+ item_id: str
103
+ instance_id: str
104
+ placement_id: str
105
+ stock_id: str
56
106
  label: str
57
107
  x: float # placed lower-left, usable (post-margin) coords
58
108
  y: float
@@ -65,6 +115,9 @@ class Placement:
65
115
  holes: list = field(default_factory=list) # in original part coords
66
116
  base_area: float = 0.0 # gross area (bbox for rect, declared area for irregular)
67
117
  holes_area: float = 0.0 # total area removed by holes/cutouts
118
+ material: str = ""
119
+ grade: str = ""
120
+ thickness: float = 0.0
68
121
 
69
122
 
70
123
  class MaxRectsBin:
@@ -158,10 +211,6 @@ class MaxRectsBin:
158
211
  inner.x + inner.w <= outer.x + outer.w + cls.EPS and
159
212
  inner.y + inner.h <= outer.y + outer.h + cls.EPS)
160
213
 
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
214
  # --------------------------------------------------------------------------
166
215
  # Holes
167
216
  # --------------------------------------------------------------------------
@@ -189,193 +238,855 @@ def hole_local(pc, hole):
189
238
  # --------------------------------------------------------------------------
190
239
  # Job runner
191
240
  # --------------------------------------------------------------------------
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
241
+ def _legacy_hole_to_canonical(hole):
242
+ if hole.get("dia") is not None:
243
+ return {
244
+ "kind": "round",
245
+ "diameter": hole.get("dia"),
246
+ "x": hole.get("x"),
247
+ "y": hole.get("y"),
248
+ }
249
+ return {
250
+ "kind": "rect",
251
+ "width": hole.get("w"),
252
+ "height": hole.get("h"),
253
+ "x": hole.get("x"),
254
+ "y": hole.get("y"),
255
+ }
199
256
 
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)
257
+
258
+ def _validation_finding(code, path, message, severity="error"):
259
+ return {
260
+ "code": code,
261
+ "severity": severity,
262
+ "path": path,
263
+ "message": message,
264
+ }
265
+
266
+
267
+ def normalize_job(job):
268
+ """Normalize and validate the legacy direct-use JSON before any placement."""
269
+ findings = []
270
+ settings = job.get("settings", {})
271
+
272
+ def number(value, path, *, positive=False, nonnegative=False):
273
+ try:
274
+ parsed = float(value)
275
+ except (TypeError, ValueError):
276
+ parsed = math.nan
277
+ valid = math.isfinite(parsed)
278
+ if positive:
279
+ valid = valid and parsed > 0
280
+ if nonnegative:
281
+ valid = valid and parsed >= 0
282
+ if not valid:
283
+ findings.append(
284
+ _validation_finding(
285
+ "invalid_numeric_input",
286
+ path,
287
+ "Value must be finite"
288
+ + (" and greater than zero." if positive else " and non-negative."),
289
+ )
290
+ )
291
+ return 0.0
292
+ return parsed
293
+
294
+ kerf = number(settings.get("kerf_in", 0.06), "$.settings.kerf_in", nonnegative=True)
295
+ gap = number(settings.get("part_gap_in", 0.25), "$.settings.part_gap_in", nonnegative=True)
296
+ margin = number(
297
+ settings.get("edge_margin_in", 0.5),
298
+ "$.settings.edge_margin_in",
299
+ nonnegative=True,
300
+ )
301
+ density = number(
302
+ settings.get("density_lb_in3", STEEL_DENSITY),
303
+ "$.settings.density_lb_in3",
304
+ positive=True,
305
+ )
306
+ unit_system = job.get("unit_system")
307
+ if unit_system is None:
308
+ findings.append(
309
+ _validation_finding(
310
+ "missing_unit_basis",
311
+ "$.unit_system",
312
+ "The direct nesting engine requires an explicit imperial unit basis.",
313
+ )
314
+ )
315
+ elif unit_system != "imperial":
316
+ findings.append(
317
+ _validation_finding(
318
+ "unsupported_unit_system",
319
+ "$.unit_system",
320
+ "The direct nesting engine currently requires imperial inches.",
321
+ )
322
+ )
323
+
324
+ project_id = job.get("project_id") or job.get("job_name") or "LEGACY-NEST"
325
+ revision_id = job.get("revision_id", "LEGACY-REVISION")
326
+ for identity_field, value in (
327
+ ("project_id", project_id),
328
+ ("revision_id", revision_id),
329
+ ):
330
+ if not isinstance(value, str) or not value:
331
+ findings.append(
332
+ _validation_finding(
333
+ f"invalid_{identity_field}",
334
+ f"$.{identity_field}",
335
+ f"{identity_field} must be a non-empty string.",
336
+ )
337
+ )
338
+ if identity_field == "project_id":
339
+ project_id = "LEGACY-NEST"
340
+ else:
341
+ revision_id = "LEGACY-REVISION"
342
+ estimate_input_hash = job.get("estimate_input_hash")
343
+ if estimate_input_hash is not None and not _valid_hash(estimate_input_hash):
344
+ findings.append(
345
+ _validation_finding(
346
+ "invalid_estimate_input_hash",
347
+ "$.estimate_input_hash",
348
+ "Estimate input hash must be a lowercase SHA-256 value.",
349
+ )
350
+ )
351
+ estimate_input_hash = None
352
+ default_material = job.get("material")
353
+ default_grade = job.get("grade")
354
+ default_thickness = settings.get("thickness_in")
355
+ parts = []
356
+ for index, part in enumerate(job.get("parts", [])):
357
+ path = f"$.parts[{index}]"
358
+ width = number(part.get("width"), f"{path}.width", positive=True)
359
+ height = number(part.get("height"), f"{path}.height", positive=True)
360
+ thickness = number(
361
+ part.get("thickness", default_thickness),
362
+ f"{path}.thickness",
363
+ positive=True,
364
+ )
365
+ try:
366
+ quantity = int(part.get("qty", 1))
367
+ quantity_valid = quantity > 0 and quantity == float(part.get("qty", 1))
368
+ except (TypeError, ValueError):
369
+ quantity, quantity_valid = 0, False
370
+ if not quantity_valid:
371
+ findings.append(
372
+ _validation_finding(
373
+ "invalid_quantity", f"{path}.qty", "Quantity must be a positive integer."
374
+ )
375
+ )
376
+ shape = part.get("shape", "rect")
377
+ if shape not in SUPPORTED_SHAPES:
378
+ findings.append(
379
+ _validation_finding(
380
+ "unsupported_shape",
381
+ f"{path}.shape",
382
+ "Supported shapes are rect and irregular.",
383
+ )
384
+ )
385
+ material = part.get("material", default_material)
386
+ grade = part.get("grade", default_grade)
387
+ if not material or not grade or not finite_positive(thickness):
388
+ findings.append(
389
+ _validation_finding(
390
+ "missing_material_basis",
391
+ path,
392
+ "Material, grade, and thickness must be explicit before placement.",
393
+ )
394
+ )
395
+ holes = part.get("holes", []) or []
396
+ holes_area = 0.0
397
+ if finite_positive(width) and finite_positive(height):
398
+ for hole_index, hole in enumerate(holes):
399
+ if not hole_within_bounds(
400
+ _legacy_hole_to_canonical(hole), width, height
401
+ ):
402
+ findings.append(
403
+ _validation_finding(
404
+ "invalid_hole_geometry",
405
+ f"{path}.holes[{hole_index}]",
406
+ "Hole is unsupported or extends outside the part.",
407
+ )
408
+ )
409
+ try:
410
+ holes_area += hole_area(hole)
411
+ except (TypeError, ValueError):
412
+ pass
413
+ base_area = width * height if finite_positive(width) and finite_positive(height) else 0
414
+ approximation = "exact"
415
+ if shape == "irregular":
416
+ if part.get("area") is None:
417
+ approximation = "bounding_box_estimate"
418
+ findings.append(
419
+ _validation_finding(
420
+ "missing_irregular_area",
421
+ f"{path}.area",
422
+ "Irregular net area is approximated by its bounding box.",
423
+ severity="warning",
424
+ )
425
+ )
426
+ else:
427
+ declared_area = number(part.get("area"), f"{path}.area", positive=True)
428
+ if math.isfinite(declared_area) and declared_area > base_area + 1e-9:
429
+ findings.append(
430
+ _validation_finding(
431
+ "invalid_irregular_area",
432
+ f"{path}.area",
433
+ "Declared irregular area cannot exceed its bounding box.",
434
+ )
435
+ )
436
+ base_area = declared_area
437
+ approximation = "declared_area"
438
+ if base_area - holes_area <= 0:
439
+ findings.append(
440
+ _validation_finding(
441
+ "nonpositive_net_area",
442
+ path,
443
+ "Part net area after holes must be greater than zero.",
444
+ )
445
+ )
446
+ explicit_source = part.get("source_id")
447
+ source_id = explicit_source or fallback_source_id(
448
+ revision_id,
449
+ {
450
+ key: part.get(key)
451
+ for key in (
452
+ "name",
453
+ "material",
454
+ "grade",
455
+ "thickness",
456
+ "width",
457
+ "height",
458
+ "shape",
459
+ )
460
+ },
461
+ )
462
+ item_id = part.get("item_id") or item_id_for(
463
+ project_id, revision_id, source_id
464
+ )
465
+ parts.append(
466
+ {
467
+ "source_id": source_id,
468
+ "item_id": item_id,
469
+ "label": part.get("name", source_id),
470
+ "w": width,
471
+ "h": height,
472
+ "quantity": quantity,
473
+ "rotatable": bool(part.get("rotatable", True)),
474
+ "shape": shape,
475
+ "holes": holes,
476
+ "base_area": base_area,
477
+ "holes_area": holes_area,
478
+ "net_area_approximation": approximation,
479
+ "material": material,
480
+ "grade": grade,
481
+ "thickness": thickness,
482
+ }
483
+ )
217
484
 
218
485
  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
- })
486
+ for index, stock in enumerate(job.get("stock", [])):
487
+ path = f"$.stock[{index}]"
488
+ width = number(stock.get("width"), f"{path}.width", positive=True)
489
+ height = number(stock.get("height"), f"{path}.height", positive=True)
490
+ thickness = number(
491
+ stock.get("thickness", default_thickness),
492
+ f"{path}.thickness",
493
+ positive=True,
494
+ )
495
+ material = stock.get("material", default_material)
496
+ grade = stock.get("grade", default_grade)
497
+ if not material or not grade or not finite_positive(thickness):
498
+ findings.append(
499
+ _validation_finding(
500
+ "missing_material_basis",
501
+ path,
502
+ "Stock material, grade, and thickness must be explicit.",
503
+ )
504
+ )
505
+ unlimited = bool(stock.get("unlimited", False))
506
+ try:
507
+ quantity = math.inf if unlimited else int(stock.get("qty", 1))
508
+ quantity_valid = unlimited or (
509
+ quantity >= 0 and quantity == float(stock.get("qty", 1))
510
+ )
511
+ except (TypeError, ValueError):
512
+ quantity, quantity_valid = 0, False
513
+ if not quantity_valid:
514
+ findings.append(
515
+ _validation_finding(
516
+ "invalid_stock_quantity",
517
+ f"{path}.qty",
518
+ "Stock quantity must be a non-negative integer or unlimited.",
519
+ )
520
+ )
521
+ per_pound = stock.get("cost_per_lb")
522
+ per_sheet = stock.get("cost_per_sheet")
523
+ if per_pound is not None and per_sheet is not None:
524
+ findings.append(
525
+ _validation_finding(
526
+ "conflicting_cost_basis",
527
+ path,
528
+ "Use either cost_per_lb or cost_per_sheet for one stock entry, not both.",
529
+ )
530
+ )
531
+ for cost_field, value in (
532
+ ("cost_per_lb", per_pound),
533
+ ("cost_per_sheet", per_sheet),
534
+ ):
535
+ if value is not None:
536
+ parsed_cost = number(
537
+ value, f"{path}.{cost_field}", nonnegative=True
538
+ )
539
+ if cost_field == "cost_per_lb":
540
+ per_pound = parsed_cost
541
+ else:
542
+ per_sheet = parsed_cost
543
+ stock_id = stock.get("stock_id") or (
544
+ "stock:"
545
+ + content_hash(
546
+ {
547
+ "name": stock.get("name", "Plate"),
548
+ "material": material,
549
+ "grade": grade,
550
+ "thickness": thickness,
551
+ "width": width,
552
+ "height": height,
553
+ }
554
+ )[:24]
555
+ )
556
+ stock_types.append(
557
+ {
558
+ "stock_id": stock_id,
559
+ "name": stock.get("name", "Plate"),
560
+ "material": material,
561
+ "grade": grade,
562
+ "W": width,
563
+ "H": height,
564
+ "thickness": thickness,
565
+ "qty": quantity,
566
+ "cost_per_lb": per_pound,
567
+ "cost_per_sheet": per_sheet,
568
+ "used": 0,
569
+ }
570
+ )
571
+ for collection_name, values, identity_field in (
572
+ ("parts", parts, "item_id"),
573
+ ("stock", stock_types, "stock_id"),
574
+ ):
575
+ seen = {}
576
+ for index, value in enumerate(values):
577
+ identity = value[identity_field]
578
+ if identity in seen:
579
+ findings.append(
580
+ _validation_finding(
581
+ f"duplicate_{identity_field}",
582
+ f"$.{collection_name}[{index}].{identity_field}",
583
+ (
584
+ f"{identity_field} duplicates row {seen[identity]}; "
585
+ "indistinguishable rows are not merged."
586
+ ),
587
+ )
588
+ )
589
+ else:
590
+ seen[identity] = index
591
+ parts.sort(key=lambda part: part["item_id"])
592
+ stock_types.sort(key=lambda stock: stock["stock_id"])
593
+ if not parts:
594
+ findings.append(
595
+ _validation_finding("missing_parts", "$.parts", "At least one part is required.")
596
+ )
597
+ if not stock_types:
598
+ findings.append(
599
+ _validation_finding("missing_stock", "$.stock", "At least one stock entry is required.")
600
+ )
601
+ normalized = {
602
+ "job_name": job.get("job_name", "Nesting job"),
603
+ "customer": job.get("customer", ""),
604
+ "project_id": project_id,
605
+ "revision_id": revision_id,
606
+ "estimate_input_hash": estimate_input_hash,
607
+ "unit_system": unit_system or "unspecified",
608
+ "settings": {
609
+ "kerf_in": kerf,
610
+ "part_gap_in": gap,
611
+ "edge_margin_in": margin,
612
+ "density_lb_in3": density,
613
+ },
614
+ "parts": parts,
615
+ "stock": [
616
+ {
617
+ key: ("unlimited" if key == "qty" and math.isinf(value) else value)
618
+ for key, value in stock.items()
619
+ if key != "used"
620
+ }
621
+ for stock in stock_types
622
+ ],
623
+ }
624
+ return normalized, stock_types, findings
625
+
229
626
 
627
+ def _material_key(value):
628
+ return value.get("material"), value.get("grade"), value.get("thickness")
629
+
630
+
631
+ def _aggregate_unplaced(units):
632
+ grouped = {}
633
+ for unit in units:
634
+ key = (unit["item_id"], unit["reason"])
635
+ row = grouped.setdefault(
636
+ key,
637
+ {
638
+ "item_id": unit["item_id"],
639
+ "label": unit["label"],
640
+ "quantity": 0,
641
+ "size": f'{_fmt(unit["w"])} x {_fmt(unit["h"])}',
642
+ "reason": unit["reason"],
643
+ },
644
+ )
645
+ row["quantity"] += 1
646
+ return sorted(grouped.values(), key=lambda row: (row["item_id"], row["reason"]))
647
+
648
+
649
+ def run_job(job):
650
+ normalized, stock_types, validation_findings = normalize_job(job)
651
+ settings = normalized["settings"]
652
+ kerf = settings["kerf_in"]
653
+ gap = settings["part_gap_in"]
654
+ margin = settings["edge_margin_in"]
655
+ density = settings["density_lb_in3"]
656
+ spacing = kerf + gap
657
+ normalized_hash = sha256_bytes(canonical_json_bytes(normalized))
658
+ blockers = [
659
+ finding
660
+ for finding in validation_findings
661
+ if finding["severity"] == "error"
662
+ ]
663
+ if blockers:
664
+ return _summarize(
665
+ normalized,
666
+ [],
667
+ [],
668
+ density,
669
+ margin,
670
+ kerf,
671
+ gap,
672
+ validation_findings,
673
+ normalized_hash,
674
+ )
675
+
676
+ units = []
677
+ for part in normalized["parts"]:
678
+ item_instances = instance_ids(part["item_id"], part["quantity"])
679
+ item_placements = placement_ids(part["item_id"], part["quantity"])
680
+ for index in range(part["quantity"]):
681
+ units.append(
682
+ {
683
+ **part,
684
+ "part_id": part["item_id"],
685
+ "instance_id": item_instances[index],
686
+ "placement_id": item_placements[index],
687
+ }
688
+ )
689
+ units.sort(key=lambda unit: (-unit["w"] * unit["h"], unit["instance_id"]))
230
690
  plates = []
231
691
 
232
- def open_plate(fit=None):
233
- for stype in stock_types:
234
- if stype["used"] >= stype["qty"]:
692
+ def compatible(stock, unit):
693
+ return _material_key(stock) == _material_key(unit)
694
+
695
+ def can_fit(stock, unit):
696
+ usable_width = stock["W"] - 2 * margin
697
+ usable_height = stock["H"] - 2 * margin
698
+ direct = unit["w"] <= usable_width + 1e-9 and unit["h"] <= usable_height + 1e-9
699
+ rotated = (
700
+ unit["rotatable"]
701
+ and unit["h"] <= usable_width + 1e-9
702
+ and unit["w"] <= usable_height + 1e-9
703
+ )
704
+ return compatible(stock, unit) and (direct or rotated)
705
+
706
+ def open_plate(unit):
707
+ for stock in stock_types:
708
+ if stock["used"] >= stock["qty"] or not can_fit(stock, unit):
235
709
  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]
710
+ usable_width = stock["W"] - 2 * margin
711
+ usable_height = stock["H"] - 2 * margin
712
+ stock["used"] += 1
713
+ plate = {
714
+ "stock": stock,
715
+ "bin": MaxRectsBin(usable_width + spacing, usable_height + spacing),
716
+ "placements": [],
717
+ }
718
+ plates.append(plate)
719
+ return plate
247
720
  return None
248
721
 
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)
722
+ def commit(plate, unit, placement):
723
+ x, y, packed_width, packed_height, rotated = placement
724
+ plate["placements"].append(
725
+ Placement(
726
+ part_id=unit["part_id"],
727
+ source_id=unit["source_id"],
728
+ item_id=unit["item_id"],
729
+ instance_id=unit["instance_id"],
730
+ placement_id=unit["placement_id"],
731
+ stock_id=plate["stock"]["stock_id"],
732
+ label=unit["label"],
733
+ x=x,
734
+ y=y,
735
+ w=packed_width - spacing,
736
+ h=packed_height - spacing,
737
+ rotated=rotated,
738
+ shape=unit["shape"],
739
+ ow=unit["w"],
740
+ oh=unit["h"],
741
+ holes=unit["holes"],
742
+ base_area=unit["base_area"],
743
+ holes_area=unit["holes_area"],
744
+ material=unit["material"],
745
+ grade=unit["grade"],
746
+ thickness=unit["thickness"],
747
+ )
748
+ )
749
+
750
+ unplaced_units = []
751
+ for unit in units:
752
+ compatible_stock = [
753
+ stock for stock in stock_types if can_fit(stock, unit)
754
+ ]
755
+ if not compatible_stock:
756
+ unplaced_units.append({**unit, "reason": "no_compatible_stock_fit"})
268
757
  continue
269
- fw, fh = u["w"] + spacing, u["h"] + spacing
758
+ packed_width, packed_height = unit["w"] + spacing, unit["h"] + spacing
270
759
  placed = False
271
- for pl in plates:
272
- res = pl["bin"].insert(fw, fh, u["rotatable"])
273
- if res:
274
- commit(pl, u, res)
760
+ for plate in plates:
761
+ if not compatible(plate["stock"], unit):
762
+ continue
763
+ placement = plate["bin"].insert(
764
+ packed_width, packed_height, unit["rotatable"]
765
+ )
766
+ if placement:
767
+ commit(plate, unit, placement)
275
768
  placed = True
276
769
  break
277
770
  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)
771
+ plate = open_plate(unit)
772
+ if plate is not None:
773
+ placement = plate["bin"].insert(
774
+ packed_width, packed_height, unit["rotatable"]
775
+ )
776
+ if placement:
777
+ commit(plate, unit, placement)
283
778
  placed = True
284
779
  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):
780
+ unplaced_units.append({**unit, "reason": "stock_exhausted"})
781
+
782
+ used_plates = [plate for plate in plates if plate["placements"]]
783
+ for index, plate in enumerate(used_plates, 1):
784
+ plate["index"] = index
785
+ return _summarize(
786
+ normalized,
787
+ used_plates,
788
+ _aggregate_unplaced(unplaced_units),
789
+ density,
790
+ margin,
791
+ kerf,
792
+ gap,
793
+ validation_findings,
794
+ normalized_hash,
795
+ )
796
+
797
+
798
+ def _metric(value, approximation):
799
+ return {"value": round(value, 1), "approximation": approximation}
800
+
801
+
802
+ def _remnant_candidates(plate, margin, spacing):
803
+ stock = plate["stock"]
804
+ usable_width = stock["W"] - 2 * margin
805
+ usable_height = stock["H"] - 2 * margin
806
+ candidates = []
807
+ for free in plate["bin"].free:
808
+ width = max(0.0, min(free.w, usable_width - free.x))
809
+ height = max(0.0, min(free.h, usable_height - free.y))
810
+ if width > spacing and height > spacing:
811
+ candidates.append(
812
+ {
813
+ "width": round(width, 2),
814
+ "height": round(height, 2),
815
+ "area": round(width * height, 2),
816
+ "status": "candidate_unverified",
817
+ }
818
+ )
819
+ return sorted(
820
+ candidates, key=lambda candidate: candidate["area"], reverse=True
821
+ )[:3]
822
+
823
+
824
+ def _summarize(
825
+ normalized,
826
+ used_plates,
827
+ unplaced,
828
+ density,
829
+ margin,
830
+ kerf,
831
+ gap,
832
+ validation_findings,
833
+ normalized_hash,
834
+ ):
835
+ estimate_input_hash = normalized.get("estimate_input_hash") or normalized_hash
295
836
  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
837
+ total_plate_area = total_packing_area = total_net_area = 0.0
838
+ total_plate_weight = total_part_weight = 0.0
839
+ total_cost = 0.0
840
+ all_used_costs_known = bool(used_plates)
841
+ part_net_cost = {}
842
+ has_irregular = any(part["shape"] == "irregular" for part in normalized["parts"])
843
+ net_approximations = {
844
+ part["net_area_approximation"] for part in normalized["parts"]
845
+ }
846
+ net_approximation_by_item = {
847
+ part["item_id"]: part["net_area_approximation"]
848
+ for part in normalized["parts"]
849
+ }
850
+
851
+ for plate in used_plates:
852
+ stock = plate["stock"]
853
+ width, height, thickness = stock["W"], stock["H"], stock["thickness"]
854
+ plate_area = width * height
855
+ plate_weight = plate_area * thickness * density
856
+ packing_area = net_area_total = part_weight = 0.0
857
+ holes = 0
310
858
  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"])
859
+ for placement in plate["placements"]:
860
+ packing_area += placement.w * placement.h
861
+ net_area = max(0.0, placement.base_area - placement.holes_area)
862
+ net_area_total += net_area
863
+ weight = net_area * thickness * density
864
+ part_weight += weight
865
+ holes += len(placement.holes)
866
+ parts_on[placement.label] = parts_on.get(placement.label, 0) + 1
867
+ if stock["cost_per_lb"] is not None:
868
+ part_net_cost[placement.label] = (
869
+ part_net_cost.get(placement.label, 0.0)
870
+ + weight * stock["cost_per_lb"]
871
+ )
872
+
873
+ if stock["cost_per_sheet"] is not None:
874
+ plate_cost = stock["cost_per_sheet"]
875
+ cost_basis = "per_sheet"
876
+ elif stock["cost_per_lb"] is not None:
877
+ plate_cost = plate_weight * stock["cost_per_lb"]
878
+ cost_basis = "per_pound"
326
879
  else:
327
880
  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
881
+ cost_basis = None
882
+ all_used_costs_known = False
351
883
  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 = {
884
+ total_cost += plate_cost
885
+ packing_approximation = "bounding_box" if any(
886
+ placement.shape == "irregular" for placement in plate["placements"]
887
+ ) else "exact"
888
+ plate_net_statuses = {
889
+ net_approximation_by_item[placement.item_id]
890
+ for placement in plate["placements"]
891
+ }
892
+ net_approximation = (
893
+ "exact"
894
+ if plate_net_statuses == {"exact"}
895
+ else (
896
+ "bounding_box_estimate"
897
+ if "bounding_box_estimate" in plate_net_statuses
898
+ else "declared_area"
899
+ )
900
+ )
901
+ report = {
902
+ "index": plate["index"],
903
+ "stock": stock["name"],
904
+ "stock_id": stock["stock_id"],
905
+ "material": stock["material"],
906
+ "grade": stock["grade"],
907
+ "size": f"{_fmt(width)} x {_fmt(height)} x {_fmt(thickness)}",
908
+ "W": width,
909
+ "H": height,
910
+ "thickness": thickness,
911
+ "parts": parts_on,
912
+ "num_parts": len(plate["placements"]),
913
+ "num_holes": holes,
914
+ "packing_utilization_pct": _metric(
915
+ 100 * packing_area / plate_area, packing_approximation
916
+ ),
917
+ "net_material_yield_pct": _metric(
918
+ 100 * net_area_total / plate_area, net_approximation
919
+ ),
920
+ "plate_weight_lb": round(plate_weight, 1),
921
+ "part_weight_lb": round(part_weight, 1),
922
+ "scrap_weight_lb": round(plate_weight - part_weight, 1),
923
+ "plate_cost": None if plate_cost is None else round(plate_cost, 2),
924
+ "cost_basis": cost_basis,
925
+ "remnant_candidates": _remnant_candidates(plate, margin, kerf + gap),
926
+ "placements": [vars(placement) for placement in plate["placements"]],
927
+ }
928
+ plate_reports.append(report)
929
+ total_plate_area += plate_area
930
+ total_packing_area += packing_area
931
+ total_net_area += net_area_total
932
+ total_plate_weight += plate_weight
933
+ total_part_weight += part_weight
934
+
935
+ if unplaced:
936
+ cost_status, cost_total = "incomplete_unplaced", None
937
+ elif all_used_costs_known:
938
+ cost_status, cost_total = "known", round(total_cost, 2)
939
+ else:
940
+ cost_status, cost_total = "not_provided", None
941
+ packing_status = "bounding_box" if has_irregular else "exact"
942
+ net_status = (
943
+ "bounding_box_estimate"
944
+ if "bounding_box_estimate" in net_approximations
945
+ else ("declared_area" if "declared_area" in net_approximations else "exact")
946
+ )
947
+ metrics = {
948
+ "packing_utilization_pct": _metric(
949
+ 100 * total_packing_area / total_plate_area if total_plate_area else 0,
950
+ packing_status,
951
+ ),
952
+ "net_material_yield_pct": _metric(
953
+ 100 * total_net_area / total_plate_area if total_plate_area else 0,
954
+ net_status,
955
+ ),
956
+ }
957
+ verification_findings = verify_nest_placements(
958
+ plate_reports,
959
+ edge_margin=margin,
960
+ inter_part_clearance=kerf + gap,
961
+ )
962
+ invalid_holes = [
963
+ finding["message"]
964
+ for finding in validation_findings
965
+ if finding["code"] == "invalid_hole_geometry"
966
+ ]
967
+ blockers = [
968
+ finding
969
+ for finding in validation_findings
970
+ if finding["severity"] == "error"
971
+ ]
972
+ burn_warnings = []
973
+ if has_irregular:
974
+ burn_warnings.append(
975
+ "Irregular parts use approximate bounding boxes; burn DXFs are suppressed."
976
+ )
977
+ if unplaced:
978
+ burn_warnings.append(
979
+ f"{sum(row['quantity'] for row in unplaced)} required part(s) did not fit; "
980
+ "burn DXFs are suppressed."
981
+ )
982
+ burn_warnings.extend(invalid_holes)
983
+ burn_warnings.extend(finding["message"] for finding in verification_findings)
984
+ burn_warnings.extend(
985
+ finding["message"] for finding in blockers if finding["code"] != "invalid_hole_geometry"
986
+ )
987
+ if blockers or unplaced or verification_findings:
988
+ geometry_readiness = "diagnostic"
989
+ elif has_irregular:
990
+ geometry_readiness = "reference_only"
991
+ else:
992
+ geometry_readiness = "geometry_verified"
993
+
994
+ placements_by_group = defaultdict(list)
995
+ for plate in plate_reports:
996
+ for placement in plate["placements"]:
997
+ placements_by_group[
998
+ (
999
+ placement["material"],
1000
+ placement["grade"],
1001
+ placement["thickness"],
1002
+ )
1003
+ ].append(placement)
1004
+
1005
+ groups = []
1006
+ group_keys = sorted(
1007
+ {_material_key(part) for part in normalized["parts"]}, key=repr
1008
+ )
1009
+ for material, grade, thickness in group_keys:
1010
+ groups.append(
1011
+ {
1012
+ "group_id": "nest-group:"
1013
+ + content_hash(
1014
+ {
1015
+ "material": material,
1016
+ "grade": grade,
1017
+ "thickness": thickness,
1018
+ }
1019
+ )[:20],
1020
+ "material": material,
1021
+ "grade": grade,
1022
+ "thickness": thickness,
1023
+ "placements": placements_by_group[(material, grade, thickness)],
1024
+ }
1025
+ )
1026
+ configuration_hash = sha256_bytes(
1027
+ canonical_json_bytes(
1028
+ {
1029
+ "algorithm_version": NEST_ALGORITHM_VERSION,
1030
+ "settings": normalized["settings"],
1031
+ "clearance_contract": "edge-margin-and-inter-part-v1",
1032
+ }
1033
+ )
1034
+ )
1035
+ result = {
1036
+ "schema_version": NEST_RESULT_VERSION,
1037
+ "algorithm_version": NEST_ALGORITHM_VERSION,
1038
+ "normalized_input_hash": normalized_hash,
1039
+ "estimate_input_hash": estimate_input_hash,
1040
+ "configuration_hash": configuration_hash,
1041
+ "outcome": "blocked",
1042
+ "package_status": "draft",
357
1043
  "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,
1044
+ "job_name": normalized["job_name"],
1045
+ "customer": normalized["customer"],
1046
+ "project_id": normalized["project_id"],
1047
+ "revision_id": normalized["revision_id"],
1048
+ "kerf_in": kerf,
1049
+ "part_gap_in": gap,
1050
+ "edge_margin_in": margin,
361
1051
  "density_lb_in3": density,
1052
+ "unit_system": normalized["unit_system"],
1053
+ },
1054
+ "clearance_contract": {
1055
+ "edge_margin_ownership": "plate_to_part",
1056
+ "inter_part_clearance_ownership": "kerf_plus_gap",
1057
+ "trailing_clearance_required_at_plate_edge": False,
1058
+ },
1059
+ "groups": groups,
1060
+ "plates_used": len(plate_reports),
1061
+ "metrics": metrics,
1062
+ "total_plate_weight_lb": round(total_plate_weight, 1),
1063
+ "total_part_weight_lb": round(total_part_weight, 1),
1064
+ "total_scrap_weight_lb": round(total_plate_weight - total_part_weight, 1),
1065
+ "cost": {"status": cost_status, "total": cost_total},
1066
+ "total_material_cost": cost_total,
1067
+ "cost_known": cost_status == "known",
1068
+ "total_holes": sum(report["num_holes"] for report in plate_reports),
1069
+ "part_net_cost": {
1070
+ key: round(value, 2) for key, value in part_net_cost.items()
362
1071
  },
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
1072
  "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"]),
1073
+ "unplaced": unplaced,
1074
+ "has_irregular": has_irregular,
1075
+ "invalid_hole_warnings": invalid_holes,
1076
+ "validation_findings": validation_findings,
1077
+ "verification": {
1078
+ "status": "verified" if not verification_findings else "failed",
1079
+ "findings": verification_findings,
1080
+ },
1081
+ "geometry_readiness": geometry_readiness,
1082
+ "burn_dxf_eligible": not burn_warnings,
1083
+ "burn_dxf_warnings": burn_warnings,
376
1084
  }
377
- res["rfq_nesting"] = rfq_nesting_block(res)
378
- return res
1085
+ result["rfq_nesting"] = rfq_nesting_block(result)
1086
+ outcome, package_status, _ = stage_decision(result)
1087
+ result["outcome"] = outcome
1088
+ result["package_status"] = package_status
1089
+ return result
379
1090
 
380
1091
 
381
1092
  def _fmt(v):
@@ -386,34 +1097,78 @@ def _fmt(v):
386
1097
  # RFQ hand-off block (feeds steel-rfq "Nesting / Drop Reference" table)
387
1098
  # --------------------------------------------------------------------------
388
1099
  def rfq_nesting_block(res):
389
- """Group plates by stock material -> Material | Nesting Plan | Drop Notes rows."""
390
- from collections import defaultdict
1100
+ """Build the versioned nest-to-RFQ handoff without merging stock variants."""
391
1101
  groups = defaultdict(list)
392
1102
  for pr in res["plate_reports"]:
393
- groups[pr["stock"]].append(pr)
1103
+ groups[
1104
+ (
1105
+ pr["stock_id"],
1106
+ pr["material"],
1107
+ pr["grade"],
1108
+ pr["thickness"],
1109
+ pr["W"],
1110
+ pr["H"],
1111
+ )
1112
+ ].append(pr)
394
1113
 
395
1114
  blocks = []
396
- for name, prs in groups.items():
1115
+ for key, prs in sorted(groups.items(), key=lambda item: repr(item[0])):
1116
+ stock_id, material, grade, thickness, width, height = key
397
1117
  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
1118
+ packing_area = sum(
1119
+ pr["packing_utilization_pct"]["value"] / 100 * pr["W"] * pr["H"]
1120
+ for pr in prs
1121
+ )
1122
+ total_area = sum(pr["W"] * pr["H"] for pr in prs)
1123
+ utilization = round(100 * packing_area / total_area, 1) if total_area else 0.0
402
1124
  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)
1125
+ candidates = sorted(
1126
+ [
1127
+ candidate
1128
+ for pr in prs
1129
+ for candidate in pr["remnant_candidates"]
1130
+ ],
1131
+ key=lambda candidate: candidate["area"],
1132
+ reverse=True,
1133
+ )[:3]
1134
+ candidate_text = (
1135
+ "; ".join(
1136
+ f"{_fmt(candidate['width'])}x{_fmt(candidate['height'])}"
1137
+ for candidate in candidates
1138
+ )
1139
+ or "none"
1140
+ )
1141
+ costs_known = all(pr["plate_cost"] is not None for pr in prs)
1142
+ cost = sum(pr["plate_cost"] for pr in prs if pr["plate_cost"] is not None)
407
1143
  blocks.append({
408
- "material": name,
1144
+ "stock_id": stock_id,
1145
+ "stock_name": prs[0]["stock"],
1146
+ "material": material,
1147
+ "grade": grade,
1148
+ "thickness": thickness,
409
1149
  "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,
1150
+ "sheet_size": f"{_fmt(width)}x{_fmt(height)}",
1151
+ "packing_utilization_pct": utilization,
1152
+ "nesting_plan": (
1153
+ f"{sheets} x {_fmt(width)}x{_fmt(height)} sheet(s) - "
1154
+ f"{utilization}% packing utilization, {parts} parts"
1155
+ ),
1156
+ "drop_notes": (
1157
+ f"Remnant candidates (not certified reusable): {candidate_text} in"
1158
+ ),
1159
+ "remnant_candidates": candidates,
1160
+ "total_cost": round(cost, 2) if costs_known and not res["unplaced"] else None,
1161
+ "geometry_readiness": res["geometry_readiness"],
415
1162
  })
416
- return blocks
1163
+ return {
1164
+ "schema_version": "1.0.0",
1165
+ "source_nest_result_version": NEST_RESULT_VERSION,
1166
+ "project_id": res["meta"]["project_id"],
1167
+ "revision_id": res["meta"]["revision_id"],
1168
+ "estimate_input_hash": res["estimate_input_hash"],
1169
+ "geometry_readiness": res["geometry_readiness"],
1170
+ "rows": blocks,
1171
+ }
417
1172
 
418
1173
 
419
1174
  # --------------------------------------------------------------------------
@@ -429,7 +1184,16 @@ def render_text(res):
429
1184
  f"Edge margin {m['edge_margin_in']}\" | {m['density_lb_in3']} lb/in^3")
430
1185
  L.append("")
431
1186
  L.append(f" Plates used ............ {res['plates_used']}")
432
- L.append(f" Overall yield .......... {res['overall_yield_pct']}%")
1187
+ packing = res["metrics"]["packing_utilization_pct"]
1188
+ net_yield = res["metrics"]["net_material_yield_pct"]
1189
+ L.append(
1190
+ f" Packing utilization ... {packing['value']}% "
1191
+ f"({packing['approximation']})"
1192
+ )
1193
+ L.append(
1194
+ f" Net material yield .... {net_yield['value']}% "
1195
+ f"({net_yield['approximation']})"
1196
+ )
433
1197
  L.append(f" Holes / cutouts ........ {res['total_holes']}")
434
1198
  L.append(f" Total plate weight ..... {res['total_plate_weight_lb']} lb")
435
1199
  L.append(f" Net part weight ........ {res['total_part_weight_lb']} lb (holes removed)")
@@ -445,14 +1209,20 @@ def render_text(res):
445
1209
  L.append(f" PLATE {pr['index']} — {pr['stock']} ({pr['size']} in)")
446
1210
  L.append(f" Parts: {parts_str}")
447
1211
  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")
1212
+ L.append(
1213
+ f" Packing: {pr['packing_utilization_pct']['value']}% "
1214
+ f"Net yield {pr['net_material_yield_pct']['value']}% "
1215
+ f"Part wt {pr['part_weight_lb']} lb / plate "
1216
+ f"{pr['plate_weight_lb']} lb Scrap {pr['scrap_weight_lb']} lb"
1217
+ )
451
1218
  if pr["plate_cost"] is not None:
452
1219
  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")
1220
+ if pr["remnant_candidates"]:
1221
+ candidate = pr["remnant_candidates"][0]
1222
+ L.append(
1223
+ " Largest remnant candidate (not certified): "
1224
+ f"{_fmt(candidate['width'])} x {_fmt(candidate['height'])} in"
1225
+ )
456
1226
  L.append("")
457
1227
  if res["part_net_cost"]:
458
1228
  L.append(" Net material cost per part (metal in part, before markup):")
@@ -463,11 +1233,20 @@ def render_text(res):
463
1233
  L.append(" " + "!" * 60)
464
1234
  L.append(" DID NOT FIT (need more/larger stock):")
465
1235
  for u in res["unplaced"]:
466
- L.append(f" - {u['label']} ({u['size']} in)")
1236
+ L.append(
1237
+ f" - {u['label']} x{u['quantity']} "
1238
+ f"({u['size']} in; {u['reason']})"
1239
+ )
467
1240
  L.append("")
468
1241
  if res["has_irregular"]:
469
1242
  L.append(" NOTE: irregular parts are nested by BOUNDING BOX. Supply a")
470
1243
  L.append(" true `area` per irregular part for exact weight/cost.")
1244
+ if res["burn_dxf_warnings"]:
1245
+ L.append("")
1246
+ L.append(" BURN DXF SUPPRESSED:")
1247
+ for warning in res["burn_dxf_warnings"]:
1248
+ L.append(f" - {warning}")
1249
+ L.append(" Reference layouts are estimating aids, not cutting instructions.")
471
1250
  L.append("=" * 64)
472
1251
  return "\n".join(L)
473
1252
 
@@ -520,7 +1299,8 @@ def render_layout(res, outdir):
520
1299
  ax.set_ylim(-1, H + 1)
521
1300
  ax.set_aspect("equal")
522
1301
  ax.set_title(f"PLATE {pr['index']} — {pr['stock']} ({pr['size']} in) "
523
- f"Yield {pr['yield_pct']}% Holes {pr['num_holes']}",
1302
+ f"Packing {pr['packing_utilization_pct']['value']}% "
1303
+ f"Holes {pr['num_holes']}",
524
1304
  fontsize=12, fontweight="bold")
525
1305
  ax.set_xlabel("inches")
526
1306
  ax.grid(True, lw=0.3, color="#eee")
@@ -540,14 +1320,17 @@ def render_layout(res, outdir):
540
1320
 
541
1321
 
542
1322
  # --------------------------------------------------------------------------
543
- # DXF: overview (all plates) + one burn file per sheet
1323
+ # DXF: clearly separated reference and geometry-verified burn files
544
1324
  # --------------------------------------------------------------------------
545
1325
  def _draw_part_dxf(msp, pc, x0, y0, profile_layer, holes_layer, notes_layer, label=True):
546
1326
  import ezdxf
547
1327
  x, y = x0 + pc["x"], y0 + pc["y"]
548
1328
  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})
1329
+ msp.add_lwpolyline(
1330
+ [(x, y), (x + w, y), (x + w, y + h), (x, y + h)],
1331
+ close=True,
1332
+ dxfattribs={"layer": profile_layer},
1333
+ )
551
1334
  for hole in pc.get("holes", []):
552
1335
  lx, ly = hole_local(pc, hole)
553
1336
  cx, cy = x + lx, y + ly
@@ -558,39 +1341,87 @@ def _draw_part_dxf(msp, pc, x0, y0, profile_layer, holes_layer, notes_layer, lab
558
1341
  if pc["rotated"]:
559
1342
  hw, hh = hh, hw
560
1343
  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:
1344
+ [
1345
+ (cx - hw / 2, cy - hh / 2),
1346
+ (cx + hw / 2, cy - hh / 2),
1347
+ (cx + hw / 2, cy + hh / 2),
1348
+ (cx - hw / 2, cy + hh / 2),
1349
+ ],
1350
+ close=True,
1351
+ dxfattribs={"layer": holes_layer},
1352
+ )
1353
+ if label and notes_layer:
565
1354
  msp.add_text(pc["label"], height=min(1.0, max(0.25, min(w, h) * 0.18)),
566
1355
  dxfattribs={"layer": notes_layer}).set_placement(
567
1356
  (x + w / 2, y + h / 2), align=ezdxf.enums.TextEntityAlignment.MIDDLE_CENTER)
568
1357
 
569
1358
 
570
1359
  def render_dxf_overview(res, outdir):
1360
+ """Write one explicitly reference-only overview with bounding-box outlines."""
571
1361
  import ezdxf
572
1362
  margin = res["meta"]["edge_margin_in"]
573
1363
  doc = ezdxf.new("R2010")
574
1364
  doc.units = ezdxf.units.IN
575
1365
  msp = doc.modelspace()
576
- for lyr, col in [("PLATE", 5), ("PROFILE", 3), ("HOLES", 1), ("NOTES", 7)]:
1366
+ for lyr, col in [("PLATE", 5), ("BOUNDS", 2), ("HOLES", 1), ("NOTES", 7)]:
577
1367
  if lyr not in doc.layers:
578
1368
  doc.layers.add(lyr, color=col)
579
1369
  x_off = 0.0
580
1370
  for pr in res["plate_reports"]:
581
1371
  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})
1372
+ msp.add_lwpolyline(
1373
+ [(x_off, 0), (x_off + W, 0), (x_off + W, H), (x_off, H)],
1374
+ close=True,
1375
+ dxfattribs={"layer": "PLATE"},
1376
+ )
584
1377
  for pc in pr["placements"]:
585
- _draw_part_dxf(msp, pc, x_off + margin, margin, "PROFILE", "HOLES", "NOTES")
1378
+ _draw_part_dxf(
1379
+ msp, pc, x_off + margin, margin, "BOUNDS", "HOLES", "NOTES"
1380
+ )
586
1381
  x_off += W + 10.0
587
- path = os.path.join(outdir, "nest.dxf")
1382
+ path = os.path.join(outdir, "reference_nest.dxf")
588
1383
  doc.saveas(path)
589
1384
  return path
590
1385
 
591
1386
 
1387
+ def render_reference_plate_dxfs(res, outdir):
1388
+ """Write one clearly named reference-only DXF per used plate."""
1389
+ import ezdxf
1390
+
1391
+ margin = res["meta"]["edge_margin_in"]
1392
+ paths = []
1393
+ for pr in res["plate_reports"]:
1394
+ doc = ezdxf.new("R2010")
1395
+ doc.units = ezdxf.units.IN
1396
+ msp = doc.modelspace()
1397
+ for layer, color in [
1398
+ ("PLATE", 5),
1399
+ ("BOUNDS", 2),
1400
+ ("HOLES", 1),
1401
+ ("NOTES", 7),
1402
+ ]:
1403
+ doc.layers.add(layer, color=color)
1404
+ width, height = pr["W"], pr["H"]
1405
+ msp.add_lwpolyline(
1406
+ [(0, 0), (width, 0), (width, height), (0, height)],
1407
+ close=True,
1408
+ dxfattribs={"layer": "PLATE"},
1409
+ )
1410
+ for placement in pr["placements"]:
1411
+ _draw_part_dxf(
1412
+ msp, placement, margin, margin, "BOUNDS", "HOLES", "NOTES"
1413
+ )
1414
+ path = os.path.join(outdir, f"reference_plate_{pr['index']}.dxf")
1415
+ doc.saveas(path)
1416
+ paths.append(path)
1417
+ return paths
1418
+
1419
+
592
1420
  def render_burn_dxfs(res, outdir):
593
- """One DXF per sheet for the burn table. Origin at sheet corner."""
1421
+ """Write cut-geometry-only DXFs for a fully verified rectangular nest."""
1422
+ if not res.get("burn_dxf_eligible", False):
1423
+ return []
1424
+
594
1425
  import ezdxf
595
1426
  margin = res["meta"]["edge_margin_in"]
596
1427
  paths = []
@@ -598,55 +1429,250 @@ def render_burn_dxfs(res, outdir):
598
1429
  doc = ezdxf.new("R2010")
599
1430
  doc.units = ezdxf.units.IN
600
1431
  msp = doc.modelspace()
601
- for lyr, col in [("PLATE", 5), ("PROFILE", 3), ("HOLES", 1), ("NOTES", 7)]:
1432
+ for lyr, col in [("PROFILE", 3), ("HOLES", 1)]:
602
1433
  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
1434
  for pc in pr["placements"]:
608
- _draw_part_dxf(msp, pc, margin, margin, "PROFILE", "HOLES", "NOTES")
1435
+ _draw_part_dxf(
1436
+ msp, pc, margin, margin, "PROFILE", "HOLES", None, label=False
1437
+ )
609
1438
  path = os.path.join(outdir, f"burn_plate_{pr['index']}.dxf")
610
1439
  doc.saveas(path)
611
1440
  paths.append(path)
612
1441
  return paths
613
1442
 
614
1443
 
1444
+ def stage_decision(res, geometry_verified_only=False):
1445
+ """Map the independently verified result onto the shared stage contract."""
1446
+ findings = list(res.get("validation_findings", []))
1447
+ findings.extend(
1448
+ {**finding, "severity": "error"}
1449
+ for finding in res.get("verification", {}).get("findings", [])
1450
+ )
1451
+ if res["unplaced"]:
1452
+ findings.append({
1453
+ "code": "UNPLACED_PARTS",
1454
+ "severity": "error",
1455
+ "path": "$.unplaced",
1456
+ "message": (
1457
+ f"{sum(row['quantity'] for row in res['unplaced'])} "
1458
+ "required part(s) remain unplaced."
1459
+ ),
1460
+ })
1461
+
1462
+ if any(finding["severity"] == "error" for finding in findings):
1463
+ outcome = "blocked"
1464
+ package_status = "nested_partial" if res["unplaced"] else "draft"
1465
+ elif res["has_irregular"]:
1466
+ outcome = "review_required"
1467
+ package_status = "review_required"
1468
+ findings.append({
1469
+ "code": "APPROXIMATE_PROFILE_GEOMETRY",
1470
+ "severity": "warning",
1471
+ "path": "$.groups",
1472
+ "message": (
1473
+ "Irregular profiles are represented by bounding boxes in "
1474
+ "reference-only artifacts."
1475
+ ),
1476
+ })
1477
+ else:
1478
+ outcome = "ready"
1479
+ package_status = "nest_verified"
1480
+
1481
+ if geometry_verified_only and outcome != "ready":
1482
+ findings.append({
1483
+ "code": "GEOMETRY_VERIFIED_REQUIRED",
1484
+ "severity": "error",
1485
+ "path": "$.geometry_readiness",
1486
+ "message": "The requested geometry-verified output is unavailable.",
1487
+ })
1488
+
1489
+ return outcome, package_status, findings
1490
+
1491
+
1492
+ def missing_render_dependencies():
1493
+ """Return optional render modules unavailable to this interpreter."""
1494
+ modules = ("ezdxf", "matplotlib", "numpy")
1495
+ return [name for name in modules if importlib.util.find_spec(name) is None]
1496
+
1497
+
1498
+ def publish_nest_run(job, args):
1499
+ """Run a legacy nest job and publish one isolated, manifested artifact set."""
1500
+ result = run_job(job)
1501
+ missing_dependencies = [] if args.no_render else missing_render_dependencies()
1502
+ if missing_dependencies:
1503
+ outcome = "dependency_missing"
1504
+ package_status = "draft"
1505
+ findings = [{
1506
+ "code": "RENDER_DEPENDENCY_MISSING",
1507
+ "severity": "error",
1508
+ "message": (
1509
+ "Rendering requires the missing module(s): "
1510
+ + ", ".join(missing_dependencies)
1511
+ ),
1512
+ }]
1513
+ else:
1514
+ outcome, package_status, findings = stage_decision(
1515
+ result, args.geometry_verified_only
1516
+ )
1517
+ result["outcome"] = outcome
1518
+ result["run_outcome"] = outcome
1519
+ result["package_status"] = package_status
1520
+ report = render_text(result)
1521
+
1522
+ configuration = {
1523
+ "algorithm_version": NEST_ALGORITHM_VERSION,
1524
+ "engine_configuration_hash": result["configuration_hash"],
1525
+ "geometry_verified_only": args.geometry_verified_only,
1526
+ "render": not args.no_render,
1527
+ }
1528
+ publication_configuration_hash = sha256_bytes(
1529
+ canonical_json_bytes(configuration)
1530
+ )
1531
+ approximations = []
1532
+ if result["has_irregular"]:
1533
+ approximations.append({
1534
+ "code": "BOUNDING_BOX_NESTING",
1535
+ "message": "One or more irregular profiles use bounding-box placement.",
1536
+ })
1537
+ qa_report = {
1538
+ "schema_version": "1.0.0",
1539
+ "stage": "steel-nest",
1540
+ "run_outcome": outcome,
1541
+ "package_status": package_status,
1542
+ "geometry_readiness": result["geometry_readiness"],
1543
+ "geometry_verified_only_requested": args.geometry_verified_only,
1544
+ "findings": findings,
1545
+ }
1546
+
1547
+ with RunPublisher(
1548
+ args.out,
1549
+ stage="steel-nest",
1550
+ run_outcome=outcome,
1551
+ package_status=package_status,
1552
+ input_hash=result["normalized_input_hash"],
1553
+ configuration_hash=publication_configuration_hash,
1554
+ schema_versions={
1555
+ "run_manifest": "1.0.0",
1556
+ "nest_result": NEST_RESULT_VERSION,
1557
+ "rfq_nesting": "1.0.0",
1558
+ },
1559
+ tool_versions={
1560
+ "pi_steel": package_version(__file__),
1561
+ "nest_algorithm": NEST_ALGORITHM_VERSION,
1562
+ },
1563
+ explicit_dates={},
1564
+ warnings=result["burn_dxf_warnings"]
1565
+ + [finding["message"] for finding in findings if finding["severity"] == "error"],
1566
+ approximations=approximations,
1567
+ run_id=args.run_id,
1568
+ ) as publisher:
1569
+ publisher.write_qa_report(qa_report)
1570
+ publisher.write_bytes(
1571
+ "report.txt",
1572
+ report.encode("utf-8"),
1573
+ readiness="diagnostic",
1574
+ media_type="text/plain",
1575
+ )
1576
+ publisher.write_json("result.json", result, readiness="diagnostic")
1577
+ if outcome in {"ready", "review_required"}:
1578
+ publisher.write_json(
1579
+ "rfq_nesting.json", result["rfq_nesting"], readiness="diagnostic"
1580
+ )
1581
+
1582
+ if not args.no_render and not missing_dependencies:
1583
+ publisher.register_artifact(
1584
+ "layout.pdf", readiness="reference_only", media_type="application/pdf"
1585
+ )
1586
+ for plate in result["plate_reports"]:
1587
+ publisher.register_artifact(
1588
+ f"plate_{plate['index']}.png",
1589
+ readiness="reference_only",
1590
+ media_type="image/png",
1591
+ )
1592
+ render_layout(result, publisher.staging_path)
1593
+
1594
+ if result["plate_reports"]:
1595
+ publisher.register_artifact(
1596
+ "reference_nest.dxf",
1597
+ readiness="reference_only",
1598
+ media_type="image/vnd.dxf",
1599
+ )
1600
+ for plate in result["plate_reports"]:
1601
+ publisher.register_artifact(
1602
+ f"reference_plate_{plate['index']}.dxf",
1603
+ readiness="reference_only",
1604
+ media_type="image/vnd.dxf",
1605
+ )
1606
+ render_dxf_overview(result, publisher.staging_path)
1607
+ render_reference_plate_dxfs(result, publisher.staging_path)
1608
+
1609
+ if outcome == "ready":
1610
+ for plate in result["plate_reports"]:
1611
+ publisher.register_artifact(
1612
+ f"burn_plate_{plate['index']}.dxf",
1613
+ readiness="geometry_verified",
1614
+ media_type="image/vnd.dxf",
1615
+ )
1616
+ render_burn_dxfs(result, publisher.staging_path)
1617
+
1618
+ final_path = publisher.publish()
1619
+
1620
+ return result, qa_report, report, final_path
1621
+
1622
+
615
1623
  # --------------------------------------------------------------------------
616
1624
  # Main
617
1625
  # --------------------------------------------------------------------------
618
- def main():
619
- ap = argparse.ArgumentParser(description="Steel plate nesting engine")
1626
+ def main(argv=None):
1627
+ ap = StageArgumentParser(description="Steel plate nesting engine")
1628
+ ap.configure_failure_diagnostics(
1629
+ stage="steel-nest",
1630
+ entry_file=__file__,
1631
+ input_option="--job",
1632
+ )
620
1633
  ap.add_argument("--job", required=True)
621
- ap.add_argument("--out", default="out")
1634
+ ap.add_argument(
1635
+ "--out",
1636
+ default="outputs",
1637
+ help="Publication root; each invocation writes an isolated runs/<run-id>/",
1638
+ )
622
1639
  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)
1640
+ ap.add_argument(
1641
+ "--geometry-verified-only",
1642
+ action="store_true",
1643
+ help="Require geometry-verified output; unresolved jobs still publish QA",
1644
+ )
1645
+ ap.add_argument("--run-id", help=argparse.SUPPRESS)
1646
+ args = ap.parse_args(argv)
1647
+
1648
+ try:
1649
+ with open(args.job, encoding="utf-8") as f:
1650
+ job = json.load(f)
1651
+ result, qa_report, report, final_path = publish_nest_run(job, args)
1652
+ except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc:
1653
+ diagnostic_path = publish_failure_diagnostic(
1654
+ args.out,
1655
+ stage="steel-nest",
1656
+ input_path=args.job,
1657
+ error=exc,
1658
+ tool_version=package_version(__file__),
1659
+ run_id=args.run_id,
1660
+ )
1661
+ suffix = (
1662
+ f"; diagnostic published: {diagnostic_path}"
1663
+ if diagnostic_path is not None
1664
+ else "; diagnostic publication unavailable"
1665
+ )
1666
+ print(f"Nesting failed: {exc}{suffix}", file=sys.stderr)
1667
+ return 1
632
1668
  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")
1669
+ print(f"\nPublished {qa_report['run_outcome']} run: {final_path}")
1670
+ if result["burn_dxf_warnings"]:
1671
+ print("Burn DXFs suppressed:")
1672
+ for warning in result["burn_dxf_warnings"]:
1673
+ print(f" - {warning}")
1674
+ return outcome_exit_code(qa_report["run_outcome"])
649
1675
 
650
1676
 
651
1677
  if __name__ == "__main__":
652
- main()
1678
+ raise SystemExit(main())