@structupath/pi-steel 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DATA_PROVENANCE.json +23 -0
- package/DATA_PROVENANCE.md +35 -0
- package/PUBLIC_DATA_POLICY.md +52 -0
- package/README.md +114 -33
- package/docs/assets/pi-steel-demo.gif +0 -0
- package/docs/assets/pi-steel-gallery.webp +0 -0
- package/package.json +35 -4
- package/pyproject.toml +28 -0
- package/requirements-dev.txt +5 -0
- package/requirements-render.txt +1 -0
- package/requirements-tested.txt +9 -0
- package/requirements.txt +7 -0
- package/scripts/check-data-provenance.py +140 -0
- package/scripts/check-public-data.py +401 -0
- package/scripts/doctor.py +127 -0
- package/skills/_shared/bootstrap.py +32 -0
- package/skills/_shared/pi_steel/__init__.py +47 -0
- package/skills/_shared/pi_steel/cli.py +167 -0
- package/skills/_shared/pi_steel/contracts.py +87 -0
- package/skills/_shared/pi_steel/geometry_verify.py +243 -0
- package/skills/_shared/pi_steel/parsing.py +205 -0
- package/skills/_shared/pi_steel/run_manifest.py +317 -0
- package/skills/_shared/pi_steel/validation.py +597 -0
- package/skills/_shared/schemas/estimate-package.schema.json +424 -0
- package/skills/_shared/schemas/nest-result.schema.json +443 -0
- package/skills/_shared/schemas/run-manifest.schema.json +145 -0
- package/skills/steel-estimate/SKILL.md +119 -0
- package/skills/steel-estimate/references/estimate-package-example.json +91 -0
- package/skills/steel-estimate/references/output-contract.md +47 -0
- package/skills/steel-estimate/scripts/acknowledge-finding.py +193 -0
- package/skills/steel-estimate/scripts/build-estimate-package.py +836 -0
- package/skills/steel-nest/SKILL.md +50 -23
- package/skills/steel-nest/references/FIXTURE_PROVENANCE.md +12 -0
- package/skills/steel-nest/references/example_job.json +21 -25
- package/skills/steel-nest/references/job_template.json +11 -9
- package/skills/steel-nest/scripts/nest.py +1276 -250
- package/skills/steel-rfq/SKILL.md +93 -175
- package/skills/steel-rfq/assets/company-profile.example.json +11 -5
- package/skills/steel-rfq/references/rfq-input.md +58 -0
- package/skills/steel-rfq/scripts/generate-rfq.py +1221 -0
- package/skills/steel-rfq/scripts/recalc.py +33 -10
- package/skills/steel-takeoff/SKILL.md +12 -8
- package/skills/steel-takeoff/assets/bom-template.csv +1 -1
- package/skills/steel-takeoff/references/takeoff-procedures.md +3 -1
- package/skills/steel-takeoff/scripts/calculate-weight.sh +5 -10
- package/skills/steel-takeoff/scripts/lookup-member.sh +2 -2
- package/skills/steel-takeoff/scripts/validate-bom.py +151 -196
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Shared command-line behavior for shipped pi-steel stages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .run_manifest import (
|
|
12
|
+
ManifestError,
|
|
13
|
+
RunPublisher,
|
|
14
|
+
canonical_json_bytes,
|
|
15
|
+
sha256_bytes,
|
|
16
|
+
sha256_file,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StageArgumentParser(argparse.ArgumentParser):
|
|
21
|
+
"""Map command usage errors to the shared stage-contract exit code."""
|
|
22
|
+
|
|
23
|
+
def configure_failure_diagnostics(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
stage: str,
|
|
27
|
+
entry_file: str,
|
|
28
|
+
input_option: str,
|
|
29
|
+
date_options: dict[str, str] | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._failure_context = {
|
|
32
|
+
"stage": stage,
|
|
33
|
+
"entry_file": entry_file,
|
|
34
|
+
"input_option": input_option,
|
|
35
|
+
"date_options": date_options or {},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def parse_args(self, args=None, namespace=None):
|
|
39
|
+
self._active_argv = list(sys.argv[1:] if args is None else args)
|
|
40
|
+
return super().parse_args(args, namespace)
|
|
41
|
+
|
|
42
|
+
def _option_value(self, option: str) -> str | None:
|
|
43
|
+
argv = getattr(self, "_active_argv", [])
|
|
44
|
+
for index, argument in enumerate(argv):
|
|
45
|
+
if argument == option and index + 1 < len(argv):
|
|
46
|
+
return str(argv[index + 1])
|
|
47
|
+
if str(argument).startswith(f"{option}="):
|
|
48
|
+
return str(argument).split("=", 1)[1]
|
|
49
|
+
action = next(
|
|
50
|
+
(item for item in self._actions if option in item.option_strings),
|
|
51
|
+
None,
|
|
52
|
+
)
|
|
53
|
+
if action is not None and action.default not in (None, argparse.SUPPRESS):
|
|
54
|
+
return str(action.default)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def error(self, message):
|
|
58
|
+
diagnostic_path = None
|
|
59
|
+
context = getattr(self, "_failure_context", None)
|
|
60
|
+
if context is not None:
|
|
61
|
+
destination = self._option_value("--out")
|
|
62
|
+
if destination is not None:
|
|
63
|
+
explicit_dates = {
|
|
64
|
+
field: value
|
|
65
|
+
for option, field in context["date_options"].items()
|
|
66
|
+
if (value := self._option_value(option)) is not None
|
|
67
|
+
}
|
|
68
|
+
diagnostic_path = publish_failure_diagnostic(
|
|
69
|
+
destination,
|
|
70
|
+
stage=context["stage"],
|
|
71
|
+
input_path=self._option_value(context["input_option"]),
|
|
72
|
+
error=ValueError(message),
|
|
73
|
+
tool_version=package_version(context["entry_file"]),
|
|
74
|
+
run_id=self._option_value("--run-id"),
|
|
75
|
+
explicit_dates=explicit_dates,
|
|
76
|
+
finding_code="cli_usage_error",
|
|
77
|
+
)
|
|
78
|
+
self.print_usage(sys.stderr)
|
|
79
|
+
suffix = (
|
|
80
|
+
f"; diagnostic published: {diagnostic_path}"
|
|
81
|
+
if diagnostic_path is not None
|
|
82
|
+
else ""
|
|
83
|
+
)
|
|
84
|
+
self.exit(1, f"{self.prog}: error: {message}{suffix}\n")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def package_version(entry_file: str) -> str:
|
|
88
|
+
package_path = Path(entry_file).resolve().parents[3] / "package.json"
|
|
89
|
+
try:
|
|
90
|
+
return json.loads(package_path.read_text(encoding="utf-8"))["version"]
|
|
91
|
+
except (OSError, KeyError, json.JSONDecodeError):
|
|
92
|
+
return "unknown"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _diagnostic_message(error: Exception) -> str:
|
|
96
|
+
"""Describe a failure without persisting input paths in published artifacts."""
|
|
97
|
+
if isinstance(error, OSError):
|
|
98
|
+
detail = error.strerror or "input/output error"
|
|
99
|
+
return f"{error.__class__.__name__}: {detail}"
|
|
100
|
+
return str(error) or error.__class__.__name__
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def publish_failure_diagnostic(
|
|
104
|
+
destination: str | Path,
|
|
105
|
+
*,
|
|
106
|
+
stage: str,
|
|
107
|
+
input_path: str | Path | None,
|
|
108
|
+
error: Exception,
|
|
109
|
+
tool_version: str,
|
|
110
|
+
run_id: str | None = None,
|
|
111
|
+
explicit_dates: dict[str, str] | None = None,
|
|
112
|
+
finding_code: str = "input_unreadable_or_invalid",
|
|
113
|
+
) -> Path | None:
|
|
114
|
+
"""Best-effort publication for failures that occur after CLI parsing.
|
|
115
|
+
|
|
116
|
+
The original failure remains authoritative: publication errors are contained so
|
|
117
|
+
callers can preserve the shared exit-code contract and report the first error.
|
|
118
|
+
"""
|
|
119
|
+
path = Path(input_path) if input_path is not None else None
|
|
120
|
+
try:
|
|
121
|
+
input_hash = (
|
|
122
|
+
sha256_file(path)
|
|
123
|
+
if path is not None and path.is_file()
|
|
124
|
+
else sha256_bytes(canonical_json_bytes({"input_state": "unavailable"}))
|
|
125
|
+
)
|
|
126
|
+
message = _diagnostic_message(error)
|
|
127
|
+
finding: dict[str, Any] = {
|
|
128
|
+
"code": finding_code,
|
|
129
|
+
"severity": "error",
|
|
130
|
+
"message": message,
|
|
131
|
+
"exception_type": error.__class__.__name__,
|
|
132
|
+
}
|
|
133
|
+
qa_report = {
|
|
134
|
+
"schema_version": "1.0.0",
|
|
135
|
+
"stage": stage,
|
|
136
|
+
"run_outcome": "usage_or_internal_error",
|
|
137
|
+
"package_status": "draft",
|
|
138
|
+
"findings": [finding],
|
|
139
|
+
"warnings": [],
|
|
140
|
+
}
|
|
141
|
+
configuration_hash = sha256_bytes(
|
|
142
|
+
canonical_json_bytes(
|
|
143
|
+
{
|
|
144
|
+
"failure_contract": "1.0.0",
|
|
145
|
+
"stage": stage,
|
|
146
|
+
"explicit_dates": explicit_dates or {},
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
with RunPublisher(
|
|
151
|
+
destination,
|
|
152
|
+
stage=stage,
|
|
153
|
+
run_outcome="usage_or_internal_error",
|
|
154
|
+
package_status="draft",
|
|
155
|
+
input_hash=input_hash,
|
|
156
|
+
configuration_hash=configuration_hash,
|
|
157
|
+
schema_versions={"run_manifest": "1.0.0"},
|
|
158
|
+
tool_versions={"pi_steel": tool_version},
|
|
159
|
+
explicit_dates=explicit_dates or {},
|
|
160
|
+
warnings=[message],
|
|
161
|
+
approximations=[],
|
|
162
|
+
run_id=run_id,
|
|
163
|
+
) as publisher:
|
|
164
|
+
publisher.write_qa_report(qa_report)
|
|
165
|
+
return publisher.publish()
|
|
166
|
+
except (ManifestError, OSError, TypeError, ValueError):
|
|
167
|
+
return None
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Canonical contract constants and deterministic identity helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
ESTIMATE_PACKAGE_VERSION = "1.0.0"
|
|
11
|
+
NEST_RESULT_VERSION = "1.0.0"
|
|
12
|
+
ITEM_INTENTS = frozenset(
|
|
13
|
+
{
|
|
14
|
+
"fabricated_part",
|
|
15
|
+
"purchased_stock",
|
|
16
|
+
"hardware",
|
|
17
|
+
"allowance",
|
|
18
|
+
"exclusion",
|
|
19
|
+
"by_others",
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def canonical_json_bytes(value: Any) -> bytes:
|
|
25
|
+
return json.dumps(
|
|
26
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
|
27
|
+
).encode("utf-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def content_hash(value: Any) -> str:
|
|
31
|
+
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def item_id_for(project_id: str, revision_id: str, source_id: str) -> str:
|
|
35
|
+
"""Derive normalized identity from project and stable source identity.
|
|
36
|
+
|
|
37
|
+
``revision_id`` remains in the signature for adapter compatibility. Legacy
|
|
38
|
+
source IDs already embed their revision when no stable source key exists;
|
|
39
|
+
explicit source IDs therefore remain stable across revisions.
|
|
40
|
+
"""
|
|
41
|
+
digest = content_hash(
|
|
42
|
+
{
|
|
43
|
+
"project_id": project_id,
|
|
44
|
+
"source_id": source_id,
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
return f"item:{digest[:24]}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def instance_ids(item_id: str, quantity: int) -> list[str]:
|
|
51
|
+
"""Expand quantity predictably; increasing quantity never renames old instances."""
|
|
52
|
+
if quantity < 0:
|
|
53
|
+
raise ValueError("quantity must be non-negative")
|
|
54
|
+
return [f"{item_id}:instance:{index:04d}" for index in range(1, quantity + 1)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def placement_ids(item_id: str, quantity: int) -> list[str]:
|
|
58
|
+
"""Derive stable per-instance placement identities from canonical item order."""
|
|
59
|
+
if quantity < 0:
|
|
60
|
+
raise ValueError("quantity must be non-negative")
|
|
61
|
+
return [f"{item_id}:placement:{index:04d}" for index in range(1, quantity + 1)]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def fallback_source_id(revision_id: str, identity: Any) -> str:
|
|
65
|
+
"""Create a revision-scoped legacy identity from stable row content."""
|
|
66
|
+
return f"legacy:{revision_id}:{content_hash(identity)[:20]}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def estimate_input_projection(package: dict[str, Any]) -> dict[str, Any]:
|
|
70
|
+
"""Return source/config semantics, excluding review and confirmation bookkeeping."""
|
|
71
|
+
projection = {
|
|
72
|
+
key: value
|
|
73
|
+
for key, value in package.items()
|
|
74
|
+
if key not in {"review"}
|
|
75
|
+
}
|
|
76
|
+
projection = json.loads(json.dumps(projection))
|
|
77
|
+
for stock in projection.get("stock", []):
|
|
78
|
+
stock.pop("reviewer_confirmation", None)
|
|
79
|
+
return projection
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def estimate_input_hash(package: dict[str, Any]) -> str:
|
|
83
|
+
return content_hash(estimate_input_projection(package))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def finding_id_for(code: str, path: str) -> str:
|
|
87
|
+
return f"finding:{content_hash({'code': code, 'path': path})[:24]}"
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Geometry checks shared by contract validation and nesting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SUPPORTED_SHAPES = frozenset({"rect", "irregular"})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def finite_positive(value: Any) -> bool:
|
|
14
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) and value > 0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def hole_area(hole: dict[str, Any]) -> float:
|
|
18
|
+
if hole.get("kind") == "round":
|
|
19
|
+
diameter = hole.get("diameter", 0)
|
|
20
|
+
return math.pi * (diameter / 2) ** 2
|
|
21
|
+
if hole.get("kind") == "rect":
|
|
22
|
+
return hole.get("width", 0) * hole.get("height", 0)
|
|
23
|
+
return 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def hole_within_bounds(hole: dict[str, Any], width: float, height: float) -> bool:
|
|
27
|
+
x, y = hole.get("x"), hole.get("y")
|
|
28
|
+
if not all(isinstance(value, (int, float)) and math.isfinite(value) for value in (x, y)):
|
|
29
|
+
return False
|
|
30
|
+
if hole.get("kind") == "round":
|
|
31
|
+
diameter = hole.get("diameter")
|
|
32
|
+
if not finite_positive(diameter):
|
|
33
|
+
return False
|
|
34
|
+
radius = diameter / 2
|
|
35
|
+
return radius <= x <= width - radius and radius <= y <= height - radius
|
|
36
|
+
if hole.get("kind") == "rect":
|
|
37
|
+
hole_width, hole_height = hole.get("width"), hole.get("height")
|
|
38
|
+
if not finite_positive(hole_width) or not finite_positive(hole_height):
|
|
39
|
+
return False
|
|
40
|
+
return (
|
|
41
|
+
hole_width / 2 <= x <= width - hole_width / 2
|
|
42
|
+
and hole_height / 2 <= y <= height - hole_height / 2
|
|
43
|
+
)
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def gross_area(geometry: dict[str, Any]) -> float:
|
|
48
|
+
if geometry.get("shape") == "irregular" and geometry.get("area") is not None:
|
|
49
|
+
return geometry["area"]
|
|
50
|
+
return geometry.get("width", 0) * geometry.get("height", 0)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def net_area(geometry: dict[str, Any]) -> float:
|
|
54
|
+
return gross_area(geometry) - sum(hole_area(hole) for hole in geometry.get("holes", []))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def plate_group_key(item: dict[str, Any]) -> tuple[Any, Any, Any]:
|
|
58
|
+
geometry = item.get("geometry", {})
|
|
59
|
+
return item.get("material"), item.get("grade"), geometry.get("thickness")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def group_plate_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
63
|
+
"""Partition parts strictly by material, grade, and thickness."""
|
|
64
|
+
groups: dict[tuple[Any, Any, Any], list[dict[str, Any]]] = defaultdict(list)
|
|
65
|
+
for item in items:
|
|
66
|
+
groups[plate_group_key(item)].append(item)
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
"material": key[0],
|
|
70
|
+
"grade": key[1],
|
|
71
|
+
"thickness": key[2],
|
|
72
|
+
"items": grouped,
|
|
73
|
+
}
|
|
74
|
+
for key, grouped in sorted(groups.items(), key=lambda pair: repr(pair[0]))
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _finite_placement_values(placement: dict[str, Any]) -> bool:
|
|
79
|
+
return all(
|
|
80
|
+
isinstance(placement.get(field), (int, float))
|
|
81
|
+
and math.isfinite(placement[field])
|
|
82
|
+
for field in ("x", "y", "w", "h")
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _placements_separated(
|
|
87
|
+
first: dict[str, Any],
|
|
88
|
+
second: dict[str, Any],
|
|
89
|
+
clearance: float,
|
|
90
|
+
epsilon: float,
|
|
91
|
+
) -> bool:
|
|
92
|
+
return (
|
|
93
|
+
first["x"] + first["w"] + clearance <= second["x"] + epsilon
|
|
94
|
+
or second["x"] + second["w"] + clearance <= first["x"] + epsilon
|
|
95
|
+
or first["y"] + first["h"] + clearance <= second["y"] + epsilon
|
|
96
|
+
or second["y"] + second["h"] + clearance <= first["y"] + epsilon
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _overlap_candidate_pairs(
|
|
101
|
+
placements: list[dict[str, Any]],
|
|
102
|
+
clearance: float,
|
|
103
|
+
epsilon: float,
|
|
104
|
+
) -> list[tuple[int, int]]:
|
|
105
|
+
"""Return deterministic overlap pairs without scanning every valid pair."""
|
|
106
|
+
numeric = {
|
|
107
|
+
index for index, placement in enumerate(placements)
|
|
108
|
+
if _finite_placement_values(placement)
|
|
109
|
+
}
|
|
110
|
+
sweepable = {
|
|
111
|
+
index for index in numeric
|
|
112
|
+
if placements[index]["w"] > 0 and placements[index]["h"] > 0
|
|
113
|
+
}
|
|
114
|
+
candidates: set[tuple[int, int]] = set()
|
|
115
|
+
active: list[int] = []
|
|
116
|
+
for current_index in sorted(
|
|
117
|
+
sweepable, key=lambda index: (placements[index]["x"], index)
|
|
118
|
+
):
|
|
119
|
+
current = placements[current_index]
|
|
120
|
+
active = [
|
|
121
|
+
index
|
|
122
|
+
for index in active
|
|
123
|
+
if (
|
|
124
|
+
placements[index]["x"]
|
|
125
|
+
+ placements[index]["w"]
|
|
126
|
+
+ clearance
|
|
127
|
+
> current["x"] + epsilon
|
|
128
|
+
)
|
|
129
|
+
]
|
|
130
|
+
for other_index in active:
|
|
131
|
+
other = placements[other_index]
|
|
132
|
+
if not (
|
|
133
|
+
other["y"] + other["h"] + clearance <= current["y"] + epsilon
|
|
134
|
+
or current["y"] + current["h"] + clearance
|
|
135
|
+
<= other["y"] + epsilon
|
|
136
|
+
):
|
|
137
|
+
candidates.add(
|
|
138
|
+
(min(other_index, current_index), max(other_index, current_index))
|
|
139
|
+
)
|
|
140
|
+
active.append(current_index)
|
|
141
|
+
|
|
142
|
+
nonsweepable = numeric - sweepable
|
|
143
|
+
for first_index in sorted(nonsweepable):
|
|
144
|
+
for second_index in sorted(numeric):
|
|
145
|
+
if first_index < second_index:
|
|
146
|
+
candidates.add((first_index, second_index))
|
|
147
|
+
elif second_index < first_index and second_index in sweepable:
|
|
148
|
+
candidates.add((second_index, first_index))
|
|
149
|
+
return sorted(candidates)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def verify_nest_placements(
|
|
153
|
+
plate_reports: list[dict[str, Any]],
|
|
154
|
+
*,
|
|
155
|
+
edge_margin: float,
|
|
156
|
+
inter_part_clearance: float,
|
|
157
|
+
) -> list[dict[str, Any]]:
|
|
158
|
+
"""Verify normalized placements without relying on a packing algorithm's state."""
|
|
159
|
+
findings: list[dict[str, Any]] = []
|
|
160
|
+
epsilon = 1e-9
|
|
161
|
+
for plate_index, plate in enumerate(plate_reports):
|
|
162
|
+
usable_width = plate.get("W", 0) - 2 * edge_margin
|
|
163
|
+
usable_height = plate.get("H", 0) - 2 * edge_margin
|
|
164
|
+
placements = plate.get("placements", [])
|
|
165
|
+
plate_basis = (
|
|
166
|
+
plate.get("material"),
|
|
167
|
+
plate.get("grade"),
|
|
168
|
+
plate.get("thickness"),
|
|
169
|
+
)
|
|
170
|
+
for placement_index, placement in enumerate(placements):
|
|
171
|
+
path = f"$.plate_reports[{plate_index}].placements[{placement_index}]"
|
|
172
|
+
values = (
|
|
173
|
+
placement.get("x"),
|
|
174
|
+
placement.get("y"),
|
|
175
|
+
placement.get("w"),
|
|
176
|
+
placement.get("h"),
|
|
177
|
+
)
|
|
178
|
+
if not all(
|
|
179
|
+
isinstance(value, (int, float))
|
|
180
|
+
and not isinstance(value, bool)
|
|
181
|
+
and math.isfinite(value)
|
|
182
|
+
for value in values
|
|
183
|
+
):
|
|
184
|
+
findings.append(
|
|
185
|
+
{
|
|
186
|
+
"code": "nonfinite_placement",
|
|
187
|
+
"path": path,
|
|
188
|
+
"message": "Placement coordinates and dimensions must be finite.",
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
continue
|
|
192
|
+
x, y, width, height = values
|
|
193
|
+
if (
|
|
194
|
+
width <= 0
|
|
195
|
+
or height <= 0
|
|
196
|
+
or x < -epsilon
|
|
197
|
+
or y < -epsilon
|
|
198
|
+
or x + width > usable_width + epsilon
|
|
199
|
+
or y + height > usable_height + epsilon
|
|
200
|
+
):
|
|
201
|
+
findings.append(
|
|
202
|
+
{
|
|
203
|
+
"code": "placement_out_of_bounds",
|
|
204
|
+
"path": path,
|
|
205
|
+
"message": "Placement must remain inside the edge-margin boundary.",
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
placement_basis = (
|
|
209
|
+
placement.get("material"),
|
|
210
|
+
placement.get("grade"),
|
|
211
|
+
placement.get("thickness"),
|
|
212
|
+
)
|
|
213
|
+
if placement_basis != plate_basis:
|
|
214
|
+
findings.append(
|
|
215
|
+
{
|
|
216
|
+
"code": "material_mismatch",
|
|
217
|
+
"path": path,
|
|
218
|
+
"message": "Placement material, grade, and thickness must match its stock.",
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
for first_index, second_index in _overlap_candidate_pairs(
|
|
223
|
+
placements, inter_part_clearance, epsilon
|
|
224
|
+
):
|
|
225
|
+
first = placements[first_index]
|
|
226
|
+
second = placements[second_index]
|
|
227
|
+
if not _placements_separated(
|
|
228
|
+
first, second, inter_part_clearance, epsilon
|
|
229
|
+
):
|
|
230
|
+
findings.append(
|
|
231
|
+
{
|
|
232
|
+
"code": "placement_overlap",
|
|
233
|
+
"path": (
|
|
234
|
+
f"$.plate_reports[{plate_index}].placements"
|
|
235
|
+
f"[{first_index},{second_index}]"
|
|
236
|
+
),
|
|
237
|
+
"message": (
|
|
238
|
+
"Placements overlap or violate the required "
|
|
239
|
+
"kerf-plus-gap clearance."
|
|
240
|
+
),
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
return findings
|