Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc4837815c |
@@ -45,6 +45,14 @@ cmakefiles(
|
||||
asciiquack/vendor/cxxopts.hpp
|
||||
db2adoc/db2adoc.py
|
||||
db2adoc/db2adoc.xsl
|
||||
brep_corpus_audit.py
|
||||
step_corpus_capture.py
|
||||
step_corpus_run.py
|
||||
step_corpus_summary.py
|
||||
step_corpus_verify.py
|
||||
step_external_corpus_expectations.json
|
||||
step_local_external_corpus_expectations.json
|
||||
step_dependency_subset.py
|
||||
inactvhdrs/CMakeLists.txt
|
||||
inactvhdrs/inactvhdrs.cpp
|
||||
)
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Audit BRep wireframe and fast shaded realizations in a directory of .g files.
|
||||
|
||||
The companion ``brep-audit`` executable does one BRep per process. This
|
||||
driver inventories each database, runs those isolated checks, checkpoints
|
||||
every result to JSON Lines, and writes JSON and Markdown summaries. Geometry
|
||||
findings do not make the driver fail: they are the intended work list.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FORMAT = "brlcad-brep-corpus-audit-v1"
|
||||
HELPER_FORMAT = "brlcad-brep-realization-audit-v1"
|
||||
|
||||
|
||||
def file_identity(path):
|
||||
try:
|
||||
info = path.stat()
|
||||
return {"size": info.st_size, "mtime_ns": info.st_mtime_ns}
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def text_tail(value, limit=8000):
|
||||
return (value or "")[-limit:]
|
||||
|
||||
|
||||
def last_phase(stderr):
|
||||
phases = re.findall(r"^brep-audit: phase=([a-z]+)$", stderr or "", re.MULTILINE)
|
||||
return phases[-1] if phases else None
|
||||
|
||||
|
||||
def process_rss_bytes(pid):
|
||||
"""Read current Linux RSS, returning zero when it is unavailable."""
|
||||
try:
|
||||
with open("/proc/{}/status".format(pid), "r", encoding="ascii") as stream:
|
||||
for line in stream:
|
||||
if line.startswith("VmRSS:"):
|
||||
return int(line.split()[1]) * 1024
|
||||
except (OSError, ValueError, IndexError):
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def kill_process(process):
|
||||
try:
|
||||
if os.name == "posix":
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
else:
|
||||
process.kill()
|
||||
except (OSError, ProcessLookupError):
|
||||
pass
|
||||
|
||||
|
||||
def file_tail(stream, limit):
|
||||
stream.flush()
|
||||
size = os.fstat(stream.fileno()).st_size
|
||||
stream.seek(max(0, size - limit))
|
||||
return stream.read().decode("utf-8", errors="replace"), size > limit
|
||||
|
||||
|
||||
def run_process(command, timeout, max_rss_mib=0):
|
||||
"""Run and monitor one helper, retaining enough detail for diagnostics."""
|
||||
started = time.monotonic()
|
||||
with tempfile.TemporaryFile() as stdout_file, \
|
||||
tempfile.TemporaryFile() as stderr_file:
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=stdout_file,
|
||||
stderr=stderr_file,
|
||||
start_new_session=(os.name == "posix"),
|
||||
)
|
||||
except OSError as error:
|
||||
return {
|
||||
"returncode": 127,
|
||||
"stdout": "",
|
||||
"stdout_truncated": False,
|
||||
"stderr": str(error),
|
||||
"elapsed_seconds": time.monotonic() - started,
|
||||
"observed_peak_rss_bytes": 0,
|
||||
"termination_reason": "launch_error",
|
||||
}
|
||||
|
||||
peak_rss = 0
|
||||
termination_reason = None
|
||||
while True:
|
||||
elapsed = time.monotonic() - started
|
||||
remaining = timeout - elapsed
|
||||
if remaining <= 0:
|
||||
termination_reason = "timeout"
|
||||
kill_process(process)
|
||||
process.wait()
|
||||
break
|
||||
try:
|
||||
process.wait(timeout=min(0.1, remaining))
|
||||
peak_rss = max(peak_rss, process_rss_bytes(process.pid))
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
rss = process_rss_bytes(process.pid)
|
||||
peak_rss = max(peak_rss, rss)
|
||||
if max_rss_mib and rss > max_rss_mib * 1024 * 1024:
|
||||
termination_reason = "rss_limit"
|
||||
kill_process(process)
|
||||
process.wait()
|
||||
break
|
||||
|
||||
stdout, stdout_truncated = file_tail(
|
||||
stdout_file, 16 * 1024 * 1024
|
||||
)
|
||||
stderr, _ = file_tail(stderr_file, 64 * 1024)
|
||||
|
||||
return {
|
||||
"returncode": process.returncode,
|
||||
"stdout": stdout,
|
||||
"stdout_truncated": stdout_truncated,
|
||||
"stderr": stderr,
|
||||
"elapsed_seconds": time.monotonic() - started,
|
||||
"observed_peak_rss_bytes": peak_rss,
|
||||
"termination_reason": termination_reason,
|
||||
}
|
||||
|
||||
|
||||
def synthetic_record(database, object_name, status, issue, run, signature):
|
||||
return {
|
||||
"format": HELPER_FORMAT,
|
||||
"scope": "brep" if object_name else "database",
|
||||
"database": str(database),
|
||||
"object": object_name,
|
||||
"status": status,
|
||||
"reference": None,
|
||||
"wireframe": None,
|
||||
"shaded": None,
|
||||
"issues": [issue],
|
||||
"audit_signature": signature,
|
||||
"database_identity": file_identity(database),
|
||||
"process": {
|
||||
"return_code": run["returncode"],
|
||||
"elapsed_seconds": run["elapsed_seconds"],
|
||||
"observed_peak_rss_bytes": run["observed_peak_rss_bytes"],
|
||||
"termination_reason": run["termination_reason"],
|
||||
"last_phase": last_phase(run["stderr"]),
|
||||
"stdout_tail": text_tail(run["stdout"]),
|
||||
"stderr_tail": text_tail(run["stderr"]),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def parse_helper_json(output):
|
||||
for line in reversed(output.splitlines()):
|
||||
if not line.lstrip().startswith("{"):
|
||||
continue
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def audit_one(helper, database, object_name, args, signature):
|
||||
command = [
|
||||
str(helper),
|
||||
"--ratio-min", str(args.ratio_min),
|
||||
"--ratio-max", str(args.ratio_max),
|
||||
"--tess-abs", str(args.tess_abs),
|
||||
"--tess-rel", str(args.tess_rel),
|
||||
"--tess-norm", str(args.tess_norm),
|
||||
]
|
||||
if args.address_space_limit_mib:
|
||||
command.extend([
|
||||
"--memory-limit-mib", str(args.address_space_limit_mib)
|
||||
])
|
||||
command.extend([str(database), object_name])
|
||||
run = run_process(command, args.timeout, args.max_rss_mib)
|
||||
|
||||
if run["termination_reason"] == "timeout":
|
||||
phase = last_phase(run["stderr"])
|
||||
return synthetic_record(
|
||||
database, object_name, "timeout",
|
||||
"{}_timeout".format(phase) if phase else "process_timeout",
|
||||
run, signature
|
||||
)
|
||||
if run["termination_reason"] == "rss_limit":
|
||||
phase = last_phase(run["stderr"])
|
||||
return synthetic_record(
|
||||
database, object_name, "memory_limit",
|
||||
"{}_rss_limit_exceeded".format(phase)
|
||||
if phase else "process_rss_limit_exceeded",
|
||||
run, signature
|
||||
)
|
||||
if run["termination_reason"] == "launch_error":
|
||||
return synthetic_record(
|
||||
database, object_name, "error", "helper_launch_failed", run, signature
|
||||
)
|
||||
|
||||
record = parse_helper_json(run["stdout"])
|
||||
if record is None:
|
||||
issue = (
|
||||
"process_signal_{}".format(-run["returncode"])
|
||||
if run["returncode"] is not None and run["returncode"] < 0
|
||||
else "helper_output_not_json"
|
||||
)
|
||||
return synthetic_record(
|
||||
database, object_name, "error", issue, run, signature
|
||||
)
|
||||
if record.get("format") != HELPER_FORMAT:
|
||||
return synthetic_record(
|
||||
database, object_name, "error", "unexpected_helper_format",
|
||||
run, signature
|
||||
)
|
||||
|
||||
record["scope"] = "brep"
|
||||
record["audit_signature"] = signature
|
||||
record["database_identity"] = file_identity(database)
|
||||
record["process"] = {
|
||||
"return_code": run["returncode"],
|
||||
"elapsed_seconds": run["elapsed_seconds"],
|
||||
"observed_peak_rss_bytes": run["observed_peak_rss_bytes"],
|
||||
"termination_reason": None,
|
||||
"last_phase": last_phase(run["stderr"]),
|
||||
"stdout_tail": "",
|
||||
"stderr_tail": text_tail(run["stderr"]),
|
||||
}
|
||||
expected_return = 0 if record.get("status") == "ok" else 1
|
||||
if run["returncode"] != expected_return:
|
||||
record["status"] = "error"
|
||||
record.setdefault("issues", []).append("helper_exit_status_mismatch")
|
||||
return record
|
||||
|
||||
|
||||
def list_breps(helper, database, timeout, signature):
|
||||
run = run_process([str(helper), "--list", str(database)], timeout)
|
||||
if run["termination_reason"] == "timeout":
|
||||
return [], synthetic_record(
|
||||
database, "", "timeout", "brep_inventory_timeout", run, signature
|
||||
)
|
||||
if run["returncode"] != 0:
|
||||
return [], synthetic_record(
|
||||
database, "", "error", "brep_inventory_failed", run, signature
|
||||
)
|
||||
if run["stdout_truncated"]:
|
||||
return [], synthetic_record(
|
||||
database, "", "error", "brep_inventory_output_too_large",
|
||||
run, signature
|
||||
)
|
||||
names = sorted(set(line.strip() for line in run["stdout"].splitlines()
|
||||
if line.strip()))
|
||||
return names, None
|
||||
|
||||
|
||||
def git_state(start):
|
||||
try:
|
||||
revision = subprocess.check_output(
|
||||
["git", "-C", str(start), "rev-parse", "HEAD"],
|
||||
stderr=subprocess.DEVNULL, text=True,
|
||||
).strip()
|
||||
dirty = bool(subprocess.check_output(
|
||||
["git", "-C", str(start), "status", "--porcelain"],
|
||||
stderr=subprocess.DEVNULL, text=True,
|
||||
).strip())
|
||||
return revision, dirty
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return "unknown", None
|
||||
|
||||
|
||||
def read_checkpoint(path, signature):
|
||||
records = {}
|
||||
if not path.is_file():
|
||||
return records
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for number, line in enumerate(stream, 1):
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
print("ignoring malformed checkpoint line {} in {}".format(
|
||||
number, path), file=sys.stderr)
|
||||
continue
|
||||
if record.get("audit_signature") != signature:
|
||||
continue
|
||||
records[(record.get("database", ""), record.get("object", ""))] = record
|
||||
return records
|
||||
|
||||
|
||||
def all_issues(record):
|
||||
issues = list(record.get("issues") or [])
|
||||
for mode in ("wireframe", "shaded"):
|
||||
result = record.get(mode)
|
||||
if result:
|
||||
issues.extend(result.get("issues") or [])
|
||||
return list(dict.fromkeys(str(issue) for issue in issues))
|
||||
|
||||
|
||||
def format_vector(values):
|
||||
if not values:
|
||||
return "-"
|
||||
return " × ".join("{:.5g}".format(value) for value in values)
|
||||
|
||||
|
||||
def format_geometry(result, primitive):
|
||||
if not result:
|
||||
return "-"
|
||||
ratios = result.get("dimension_ratios")
|
||||
ratio_text = ""
|
||||
if ratios:
|
||||
ratio_text = "; ratios " + "/".join(
|
||||
"-" if value is None else "{:.3g}".format(value)
|
||||
for value in ratios
|
||||
)
|
||||
return "{} points, {} {}{}".format(
|
||||
result.get("vertices", 0), result.get("primitives", 0),
|
||||
primitive, ratio_text,
|
||||
)
|
||||
|
||||
|
||||
def markdown_escape(value):
|
||||
return str(value).replace("|", "\\|").replace("\n", "<br>")
|
||||
|
||||
|
||||
def worklist_markdown(summary, input_directory):
|
||||
totals = summary["totals"]
|
||||
lines = [
|
||||
"# BRep realization audit",
|
||||
"",
|
||||
"Revision: `{}`{}".format(
|
||||
summary["revision"], " (dirty)" if summary.get("dirty") else ""
|
||||
),
|
||||
"",
|
||||
(
|
||||
"{} databases, {} BReps: {} okay, {} geometry findings, "
|
||||
"{} timeouts, {} memory-limit stops, and {} process errors."
|
||||
).format(
|
||||
totals["databases"], totals["breps"], totals["ok"], totals["fail"],
|
||||
totals["timeout"], totals["memory_limit"], totals["error"],
|
||||
),
|
||||
"",
|
||||
(
|
||||
"Accepted generated/reference dimension ratios: {}–{}. "
|
||||
"Shaded tessellation tolerance: abs {}, rel {}, norm {}."
|
||||
).format(
|
||||
summary["options"]["ratio_min"], summary["options"]["ratio_max"],
|
||||
summary["options"]["tess_abs"], summary["options"]["tess_rel"],
|
||||
summary["options"]["tess_norm"],
|
||||
),
|
||||
"",
|
||||
"## Work list",
|
||||
"",
|
||||
]
|
||||
findings = [
|
||||
record for record in summary["results"]
|
||||
if record.get("status") != "ok"
|
||||
]
|
||||
if not findings:
|
||||
lines.append("No findings.")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
lines.extend([
|
||||
"| Database | BRep | Status | Reference dimensions | Wireframe | Shaded | Peak RSS MiB | Issues |",
|
||||
"|---|---|---|---|---|---|---:|---|",
|
||||
])
|
||||
for record in findings:
|
||||
database = Path(record.get("database", ""))
|
||||
try:
|
||||
database_name = str(database.relative_to(input_directory))
|
||||
except ValueError:
|
||||
database_name = str(database)
|
||||
wire = record.get("wireframe")
|
||||
shaded = record.get("shaded")
|
||||
peaks = [
|
||||
int(result.get("peak_rss_bytes") or 0)
|
||||
for result in (wire, shaded) if result
|
||||
]
|
||||
process = record.get("process") or {}
|
||||
peaks.append(int(process.get("observed_peak_rss_bytes") or 0))
|
||||
reference = record.get("reference") or {}
|
||||
lines.append("| {} | {} | {} | {} | {} | {} | {:.1f} | {} |".format(
|
||||
markdown_escape(database_name),
|
||||
markdown_escape(record.get("object") or "(inventory)"),
|
||||
markdown_escape(record.get("status", "error")),
|
||||
markdown_escape(format_vector(reference.get("dimensions"))),
|
||||
markdown_escape(format_geometry(wire, "segments")),
|
||||
markdown_escape(format_geometry(shaded, "triangles")),
|
||||
max(peaks, default=0) / (1024.0 * 1024.0),
|
||||
markdown_escape(", ".join(all_issues(record)) or "unspecified"),
|
||||
))
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("input_directory", type=Path)
|
||||
parser.add_argument("output_directory", type=Path)
|
||||
parser.add_argument(
|
||||
"--brep-audit", type=Path, default=Path(".build/bin/brep-audit")
|
||||
)
|
||||
parser.add_argument("-j", "--jobs", type=int, default=1,
|
||||
help="BRep processes to run concurrently (default: 1)")
|
||||
parser.add_argument("--timeout", type=float, default=300.0,
|
||||
help="seconds allowed per BRep")
|
||||
parser.add_argument("--list-timeout", type=float, default=120.0,
|
||||
help="seconds allowed to inventory one database")
|
||||
parser.add_argument("--ratio-min", type=float, default=0.5)
|
||||
parser.add_argument("--ratio-max", type=float, default=2.0)
|
||||
parser.add_argument("--tess-abs", type=float, default=0.0)
|
||||
parser.add_argument("--tess-rel", type=float, default=0.01)
|
||||
parser.add_argument("--tess-norm", type=float, default=0.0)
|
||||
parser.add_argument(
|
||||
"--max-rss-mib", type=int, default=0,
|
||||
help="kill a BRep helper above this observed RSS (zero disables)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--address-space-limit-mib", type=int, default=0,
|
||||
help="set the helper RLIMIT_AS in MiB (zero disables)"
|
||||
)
|
||||
parser.add_argument("--only-file",
|
||||
help="regular expression matched against .g filenames")
|
||||
parser.add_argument("--only-object",
|
||||
help="regular expression matched against BRep names")
|
||||
parser.add_argument("--resume", action="store_true",
|
||||
help="retain checkpointed results with identical options")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.jobs < 1:
|
||||
parser.error("--jobs must be positive")
|
||||
if args.timeout <= 0 or args.list_timeout <= 0:
|
||||
parser.error("timeouts must be positive")
|
||||
if args.ratio_min <= 0 or args.ratio_max < args.ratio_min:
|
||||
parser.error("dimension ratio limits are invalid")
|
||||
if min(args.tess_abs, args.tess_rel, args.tess_norm) < 0:
|
||||
parser.error("tessellation tolerances cannot be negative")
|
||||
if args.max_rss_mib < 0 or args.address_space_limit_mib < 0:
|
||||
parser.error("memory limits cannot be negative")
|
||||
|
||||
input_directory = args.input_directory.resolve()
|
||||
output_directory = args.output_directory.resolve()
|
||||
helper = args.brep_audit.resolve()
|
||||
if not helper.is_file():
|
||||
parser.error("brep-audit not found: {}".format(helper))
|
||||
file_matcher = re.compile(args.only_file) if args.only_file else None
|
||||
object_matcher = re.compile(args.only_object) if args.only_object else None
|
||||
databases = sorted(
|
||||
path.resolve() for path in input_directory.iterdir()
|
||||
if path.is_file() and path.suffix.lower() == ".g" and
|
||||
(not file_matcher or file_matcher.search(path.name))
|
||||
)
|
||||
if not databases:
|
||||
parser.error("no matching .g files in {}".format(input_directory))
|
||||
|
||||
options = {
|
||||
"ratio_min": args.ratio_min,
|
||||
"ratio_max": args.ratio_max,
|
||||
"tess_abs": args.tess_abs,
|
||||
"tess_rel": args.tess_rel,
|
||||
"tess_norm": args.tess_norm,
|
||||
"timeout": args.timeout,
|
||||
"list_timeout": args.list_timeout,
|
||||
"max_rss_mib": args.max_rss_mib,
|
||||
"address_space_limit_mib": args.address_space_limit_mib,
|
||||
"only_file": args.only_file,
|
||||
"only_object": args.only_object,
|
||||
"helper": str(helper),
|
||||
"helper_size": helper.stat().st_size,
|
||||
"helper_mtime_ns": helper.stat().st_mtime_ns,
|
||||
}
|
||||
signature = options
|
||||
output_directory.mkdir(parents=True, exist_ok=True)
|
||||
checkpoint_path = output_directory / "brep-audit-results.jsonl"
|
||||
records = (
|
||||
read_checkpoint(checkpoint_path, signature) if args.resume else {}
|
||||
)
|
||||
|
||||
tasks = []
|
||||
for index, database in enumerate(databases, 1):
|
||||
print("[inventory {}/{}] {}".format(
|
||||
index, len(databases), database.name), flush=True)
|
||||
names, error_record = list_breps(
|
||||
helper, database, args.list_timeout, signature
|
||||
)
|
||||
if error_record:
|
||||
for key in list(records):
|
||||
if key[0] == str(database):
|
||||
records.pop(key)
|
||||
records[(str(database), "")] = error_record
|
||||
continue
|
||||
records.pop((str(database), ""), None)
|
||||
if object_matcher:
|
||||
names = [name for name in names if object_matcher.search(name)]
|
||||
current_names = set(names)
|
||||
identity = file_identity(database)
|
||||
for key, record in list(records.items()):
|
||||
if key[0] != str(database):
|
||||
continue
|
||||
if key[1] not in current_names or \
|
||||
record.get("database_identity") != identity:
|
||||
records.pop(key)
|
||||
tasks.extend((database, name) for name in names)
|
||||
|
||||
# Rewrite a resumed checkpoint with only the selected run signature, then
|
||||
# append each new result immediately so an interrupted corpus run is usable.
|
||||
with checkpoint_path.open("w", encoding="utf-8") as checkpoint:
|
||||
for record in sorted(
|
||||
records.values(),
|
||||
key=lambda item: (item.get("database", ""), item.get("object", ""))):
|
||||
checkpoint.write(json.dumps(record, sort_keys=True) + "\n")
|
||||
checkpoint.flush()
|
||||
|
||||
pending = [
|
||||
task for task in tasks if (str(task[0]), task[1]) not in records
|
||||
]
|
||||
resumed = len(tasks) - len(pending)
|
||||
if resumed:
|
||||
print("resuming {} completed BRep records".format(resumed), flush=True)
|
||||
|
||||
completed = 0
|
||||
with checkpoint_path.open("a", encoding="utf-8") as checkpoint:
|
||||
with ThreadPoolExecutor(max_workers=args.jobs) as pool:
|
||||
futures = {
|
||||
pool.submit(
|
||||
audit_one, helper, database, object_name, args, signature
|
||||
): (database, object_name)
|
||||
for database, object_name in pending
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
database, object_name = futures[future]
|
||||
try:
|
||||
record = future.result()
|
||||
except Exception as error: # Preserve the rest of a corpus run.
|
||||
run = {
|
||||
"returncode": 125,
|
||||
"stdout": "",
|
||||
"stderr": repr(error),
|
||||
"elapsed_seconds": 0.0,
|
||||
"observed_peak_rss_bytes": 0,
|
||||
"termination_reason": "driver_error",
|
||||
}
|
||||
record = synthetic_record(
|
||||
database, object_name, "error",
|
||||
"audit_driver_exception", run, signature
|
||||
)
|
||||
records[(str(database), object_name)] = record
|
||||
checkpoint.write(json.dumps(record, sort_keys=True) + "\n")
|
||||
checkpoint.flush()
|
||||
completed += 1
|
||||
print("[audit {}/{}] {}:{} {}".format(
|
||||
completed, len(pending), database.name, object_name,
|
||||
record["status"],
|
||||
), flush=True)
|
||||
|
||||
results = sorted(
|
||||
records.values(),
|
||||
key=lambda item: (item.get("database", ""), item.get("object", "")),
|
||||
)
|
||||
statuses = Counter(
|
||||
record.get("status", "error")
|
||||
for record in results if record.get("scope") == "brep"
|
||||
)
|
||||
database_errors = [
|
||||
record for record in results if record.get("scope") == "database"
|
||||
]
|
||||
revision, dirty = git_state(Path(__file__).resolve().parents[2])
|
||||
summary = {
|
||||
"format": FORMAT,
|
||||
"generated_utc": datetime.datetime.now(
|
||||
datetime.timezone.utc
|
||||
).isoformat(),
|
||||
"revision": revision,
|
||||
"dirty": dirty,
|
||||
"input_directory": str(input_directory),
|
||||
"output_directory": str(output_directory),
|
||||
"helper": str(helper),
|
||||
"options": options,
|
||||
"totals": {
|
||||
"databases": len(databases),
|
||||
"database_inventory_errors": len(database_errors),
|
||||
"breps": sum(statuses.values()),
|
||||
"ok": statuses["ok"],
|
||||
"fail": statuses["fail"],
|
||||
"timeout": statuses["timeout"],
|
||||
"memory_limit": statuses["memory_limit"],
|
||||
"error": statuses["error"] + len(database_errors),
|
||||
},
|
||||
"results": results,
|
||||
}
|
||||
summary_path = output_directory / "brep-audit-summary.json"
|
||||
worklist_path = output_directory / "brep-audit-worklist.md"
|
||||
summary_path.write_text(
|
||||
json.dumps(summary, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
worklist_path.write_text(
|
||||
worklist_markdown(summary, input_directory), encoding="utf-8"
|
||||
)
|
||||
totals = summary["totals"]
|
||||
print(
|
||||
"audited {} BReps: {} okay; {} work items; reports in {}".format(
|
||||
totals["breps"], totals["ok"],
|
||||
totals["breps"] - totals["ok"] + len(database_errors),
|
||||
output_directory,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture standard swrast overview images for converted STEP databases.
|
||||
|
||||
Each database is opened in its own gsh process so the display manager and GED
|
||||
state are isolated. The two modes are written to separate directories and
|
||||
use the database filename (without its final ``.g``) as the image name.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def image_stem(path):
|
||||
return path.name[:-2] if path.name.lower().endswith(".g") else path.stem
|
||||
|
||||
|
||||
def capture(gsh, model, output, mode, azimuth, elevation, timeout):
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
image = output / (image_stem(model) + ".png")
|
||||
try:
|
||||
tops = subprocess.run(
|
||||
[str(gsh), "--new-cmds", str(model), "tops"],
|
||||
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
timeout=timeout,
|
||||
).stdout.splitlines()
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"model": str(model), "mode": mode, "image": str(image),
|
||||
"returncode": 124, "complete": False,
|
||||
"log": "timed out while listing top-level objects"}
|
||||
tops = [line.strip().rstrip("/") for line in tops
|
||||
if line.strip().endswith("/") and line.strip().count("/") == 1]
|
||||
if not tops:
|
||||
return {"model": str(model), "mode": mode, "image": str(image),
|
||||
"returncode": 1, "complete": False,
|
||||
"log": "database has no top-level objects"}
|
||||
commands = [
|
||||
"dm attach swrast SWDM",
|
||||
"draw -m{} {}".format(mode, " ".join(tops)),
|
||||
"autoview",
|
||||
"ae {} {}".format(azimuth, elevation),
|
||||
"screengrab -D SWDM {}".format(image),
|
||||
"quit",
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[str(gsh), "--new-cmds", str(model)],
|
||||
input="\n".join(commands) + "\n",
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
return {"model": str(model), "mode": mode, "image": str(image),
|
||||
"returncode": 124, "complete": False,
|
||||
"log": str(error.stdout or "")[-4000:] + "\ntimed out during capture"}
|
||||
return {
|
||||
"model": str(model),
|
||||
"mode": mode,
|
||||
"image": str(image),
|
||||
"returncode": proc.returncode,
|
||||
"complete": proc.returncode == 0 and image.is_file() and image.stat().st_size > 0,
|
||||
"log": proc.stdout[-4000:],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("input_directory", type=Path)
|
||||
parser.add_argument("output_directory", type=Path)
|
||||
parser.add_argument("--gsh", type=Path, default=Path(".build/bin/gsh"))
|
||||
parser.add_argument("--jobs", type=int, default=min(2, os.cpu_count() or 1))
|
||||
parser.add_argument("--timeout", type=int, default=300,
|
||||
help="maximum seconds per model and view")
|
||||
parser.add_argument("--mode", choices=("wireframe", "shaded", "both"), default="both")
|
||||
parser.add_argument("--only", help="regular expression matched against model names")
|
||||
parser.add_argument("--azimuth", type=float, default=35.0)
|
||||
parser.add_argument("--elevation", type=float, default=25.0)
|
||||
args = parser.parse_args()
|
||||
if args.jobs < 1:
|
||||
parser.error("--jobs must be positive")
|
||||
if args.timeout < 1:
|
||||
parser.error("--timeout must be positive")
|
||||
if not args.gsh.is_file():
|
||||
parser.error("gsh not found: {}".format(args.gsh))
|
||||
|
||||
matcher = re.compile(args.only) if args.only else None
|
||||
models = sorted(path for path in args.input_directory.glob("*.g")
|
||||
if not matcher or matcher.search(path.name))
|
||||
if not models:
|
||||
parser.error("no converted .g models in {}".format(args.input_directory))
|
||||
modes = [("wireframe", 0), ("shaded", 1)] if args.mode == "both" else [
|
||||
(args.mode, 0 if args.mode == "wireframe" else 1)
|
||||
]
|
||||
jobs = []
|
||||
for name, mode in modes:
|
||||
target = args.output_directory / name
|
||||
jobs.extend((model, target, mode) for model in models)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=args.jobs) as pool:
|
||||
results = list(pool.map(
|
||||
lambda item: capture(args.gsh, item[0], item[1], item[2],
|
||||
args.azimuth, args.elevation, args.timeout), jobs
|
||||
))
|
||||
args.output_directory.mkdir(parents=True, exist_ok=True)
|
||||
manifest = {
|
||||
"format": "brlcad-step-corpus-capture-v1",
|
||||
"azimuth": args.azimuth,
|
||||
"elevation": args.elevation,
|
||||
"jobs": args.jobs,
|
||||
"results": results,
|
||||
}
|
||||
(args.output_directory / "capture-manifest.json").write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
failed = [item for item in results if not item["complete"]]
|
||||
print("captured {}/{} images".format(len(results) - len(failed), len(results)))
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,501 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the schema-dispatching BRL-CAD STEP importer over an external corpus.
|
||||
|
||||
Unknown schemas are recorded in the run manifest but are not passed to the
|
||||
converter. Each supported input gets a structured report, a diagnostic log,
|
||||
and a tab-separated status record consumed by ``step_corpus_summary.py``.
|
||||
Nested input directories are scanned recursively, and existing complete
|
||||
records may be resumed.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
STEP_SUFFIXES = {".stp", ".step", ".p21"}
|
||||
INCOMPLETE_PROCESS_STATUSES = {124, 130, 137, 143}
|
||||
|
||||
|
||||
def schema_names(path):
|
||||
with path.open("rb") as stream:
|
||||
header = stream.read(2 * 1024 * 1024).decode("latin-1", errors="replace")
|
||||
match = re.search(
|
||||
r"FILE_SCHEMA\s*\(\s*\((.*?)\)\s*\)", header,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if not match:
|
||||
return []
|
||||
return [
|
||||
value.replace("''", "'").upper()
|
||||
for value in re.findall(r"'((?:''|[^'])*)'", match.group(1))
|
||||
]
|
||||
|
||||
|
||||
def schema_family(names):
|
||||
"""Mirror the public host's supported-family and interim-profile routing."""
|
||||
families = set()
|
||||
legacy_ap203e2 = {
|
||||
"CONFIGURATION_CONTROL_3D_DESIGN_MIM_LF",
|
||||
"CCD_CLA_GVP_AST",
|
||||
"CCD_CLA_GVP_AST_ASD",
|
||||
}
|
||||
interim_companions = (
|
||||
"GEOMETRIC_VALIDATION_PROPERTIES_MIM",
|
||||
"GEOMETRIC_VALIDATION_PROPERTY_REPRESENTATION_MIM",
|
||||
"SHAPE_APPEARANCE_LAYER_MIM",
|
||||
"SHAPE_APPEARANCE_LAYERS_MIM",
|
||||
)
|
||||
for name in names:
|
||||
if "AP203_CONFIGURATION_CONTROLLED" in name or any(
|
||||
name.startswith(alias) for alias in legacy_ap203e2):
|
||||
families.add("AP203e2")
|
||||
elif "AUTOMOTIVE_DESIGN" in name:
|
||||
families.add("AP214")
|
||||
elif "CONFIG_CONTROL_DESIGN" in name:
|
||||
families.add("AP203")
|
||||
elif "AP242" in name or "MANAGED_MODEL_BASED_3D_ENGINEERING" in name:
|
||||
families.add("AP242")
|
||||
|
||||
if families == {"AP203"} and any(
|
||||
name.startswith(companion)
|
||||
for name in names for companion in interim_companions):
|
||||
return "AP203e2"
|
||||
return next(iter(families)) if len(families) == 1 else "unsupported"
|
||||
|
||||
|
||||
def artifact_stem(path, input_directory):
|
||||
relative = path.relative_to(input_directory).as_posix()
|
||||
readable = re.sub(r"[^A-Za-z0-9_.-]+", "_", relative)
|
||||
# Sanitizing path separators and punctuation is not injective. Retain a
|
||||
# readable prefix while making nested and similarly named inputs safe.
|
||||
identity = hashlib.sha256(relative.encode("utf-8")).hexdigest()[:12]
|
||||
return "{}__{}_".format(readable, identity)
|
||||
|
||||
|
||||
def legacy_artifact_stem(path, input_directory):
|
||||
"""Return the pre-v2 stem so an in-progress corpus can be resumed."""
|
||||
relative = path.relative_to(input_directory).as_posix()
|
||||
return re.sub(r"[^A-Za-z0-9_.-]+", "_", relative) + "_"
|
||||
|
||||
|
||||
def completed_status(path):
|
||||
try:
|
||||
status = int(path.read_text(encoding="utf-8").split("\t", 1)[0])
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return status not in INCOMPLETE_PROCESS_STATUSES
|
||||
|
||||
|
||||
def synthetic_report(input_path, status, message):
|
||||
return {
|
||||
"format": "brlcad-step-import-report-v1",
|
||||
"input": str(input_path),
|
||||
"output": "",
|
||||
"exit_status": status,
|
||||
"coverage": {
|
||||
"geometry_attempted": 0,
|
||||
"geometry_written": 0,
|
||||
"geometry_skipped": 0,
|
||||
},
|
||||
"validation": {
|
||||
"invalid_breps": 0,
|
||||
"output_failures": 0,
|
||||
"repairs": 0,
|
||||
"inferred_curves": 0,
|
||||
},
|
||||
"diagnostics": [{
|
||||
"severity": "error",
|
||||
"entity_id": 0,
|
||||
"entity_type": "",
|
||||
"attribute": "",
|
||||
"message": message,
|
||||
"count": 1,
|
||||
"aggregated": False,
|
||||
}],
|
||||
"timings_us": {},
|
||||
"peak_rss_bytes": 0,
|
||||
"skipped_items": [],
|
||||
"corpus_run_incomplete": True,
|
||||
}
|
||||
|
||||
|
||||
def file_sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def executable_identity(path):
|
||||
stat = path.stat()
|
||||
return {
|
||||
"path": str(path),
|
||||
"size": stat.st_size,
|
||||
"mtime_ns": stat.st_mtime_ns,
|
||||
"sha256": file_sha256(path),
|
||||
}
|
||||
|
||||
|
||||
def schema_plugin_paths(step_g):
|
||||
"""Locate build/install-tree schema modules used by the plugin host."""
|
||||
plugin_directory = step_g.parent.parent / "libexec" / "step"
|
||||
if not plugin_directory.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
path for path in plugin_directory.glob("libstep-schema-*")
|
||||
if path.is_file()
|
||||
)
|
||||
|
||||
|
||||
def schema_plugin_identities(step_g):
|
||||
"""Identify build/install-tree schema modules used by the plugin host."""
|
||||
return [executable_identity(path) for path in schema_plugin_paths(step_g)]
|
||||
|
||||
|
||||
def linked_library_identities(executables, runtime_root):
|
||||
"""Hash resolved runtime libraries supplied by the selected build tree.
|
||||
|
||||
Converter and plugin hashes do not detect a rebuilt geometry or STEPcode
|
||||
shared library. On platforms with ``ldd``, retain every resolved library
|
||||
below the executable's build/install root so a long run cannot be mistaken
|
||||
for a homogeneous baseline after one of those dependencies changes.
|
||||
"""
|
||||
libraries = {}
|
||||
for executable in executables:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ldd", str(executable)], check=False, capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except OSError:
|
||||
return []
|
||||
if result.returncode:
|
||||
continue
|
||||
for line in result.stdout.splitlines():
|
||||
match = re.search(r"=>\s+(/\S+)", line)
|
||||
if not match:
|
||||
match = re.match(r"\s*(/\S+)", line)
|
||||
if not match:
|
||||
continue
|
||||
path = Path(match.group(1)).resolve()
|
||||
try:
|
||||
path.relative_to(runtime_root)
|
||||
except ValueError:
|
||||
continue
|
||||
if path.is_file():
|
||||
libraries[str(path)] = path
|
||||
return [executable_identity(libraries[name]) for name in sorted(libraries)]
|
||||
|
||||
|
||||
def host_snapshot():
|
||||
"""Capture lightweight run context without adding a monitoring dependency."""
|
||||
snapshot = {
|
||||
"utc": datetime.datetime.now(datetime.timezone.utc).replace(
|
||||
microsecond=0
|
||||
).isoformat(),
|
||||
}
|
||||
try:
|
||||
snapshot["load_average"] = list(os.getloadavg())
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
try:
|
||||
values = {}
|
||||
with Path("/proc/meminfo").open("r", encoding="ascii") as stream:
|
||||
for line in stream:
|
||||
key, value = line.split(":", 1)
|
||||
if key in ("MemTotal", "MemAvailable"):
|
||||
values[key] = int(value.split()[0]) * 1024
|
||||
if values:
|
||||
snapshot["memory_bytes"] = values
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return snapshot
|
||||
|
||||
|
||||
def cpu_model():
|
||||
try:
|
||||
with Path("/proc/cpuinfo").open("r", encoding="ascii") as stream:
|
||||
for line in stream:
|
||||
if line.lower().startswith("model name"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
except (OSError, IndexError):
|
||||
pass
|
||||
return platform.processor()
|
||||
|
||||
|
||||
def write_manifest(path, manifest):
|
||||
"""Atomically preserve progress so an interrupted long run is auditable."""
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def terminate_process(process, force=False):
|
||||
try:
|
||||
if os.name == "nt":
|
||||
process.kill() if force else process.terminate()
|
||||
else:
|
||||
os.killpg(process.pid, signal.SIGKILL if force else signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
|
||||
def run_converter(command, log, timeout):
|
||||
"""Run one converter and terminate its complete process group on timeout."""
|
||||
process = subprocess.Popen(
|
||||
command, stdout=log, stderr=subprocess.STDOUT,
|
||||
start_new_session=os.name != "nt",
|
||||
)
|
||||
try:
|
||||
return process.wait(timeout=timeout if timeout > 0 else None), False
|
||||
except subprocess.TimeoutExpired:
|
||||
terminate_process(process)
|
||||
try:
|
||||
process.wait(timeout=30)
|
||||
except subprocess.TimeoutExpired:
|
||||
terminate_process(process, force=True)
|
||||
process.wait()
|
||||
return 124, True
|
||||
except BaseException:
|
||||
terminate_process(process)
|
||||
try:
|
||||
process.wait(timeout=30)
|
||||
except subprocess.TimeoutExpired:
|
||||
terminate_process(process, force=True)
|
||||
process.wait()
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("input_directory", type=Path)
|
||||
parser.add_argument("output_directory", type=Path)
|
||||
parser.add_argument("--step-g", type=Path, default=Path(".build/bin/step-g"))
|
||||
parser.add_argument("-j", "--jobs", type=int, default=min(12, os.cpu_count() or 1),
|
||||
help="geometry workers inside each converter")
|
||||
parser.add_argument("--previous", type=Path,
|
||||
help="previous corpus-summary.json for trend deltas")
|
||||
parser.add_argument("--expectations", type=Path,
|
||||
help="checksum-pinned corpus expectation manifest")
|
||||
parser.add_argument("--allow-missing-expectations", action="store_true",
|
||||
help="allow expectation entries absent from a filtered run")
|
||||
parser.add_argument("--run-id", help="stable label for this run")
|
||||
parser.add_argument("--resume", action="store_true")
|
||||
parser.add_argument("--exact", action="store_true")
|
||||
parser.add_argument("--timeout", type=float, default=0.0,
|
||||
help="outer wall-clock limit per file in seconds (0 disables)")
|
||||
parser.add_argument("--stall-timeout", type=float,
|
||||
help="override step-g's no-progress timeout in seconds")
|
||||
parser.add_argument(
|
||||
"--only", help="regular expression matched against relative input paths"
|
||||
)
|
||||
parser.add_argument("--list", action="store_true",
|
||||
help="classify inputs without running converters")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.jobs < 1 or args.timeout < 0:
|
||||
parser.error("--jobs must be positive and --timeout must be nonnegative")
|
||||
if args.stall_timeout is not None and args.stall_timeout <= 0:
|
||||
parser.error("--stall-timeout must be positive")
|
||||
input_directory = args.input_directory.resolve()
|
||||
output_directory = args.output_directory.resolve()
|
||||
output_directory.mkdir(parents=True, exist_ok=True)
|
||||
matcher = re.compile(args.only) if args.only else None
|
||||
inputs = sorted(
|
||||
path for path in input_directory.rglob("*")
|
||||
if path.is_file() and path.suffix.lower() in STEP_SUFFIXES and
|
||||
(not matcher or matcher.search(path.relative_to(input_directory).as_posix()))
|
||||
)
|
||||
if not inputs:
|
||||
parser.error("no matching STEP files in {}".format(input_directory))
|
||||
|
||||
run_id = args.run_id or datetime.datetime.now(
|
||||
datetime.timezone.utc
|
||||
).strftime("%Y%m%dT%H%M%SZ")
|
||||
step_g = args.step_g.resolve()
|
||||
if not step_g.is_file():
|
||||
parser.error("step-g not found: {}".format(step_g))
|
||||
plugin_paths = schema_plugin_paths(step_g)
|
||||
runtime_root = step_g.parent.parent.resolve()
|
||||
manifest = {
|
||||
"format": "brlcad-step-corpus-run-v1",
|
||||
"run_id": run_id,
|
||||
"started_utc": datetime.datetime.now(datetime.timezone.utc).replace(
|
||||
microsecond=0
|
||||
).isoformat(),
|
||||
"host": {
|
||||
"hostname": platform.node(),
|
||||
"platform": platform.platform(),
|
||||
"machine": platform.machine(),
|
||||
"cpu_model": cpu_model(),
|
||||
"logical_cpus": os.cpu_count(),
|
||||
"initial_snapshot": host_snapshot(),
|
||||
},
|
||||
"input_directory": str(input_directory),
|
||||
"output_directory": str(output_directory),
|
||||
"options": {
|
||||
"jobs": args.jobs,
|
||||
"exact": args.exact,
|
||||
"recursive": True,
|
||||
"timeout_seconds": args.timeout,
|
||||
"stall_timeout_seconds": args.stall_timeout,
|
||||
},
|
||||
"step_g": executable_identity(step_g),
|
||||
"schema_plugins": [executable_identity(path) for path in plugin_paths],
|
||||
"linked_libraries": linked_library_identities(
|
||||
[step_g] + plugin_paths, runtime_root
|
||||
),
|
||||
"expectations": str(args.expectations.resolve()) if args.expectations else None,
|
||||
"files": [],
|
||||
}
|
||||
manifest_path = output_directory / "run-manifest.json"
|
||||
write_manifest(manifest_path, manifest)
|
||||
|
||||
for index, input_path in enumerate(inputs, 1):
|
||||
relative_input = input_path.relative_to(input_directory).as_posix()
|
||||
declared_schemas = schema_names(input_path)
|
||||
declared_schema = declared_schemas[0] if declared_schemas else ""
|
||||
family = schema_family(declared_schemas)
|
||||
record = {
|
||||
"input": str(input_path),
|
||||
"declared_schema": declared_schema,
|
||||
"declared_schemas": declared_schemas,
|
||||
"schema": family,
|
||||
}
|
||||
if args.list:
|
||||
print("{}\t{}\t{}".format(
|
||||
family, ";".join(declared_schemas), relative_input
|
||||
))
|
||||
manifest["files"].append(record)
|
||||
write_manifest(manifest_path, manifest)
|
||||
continue
|
||||
input_stat = input_path.stat()
|
||||
record["input_identity"] = {
|
||||
"size": input_stat.st_size,
|
||||
"mtime_ns": input_stat.st_mtime_ns,
|
||||
"sha256": file_sha256(input_path),
|
||||
}
|
||||
if family == "unsupported":
|
||||
record["result"] = "skipped"
|
||||
record["reason"] = "unrecognized schema"
|
||||
manifest["files"].append(record)
|
||||
write_manifest(manifest_path, manifest)
|
||||
print("[{}/{}] skip {} ({})".format(
|
||||
index, len(inputs), relative_input, family
|
||||
))
|
||||
continue
|
||||
|
||||
stem = artifact_stem(input_path, input_directory)
|
||||
report_path = output_directory / (stem + ".json")
|
||||
log_path = output_directory / (stem + ".log")
|
||||
status_path = output_directory / (stem + ".status")
|
||||
if args.resume:
|
||||
legacy_stem = legacy_artifact_stem(input_path, input_directory)
|
||||
legacy_report = output_directory / (legacy_stem + ".json")
|
||||
legacy_status = output_directory / (legacy_stem + ".status")
|
||||
if legacy_report.is_file() and completed_status(legacy_status):
|
||||
stem = legacy_stem
|
||||
report_path = legacy_report
|
||||
log_path = output_directory / (stem + ".log")
|
||||
status_path = legacy_status
|
||||
if args.resume and report_path.is_file() and completed_status(status_path):
|
||||
record["result"] = "resumed"
|
||||
record["report"] = report_path.name
|
||||
manifest["files"].append(record)
|
||||
write_manifest(manifest_path, manifest)
|
||||
print("[{}/{}] keep {}".format(index, len(inputs), relative_input))
|
||||
continue
|
||||
|
||||
command = [
|
||||
str(step_g), "-D", "-j", str(args.jobs),
|
||||
"--report", str(report_path),
|
||||
]
|
||||
if args.exact:
|
||||
command.append("--exact")
|
||||
if args.stall_timeout is not None:
|
||||
command.extend(["--stall-timeout", str(args.stall_timeout)])
|
||||
command.append(str(input_path))
|
||||
print("[{}/{}] {} {}".format(index, len(inputs), family, relative_input),
|
||||
flush=True)
|
||||
record.update({"result": "running", "host_before": host_snapshot()})
|
||||
manifest["files"].append(record)
|
||||
write_manifest(manifest_path, manifest)
|
||||
started = time.monotonic()
|
||||
with log_path.open("wb") as log:
|
||||
try:
|
||||
status, timed_out = run_converter(command, log, args.timeout)
|
||||
message = "converter exited without writing a structured report"
|
||||
if timed_out:
|
||||
message = "converter exceeded the {:.1f}-second outer timeout".format(
|
||||
args.timeout
|
||||
)
|
||||
except OSError as error:
|
||||
status = 127
|
||||
timed_out = False
|
||||
message = "could not execute converter: {}".format(error)
|
||||
elapsed = time.monotonic() - started
|
||||
if not report_path.is_file():
|
||||
report_path.write_text(
|
||||
json.dumps(synthetic_report(input_path, status, message), indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
status_path.write_text(
|
||||
"{}\t{:.3f}\t{}\t{}\n".format(status, elapsed, family, input_path),
|
||||
encoding="utf-8",
|
||||
)
|
||||
record.update({
|
||||
"result": "completed",
|
||||
"process_exit_status": status,
|
||||
"elapsed_seconds": elapsed,
|
||||
"timed_out": timed_out,
|
||||
"report": report_path.name,
|
||||
"log": log_path.name,
|
||||
"host_after": host_snapshot(),
|
||||
})
|
||||
write_manifest(manifest_path, manifest)
|
||||
|
||||
manifest["finished_utc"] = datetime.datetime.now(
|
||||
datetime.timezone.utc
|
||||
).replace(microsecond=0).isoformat()
|
||||
manifest["host"]["final_snapshot"] = host_snapshot()
|
||||
write_manifest(manifest_path, manifest)
|
||||
if not args.list:
|
||||
summary_command = [
|
||||
sys.executable,
|
||||
str(Path(__file__).with_name("step_corpus_summary.py")),
|
||||
str(output_directory),
|
||||
"--run-id", run_id,
|
||||
]
|
||||
if args.previous:
|
||||
summary_command.extend(["--previous", str(args.previous.resolve())])
|
||||
summary_status = subprocess.call(summary_command)
|
||||
if summary_status or not args.expectations:
|
||||
return summary_status
|
||||
verify_command = [
|
||||
sys.executable,
|
||||
str(Path(__file__).with_name("step_corpus_verify.py")),
|
||||
str(output_directory),
|
||||
str(args.expectations.resolve()),
|
||||
]
|
||||
if args.allow_missing_expectations:
|
||||
verify_command.append("--allow-missing")
|
||||
return subprocess.call(verify_command)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,939 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Summarize one BRL-CAD STEP converter corpus run and compare it to another.
|
||||
|
||||
The corpus runner writes one ``NAME.status`` record and one ``NAME.json``
|
||||
converter report per input. A status record is tab separated::
|
||||
|
||||
exit_status<TAB>elapsed_seconds<TAB>schema<TAB>input_path
|
||||
|
||||
This utility intentionally consumes the converters' structured reports rather
|
||||
than scraping diagnostics. It writes a durable JSON record suitable for trend
|
||||
plots and a compact Markdown companion for review.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FORMAT = "brlcad-step-corpus-summary-v2"
|
||||
|
||||
|
||||
INCOMPLETE_PROCESS_STATUSES = {124, 130, 137, 143}
|
||||
|
||||
|
||||
def run_complete(item):
|
||||
"""Return whether an input reached a normal converter terminal state."""
|
||||
if "run_complete" in item:
|
||||
return bool(item["run_complete"])
|
||||
return int(item.get("process_exit_status", 0)) not in INCOMPLETE_PROCESS_STATUSES
|
||||
|
||||
|
||||
def read_json(path):
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
return json.load(stream)
|
||||
|
||||
|
||||
def git_state(start):
|
||||
try:
|
||||
revision = subprocess.check_output(
|
||||
["git", "-C", str(start), "rev-parse", "HEAD"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
).strip()
|
||||
dirty = bool(
|
||||
subprocess.check_output(
|
||||
["git", "-C", str(start), "status", "--porcelain"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
).strip()
|
||||
)
|
||||
return revision, dirty
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return "unknown", None
|
||||
|
||||
|
||||
def failure_category(reason):
|
||||
text = (reason or "").lower()
|
||||
if any(word in text for word in ("budget", "expired", "timed out", "timeout")):
|
||||
return "work_budget"
|
||||
if any(word in text for word in (
|
||||
"not solid", "non-solid", "solid validation", "validate as a solid"
|
||||
)):
|
||||
return "non_solid"
|
||||
if "structural" in text:
|
||||
return "structural"
|
||||
if "exact" in text or "conversion failed" in text:
|
||||
return "exact_conversion"
|
||||
if "opennurbs" in text:
|
||||
return "structural"
|
||||
if "no importer" in text or "unsupported" in text:
|
||||
return "unsupported"
|
||||
if "output" in text or "database" in text or "write" in text:
|
||||
return "output"
|
||||
return "other"
|
||||
|
||||
|
||||
def parse_status(path):
|
||||
fields = path.read_text(encoding="utf-8").rstrip("\n").split("\t", 3)
|
||||
if len(fields) != 4:
|
||||
raise ValueError("{} is not a four-field corpus status record".format(path))
|
||||
return {
|
||||
"process_exit_status": int(fields[0]),
|
||||
"elapsed_seconds": float(fields[1]),
|
||||
"schema": fields[2],
|
||||
"input": fields[3],
|
||||
}
|
||||
|
||||
|
||||
def occurrence_depth(occurrences):
|
||||
"""Return the maximum product-assembly depth represented by occurrences."""
|
||||
children = {}
|
||||
vertices = set()
|
||||
for occurrence in occurrences:
|
||||
parent = int(occurrence.get("parent_product_id") or 0)
|
||||
child = int(occurrence.get("child_product_id") or 0)
|
||||
if parent <= 0 or child <= 0:
|
||||
continue
|
||||
children.setdefault(parent, set()).add(child)
|
||||
vertices.update((parent, child))
|
||||
|
||||
active = set()
|
||||
memo = {}
|
||||
|
||||
def visit(vertex):
|
||||
if vertex in memo:
|
||||
return memo[vertex]
|
||||
if vertex in active:
|
||||
# A cycle is invalid, but retain a useful finite metric and let the
|
||||
# converter's structured diagnostics report the underlying issue.
|
||||
return 0
|
||||
active.add(vertex)
|
||||
depth = max((1 + visit(child)
|
||||
for child in children.get(vertex, ())), default=0)
|
||||
active.remove(vertex)
|
||||
memo[vertex] = depth
|
||||
return depth
|
||||
|
||||
return max((visit(vertex) for vertex in vertices), default=0)
|
||||
|
||||
|
||||
def report_record(status_path):
|
||||
status = parse_status(status_path)
|
||||
report_path = status_path.with_suffix(".json")
|
||||
if not report_path.is_file():
|
||||
raise ValueError("missing report paired with {}".format(status_path))
|
||||
report = read_json(report_path)
|
||||
coverage = report.get("coverage") or {}
|
||||
validation = report.get("validation") or {}
|
||||
timings = report.get("timings_us") or {}
|
||||
performance = report.get("performance") or {}
|
||||
calibration = performance.get("calibration") or {}
|
||||
stage_timings = performance.get("stages") or {}
|
||||
skipped = report.get("skipped_items") or []
|
||||
occurrences = report.get("occurrence_details") or []
|
||||
configuration_records = report.get("configuration_records") or []
|
||||
representation_coverage = report.get("representation_coverage") or []
|
||||
attempted = int(coverage.get("geometry_attempted") or 0)
|
||||
written = int(coverage.get("geometry_written") or 0)
|
||||
skipped_count = int(coverage.get("geometry_skipped") or len(skipped))
|
||||
categories = Counter(failure_category(item.get("reason")) for item in skipped)
|
||||
invalid_breps = int(validation.get("invalid_breps") or 0)
|
||||
invalid_breps_written = int(validation.get("invalid_breps_written") or 0)
|
||||
invalid_breps_rejected = int(validation.get("invalid_breps_rejected") or 0)
|
||||
output_failures = int(validation.get("output_failures") or 0)
|
||||
inferred_curves = int(validation.get("inferred_curves") or 0)
|
||||
if invalid_breps_written:
|
||||
categories["invalid_brep_preserved"] += invalid_breps_written
|
||||
if output_failures:
|
||||
categories["output"] += output_failures
|
||||
configuration_types = Counter(
|
||||
item.get("type") or "UNKNOWN" for item in configuration_records
|
||||
)
|
||||
representation_statuses = Counter(
|
||||
item.get("status") or "unknown" for item in representation_coverage
|
||||
)
|
||||
converter_status = int(
|
||||
report.get("exit_status", status["process_exit_status"])
|
||||
)
|
||||
intentionally_empty = (
|
||||
status["process_exit_status"] == 6 and converter_status == 6 and
|
||||
attempted == 0 and bool(representation_coverage) and
|
||||
all(item.get("status") == "intentionally_non_geometric"
|
||||
for item in representation_coverage)
|
||||
)
|
||||
input_path = str(report.get("input") or status["input"])
|
||||
result = {
|
||||
"name": os.path.basename(input_path),
|
||||
"input": input_path,
|
||||
"schema": status["schema"],
|
||||
"report": report_path.name,
|
||||
"process_exit_status": status["process_exit_status"],
|
||||
"converter_exit_status": converter_status,
|
||||
"run_complete": status["process_exit_status"] not in
|
||||
INCOMPLETE_PROCESS_STATUSES,
|
||||
"elapsed_seconds": status["elapsed_seconds"],
|
||||
"geometry_attempted": attempted,
|
||||
"geometry_written": written,
|
||||
"geometry_skipped": skipped_count,
|
||||
"success_fraction": (float(written) / attempted) if attempted else 0.0,
|
||||
"products": int(coverage.get("products") or 0),
|
||||
"occurrences": int(coverage.get("occurrences") or 0),
|
||||
"assembly_usage_count": len(report.get("assembly_usages") or []),
|
||||
"occurrence_detail_count": len(occurrences),
|
||||
"occurrence_max_depth": occurrence_depth(occurrences),
|
||||
"occurrence_methods": sorted({
|
||||
item.get("shape_method") or "unknown" for item in occurrences
|
||||
}),
|
||||
"styles_extracted": int(coverage.get("styles_extracted") or 0),
|
||||
"styles_applied": int(coverage.get("styles_applied") or 0),
|
||||
"layers_extracted": int(coverage.get("layers_extracted") or 0),
|
||||
"materials_extracted": int(coverage.get("materials_extracted") or 0),
|
||||
"properties_extracted": int(coverage.get("properties_extracted") or 0),
|
||||
"configuration_record_count": len(configuration_records),
|
||||
"configuration_record_types": dict(sorted(configuration_types.items())),
|
||||
"configuration_metadata_count": len(
|
||||
report.get("configuration_metadata") or {}
|
||||
),
|
||||
"product_alternative_count": len(
|
||||
report.get("product_alternatives") or []
|
||||
),
|
||||
"usage_substitute_count": len(report.get("usage_substitutes") or []),
|
||||
"entity_type_counts": dict(sorted(
|
||||
(coverage.get("entity_counts") or {}).items()
|
||||
)),
|
||||
"unsupported_entity_counts": dict(sorted(
|
||||
(coverage.get("unsupported_counts") or {}).items()
|
||||
)),
|
||||
"representation_status_counts": dict(sorted(
|
||||
representation_statuses.items()
|
||||
)),
|
||||
"clean": status["process_exit_status"] not in
|
||||
INCOMPLETE_PROCESS_STATUSES and attempted > 0 and
|
||||
written == attempted and
|
||||
converter_status == 0 and
|
||||
invalid_breps_written == 0 and output_failures == 0 and
|
||||
inferred_curves == 0,
|
||||
"intentionally_empty": intentionally_empty,
|
||||
"invalid_breps": invalid_breps,
|
||||
"invalid_breps_written": invalid_breps_written,
|
||||
"invalid_breps_rejected": invalid_breps_rejected,
|
||||
"output_failures": output_failures,
|
||||
"repairs": int(validation.get("repairs") or 0),
|
||||
"inferred_curves": inferred_curves,
|
||||
"tolerance_mm": validation.get("tolerance_mm"),
|
||||
"peak_rss_bytes": int(report.get("peak_rss_bytes") or 0),
|
||||
"load_us": int(timings.get("load") or 0),
|
||||
"index_us": int(timings.get("index") or 0),
|
||||
"inventory_us": int(timings.get("inventory") or 0),
|
||||
"convert_us": int(timings.get("convert") or 0),
|
||||
"calibration": {
|
||||
"queries": int(calibration.get("queries") or 0),
|
||||
"elapsed_us": int(calibration.get("elapsed_us") or 0),
|
||||
"scalar_queries_per_second": float(
|
||||
calibration.get("scalar_queries_per_second") or 0.0
|
||||
),
|
||||
"parallel_queries_per_second": float(
|
||||
calibration.get("parallel_queries_per_second") or 0.0
|
||||
),
|
||||
},
|
||||
"stage_timings_us": {
|
||||
name: int(values.get("total_us") or 0)
|
||||
for name, values in sorted(stage_timings.items())
|
||||
},
|
||||
"failure_categories": dict(sorted(categories.items())),
|
||||
"skipped_items": [
|
||||
{
|
||||
"entity_id": item.get("entity_id"),
|
||||
"entity_type": item.get("entity_type"),
|
||||
"reason": item.get("reason"),
|
||||
"category": failure_category(item.get("reason")),
|
||||
}
|
||||
for item in skipped
|
||||
],
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def totals(files):
|
||||
by_schema = {}
|
||||
for schema in sorted(set(item["schema"] for item in files)):
|
||||
by_schema[schema] = totals_base(
|
||||
[item for item in files if item["schema"] == schema]
|
||||
)
|
||||
result = totals_base(files)
|
||||
result["by_schema"] = by_schema
|
||||
return result
|
||||
|
||||
|
||||
def totals_base(files):
|
||||
completed = [item for item in files if run_complete(item)]
|
||||
incomplete = [item for item in files if not run_complete(item)]
|
||||
process_statuses = Counter(
|
||||
str(item["process_exit_status"]) for item in completed
|
||||
)
|
||||
converter_statuses = Counter(
|
||||
str(item["converter_exit_status"]) for item in completed
|
||||
)
|
||||
attempted = sum(item["geometry_attempted"] for item in completed)
|
||||
written = sum(item["geometry_written"] for item in completed)
|
||||
categories = Counter()
|
||||
entity_types = Counter()
|
||||
unsupported_types = Counter()
|
||||
configuration_types = Counter()
|
||||
representation_statuses = Counter()
|
||||
occurrence_methods = set()
|
||||
for item in completed:
|
||||
categories.update(item["failure_categories"])
|
||||
entity_types.update(item.get("entity_type_counts") or {})
|
||||
unsupported_types.update(item.get("unsupported_entity_counts") or {})
|
||||
configuration_types.update(item.get("configuration_record_types") or {})
|
||||
representation_statuses.update(
|
||||
item.get("representation_status_counts") or {}
|
||||
)
|
||||
occurrence_methods.update(item.get("occurrence_methods") or [])
|
||||
return {
|
||||
"files": len(files),
|
||||
"completed_files": len(completed),
|
||||
"inconclusive_files": len(incomplete),
|
||||
"clean_files": sum(1 for item in completed if item["clean"]),
|
||||
"intentionally_empty_files": sum(
|
||||
1 for item in completed if item.get("intentionally_empty")
|
||||
),
|
||||
"process_exit_status_counts": dict(sorted(process_statuses.items())),
|
||||
"converter_exit_status_counts": dict(sorted(converter_statuses.items())),
|
||||
"geometry_attempted": attempted,
|
||||
"geometry_written": written,
|
||||
"geometry_skipped": sum(item["geometry_skipped"] for item in completed),
|
||||
"success_fraction": (float(written) / attempted) if attempted else 0.0,
|
||||
"products": sum(item.get("products", 0) for item in completed),
|
||||
"occurrences": sum(item.get("occurrences", 0) for item in completed),
|
||||
"assembly_usage_count": sum(
|
||||
item.get("assembly_usage_count", 0) for item in completed
|
||||
),
|
||||
"occurrence_detail_count": sum(
|
||||
item.get("occurrence_detail_count", 0) for item in completed
|
||||
),
|
||||
"occurrence_max_depth": max(
|
||||
(item.get("occurrence_max_depth", 0) for item in completed),
|
||||
default=0,
|
||||
),
|
||||
"occurrence_methods": sorted(occurrence_methods),
|
||||
"styles_extracted": sum(
|
||||
item.get("styles_extracted", 0) for item in completed
|
||||
),
|
||||
"styles_applied": sum(
|
||||
item.get("styles_applied", 0) for item in completed
|
||||
),
|
||||
"layers_extracted": sum(
|
||||
item.get("layers_extracted", 0) for item in completed
|
||||
),
|
||||
"materials_extracted": sum(
|
||||
item.get("materials_extracted", 0) for item in completed
|
||||
),
|
||||
"properties_extracted": sum(
|
||||
item.get("properties_extracted", 0) for item in completed
|
||||
),
|
||||
"configuration_record_count": sum(
|
||||
item.get("configuration_record_count", 0) for item in completed
|
||||
),
|
||||
"configuration_metadata_count": sum(
|
||||
item.get("configuration_metadata_count", 0) for item in completed
|
||||
),
|
||||
"product_alternative_count": sum(
|
||||
item.get("product_alternative_count", 0) for item in completed
|
||||
),
|
||||
"usage_substitute_count": sum(
|
||||
item.get("usage_substitute_count", 0) for item in completed
|
||||
),
|
||||
"invalid_breps": sum(
|
||||
item.get("invalid_breps", 0) for item in completed
|
||||
),
|
||||
"invalid_breps_written": sum(
|
||||
item.get("invalid_breps_written", 0) for item in completed
|
||||
),
|
||||
"invalid_breps_rejected": sum(
|
||||
item.get("invalid_breps_rejected", 0) for item in completed
|
||||
),
|
||||
"output_failures": sum(
|
||||
item.get("output_failures", 0) for item in completed
|
||||
),
|
||||
"repairs": sum(item.get("repairs", 0) for item in completed),
|
||||
"inferred_curves": sum(
|
||||
item.get("inferred_curves", 0) for item in completed
|
||||
),
|
||||
"elapsed_seconds": sum(item["elapsed_seconds"] for item in completed),
|
||||
"load_seconds": sum(item.get("load_us", 0) for item in completed) / 1.0e6,
|
||||
"index_seconds": sum(item.get("index_us", 0) for item in completed) / 1.0e6,
|
||||
"inventory_seconds": sum(
|
||||
item.get("inventory_us", 0) for item in completed
|
||||
) / 1.0e6,
|
||||
"convert_seconds": sum(
|
||||
item.get("convert_us", 0) for item in completed
|
||||
) / 1.0e6,
|
||||
"inconclusive_elapsed_seconds": sum(
|
||||
item["elapsed_seconds"] for item in incomplete
|
||||
),
|
||||
"observed_inconclusive_geometry_attempted": sum(
|
||||
item["geometry_attempted"] for item in incomplete
|
||||
),
|
||||
"observed_inconclusive_geometry_written": sum(
|
||||
item["geometry_written"] for item in incomplete
|
||||
),
|
||||
"observed_inconclusive_geometry_skipped": sum(
|
||||
item["geometry_skipped"] for item in incomplete
|
||||
),
|
||||
"peak_file_rss_bytes": max(
|
||||
(item["peak_rss_bytes"] for item in files), default=0
|
||||
),
|
||||
"entity_type_counts": dict(sorted(entity_types.items())),
|
||||
"unsupported_entity_counts": dict(sorted(unsupported_types.items())),
|
||||
"configuration_record_types": dict(sorted(configuration_types.items())),
|
||||
"representation_status_counts": dict(
|
||||
sorted(representation_statuses.items())
|
||||
),
|
||||
"failure_categories": dict(sorted(categories.items())),
|
||||
}
|
||||
|
||||
|
||||
def positive_ratio(current, previous):
|
||||
return (float(current) / float(previous)) if previous > 0 else None
|
||||
|
||||
|
||||
def median_ratio(current_files, previous_files, field):
|
||||
ratios = [
|
||||
positive_ratio(current.get(field, 0), previous.get(field, 0))
|
||||
for current, previous in zip(current_files, previous_files)
|
||||
]
|
||||
ratios = [value for value in ratios if value is not None and value > 0]
|
||||
return statistics.median(ratios) if ratios else None
|
||||
|
||||
|
||||
def geometric_mean_ratio(current_files, previous_files, field):
|
||||
ratios = [
|
||||
positive_ratio(current.get(field, 0), previous.get(field, 0))
|
||||
for current, previous in zip(current_files, previous_files)
|
||||
]
|
||||
ratios = [value for value in ratios if value is not None and value > 0]
|
||||
return math.exp(sum(math.log(value) for value in ratios) / len(ratios)) \
|
||||
if ratios else None
|
||||
|
||||
|
||||
def performance_comparison(current_files, previous_files):
|
||||
current = totals_base(current_files)
|
||||
previous = totals_base(previous_files)
|
||||
fields = {
|
||||
"elapsed": "elapsed_seconds",
|
||||
"load": "load_seconds",
|
||||
"convert": "convert_seconds",
|
||||
"peak_file_rss": "peak_file_rss_bytes",
|
||||
}
|
||||
aggregate_ratios = {
|
||||
label: positive_ratio(current[field], previous[field])
|
||||
for label, field in fields.items()
|
||||
}
|
||||
per_file_fields = {
|
||||
"elapsed": "elapsed_seconds",
|
||||
"load": "load_us",
|
||||
"convert": "convert_us",
|
||||
"peak_rss": "peak_rss_bytes",
|
||||
}
|
||||
return {
|
||||
"comparable_files": len(current_files),
|
||||
"current": {field: current[field] for field in fields.values()},
|
||||
"previous": {field: previous[field] for field in fields.values()},
|
||||
"aggregate_ratios": aggregate_ratios,
|
||||
"median_file_ratios": {
|
||||
label: median_ratio(current_files, previous_files, field)
|
||||
for label, field in per_file_fields.items()
|
||||
},
|
||||
"geometric_mean_file_ratios": {
|
||||
label: geometric_mean_ratio(current_files, previous_files, field)
|
||||
for label, field in per_file_fields.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def converter_options(configuration):
|
||||
options = (configuration or {}).get("options") or {}
|
||||
return {
|
||||
"jobs": options.get("jobs"),
|
||||
"exact": bool(options.get("exact", False)),
|
||||
"stall_timeout_seconds": options.get("stall_timeout_seconds"),
|
||||
}
|
||||
|
||||
|
||||
def converter_identity(configuration):
|
||||
configuration = configuration or {}
|
||||
executable = configuration.get("step_g") or {}
|
||||
plugins = configuration.get("schema_plugins") or []
|
||||
libraries = configuration.get("linked_libraries") or []
|
||||
return {
|
||||
"host": executable.get("sha256"),
|
||||
"schema_plugins": [
|
||||
(os.path.basename(item.get("path") or ""), item.get("sha256"))
|
||||
for item in plugins
|
||||
],
|
||||
"linked_libraries": [
|
||||
(os.path.basename(item.get("path") or ""), item.get("sha256"))
|
||||
for item in libraries
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def host_identity(configuration):
|
||||
host = (configuration or {}).get("host") or {}
|
||||
return {
|
||||
key: host.get(key)
|
||||
for key in ("hostname", "platform", "machine", "cpu_model",
|
||||
"logical_cpus")
|
||||
}
|
||||
|
||||
|
||||
def host_observations(manifest):
|
||||
snapshots = []
|
||||
host = manifest.get("host") or {}
|
||||
for name in ("initial_snapshot", "final_snapshot"):
|
||||
if isinstance(host.get(name), dict):
|
||||
snapshots.append(host[name])
|
||||
for item in manifest.get("files", []):
|
||||
for name in ("host_before", "host_after"):
|
||||
if isinstance(item.get(name), dict):
|
||||
snapshots.append(item[name])
|
||||
|
||||
loads = [item.get("load_average") for item in snapshots]
|
||||
loads = [item for item in loads if isinstance(item, list) and len(item) == 3]
|
||||
available = [
|
||||
(item.get("memory_bytes") or {}).get("MemAvailable")
|
||||
for item in snapshots
|
||||
]
|
||||
available = [int(value) for value in available if value is not None]
|
||||
result = {"snapshot_count": len(snapshots)}
|
||||
if loads:
|
||||
labels = ("one_minute", "five_minutes", "fifteen_minutes")
|
||||
result["load_average"] = {
|
||||
label: {
|
||||
"minimum": min(float(item[index]) for item in loads),
|
||||
"mean": statistics.mean(float(item[index]) for item in loads),
|
||||
"maximum": max(float(item[index]) for item in loads),
|
||||
}
|
||||
for index, label in enumerate(labels)
|
||||
}
|
||||
if available:
|
||||
result["available_memory_bytes"] = {
|
||||
"minimum": min(available),
|
||||
"maximum": max(available),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def compare(current_files, previous, current_configuration=None,
|
||||
previous_configuration=None):
|
||||
if not previous:
|
||||
return None
|
||||
old_files = {item["input"]: item for item in previous.get("files", [])}
|
||||
changes = []
|
||||
comparable_current = []
|
||||
comparable_previous = []
|
||||
for current in current_files:
|
||||
old = old_files.get(current["input"])
|
||||
if not run_complete(current):
|
||||
changes.append({
|
||||
"input": current["input"],
|
||||
"change": "inconclusive",
|
||||
"process_exit_status": current["process_exit_status"],
|
||||
})
|
||||
continue
|
||||
if not old:
|
||||
changes.append({"input": current["input"], "change": "new"})
|
||||
continue
|
||||
if not run_complete(old):
|
||||
changes.append({"input": current["input"],
|
||||
"change": "previous_inconclusive"})
|
||||
continue
|
||||
comparable_current.append(current)
|
||||
comparable_previous.append(old)
|
||||
written_delta = current["geometry_written"] - old.get("geometry_written", 0)
|
||||
skipped_delta = current["geometry_skipped"] - old.get("geometry_skipped", 0)
|
||||
inferred_delta = current.get("inferred_curves", 0) - \
|
||||
old.get("inferred_curves", 0)
|
||||
old_skips = {
|
||||
(item.get("entity_id"), item.get("entity_type"), item.get("reason"))
|
||||
for item in old.get("skipped_items", [])
|
||||
}
|
||||
new_skips = {
|
||||
(item.get("entity_id"), item.get("entity_type"), item.get("reason"))
|
||||
for item in current.get("skipped_items", [])
|
||||
}
|
||||
if (written_delta or skipped_delta or inferred_delta or
|
||||
bool(old.get("clean")) != current["clean"] or
|
||||
old_skips != new_skips):
|
||||
changes.append({
|
||||
"input": current["input"],
|
||||
"written_delta": written_delta,
|
||||
"skipped_delta": skipped_delta,
|
||||
"inferred_curves_delta": inferred_delta,
|
||||
"became_clean": current["clean"] and not old.get("clean"),
|
||||
"regressed_from_clean": bool(old.get("clean")) and not current["clean"],
|
||||
"fixed_skips": [list(item) for item in sorted(old_skips - new_skips)],
|
||||
"new_skips": [list(item) for item in sorted(new_skips - old_skips)],
|
||||
})
|
||||
previous_totals = totals(comparable_previous)
|
||||
current_totals = totals(comparable_current)
|
||||
input_hash_pairs = [
|
||||
(current.get("input_sha256"), old.get("input_sha256"))
|
||||
for current, old in zip(comparable_current, comparable_previous)
|
||||
]
|
||||
input_hashes_verified = bool(input_hash_pairs) and all(
|
||||
current and old and current == old
|
||||
for current, old in input_hash_pairs
|
||||
)
|
||||
options_match = bool(
|
||||
current_configuration and previous_configuration
|
||||
) and converter_options(current_configuration) == converter_options(
|
||||
previous_configuration
|
||||
)
|
||||
hosts_match = bool(
|
||||
current_configuration and previous_configuration
|
||||
) and host_identity(current_configuration) == host_identity(
|
||||
previous_configuration
|
||||
)
|
||||
current_observations = (
|
||||
current_configuration or {}
|
||||
).get("host_observations") or {}
|
||||
previous_observations = (
|
||||
previous_configuration or {}
|
||||
).get("host_observations") or {}
|
||||
loads_recorded = bool(
|
||||
current_observations.get("load_average") and
|
||||
previous_observations.get("load_average")
|
||||
)
|
||||
return {
|
||||
"previous_run_id": previous.get("run_id"),
|
||||
"geometry_written_delta": current_totals["geometry_written"] -
|
||||
int(previous_totals.get("geometry_written") or 0),
|
||||
"geometry_skipped_delta": current_totals["geometry_skipped"] -
|
||||
int(previous_totals.get("geometry_skipped") or 0),
|
||||
"clean_files_delta": current_totals["clean_files"] -
|
||||
int(previous_totals.get("clean_files") or 0),
|
||||
"regressed_clean_files": [
|
||||
item["input"] for item in changes if item.get("regressed_from_clean")
|
||||
],
|
||||
"newly_clean_files": [
|
||||
item["input"] for item in changes if item.get("became_clean")
|
||||
],
|
||||
"inconclusive_files": [
|
||||
item["input"] for item in changes
|
||||
if item.get("change") == "inconclusive"
|
||||
],
|
||||
"configuration": {
|
||||
"current_options": (current_configuration or {}).get("options"),
|
||||
"previous_options": (previous_configuration or {}).get("options"),
|
||||
"converter_options_match": options_match,
|
||||
"host_identity_match": hosts_match,
|
||||
"input_hashes_verified": input_hashes_verified,
|
||||
"load_observations_recorded": loads_recorded,
|
||||
"timing_context_comparable": options_match and hosts_match and
|
||||
input_hashes_verified and
|
||||
loads_recorded,
|
||||
"executable_match": bool(
|
||||
(current_configuration or {}).get("step_g") and
|
||||
(previous_configuration or {}).get("step_g")
|
||||
) and converter_identity(current_configuration) ==
|
||||
converter_identity(previous_configuration),
|
||||
"current_outer_timeout_seconds": (
|
||||
(current_configuration or {}).get("options") or {}
|
||||
).get("timeout_seconds"),
|
||||
"previous_outer_timeout_seconds": (
|
||||
(previous_configuration or {}).get("options") or {}
|
||||
).get("timeout_seconds"),
|
||||
"current_host_identity": host_identity(current_configuration),
|
||||
"previous_host_identity": host_identity(previous_configuration),
|
||||
"current_host_observations": current_observations,
|
||||
"previous_host_observations": previous_observations,
|
||||
},
|
||||
"performance": performance_comparison(
|
||||
comparable_current, comparable_previous
|
||||
),
|
||||
"changes": changes,
|
||||
}
|
||||
|
||||
|
||||
def run_configuration(run_directory):
|
||||
path = Path(run_directory) / "run-manifest.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
manifest = read_json(path)
|
||||
return {
|
||||
"manifest": str(path.resolve()),
|
||||
"options": manifest.get("options"),
|
||||
"step_g": manifest.get("step_g"),
|
||||
"schema_plugins": manifest.get("schema_plugins"),
|
||||
"linked_libraries": manifest.get("linked_libraries"),
|
||||
"host": manifest.get("host"),
|
||||
"host_observations": host_observations(manifest),
|
||||
"started_utc": manifest.get("started_utc"),
|
||||
"finished_utc": manifest.get("finished_utc"),
|
||||
}
|
||||
|
||||
|
||||
def attach_input_identities(files, run_directory):
|
||||
path = Path(run_directory) / "run-manifest.json"
|
||||
if not path.is_file():
|
||||
return
|
||||
manifest = read_json(path)
|
||||
identities = {
|
||||
record.get("input"): record.get("input_identity") or {}
|
||||
for record in manifest.get("files") or []
|
||||
}
|
||||
for item in files:
|
||||
identity = identities.get(item["input"]) or {}
|
||||
if identity.get("sha256"):
|
||||
item["input_sha256"] = identity["sha256"]
|
||||
item["input_size"] = identity.get("size")
|
||||
|
||||
|
||||
def markdown(summary):
|
||||
total = summary["totals"]
|
||||
def count_list(values):
|
||||
return ", ".join(
|
||||
"{} {}".format(count, name)
|
||||
for name, count in sorted(
|
||||
values.items(), key=lambda item: (-item[1], item[0])
|
||||
)
|
||||
)
|
||||
|
||||
lines = [
|
||||
"# STEP corpus run {}".format(summary["run_id"]),
|
||||
"",
|
||||
"Revision: `{}`{}".format(
|
||||
summary["revision"], " (dirty)" if summary.get("dirty") else ""
|
||||
),
|
||||
"",
|
||||
"| Schema | Files clean | Intentional empty | Geometry written | Success |",
|
||||
"|---|---:|---:|---:|---:|",
|
||||
]
|
||||
for schema, values in sorted(total["by_schema"].items()):
|
||||
lines.append("| {} | {}/{} | {} | {}/{} | {:.2%} |".format(
|
||||
schema, values["clean_files"], values["completed_files"],
|
||||
values["intentionally_empty_files"],
|
||||
values["geometry_written"], values["geometry_attempted"],
|
||||
values["success_fraction"],
|
||||
))
|
||||
lines.append("| Combined | {}/{} | {} | {}/{} | {:.2%} |".format(
|
||||
total["clean_files"], total["completed_files"],
|
||||
total["intentionally_empty_files"], total["geometry_written"],
|
||||
total["geometry_attempted"], total["success_fraction"],
|
||||
))
|
||||
lines.extend([
|
||||
"",
|
||||
"Assembly coverage: {} product(s), {} occurrence(s), maximum depth "
|
||||
"{}, using {}.".format(
|
||||
total["products"], total["occurrences"],
|
||||
total["occurrence_max_depth"],
|
||||
", ".join(total["occurrence_methods"]) or "no occurrence method",
|
||||
),
|
||||
"Presentation and metadata: {} style(s) extracted, {} applied, {} "
|
||||
"layer(s), {} material(s), {} property value(s), and {} retained "
|
||||
"configuration record(s).".format(
|
||||
total["styles_extracted"], total["styles_applied"],
|
||||
total["layers_extracted"], total["materials_extracted"],
|
||||
total["properties_extracted"],
|
||||
total["configuration_record_count"],
|
||||
),
|
||||
"Validation: {} invalid B-Rep(s), {} preserved and {} rejected; {} "
|
||||
"bounded repair(s) and {} tagged curve inference(s) applied.".format(
|
||||
total["invalid_breps"], total["invalid_breps_written"],
|
||||
total["invalid_breps_rejected"],
|
||||
total["repairs"],
|
||||
total["inferred_curves"],
|
||||
),
|
||||
])
|
||||
if total["failure_categories"]:
|
||||
lines.append("Geometry omission categories: {}.".format(
|
||||
count_list(total["failure_categories"])
|
||||
))
|
||||
lines.append("Converter terminal statuses: {}.".format(
|
||||
count_list(total["converter_exit_status_counts"])
|
||||
))
|
||||
if total["unsupported_entity_counts"]:
|
||||
lines.append("Unsupported STEP entity inventory: {}.".format(
|
||||
count_list(total["unsupported_entity_counts"])
|
||||
))
|
||||
observations = ((summary.get("run_configuration") or {}).get(
|
||||
"host_observations") or {})
|
||||
one_minute = (observations.get("load_average") or {}).get("one_minute")
|
||||
if one_minute:
|
||||
lines.extend([
|
||||
"",
|
||||
"Host one-minute load over {} boundary samples: {:.2f} mean "
|
||||
"({:.2f}-{:.2f}).".format(
|
||||
observations["snapshot_count"], one_minute["mean"],
|
||||
one_minute["minimum"], one_minute["maximum"],
|
||||
),
|
||||
])
|
||||
if total["inconclusive_files"]:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Inconclusive (timed out or interrupted): {} file(s); partial "
|
||||
"geometry is excluded from totals and deltas.".format(
|
||||
total["inconclusive_files"]
|
||||
)
|
||||
)
|
||||
lines.extend([
|
||||
"", "## Per file", "",
|
||||
"| Schema | Input | Status | Written | Skipped | Inferred | Reported load s | "
|
||||
"Reported convert s | Wall s | Peak RSS MiB |",
|
||||
"|---|---|---|---:|---:|---:|---:|---:|---:|---:|",
|
||||
])
|
||||
for item in summary["files"]:
|
||||
lines.append("| {} | {} | {} | {}/{} | {} | {} | {:.1f} | {:.1f} | {:.1f} | {:.1f} |".format(
|
||||
item["schema"], item["name"].replace("|", "\\|"),
|
||||
"clean" if item["clean"] else
|
||||
("intentional empty" if item.get("intentionally_empty") else
|
||||
("partial" if run_complete(item) else "inconclusive")),
|
||||
item["geometry_written"], item["geometry_attempted"],
|
||||
item["geometry_skipped"], item.get("inferred_curves", 0),
|
||||
item["load_us"] / 1.0e6,
|
||||
item["convert_us"] / 1.0e6, item["elapsed_seconds"],
|
||||
item["peak_rss_bytes"] / (1024.0 * 1024.0),
|
||||
))
|
||||
delta = summary.get("delta")
|
||||
if delta:
|
||||
lines.extend([
|
||||
"", "## Delta", "",
|
||||
"Written geometry: {:+d}; skipped geometry: {:+d}; clean files: {:+d}.".format(
|
||||
delta["geometry_written_delta"], delta["geometry_skipped_delta"],
|
||||
delta["clean_files_delta"],
|
||||
),
|
||||
])
|
||||
if delta["regressed_clean_files"]:
|
||||
lines.append("Regressed clean files: {}.".format(
|
||||
", ".join(delta["regressed_clean_files"])
|
||||
))
|
||||
if delta["inconclusive_files"]:
|
||||
lines.append("Not compared because the run was incomplete: {}.".format(
|
||||
", ".join(delta["inconclusive_files"])
|
||||
))
|
||||
performance = delta.get("performance") or {}
|
||||
ratios = performance.get("aggregate_ratios") or {}
|
||||
if performance.get("comparable_files"):
|
||||
lines.extend([
|
||||
"",
|
||||
"Performance over {} comparable files (current/previous): "
|
||||
"wall {:.3f}x and peak-file RSS {:.3f}x. Reported phase "
|
||||
"ratios are load {:.3f}x and conversion {:.3f}x; phase "
|
||||
"boundaries may differ across binaries.".format(
|
||||
performance["comparable_files"],
|
||||
ratios.get("elapsed") or 0.0,
|
||||
ratios.get("peak_file_rss") or 0.0,
|
||||
ratios.get("load") or 0.0,
|
||||
ratios.get("convert") or 0.0,
|
||||
),
|
||||
])
|
||||
configuration = delta.get("configuration") or {}
|
||||
if not configuration.get("converter_options_match"):
|
||||
lines.append(
|
||||
"Run options differ or could not be verified; performance "
|
||||
"ratios are informational only."
|
||||
)
|
||||
if not configuration.get("host_identity_match"):
|
||||
lines.append(
|
||||
"Host identity differs or could not be verified; timing "
|
||||
"ratios are not controlled benchmarks."
|
||||
)
|
||||
if not configuration.get("input_hashes_verified"):
|
||||
lines.append(
|
||||
"Input hashes differ or were not recorded in both runs; "
|
||||
"timing ratios are informational only."
|
||||
)
|
||||
if not configuration.get("load_observations_recorded"):
|
||||
lines.append(
|
||||
"Both runs did not record host-load observations; "
|
||||
"timing ratios are not controlled benchmarks."
|
||||
)
|
||||
else:
|
||||
current_load = configuration["current_host_observations"][
|
||||
"load_average"
|
||||
]["one_minute"]["mean"]
|
||||
previous_load = configuration["previous_host_observations"][
|
||||
"load_average"
|
||||
]["one_minute"]["mean"]
|
||||
lines.append(
|
||||
"Recorded one-minute load mean was {:.2f} current versus "
|
||||
"{:.2f} previous.".format(current_load, previous_load)
|
||||
)
|
||||
if configuration.get("timing_context_comparable"):
|
||||
lines.append(
|
||||
"Host, input hashes, converter options, and host-load "
|
||||
"telemetry are comparable; unrecorded background activity "
|
||||
"may still affect timings."
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("run_directory", type=Path)
|
||||
parser.add_argument("--previous", type=Path,
|
||||
help="previous corpus-summary.json for delta reporting")
|
||||
parser.add_argument("--output", type=Path,
|
||||
help="output JSON (default: RUN_DIRECTORY/corpus-summary.json)")
|
||||
parser.add_argument("--run-id", help="stable run label; defaults to directory name")
|
||||
parser.add_argument("--no-markdown", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
run_directory = args.run_directory.resolve()
|
||||
status_paths = sorted(run_directory.glob("*.status"))
|
||||
if not status_paths:
|
||||
parser.error("{} contains no *.status corpus records".format(run_directory))
|
||||
try:
|
||||
files = [report_record(path) for path in status_paths]
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print("step_corpus_summary.py: {}".format(error), file=sys.stderr)
|
||||
return 2
|
||||
attach_input_identities(files, run_directory)
|
||||
|
||||
revision, dirty = git_state(Path(__file__).resolve().parents[2])
|
||||
previous = read_json(args.previous) if args.previous else None
|
||||
current_configuration = run_configuration(run_directory)
|
||||
previous_configuration = run_configuration(
|
||||
previous.get("run_directory")
|
||||
) if previous and previous.get("run_directory") else None
|
||||
generated = datetime.datetime.now(datetime.timezone.utc).replace(
|
||||
microsecond=0
|
||||
).isoformat()
|
||||
summary = {
|
||||
"format": FORMAT,
|
||||
"run_id": args.run_id or run_directory.name,
|
||||
"generated_utc": generated,
|
||||
"run_directory": str(run_directory),
|
||||
"revision": revision,
|
||||
"dirty": dirty,
|
||||
"run_configuration": current_configuration,
|
||||
"totals": totals(files),
|
||||
"files": files,
|
||||
}
|
||||
summary["delta"] = compare(
|
||||
files, previous, current_configuration, previous_configuration
|
||||
)
|
||||
|
||||
output = args.output or (run_directory / "corpus-summary.json")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output.open("w", encoding="utf-8") as stream:
|
||||
json.dump(summary, stream, indent=2, sort_keys=True)
|
||||
stream.write("\n")
|
||||
if not args.no_markdown:
|
||||
output.with_suffix(".md").write_text(markdown(summary), encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify a recorded STEP corpus run against checksum-pinned expectations.
|
||||
|
||||
External STEP samples are often valuable but not suitable for copying into
|
||||
BRL-CAD. This tool keeps their evidence reproducible without copying the
|
||||
files: an expectation manifest records the source, accepted local filenames,
|
||||
SHA-256 digest, and report assertions. The verifier consumes the structured
|
||||
reports and run manifest produced by ``step_corpus_run.py``.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FORMAT = "brlcad-step-corpus-verification-v1"
|
||||
|
||||
|
||||
def read_json(path):
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
return json.load(stream)
|
||||
|
||||
|
||||
def file_sha256(path):
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def dotted_value(document, path):
|
||||
value = document
|
||||
for component in path.split("."):
|
||||
if not isinstance(value, dict) or component not in value:
|
||||
raise KeyError(path)
|
||||
value = value[component]
|
||||
return value
|
||||
|
||||
|
||||
def occurrence_depth(occurrences):
|
||||
children = {}
|
||||
vertices = set()
|
||||
for occurrence in occurrences:
|
||||
parent = int(occurrence.get("parent_product_id") or 0)
|
||||
child = int(occurrence.get("child_product_id") or 0)
|
||||
if parent <= 0 or child <= 0:
|
||||
continue
|
||||
children.setdefault(parent, set()).add(child)
|
||||
vertices.update((parent, child))
|
||||
|
||||
active = set()
|
||||
memo = {}
|
||||
|
||||
def visit(vertex):
|
||||
if vertex in memo:
|
||||
return memo[vertex]
|
||||
if vertex in active:
|
||||
raise ValueError("occurrence product graph contains a cycle")
|
||||
active.add(vertex)
|
||||
depth = max((1 + visit(child) for child in children.get(vertex, ())),
|
||||
default=0)
|
||||
active.remove(vertex)
|
||||
memo[vertex] = depth
|
||||
return depth
|
||||
|
||||
return max((visit(vertex) for vertex in vertices), default=0)
|
||||
|
||||
|
||||
def derived_values(report):
|
||||
occurrences = report.get("occurrence_details") or []
|
||||
metadata = report.get("product_metadata") or []
|
||||
materials = [
|
||||
material
|
||||
for product in metadata
|
||||
for material in product.get("materials") or []
|
||||
]
|
||||
material_properties = [
|
||||
prop
|
||||
for material in materials
|
||||
for prop in material.get("properties") or []
|
||||
]
|
||||
material_numeric_values = sorted({
|
||||
value
|
||||
for prop in material_properties
|
||||
for value in prop.get("values") or []
|
||||
if isinstance(value, (int, float))
|
||||
})
|
||||
return {
|
||||
"assembly_usage_count": len(report.get("assembly_usages") or []),
|
||||
"occurrence_detail_count": len(occurrences),
|
||||
"occurrence_max_depth": occurrence_depth(occurrences),
|
||||
"occurrence_methods": sorted({
|
||||
item.get("shape_method") or "" for item in occurrences
|
||||
}),
|
||||
"skipped_entity_ids": sorted({
|
||||
int(item.get("entity_id") or 0)
|
||||
for item in report.get("skipped_items") or []
|
||||
if int(item.get("entity_id") or 0) > 0
|
||||
}),
|
||||
"product_metadata_count": len(metadata),
|
||||
"material_count": len(materials),
|
||||
"material_identifiers": sorted({
|
||||
material.get("identifier") or "" for material in materials
|
||||
}),
|
||||
"material_names": sorted({
|
||||
material.get("name") or "" for material in materials
|
||||
}),
|
||||
"material_property_count": len(material_properties),
|
||||
"material_property_names": sorted({
|
||||
prop.get("name") or "" for prop in material_properties
|
||||
}),
|
||||
"material_property_units": sorted({
|
||||
prop.get("units") or "" for prop in material_properties
|
||||
if prop.get("units")
|
||||
}),
|
||||
"material_property_numeric_values": material_numeric_values,
|
||||
"validation_property_count": sum(
|
||||
len(product.get("validation_properties") or [])
|
||||
for product in metadata
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def compare_expected(report, expected):
|
||||
combined = dict(report)
|
||||
combined["derived"] = derived_values(report)
|
||||
checks = []
|
||||
errors = []
|
||||
for path, wanted in sorted((expected.get("equals") or {}).items()):
|
||||
try:
|
||||
actual = dotted_value(combined, path)
|
||||
except KeyError:
|
||||
errors.append("{} is missing".format(path))
|
||||
continue
|
||||
checks.append(path)
|
||||
if actual != wanted:
|
||||
errors.append("{} is {!r}, expected {!r}".format(
|
||||
path, actual, wanted
|
||||
))
|
||||
for path, wanted in sorted((expected.get("minimum") or {}).items()):
|
||||
try:
|
||||
actual = dotted_value(combined, path)
|
||||
except KeyError:
|
||||
errors.append("{} is missing".format(path))
|
||||
continue
|
||||
checks.append(path)
|
||||
if not isinstance(actual, (int, float)) or actual < wanted:
|
||||
errors.append("{} is {!r}, expected at least {!r}".format(
|
||||
path, actual, wanted
|
||||
))
|
||||
return checks, errors, combined["derived"]
|
||||
|
||||
|
||||
def markdown(result):
|
||||
lines = [
|
||||
"# STEP external corpus verification",
|
||||
"",
|
||||
"Expectation manifest: `{}`".format(result["expectations"]),
|
||||
"",
|
||||
"| Input | Source/producer | Result | Checks |",
|
||||
"|---|---|---:|---:|",
|
||||
]
|
||||
for item in result["files"]:
|
||||
source = item.get("producer") or item.get("source_page") or ""
|
||||
lines.append("| {} | {} | {} | {} |".format(
|
||||
item["name"].replace("|", "\\|"),
|
||||
source.replace("|", "\\|"),
|
||||
item["result"], len(item.get("checks") or []),
|
||||
))
|
||||
for error in item.get("errors") or []:
|
||||
lines.append(" - **{}:** {}".format(item["name"], error))
|
||||
lines.extend([
|
||||
"",
|
||||
"Verified {}/{} present files; {} manifest entries were missing.".format(
|
||||
result["passed_files"], result["present_files"],
|
||||
result["missing_files"],
|
||||
),
|
||||
])
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("run_directory", type=Path)
|
||||
parser.add_argument("expectations", type=Path)
|
||||
parser.add_argument("--allow-missing", action="store_true",
|
||||
help="do not fail for expectation entries absent from the run")
|
||||
parser.add_argument("--output", type=Path,
|
||||
help="output JSON (default: RUN/corpus-verification.json)")
|
||||
args = parser.parse_args()
|
||||
|
||||
run_directory = args.run_directory.resolve()
|
||||
manifest_path = run_directory / "run-manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
parser.error("{} does not contain run-manifest.json".format(run_directory))
|
||||
expectations_path = args.expectations.resolve()
|
||||
run = read_json(manifest_path)
|
||||
expectations = read_json(expectations_path)
|
||||
if expectations.get("format") != "brlcad-step-corpus-expectations-v1":
|
||||
parser.error("unsupported expectation manifest format")
|
||||
|
||||
run_files = {}
|
||||
for record in run.get("files") or []:
|
||||
input_name = Path(record.get("input") or "").name
|
||||
if input_name:
|
||||
run_files[input_name] = record
|
||||
|
||||
results = []
|
||||
passed = 0
|
||||
present = 0
|
||||
missing = 0
|
||||
for expectation in expectations.get("files") or []:
|
||||
names = [expectation["name"]] + list(expectation.get("aliases") or [])
|
||||
record = next((run_files[name] for name in names if name in run_files), None)
|
||||
item = {
|
||||
"name": expectation["name"],
|
||||
"source_page": expectation.get("source_page"),
|
||||
"producer": expectation.get("producer"),
|
||||
"checks": [],
|
||||
"errors": [],
|
||||
}
|
||||
if record is None:
|
||||
missing += 1
|
||||
item["result"] = "missing"
|
||||
if not args.allow_missing:
|
||||
item["errors"].append("input was not present in the corpus run")
|
||||
results.append(item)
|
||||
continue
|
||||
|
||||
present += 1
|
||||
input_path = Path(record["input"])
|
||||
actual_hash = file_sha256(input_path) if input_path.is_file() else None
|
||||
item["input"] = str(input_path)
|
||||
item["sha256"] = actual_hash
|
||||
item["checks"].append("sha256")
|
||||
recorded_hash = (record.get("input_identity") or {}).get("sha256")
|
||||
if recorded_hash and recorded_hash != actual_hash:
|
||||
item["errors"].append(
|
||||
"input changed after the run manifest recorded its SHA-256"
|
||||
)
|
||||
if actual_hash != expectation.get("sha256"):
|
||||
item["errors"].append("SHA-256 is {}, expected {}".format(
|
||||
actual_hash, expectation.get("sha256")
|
||||
))
|
||||
|
||||
report_name = record.get("report")
|
||||
report_path = run_directory / report_name if report_name else None
|
||||
if not report_path or not report_path.is_file():
|
||||
item["errors"].append("structured converter report is missing")
|
||||
else:
|
||||
report = read_json(report_path)
|
||||
checks, errors, derived = compare_expected(
|
||||
report, expectation.get("expected") or {}
|
||||
)
|
||||
item["checks"].extend(checks)
|
||||
item["errors"].extend(errors)
|
||||
item["derived"] = derived
|
||||
item["report"] = str(report_path)
|
||||
|
||||
item["result"] = "pass" if not item["errors"] else "fail"
|
||||
if item["result"] == "pass":
|
||||
passed += 1
|
||||
results.append(item)
|
||||
|
||||
failed = sum(1 for item in results if item.get("errors"))
|
||||
result = {
|
||||
"format": FORMAT,
|
||||
"expectations": str(expectations_path),
|
||||
"run_directory": str(run_directory),
|
||||
"passed_files": passed,
|
||||
"present_files": present,
|
||||
"missing_files": missing,
|
||||
"failed_files": failed,
|
||||
"files": results,
|
||||
}
|
||||
output = args.output or (run_directory / "corpus-verification.json")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8")
|
||||
output.with_suffix(".md").write_text(markdown(result), encoding="utf-8")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a small, forward-complete STEP geometry subset.
|
||||
|
||||
This helper is intended for local diagnosis and for identifying the minimal
|
||||
geometry that a project-owned synthetic regression test must reproduce. It
|
||||
copies the dependency closure of one or more STEP face entities and wraps them
|
||||
in an OPEN_SHELL product, retaining the source entity numbers so converter
|
||||
diagnostics remain directly comparable to the original file. A diagnostic
|
||||
--closed-shell mode instead uses CLOSED_SHELL/MANIFOLD_SOLID_BREP so reductions
|
||||
can exercise topology rules which only apply to asserted solids. A face subset
|
||||
is not necessarily a valid closed shell, so that mode is for failure reduction,
|
||||
not evidence that the extracted result is valid geometry. The output keeps the
|
||||
input model's provenance and is not automatically suitable for committing or
|
||||
redistribution.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ADVANCED_FACE = re.compile(
|
||||
r"^[ \t]*#[0-9]+[ \t]*=[ \t]*ADVANCED_FACE[ \t]*\(",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
FILE_SCHEMA = re.compile(
|
||||
r"FILE_SCHEMA\s*\(\s*\(\s*'([^']+)'", re.IGNORECASE | re.DOTALL
|
||||
)
|
||||
ENTITY_TYPE = re.compile(
|
||||
r"^[ \t]*#[0-9]+[ \t]*=[ \t]*([A-Z0-9_]+)[ \t]*\(",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def instance_end(text, start):
|
||||
"""Return the first record-ending semicolon outside strings/comments."""
|
||||
in_string = False
|
||||
in_comment = False
|
||||
offset = start
|
||||
while offset < len(text):
|
||||
current = text[offset]
|
||||
following = text[offset + 1] if offset + 1 < len(text) else ""
|
||||
if in_comment:
|
||||
if current == "*" and following == "/":
|
||||
in_comment = False
|
||||
offset += 2
|
||||
continue
|
||||
elif in_string:
|
||||
if current == "'":
|
||||
if following == "'":
|
||||
offset += 2
|
||||
continue
|
||||
in_string = False
|
||||
elif current == "/" and following == "*":
|
||||
in_comment = True
|
||||
offset += 2
|
||||
continue
|
||||
elif current == "'":
|
||||
in_string = True
|
||||
elif current == ";":
|
||||
return offset + 1
|
||||
offset += 1
|
||||
raise ValueError("unterminated STEP instance starting at byte {}".format(start))
|
||||
|
||||
|
||||
def instances(text):
|
||||
"""Return Part 21 instances without recognizing text in strings/comments."""
|
||||
result = {}
|
||||
offset = 0
|
||||
line_start = True
|
||||
in_string = False
|
||||
in_comment = False
|
||||
while offset < len(text):
|
||||
current = text[offset]
|
||||
following = text[offset + 1] if offset + 1 < len(text) else ""
|
||||
if in_comment:
|
||||
if current == "*" and following == "/":
|
||||
in_comment = False
|
||||
offset += 2
|
||||
continue
|
||||
elif in_string:
|
||||
if current == "'":
|
||||
if following == "'":
|
||||
offset += 2
|
||||
continue
|
||||
in_string = False
|
||||
elif current == "/" and following == "*":
|
||||
in_comment = True
|
||||
offset += 2
|
||||
continue
|
||||
elif current == "'":
|
||||
in_string = True
|
||||
elif current == "\n" or current == "\r":
|
||||
line_start = True
|
||||
elif line_start and current in " \t":
|
||||
pass
|
||||
elif line_start and current == "#":
|
||||
digit_end = offset + 1
|
||||
while digit_end < len(text) and text[digit_end].isdigit():
|
||||
digit_end += 1
|
||||
separator = digit_end
|
||||
while separator < len(text) and text[separator] in " \t":
|
||||
separator += 1
|
||||
if digit_end > offset + 1 and separator < len(text) and \
|
||||
text[separator] == "=":
|
||||
entity_id = int(text[offset + 1:digit_end])
|
||||
end = instance_end(text, offset)
|
||||
if entity_id in result:
|
||||
raise ValueError(
|
||||
"duplicate STEP entity #{}".format(entity_id)
|
||||
)
|
||||
result[entity_id] = text[offset:end].strip()
|
||||
offset = end
|
||||
line_start = False
|
||||
continue
|
||||
line_start = False
|
||||
else:
|
||||
line_start = False
|
||||
offset += 1
|
||||
return result
|
||||
|
||||
|
||||
def references(record):
|
||||
"""Yield entity references outside Part 21 strings and comments."""
|
||||
offset = 0
|
||||
in_string = False
|
||||
in_comment = False
|
||||
while offset < len(record):
|
||||
current = record[offset]
|
||||
following = record[offset + 1] if offset + 1 < len(record) else ""
|
||||
if in_comment:
|
||||
if current == "*" and following == "/":
|
||||
in_comment = False
|
||||
offset += 2
|
||||
continue
|
||||
elif in_string:
|
||||
if current == "'":
|
||||
if following == "'":
|
||||
offset += 2
|
||||
continue
|
||||
in_string = False
|
||||
elif current == "/" and following == "*":
|
||||
in_comment = True
|
||||
offset += 2
|
||||
continue
|
||||
elif current == "'":
|
||||
in_string = True
|
||||
elif current == "#":
|
||||
digit_end = offset + 1
|
||||
while digit_end < len(record) and record[digit_end].isdigit():
|
||||
digit_end += 1
|
||||
if digit_end > offset + 1:
|
||||
yield int(record[offset + 1:digit_end])
|
||||
offset = digit_end
|
||||
continue
|
||||
offset += 1
|
||||
|
||||
|
||||
def dependency_closure(records, roots):
|
||||
pending = list(roots)
|
||||
selected = set()
|
||||
while pending:
|
||||
entity_id = pending.pop()
|
||||
if entity_id in selected:
|
||||
continue
|
||||
record = records.get(entity_id)
|
||||
if record is None:
|
||||
raise ValueError("STEP entity #{} is not present".format(entity_id))
|
||||
selected.add(entity_id)
|
||||
for referenced_id in references(record):
|
||||
if referenced_id != entity_id and referenced_id not in selected:
|
||||
pending.append(referenced_id)
|
||||
return selected
|
||||
|
||||
|
||||
def record_type(record):
|
||||
match = ENTITY_TYPE.match(record)
|
||||
return match.group(1).upper() if match else ""
|
||||
|
||||
|
||||
def adjacent_faces(records, faces):
|
||||
"""Return ADVANCED_FACE entities sharing an EDGE_CURVE with faces."""
|
||||
reverse = {}
|
||||
for entity_id, record in records.items():
|
||||
for referenced_id in references(record):
|
||||
reverse.setdefault(referenced_id, set()).add(entity_id)
|
||||
|
||||
edge_curves = {
|
||||
entity_id
|
||||
for entity_id in dependency_closure(records, faces)
|
||||
if record_type(records[entity_id]) == "EDGE_CURVE"
|
||||
}
|
||||
oriented_edges = {
|
||||
entity_id
|
||||
for edge_id in edge_curves
|
||||
for entity_id in reverse.get(edge_id, ())
|
||||
if record_type(records[entity_id]) == "ORIENTED_EDGE"
|
||||
}
|
||||
edge_loops = {
|
||||
entity_id
|
||||
for oriented_id in oriented_edges
|
||||
for entity_id in reverse.get(oriented_id, ())
|
||||
if record_type(records[entity_id]) == "EDGE_LOOP"
|
||||
}
|
||||
face_bounds = {
|
||||
entity_id
|
||||
for loop_id in edge_loops
|
||||
for entity_id in reverse.get(loop_id, ())
|
||||
if record_type(records[entity_id]) in {
|
||||
"FACE_BOUND", "FACE_OUTER_BOUND"
|
||||
}
|
||||
}
|
||||
return {
|
||||
entity_id
|
||||
for bound_id in face_bounds
|
||||
for entity_id in reverse.get(bound_id, ())
|
||||
if record_type(records[entity_id]) == "ADVANCED_FACE"
|
||||
}
|
||||
|
||||
|
||||
def quoted(value):
|
||||
return value.replace("'", "''")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("input", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument(
|
||||
"--face",
|
||||
type=int,
|
||||
action="append",
|
||||
default=[],
|
||||
help="ADVANCED_FACE entity ID to retain (repeatable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--face-range",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="FIRST:LAST",
|
||||
help="inclusive contiguous ADVANCED_FACE ID range to retain (repeatable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--face-neighbor-depth",
|
||||
type=int,
|
||||
default=0,
|
||||
metavar="N",
|
||||
help=(
|
||||
"also retain ADVANCED_FACE neighbors reachable through N layers "
|
||||
"of shared EDGE_CURVEs"
|
||||
),
|
||||
)
|
||||
parser.add_argument("--name", default="reduced_step_regression")
|
||||
parser.add_argument(
|
||||
"--context",
|
||||
type=int,
|
||||
help=(
|
||||
"source GEOMETRIC_REPRESENTATION_CONTEXT entity ID to preserve; "
|
||||
"recommended when source angle or length units are not SI defaults"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--uncertainty",
|
||||
type=float,
|
||||
default=1.0e-6,
|
||||
help="fixture uncertainty in millimetres (default: 1e-6)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--closed-shell",
|
||||
action="store_true",
|
||||
help=(
|
||||
"wrap the face subset as CLOSED_SHELL/MANIFOLD_SOLID_BREP "
|
||||
"instead of OPEN_SHELL/SHELL_BASED_SURFACE_MODEL"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
faces = list(args.face)
|
||||
for face_range in args.face_range:
|
||||
fields = face_range.split(":", 1)
|
||||
if len(fields) != 2:
|
||||
parser.error("--face-range must be FIRST:LAST")
|
||||
try:
|
||||
first, last = (int(field) for field in fields)
|
||||
except ValueError:
|
||||
parser.error("--face-range must contain integer entity IDs")
|
||||
if first > last:
|
||||
parser.error("--face-range FIRST must not exceed LAST")
|
||||
faces.extend(range(first, last + 1))
|
||||
faces = sorted(set(faces))
|
||||
if not faces:
|
||||
parser.error("at least one --face or --face-range is required")
|
||||
if args.face_neighbor_depth < 0:
|
||||
parser.error("--face-neighbor-depth must be nonnegative")
|
||||
|
||||
source = args.input.read_text(encoding="latin-1")
|
||||
records = instances(source)
|
||||
for entity_id in faces:
|
||||
record = records.get(entity_id)
|
||||
if record is None:
|
||||
parser.error("STEP entity #{} is not present".format(entity_id))
|
||||
if not ADVANCED_FACE.match(record):
|
||||
parser.error(
|
||||
"STEP entity #{} is not an ADVANCED_FACE".format(entity_id)
|
||||
)
|
||||
expanded_faces = set(faces)
|
||||
frontier = set(faces)
|
||||
for _ in range(args.face_neighbor_depth):
|
||||
neighbors = adjacent_faces(records, frontier) - expanded_faces
|
||||
if not neighbors:
|
||||
break
|
||||
expanded_faces.update(neighbors)
|
||||
frontier = neighbors
|
||||
faces = sorted(expanded_faces)
|
||||
closure_roots = list(faces)
|
||||
if args.context is not None:
|
||||
closure_roots.append(args.context)
|
||||
selected = dependency_closure(records, closure_roots)
|
||||
schema_match = FILE_SCHEMA.search(source)
|
||||
schema = (
|
||||
schema_match.group(1)
|
||||
if schema_match
|
||||
else "AUTOMOTIVE_DESIGN { 1 0 10303 214 3 1 1 1 }"
|
||||
)
|
||||
|
||||
next_id = max(records) + 1
|
||||
wrapper_ids = list(range(next_id, next_id + 18))
|
||||
(
|
||||
shell_id,
|
||||
model_id,
|
||||
length_id,
|
||||
angle_id,
|
||||
solid_angle_id,
|
||||
uncertainty_id,
|
||||
context_id,
|
||||
application_id,
|
||||
protocol_id,
|
||||
product_context_id,
|
||||
definition_context_id,
|
||||
product_id,
|
||||
formation_id,
|
||||
definition_id,
|
||||
definition_shape_id,
|
||||
representation_id,
|
||||
shape_definition_id,
|
||||
_unused_id,
|
||||
) = wrapper_ids
|
||||
|
||||
safe_name = quoted(args.name)
|
||||
face_refs = ",".join("#{}".format(entity_id) for entity_id in faces)
|
||||
if args.closed_shell:
|
||||
wrapper = [
|
||||
"#{}=CLOSED_SHELL('{} shell',({}));".format(
|
||||
shell_id, safe_name, face_refs
|
||||
),
|
||||
"#{}=MANIFOLD_SOLID_BREP('{} model',#{});".format(
|
||||
model_id, safe_name, shell_id
|
||||
),
|
||||
]
|
||||
else:
|
||||
wrapper = [
|
||||
"#{}=OPEN_SHELL('{} shell',({}));".format(
|
||||
shell_id, safe_name, face_refs
|
||||
),
|
||||
"#{}=SHELL_BASED_SURFACE_MODEL('{} model',(#{}));".format(
|
||||
model_id, safe_name, shell_id
|
||||
),
|
||||
]
|
||||
if args.context is None:
|
||||
wrapper.extend(
|
||||
[
|
||||
"#{}=(LENGTH_UNIT() NAMED_UNIT(*) "
|
||||
"SI_UNIT(.MILLI.,.METRE.));".format(length_id),
|
||||
"#{}=(NAMED_UNIT(*) PLANE_ANGLE_UNIT() "
|
||||
"SI_UNIT($,.RADIAN.));".format(angle_id),
|
||||
"#{}=(NAMED_UNIT(*) SI_UNIT($,.STERADIAN.) "
|
||||
"SOLID_ANGLE_UNIT());".format(solid_angle_id),
|
||||
"#{}=UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({:.17g}),"
|
||||
"#{},'distance_accuracy_value','maximum gap value');".format(
|
||||
uncertainty_id, args.uncertainty, length_id
|
||||
),
|
||||
"#{}=(GEOMETRIC_REPRESENTATION_CONTEXT(3) "
|
||||
"GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{})) "
|
||||
"GLOBAL_UNIT_ASSIGNED_CONTEXT((#{},#{},#{})) "
|
||||
"REPRESENTATION_CONTEXT('fixture','3D'));".format(
|
||||
context_id,
|
||||
uncertainty_id,
|
||||
length_id,
|
||||
angle_id,
|
||||
solid_angle_id,
|
||||
),
|
||||
]
|
||||
)
|
||||
representation_context_id = (
|
||||
args.context if args.context is not None else context_id
|
||||
)
|
||||
wrapper.extend(
|
||||
[
|
||||
"#{}=APPLICATION_CONTEXT('automotive design');".format(application_id),
|
||||
"#{}=APPLICATION_PROTOCOL_DEFINITION("
|
||||
"'fixture','automotive_design',2010,#{});".format(
|
||||
protocol_id, application_id
|
||||
),
|
||||
"#{}=PRODUCT_CONTEXT('',#{},'mechanical');".format(
|
||||
product_context_id, application_id
|
||||
),
|
||||
"#{}=PRODUCT_DEFINITION_CONTEXT('part definition',#{},'design');".format(
|
||||
definition_context_id, application_id
|
||||
),
|
||||
"#{}=PRODUCT('{}','{}','reduced corpus regression fixture',(#{}));".format(
|
||||
product_id, safe_name, safe_name, product_context_id
|
||||
),
|
||||
"#{}=PRODUCT_DEFINITION_FORMATION_WITH_SPECIFIED_SOURCE("
|
||||
"'1','',#{},.MADE.);".format(formation_id, product_id),
|
||||
"#{}=PRODUCT_DEFINITION('design','',#{},#{});".format(
|
||||
definition_id, formation_id, definition_context_id
|
||||
),
|
||||
"#{}=PRODUCT_DEFINITION_SHAPE('','',#{});".format(
|
||||
definition_shape_id, definition_id
|
||||
),
|
||||
"#{}={}('',(#{}),#{});".format(
|
||||
representation_id,
|
||||
"ADVANCED_BREP_SHAPE_REPRESENTATION" if args.closed_shell else
|
||||
"MANIFOLD_SURFACE_SHAPE_REPRESENTATION",
|
||||
model_id,
|
||||
representation_context_id,
|
||||
),
|
||||
"#{}=SHAPE_DEFINITION_REPRESENTATION(#{},#{});".format(
|
||||
shape_definition_id, definition_shape_id, representation_id
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
lines = [
|
||||
"ISO-10303-21;",
|
||||
"HEADER;",
|
||||
"FILE_DESCRIPTION(('BRL-CAD reduced corpus regression fixture'),'2;1');",
|
||||
"FILE_NAME('{}','',('BRL-CAD'),('BRL-CAD'),'','','');".format(safe_name),
|
||||
"FILE_SCHEMA(('{}'));".format(quoted(schema)),
|
||||
"ENDSEC;",
|
||||
"DATA;",
|
||||
]
|
||||
lines.extend(records[entity_id] for entity_id in sorted(selected))
|
||||
lines.extend(wrapper)
|
||||
lines.extend(("ENDSEC;", "END-ISO-10303-21;", ""))
|
||||
args.output.write_text("\n".join(lines), encoding="latin-1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"format": "brlcad-step-corpus-expectations-v1",
|
||||
"description": "Checksum-pinned external samples used for independent producer qualification without copying their exchange files into BRL-CAD.",
|
||||
"redistribution": "The linked STEP Tools pages state All Rights Reserved, while the STEPcode collective work is distributed under its 3-clause BSD COPYING terms. Obtain each sample from its stated source; this manifest does not independently grant redistribution rights.",
|
||||
"files": [
|
||||
{
|
||||
"name": "cubsomcy.stp",
|
||||
"aliases": ["ap203_cubsomcy.stp"],
|
||||
"sha256": "eb8fb40f56523fb154b435066f7b2719a88e15e30d0b8d8e6d01dd99339ddcf1",
|
||||
"source_page": "https://www.steptools.com/docs/stpfiles/ap203/index.html",
|
||||
"producer": "CATIA / Alcatel Space Industries",
|
||||
"expected": {
|
||||
"equals": {
|
||||
"exit_status": 0,
|
||||
"coverage.products": 1,
|
||||
"coverage.occurrences": 0,
|
||||
"coverage.geometry_attempted": 2,
|
||||
"coverage.geometry_written": 2,
|
||||
"coverage.geometry_skipped": 0,
|
||||
"coverage.outcome": "complete",
|
||||
"coverage.entity_counts.ADVANCED_BREP_SHAPE_REPRESENTATION": 1,
|
||||
"coverage.entity_counts.FACETED_BREP_SHAPE_REPRESENTATION": 1,
|
||||
"derived.occurrence_max_depth": 0,
|
||||
"derived.skipped_entity_ids": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "moon_buggy_asm.stp",
|
||||
"aliases": ["ap203_moon_buggy_asm.stp"],
|
||||
"sha256": "2afaaf50cd7ec15e36367be0a1e41730e7af5440ed974b62a0a21cf6346289dd",
|
||||
"source_page": "https://www.steptools.com/docs/stpfiles/ap203/index.html",
|
||||
"producer": "STEPNet / EDS Unigraphics 10.5",
|
||||
"expected": {
|
||||
"equals": {
|
||||
"exit_status": 1,
|
||||
"coverage.products": 20,
|
||||
"coverage.occurrences": 23,
|
||||
"coverage.geometry_attempted": 12,
|
||||
"coverage.geometry_written": 9,
|
||||
"coverage.geometry_skipped": 3,
|
||||
"coverage.outcome": "partial",
|
||||
"coverage.entity_counts.MAPPED_ITEM": 23,
|
||||
"derived.assembly_usage_count": 23,
|
||||
"derived.occurrence_detail_count": 23,
|
||||
"derived.occurrence_max_depth": 4,
|
||||
"derived.occurrence_methods": ["mapped"],
|
||||
"derived.skipped_entity_ids": [32060, 32220, 32820]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "123Block_Color.stp",
|
||||
"aliases": ["ap203e2_123Block_Color.stp"],
|
||||
"sha256": "4bbdd752fd3c4e7ebfee633da210b9e3df62c583a604e874ca5e9c36545d2be4",
|
||||
"source_page": "https://www.steptools.com/docs/stpfiles/ap203e2/index.html",
|
||||
"producer": "CATIA / Boeing",
|
||||
"expected": {
|
||||
"equals": {
|
||||
"exit_status": 0,
|
||||
"coverage.products": 1,
|
||||
"coverage.geometry_attempted": 1,
|
||||
"coverage.geometry_written": 1,
|
||||
"coverage.geometry_skipped": 0,
|
||||
"coverage.outcome": "complete",
|
||||
"coverage.styles_extracted": 1,
|
||||
"coverage.styles_applied": 1,
|
||||
"derived.skipped_entity_ids": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "boxy_with_linearsize.stp",
|
||||
"aliases": ["ap203e2_boxy_with_linearsize.stp"],
|
||||
"sha256": "e120b982c5bb5c8b62c99bb03d5ae46c16d9200fcb53be6521c201be98ead6d6",
|
||||
"source_page": "https://www.steptools.com/docs/stpfiles/ap214/index.html",
|
||||
"producer": "STEP-NC Machine / STEP Tools, Inc.",
|
||||
"expected": {
|
||||
"equals": {
|
||||
"exit_status": 3,
|
||||
"coverage.products": 1,
|
||||
"coverage.geometry_attempted": 1,
|
||||
"coverage.geometry_written": 0,
|
||||
"coverage.geometry_skipped": 1,
|
||||
"coverage.outcome": "failed",
|
||||
"coverage.entity_counts.DATUM": 3,
|
||||
"coverage.entity_counts.PLUS_MINUS_TOLERANCE": 1,
|
||||
"derived.skipped_entity_ids": [26]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "as1-ac-214.stp",
|
||||
"aliases": ["ap214_as1-ac-214.stp"],
|
||||
"sha256": "1bb1a0e55dc4a0169e329529eb520596690f8745077c1bdd2f9fa41fa5a2aa95",
|
||||
"source_page": "https://www.steptools.com/docs/stpfiles/ap214/index.html",
|
||||
"producer": "AutoCAD / STEPnet",
|
||||
"expected": {
|
||||
"equals": {
|
||||
"exit_status": 0,
|
||||
"coverage.products": 9,
|
||||
"coverage.occurrences": 13,
|
||||
"coverage.geometry_attempted": 5,
|
||||
"coverage.geometry_written": 5,
|
||||
"coverage.geometry_skipped": 0,
|
||||
"coverage.outcome": "complete",
|
||||
"coverage.styles_extracted": 5,
|
||||
"coverage.styles_applied": 5,
|
||||
"coverage.layers_extracted": 1,
|
||||
"derived.assembly_usage_count": 13,
|
||||
"derived.occurrence_detail_count": 13,
|
||||
"derived.occurrence_max_depth": 3,
|
||||
"derived.occurrence_methods": ["referenced"],
|
||||
"derived.skipped_entity_ids": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "as1-ug-214.stp",
|
||||
"aliases": ["ap214_as1-ug-214.stp"],
|
||||
"sha256": "53c5069f5d6b59435eb32ac743cc82e53bd3607954c94785d989d579d0e29426",
|
||||
"source_page": "https://www.steptools.com/docs/stpfiles/ap214/index.html",
|
||||
"producer": "Unigraphics / STEPnet",
|
||||
"expected": {
|
||||
"equals": {
|
||||
"exit_status": 0,
|
||||
"coverage.products": 9,
|
||||
"coverage.occurrences": 13,
|
||||
"coverage.geometry_attempted": 5,
|
||||
"coverage.geometry_written": 5,
|
||||
"coverage.geometry_skipped": 0,
|
||||
"coverage.outcome": "complete",
|
||||
"coverage.styles_extracted": 5,
|
||||
"coverage.styles_applied": 5,
|
||||
"coverage.layers_extracted": 3,
|
||||
"coverage.properties_extracted": 42,
|
||||
"derived.assembly_usage_count": 13,
|
||||
"derived.occurrence_detail_count": 13,
|
||||
"derived.occurrence_max_depth": 3,
|
||||
"derived.occurrence_methods": ["referenced"],
|
||||
"derived.validation_property_count": 42,
|
||||
"derived.skipped_entity_ids": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dm1-id-214.stp",
|
||||
"sha256": "25e727cdc4d738064da7907d604bd4ceeb4e941c6fdc201f3df04c0b94671661",
|
||||
"source_page": "STEPcode data/ap214e3/dm1-id-214.stp (3-clause BSD collective work)",
|
||||
"producer": "I-DEAS Master Series 9 / SDRC",
|
||||
"expected": {
|
||||
"equals": {
|
||||
"exit_status": 0,
|
||||
"coverage.products": 7,
|
||||
"coverage.occurrences": 7,
|
||||
"coverage.geometry_attempted": 3,
|
||||
"coverage.geometry_written": 3,
|
||||
"coverage.geometry_skipped": 0,
|
||||
"coverage.outcome": "complete",
|
||||
"coverage.styles_extracted": 3,
|
||||
"coverage.styles_applied": 3,
|
||||
"coverage.materials_extracted": 3,
|
||||
"coverage.properties_extracted": 22,
|
||||
"coverage.entity_counts.MAKE_FROM_USAGE_OPTION": 3,
|
||||
"derived.assembly_usage_count": 7,
|
||||
"derived.occurrence_detail_count": 7,
|
||||
"derived.occurrence_max_depth": 1,
|
||||
"derived.occurrence_methods": ["referenced"],
|
||||
"derived.material_count": 3,
|
||||
"derived.material_identifiers": ["AMS 4928", "AMS 5613", "AMS 5662"],
|
||||
"derived.material_names": ["Greek Ascoloy", "Inconel 718", "Titanium 6-4"],
|
||||
"derived.validation_property_count": 19,
|
||||
"derived.skipped_entity_ids": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"format": "brlcad-step-corpus-expectations-v1",
|
||||
"description": "Checksum-pinned independent-producer samples from locally retained, non-redistributed corpora whose original download pages were not recorded.",
|
||||
"redistribution": "These records provide reproducible local qualification evidence only. They do not grant permission to copy the exchange files into BRL-CAD or redistribute them.",
|
||||
"files": [
|
||||
{
|
||||
"name": "Leopard2-A6.step",
|
||||
"sha256": "ef8f21f6454f7d952b619ed1e41dab824f31f393e81a3e138392c53605a0ab94",
|
||||
"source_page": "Local non-redistributed GrabCAD corpus; original download page not recorded",
|
||||
"producer": "Autodesk Inventor 2018 / ST-Developer v17",
|
||||
"expected": {
|
||||
"equals": {
|
||||
"exit_status": 1,
|
||||
"coverage.products": 14,
|
||||
"coverage.occurrences": 338,
|
||||
"coverage.geometry_attempted": 31,
|
||||
"coverage.geometry_written": 31,
|
||||
"coverage.geometry_skipped": 0,
|
||||
"coverage.outcome": "partial",
|
||||
"coverage.styles_extracted": 2425,
|
||||
"coverage.styles_applied": 31,
|
||||
"coverage.materials_extracted": 13,
|
||||
"coverage.properties_extracted": 26,
|
||||
"coverage.properties_invalid": 13,
|
||||
"validation.invalid_breps": 1,
|
||||
"validation.invalid_breps_written": 1,
|
||||
"validation.invalid_breps_rejected": 0,
|
||||
"derived.assembly_usage_count": 338,
|
||||
"derived.occurrence_detail_count": 338,
|
||||
"derived.occurrence_max_depth": 1,
|
||||
"derived.occurrence_methods": ["referenced"],
|
||||
"derived.product_metadata_count": 13,
|
||||
"derived.material_count": 13,
|
||||
"derived.material_identifiers": [""],
|
||||
"derived.material_names": ["Generic", "SAE 1020"],
|
||||
"derived.material_property_count": 26,
|
||||
"derived.material_property_names": ["Generic", "SAE 1020", "density measure"],
|
||||
"derived.material_property_units": ["gram^1*centimetre^3"],
|
||||
"derived.material_property_numeric_values": [1, 7.84],
|
||||
"derived.validation_property_count": 0,
|
||||
"derived.skipped_entity_ids": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
Remaining STEP Import/Export Work
|
||||
=================================
|
||||
|
||||
Status: 2026-08-05
|
||||
|
||||
|
||||
Purpose
|
||||
-------
|
||||
|
||||
This is the current implementation and qualification plan for the public
|
||||
step-g and g-step converters. It replaces recommended_practices_todo.txt,
|
||||
save_invalid_plan.txt, and step_refactor.txt. It is not a development
|
||||
changelog: completed work is summarized only to define the supported baseline,
|
||||
and the detailed sections describe work which remains.
|
||||
|
||||
Status terms used below are:
|
||||
|
||||
* COMPLETE -- implemented and covered by focused tests;
|
||||
* ACTIVE -- being qualified or reduced from real inputs;
|
||||
* REMAINING -- useful work with a defined scope;
|
||||
* BLOCKED -- requires a missing prerequisite and must not be approximated; and
|
||||
* DEFERRED -- outside the present converter objective.
|
||||
|
||||
A feature is complete only when schema-valid fixtures, permissive and strict
|
||||
policy tests, and structured report assertions prove that geometry or semantics
|
||||
did not disappear. Independent-producer evidence is required wherever a
|
||||
BRL-CAD export and re-import could otherwise repeat the same mistake.
|
||||
|
||||
|
||||
Supported Baseline
|
||||
------------------
|
||||
|
||||
Architecture and schemas
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The public converter architecture is COMPLETE:
|
||||
|
||||
* step-g and g-step are schema-dispatching hosts;
|
||||
* AP203 edition 1, AP203 edition 2, AP214 edition 3, and AP242 editions 1
|
||||
through 4 are bidirectional plugins;
|
||||
* AP242 generated bindings remain edition-specific because the entity sets and
|
||||
some Part 21 physical layouts differ;
|
||||
* the AP242 export alias targets edition 4, while automatic import probes
|
||||
editions 4 through 1;
|
||||
* common conversion behavior is schema-neutral and schema-only behavior is
|
||||
under ap203, ap203e2, ap214, or ap242; and
|
||||
* obsolete schema-specific public converter programs are not built.
|
||||
|
||||
Explicit --schema ap242e1 through ap242e4 selection remains useful when a file
|
||||
uses only the editions' common physical subset. Interim identifiers
|
||||
CONFIGURATION_CONTROL_3D_DESIGN_MIM_LF, CCD_CLA_GVP_AST, and
|
||||
CCD_CLA_GVP_AST_ASD route to AP203e2. Supported multi-schema AP203e2 headers
|
||||
also route correctly; unknown companion schemas remain visible, and genuinely
|
||||
ambiguous supported-AP combinations fail rather than guessing.
|
||||
|
||||
The STEPcode lazy reader supports multiple DATA sections, SCOPE,
|
||||
source-ordered complex components, bounded diagnostics, type census output,
|
||||
and schema-independent retention of unknown configuration records. AP203
|
||||
Recommended Practice 8 fixtures cover referenced, mapped, and replicated
|
||||
shapes, multi-level hierarchy, SHUO and usage semantics, multiple SDR forms,
|
||||
wireframes, configuration data, and SCOPE.
|
||||
|
||||
STEPcode and build tractability
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
BRL-CAD uses STEPcode's compact generated API v2, packed schema modules,
|
||||
precomputed attribute layouts, bounded unity chunks, and generated source
|
||||
manifests. Entity and type chunk limits are independently tunable; 64:8 is the
|
||||
qualified default. STEP plugins retain optimized compilation but disable LTO.
|
||||
|
||||
On the qualification host, a serial AP242e4 binding build without LTO fell
|
||||
from 8m25s and 6.22 GB peak RSS at 64:64 to 6m12s and 1.33 GB at 64:8. NIST
|
||||
CTC 03 showed no measurable conversion benefit from LTO.
|
||||
|
||||
The importer is split into schema-neutral common build units for session and
|
||||
cache handling, document traversal, job scheduling, geometric sets, topology,
|
||||
periodic bands, pullback, seam repair, loop repair, validation, and
|
||||
finalization. Schema plugins consume those objects directly, preserving
|
||||
static Factory registration without a new runtime ABI. A local geometry
|
||||
repair edit therefore recompiles its topical unit and relinks only requested
|
||||
plugins. Revisit these boundaries only in response to a measured dependency,
|
||||
ownership, or rebuild problem.
|
||||
|
||||
Default policy and loss accounting
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The user-facing default is deliberately permissive:
|
||||
|
||||
* safe topology repair is enabled;
|
||||
* exact and strict modes are disabled;
|
||||
* storage-safe invalid BReps are preserved and explicitly tagged;
|
||||
* useful partial output is published with status 1 and a structured report;
|
||||
* --strict is transactional; and
|
||||
* --reject-invalid-objs restores invalid-BRep rejection.
|
||||
|
||||
Recovery order is source-faithful construction, bounded safe repair, tagged
|
||||
modeling inference for a proven source contradiction, marked storage-safe
|
||||
invalid fallback, and finally omission. Scale-bounded inference is attempted
|
||||
only in a detached whole-item retry after the ordinary candidate fails. It is
|
||||
committed only when either:
|
||||
|
||||
* the rebuilt BRep passes normal structural and solidness validation; or
|
||||
* permissive invalid preservation proves that a structurally safe open result
|
||||
corresponds one-for-one to source EDGE_CURVEs with exactly one authored
|
||||
ORIENTED_EDGE use. Such a result remains invalid and is tagged
|
||||
`step:invalidity=authored_open_topology`; it is never presented as a solid.
|
||||
|
||||
Inference is disabled by exact, strict, no-repair, and reject-invalid policy.
|
||||
Reports and affected database objects retain source edge identifiers,
|
||||
discrepancy, safe and inference limits, declared tolerance, inference kind,
|
||||
and explanatory detail. Rejected inference transactions do not contaminate
|
||||
ordinary results or diagnostics.
|
||||
|
||||
Every product-bound representation, representation item, selected BRL-CAD
|
||||
object, and reachable child receives a handled, intentionally non-geometric,
|
||||
skipped, malformed, unsupported, or failed disposition. Import and export
|
||||
distinguish complete, partial, empty, and failed outcomes. The pre-conversion
|
||||
entity census describes the input and does not imply conversion support.
|
||||
|
||||
A preserved invalid BRep is not, by itself, proof of bad authoring. Unless an
|
||||
independent topology proof establishes a source contradiction, it remains an
|
||||
unresolved converter result tagged `step:geometry_status=invalid_preserved`
|
||||
and `step:source_validity=unresolved`. It forces a partial result and remains
|
||||
in the failure ratchet.
|
||||
|
||||
Geometry
|
||||
~~~~~~~~
|
||||
|
||||
The following geometry paths are COMPLETE for their documented subsets:
|
||||
|
||||
* rigid product hierarchies, repeated occurrences, transforms, and shared
|
||||
product/occurrence emission in every supported schema;
|
||||
* optional native CSG import/export in AP203e2, AP214, and AP242, including
|
||||
supported Boolean trees, exact mapped primitives, BRep leaves, transforms,
|
||||
and RECTANGULAR_PYRAMID;
|
||||
* exact compatible extruded and revolved area/face solids and linear or
|
||||
circular SWEPT_DISK_SOLID in AP203e2, AP214, and AP242;
|
||||
* AP242 triangulated faces, simple/complex surface sets, shells, and solids,
|
||||
including strips, fans, pnindex, normals, welding, closure, and winding;
|
||||
* analytic and spline BRep import, periodic and singular topology, voids,
|
||||
bounded repair, transactional inference, and explicit invalid fallback;
|
||||
* solid-BRep export/re-import for sphere, cylinder, cone, torus, box, open
|
||||
NURBS faces with voids, rational Cobb surfaces, and BREP_WITH_VOIDS;
|
||||
* mated librt BRep fallback for EHY, EPA, HYP, PART, PIPE, EXTRUDE, REVOLVE,
|
||||
solid BOT, closed ARS, and supported NMG input;
|
||||
* sketch recovery for reordered or reversed segments, zero-length segments,
|
||||
inner loops, lines, circles, arcs, Beziers, and rational/non-rational NURBS;
|
||||
and
|
||||
* mixed GEOMETRIC_SET curves, points, and CURVE_BOUNDED_SURFACEs with partial
|
||||
set loss accounting and shared bounded pullback validation.
|
||||
|
||||
The detailed native-CSG primitive and operator matrix is maintained in
|
||||
src/conv/step/README.csg.md. AP203e1 has no native-CSG path; non-union AP203e1
|
||||
trees are BLOCKED on reliable Boolean evaluation.
|
||||
|
||||
STEP B-spline curves and surfaces share exact conversion from EXPRESS full knot
|
||||
vectors to OpenNURBS endpoint-truncated storage. Valid unclamped closed
|
||||
surfaces are clamped and only numerically equal seam controls are normalized,
|
||||
subject to dense before/after audits.
|
||||
|
||||
Presentation, units, configuration, and PMI
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
AP203e2, AP214, and AP242 share colour, transparency, style, layer, material,
|
||||
property, validation-value, and recursive unit-graph extraction. Export
|
||||
round-trips product identity and revision, presentation, multiple materials,
|
||||
typed properties, known SI and exact conversion units, derived units,
|
||||
occurrence labels, and reference designators. AP203e1 explicitly reports
|
||||
presentation or property requests it cannot author.
|
||||
|
||||
One constrained safe-mode repair handles an identified producer defect where
|
||||
a positive density's complete unit graph is mass^1 times length^3 instead of
|
||||
mass^1 times length^-3. The value is retained, the denominator dimension is
|
||||
corrected, provenance is recorded, and exact, strict, and no-repair modes
|
||||
reject the graph. This is not a general unit-guessing rule.
|
||||
|
||||
--output-units, --angle-units, and --uncertainty form one schema-neutral export
|
||||
policy covering BReps, native CSG, cone angles, and rigid occurrence
|
||||
translations.
|
||||
|
||||
Configuration data is retained as a typed reference graph with exact Part 21
|
||||
right-hand sides. Cross-schema authoring covers approvals, provenance,
|
||||
assignments, effectivity, alternatives, substitutes, categories, people,
|
||||
organizations, addresses, security classifications, documents, date/time
|
||||
chains, and their legal schema-specific assignment forms.
|
||||
|
||||
DATUM supports point, line, plane, frame, and bounded-target forms, semantic
|
||||
roles, axes, dimensions, identifiers, validation, transforms, and versioned
|
||||
legacy-compatible database storage. ANNOT supports model-space planes,
|
||||
per-segment roles and styles, UTF-8 text, runtime TrueType outlines,
|
||||
transforms, and legacy screen-space compatibility. The hardened private
|
||||
struetype code is shared from libbu; LGPL OSIFont remains runtime data and is
|
||||
not compiled into a binary.
|
||||
|
||||
AP242 PMI stage 1 retains connected semantic, presentation, association, and
|
||||
dependency records with source values, component order, references, product
|
||||
ownership, and native disposition. Exactly resolved point, axis, and plane
|
||||
datums become DATUM objects. Supported graphical content becomes model-space
|
||||
ANNOT geometry; ambiguous content remains semantic-only. Malformed
|
||||
presentation is partial in permissive mode and transactional in strict mode.
|
||||
|
||||
Safety and test infrastructure
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
BRL-CAD and STEPcode support ASan+UBSan and a separate TSan mode. Release and
|
||||
ASan+UBSan have covered the complete STEP/AP range; focused parallel, failure,
|
||||
native-CSG, and BRep-export paths have run under TSan. On the qualification
|
||||
host TSan must run through `setarch x86_64 -R` because its runtime otherwise
|
||||
fails before main. Repeated -j8 versus -j1 output comparison remains part of
|
||||
the determinism gate.
|
||||
|
||||
|
||||
Validation Snapshot
|
||||
-------------------
|
||||
|
||||
In-tree tests include success, malformed, partial, strict, round-trip,
|
||||
sanitizer, and output-validity cases across every supported schema. The last
|
||||
complete focused Release STEP matrix contains 197 tests. Official NIST MBE PMI
|
||||
files are stored under db/nist with documented provenance. Five AP203 MIM
|
||||
files exercise AP203e2, and 17 AP242 files span editions 1 through 4.
|
||||
|
||||
Checksum-pinned, non-redistributed STEP Tools and STEPcode samples cover CATIA,
|
||||
Unigraphics, AutoCAD, I-DEAS, STEPnet, and STEP Tools producers. Important
|
||||
independent modern-schema evidence currently includes:
|
||||
|
||||
* AP242 Leopard2-A6: 31/31 geometry items, 14 products, 338 occurrences,
|
||||
2,425 styles, 13 material identities, and 26 material values;
|
||||
* AP242 Open Source Rover: six product levels, 713 products, 1,390 rigid
|
||||
occurrences, and 569 applied styles; and
|
||||
* AP203e2 ROTATOR_ASM: eight product levels, 126 participating products, 404
|
||||
usages, 671 product-property records, and 3,991 styles.
|
||||
|
||||
The last complete unified corpus baseline is the 1,138-file dry run at
|
||||
`.build/step-corpus-full-j8-validity-ladder-20260804-post-speaker`:
|
||||
|
||||
* schemas: 616 AP203, 375 AP214, four AP203e2, and 143 AP242 files;
|
||||
* files: 1,057 clean; process statuses were 1,064 complete, 69 useful partial,
|
||||
three intentionally empty, one failed, and one SIGSEGV;
|
||||
* geometry: 41,765 of 41,992 attempted items written (99.46 percent), 221 exact
|
||||
omissions, and six unsupported-representation omissions;
|
||||
* invalid fallback: 386 structurally invalid but storage-safe BReps preserved,
|
||||
none rejected;
|
||||
* aggregate wall/conversion time: 36,796.2/34,678.9 seconds; and
|
||||
* maximum per-file RSS: 3.62 GB.
|
||||
|
||||
This remains the comparison baseline, not the current expected result. The
|
||||
crash and several omission groups have since been fixed in selected working
|
||||
runs. Its timing delta from the preceding baseline is not a controlled
|
||||
regression result because host load and available memory differed materially.
|
||||
Preserve both manifests and compare future runs only under similar load.
|
||||
|
||||
The corpus runner records hashes, schemas, converter/plugin/shared-library
|
||||
identities, reports, logs, wall time, peak RSS, host load, memory availability,
|
||||
geometry outcomes, hierarchy depth, metadata, and failure categories. Dry
|
||||
mode still parses, constructs, validates, and repairs geometry; it omits only
|
||||
database serialization. Historical `.g` files are comparative artifacts, not
|
||||
correctness oracles, and must remain intact.
|
||||
|
||||
Selected post-baseline qualifications establish:
|
||||
|
||||
* cargo ship now terminates normally; its two remaining selected omissions are
|
||||
explicit missing required FACE_SURFACE references;
|
||||
* hAVYARD now terminates normally after rejected seam transactions reacquire
|
||||
topology owned by the restored BRep; a direct owned lifetime fixture remains
|
||||
to be added;
|
||||
* SNAP #801290 is a valid solid after a face-scoped horn-torus keyhole repair;
|
||||
* all ten historical T90 omissions serialize: six valid solids, two
|
||||
source-open BReps tagged `authored_open_topology`, and two storage-safe
|
||||
partial fallbacks whose contradictions exceed bounded inference limits;
|
||||
* paired 135-file AP203/AP242 Recurta runs write all 139 attempted items per
|
||||
schema as valid BReps, with three tagged inferences per schema; and
|
||||
* the selected 421-face speaker solid now produces a validated 419-face solid
|
||||
through a whole-item shared-spur transaction.
|
||||
|
||||
The T90 serialized selection is recorded in
|
||||
`.build/t90_omissions_final_write.json`; it attempted and wrote ten of ten,
|
||||
skipped none, and explicitly preserved four invalid objects. The file-level
|
||||
partial status is intentional and does not represent an omission.
|
||||
|
||||
Two measured performance targets remain useful:
|
||||
|
||||
* Recurta `10012.step`, 992 faces: 78.7 seconds in the final qualification;
|
||||
profiling attributes most time to periodic trim-orientation repair and
|
||||
repeated transaction work, not preflight construction; and
|
||||
* chess king, 13,234 faces: about 616.8 seconds and 2.92 GB in its selected
|
||||
clean run.
|
||||
|
||||
|
||||
Remaining Work in Recommended Order
|
||||
-----------------------------------
|
||||
|
||||
1. Close the current corpus ratchet -- ACTIVE
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Complete selected work before starting another expensive unified run:
|
||||
|
||||
1. Add a compact rejected-seam-transaction lifetime fixture, qualify it under
|
||||
ASan+UBSan, and retain the complete hAVYARD real-file check.
|
||||
2. Sample and reduce the largest post-baseline omission sets:
|
||||
|
||||
* Garrett: 49;
|
||||
* 2-litre engine: 49, including 48 GEOMETRIC_SET items;
|
||||
* Open Source Rover: 16;
|
||||
* Epoxy granite VMC: seven; and
|
||||
* BLANCO: five.
|
||||
|
||||
3. Retest the former 58-omission GEOMETRIC_SET group with the completed bounded
|
||||
surface path. The independent b/c/d compatibility files are already
|
||||
qualified except for six d-wireframe boundary/pcurve disagreements.
|
||||
4. Reduce the Leopard2-A6 export/re-import failure at STEP edge #460691. One
|
||||
of 31 exported BReps fails while splitting a pcurve whose candidate
|
||||
fragment is 36.4476 model units from its subedge. This is a geometry
|
||||
interoperability problem, not schema selection or assembly loss.
|
||||
5. Retain MOUSE as a source edge/surface mismatch unless independent geometry
|
||||
proves a narrower correction.
|
||||
6. Revisit T90 #20240 and #20307 only if independent geometry supports a
|
||||
narrower reconstruction. Their marked partial results are preferable to
|
||||
inventing a false solid.
|
||||
7. Remove repeated full-BRep scans and copies from large periodic repair
|
||||
transactions while preserving exact rollback behavior.
|
||||
8. Run focused Release, ASan+UBSan, TSan where applicable, and -j1/-j8
|
||||
determinism gates. Then run the complete 1,138-file qualification.
|
||||
|
||||
Corpus fixes require a compact reproducer, permissive and strict assertions,
|
||||
structured report coverage, and a selected real-file check. Do not introduce
|
||||
broad repair heuristics solely to improve corpus percentages.
|
||||
|
||||
The next unified run is acceptable only if every file reaches a normal terminal
|
||||
report; there is no crash, hang, schema-load failure, corrupt output, or
|
||||
unexplained zero-geometry result; and all omissions and invalid fallbacks are
|
||||
grouped by owned root cause.
|
||||
|
||||
2. Expand independent AP203e2/AP242 evidence -- REMAINING
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Locate and pin independent examples for:
|
||||
|
||||
* AP203e2 material and property association variants;
|
||||
* AP242 materials beyond the Inventor MAKE_FROM_USAGE_OPTION pattern;
|
||||
* mixed representation contexts and large multiple-product files; and
|
||||
* AP203e2/AP242 sweeps, NURBS, shells, voids, and mixed BRep/CSG assemblies.
|
||||
|
||||
Promote small distributable reductions into in-tree fixtures. For external
|
||||
inputs record source, producer, SHA-256, and structured expectations. A
|
||||
BRL-CAD-authored round trip alone cannot close this item.
|
||||
|
||||
3. Complete AP242 semantic PMI and MBD -- REMAINING
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Stage 1 graph retention and exact native point/axis/plane/curve creation are
|
||||
complete. Remaining interpretation and authoring work is to:
|
||||
|
||||
* normalize dimension type/value, tolerance type and magnitude,
|
||||
material-condition modifiers, datum-reference compartments, and zones;
|
||||
* preserve stable product, occurrence, face, edge, and vertex target links;
|
||||
* distinguish semantic-only, graphical-only, and validated combined PMI;
|
||||
* map representable text, leaders, extension/dimension lines, arrowheads,
|
||||
fills, area targets, and supported symbols;
|
||||
* explicitly report graphical content which cannot be mapped faithfully;
|
||||
* author retained semantic and supported presentation records in AP242; and
|
||||
* qualify against NIST PMI and independent AP242 files, not only hand-authored
|
||||
fixtures.
|
||||
|
||||
An ANNOT outline is not proof that original text or tolerance semantics were
|
||||
recovered. Do not infer missing semantics from graphical similarity.
|
||||
|
||||
4. Complete tessellated coverage and optional export -- REMAINING
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Add faithful mappings for tessellated point, curve, wire, geometric-set, and
|
||||
repositioned-item forms. Add explicit optional AP242 BOT export using the
|
||||
shared unit and uncertainty policy. Tessellation must not silently replace
|
||||
the eventual evaluated-BRep default for analytic solids.
|
||||
|
||||
5. Broaden existing-BRep interoperability -- REMAINING
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Reduce the six d-wireframe boundary/pcurve disagreements and independently
|
||||
classify each as an authored tolerance violation or adapter defect. Preserve
|
||||
faithful partial-set output until that proof exists; do not stretch pcurves or
|
||||
widen model uncertainty merely to reach complete counts.
|
||||
|
||||
Expand independent-producer coverage for rational and non-rational NURBS,
|
||||
open shells, nested BREP_WITH_VOIDS, reversed proxies/faces, difficult trims,
|
||||
periodic and singular surfaces, and closed curves. Define a legal policy for
|
||||
one BRL-CAD object containing disconnected material volumes. Add wireframe
|
||||
and surface-curve export for databases without face topology.
|
||||
|
||||
Verification must include STEPcode readback, strict BRL-CAD re-import,
|
||||
topology and material-ray comparison, and an independent STEP validator or
|
||||
consumer where available. Textual identity is insufficient.
|
||||
|
||||
Sketch containment still needs a complete loop hierarchy for disjoint outer
|
||||
regions and nested material islands. REVOLVE should split profiles at sharp
|
||||
C0/tangent discontinuities when needed to avoid pathological wireframe and CDT
|
||||
refinement.
|
||||
|
||||
DSP, deprecated HF, EBM, and VOL are a separate scalability and fidelity
|
||||
project. They require bounded-error patching or faithful faceting, discrete
|
||||
boundary simplification, source-semantic ray/material comparisons, and hard
|
||||
validity, memory, and complexity limits. A single high-degree DSP surface or
|
||||
unbounded voxel-to-BRep expansion is not an acceptable general solution.
|
||||
|
||||
6. Complete product and configuration mappings -- REMAINING
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Define scoped mappings and loss policy for region identifiers,
|
||||
material/shader strings, and selected arbitrary BRL-CAD attributes. Do not
|
||||
conflate opaque attributes with typed STEP properties.
|
||||
|
||||
Broaden normalized named-unit conveniences only where schemas agree; the
|
||||
recursive retained graph remains authoritative. Extend authoring for
|
||||
remaining configuration identities, relationships, complex addresses, and
|
||||
application edges only when required source values exist. Expand
|
||||
import-export-import tests to multiple products, occurrences, shared complex
|
||||
effectivities, and schema-specific graphs.
|
||||
|
||||
7. Resolve assembly and native-CSG edge cases -- REMAINING
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Define explicit policy for scale, mirror, and future non-rigid occurrence
|
||||
transforms. Preserve or explicitly report nested combination identity inside
|
||||
one CSG expression; STEP CSG_SELECT cannot directly use another CSG_SOLID as
|
||||
an operand. Expand stable naming and independent multi-level mixed BRep/CSG
|
||||
evidence across AP203e2, AP214, and AP242.
|
||||
|
||||
NOT and guard/no-op Boolean nodes have no lossless finite STEP CSG encoding.
|
||||
AP242 CYCLIDE_SEGMENT_SOLID remains unsupported because recognizing a faithful
|
||||
BRL-CAD PIPE subset requires additional geometric semantics; similar
|
||||
dimensions alone are insufficient.
|
||||
|
||||
8. Implement remaining exact sweeps -- REMAINING
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Add nonlinear swept-disk directrices only when OpenNURBS can construct a
|
||||
faithful pipe with validated frame continuity and self-intersection handling.
|
||||
Ellipse, spline, and composite-curve cases remain. Implement drafted and
|
||||
trimmed face sweeps only after referenced termination, offset, trim intent,
|
||||
and per-edge draft can be evaluated exactly. Inherited depth or angle is not
|
||||
a faithful fallback.
|
||||
|
||||
|
||||
Blocked and Deferred Work
|
||||
-------------------------
|
||||
|
||||
Evaluated-BRep default export -- BLOCKED
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The broadly interoperable default should eventually evaluate arbitrary
|
||||
Boolean trees into final BReps. Current Boolean evaluation is not reliable
|
||||
enough. Continue evaluator work and validation; when it is ready, make
|
||||
evaluated BRep the default without weakening optional exact native CSG.
|
||||
|
||||
STEPcode upstreaming -- DEFERRED maintenance
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The generated-binding scalability work required by step-g and g-step is
|
||||
complete. Remaining STEPcode work is upstream preparation and maintenance:
|
||||
retain API v1 compatibility where required, and address correctness, cache,
|
||||
generation, or resource defects when measured. Reopen generator scalability
|
||||
only for a demonstrated regression or a new schema exceeding current limits.
|
||||
|
||||
Additional application protocols -- DEFERRED
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Do not build every ISO SMRL schema speculatively. AP224 machining features and
|
||||
AP238 STEP-NC are the strongest future geometry/manufacturing candidates once
|
||||
representative inputs and mapping goals exist. AP209 analysis and AP210
|
||||
electronics require domain-specific semantics. AP232 and AP239 are primarily
|
||||
metadata protocols and are not geometry targets merely because they share
|
||||
integrated resources.
|
||||
|
||||
ISO SMRL and historical STEPmod selection, provenance, and redistribution
|
||||
policy are documented in STEP(5).
|
||||
|
||||
|
||||
Definition of Done
|
||||
------------------
|
||||
|
||||
Every new decoder, emitter, or repair must include, as applicable:
|
||||
|
||||
1. an independent hand-authored or external fixture;
|
||||
2. a supported case and a malformed or explicitly unsupported case;
|
||||
3. permissive and transactional strict assertions;
|
||||
4. structured report checks proving exhaustive loss accounting;
|
||||
5. schema-specific coverage where legal alternatives or physical layouts
|
||||
differ;
|
||||
6. round-trip geometry, topology, semantic, and unit checks;
|
||||
7. Release, sanitizer, and deterministic parallel tests;
|
||||
8. selected corpus reruns for every affected real file; and
|
||||
9. matching step-g, g-step, STEP(5), CSG matrix, and plan documentation.
|
||||
|
||||
The overall STEP effort is broadly robust when:
|
||||
|
||||
* every input and selected output object is exhaustively accounted for;
|
||||
* the unified corpus has no crash, hang, schema-load failure, corrupt output,
|
||||
or unexplained zero-geometry result;
|
||||
* AP203e2 and AP242 have independent producer evidence comparable to the
|
||||
relevant AP203/AP214 baseline;
|
||||
* supported tessellation, rigid assemblies, presentation, configuration, and
|
||||
PMI round-trip with explicit losses;
|
||||
* existing BReps interoperate across analytic, NURBS, periodic, singular,
|
||||
shell, void, orientation, and trim cases with independent consumers;
|
||||
* native CSG remains optional, exact, and explicit about unsupported content;
|
||||
* parallel, sanitizer, deterministic-output, performance, and memory gates
|
||||
remain green; and
|
||||
* reliable Boolean evaluation enables evaluated-BRep default export without
|
||||
regressing native CSG.
|
||||
Reference in New Issue
Block a user