diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a26c36..2ec20b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4d7ce09..2f13ff4 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/package.json b/package.json index 9fcd7f4..05d81dc 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/pyproject.toml b/pyproject.toml index 0fa5fe2..0710fd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/skills/_shared/pi_steel/geometry_verify.py b/skills/_shared/pi_steel/geometry_verify.py index 94e5d5d..fb0618d 100644 --- a/skills/_shared/pi_steel/geometry_verify.py +++ b/skills/_shared/pi_steel/geometry_verify.py @@ -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 + + +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) diff --git a/skills/_shared/pi_steel/parsing.py b/skills/_shared/pi_steel/parsing.py index 8838a18..5ad9b02 100644 --- a/skills/_shared/pi_steel/parsing.py +++ b/skills/_shared/pi_steel/parsing.py @@ -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"), @@ -167,6 +171,8 @@ def adapt_legacy_nest( } if "area" in part: geometry["area"] = part["area"] + if "outline" in part: + geometry["outline"] = part["outline"] item = { "intent": "fabricated_part", "source_id": source_id, diff --git a/skills/_shared/pi_steel/validation.py b/skills/_shared/pi_steel/validation.py index 51a6d8b..aa3462b 100644 --- a/skills/_shared/pi_steel/validation.py +++ b/skills/_shared/pi_steel/validation.py @@ -21,7 +21,10 @@ SUPPORTED_SHAPES, finite_positive, hole_within_bounds, + hole_within_outline, net_area, + polygon_area, + validate_outline, ) @@ -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", [])): @@ -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): @@ -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.", ) diff --git a/skills/_shared/schemas/estimate-package.schema.json b/skills/_shared/schemas/estimate-package.schema.json index 63e387d..9c604b2 100644 --- a/skills/_shared/schemas/estimate-package.schema.json +++ b/skills/_shared/schemas/estimate-package.schema.json @@ -110,6 +110,16 @@ "height": { "type": "number", "exclusiveMinimum": 0 }, "thickness": { "type": "number", "exclusiveMinimum": 0 }, "area": { "type": "number", "exclusiveMinimum": 0 }, + "outline": { + "type": "array", + "items": { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + }, + "minItems": 3 + }, "holes": { "type": "array", "items": { "$ref": "#/$defs/hole" }, diff --git a/skills/_shared/schemas/nest-result.schema.json b/skills/_shared/schemas/nest-result.schema.json index 323b1af..a4b14fc 100644 --- a/skills/_shared/schemas/nest-result.schema.json +++ b/skills/_shared/schemas/nest-result.schema.json @@ -226,7 +226,13 @@ "properties": { "value": { "type": "number", "minimum": 0, "maximum": 100 }, "approximation": { - "enum": ["exact", "bounding_box", "declared_area", "bounding_box_estimate"] + "enum": [ + "exact", + "bounding_box", + "declared_area", + "bounding_box_estimate", + "outline_exact" + ] } }, "additionalProperties": false @@ -273,6 +279,16 @@ "ow": { "type": "number", "exclusiveMinimum": 0 }, "oh": { "type": "number", "exclusiveMinimum": 0 }, "holes": { "type": "array", "items": { "type": "object" } }, + "outline": { + "type": "array", + "items": { + "type": "array", + "items": { "type": "number" }, + "minItems": 2, + "maxItems": 2 + }, + "oneOf": [{ "maxItems": 0 }, { "minItems": 3 }] + }, "base_area": { "type": "number", "exclusiveMinimum": 0 }, "holes_area": { "type": "number", "minimum": 0 }, "material": { "type": "string" }, diff --git a/skills/steel-estimate/scripts/build-estimate-package.py b/skills/steel-estimate/scripts/build-estimate-package.py index 13464fc..8d0980f 100755 --- a/skills/steel-estimate/scripts/build-estimate-package.py +++ b/skills/steel-estimate/scripts/build-estimate-package.py @@ -183,6 +183,8 @@ def nest_job_from_package( } if geometry.get("area") is not None: part["area"] = geometry["area"] + if geometry.get("outline") is not None: + part["outline"] = geometry["outline"] parts.append(part) return { "job_name": package["project"].get("name") diff --git a/skills/steel-nest/SKILL.md b/skills/steel-nest/SKILL.md index 5f1c692..fd58d65 100644 --- a/skills/steel-nest/SKILL.md +++ b/skills/steel-nest/SKILL.md @@ -28,7 +28,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 on those, get the true cut area (in²) into the part's `area` field. 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), 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. - 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:** @@ -40,7 +40,7 @@ Everything drives a single job JSON (schema in `references/job_template.json`; a Gather three things: -1. **Parts** — for each unique part: name, width × height (inches; use the bounding box for odd shapes), quantity, whether it's `rect` or `irregular`, and whether rotation is allowed (`rotatable: false` locks grain/rolling direction for anisotropic material or directional finish). If a rectangular part has **holes or cutouts**, add a `holes` list — each hole's `x,y` is its center from the part's lower-left corner: round = `{"dia":, "x":, "y":}`, rectangular cutout = `{"w":, "h":, "x":, "y":}`. A supported hole must remain fully inside its part or fabrication-style DXFs are suppressed. Holes are optional; skip them if you only need the layout/estimate. +1. **Parts** — for each unique part: name, width × height (inches; use the bounding box for odd shapes), quantity, whether it's `rect` or `irregular`, and whether rotation is allowed (`rotatable: false` locks grain/rolling direction for anisotropic material or directional finish). For an irregular part, add an `outline` vertex list for exact area, weight, and hole checks. If a part has **holes or cutouts**, add a `holes` list — each hole's `x,y` is its center from the part's lower-left corner: round = `{"dia":, "x":, "y":}`, rectangular cutout = `{"w":, "h":, "x":, "y":}`. A supported hole must remain fully inside its part or fabrication-style DXFs are suppressed. Holes are optional; skip them if you only need the layout/estimate. 2. **Stock** — plate size(s), explicit material, grade, and thickness, plus finite quantity or `unlimited`. A price is optional; if provided, use exactly one approved basis (`cost_per_lb` or `cost_per_sheet`) and retain its source outside this legacy JSON boundary. 3. **Cut settings** — kerf, part gap, edge margin, material density. Sensible defaults are in the template; only ask if the user hasn't implied them. Common kerf: plasma ~0.06", oxy-fuel ~0.10", laser ~0.02", waterjet ~0.03". diff --git a/skills/steel-nest/scripts/nest.py b/skills/steel-nest/scripts/nest.py index dbf9190..4a4e678 100644 --- a/skills/steel-nest/scripts/nest.py +++ b/skills/steel-nest/scripts/nest.py @@ -70,6 +70,9 @@ SUPPORTED_SHAPES, finite_positive, hole_within_bounds, + hole_within_outline, + polygon_area, + validate_outline, verify_nest_placements, ) @@ -109,6 +112,7 @@ class Placement: ow: float # original (unrotated) part width oh: float # original (unrotated) part height holes: list = field(default_factory=list) # in original part coords + outline: list = field(default_factory=list) # true profile, original coords base_area: float = 0.0 # gross area (bbox for rect, declared area for irregular) holes_area: float = 0.0 # total area removed by holes/cutouts material: str = "" @@ -231,6 +235,13 @@ def hole_local(pc, hole): return hx, hy +def outline_local(pc, outline): + """Outline vertices in the placed part's frame, rotated with the part.""" + if pc["rotated"]: + return [(pc["oh"] - y, x) for x, y in outline] + return [(x, y) for x, y in outline] + + # -------------------------------------------------------------------------- # Job runner # -------------------------------------------------------------------------- @@ -388,13 +399,33 @@ def number(value, path, *, positive=False, nonnegative=False): "Material, grade, and thickness must be explicit before placement.", ) ) + outline = part.get("outline") + valid_outline = False + if outline is not None: + if shape != "irregular": + findings.append( + _validation_finding( + "outline_on_rect", + f"{path}.outline", + "Outlines describe irregular parts; rectangular parts are exact already.", + ) + ) + outline = None + else: + outline_problems = validate_outline(outline, width, height) + for problem in outline_problems: + findings.append( + _validation_finding( + "invalid_outline", f"{path}.outline", problem + ) + ) + valid_outline = not outline_problems holes = part.get("holes", []) or [] holes_area = 0.0 if finite_positive(width) and finite_positive(height): for hole_index, hole in enumerate(holes): - if not hole_within_bounds( - _legacy_hole_to_canonical(hole), width, height - ): + canonical_hole = _legacy_hole_to_canonical(hole) + if not hole_within_bounds(canonical_hole, width, height): findings.append( _validation_finding( "invalid_hole_geometry", @@ -402,6 +433,16 @@ def number(value, path, *, positive=False, nonnegative=False): "Hole is unsupported or extends outside the part.", ) ) + elif valid_outline and not hole_within_outline( + canonical_hole, outline + ): + findings.append( + _validation_finding( + "invalid_hole_geometry", + f"{path}.holes[{hole_index}]", + "Hole must remain inside the part outline, not just its bounding box.", + ) + ) try: holes_area += hole_area(hole) except (TypeError, ValueError): @@ -409,7 +450,26 @@ def number(value, path, *, positive=False, nonnegative=False): base_area = width * height if finite_positive(width) and finite_positive(height) else 0 approximation = "exact" if shape == "irregular": - if part.get("area") is None: + if valid_outline: + exact_area = polygon_area(outline) + if part.get("area") is not None: + declared_area = number( + part.get("area"), f"{path}.area", positive=True + ) + if ( + math.isfinite(declared_area) + and abs(declared_area - exact_area) > 1e-6 + ): + findings.append( + _validation_finding( + "outline_area_mismatch", + f"{path}.area", + "Declared area disagrees with the outline's exact area.", + ) + ) + base_area = exact_area + approximation = "outline_exact" + elif part.get("area") is None: approximation = "bounding_box_estimate" findings.append( _validation_finding( @@ -440,20 +500,24 @@ def number(value, path, *, positive=False, nonnegative=False): ) ) explicit_source = part.get("source_id") + fallback_identity = { + key: part.get(key) + for key in ( + "name", + "material", + "grade", + "thickness", + "width", + "height", + "shape", + ) + } + # 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: + fallback_identity["outline"] = part["outline"] source_id = explicit_source or fallback_source_id( - revision_id, - { - key: part.get(key) - for key in ( - "name", - "material", - "grade", - "thickness", - "width", - "height", - "shape", - ) - }, + revision_id, fallback_identity ) item_id = part.get("item_id") or item_id_for( project_id, revision_id, source_id @@ -469,6 +533,7 @@ def number(value, path, *, positive=False, nonnegative=False): "rotatable": bool(part.get("rotatable", True)), "shape": shape, "holes": holes, + "outline": outline if valid_outline else [], "base_area": base_area, "holes_area": holes_area, "net_area_approximation": approximation, @@ -735,6 +800,7 @@ def commit(plate, unit, placement): ow=unit["w"], oh=unit["h"], holes=unit["holes"], + outline=unit["outline"], base_area=unit["base_area"], holes_area=unit["holes_area"], material=unit["material"], @@ -795,6 +861,14 @@ def _metric(value, approximation): return {"value": round(value, 1), "approximation": approximation} +def _net_status(statuses): + """Least-exact net-area status wins: estimates dominate exact outlines.""" + for status in ("bounding_box_estimate", "declared_area", "outline_exact"): + if status in statuses: + return status + return "exact" + + def _remnant_candidates(plate, margin, spacing): stock = plate["stock"] usable_width = stock["W"] - 2 * margin @@ -885,15 +959,7 @@ def _summarize( net_approximation_by_item[placement.item_id] for placement in plate["placements"] } - net_approximation = ( - "exact" - if plate_net_statuses == {"exact"} - else ( - "bounding_box_estimate" - if "bounding_box_estimate" in plate_net_statuses - else "declared_area" - ) - ) + net_approximation = _net_status(plate_net_statuses) report = { "index": plate["index"], "stock": stock["name"], @@ -935,11 +1001,7 @@ def _summarize( else: cost_status, cost_total = "not_provided", None packing_status = "bounding_box" if has_irregular else "exact" - net_status = ( - "bounding_box_estimate" - if "bounding_box_estimate" in net_approximations - else ("declared_area" if "declared_area" in net_approximations else "exact") - ) + net_status = _net_status(net_approximations) metrics = { "packing_utilization_pct": _metric( 100 * total_packing_area / total_plate_area if total_plate_area else 0, @@ -1273,9 +1335,17 @@ def render_layout(res, outdir): w, h = pc["w"], pc["h"] irregular = pc["shape"] == "irregular" face = "#f4c9a0" if irregular else "#a9c8e8" - ax.add_patch(mpatches.Rectangle((x, y), w, h, facecolor=face, - edgecolor="#1a3b5c", lw=1.2, - hatch="///" if irregular else None, alpha=0.9)) + if pc.get("outline"): + ax.add_patch(mpatches.Rectangle( + (x, y), w, h, fill=False, ec="#c9a227", lw=0.6, ls=":")) + ax.add_patch(mpatches.Polygon( + [(x + vx, y + vy) for vx, vy in outline_local(pc, pc["outline"])], + closed=True, facecolor=face, edgecolor="#1a3b5c", + lw=1.2, alpha=0.9)) + else: + ax.add_patch(mpatches.Rectangle((x, y), w, h, facecolor=face, + edgecolor="#1a3b5c", lw=1.2, + hatch="///" if irregular else None, alpha=0.9)) for hole in pc.get("holes", []): lx, ly = hole_local(pc, hole) cx, cy = x + lx, y + ly @@ -1322,11 +1392,18 @@ def _draw_part_dxf(msp, pc, x0, y0, profile_layer, holes_layer, notes_layer, lab import ezdxf x, y = x0 + pc["x"], y0 + pc["y"] w, h = pc["w"], pc["h"] - msp.add_lwpolyline( - [(x, y), (x + w, y), (x + w, y + h), (x, y + h)], - close=True, - dxfattribs={"layer": profile_layer}, - ) + if pc.get("outline"): + msp.add_lwpolyline( + [(x + vx, y + vy) for vx, vy in outline_local(pc, pc["outline"])], + close=True, + dxfattribs={"layer": profile_layer}, + ) + else: + msp.add_lwpolyline( + [(x, y), (x + w, y), (x + w, y + h), (x, y + h)], + close=True, + dxfattribs={"layer": profile_layer}, + ) for hole in pc.get("holes", []): lx, ly = hole_local(pc, hole) cx, cy = x + lx, y + ly diff --git a/tests/test_outline_geometry.py b/tests/test_outline_geometry.py new file mode 100644 index 0000000..d7fd6b0 --- /dev/null +++ b/tests/test_outline_geometry.py @@ -0,0 +1,381 @@ +import importlib.util +import json +import sys +from copy import deepcopy +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SHARED = ROOT / "skills" / "_shared" +sys.path.insert(0, str(SHARED)) +NEST_SCRIPT = ROOT / "skills" / "steel-nest" / "scripts" / "nest.py" +SPEC = importlib.util.spec_from_file_location("pi_steel_nest_outline", NEST_SCRIPT) +nest = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = nest +SPEC.loader.exec_module(nest) + +from pi_steel.geometry_verify import ( # noqa: E402 + hole_within_outline, + point_in_polygon, + polygon_area, + polygon_is_simple, + validate_outline, +) +from pi_steel.validation import validate_estimate_package # noqa: E402 + + +# L-shape: 8 x 6 bounding box with the upper-right 5 x 3 notch removed. +L_SHAPE = [[0, 0], [8, 0], [8, 3], [3, 3], [3, 6], [0, 6]] +BOWTIE = [[0, 0], [4, 4], [4, 0], [0, 4]] + + +def test_polygon_helpers_on_l_shape_and_bowtie(): + assert polygon_area(L_SHAPE) == 33.0 + assert polygon_is_simple(L_SHAPE) + assert not polygon_is_simple(BOWTIE) + assert point_in_polygon((1.5, 1.5), L_SHAPE) + assert not point_in_polygon((6, 5), L_SHAPE) + assert validate_outline(L_SHAPE, 8, 6) == [] + assert validate_outline(L_SHAPE, 10, 6) != [] + assert validate_outline([[0, 0], [1, 1]], 8, 6) != [] + + +def test_hole_containment_uses_true_outline(): + inside = {"kind": "round", "diameter": 1, "x": 1.5, "y": 1.5} + in_notch = {"kind": "round", "diameter": 1, "x": 6, "y": 5} + touching_edge = {"kind": "round", "diameter": 2, "x": 3.5, "y": 2.5} + assert hole_within_outline(inside, L_SHAPE) + assert not hole_within_outline(in_notch, L_SHAPE) + assert not hole_within_outline(touching_edge, L_SHAPE) + rect_ok = {"kind": "rect", "width": 1, "height": 1, "x": 1.5, "y": 1.5} + rect_crossing = {"kind": "rect", "width": 4, "height": 1, "x": 4, "y": 2.8} + assert hole_within_outline(rect_ok, L_SHAPE) + assert not hole_within_outline(rect_crossing, L_SHAPE) + + +def outline_job(): + return { + "job_name": "SYNTHETIC-OUTLINE", + "material": "carbon_steel", + "grade": "A36", + "unit_system": "imperial", + "settings": { + "kerf_in": 0.05, + "part_gap_in": 0.2, + "edge_margin_in": 0.5, + "thickness_in": 0.5, + "density_lb_in3": 0.2836, + }, + "stock": [ + { + "stock_id": "SYNTHETIC-STOCK-OUTLINE", + "name": "Synthetic Plate", + "width": 20, + "height": 10, + "thickness": 0.5, + "qty": 1, + } + ], + "parts": [ + { + "source_id": "SYNTHETIC-SRC-L", + "name": "SYNTHETIC-L", + "width": 8, + "height": 6, + "qty": 1, + "shape": "irregular", + "outline": deepcopy(L_SHAPE), + } + ], + } + + +def test_outline_part_gets_exact_area_and_review_required_outcome(): + result = nest.run_job(outline_job()) + assert result["outcome"] == "review_required" + part = result["plate_reports"][0]["placements"][0] + assert part["base_area"] == 33.0 + assert part["outline"] == L_SHAPE + assert result["metrics"]["net_material_yield_pct"]["approximation"] == ( + "outline_exact" + ) + # Exact net weight: 33 in^2 x 0.5 in x 0.2836 lb/in^3. + assert result["total_part_weight_lb"] == round(33 * 0.5 * 0.2836, 1) + assert result["burn_dxf_eligible"] is False + + +def test_hole_in_bbox_but_outside_outline_blocks(): + job = outline_job() + job["parts"][0]["holes"] = [{"dia": 1, "x": 6, "y": 5}] + result = nest.run_job(job) + assert result["outcome"] == "blocked" + assert any( + finding["code"] == "invalid_hole_geometry" + for finding in result["validation_findings"] + ) + + +def test_self_intersecting_or_mismatched_outline_blocks(): + job = outline_job() + job["parts"][0]["outline"] = deepcopy(BOWTIE) + job["parts"][0]["width"], job["parts"][0]["height"] = 4, 4 + bowtie = nest.run_job(job) + assert bowtie["outcome"] == "blocked" + assert any( + finding["code"] == "invalid_outline" + for finding in bowtie["validation_findings"] + ) + + job = outline_job() + job["parts"][0]["area"] = 30 + mismatch = nest.run_job(job) + assert mismatch["outcome"] == "blocked" + assert any( + finding["code"] == "outline_area_mismatch" + for finding in mismatch["validation_findings"] + ) + + job = outline_job() + job["parts"][0]["shape"] = "rect" + on_rect = nest.run_job(job) + assert on_rect["outcome"] == "blocked" + assert any( + finding["code"] == "outline_on_rect" + for finding in on_rect["validation_findings"] + ) + + +def test_reference_renders_draw_true_outline(tmp_path): + result = nest.run_job(outline_job()) + dxf_paths = nest.render_reference_plate_dxfs(result, tmp_path) + assert dxf_paths + import ezdxf + + document = ezdxf.readfile(dxf_paths[0]) + polylines = [ + entity + for entity in document.modelspace() + if entity.dxftype() == "LWPOLYLINE" + and entity.dxf.layer == "BOUNDS" + ] + assert polylines + assert len(polylines[0]) == len(L_SHAPE) + + pdf_path, png_paths = nest.render_layout(result, tmp_path) + assert Path(pdf_path).exists() + assert all(Path(path).exists() for path in png_paths) + + +def canonical_outline_package(): + return { + "schema_version": "1.0.0", + "project": { + "project_id": "SYNTHETIC-OUTLINE-001", + "revision": {"revision_id": "SYNTHETIC-REV-A"}, + }, + "unit_system": "imperial", + "items": [ + { + "intent": "fabricated_part", + "source_id": "SYNTHETIC-SRC-L", + "item_id": "item:synthetic-outline-l", + "quantity": 1, + "mark": "L1", + "material": "carbon_steel", + "grade": "A36", + "geometry": { + "shape": "irregular", + "width": 8, + "height": 6, + "thickness": 0.5, + "outline": deepcopy(L_SHAPE), + "holes": [], + "rotatable": True, + }, + "source_evidence": [ + {"source": "SYNTHETIC-SOURCE", "locator": "L1"} + ], + } + ], + "stock": [], + "commercial_basis": {"currency": "USD", "costs": []}, + "review": {"status": "draft", "findings": [], "acknowledgements": []}, + "lineage": { + "source_type": "synthetic_test", + "source_hash": "a" * 64, + "configuration_hash": "b" * 64, + }, + } + + +def test_canonical_validator_accepts_outline_without_declared_area(): + result = validate_estimate_package(canonical_outline_package()) + codes = {finding["code"] for finding in result.findings} + assert "invalid_irregular_area" not in codes + assert "invalid_outline" not in codes + assert not result.blockers + + +def test_canonical_validator_rejects_notch_hole_and_bad_outline(): + package = canonical_outline_package() + package["items"][0]["geometry"]["holes"] = [ + {"kind": "round", "diameter": 1, "x": 6, "y": 5} + ] + notch = validate_estimate_package(package) + assert any( + finding["code"] == "hole_outside_outline" for finding in notch.blockers + ) + + package = canonical_outline_package() + package["items"][0]["geometry"]["outline"] = deepcopy(BOWTIE) + package["items"][0]["geometry"]["width"] = 4 + package["items"][0]["geometry"]["height"] = 4 + bowtie = validate_estimate_package(package) + assert any( + finding["code"] == "invalid_outline" for finding in bowtie.blockers + ) + + package = canonical_outline_package() + package["items"][0]["geometry"]["area"] = 30 + mismatch = validate_estimate_package(package) + assert any( + finding["code"] == "outline_area_mismatch" + for finding in mismatch.blockers + ) + + +def test_pipeline_carries_outline_through_nest(tmp_path): + sys.path.insert(0, str(ROOT / "tests")) + from test_estimate_pipeline import load_package, run_pipeline # noqa: E402 + + package = load_package() + for item in package["items"]: + geometry = item.get("geometry") + if geometry and item.get("mark") == "P2": + geometry["shape"] = "irregular" + width, height = geometry["width"], geometry["height"] + geometry["outline"] = [ + [0, 0], + [width, 0], + [width, height / 2], + [width / 2, height / 2], + [width / 2, height], + [0, height], + ] + completed, run_path = run_pipeline( + tmp_path, package, "SYNTHETIC-PIPELINE-OUTLINE" + ) + assert completed.returncode == 2, completed.stdout + completed.stderr + nest_result = json.loads((run_path / "nest-result.json").read_text()) + assert nest_result["metrics"]["net_material_yield_pct"]["approximation"] in { + "outline_exact", + "declared_area", + } + outlines = [ + placement["outline"] + for plate in nest_result["plate_reports"] + for placement in plate["placements"] + if placement["outline"] + ] + assert outlines + + +def test_self_touching_rings_are_rejected(): + # Reused boundary via a repeated vertex (CodeRabbit review case). + reused = [[0, 0], [4, 0], [4, 4], [0, 4], [0, 0], [2, 0]] + assert not polygon_is_simple(reused) + # A non-adjacent edge endpoint touching another edge, without any + # repeated vertex: the (6,4)->(3,0) edge lands on the bottom edge. + t_touch = [[0, 0], [6, 0], [6, 4], [3, 0], [0, 4]] + assert not polygon_is_simple(t_touch) + # Concave but genuinely simple rings still pass. + concave = [[0, 0], [4, 0], [2, 2], [4, 4], [0, 4]] + assert polygon_is_simple(concave) + + +def test_distinct_outlines_get_distinct_fallback_identities(): + import pi_steel.parsing as parsing + + mirrored = [[0, 0], [8, 0], [8, 6], [5, 6], [5, 3], [0, 3]] + legacy = { + "material": "carbon_steel", + "grade": "A36", + "unit_system": "imperial", + "settings": {"thickness_in": 0.5}, + "stock": [{"width": 20, "height": 10, "qty": 2}], + "parts": [ + { + "name": "GUSSET", + "width": 8, + "height": 6, + "qty": 1, + "shape": "irregular", + "outline": deepcopy(L_SHAPE), + }, + { + "name": "GUSSET", + "width": 8, + "height": 6, + "qty": 1, + "shape": "irregular", + "outline": mirrored, + }, + ], + } + package = parsing.adapt_legacy_nest( + legacy, project_id="SYNTHETIC-PRJ", revision_id="SYNTHETIC-REV" + ) + source_ids = [item["source_id"] for item in package["items"]] + assert len(set(source_ids)) == 2 + + direct = nest.run_job( + { + "job_name": "SYNTHETIC-DISTINCT", + "material": "carbon_steel", + "grade": "A36", + "unit_system": "imperial", + "settings": {"thickness_in": 0.5}, + "stock": [ + { + "stock_id": "SYNTHETIC-STOCK-D", + "width": 20, + "height": 10, + "thickness": 0.5, + "qty": 2, + } + ], + "parts": deepcopy(legacy["parts"]), + } + ) + assert not any( + finding["code"].startswith("duplicate_") + for finding in direct["validation_findings"] + ) + + +def test_placement_outline_schema_rejects_degenerate_vertex_lists(): + import jsonschema + + schema = json.loads( + (SHARED / "schemas" / "nest-result.schema.json").read_text() + ) + validator = jsonschema.Draft202012Validator( + { + "$schema": schema["$schema"], + "$ref": "#/$defs/outlineOnly", + "$defs": { + "outlineOnly": { + "type": "object", + "properties": { + "outline": schema["$defs"]["placement"]["properties"][ + "outline" + ] + }, + } + }, + } + ) + assert validator.is_valid({"outline": []}) + 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]]})