@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.
- package/DATA_PROVENANCE.json +23 -0
- package/DATA_PROVENANCE.md +35 -0
- package/PUBLIC_DATA_POLICY.md +52 -0
- package/README.md +114 -33
- package/docs/assets/pi-steel-demo.gif +0 -0
- package/docs/assets/pi-steel-gallery.webp +0 -0
- package/package.json +35 -4
- package/pyproject.toml +28 -0
- package/requirements-dev.txt +5 -0
- package/requirements-render.txt +1 -0
- package/requirements-tested.txt +9 -0
- package/requirements.txt +7 -0
- package/scripts/check-data-provenance.py +140 -0
- package/scripts/check-public-data.py +401 -0
- package/scripts/doctor.py +127 -0
- package/skills/_shared/bootstrap.py +32 -0
- package/skills/_shared/pi_steel/__init__.py +47 -0
- package/skills/_shared/pi_steel/cli.py +167 -0
- package/skills/_shared/pi_steel/contracts.py +87 -0
- package/skills/_shared/pi_steel/geometry_verify.py +243 -0
- package/skills/_shared/pi_steel/parsing.py +205 -0
- package/skills/_shared/pi_steel/run_manifest.py +317 -0
- package/skills/_shared/pi_steel/validation.py +597 -0
- package/skills/_shared/schemas/estimate-package.schema.json +424 -0
- package/skills/_shared/schemas/nest-result.schema.json +443 -0
- package/skills/_shared/schemas/run-manifest.schema.json +145 -0
- package/skills/steel-estimate/SKILL.md +119 -0
- package/skills/steel-estimate/references/estimate-package-example.json +91 -0
- package/skills/steel-estimate/references/output-contract.md +47 -0
- package/skills/steel-estimate/scripts/acknowledge-finding.py +193 -0
- package/skills/steel-estimate/scripts/build-estimate-package.py +836 -0
- package/skills/steel-nest/SKILL.md +50 -23
- package/skills/steel-nest/references/FIXTURE_PROVENANCE.md +12 -0
- package/skills/steel-nest/references/example_job.json +21 -25
- package/skills/steel-nest/references/job_template.json +11 -9
- package/skills/steel-nest/scripts/nest.py +1276 -250
- package/skills/steel-rfq/SKILL.md +93 -175
- package/skills/steel-rfq/assets/company-profile.example.json +11 -5
- package/skills/steel-rfq/references/rfq-input.md +58 -0
- package/skills/steel-rfq/scripts/generate-rfq.py +1221 -0
- package/skills/steel-rfq/scripts/recalc.py +33 -10
- package/skills/steel-takeoff/SKILL.md +12 -8
- package/skills/steel-takeoff/assets/bom-template.csv +1 -1
- package/skills/steel-takeoff/references/takeoff-procedures.md +3 -1
- package/skills/steel-takeoff/scripts/calculate-weight.sh +5 -10
- package/skills/steel-takeoff/scripts/lookup-member.sh +2 -2
- package/skills/steel-takeoff/scripts/validate-bom.py +151 -196
- 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
|
-
*
|
|
12
|
-
from weight/cost, rotated with the part, drawn in the layout,
|
|
13
|
-
as
|
|
14
|
-
*
|
|
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
|
-
-
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
"
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
758
|
+
packed_width, packed_height = unit["w"] + spacing, unit["h"] + spacing
|
|
270
759
|
placed = False
|
|
271
|
-
for
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
279
|
-
if
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
used_plates = [
|
|
288
|
-
for
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
312
|
-
|
|
313
|
-
net_area = max(0.0,
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
parts_on[
|
|
319
|
-
if
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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":
|
|
359
|
-
"customer":
|
|
360
|
-
"
|
|
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":
|
|
374
|
-
|
|
375
|
-
"
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
"""
|
|
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[
|
|
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
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
"
|
|
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(
|
|
411
|
-
"
|
|
412
|
-
"nesting_plan":
|
|
413
|
-
|
|
414
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
449
|
-
|
|
450
|
-
|
|
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["
|
|
454
|
-
|
|
455
|
-
L.append(
|
|
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(
|
|
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"
|
|
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:
|
|
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(
|
|
550
|
-
|
|
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
|
-
[
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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), ("
|
|
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(
|
|
583
|
-
|
|
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(
|
|
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, "
|
|
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
|
-
"""
|
|
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 [("
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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())
|