Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ 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.4.0] - 2026-08-23

### Added

- True polygon outlines for irregular plate parts: an optional
`geometry.outline` vertex list gives exact shoelace areas and weights
(`outline_exact`, replacing hand-declared estimates), exact
hole-inside-profile verification (a hole in a notch now blocks instead
of passing the bounding-box check), and real profile rendering in
layouts and reference DXFs. Outlines are validated as simple polygons
whose bounding box matches the declared part size, at the canonical
contract and in the direct nesting engine alike. Placement remains by
bounding box and burn-DXF suppression for irregular parts is unchanged.

## [0.3.1] - 2026-08-23

### Added
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ does not invent pricing.
### Nesting and DXF safety

`steel-nest` uses MaxRects bin packing for rectangular parts and reports yield,
scrap, reusable drops, and unplaced material. Irregular parts are estimated by
bounding box and are always flagged.
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.

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
Expand Down Expand Up @@ -146,7 +147,7 @@ npm run provenance:check # shape-data integrity and recorded decision
npm run release:check # complete release gate
```

The current package version is `0.3.1`. `release:check` verifies the test,
The current package version is `0.4.0`. `release:check` verifies the test,
privacy, package-content, shape-data integrity, ownership, license, and
redistribution contracts before publication.

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@structupath/pi-steel",
"version": "0.3.1",
"version": "0.4.0",
"description": "Structural steel estimating for Pi \u2014 validated takeoffs, plate nesting, guarded DXF output, and review-ready RFQ packages.",
"type": "module",
"keywords": [
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pi-steel-runtime"
version = "0.3.1"
version = "0.4.0"
description = "Python runtime dependencies and test configuration for pi-steel"
requires-python = ">=3.11,<3.14"
dependencies = [
Expand Down
212 changes: 210 additions & 2 deletions skills/_shared/pi_steel/geometry_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,217 @@ def hole_within_bounds(hole: dict[str, Any], width: float, height: float) -> boo
return False


def _finite_point(point: Any) -> bool:
return (
isinstance(point, (list, tuple))
and len(point) == 2
and all(
isinstance(value, (int, float))
and not isinstance(value, bool)
and math.isfinite(value)
for value in point
)
)


def polygon_area(outline: list[Any]) -> float:
"""Absolute shoelace area of a closed polygon given as vertex pairs."""
total = 0.0
count = len(outline)
for index in range(count):
x1, y1 = outline[index]
x2, y2 = outline[(index + 1) % count]
total += x1 * y2 - x2 * y1
return abs(total) / 2.0


def _orient(a, b, c):
value = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
if value > 1e-12:
return 1
if value < -1e-12:
return -1
return 0


def _segments_properly_intersect(p1, p2, p3, p4) -> bool:
"""Whether open segments p1-p2 and p3-p4 cross (shared endpoints excluded)."""
o1, o2 = _orient(p1, p2, p3), _orient(p1, p2, p4)
o3, o4 = _orient(p3, p4, p1), _orient(p3, p4, p2)
return o1 != o2 and o3 != o4 and 0 not in (o1, o2, o3, o4)


def _segments_touch(p1, p2, p3, p4) -> bool:
"""Any contact between closed segments: crossing, touch, or overlap."""
o1, o2 = _orient(p1, p2, p3), _orient(p1, p2, p4)
o3, o4 = _orient(p3, p4, p1), _orient(p3, p4, p2)
if o1 != o2 and o3 != o4:
return True
return (
(o1 == 0 and _point_on_segment(p3, p1, p2))
or (o2 == 0 and _point_on_segment(p4, p1, p2))
or (o3 == 0 and _point_on_segment(p1, p3, p4))
or (o4 == 0 and _point_on_segment(p2, p3, p4))
)


def polygon_is_simple(outline: list[Any]) -> bool:
"""Whether the ring never touches itself.

Rejects repeated vertices (which also covers zero-length edges and
spikes) and any contact between non-adjacent edges — proper crossings,
endpoint touches, and collinear overlaps alike.
"""
count = len(outline)
if len({(point[0], point[1]) for point in outline}) != count:
return False
edges = [
(outline[index], outline[(index + 1) % count]) for index in range(count)
]
for first in range(count):
for second in range(first + 1, count):
if second == first + 1 or (first == 0 and second == count - 1):
continue
if _segments_touch(*edges[first], *edges[second]):
return False
return True
Comment thread
Steel-tech marked this conversation as resolved.


def point_in_polygon(point: Any, outline: list[Any]) -> bool:
"""Ray-casting containment; boundary points count as inside."""
x, y = point
inside = False
count = len(outline)
for index in range(count):
x1, y1 = outline[index]
x2, y2 = outline[(index + 1) % count]
if _point_on_segment((x, y), (x1, y1), (x2, y2)):
return True
if (y1 > y) != (y2 > y):
crossing = (x2 - x1) * (y - y1) / (y2 - y1) + x1
if x < crossing:
inside = not inside
return inside


def _point_on_segment(point, start, end) -> bool:
px, py = point
x1, y1 = start
x2, y2 = end
cross = (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1)
if abs(cross) > 1e-9:
return False
return (
min(x1, x2) - 1e-9 <= px <= max(x1, x2) + 1e-9
and min(y1, y2) - 1e-9 <= py <= max(y1, y2) + 1e-9
)


def _point_segment_distance(point, start, end) -> float:
px, py = point
x1, y1 = start
x2, y2 = end
dx, dy = x2 - x1, y2 - y1
length_squared = dx * dx + dy * dy
if length_squared == 0:
return math.hypot(px - x1, py - y1)
t = max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / length_squared))
return math.hypot(px - (x1 + t * dx), py - (y1 + t * dy))


def validate_outline(
outline: Any, width: Any, height: Any
) -> list[str]:
"""Return human-readable problems with an irregular part outline.

A valid outline is a simple polygon of at least three finite vertex
pairs whose bounding box matches the declared width and height (the
outline defines the part in its own local frame).
"""
problems: list[str] = []
if not isinstance(outline, list) or len(outline) < 3:
return ["Outline requires at least three [x, y] vertex pairs."]
if not all(_finite_point(point) for point in outline):
return ["Outline vertices must be finite [x, y] pairs."]
if not polygon_is_simple(outline):
problems.append("Outline edges must not cross (simple polygon).")
if polygon_area(outline) <= 1e-9:
problems.append("Outline must enclose a positive area.")
if finite_positive(width) and finite_positive(height):
xs = [point[0] for point in outline]
ys = [point[1] for point in outline]
epsilon = 1e-6
if (
min(xs) < -epsilon
or min(ys) < -epsilon
or max(xs) > width + epsilon
or max(ys) > height + epsilon
or abs(min(xs)) > epsilon
or abs(min(ys)) > epsilon
or abs(max(xs) - width) > epsilon
or abs(max(ys) - height) > epsilon
):
problems.append(
"Outline bounding box must span exactly 0..width and 0..height."
)
return problems


def hole_within_outline(hole: dict[str, Any], outline: list[Any]) -> bool:
"""Exact containment of a supported hole inside the part outline."""
x, y = hole.get("x"), hole.get("y")
if not all(
isinstance(value, (int, float)) and math.isfinite(value)
for value in (x, y)
):
return False
count = len(outline)
edges = [
(outline[index], outline[(index + 1) % count]) for index in range(count)
]
if hole.get("kind") == "round":
diameter = hole.get("diameter")
if not finite_positive(diameter):
return False
radius = diameter / 2
if not point_in_polygon((x, y), outline):
return False
return all(
_point_segment_distance((x, y), start, end) >= radius - 1e-9
for start, end in edges
)
if hole.get("kind") == "rect":
hole_width, hole_height = hole.get("width"), hole.get("height")
if not finite_positive(hole_width) or not finite_positive(hole_height):
return False
corners = [
(x - hole_width / 2, y - hole_height / 2),
(x + hole_width / 2, y - hole_height / 2),
(x + hole_width / 2, y + hole_height / 2),
(x - hole_width / 2, y + hole_height / 2),
]
if not all(point_in_polygon(corner, outline) for corner in corners):
return False
rect_edges = [
(corners[index], corners[(index + 1) % 4]) for index in range(4)
]
return not any(
_segments_properly_intersect(*rect_edge, *edge)
for rect_edge in rect_edges
for edge in edges
)
return False


def gross_area(geometry: dict[str, Any]) -> float:
if geometry.get("shape") == "irregular" and geometry.get("area") is not None:
return geometry["area"]
if geometry.get("shape") == "irregular":
outline = geometry.get("outline")
if isinstance(outline, list) and len(outline) >= 3 and all(
_finite_point(point) for point in outline
):
return polygon_area(outline)
if geometry.get("area") is not None:
return geometry["area"]
return geometry.get("width", 0) * geometry.get("height", 0)


Expand Down
6 changes: 6 additions & 0 deletions skills/_shared/pi_steel/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ def adapt_legacy_nest(
key: part.get(key)
for key in ("name", "width", "height", "shape", "area")
}
# Only outline-bearing parts add the key: existing fallback identities
# must stay byte-stable for parts without one.
if part.get("outline") is not None:
identity["outline"] = part["outline"]
source_id = explicit_source or fallback_source_id(revision_id, identity)
geometry = {
"shape": part.get("shape", "rect"),
Expand Down Expand Up @@ -167,6 +171,8 @@ def adapt_legacy_nest(
}
if "area" in part:
geometry["area"] = part["area"]
if "outline" in part:
geometry["outline"] = part["outline"]
Comment thread
Steel-tech marked this conversation as resolved.
item = {
"intent": "fabricated_part",
"source_id": source_id,
Expand Down
56 changes: 54 additions & 2 deletions skills/_shared/pi_steel/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@
SUPPORTED_SHAPES,
finite_positive,
hole_within_bounds,
hole_within_outline,
net_area,
polygon_area,
validate_outline,
)


Expand Down Expand Up @@ -249,6 +252,42 @@ def _geometry_findings(
f"{base}.{field}",
f"{field} must be greater than zero.",
)
outline = geometry.get("outline")
valid_outline = False
if outline is not None:
if shape != "irregular":
_add(
findings,
input_hash,
"outline_on_rect",
"blocker",
f"{base}.outline",
"Outlines describe irregular parts; rectangular parts are exact already.",
)
else:
problems = validate_outline(
outline, geometry.get("width"), geometry.get("height")
)
for problem in problems:
_add(
findings,
input_hash,
"invalid_outline",
"blocker",
f"{base}.outline",
problem,
)
valid_outline = not problems
if valid_outline and finite_positive(geometry.get("area")):
if abs(polygon_area(outline) - geometry["area"]) > 1e-6:
_add(
findings,
input_hash,
"outline_area_mismatch",
"blocker",
f"{base}.area",
"Declared area disagrees with the outline's exact area.",
)
width, height = geometry.get("width"), geometry.get("height")
if finite_positive(width) and finite_positive(height):
for hole_index, hole in enumerate(geometry.get("holes", [])):
Expand All @@ -261,6 +300,15 @@ def _geometry_findings(
f"{base}.holes[{hole_index}]",
"Hole geometry must be positive and contained by the part.",
)
elif valid_outline and not hole_within_outline(hole, outline):
_add(
findings,
input_hash,
"hole_outside_outline",
"blocker",
f"{base}.holes[{hole_index}]",
"Hole must remain inside the part outline, not just its bounding box.",
)
try:
area = net_area(geometry)
except (TypeError, ValueError, OverflowError):
Expand All @@ -274,14 +322,18 @@ def _geometry_findings(
base,
"Part net area after holes must be greater than zero.",
)
if shape == "irregular" and not finite_positive(geometry.get("area")):
if (
shape == "irregular"
and outline is None
and not finite_positive(geometry.get("area"))
):
_add(
findings,
input_hash,
"invalid_irregular_area",
"blocker",
f"{base}.area",
"Irregular parts require a positive true-cut area.",
"Irregular parts require a positive true-cut area or an outline.",
)


Expand Down
Loading
Loading