Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9883e79
feat(cutlist): add linear cut-list optimization skill
claude Aug 23, 2026
2f6fdf7
feat(pipeline): run member cut-lists through estimate and RFQ
claude Aug 23, 2026
67224f2
docs(release): document cut-list skill and cut v0.3.0
claude Aug 23, 2026
59ef99d
fix(cutlist): resolve review findings on correctness and reuse
claude Aug 23, 2026
568c95a
feat(contracts): model linear stock in the canonical estimate
claude Aug 23, 2026
a16e93c
fix(pipeline): suppress verified cutting list on non-ready runs
claude Aug 23, 2026
81eedaf
fix(cutlist): address review findings on stock input strictness
claude Aug 23, 2026
b479317
Merge squash-merged main (PR #6) back into working branch
claude Aug 23, 2026
bb56f57
feat(cutlist): bounded exact search and cost-first ranking
claude Aug 23, 2026
164b69c
feat(cutlist): exact search explores optimal partial plans
claude Aug 23, 2026
0012cb6
test(cutlist): cover review's partial-plan case
claude Aug 23, 2026
86c4a0b
Merge squash-merged main (PR #7) back into working branch
claude Aug 23, 2026
8f8e998
feat(nest): true polygon outlines for irregular parts
claude Aug 23, 2026
82e767d
fix(outline): reject self-touching rings, distinct fallback identities
claude Aug 23, 2026
72d1f1b
Merge squash-merged main (PR #8) back into working branch
claude Aug 23, 2026
cd399dd
docs(plans): true-shape compaction plan for v0.5
claude Aug 23, 2026
09b2aa5
feat(geometry): polygon clearance primitives for true-shape work
claude Aug 23, 2026
e082941
docs(plans): scan-to-first-contact slide, not binary search
claude Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions docs/plans/2026-08-23-002-feat-true-shape-compaction-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
---
title: "feat: True-Shape Compaction for Outlined Irregular Parts"
type: feat
date: 2026-08-23
---

# feat: True-Shape Compaction for Outlined Irregular Parts

## Summary

Make v0.5 the first placement-quality milestone for irregular geometry. Parts
that carry a validated polygon outline (added in v0.4.0) still occupy their
full bounding box during placement, so real yield is understated and layouts
waste plate. This plan adds a deterministic post-placement compaction pass
that slides outlined parts along fixed axes until their true profiles reach
the required clearance, plus an independent true-shape clearance verifier
that gates every compacted layout. Placement claims only improve when the
verifier proves them.

---

## Problem Frame

v0.4.0 gave irregular parts exact areas, exact hole checks, and true profile
rendering, but the MaxRects packer still reserves the whole bounding box.
Two L-shaped gussets whose profiles could interlock are laid out as if they
were rectangles, so sheets-needed and remnant candidates are pessimistic.
Full no-fit-polygon nesting is a dedicated CAM engine and remains out of
scope; the tractable, verifiable step is compaction: keep the proven
bounding-box packing as the starting layout, then recover space by sliding
parts toward the plate origin while their outlines (not their boxes) respect
kerf-plus-gap clearance.

The safety posture must not weaken. A compaction bug that overlaps two parts
would be worse than the pessimism it fixes, so the compacted layout is only
published when an independent polygon-clearance verifier — sharing no code
path with the compactor — proves every pairwise profile distance and plate
containment. Any verification failure discards compaction for that plate and
keeps the original bounding-box layout.

---

## Requirements

### Safety and verification

- R1. Compaction must never move a part such that any two true profiles come
closer than kerf plus part gap, or any profile crosses the usable-area
boundary; rectangles keep their exact-footprint guarantees.
- R2. An independent verifier must recheck every compacted plate using only
published placement data: pairwise minimum polygon distance, plate bounds,
and hole containment. A failed check discards compaction for that plate
and records a finding; it never blocks the run that the uncompacted layout
would have passed.
- R3. Burn-DXF eligibility rules are unchanged: any irregular part still
suppresses burn output and holds the run at review_required.
- R4. Determinism: identical input must produce identical compacted layouts;
no randomized restarts or time-dependent iteration.

### Reporting honesty

- R5. Metrics must distinguish the packing basis: bounding-box packing
utilization stays as-is, and a new true-shape utilization is reported only
for plates where every irregular part has a validated outline.
- R6. Remnant candidates remain rectangle-based and unverified; compaction
may enlarge them but must not claim certified reusable stock.
- R7. The result must record per-plate whether compaction ran, how much slide
distance it recovered, and whether the verifier accepted it.

---

## Design

### Polygon clearance primitives (shared `geometry_verify`)

- `polygon_min_distance(a, b)` — exact minimum distance between two simple
polygons via pairwise segment distance, with an early exit when bounding
boxes are farther apart than the current minimum. Zero when boundaries
touch or interiors overlap (overlap detected by any vertex containment or
edge intersection).
- `polygon_within_rect(outline, width, height)` — containment of a profile
in the usable area.
- Rectangles participate as their four-corner polygons so mixed plates are
handled uniformly.

### Compaction pass (in `nest.py`, after MaxRects placement)

Deterministic left-then-down sliding, one plate at a time:

1. Order placements by (x, y, placement_id).
2. For each placement, slide leftward (then downward) by scanning fixed
1/32 in steps from the current position and stopping one step before the
first offset where the profile violates `kerf + gap` clearance against
any already-fixed profile or the usable boundary. Clearance along a
slide is NOT monotonic for concave profiles (a notch can make an offset
clear, then blocked, then clear again), so binary search is unsound
here; the slide is a continuous motion, and the first blocking step is
the physical stop. Step count is bounded by plate size over resolution,
keeping the pass deterministic.
3. Repeat the sweep until a pass moves nothing (bounded iteration count).
Comment thread
Steel-tech marked this conversation as resolved.

U2's fixtures must include a concave alternating-clearance case — a
profile whose slide path is clear, blocked by a notch neighbor, then clear
again — proving the scan stops at the first contact rather than tunneling
to a later clear interval.

Only plates where every irregular part carries a validated outline are
eligible; mixed plates with outline-less irregular parts keep the
bounding-box layout untouched.

### Verification and gating

`verify_true_shape_placements(plate)` runs after compaction with the same
clearance contract as the rectangular verifier. Acceptance replaces the
plate's placements and recomputes plate metrics; rejection restores the
original placements, adds a `COMPACTION_REJECTED` warning finding, and the
run proceeds exactly as v0.4.0 would have.

### Result contract additions

- Plate report: `compaction: {ran, accepted, recovered_in, passes}`.
- Metrics: `true_shape_utilization_pct` with approximation label, present
only when R5's condition holds.
- Nest-result schema additions are additive; schema version stays 1.0.0.

---

## Units of Work

- U1. Polygon distance and containment primitives with exhaustive unit tests
(touching, overlapping, nested, translated-apart cases).
- U2. Compaction pass behind a `--compact-outlines` flag defaulting off in
the direct engine and pipeline until U3 lands; deterministic-layout tests.
- U3. Independent true-shape verifier plus the discard-on-failure gate;
adversarial tests that corrupt a compacted layout and prove rejection.
- U4. Metrics, plate-report contract, schema, renderer updates (compacted
outlines drawn at their new positions), SKILL.md and README boundaries.
- U5. Flip the default on with a recorded before/after fixture comparison
demonstrating recovered plate area, then release as v0.5.0.

## Non-Goals

- No-fit-polygon or free-rotation true-shape nesting.
- Any change to burn-DXF eligibility for irregular parts.
- Curved (arc/spline) outline segments; outlines remain polygonal.
- Remnant certification.
77 changes: 77 additions & 0 deletions skills/_shared/pi_steel/geometry_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,83 @@ def hole_within_outline(hole: dict[str, Any], outline: list[Any]) -> bool:
return False


def _segment_min_distance(p1, p2, p3, p4) -> float:
"""Minimum distance between two closed segments."""
if _segments_touch(p1, p2, p3, p4):
return 0.0
return min(
_point_segment_distance(p1, p3, p4),
_point_segment_distance(p2, p3, p4),
_point_segment_distance(p3, p1, p2),
_point_segment_distance(p4, p1, p2),
)


def _polygon_edges(outline: list[Any]) -> list[tuple[Any, Any]]:
count = len(outline)
return [
(outline[index], outline[(index + 1) % count]) for index in range(count)
]


def rect_outline(width: float, height: float) -> list[list[float]]:
"""The four-corner polygon of an axis-aligned rectangle at the origin."""
return [[0.0, 0.0], [width, 0.0], [width, height], [0.0, height]]


def polygons_overlap(first: list[Any], second: list[Any]) -> bool:
"""Whether two simple polygons share boundary or interior points."""
if any(point_in_polygon(point, second) for point in first):
return True
if any(point_in_polygon(point, first) for point in second):
return True
return any(
_segments_touch(*edge_a, *edge_b)
for edge_a in _polygon_edges(first)
for edge_b in _polygon_edges(second)
)


def polygon_min_distance(first: list[Any], second: list[Any]) -> float:
"""Exact minimum distance between two simple polygons; zero on contact.

Uses a bounding-box early exit per edge pair so dense outlines stay
affordable inside pairwise plate verification.
"""
if polygons_overlap(first, second):
return 0.0
minimum = math.inf
for edge_a in _polygon_edges(first):
(ax1, ay1), (ax2, ay2) = edge_a
for edge_b in _polygon_edges(second):
(bx1, by1), (bx2, by2) = edge_b
if (
min(ax1, ax2) - minimum > max(bx1, bx2)
or min(bx1, bx2) - minimum > max(ax1, ax2)
or min(ay1, ay2) - minimum > max(by1, by2)
or min(by1, by2) - minimum > max(ay1, ay2)
):
continue
distance = _segment_min_distance(*edge_a, *edge_b)
if distance < minimum:
minimum = distance
if minimum == 0.0:
return 0.0
return minimum


def polygon_within_rect(
outline: list[Any], width: float, height: float
) -> bool:
"""Whether every vertex lies inside the axis-aligned 0..width x 0..height box."""
epsilon = 1e-9
return all(
-epsilon <= point[0] <= width + epsilon
and -epsilon <= point[1] <= height + epsilon
for point in outline
)


def gross_area(geometry: dict[str, Any]) -> float:
if geometry.get("shape") == "irregular":
outline = geometry.get("outline")
Expand Down
38 changes: 38 additions & 0 deletions tests/test_outline_geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,3 +379,41 @@ def test_placement_outline_schema_rejects_degenerate_vertex_lists():
assert validator.is_valid({"outline": [[0, 0], [1, 0], [1, 1]]})
assert not validator.is_valid({"outline": [[0, 0]]})
assert not validator.is_valid({"outline": [[0, 0], [1, 0]]})


def test_polygon_distance_primitives():
from pi_steel.geometry_verify import (
polygon_min_distance,
polygon_within_rect,
polygons_overlap,
rect_outline,
)

unit = rect_outline(2, 2)
apart = [[5, 0], [7, 0], [7, 2], [5, 2]]
touching = [[2, 0], [4, 0], [4, 2], [2, 2]]
overlapping = [[1, 1], [3, 1], [3, 3], [1, 3]]
nested = [[0.5, 0.5], [1.5, 0.5], [1.5, 1.5], [0.5, 1.5]]

assert not polygons_overlap(unit, apart)
assert polygons_overlap(unit, touching)
assert polygons_overlap(unit, overlapping)
assert polygons_overlap(unit, nested)

assert polygon_min_distance(unit, apart) == 3.0
assert polygon_min_distance(unit, touching) == 0.0
assert polygon_min_distance(unit, overlapping) == 0.0
assert polygon_min_distance(unit, nested) == 0.0
# Diagonal separation: closest corners are (2,2) and (4,4).
diagonal = [[4, 4], [6, 4], [6, 6], [4, 6]]
import math

assert abs(polygon_min_distance(unit, diagonal) - math.hypot(2, 2)) < 1e-9

# An L-shape and a small square nested near (not into) its notch.
near_notch = [[4, 4], [7, 4], [7, 5], [4, 5]]
assert not polygons_overlap(L_SHAPE, near_notch)
assert polygon_min_distance(L_SHAPE, near_notch) == 1.0

assert polygon_within_rect(L_SHAPE, 8, 6)
assert not polygon_within_rect(L_SHAPE, 7, 6)
Loading