diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec20b9..8c89a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to `@structupath/pi-steel` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-08-24 + +### Added + +- Verified true-shape compaction for outlined irregular parts: after + MaxRects bounding-box packing, placements slide left-then-down in + fixed 1/32-in scan-to-first-contact steps until their true profiles + (not their boxes) reach the kerf-plus-gap clearance, so complementary + profiles interlock and recover plate. Every compacted plate is + re-checked by an independent polygon-clearance verifier that rebuilds + profiles from the published placement data alone; any failure discards + compaction for that plate with a non-blocking `COMPACTION_REJECTED` + finding and keeps the proven bounding-box layout. Plate reports record + `compaction` (`ran`, `accepted`, `recovered_in`, `passes`), remnant + candidates are rebuilt from the compacted bounding boxes (still + rectangle-based and uncertified), and a `true_shape_utilization_pct` + metric reports exact profile area on plates whose irregular parts all + carry validated outlines. Enabled by default in the direct engine and + the estimate pipeline; `--no-compact-outlines` opts out, and the choice + is captured in the configuration hash. Burn-DXF eligibility and the + review-required posture for irregular parts are unchanged. + ## [0.4.0] - 2026-08-23 ### Added diff --git a/README.md b/README.md index 2f13ff4..dec3984 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,9 @@ does not invent pricing. `steel-nest` uses MaxRects bin packing for rectangular parts and reports yield, scrap, reusable drops, and unplaced material. Irregular parts may carry a true polygon outline for exact areas, weights, hole checks, and drawn profiles; they -are placed by bounding box and always flagged. +are placed by bounding box, always flagged, and then compacted along their true +profiles in fixed scan-to-first-contact steps — every compacted plate must pass +an independent polygon-clearance verifier or the bounding-box layout is kept. Per-sheet `burn_plate_N.dxf` files are emitted only when the full nest is complete and every supported hole remains inside its part. Otherwise, pi-steel @@ -147,7 +149,8 @@ npm run provenance:check # shape-data integrity and recorded decision npm run release:check # complete release gate ``` -The current package version is `0.4.0`. `release:check` verifies the test, +The current package version is declared in `package.json` and recorded per +release in `CHANGELOG.md`. `release:check` verifies the test, privacy, package-content, shape-data integrity, ownership, license, and redistribution contracts before publication. diff --git a/package.json b/package.json index 05d81dc..980cb76 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@structupath/pi-steel", - "version": "0.4.0", + "version": "0.5.0", "description": "Structural steel estimating for Pi \u2014 validated takeoffs, plate nesting, guarded DXF output, and review-ready RFQ packages.", "type": "module", "keywords": [ diff --git a/pyproject.toml b/pyproject.toml index 0710fd2..e502a8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pi-steel-runtime" -version = "0.4.0" +version = "0.5.0" description = "Python runtime dependencies and test configuration for pi-steel" requires-python = ">=3.11,<3.14" dependencies = [ diff --git a/skills/_shared/pi_steel/geometry_verify.py b/skills/_shared/pi_steel/geometry_verify.py index b6b18a3..b22e642 100644 --- a/skills/_shared/pi_steel/geometry_verify.py +++ b/skills/_shared/pi_steel/geometry_verify.py @@ -323,6 +323,117 @@ def polygon_within_rect( ) +TRUE_SHAPE_EPSILON = 1e-6 + + +def placed_profile(placement: dict[str, Any]) -> list[tuple[float, float]] | None: + """True profile of one placement in plate coordinates. + + Rebuilt from published placement data alone (outline, rotation flag, + original height, position) so verification never trusts a packing or + compaction algorithm's internal bookkeeping. Rotation follows the + placement contract: a rotated part maps local (x, y) to (oh - y, x). + """ + outline = placement.get("outline") + if ( + placement.get("shape") == "irregular" + and isinstance(outline, list) + and len(outline) >= 3 + ): + if not all(_finite_point(point) for point in outline): + return None + if placement.get("rotated"): + oh = placement.get("oh") + if not isinstance(oh, (int, float)) or isinstance(oh, bool) or not math.isfinite(oh): + return None + local = [(oh - point[1], point[0]) for point in outline] + else: + local = [(point[0], point[1]) for point in outline] + else: + width, height = placement["w"], placement["h"] + local = [(0.0, 0.0), (width, 0.0), (width, height), (0.0, height)] + x, y = placement["x"], placement["y"] + return [(x + px, y + py) for px, py in local] + + +def verify_true_shape_placements( + placements: list[dict[str, Any]], + *, + usable_width: float, + usable_height: float, + clearance: float, + path_prefix: str = "$", +) -> list[dict[str, Any]]: + """Independently verify true-profile clearance on one plate. + + Profiles are rebuilt from published placement data via placed_profile. + Pairs whose bounding boxes keep the clearance on either axis are + provably safe, so exact polygon distances run only on the remaining + candidate pairs. + """ + findings: list[dict[str, Any]] = [] + profiles: dict[int, list[tuple[float, float]]] = {} + for index, placement in enumerate(placements): + path = f"{path_prefix}.placements[{index}]" + if not _finite_placement_values(placement): + findings.append( + { + "code": "nonfinite_placement", + "path": path, + "message": "Placement coordinates and dimensions must be finite.", + } + ) + continue + profile = placed_profile(placement) + if profile is None: + findings.append( + { + "code": "invalid_profile", + "path": path, + "message": ( + "Placement profile could not be rebuilt for " + "true-shape verification." + ), + } + ) + continue + profiles[index] = profile + if not polygon_within_rect(profile, usable_width, usable_height): + findings.append( + { + "code": "true_shape_out_of_bounds", + "path": path, + "message": ( + "True profile must remain inside the edge-margin " + "boundary." + ), + } + ) + for first_index, second_index in _overlap_candidate_pairs( + placements, clearance, TRUE_SHAPE_EPSILON + ): + if first_index not in profiles or second_index not in profiles: + continue + distance = polygon_min_distance( + profiles[first_index], profiles[second_index] + ) + if distance < clearance - TRUE_SHAPE_EPSILON: + findings.append( + { + "code": "true_shape_clearance_violation", + "path": ( + f"{path_prefix}.placements" + f"[{first_index},{second_index}]" + ), + "message": ( + "True profiles overlap or violate the required " + "kerf-plus-gap clearance." + ), + } + ) + return findings + + def gross_area(geometry: dict[str, Any]) -> float: if geometry.get("shape") == "irregular": outline = geometry.get("outline") @@ -504,6 +615,20 @@ def verify_nest_placements( } ) + if (plate.get("compaction") or {}).get("accepted"): + # Compacted plates interlock true profiles, so bounding boxes + # may legitimately come closer than the clearance; the pairwise + # contract is enforced on the rebuilt profiles instead. + findings.extend( + verify_true_shape_placements( + placements, + usable_width=usable_width, + usable_height=usable_height, + clearance=inter_part_clearance, + path_prefix=f"$.plate_reports[{plate_index}]", + ) + ) + continue for first_index, second_index in _overlap_candidate_pairs( placements, inter_part_clearance, epsilon ): diff --git a/skills/_shared/schemas/nest-result.schema.json b/skills/_shared/schemas/nest-result.schema.json index a4b14fc..8580f7a 100644 --- a/skills/_shared/schemas/nest-result.schema.json +++ b/skills/_shared/schemas/nest-result.schema.json @@ -83,7 +83,8 @@ "part_gap_in": { "type": "number", "minimum": 0 }, "edge_margin_in": { "type": "number", "minimum": 0 }, "density_lb_in3": { "type": "number", "exclusiveMinimum": 0 }, - "unit_system": { "type": "string" } + "unit_system": { "type": "string" }, + "compact_outlines": { "type": "boolean" } }, "additionalProperties": false }, @@ -125,7 +126,8 @@ "required": ["packing_utilization_pct", "net_material_yield_pct"], "properties": { "packing_utilization_pct": { "$ref": "#/$defs/metric" }, - "net_material_yield_pct": { "$ref": "#/$defs/metric" } + "net_material_yield_pct": { "$ref": "#/$defs/metric" }, + "true_shape_utilization_pct": { "$ref": "#/$defs/metric" } }, "additionalProperties": false }, @@ -366,6 +368,18 @@ "type": "array", "items": { "$ref": "#/$defs/remnantCandidate" } }, + "compaction": { + "type": "object", + "required": ["ran", "accepted", "recovered_in", "passes"], + "properties": { + "ran": { "type": "boolean" }, + "accepted": { "type": "boolean" }, + "recovered_in": { "type": "number", "minimum": 0 }, + "passes": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + "true_shape_utilization_pct": { "$ref": "#/$defs/metric" }, "placements": { "type": "array", "items": { "$ref": "#/$defs/placement" } diff --git a/skills/steel-estimate/scripts/build-estimate-package.py b/skills/steel-estimate/scripts/build-estimate-package.py index 8d0980f..64e2ef3 100755 --- a/skills/steel-estimate/scripts/build-estimate-package.py +++ b/skills/steel-estimate/scripts/build-estimate-package.py @@ -639,7 +639,10 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: density_lb_in3=args.density_lb_in3, ) if nest_job is not None: - nest_result = nest_engine.run_job(nest_job) + nest_result = nest_engine.run_job( + nest_job, + compact_outlines=not getattr(args, "no_compact_outlines", False), + ) for nest_finding in nest_result["validation_findings"]: findings.append( _finding( @@ -860,6 +863,7 @@ def build_pipeline(args) -> tuple[dict[str, Any], Path]: "part_gap_in": args.part_gap_in, "edge_margin_in": args.edge_margin_in, "density_lb_in3": args.density_lb_in3, + "compact_outlines": not getattr(args, "no_compact_outlines", False), "mill_lengths_ft": mill_lengths, "cutlist_kerf_in": args.cutlist_kerf_in, "end_trim_in": args.end_trim_in, @@ -1118,6 +1122,11 @@ def main(argv=None) -> int: default="40,50,60", help="Comma-separated purchasable mill lengths for member cut-lists", ) + parser.add_argument( + "--no-compact-outlines", + action="store_true", + help="Keep pure bounding-box nest layouts (skip verified compaction)", + ) parser.add_argument("--cutlist-kerf-in", type=float, default=0.125) parser.add_argument("--end-trim-in", type=float, default=0.25) parser.add_argument("--min-drop-in", type=float, default=24.0) diff --git a/skills/steel-nest/SKILL.md b/skills/steel-nest/SKILL.md index fd58d65..43d77a3 100644 --- a/skills/steel-nest/SKILL.md +++ b/skills/steel-nest/SKILL.md @@ -20,7 +20,8 @@ Be honest with the user about the boundary — it protects the shop from over-tr - Multiple compatible plate sizes with independently verified bounds, non-overlap, and material grouping. - Explicit clearance ownership: edge margin is the plate-to-part keep-out; kerf plus part gap is the minimum edge-to-edge clearance between parts. No trailing kerf/gap is required at the usable plate boundary. - **Holes and rectangular cutouts** on verified rectangular parts — subtracted from weight/cost, rotated with the part, and emitted as cut geometry only when the complete job passes the output gate. -- Separate packing-utilization and net-material-yield percentages with approximation labels. +- **Verified true-shape compaction** for outlined irregular parts: after bounding-box packing, parts slide left-then-down in fixed 1/32-in scan-to-first-contact steps until their true profiles (not their boxes) reach the kerf-plus-gap clearance, letting complementary profiles interlock. Every compacted plate is re-checked by an independent polygon-clearance verifier that rebuilds profiles from the published placements; a failed check discards compaction for that plate (`COMPACTION_REJECTED` warning) and keeps the proven bounding-box layout. Per-plate results are recorded under `compaction` (`ran`, `accepted`, `recovered_in`, `passes`). Disable with `--no-compact-outlines`. +- Separate packing-utilization and net-material-yield percentages with approximation labels, plus `true_shape_utilization_pct` (exact profile area over plate area) on plates whose irregular parts all carry validated outlines. - Remnant candidates per plate, explicitly not certified reusable drops. - Material weight and optional cost by one explicit basis per stock entry (`cost_per_lb` or `cost_per_sheet`, never both). - Labeled layout (PDF + one PNG per plate). @@ -28,7 +29,7 @@ Be honest with the user about the boundary — it protects the shop from over-tr - **Guarded cut-geometry files**: one DXF per sheet (`burn_plate_N.dxf`) containing only closed part outlines on `PROFILE` and holes/cutouts on `HOLES`, with origin at the sheet corner. They exist only when every part is rectangular, every required part fits, and every supported hole stays inside its part. **Approximate — always flag it:** -- **Irregular parts** (gussets, brackets, curved profiles, parts with holes) are nested by their **bounding box**, not true shape. Real yield is a little better than reported. For exact weight/cost, give the part an `outline` — a list of `[x, y]` vertices tracing the true profile in the part's local frame (bounding box spanning `0..width` × `0..height`, simple polygon, no crossing edges). The engine then computes the exact shoelace area (`outline_exact`), checks that holes stay inside the true profile (not just the box), and draws the real outline in layouts and reference DXFs. Without an outline, supply the true cut area (in²) in `area`, or the bounding box is used as an estimate. Placement is still by bounding box — this is NOT true-shape nesting like a dedicated CAM engine. +- **Irregular parts** (gussets, brackets, curved profiles, parts with holes) are nested by their **bounding box**, not true shape. Real yield is a little better than reported. For exact weight/cost, give the part an `outline` — a list of `[x, y]` vertices tracing the true profile in the part's local frame (bounding box spanning `0..width` × `0..height`, simple polygon, no crossing edges). The engine then computes the exact shoelace area (`outline_exact`), checks that holes stay inside the true profile (not just the box), draws the real outline in layouts and reference DXFs, and recovers plate through the verified compaction pass above. Without an outline, supply the true cut area (in²) in `area`, or the bounding box is used as an estimate. Initial placement is still by bounding box and compaction only slides parts along fixed axes — this is NOT free-rotation no-fit-polygon nesting like a dedicated CAM engine, and remnant candidates stay rectangle-based and uncertified. - Any irregular part suppresses all fabrication-style DXFs for that job. The remaining PDF, PNG, report, JSON, and `reference_nest.dxf` outputs are estimating aids, not cutting instructions. **Do NOT pretend to do:** @@ -69,6 +70,8 @@ current after a blocked rerun. Use `--geometry-verified-only` when reference-only geometry does not satisfy the request. The command still publishes its QA diagnostics, but exits unsuccessfully. +Use `--no-compact-outlines` to keep pure bounding-box layouts (skip the verified +true-shape compaction pass); the flag is recorded in the configuration hash. Each run contains: diff --git a/skills/steel-nest/scripts/nest.py b/skills/steel-nest/scripts/nest.py index 4a4e678..cd6746f 100644 --- a/skills/steel-nest/scripts/nest.py +++ b/skills/steel-nest/scripts/nest.py @@ -21,9 +21,16 @@ every part is rectangular, every required part fits, and supported holes stay inside the part. + * Verified true-shape compaction: outlined irregular parts slide + left-then-down in fixed 1/32-in scan-to-first-contact steps until + their true profiles reach kerf+gap clearance, and an independent + polygon-clearance verifier gates every compacted plate (rejection + keeps the proven bounding-box layout). + Deliberately NOT done: - * True-shape nesting of irregular parts (they nest by BOUNDING BOX, - clearly flagged). Supply a part `area` for exact weight on those. + * Free-rotation / no-fit-polygon nesting (initial placement is by + BOUNDING BOX, clearly flagged; compaction only slides along axes). + Supply an `outline` or `area` per irregular part for exact weight. * Machine-ready G-code / NC with kerf comp, pierce points and lead-ins for a specific controller. The burn-table DXF is an import file; the machine's own CAM/post applies those (that is where they belong). @@ -72,12 +79,16 @@ hole_within_bounds, hole_within_outline, polygon_area, + polygon_min_distance, validate_outline, verify_nest_placements, + verify_true_shape_placements, ) STEEL_DENSITY = 0.2836 # lb/in^3, A36 mild steel -NEST_ALGORITHM_VERSION = "maxrects-bssf-u3" +NEST_ALGORITHM_VERSION = "maxrects-bssf-u3-tsc1" +COMPACTION_STEP_IN = 0.03125 # fixed 1/32-in scan resolution +COMPACTION_MAX_PASSES = 8 _valid_hash = is_sha256 @@ -242,6 +253,146 @@ def outline_local(pc, outline): return [(x, y) for x, y in outline] +# -------------------------------------------------------------------------- +# True-shape compaction (post-placement, verified before acceptance) +# -------------------------------------------------------------------------- +def _profile_local(placement): + """Placed-frame profile vertices (before translation) for a placement.""" + if placement.shape == "irregular" and placement.outline: + return outline_local(vars(placement), placement.outline) + return [ + (0.0, 0.0), + (placement.w, 0.0), + (placement.w, placement.h), + (0.0, placement.h), + ] + + +def _slide_steps(placement, axis, local_profile, others, clearance): + """Fixed steps the placement slides toward zero along one axis. + + Scan-to-first-contact: clearance along a slide is not monotonic for + concave profiles (a notch can make an offset clear, then blocked, then + clear again), so the scan advances the fixed step and stops one step + before the first offset that violates the clearance. Bounding-box gaps + only skip offsets that are provably clear: a neighbor separated by the + clearance on the cross axis can never be violated, and a neighbor + fully ahead of the slide direction only recedes because the current + position already satisfies the clearance. + """ + step = COMPACTION_STEP_IN + if axis == "x": + position, extent = placement.x, placement.w + cross_low, cross_high = placement.y, placement.y + placement.h + else: + position, extent = placement.y, placement.h + cross_low, cross_high = placement.x, placement.x + placement.w + max_steps = int(math.floor((position + 1e-9) / step)) + if max_steps <= 0: + return 0 + + relevant = [] + for other, profile in others: + if axis == "x": + other_low, other_high = other.x, other.x + other.w + other_cross_low, other_cross_high = other.y, other.y + other.h + else: + other_low, other_high = other.y, other.y + other.h + other_cross_low, other_cross_high = other.x, other.x + other.w + if ( + other_cross_high + clearance <= cross_low + or cross_high + clearance <= other_cross_low + ): + continue + if other_low >= position + extent: + continue + relevant.append((other_high, profile)) + + steps = 0 + while steps < max_steps: + candidate = position - (steps + 1) * step + limit = max_steps - steps + exact = [] + for other_high, profile in relevant: + gap = candidate - other_high + if gap >= clearance: + limit = min(limit, 1 + int((gap - clearance) / step)) + else: + exact.append(profile) + if not exact: + steps += limit + continue + if axis == "x": + candidate_profile = [ + (px + candidate, py + placement.y) for px, py in local_profile + ] + else: + candidate_profile = [ + (px + placement.x, py + candidate) for px, py in local_profile + ] + if any( + polygon_min_distance(candidate_profile, profile) < clearance - 1e-9 + for profile in exact + ): + break + steps += 1 + return min(steps, max_steps) + + +def compact_placements(placements, clearance): + """Deterministic left-then-down true-shape compaction of one plate. + + Mutates placement coordinates in place and returns (recovered_in, + passes); callers snapshot the coordinates and only accept the moves + after independent verification. + """ + local_profiles = [_profile_local(placement) for placement in placements] + recovered = 0.0 + passes = 0 + while passes < COMPACTION_MAX_PASSES: + passes += 1 + moved = False + order = sorted( + range(len(placements)), + key=lambda index: ( + placements[index].x, + placements[index].y, + placements[index].placement_id, + ), + ) + for index in order: + placement = placements[index] + others = [ + ( + placements[other_index], + [ + ( + px + placements[other_index].x, + py + placements[other_index].y, + ) + for px, py in local_profiles[other_index] + ], + ) + for other_index in range(len(placements)) + if other_index != index + ] + for axis in ("x", "y"): + steps = _slide_steps( + placement, axis, local_profiles[index], others, clearance + ) + if steps: + distance = steps * COMPACTION_STEP_IN + if axis == "x": + placement.x = max(0.0, placement.x - distance) + else: + placement.y = max(0.0, placement.y - distance) + recovered += distance + moved = True + if not moved: + break + return recovered, passes + + # -------------------------------------------------------------------------- # Job runner # -------------------------------------------------------------------------- @@ -707,7 +858,7 @@ def _aggregate_unplaced(units): return sorted(grouped.values(), key=lambda row: (row["item_id"], row["reason"])) -def run_job(job): +def run_job(job, compact_outlines=True): normalized, stock_types, validation_findings = normalize_job(job) settings = normalized["settings"] kerf = settings["kerf_in"] @@ -732,6 +883,7 @@ def run_job(job): gap, validation_findings, normalized_hash, + compact_outlines, ) units = [] @@ -844,6 +996,70 @@ def commit(plate, unit, placement): used_plates = [plate for plate in plates if plate["placements"]] for index, plate in enumerate(used_plates, 1): plate["index"] = index + plate["compaction"] = { + "ran": False, + "accepted": False, + "recovered_in": 0.0, + "passes": 0, + } + if compact_outlines: + for plate in used_plates: + placements = plate["placements"] + irregular = [ + placement + for placement in placements + if placement.shape == "irregular" + ] + if not irregular or any( + len(placement.outline) < 3 for placement in irregular + ): + continue + snapshot = [(placement.x, placement.y) for placement in placements] + recovered, passes = compact_placements(placements, spacing) + if recovered <= 0.0: + plate["compaction"].update({"ran": True, "passes": passes}) + continue + stock = plate["stock"] + gate_findings = verify_true_shape_placements( + [vars(placement) for placement in placements], + usable_width=stock["W"] - 2 * margin, + usable_height=stock["H"] - 2 * margin, + clearance=spacing, + ) + if gate_findings: + for placement, (x, y) in zip(placements, snapshot): + placement.x, placement.y = x, y + plate["compaction"].update({"ran": True, "passes": passes}) + validation_findings.append( + { + "code": "COMPACTION_REJECTED", + "severity": "warning", + "path": f"$.plate_reports[{plate['index'] - 1}]", + "message": ( + "True-shape compaction failed independent " + "verification; the bounding-box layout was kept." + ), + } + ) + continue + plate["compaction"] = { + "ran": True, + "accepted": True, + "recovered_in": round(recovered, 6), + "passes": passes, + } + # Free rectangles are rebuilt from the compacted bounding boxes + # so remnant candidates never claim space a moved part now + # occupies; they stay rectangle-based and unverified. + rebuilt = MaxRectsBin(plate["bin"].width, plate["bin"].height) + for placement in placements: + rebuilt._place_and_split( + placement.x, + placement.y, + placement.w + spacing, + placement.h + spacing, + ) + plate["bin"] = rebuilt return _summarize( normalized, used_plates, @@ -854,6 +1070,7 @@ def commit(plate, unit, placement): gap, validation_findings, normalized_hash, + compact_outlines, ) @@ -901,10 +1118,14 @@ def _summarize( gap, validation_findings, normalized_hash, + compact_outlines, ): estimate_input_hash = normalized.get("estimate_input_hash") or normalized_hash plate_reports = [] total_plate_area = total_packing_area = total_net_area = 0.0 + total_true_area = 0.0 + all_plates_true_shape = bool(used_plates) + any_placed_irregular = False total_plate_weight = total_part_weight = 0.0 total_cost = 0.0 all_used_costs_known = bool(used_plates) @@ -985,8 +1206,32 @@ def _summarize( "plate_cost": None if plate_cost is None else round(plate_cost, 2), "cost_basis": cost_basis, "remnant_candidates": _remnant_candidates(plate, margin, kerf + gap), + "compaction": plate.get( + "compaction", + {"ran": False, "accepted": False, "recovered_in": 0.0, "passes": 0}, + ), "placements": [vars(placement) for placement in plate["placements"]], } + plate_irregular = [ + placement + for placement in plate["placements"] + if placement.shape == "irregular" + ] + if all(len(placement.outline) >= 3 for placement in plate_irregular): + true_area = sum( + polygon_area(placement.outline) + if placement.shape == "irregular" + else placement.w * placement.h + for placement in plate["placements"] + ) + report["true_shape_utilization_pct"] = _metric( + 100 * true_area / plate_area, + "outline_exact" if plate_irregular else "exact", + ) + total_true_area += true_area + any_placed_irregular = any_placed_irregular or bool(plate_irregular) + else: + all_plates_true_shape = False plate_reports.append(report) total_plate_area += plate_area total_packing_area += packing_area @@ -1012,6 +1257,11 @@ def _summarize( net_status, ), } + if all_plates_true_shape: + metrics["true_shape_utilization_pct"] = _metric( + 100 * total_true_area / total_plate_area if total_plate_area else 0, + "outline_exact" if any_placed_irregular else "exact", + ) verification_findings = verify_nest_placements( plate_reports, edge_margin=margin, @@ -1087,6 +1337,11 @@ def _summarize( "algorithm_version": NEST_ALGORITHM_VERSION, "settings": normalized["settings"], "clearance_contract": "edge-margin-and-inter-part-v1", + "compaction": { + "enabled": bool(compact_outlines), + "step_in": COMPACTION_STEP_IN, + "max_passes": COMPACTION_MAX_PASSES, + }, } ) ) @@ -1108,6 +1363,7 @@ def _summarize( "edge_margin_in": margin, "density_lb_in3": density, "unit_system": normalized["unit_system"], + "compact_outlines": bool(compact_outlines), }, "clearance_contract": { "edge_margin_ownership": "plate_to_part", @@ -1252,6 +1508,12 @@ def render_text(res): f" Net material yield .... {net_yield['value']}% " f"({net_yield['approximation']})" ) + true_shape = res["metrics"].get("true_shape_utilization_pct") + if true_shape is not None: + L.append( + f" True-shape claimed .... {true_shape['value']}% " + f"({true_shape['approximation']})" + ) L.append(f" Holes / cutouts ........ {res['total_holes']}") L.append(f" Total plate weight ..... {res['total_plate_weight_lb']} lb") L.append(f" Net part weight ........ {res['total_part_weight_lb']} lb (holes removed)") @@ -1275,6 +1537,13 @@ def render_text(res): ) if pr["plate_cost"] is not None: L.append(f" Cost: ${pr['plate_cost']:,.2f}") + compaction = pr.get("compaction") or {} + if compaction.get("accepted"): + L.append( + " Compacted: true-shape slide recovered " + f"{_fmt(compaction['recovered_in'])} in " + f"({compaction['passes']} pass(es), independently verified)" + ) if pr["remnant_candidates"]: candidate = pr["remnant_candidates"][0] L.append( @@ -1569,7 +1838,8 @@ def missing_render_dependencies(): def publish_nest_run(job, args): """Run a legacy nest job and publish one isolated, manifested artifact set.""" - result = run_job(job) + compact_outlines = not getattr(args, "no_compact_outlines", False) + result = run_job(job, compact_outlines=compact_outlines) missing_dependencies = [] if args.no_render else missing_render_dependencies() if missing_dependencies: outcome = "dependency_missing" @@ -1596,6 +1866,7 @@ def publish_nest_run(job, args): "engine_configuration_hash": result["configuration_hash"], "geometry_verified_only": args.geometry_verified_only, "render": not args.no_render, + "compact_outlines": compact_outlines, } publication_configuration_hash = sha256_bytes( canonical_json_bytes(configuration) @@ -1709,6 +1980,11 @@ def main(argv=None): help="Publication root; each invocation writes an isolated runs//", ) ap.add_argument("--no-render", action="store_true", help="Skip PDF/PNG/DXF") + ap.add_argument( + "--no-compact-outlines", + action="store_true", + help="Keep pure bounding-box layouts (skip verified true-shape compaction)", + ) ap.add_argument( "--geometry-verified-only", action="store_true", diff --git a/tests/test_compaction.py b/tests/test_compaction.py new file mode 100644 index 0000000..6a335e8 --- /dev/null +++ b/tests/test_compaction.py @@ -0,0 +1,410 @@ +"""True-shape compaction: scan-to-first-contact pass, independent verifier, +and the discard-on-failure gate (v0.5 plan U2/U3).""" + +import copy +import importlib.util +import json +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "skills" / "_shared")) + +from pi_steel.geometry_verify import ( # noqa: E402 + placed_profile, + polygon_min_distance, + validate_outline, + verify_true_shape_placements, +) + + +def _load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +nest = _load("nest_compaction", REPO / "skills" / "steel-nest" / "scripts" / "nest.py") + + +# The L-gusset and the Z-strap interlock: the Z's top slab slides over the +# L's arm while its offset bottom block clears the L's bottom slab by the +# 0.25-in clearance, so the Z's bounding box legally overlaps the L's. +L_OUTLINE = [[0, 0], [8, 0], [8, 3], [3, 3], [3, 6], [0, 6]] +Z_OUTLINE = [[0, 3.5], [4.75, 3.5], [4.75, 0], [7.75, 0], [7.75, 6], [0, 6]] + +# Concave neighbor for the alternating-clearance fixture: a pocket opening +# right at y in [1.75, 3.5] with a tooth hanging from the pocket ceiling at +# x in [1.3, 1.7], y in [3.1, 3.5]. A 0.5 x 1 slider entering at y = 2 sees +# clear offsets, then the tooth (0.1-in gap, under the 0.25-in clearance), +# then a clear interval deep in the pocket it must never tunnel into. +NOTCH_OUTLINE = [ + [0, 0], [4, 0], [4, 1.75], [0.25, 1.75], [0.25, 3.5], [1.3, 3.5], + [1.3, 3.1], [1.7, 3.1], [1.7, 3.5], [4, 3.5], [4, 5], [0, 5], +] + +CLEARANCE = 0.25 +STEP = nest.COMPACTION_STEP_IN + + +def interlock_job(): + return { + "job_name": "TSC-INTERLOCK", + "material": "carbon_steel", + "grade": "A36", + "unit_system": "imperial", + "settings": { + "kerf_in": 0.0, + "part_gap_in": 0.25, + "edge_margin_in": 0.5, + "thickness_in": 0.5, + "density_lb_in3": 0.2836, + }, + "stock": [ + { + "stock_id": "TSC-STOCK", + "name": "Synthetic Plate", + "width": 18.25, + "height": 7, + "thickness": 0.5, + "qty": 1, + } + ], + "parts": [ + { + "source_id": "TSC-SRC-L", + "name": "TSC-L", + "width": 8, + "height": 6, + "qty": 1, + "shape": "irregular", + "outline": copy.deepcopy(L_OUTLINE), + "rotatable": False, + }, + { + "source_id": "TSC-SRC-Z", + "name": "TSC-Z", + "width": 7.75, + "height": 6, + "qty": 1, + "shape": "irregular", + "outline": copy.deepcopy(Z_OUTLINE), + "rotatable": False, + }, + ], + } + + +def _placement(**kwargs): + defaults = { + "part_id": kwargs.get("label", "part"), + "source_id": "src", + "item_id": kwargs.get("label", "part"), + "instance_id": kwargs.get("label", "part") + "#1", + "placement_id": kwargs.get("label", "part") + "#1", + "stock_id": "stock", + "label": "part", + "x": 0.0, + "y": 0.0, + "w": 1.0, + "h": 1.0, + "rotated": False, + "shape": "rect", + "ow": kwargs.get("w", 1.0), + "oh": kwargs.get("h", 1.0), + "holes": [], + "outline": [], + "base_area": 0.0, + "holes_area": 0.0, + "material": "carbon_steel", + "grade": "A36", + "thickness": 0.5, + } + defaults.update(kwargs) + return nest.Placement(**defaults) + + +def _by_label(result, label): + for plate in result["plate_reports"]: + for placement in plate["placements"]: + if placement["label"] == label: + return placement + raise AssertionError(f"placement {label} not found") + + +# --------------------------------------------------------------------------- +# The mandatory concave fixture: scan stops at first contact, never tunnels +# --------------------------------------------------------------------------- + +def test_notch_fixture_outline_is_valid(): + assert validate_outline(NOTCH_OUTLINE, 4, 5) == [] + + +def test_scan_stops_at_first_contact_before_the_tooth(): + neighbor = _placement( + label="notch", shape="irregular", outline=copy.deepcopy(NOTCH_OUTLINE), + x=0.0, y=0.0, w=4.0, h=5.0, ow=4.0, oh=5.0, + ) + slider = _placement(label="slider", x=5.0, y=2.0, w=0.5, h=1.0) + recovered, passes = nest.compact_placements([neighbor, slider], CLEARANCE) + + # First violating offset: hypot(x - 1.7, 0.1) < 0.25 at x < 1.92913, so + # the slider halts one step earlier, at 5.0 - 98/32 = 1.9375. The pocket + # holds a deeper clear interval around x = 0.5 that a bisection over + # "clear vs blocked" would tunnel into; the fixed-step scan must not. + assert slider.x == 1.9375 + assert slider.y == 2.0 + assert neighbor.x == 0.0 and neighbor.y == 0.0 + assert recovered == 5.0 - 1.9375 + assert passes == 2 # one moving pass, one clean convergence pass + + # The deep clear interval really is clear (the trap exists): the slider + # placed there passes true-shape verification, so only the scan order + # keeps it out. + parked = dict(vars(slider), x=0.5) + assert ( + polygon_min_distance(placed_profile(parked), placed_profile(vars(neighbor))) + >= CLEARANCE + ) + + +def test_scan_respects_the_exact_clearance_contact(): + # A slider directly above a slab stops exactly at the kerf-plus-gap + # distance: contact at the clearance is legal, one step closer is not. + slab = _placement(label="slab", x=0.0, y=0.0, w=4.0, h=2.0) + slider = _placement(label="slider", x=1.0, y=3.0, w=1.0, h=1.0) + nest.compact_placements([slab, slider], CLEARANCE) + assert slider.y == 2.25 + assert slider.x == 0.0 + + +# --------------------------------------------------------------------------- +# End-to-end: interlocking profiles recover plate, gated by the verifier +# --------------------------------------------------------------------------- + +def test_interlocking_outlines_compact_and_verify(): + result = nest.run_job(interlock_job()) + assert result["outcome"] == "review_required" + assert result["verification"]["status"] == "verified" + + plate = result["plate_reports"][0] + compaction = plate["compaction"] + assert compaction["ran"] is True + assert compaction["accepted"] is True + assert compaction["recovered_in"] == 4.75 + assert compaction["passes"] >= 1 + + l_part = _by_label(result, "TSC-L") + z_part = _by_label(result, "TSC-Z") + assert (l_part["x"], l_part["y"]) == (0.0, 0.0) + # The Z slid from 8.25 until its bottom block reached the clearance off + # the L's bottom slab: 3.5 + 4.75 = 8 + 0.25. + assert (z_part["x"], z_part["y"]) == (3.5, 0.0) + # Bounding boxes now overlap -- the recovery is real, not box shuffling. + assert z_part["x"] < l_part["x"] + l_part["w"] + + # Burn posture is unchanged by compaction (R3). + assert result["geometry_readiness"] == "reference_only" + assert result["burn_dxf_eligible"] is False + + +def test_compaction_is_deterministic(): + first = nest.run_job(interlock_job()) + second = nest.run_job(interlock_job()) + assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True) + + +def test_remnants_rebuilt_from_compacted_layout(): + compacted = nest.run_job(interlock_job()) + boxed = nest.run_job(interlock_job(), compact_outlines=False) + compacted_best = compacted["plate_reports"][0]["remnant_candidates"][0] + boxed_best = boxed["plate_reports"][0]["remnant_candidates"][0] + # Sliding the Z left frees a wider right-hand strip. + assert compacted_best["area"] > boxed_best["area"] + assert compacted_best["status"] == "candidate_unverified" + # No remnant candidate may claim space the moved parts now occupy: the + # widest strip starts right of the compacted Z bounding box. + z_part = _by_label(compacted, "TSC-Z") + assert compacted_best["width"] <= 17.25 - (z_part["x"] + z_part["w"] + CLEARANCE) + + +def test_flag_off_keeps_bounding_box_layout(): + result = nest.run_job(interlock_job(), compact_outlines=False) + compaction = result["plate_reports"][0]["compaction"] + assert compaction == { + "ran": False, "accepted": False, "recovered_in": 0.0, "passes": 0, + } + z_part = _by_label(result, "TSC-Z") + assert (z_part["x"], z_part["y"]) == (8.25, 0.0) + assert result["meta"]["compact_outlines"] is False + + +def test_configuration_hash_tracks_compaction_but_input_hash_does_not(): + on = nest.run_job(interlock_job()) + off = nest.run_job(interlock_job(), compact_outlines=False) + assert on["normalized_input_hash"] == off["normalized_input_hash"] + assert on["configuration_hash"] != off["configuration_hash"] + + +def test_rect_only_plates_are_not_compacted(): + job = interlock_job() + for part in job["parts"]: + part.pop("outline") + part["shape"] = "rect" + result = nest.run_job(job) + compaction = result["plate_reports"][0]["compaction"] + assert compaction["ran"] is False + assert result["outcome"] == "ready" + metric = result["metrics"]["true_shape_utilization_pct"] + assert metric["approximation"] == "exact" + + +def test_outline_less_irregular_part_disables_the_plate(): + job = interlock_job() + job["parts"][1].pop("outline") + job["parts"][1]["area"] = 33.9375 + result = nest.run_job(job) + plate = result["plate_reports"][0] + assert plate["compaction"]["ran"] is False + assert "true_shape_utilization_pct" not in plate + assert "true_shape_utilization_pct" not in result["metrics"] + z_part = _by_label(result, "TSC-Z") + assert z_part["x"] == 8.25 + + +def test_true_shape_metric_reports_exact_outline_area(): + result = nest.run_job(interlock_job()) + plate = result["plate_reports"][0] + metric = plate["true_shape_utilization_pct"] + assert metric["approximation"] == "outline_exact" + l_area = 8 * 6 - 5 * 3 + z_area = 7.75 * 6 - 4.75 * 3.5 + expected = 100 * (l_area + z_area) / (18.25 * 7) + assert abs(metric["value"] - round(expected, 1)) < 0.05 + assert result["metrics"]["true_shape_utilization_pct"]["value"] == metric["value"] + + +# --------------------------------------------------------------------------- +# Independent verifier and the discard-on-failure gate +# --------------------------------------------------------------------------- + +def test_verifier_rejects_profile_overlap_that_boxes_allow(): + l_placement = { + "shape": "irregular", "outline": copy.deepcopy(L_OUTLINE), + "rotated": False, "x": 0.0, "y": 0.0, "w": 8.0, "h": 6.0, + "ow": 8.0, "oh": 6.0, + } + # Tucked into the notch with legal profile clearance: accepted. + tucked = { + "shape": "rect", "outline": [], "rotated": False, + "x": 3.25, "y": 3.25, "w": 2.0, "h": 2.0, "ow": 2.0, "oh": 2.0, + } + assert verify_true_shape_placements( + [l_placement, tucked], + usable_width=16.0, usable_height=6.0, clearance=CLEARANCE, + ) == [] + # One half inch lower the rect enters the L's bottom slab: rejected. + overlapping = dict(tucked, y=2.75) + findings = verify_true_shape_placements( + [l_placement, overlapping], + usable_width=16.0, usable_height=6.0, clearance=CLEARANCE, + ) + assert [finding["code"] for finding in findings] == [ + "true_shape_clearance_violation" + ] + + +def test_verifier_rebuilds_rotated_outlines(): + # A rotated L occupies (oh - y, x): the same notch tuck only verifies + # when the verifier applies the placement rotation contract itself. + rotated_l = { + "shape": "irregular", "outline": copy.deepcopy(L_OUTLINE), + "rotated": True, "x": 0.0, "y": 0.0, "w": 6.0, "h": 8.0, + "ow": 8.0, "oh": 6.0, + } + # Rotated notch spans x in [0, 3], y in [3, 8] shifted: local corners + # become (6 - y, x), so the open notch sits at x in [0, 3], y in [3, 8]. + tucked = { + "shape": "rect", "outline": [], "rotated": False, + "x": 0.0, "y": 3.25, "w": 2.0, "h": 2.0, "ow": 2.0, "oh": 2.0, + } + assert verify_true_shape_placements( + [rotated_l, tucked], + usable_width=12.0, usable_height=8.0, clearance=CLEARANCE, + ) == [] + unrotated_claim = dict(rotated_l, rotated=False, w=8.0, h=6.0) + findings = verify_true_shape_placements( + [unrotated_claim, tucked], + usable_width=12.0, usable_height=8.0, clearance=CLEARANCE, + ) + assert findings, "same coordinates without rotation must collide" + + +def test_verifier_flags_out_of_bounds_profiles(): + placement = { + "shape": "irregular", "outline": copy.deepcopy(L_OUTLINE), + "rotated": False, "x": -0.5, "y": 0.0, "w": 8.0, "h": 6.0, + "ow": 8.0, "oh": 6.0, + } + findings = verify_true_shape_placements( + [placement], usable_width=16.0, usable_height=6.0, clearance=CLEARANCE, + ) + assert [finding["code"] for finding in findings] == ["true_shape_out_of_bounds"] + + +def test_gate_discards_a_corrupt_compaction(monkeypatch): + """Adversarial U3 case: a buggy compactor is rejected wholesale.""" + real = nest.compact_placements + + def corrupting(placements, clearance): + recovered, passes = real(placements, clearance) + for placement in placements: + if placement.label == "TSC-Z": + placement.x -= 1.0 # tunnel one inch into the L's clearance + recovered += 1.0 + return recovered, passes + + monkeypatch.setattr(nest, "compact_placements", corrupting) + result = nest.run_job(interlock_job()) + + plate = result["plate_reports"][0] + assert plate["compaction"]["ran"] is True + assert plate["compaction"]["accepted"] is False + assert plate["compaction"]["recovered_in"] == 0.0 + # Layout restored to the proven bounding-box packing. + z_part = _by_label(result, "TSC-Z") + assert (z_part["x"], z_part["y"]) == (8.25, 0.0) + # The run proceeds exactly as the uncompacted run would (R2)... + assert result["outcome"] == "review_required" + assert result["verification"]["status"] == "verified" + # ...with the rejection recorded as a non-blocking finding. + warnings = [ + finding + for finding in result["validation_findings"] + if finding["code"] == "COMPACTION_REJECTED" + ] + assert len(warnings) == 1 + assert warnings[0]["severity"] == "warning" + + +def test_accepted_plate_survives_result_level_verification(): + # verify_nest_placements dispatches accepted plates to the true-shape + # verifier; corrupting a published placement must now fail verification. + result = nest.run_job(interlock_job()) + tampered = copy.deepcopy(result["plate_reports"]) + for placement in tampered[0]["placements"]: + if placement["label"] == "TSC-Z": + placement["x"] -= 1.0 + from pi_steel.geometry_verify import verify_nest_placements + + clean = verify_nest_placements( + result["plate_reports"], edge_margin=0.5, inter_part_clearance=CLEARANCE + ) + assert clean == [] + findings = verify_nest_placements( + tampered, edge_margin=0.5, inter_part_clearance=CLEARANCE + ) + assert any( + finding["code"] == "true_shape_clearance_violation" for finding in findings + )