Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ab8c379e9 | ||
|
|
c12e55973a | ||
|
|
1968ba02a3 | ||
|
|
40621be317 | ||
|
|
92ba4caa42 | ||
|
|
ef75af075a |
@@ -45,14 +45,6 @@ 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
|
||||
)
|
||||
|
||||
@@ -1,619 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,127 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,501 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,939 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,288 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,449 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,178 +0,0 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,508 +0,0 @@
|
||||
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.
|
||||
@@ -17,8 +17,10 @@ brlcad_addexec(asc-nmg nmg/asc-nmg.c "libwdb;${M_LIBRARY}" FOLDER Conv)
|
||||
|
||||
brlcad_addexec(bot_dump bot_dump.c "libged;librt" FOLDER Conv)
|
||||
|
||||
verbose_add_subdirectory("src/conv" comgeom)
|
||||
|
||||
set(
|
||||
comgeom-g_SRCS
|
||||
comgeom-g_old_SRCS
|
||||
comgeom/cvt.c
|
||||
comgeom/f2a.c
|
||||
comgeom/mat.c
|
||||
@@ -27,7 +29,7 @@ set(
|
||||
comgeom/solid.c
|
||||
comgeom/tools.c
|
||||
)
|
||||
brlcad_addexec(comgeom-g "${comgeom-g_SRCS}" "libwdb;libbn;libbu;${M_LIBRARY}" FOLDER Conv)
|
||||
brlcad_addexec(comgeom-g_old "${comgeom-g_old_SRCS}" "libwdb;libbn;libbu;${M_LIBRARY}" FOLDER Conv)
|
||||
|
||||
brlcad_addexec(conv-vg2g conv-vg2g.c "libbu" FOLDER Conv)
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
set(comgeom_srcs
|
||||
ComGeomConverter.cpp
|
||||
ComGeomReader.cpp
|
||||
RegionManager.cpp
|
||||
SolidParser.cpp
|
||||
main.cpp
|
||||
)
|
||||
|
||||
brlcad_addexec(comgeom-g "${comgeom_srcs}" "libwdb;librt;libnmg;libbu" FOLDER Conv)
|
||||
target_include_directories(comgeom-g BEFORE PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
cmakefiles(
|
||||
CMakeLists.txt
|
||||
COMGEOM_V1.txt
|
||||
COMGEOM_versions.txt
|
||||
ComGeomConverter.h
|
||||
ComGeomReader.h
|
||||
RegionManager.h
|
||||
SolidParser.h
|
||||
bot_to_arbn_pipeline_thoughts.txt
|
||||
)
|
||||
|
||||
# Local Variables:
|
||||
# tab-width: 8
|
||||
# mode: cmake
|
||||
# indent-tabs-mode: t
|
||||
# End:
|
||||
# ex: shiftwidth=2 tabstop=8
|
||||
@@ -0,0 +1,498 @@
|
||||
COM-GEOM v1 FORMAT DEFINITION FOR THE GIFT CODE
|
||||
Based on BRL 1975 Manuals (Vol. I: BRL Report 1802; Vol. II: ARBRL-TR-02189)
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 0. GENERAL PRINCIPLES
|
||||
|
||||
1. Coordinate System
|
||||
- Right-handed Cartesian system (X,Y,Z).
|
||||
- Origin is user-defined (e.g., turret datum intersection for a tank).
|
||||
- All dimensional values (coordinates, lengths, radii) use a single unit family
|
||||
declared on the Title Card (columns 79-80): IN, FT, CM, M, or MM (default
|
||||
IN).
|
||||
|
||||
2. Data Organization (Target Description)
|
||||
A COM-GEOM data set consists of (in order):
|
||||
1) Title Card
|
||||
2) Target Specification Card
|
||||
3) Solid Table (one or more cards per solid)
|
||||
4) Region Table (one or more cards per region; ends with "-1" sentinel card)
|
||||
5) (Optional) Region RPP Table
|
||||
6) Region Identification Table
|
||||
7) Program Option / Control Cards (for GIFT processing and output runs)
|
||||
|
||||
3. Numeric Formats
|
||||
Unless otherwise noted:
|
||||
- Integers are right- or left-adjusted in their column ranges.
|
||||
- Floating point fields: F10.5 (classic) or F10.n where width 10 columns,
|
||||
decimal point explicit.
|
||||
- Blank where a "+" intersection operator is implied.
|
||||
|
||||
4. Combination Logic (Region Construction)
|
||||
Given solids S1, S2 ...
|
||||
- Intersection: adjacency (implicit "+"). Example: 1 2 3 means S1 intersect S2
|
||||
intersect S3.
|
||||
- Subtraction: prefix "-" before a solid number: ... -7 means subtract volume
|
||||
of S7 from accumulated set.
|
||||
- Union: keyword OR splits disjoint boolean branches: BranchA OR BranchB.
|
||||
- Dummy region: region card with only region number and no solids (assigned
|
||||
special identification code 111).
|
||||
- Recommended practice (per BRL guidance): Avoid OR on Region cards; instead
|
||||
assign identical identification (Item) codes to multiple simple regions.
|
||||
|
||||
5. Air Space and Items
|
||||
Each region is either:
|
||||
- Item (component) region: has item code (e.g., armor plate, wheel).
|
||||
- Air region: has air space code (e.g., crew compartment air).
|
||||
Rules:
|
||||
- Item regions must not overlap (beyond TOL).
|
||||
- Air regions may overlap other air regions with same air code.
|
||||
- Air regions must NOT overlap air regions with different air codes (beyond
|
||||
TOL).
|
||||
- Air regions may overlap item regions (they represent enclosing void).
|
||||
|
||||
6. Tolerances (Control Card)
|
||||
- Overlap tolerance TOL: maximum allowable spatial overlap distance between
|
||||
regions before flagged as error.
|
||||
- Line-of-sight gap tolerance TOLLOS: maximum axial gap along a ray ignored
|
||||
when assembling ray intersection chains.
|
||||
|
||||
7. End Sentinel & Codes
|
||||
- Void Space Code: numeric code inserted where no component.
|
||||
- Phantom Item Code: code assigned to "phantom armor" or shielding constructs
|
||||
generated during checking.
|
||||
- End-of-ray Signal Code: code marking termination of component stack for a ray.
|
||||
|
||||
8. Storage Model
|
||||
MASTER-ASTER equivalence array holds all processed geometry. Size NDQ must be
|
||||
large enough; formula given (Section 11 of Vol II) reproduced below (see
|
||||
Section 7).
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 1. TITLE CARD
|
||||
|
||||
Columns (1-80):
|
||||
1-60: Free text title (target name/description).
|
||||
61-78: (Usually blank or ancillary description).
|
||||
79-80: Units code (IN, FT, CM, M, MM). If blank defaults to IN.
|
||||
|
||||
Example:
|
||||
SAMPLE LIGHT ARMORED CAR WITH DRIVER IN
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 2. TARGET SPECIFICATION CARD
|
||||
|
||||
Format: (Counts of solids / regions / surrounding RPPs)
|
||||
Columns (fixed interpretation from manuals):
|
||||
1-10 Number of Solids (NSOLID)
|
||||
11-20 Number of Regions (NREGION, expected or max)
|
||||
21-30 Number of Surrounding RPPs (if used) (NRPP) or MAX Regions (context dependent)
|
||||
31-40 MAX pointers or storage (implementation field) - historically used internally
|
||||
Remaining columns may be reserved or blank.
|
||||
|
||||
If a dedicated second card exists (variant implementations) it repeats extended capacity fields.
|
||||
|
||||
Example:
|
||||
20 20 1
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 3. SOLID TABLE
|
||||
|
||||
Each solid has a unique consecutive integer ID starting at 1.
|
||||
|
||||
GENERAL CARD FORMAT (all solids):
|
||||
Columns:
|
||||
1-3 Solid Number (integer)
|
||||
4-6 Solid Symbol (3 chars): RPP BOX RAW ARB ARS ELL SPH RCC REC TRC TEC TOR
|
||||
7 Variant / Vertex Count / Option flag (for ARB, ARS, ELL1) else blank
|
||||
11-70 Numeric fields (F10.5 blocks)
|
||||
71-80 Comment (recommended: name or purpose)
|
||||
|
||||
3.1 Rectangular Parallelepiped (RPP)
|
||||
No explicit vertex; define bounding box aligned to global axes.
|
||||
Fields (F10.5 each):
|
||||
XMIN, XMAX, YMIN, YMAX, ZMIN, ZMAX.
|
||||
Example:
|
||||
6 RPP 20.0000 23.0000 -10.0000 10.0000 28.0000 46.0000 BODY
|
||||
|
||||
3.2 BOX (Arbitrarily oriented)
|
||||
Vertex V and three mutually perpendicular edge vectors H, W, D.
|
||||
Fields:
|
||||
Vx Vy Vz Hx Hy Hz Wx Wy Wz Dx Dy Dz
|
||||
Example:
|
||||
1 BOX 21.5000 37.0000 0.0000 0.0000 45.0000 11.0000 40.0000 0.0000 0.0000 11.0000 0.0000 0.0000 STEERING WHEEL
|
||||
|
||||
3.3 RAW (Right Angle Wedge)
|
||||
Vertex V; two perpendicular legs H,W in wedge plane; depth D.
|
||||
Fields:
|
||||
Vx Vy Vz Hx Hy Hz Wx Wy Wz Dx Dy Dz
|
||||
Example:
|
||||
12 RAW -75.0000 -36.0000 12.0000 60.0000 12.0000 0.0000 60.0000 12.0000 0.0000 -60.0000 12.0000 0.0000 FENDER
|
||||
|
||||
3.4 ARB (Convex Polyhedron) - ARB4 / ARB5 / ARB6 / ARB7 / ARB8
|
||||
Column 7 = number of vertices (4-8).
|
||||
Card sequence: Enough cards to list all vertices (each vertex: X Y Z).
|
||||
One vertex triple per F10.5 block; continue across cards.
|
||||
Face set is internally inferred by GIFT according to vertex count pattern (see manual).
|
||||
Example ARB5 (two cards if needed):
|
||||
3 ARB5 33.5000 37.0000 12.0000 46.0000 -75.0000 -75.0000 20.0000 50.0000 0.0000 ...
|
||||
(continuation)
|
||||
|
||||
3.5 ARS (Triangular Surfaced Polyhedron)
|
||||
Column 7: M (number of curves)
|
||||
Column 11-20: N (points per curve)
|
||||
Then M x N vertex points laid out curve by curve (each curve on one or more cards).
|
||||
Repeated vertices allowed; triangles formed between successive curves (see Vol I Fig. 13).
|
||||
Example:
|
||||
11 ARS7 (points...) (comments)
|
||||
|
||||
3.6 ELL / ELL1 (Ellipsoid of Revolution)
|
||||
Two input modes:
|
||||
(a) ELL: Foci F1, F2 and major axis length L.
|
||||
Fields: F1x F1y F1z F2x F2y F2z L
|
||||
(b) ELL1 (Column 7 = 1): Center V, semi-major axis vector A, radius R of equatorial circle.
|
||||
Fields: Vx Vy Vz Ax Ay Az R
|
||||
Example:
|
||||
14 ELL1 24.0000 52.0000 0.0000 35.0000 0.0000 20.0000 7.5000 DRIVER CHEST
|
||||
|
||||
3.7 SPH (Sphere)
|
||||
Center V and radius R.
|
||||
Fields: Vx Vy Vz R
|
||||
Example:
|
||||
15 SPH 45.0000 -30.0000 -30.0000 7.5000 HEAD
|
||||
|
||||
3.8 RCC (Right Circular Cylinder)
|
||||
Center of one circular base (vertex V), height vector H, radius R.
|
||||
Fields: Vx Vy Vz Hx Hy Hz R
|
||||
Example:
|
||||
16 RCC -30.0000 -70.0000 -30.0000 48.0000 24.0000 0.0000 13.0000 LEG
|
||||
|
||||
3.9 REC (Right Elliptical Cylinder)
|
||||
Center V, height vector H, semi-major axis vector A, semi-minor axis vector B (both in base plane).
|
||||
Fields: Vx Vy Vz Hx Hy Hz Ax Ay Az Bx By Bz
|
||||
Example:
|
||||
17 REC -30.0000 -30.0000 -30.0000 48.0000 24.0000 0.0000 13.0000 13.0000 0.0000 5.0000 0.0000 0.0000 ARM
|
||||
|
||||
3.10 TRC (Truncated Circular Cone)
|
||||
Base center V, height vector H, base radius R1, top radius R2.
|
||||
Fields: Vx Vy Vz Hx Hy Hz R1 R2
|
||||
Example:
|
||||
18 TRC -30.0000 -30.0000 -70.0000 48.0000 24.0000 0.0000 13.0000 9.0000 TORSO LOWER
|
||||
|
||||
3.11 TEC (Truncated Elliptic Cone)
|
||||
Base center V, height vector H, base semi-major A, base semi-minor B, ratio P (BaseEllipse / TopEllipse).
|
||||
Fields: Vx Vy Vz Hx Hy Hz Ax Ay Az Bx By Bz P
|
||||
Example:
|
||||
19 TEC -30.0000 -70.0000 0.0000 48.0000 24.0000 0.0000 24.0000 0.0000 20.0000 12.0000 0.0000 10.0000 1.5000
|
||||
|
||||
3.12 TOR (Torus)
|
||||
Center V, normal vector N (to torus plane), major radius R1 (centerline), tube radius R2.
|
||||
Fields: Vx Vy Vz Nx Ny Nz R1 R2
|
||||
Example:
|
||||
20 TOR 21.5000 37.0000 0.0000 0.0000 45.0000 11.0000 40.0000 11.0000 STEERING RIM
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 4. REGION TABLE
|
||||
|
||||
Card Format (per region):
|
||||
Columns:
|
||||
1-2 Blank (recommended)
|
||||
3-5 Region Number (integer, consecutive starting at 1)
|
||||
6 Blank
|
||||
7-8 "OR" if union logic is used (introductory OR for region; optional but required if any OR appears later)
|
||||
9-69 Sequence of signed solid references (2-3 digit solid numbers with optional leading minus sign; blanks imply "+" intersection). Space or "OR" may appear to break into disjoint branches.
|
||||
70-80 Comment (e.g., semantic label, region definition subset index)
|
||||
|
||||
Grammar rules:
|
||||
- Intersection: adjacency (implicit +).
|
||||
- Subtraction: a leading "-" before the solid number.
|
||||
- Union: OR separating disjoint boolean expressions.
|
||||
- End of Region Table: card with region number -1 in columns 4-5 (" -1" Card).
|
||||
|
||||
Example (two-card region expression 1 + 2 - 3 OR 4 + 5 - 6):
|
||||
Card 1:
|
||||
___111 OR 1 2 -3 4
|
||||
Card 2:
|
||||
___111 5 -6
|
||||
|
||||
Dummy region (no solids):
|
||||
___012 (columns 9-69 blank) (Region 12 dummy)
|
||||
|
||||
Sentinel:
|
||||
____-1
|
||||
|
||||
Best Practice:
|
||||
Prefer multiple simple non-OR regions sharing a common identification code (see Section 5) over complex OR expressions.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 5. REGION IDENTIFICATION TABLE
|
||||
|
||||
Each card assigns either an Item Code (component) or Air Space Code to a region.
|
||||
|
||||
Format:
|
||||
Columns:
|
||||
1-2 Blank
|
||||
3-5 Region Number
|
||||
6-10 Blank or separator
|
||||
11-15 Item or Air Space Code (integer)
|
||||
16-69 Optional additional codes or reserved blanks
|
||||
70-80 Comment (verbal description + region formula)
|
||||
|
||||
Special Codes:
|
||||
- 01 Exterior air / Surrounding void (example usage; manuals show 02,03,05 for interior categories).
|
||||
- 02 Crew compartment air
|
||||
- 03 Passenger compartment air
|
||||
- 05 Engine compartment air
|
||||
- 09 (DO NOT USE) reserved
|
||||
- 111 Dummy region code
|
||||
- 501 Track region (may be remapped to 502 for track edge by GIFT processing)
|
||||
- 999 Forbidden as a component code
|
||||
|
||||
Item Code Ranges (per Table 3 of manual):
|
||||
1- 99 Personnel & miscellaneous interior
|
||||
100-199 Armor & structural
|
||||
200-299 Powertrain / engine group
|
||||
300-399 Suspension / drive
|
||||
400-499 Mobility auxiliaries
|
||||
500-599 Track / running gear (501 special)
|
||||
600-699 Armament
|
||||
700-799 Fire control
|
||||
800-899 Communications / electronics
|
||||
900-998 Ammunition
|
||||
|
||||
If a region is air: its code is an air space code; otherwise an item code.
|
||||
|
||||
Example:
|
||||
__001 40 STEERING WHEEL 1-2
|
||||
__002 100 STEERING SHAFT 2
|
||||
__012 111 DUMMY
|
||||
__019 02 INTERIOR AIR 19
|
||||
__020 02 INTERIOR AIR 20
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 6. REGION RPP TABLE (OPTIONAL)
|
||||
|
||||
Purpose: Override automatically computed bounding boxes (per region).
|
||||
|
||||
Card Format:
|
||||
Columns:
|
||||
1-10 Region Number
|
||||
11-20 XMIN
|
||||
21-30 XMAX
|
||||
31-40 YMIN
|
||||
41-50 YMAX
|
||||
51-60 ZMIN
|
||||
61-70 ZMAX
|
||||
71-80 Comment
|
||||
|
||||
Omit dummy regions (those with identification code 111).
|
||||
If absent for a region, GIFT computes bounding RPP automatically.
|
||||
|
||||
Example:
|
||||
1 20.0000 23.0000 -10.0000 10.0000 28.0000 46.0000
|
||||
2 15.0000 21.5000 -11.0000 11.0000 28.0000 46.0000
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 7. STORAGE FORMULA (MASTER-ASTER SIZE NDQ)
|
||||
|
||||
Minimum NDQ (memory words) must satisfy largest of:
|
||||
1) LDES - 1
|
||||
2) 6 * (NSOLID) + LREGON - 1
|
||||
3) K * (NREGION) + LRIN - 1 (K = 5 CDC, 8 UNIVAC, 11 IBM)
|
||||
|
||||
Plus if alphanumeric region descriptions stored:
|
||||
NDQ >= LDES + (K - 1) * NREGION - 1
|
||||
|
||||
Detailed formula from Volume II (for precise sizing):
|
||||
TOTAL STORAGE =
|
||||
4 * (#Solids)
|
||||
+ 7 * (#RCC)
|
||||
+12 * (#REC)
|
||||
+ 8 * (#TRC)
|
||||
+15 * (#TEC)
|
||||
+ 4 * (#SPH)
|
||||
+12 * (#ELL/ELL1)
|
||||
+11 * (#TOR)
|
||||
+ 6 * (#RPP)
|
||||
+15 * (#BOX)
|
||||
+12 * (#RAW)
|
||||
+ (ARB Storage)
|
||||
+ (Non-convex ARS Storage)
|
||||
+ (Convex ARS Storage)
|
||||
+14 * (#Regions)
|
||||
+ (#Region Descriptors)
|
||||
+ 6
|
||||
+ (Alphanumeric Storage optional)
|
||||
|
||||
ARB Storage per ARB:
|
||||
4 * (#faces) + 1
|
||||
|
||||
Non-convex ARS:
|
||||
88 + 4 * (L + 2 * N * M)
|
||||
where L = #points read directly
|
||||
M = #curves
|
||||
N = #points per curve
|
||||
|
||||
Convex ARS:
|
||||
4 * (#non-degenerate faces) + 1
|
||||
|
||||
Region Descriptors:
|
||||
Count of non-zero entries in columns 9-69 of Region Table.
|
||||
|
||||
Alphanumeric storage per region:
|
||||
CDC=4, UNIVAC=7, IBM=10 words.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 8. CONTROL (PROGRAM OPTION) CARD (GIFT EXECUTION)
|
||||
|
||||
Columns (classic Control Card from Vol II):
|
||||
1 Use processed geometry from binary Unit 4 (0=no, non-zero=yes)
|
||||
2 Write processed geometry to binary Unit 4 (0=no, non-zero=yes)
|
||||
3-5 FORTRAN unit number for raw COM-GEOM input (0 or 5 = Unit 5)
|
||||
6 Print MASTER-ASTER array (debug) if non-zero
|
||||
7 Print Ordered Region Identification Table if non-zero
|
||||
8 Print Ordered Region RPP Equivalent Table if non-zero
|
||||
9 Print Solid RPP Equivalent Table if non-zero
|
||||
10 Suppress printing of Solid/Region tables if non-zero (No Print)
|
||||
11-30 Reserved / (legacy fields)
|
||||
31-40 Overlap tolerance TOL (F10)
|
||||
41-50 Line-of-sight gap tolerance TOLLOS (F10)
|
||||
51-55 Void Space Code (integer; default 1 if 0)
|
||||
56-60 Phantom Item Code (default 111 if 0)
|
||||
61-65 End-of-ray Signal Code (default 9 if 0)
|
||||
66-80 Comments
|
||||
|
||||
Example:
|
||||
0 1 5 0 1 1 0 0.010000 0.010000 1 111 9 RUN A
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 9. VALIDATION & RULES SUMMARY
|
||||
|
||||
1) Solid Numbers: Consecutive starting at 1. No gaps.
|
||||
2) Region Numbers: Consecutive starting at 1; end sentinel: region -1 card.
|
||||
3) Dummy Regions: Allowed; must carry identification code 111; no solids.
|
||||
4) Item Regions: Must not overlap beyond TOL.
|
||||
5) Air Regions: May overlap same air code; not different air codes beyond TOL.
|
||||
6) Negative Descriptors: Subtraction only applies to immediately preceding accumulated set in branch.
|
||||
7) OR Branches: Disjoint boolean unions; minimized use recommended.
|
||||
8) Tolerances: Set to reflect modeling precision; typical TOL=TOLLOS=0.01 in same units.
|
||||
9) Special Codes: Avoid 999, 09, ensure proper use of 111, 501/502.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 10. MINIMAL WORKED EXAMPLE
|
||||
|
||||
Title:
|
||||
SAMPLE LIGHT VEHICLE WITH DRIVER IN
|
||||
|
||||
Target Specification:
|
||||
20 20 1
|
||||
|
||||
Solid (excerpt):
|
||||
1 BOX 21.5000 37.0000 0.0000 0.0000 45.0000 11.0000 40.0000 0.0000 0.0000 11.0000 0.0000 0.0000 STEERING WHEEL
|
||||
2 TOR 21.5000 37.0000 0.0000 0.0000 45.0000 11.0000 40.0000 11.0000 RIM
|
||||
3 SPH 33.5000 37.0000 12.0000 7.5000 HEAD
|
||||
...
|
||||
20 RCC -30.0000 -70.0000 -30.0000 48.0000 24.0000 0.0000 13.0000 LEG
|
||||
|
||||
Region Table (excerpt):
|
||||
1 1 2 (STEERING WHEEL ASSEMBLY: BOX intersect TOR)
|
||||
2 3 (HEAD)
|
||||
3 4 -5 6 (ARM: Intersection of solids 4 & 6 minus 5)
|
||||
...
|
||||
12 (Dummy)
|
||||
...
|
||||
End: -1
|
||||
|
||||
Region Identification:
|
||||
1 40 STEERING WHEEL 1 2
|
||||
2 100 STEERING SHAFT 3
|
||||
3 100 ARM (UPPER) 4 -5 6
|
||||
12 111 DUMMY
|
||||
19 02 INTERIOR AIR
|
||||
20 02 INTERIOR AIR
|
||||
|
||||
Region RPP (optional):
|
||||
1 20.0 23.0 -10.0 10.0 28.0 46.0
|
||||
2 32.0 37.5 -11.0 11.0 28.0 46.0
|
||||
...
|
||||
|
||||
Control Card:
|
||||
0 1 5 0 1 0 0 0 0.010000 0.010000 1 111 9 STD RUN
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 11. COMMON PITFALLS AND RECOMMENDATIONS
|
||||
|
||||
1) Overlapping Items: Resolve by subtracting one from the other or adjusting tolerance; large overlaps are modeling errors, not tolerance issues.
|
||||
2) Excessive OR Usage: Replace with multiple regions sharing identification codes to simplify ray tracing and checking.
|
||||
3) Insufficient NDQ: Use storage formula; double NDQ if "NO MORE ROOM" messages appear after >75% of Solid Table processed.
|
||||
4) Dummy Regions: Keep for numbering alignment only; always code 111.
|
||||
5) Precision: If coordinate acquisition is from drawings with limited precision, set TOL/TOLLOS to at least the drawing resolution.
|
||||
6) ARS Conversions: For legacy MAGIC/SHOTGEN conversions using ARS, verify point ordering and duplication to preserve intended triangular mesh.
|
||||
7) TEC Ratio P: Ensure P > 1; if P < 1 code will treat cone as inverted (swap top/base); better to input consistent orientation.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 12. DIFFERENCES VS. OTHER ERA GEOMETRY FORMATS
|
||||
|
||||
MAGIC / SHOTGEN vs COM-GEOM v1:
|
||||
- COM-GEOM v1 uses boolean combinatorial solids with explicit card columns.
|
||||
- Emphasizes minimal OR, encourages identification grouping.
|
||||
- Tolerances (TOL/TOLLOS) user-adjustable on Control Card (MAGIC earlier versions required source edits).
|
||||
- Storage formulas explicitly published; user responsible for NDQ sizing (contrast with more opaque internal allocation in other codes).
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 13. QUICK REFERENCE TABLES
|
||||
|
||||
13.1 Solid Symbols and Required Fields
|
||||
|
||||
Symbol | Type | Fields (Order)
|
||||
RPP | Axis-aligned box | XMIN XMAX YMIN YMAX ZMIN ZMAX
|
||||
BOX | Oriented box | Vx Vy Vz Hx Hy Hz Wx Wy Wz Dx Dy Dz
|
||||
RAW | Right wedge | Vx Vy Vz Hx Hy Hz Wx Wy Wz Dx Dy Dz
|
||||
ARB4-8 | Convex polyhedron | Vertex triples (count in col 7)
|
||||
ARS | Triangulated polyhedron | M, N then M x N vertex triples
|
||||
ELL | Ellipsoid (foci) | F1x F1y F1z F2x F2y F2z L
|
||||
ELL1 | Ellipsoid (axis) | Vx Vy Vz Ax Ay Az R (col 7=1)
|
||||
SPH | Sphere | Vx Vy Vz R
|
||||
RCC | Right circular cylinder | Vx Vy Vz Hx Hy Hz R
|
||||
REC | Right elliptical cylinder | Vx Vy Vz Hx Hy Hz Ax Ay Az Bx By Bz
|
||||
TRC | Truncated circular cone | Vx Vy Vz Hx Hy Hz R1 R2
|
||||
TEC | Truncated elliptic cone | Vx Vy Vz Hx Hy Hz Ax Ay Az Bx By Bz P
|
||||
TOR | Torus | Vx Vy Vz Nx Ny Nz R1 R2
|
||||
|
||||
13.2 Special Identification Codes
|
||||
111 Dummy region
|
||||
501 Track (may become 502)
|
||||
01,02,03,05 Air categories (examples)
|
||||
999 Forbidden component code
|
||||
09 Forbidden air code
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 14. EXTENSIBILITY NOTES
|
||||
|
||||
To add new solid types (historical practice for later internal variants):
|
||||
- Reserve a new 3-char symbol in columns 4-6.
|
||||
- Define fixed F10.5 field sequence and document vertex or vector semantics.
|
||||
- Update MASTER-ASTER storage calculator with new per-solid word count.
|
||||
- Maintain backward compatibility by keeping existing card column positions.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
SECTION 15. VALIDATION CHECKLIST
|
||||
|
||||
Before running GIFT:
|
||||
[ ] Title card unit code set.
|
||||
[ ] NSOLID equals number of solid definitions.
|
||||
[ ] NREGION equals number of region definitions (excluding -1 sentinel).
|
||||
[ ] Solid numbers consecutive from 1.
|
||||
[ ] Region numbers consecutive from 1; -1 end card present.
|
||||
[ ] Region Identification codes valid (no 999, no 09).
|
||||
[ ] Dummy regions have 111 code and no solids.
|
||||
[ ] NDQ sized using formula (or rule of thumb 45*NSOLID + margin).
|
||||
[ ] TOL/TOLLOS appropriate for measurement precision.
|
||||
[ ] Void / Phantom / End-of-ray codes non-zero or rely on defaults (1,111,9).
|
||||
[ ] Optional Region RPP Table consistent (no dummy region entries).
|
||||
[ ] No unintended overlaps (test with CHECK option).
|
||||
[ ] Use CHECK / TESTG GIFT options for geometry validation before production runs.
|
||||
|
||||
---------------------------------------------------------------------
|
||||
|
||||
End of COM-GEOM v1 ASCII specification.
|
||||
@@ -0,0 +1,350 @@
|
||||
# COMGEOM Versions (1967–present)
|
||||
|
||||
This is a survey of the COMGEOM geometry input language across its lifecycle,
|
||||
from the earliest MAGI/MAGIC card decks through the stable v1 and the tokenized
|
||||
v4/v5 formats. It highlights what changed between versions, when those changes
|
||||
occurred, and provides practical guidance for recognizing and migrating old
|
||||
decks.
|
||||
|
||||
Quick summary: v0 (pre‑v1) was the early MAGIC card style with implicit
|
||||
intersections and “OR” unions; v1 formalized strict punched‑card columns; v2/v3
|
||||
were transitional internal experiments (partial free‑field parsing,
|
||||
parentheses, generalized cones); v4 is the fully tokenized, parenthesized
|
||||
grammar; v5 added attribute/metadata lines atop v4.
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
- 1967–1974: Pre‑formal MAGIC/MAGI “v0”
|
||||
- 12 primitive shapes; implicit intersection (adjacency), `OR` for unions; no parentheses
|
||||
- Dual ellipsoid input forms; 5‑card ARB; optional TESTG/VOLUM/AREA sections
|
||||
- Region IDs (3‑digit components / 2‑digit spaces) already in place
|
||||
- 1974–1976: COMGEOM v1 (formal punched‑card format)
|
||||
- Strict column positions; fixed parameter counts per primitive
|
||||
- Region table layout (cards 16A/16B) and `-1` terminator card
|
||||
- Error taxonomy; RPP enclosure required
|
||||
- 1977–1981: Transitional v2 → v3 (internal, not publicly specified)
|
||||
- v2: Column‑first with free‑field fallback; early generalized cones; explicit `+` tokens begin to appear
|
||||
- v3: Free‑field first; limited parentheses; explicit `+ - UNION/OR`; auto bounding boxes begin to appear
|
||||
- 1982–1984: COMGEOM v4 (stable token grammar)
|
||||
- Whitespace/tokenized parsing; parentheses; decoupled geometry vs. region structure
|
||||
- 1985+: COMGEOM v5 (v4 + attributes)
|
||||
- Attribute/metadata lines: material, aircode, color, region parameters; same boolean grammar as v4
|
||||
|
||||
Modern translators (e.g., `comgeom-g`) typically support v1, v4, and v5. Other
|
||||
versions would require manual adaptation.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Noteworthy Changes
|
||||
|------------|---------|--------------------------------------|------------------------------------------------|
|
||||
| Era | Version | Context | Noteworthy Changes |
|
||||
|------------|---------|--------------------------------------|------------------------------------------------|
|
||||
| 1967–1970 | v0 | Early MAGI/MAGIC on CDC/BRL | 12 primitives; adjacency = intersection; |
|
||||
| | | | `-` subtract; `OR` unions; no parentheses; |
|
||||
| | | | TEC permits oblique height vectors |
|
||||
|------------|---------|--------------------------------------|------------------------------------------------|
|
||||
| 1970–1974 | v0 | MAGIC manuals | Canonical 5‑card ARB; dual ellipsoid input |
|
||||
| | | | (foci vs center form); optional |
|
||||
| | | | TESTG/VOLUM/AREA cards; strict(er) carding |
|
||||
|------------|---------|--------------------------------------|------------------------------------------------|
|
||||
| 1974–1976 | v1 | GIFT integration; CDC/FORTRAN | Formal fixed‑columns per primitive; |
|
||||
| | | | region table cards (16A/16B); `-1` end marker; |
|
||||
| | | | RPP enclosure required |
|
||||
|------------|---------|--------------------------------------|------------------------------------------------|
|
||||
| 1977–1979 | v2 | Internal expansion | Column parsing w/ free‑field fallback; |
|
||||
| | | | explicit `+` tokens; early generalized cones; |
|
||||
| | | | torus parameter ordering fixes |
|
||||
|------------|---------|--------------------------------------|------------------------------------------------|
|
||||
| 1979–1981 | v3 | Move to machine‑independent parsing | Whitespace tokenization; shallow parentheses; |
|
||||
| | | | explicit `+ - UNION/OR`; auto RPP; |
|
||||
| | | | early ARB face validation |
|
||||
|------------|---------|--------------------------------------|------------------------------------------------|
|
||||
| 1982–1984 | v4 | Consolidation into BRL‑CAD pipeline | Fully tokenized whitespace grammar; |
|
||||
| | | | stable parentheses; clean geometry/structure |
|
||||
| | | | separation |
|
||||
|------------|---------|--------------------------------------|------------------------------------------------|
|
||||
| 1985+ | v5 | Attribute/metadata enrichment | Adds attribute lines (material, aircode, |
|
||||
| | | | color, region params); same v4 |
|
||||
| | | | geometry/boolean grammar |
|
||||
|------------|---------|--------------------------------------|------------------------------------------------|
|
||||
|
||||
Provenance: v0 details are drawn from the 1967 MAGI report and the MAGIC JTCG
|
||||
User Manual (61 JTCG/ME‑71‑7‑1). v1 is documented in BRL Report 1802 and
|
||||
ARBRL‑TR‑02189. v2/v3 are reconstructed from tool comments and translator
|
||||
heuristics. v4/v5 are reflected in BRL‑CAD-era translators and user guides.
|
||||
|
||||
---
|
||||
|
||||
## Primitive set by version
|
||||
|
||||
The early “MAGIC twelve” primitives were established by 1970 and remained the
|
||||
backbone across versions:
|
||||
|
||||
- RPP (axis‑aligned box), BOX (oriented box), SPH, RCC (right circular
|
||||
cylinder), REC (right elliptical cylinder), TRC (truncated right angle cone),
|
||||
TEC (truncated elliptic cone; height vector need not be perpendicular to base),
|
||||
ELL (ellipsoid of revolution), RAW/WED (right angle wedge), ARB (arbitrary
|
||||
convex polyhedron), ARS (arbitrary curved surface), TOR (torus)
|
||||
|
||||
| Primitive | v0 (MAGIC) | v1 | v2/v3 (notes) | v4 | v5 |
|
||||
|-----------|------------|----|------------------------------------|----|----|
|
||||
| RPP | ✓ | ✓ | Auto‑derive allowed (v3) | ✓ | ✓ |
|
||||
| BOX | ✓ | ✓ | | ✓ | ✓ |
|
||||
| SPH | ✓ | ✓ | | ✓ | ✓ |
|
||||
| RCC | ✓ | ✓ | Param order normalized (v3) | ✓ | ✓ |
|
||||
| REC | ✓ | ✓ | | ✓ | ✓ |
|
||||
| TRC | ✓ | ✓ | Distinct from TEC clarified (v3) | ✓ | ✓ |
|
||||
| TEC | ✓ | ✓ | Pre‑TGC experiments | ✓ | ✓ |
|
||||
| ELL | ✓ (dual) | ✓ | Dual input retained | ✓ | ✓ |
|
||||
| RAW/WED | ✓ | ✓ | | ✓ | ✓ |
|
||||
| ARB | ✓ (5‑card) | ✓ | Face validation begins (v3) | ✓ | ✓ |
|
||||
| ARS | ✓ | ✓ | Same semantics | ✓ | ✓ |
|
||||
| TOR | ✓ | ✓ | Minor<Major enforcement tightened | ✓ | ✓ |
|
||||
|
||||
Notes:
|
||||
- ARB in v0/v1 is the “MAGIC 5‑card” convex polyhedron: 8 vertices then 6 faces
|
||||
by ordinal indices; BRL‑CAD later canonicalizes to `arb8`.
|
||||
- Ellipsoid dual input (foci+major length or center+axis+equatorial radius) is
|
||||
an early quirk that persists; translators normalize internally.
|
||||
- TEC (truncated elliptic cone) allows a non‑perpendicular height vector as
|
||||
early as v0; generalized cone (TGC) is a later BRL‑CAD primitive outside
|
||||
COMGEOM proper.
|
||||
|
||||
---
|
||||
|
||||
## Boolean and region semantics across versions
|
||||
|
||||
### v0 (pre‑v1)
|
||||
- Intersection: implied by adjacency of body references (`Region = 1 2` means
|
||||
inside both 1 and 2)
|
||||
- Subtraction: leading minus (`-3` means outside body 3)
|
||||
- Union: `OR` between clauses (`Region = (1 -2) OR (4 -5)`)
|
||||
- No parentheses: evaluation is effectively left‑to‑right by clause; `OR` lumps
|
||||
subregions
|
||||
- “Buttressing” rule: where two bodies share a surface, each region must negate
|
||||
the other body to avoid ambiguous boundaries (e.g., `+2 -3` vs `+3 -2`)
|
||||
|
||||
Example (v0 style):
|
||||
```
|
||||
200 = 2 -3
|
||||
300 = 3 -2
|
||||
```
|
||||
|
||||
### v1
|
||||
- Same operators, strict fixed columns:
|
||||
- Region number cols 1–5, optional `OR` marker (IA(1)), then up to 9
|
||||
operator+body tokens per card
|
||||
- Continuation cards permitted; `-1` in cols 1–5 terminates region table
|
||||
- Parentheses still not part of the grammar; grouping via `OR` card positions
|
||||
|
||||
### v2 / v3 (transitional)
|
||||
- v2: Column parsing with free‑field fallback; explicit `+` tokens increasingly
|
||||
required in ambiguous decks
|
||||
- v3: Free‑field tokenization; limited parentheses; `UNION`/`U` recognized
|
||||
synonyms for `OR`
|
||||
- Automatic bounding boxes if RPP omitted (conventional runs)
|
||||
|
||||
### v4 / v5
|
||||
- Full token grammar; parentheses supported; explicit `+ - OR UNION` with
|
||||
predictable precedence
|
||||
- Geometry declarations cleanly separated from boolean structure; comments
|
||||
supported
|
||||
|
||||
Comparative snippets:
|
||||
- v0/v1 region equation (fixed columns):
|
||||
```
|
||||
001 +2 -3
|
||||
002 +3 -2
|
||||
-1
|
||||
```
|
||||
- v4/v5 region expression (tokenized):
|
||||
```
|
||||
region 001: +2 -3
|
||||
region 002: +3 -2
|
||||
```
|
||||
- v4/v5 with grouping:
|
||||
```
|
||||
region 010: ( +2 -3 ) OR ( +4 -5 )
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Card / field structure changes
|
||||
|
||||
### v0 (MAGIC card decks)
|
||||
- Mixed adherence to columns
|
||||
- Multi‑card primitives:
|
||||
- BOX: Vx Vy Vz / Ax Ay Az / Bx By Bz / Cx Cy Cz (2 cards)
|
||||
- RCC/REC/TRC/RAW: 2 cards; TEC: 3 cards; ARB: 5 cards; ARS: 2 +
|
||||
M*ceil((N+1)/2) cards
|
||||
- Region identification card per region: region number + component code or
|
||||
space code + description
|
||||
|
||||
### v1
|
||||
- Formal fixed‑column definitions per primitive; field counts validated
|
||||
- RPP mandatory enclosure
|
||||
- Region table formatting codified; `-1` terminator
|
||||
- Error messages categorized (fatal vs non‑fatal)
|
||||
|
||||
### v2/v3 (reconstructed)
|
||||
- Free‑field parsing introduced to accept large/overflow coordinates
|
||||
- Whole‑line comments (`#` or `!`) accepted in some parsers (internal)
|
||||
- Early “DUMMY” region recognition; improved error diagnostics
|
||||
|
||||
### v4/v5
|
||||
- Whitespace/token parsing only; columns ignored
|
||||
- v5 adds attribute/metadata lines (see next section)
|
||||
|
||||
---
|
||||
|
||||
## Attributes and metadata
|
||||
|
||||
- v0/v1: Region identification via numeric codes only (3‑digit component;
|
||||
2‑digit space). Materials/colors external or implicit.
|
||||
- v5: Attribute lines augment region definitions:
|
||||
- `material=Steel`, `aircode=02`, `color=0/255/0`, `los=...`, `density=...`
|
||||
- Name=value or token style, parsed separately from geometry/boolean
|
||||
|
||||
---
|
||||
|
||||
## Recognizing versions in the wild (cheat sheet)
|
||||
|
||||
- Likely v0 (MAGIC):
|
||||
- Cards referencing TESTG/VOLUM/AREA sections
|
||||
- Primitive blocks matching MAGIC manual’s 2/3/5‑card counts and ordering
|
||||
- Region tables with `-1` terminator but no parentheses anywhere; `OR`
|
||||
appears literally
|
||||
- Ellipsoid blocks with foci pair + major length
|
||||
|
||||
- v1:
|
||||
- Strict columns; values neatly aligned; region table with IA(1) `OR` markers
|
||||
- RPP enclosure present and required
|
||||
|
||||
- v2/v3 (transitional):
|
||||
- Mixture of column and free‑field lines; occasional parentheses; `UNION`
|
||||
token present
|
||||
- Comments starting `#` or `!` in legacy sources; generalized cone parameters
|
||||
pre‑TGC
|
||||
|
||||
- v4:
|
||||
- Whitespace tokens; parentheses; clean separation of bodies from boolean
|
||||
structure; no attribute lines
|
||||
|
||||
- v5:
|
||||
- Same as v4 plus attribute/metadata lines (e.g., `material=`, `aircode=`,
|
||||
`color=`)
|
||||
|
||||
---
|
||||
|
||||
## Migration guidance
|
||||
|
||||
1. Inventory and validate
|
||||
- Count per‑primitive parameters; flag lines with wrong cardinality
|
||||
- Verify region table completeness: every point belongs to exactly one
|
||||
region; buttressing negations present
|
||||
|
||||
2. Convert v0 → v1
|
||||
- Pad to fixed columns; split multi‑card primitives correctly
|
||||
- Keep dual ellipsoid inputs; normalize internally if supported
|
||||
|
||||
3. Convert v1 → v4/v5
|
||||
- Translate region boolean expressions to explicit tokens with parentheses
|
||||
- Add attributes (v5): material, aircode (01 external, 02 crew, 05 engine,
|
||||
09 terminus), region properties
|
||||
|
||||
4. Validate by ray tracing
|
||||
- Reproduce small sample angles from manual output (cell listings,
|
||||
obliquities, LOS/normal thickness) to confirm equivalence
|
||||
|
||||
Pitfalls:
|
||||
- Ellipsoid dual form mapping; ensure axis vector direction/length matches
|
||||
major axis semantics
|
||||
- TEC/TRC confusion in transitional decks; verify height vector
|
||||
perpendicularity (TEC: may be oblique)
|
||||
- ARB face index blocks: ensure 6 faces present; repeat a face for 4/5‑face
|
||||
forms (per v1 storage) or convert to `arb8`
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### v0 style (MAGIC region)
|
||||
```
|
||||
15 +15 -17 -14 -16
|
||||
16 +15 +16 -14 -17 -21 -22
|
||||
-1
|
||||
```
|
||||
|
||||
### v1 fixed columns (same semantics)
|
||||
```
|
||||
00015 +15 -17 -14 -16
|
||||
00016 +15 +16 -14 -17 -21 -22
|
||||
-1
|
||||
```
|
||||
|
||||
### v4/v5 tokenized with grouping
|
||||
```
|
||||
region 15: (+15 -17 -14 -16)
|
||||
region 16: ((+15 +16 -14 -17) OR (-21 -22)) # if logically intended
|
||||
```
|
||||
|
||||
### v5 attributes
|
||||
```
|
||||
region 15: (+15 -17 -14 -16)
|
||||
material=Steel
|
||||
aircode=02
|
||||
color=128/128/128
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Region identification codes (early and stable)
|
||||
|
||||
Components (3 digits):
|
||||
- 001–099 Internal components
|
||||
- 100–199 Armor
|
||||
- 200–299 Fuel (often reused in later targets)
|
||||
- 300–399 Misc external
|
||||
- 400–499 Gun components
|
||||
- 500–599 Track suspension (501 reserved for track; edge hits become 502)
|
||||
- 600–699 Wheel suspension
|
||||
- 700–799 Power train
|
||||
- 800–899 Miscellaneous
|
||||
- 900–998 Unused
|
||||
- 999 Ground/soil
|
||||
|
||||
Spaces (2 digits):
|
||||
- 01 External air
|
||||
- 02 Crew compartment air
|
||||
- 05 Engine compartment air
|
||||
- 09 No further target
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- MAGI (1967): Early geometric description technique for vulnerability analysis
|
||||
(MAGI‑6701)
|
||||
- MAGIC User Manual (JTCG/ME 61 JTCG/ME‑71‑7‑1; ‑7‑2‑1; ‑7‑2‑2): Combinatorial
|
||||
geometry, body/region cards, optional QA routines, sample problems
|
||||
- BRL Report 1802; ARBRL‑TR‑02189: Formal COMGEOM v1 card specifications
|
||||
- BRL‑CAD documentation: Primitive mappings (e.g., `arb8`, TGC) and later
|
||||
pipeline translators
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Field counts per primitive (v0/v1 reference)
|
||||
|
||||
- RPP: 6 scalars (Xmin Xmax Ymin Ymax Zmin Zmax)
|
||||
- BOX: 12 scalars (Vx Vy Vz Ax Ay Az Bx By Bz Cx Cy Cz) over 2 cards
|
||||
- SPH: 4 scalars (Cx Cy Cz R)
|
||||
- RCC: 7 scalars over 2 cards (Vx Vy Vz Hx Hy Hz R)
|
||||
- REC: 12 scalars over 2 cards (Vx Vy Vz Hx Hy Hz R1x R1y R1z R2x R2y R2z)
|
||||
- TRC: 8 scalars over 2 cards (Vx Vy Vz Hx Hy Hz R1 R2)
|
||||
- ELL: (foci Fl(3) F2(3) + L) OR (center V(3) + A(3) + equatorial R)
|
||||
- RAW/WED: 12 scalars over 2 cards (V(3) + leg A(3) + leg B(3) + width C(3); A and B interchangeable)
|
||||
- ARB: 5 cards: vertices 1–2 on card A; pairs (3,4), (5,6), (7,8) on cards B; faces (6 x 4 ordinals) on card C
|
||||
- TEC: 13 scalars over 3 cards (V(3) H(3) A(3) B(3) P)
|
||||
- ARS: 2 header integers (M curves, N points) + M * ceil((N+1)/2) cards of point pairs
|
||||
@@ -0,0 +1,186 @@
|
||||
#include "ComGeomConverter.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include "ComGeomReader.h"
|
||||
#include "SolidParser.h"
|
||||
#include "RegionManager.h"
|
||||
|
||||
ComGeomConverter::ComGeomConverter(const std::string &in,
|
||||
const std::string &out,
|
||||
const ConverterOptions &opts)
|
||||
: m_inPath(in), m_outPath(out), m_opts(opts)
|
||||
{}
|
||||
|
||||
ComGeomConverter::~ComGeomConverter() {
|
||||
if (m_in) fclose(m_in);
|
||||
if (m_out) wdb_close(m_out);
|
||||
}
|
||||
|
||||
bool ComGeomConverter::openInput() {
|
||||
if (!bu_file_exists(m_inPath.c_str(), NULL)) {
|
||||
std::perror(m_inPath.c_str());
|
||||
return false;
|
||||
}
|
||||
m_in = fopen(m_inPath.c_str(), "rb");
|
||||
if (!m_in) {
|
||||
std::perror(m_inPath.c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ComGeomConverter::openOutput() {
|
||||
if (bu_file_exists(m_outPath.c_str(), NULL))
|
||||
bu_log("Warning: appending to existing database\n");
|
||||
m_out = wdb_fopen(m_outPath.c_str());
|
||||
if (!m_out) {
|
||||
std::perror(m_outPath.c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ComGeomConverter::writeDbHeader(const std::string &title, const std::string &units) {
|
||||
if (mk_conversion(units.c_str()) < 0) {
|
||||
std::printf("WARNING: unknown units '%s', using inches\n", units.c_str());
|
||||
(void)mk_conversion("in");
|
||||
}
|
||||
if (mk_id_units(m_out, title.c_str(), units.c_str()) < 0) {
|
||||
std::printf("Unable to write database ID, units='%s'\n", units.c_str());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int ComGeomConverter::run() {
|
||||
if (m_opts.version != 0 && m_opts.version != 1 && m_opts.version != 4 && m_opts.version != 5) {
|
||||
std::fprintf(stderr,"version %d not supported\n", m_opts.version);
|
||||
return 1;
|
||||
}
|
||||
if (!openInput() || !openOutput()) return 1;
|
||||
|
||||
std::printf("Reading version %d COMGEOM file\n", m_opts.version);
|
||||
|
||||
/* Title & units */
|
||||
std::string title, units;
|
||||
{
|
||||
ComGeomReader r(m_in);
|
||||
auto l = r.next("title card");
|
||||
if (!l) { std::printf("Empty input file: no title record\n"); return 10; }
|
||||
std::string_view sv(l->s);
|
||||
if (m_opts.version == 0) {
|
||||
title = ComGeomReader::rstrip(sv);
|
||||
ComGeomReader::trimLeadingSpacesInplace(title);
|
||||
units = "in";
|
||||
} else if (m_opts.version == 1) {
|
||||
title = ComGeomReader::rstrip(sv);
|
||||
ComGeomReader::trimLeadingSpacesInplace(title);
|
||||
units = "in";
|
||||
} else {
|
||||
units.assign(sv.substr(0,2));
|
||||
title.assign(sv.substr(3));
|
||||
ComGeomReader::trimLeadingSpacesInplace(title);
|
||||
title = ComGeomReader::rstrip(title);
|
||||
units = ComGeomReader::rstrip(units);
|
||||
ComGeomReader::toLowerInplace(units);
|
||||
}
|
||||
std::printf("Title: %s\nUnits: %s\n", title.c_str(), units.c_str());
|
||||
}
|
||||
if (!writeDbHeader(title, units)) return 1;
|
||||
|
||||
int solTotal=0, regTotal=0;
|
||||
{
|
||||
ComGeomReader r(m_in);
|
||||
if (m_opts.version == 0) {
|
||||
// Attempt single read first
|
||||
auto l = r.next("v0 constraints candidate");
|
||||
if (l) {
|
||||
std::string_view sv(l->s);
|
||||
solTotal = ComGeomReader::fieldInt(sv, 30, 10);
|
||||
regTotal = ComGeomReader::fieldInt(sv, 40, 10);
|
||||
}
|
||||
if (solTotal <= 0 || regTotal <= 0) {
|
||||
// Scan up to 50 lines to find plausible NBODY/NRMAX pair
|
||||
solTotal = regTotal = 0;
|
||||
for (int scan=0; scan<50; ++scan) {
|
||||
auto cand = r.next("scan constraints");
|
||||
if (!cand) break;
|
||||
std::string_view sv2(cand->s);
|
||||
int nb = ComGeomReader::fieldInt(sv2, 30, 10);
|
||||
int nr = ComGeomReader::fieldInt(sv2, 40, 10);
|
||||
if (nb > 0 && nr > 0) {
|
||||
solTotal = nb;
|
||||
regTotal = nr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (solTotal == 0 || regTotal == 0) {
|
||||
std::printf("Warning: v0 NBODY/NRMAX not found, dynamic sizing will be used\n");
|
||||
solTotal = 0; // dynamic
|
||||
regTotal = 0;
|
||||
}
|
||||
}
|
||||
} else if (m_opts.version == 1) {
|
||||
solTotal = regTotal = 9999;
|
||||
} else {
|
||||
auto l = r.next("control card");
|
||||
if (!l) { std::printf("No control card .... STOP\n"); return 10; }
|
||||
std::string_view sv(l->s);
|
||||
if (m_opts.version == 4) {
|
||||
solTotal = ComGeomReader::fieldInt(sv, 0, 20);
|
||||
regTotal = ComGeomReader::fieldInt(sv, 20, 10);
|
||||
} else {
|
||||
solTotal = ComGeomReader::fieldInt(sv, 0, 5);
|
||||
regTotal = ComGeomReader::fieldInt(sv, 5, 5);
|
||||
}
|
||||
}
|
||||
if (m_opts.verbose)
|
||||
std::printf("Expecting %d solids, %d regions\n", solTotal, regTotal);
|
||||
}
|
||||
|
||||
/* Solids */
|
||||
{
|
||||
if (m_opts.verbose) std::puts("Primitive table");
|
||||
ComGeomReader r(m_in);
|
||||
SolidParserOptions sopts{m_opts.version,m_opts.verbose,m_opts.suffix};
|
||||
sopts.legacy_trunc = m_opts.legacy_truncate_names;
|
||||
SolidParser sp(r, m_out, sopts);
|
||||
sp.setSolidsExpected(solTotal);
|
||||
while (true) {
|
||||
auto res = sp.parseNext();
|
||||
if (res.status == SolidParseResult::Eof) {
|
||||
if (m_opts.verbose)
|
||||
std::printf("\nprocessed %d of %d solids\n\n", sp.solidsProcessed(), solTotal);
|
||||
if (solTotal > 0 && sp.solidsProcessed() < solTotal && m_opts.version > 1) {
|
||||
std::printf("some solids are missing, aborting\n");
|
||||
return 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (res.status == SolidParseResult::Error) {
|
||||
std::fprintf(stderr,"error converting solid %d: %s\n",
|
||||
sp.solidsProcessed(), res.message.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Regions */
|
||||
{
|
||||
if (m_opts.verbose) std::puts("\nRegion table");
|
||||
ComGeomReader r(m_in);
|
||||
RegionManagerOptions ropts;
|
||||
ropts.version = m_opts.version;
|
||||
ropts.verbose = m_opts.verbose;
|
||||
ropts.suffix = m_opts.suffix;
|
||||
ropts.regTotal = regTotal;
|
||||
ropts.legacy_trunc = m_opts.legacy_truncate_names;
|
||||
RegionManager rm(r, m_out, ropts);
|
||||
if (!rm.parseRegions()) return 10;
|
||||
if (m_opts.verbose) std::puts("\nGroups");
|
||||
if (!rm.writeGroups()) return 10;
|
||||
if (m_opts.verbose) std::puts("");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#ifndef BRLCAD_COMGEOM_CONVERTER_H
|
||||
#define BRLCAD_COMGEOM_CONVERTER_H
|
||||
|
||||
#include "common.h"
|
||||
#include <string>
|
||||
|
||||
extern "C" {
|
||||
#include "wdb.h"
|
||||
#include "bu/file.h"
|
||||
#include "bu/log.h"
|
||||
#include "bu/app.h"
|
||||
}
|
||||
|
||||
struct ConverterOptions {
|
||||
int version = 5;
|
||||
int verbose = 0;
|
||||
std::string suffix;
|
||||
bool legacy_truncate_names = false; // enable legacy suffix truncation (max 13 chars)
|
||||
};
|
||||
|
||||
class ComGeomConverter {
|
||||
public:
|
||||
ComGeomConverter(const std::string &inputPath,
|
||||
const std::string &outputPath,
|
||||
const ConverterOptions &opts);
|
||||
~ComGeomConverter();
|
||||
int run();
|
||||
|
||||
private:
|
||||
bool openInput();
|
||||
bool openOutput();
|
||||
bool writeDbHeader(const std::string &title, const std::string &units);
|
||||
|
||||
private:
|
||||
std::string m_inPath, m_outPath;
|
||||
ConverterOptions m_opts;
|
||||
FILE *m_in = nullptr;
|
||||
rt_wdb *m_out = nullptr;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,76 @@
|
||||
#include "ComGeomReader.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cctype>
|
||||
#include <algorithm>
|
||||
|
||||
ComGeomReader::ComGeomReader(FILE *fp) : m_fp(fp) {}
|
||||
ComGeomReader::~ComGeomReader() = default;
|
||||
|
||||
std::optional<ComGeomReader::Line> ComGeomReader::next(const char * /*title*/) {
|
||||
if (!m_fp) return std::nullopt;
|
||||
std::string s;
|
||||
int c = 0;
|
||||
while ((c = fgetc(m_fp)) != EOF) {
|
||||
if (c == '\r') {
|
||||
int nc = fgetc(m_fp);
|
||||
if (nc != '\n' && nc != EOF) ungetc(nc, m_fp);
|
||||
break;
|
||||
}
|
||||
if (c == '\n') break;
|
||||
s.push_back(static_cast<char>(c));
|
||||
}
|
||||
if (c == EOF && s.empty())
|
||||
return std::nullopt;
|
||||
m_line++;
|
||||
return Line{std::move(s), m_line};
|
||||
}
|
||||
|
||||
int ComGeomReader::fieldInt(std::string_view sv, size_t start, size_t len) {
|
||||
if (start >= sv.size()) return 0;
|
||||
size_t end = std::min(sv.size(), start + len);
|
||||
// Work on the field window only
|
||||
size_t b = start, e = end;
|
||||
// Trim leading whitespace
|
||||
while (b < e && std::isspace(static_cast<unsigned char>(sv[b]))) b++;
|
||||
// Trim trailing whitespace
|
||||
while (e > b && std::isspace(static_cast<unsigned char>(sv[e-1]))) e--;
|
||||
if (b >= e) return 0;
|
||||
// Note that we preserve internal spaces to match the behavior of the
|
||||
// original C code - "1 23" would be read as 1 rather than 123. Whether
|
||||
// or not this is really desirable behavior is a question - i.e. whether
|
||||
// there is a valid use case where a line would make the above separation
|
||||
// with intent in the COMGEOM formats - but for now we match legacy code.
|
||||
std::string token(sv.substr(b, e - b));
|
||||
return std::atoi(token.c_str());
|
||||
}
|
||||
|
||||
double ComGeomReader::fieldDouble(std::string_view sv, size_t start, size_t len) {
|
||||
if (start >= sv.size()) return 0.0;
|
||||
size_t end = std::min(sv.size(), start + len);
|
||||
// Work on the field window only
|
||||
size_t b = start, e = end;
|
||||
// Trim leading whitespace
|
||||
while (b < e && std::isspace(static_cast<unsigned char>(sv[b]))) b++;
|
||||
// Trim trailing whitespace
|
||||
while (e > b && std::isspace(static_cast<unsigned char>(sv[e-1]))) e--;
|
||||
if (b >= e) return 0.0;
|
||||
std::string token(sv.substr(b, e - b)); // preserve internal spaces to match legacy C
|
||||
return std::atof(token.c_str());
|
||||
}
|
||||
|
||||
std::string ComGeomReader::rstrip(std::string_view s) {
|
||||
size_t end = s.size();
|
||||
while (end > 0 && s[end-1] == ' ') end--;
|
||||
return std::string(s.substr(0, end));
|
||||
}
|
||||
|
||||
void ComGeomReader::toLowerInplace(std::string &s) {
|
||||
for (auto &c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
}
|
||||
|
||||
void ComGeomReader::trimLeadingSpacesInplace(std::string &s) {
|
||||
size_t i = 0;
|
||||
while (i < s.size() && std::isspace(static_cast<unsigned char>(s[i]))) i++;
|
||||
if (i) s.erase(0, i);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#ifndef BRLCAD_COMGEOM_READER_H
|
||||
#define BRLCAD_COMGEOM_READER_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
class ComGeomReader {
|
||||
public:
|
||||
struct Line {
|
||||
std::string s;
|
||||
size_t number = 0;
|
||||
std::string_view view() const { return s; }
|
||||
};
|
||||
|
||||
explicit ComGeomReader(FILE *fp);
|
||||
~ComGeomReader();
|
||||
|
||||
std::optional<Line> next(const char *title);
|
||||
|
||||
static int fieldInt(std::string_view sv, size_t start, size_t len);
|
||||
static double fieldDouble(std::string_view sv, size_t start, size_t len);
|
||||
|
||||
static std::string rstrip(std::string_view s);
|
||||
static void toLowerInplace(std::string &s);
|
||||
static void trimLeadingSpacesInplace(std::string &s);
|
||||
|
||||
size_t lineNo() const { return m_line; }
|
||||
|
||||
private:
|
||||
FILE *m_fp = nullptr;
|
||||
size_t m_line = 0;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,424 @@
|
||||
#include "RegionManager.h"
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
|
||||
RegionManager::RegionManager(ComGeomReader &reader, rt_wdb *outfp, const RegionManagerOptions &opts)
|
||||
: m_r(reader), m_out(outfp), m_opts(opts)
|
||||
{
|
||||
// For dynamic v0 decks (regTotal == 0) preallocate provisional capacity
|
||||
size_t allocCount = static_cast<size_t>(m_opts.regTotal + 1);
|
||||
if (m_opts.version == 0 && m_opts.regTotal == 0) {
|
||||
allocCount = 4097; // provisional region cap
|
||||
}
|
||||
m_regionHeads.resize(allocCount);
|
||||
for (auto &wm : m_regionHeads) {
|
||||
WMEMBER_INIT(&wm);
|
||||
}
|
||||
groupInit();
|
||||
}
|
||||
|
||||
RegionManager::~RegionManager() {
|
||||
for (auto &wm : m_regionHeads) {
|
||||
if (wm.wm_name) {
|
||||
bu_free(wm.wm_name, "region wm_name");
|
||||
wm.wm_name = nullptr;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < m_groupCount; ++i) {
|
||||
if (m_groups[i].head.wm_name) {
|
||||
bu_free(m_groups[i].head.wm_name, "group wm_name");
|
||||
m_groups[i].head.wm_name = nullptr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string RegionManager::makeRegionName(int n) const {
|
||||
std::string suf = m_opts.suffix;
|
||||
if (m_opts.legacy_trunc && suf.size() > 13) suf.resize(13);
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "r%d%s", n, suf.c_str());
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
void RegionManager::colPrint(const std::string &s) {
|
||||
if (!m_opts.verbose) return;
|
||||
std::fputs(s.c_str(), stdout);
|
||||
m_colCur += s.size();
|
||||
while (m_colCur < 78 && (m_colCur % 10)) {
|
||||
putchar(' ');
|
||||
++m_colCur;
|
||||
}
|
||||
if (m_colCur >= 78) {
|
||||
std::fputc('\n', stdout);
|
||||
m_colCur = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- v0 (MAGIC) region table with OR semantics ---------------- */
|
||||
bool RegionManager::parseRegionTableV0() {
|
||||
auto first = m_r.next("v0 region card");
|
||||
if (!first) { std::fprintf(stderr,"v0 region: EOF\n"); return false; }
|
||||
std::string current = first->s;
|
||||
|
||||
int lastRegNum = 0;
|
||||
while (true) {
|
||||
int reg_num = ComGeomReader::fieldInt(current, 0, 5);
|
||||
if (reg_num < 0) break;
|
||||
if (reg_num == 0) {
|
||||
auto nl = m_r.next("v0 region card");
|
||||
if (!nl) break;
|
||||
current = nl->s;
|
||||
continue;
|
||||
}
|
||||
// resize if needed (dynamic)
|
||||
if (reg_num >= static_cast<int>(m_regionHeads.size())) {
|
||||
std::fprintf(stderr,"v0 region number %d exceeds provisional allocation\n", reg_num);
|
||||
return false;
|
||||
}
|
||||
lastRegNum = reg_num;
|
||||
wmember &head = m_regionHeads[static_cast<size_t>(reg_num)];
|
||||
if (!head.wm_name) head.wm_name = bu_strdup(makeRegionName(reg_num).c_str());
|
||||
|
||||
struct Term { int body; bool subtract; };
|
||||
std::vector<std::vector<Term>> clauses;
|
||||
clauses.emplace_back();
|
||||
|
||||
auto parseLine = [&](const std::string &line) {
|
||||
for (int i=0;i<9;++i) {
|
||||
size_t slot = 6 + i*7;
|
||||
if (slot + 7 > line.size()) break;
|
||||
std::string field = line.substr(slot, 7);
|
||||
std::string op = field.substr(0,2);
|
||||
std::string numstr = field.substr(2);
|
||||
ComGeomReader::trimLeadingSpacesInplace(op);
|
||||
numstr = ComGeomReader::rstrip(numstr);
|
||||
ComGeomReader::trimLeadingSpacesInplace(numstr);
|
||||
if (numstr.empty()) continue;
|
||||
int bnum = std::atoi(numstr.c_str());
|
||||
if (bnum == 0) continue;
|
||||
if (op == "OR" || op == "or") {
|
||||
if (!clauses.back().empty()) clauses.emplace_back();
|
||||
continue;
|
||||
}
|
||||
bool subtract = (bnum < 0);
|
||||
if (subtract) bnum = -bnum;
|
||||
clauses.back().push_back({bnum, subtract});
|
||||
}
|
||||
};
|
||||
parseLine(current);
|
||||
|
||||
while (true) {
|
||||
auto nextLine = m_r.next("v0 region card");
|
||||
if (!nextLine) break;
|
||||
int newRegTest = ComGeomReader::fieldInt(nextLine->s, 0, 5);
|
||||
std::string trimmed = ComGeomReader::rstrip(nextLine->s);
|
||||
std::string t2 = trimmed; ComGeomReader::trimLeadingSpacesInplace(t2);
|
||||
if (t2 == "end") {
|
||||
if (m_opts.regTotal == 0) m_opts.regTotal = reg_num;
|
||||
break;
|
||||
}
|
||||
if (newRegTest < 0) { // negative sentinel
|
||||
if (m_opts.regTotal == 0) m_opts.regTotal = reg_num;
|
||||
current = nextLine->s;
|
||||
break;
|
||||
}
|
||||
if (newRegTest > 0) { // new region starts
|
||||
current = nextLine->s;
|
||||
break;
|
||||
}
|
||||
parseLine(nextLine->s);
|
||||
}
|
||||
|
||||
int clauseIdx = 0;
|
||||
for (auto &cl : clauses) {
|
||||
if (cl.empty()) continue;
|
||||
wmember clauseHead; WMEMBER_INIT(&clauseHead);
|
||||
char cname[64]; std::snprintf(cname, sizeof(cname), "r%d_c%d", reg_num, ++clauseIdx);
|
||||
clauseHead.wm_name = bu_strdup(cname);
|
||||
bool firstTerm = true;
|
||||
for (auto &t : cl) {
|
||||
std::string memberName = std::string("s") + std::to_string(t.body) + m_opts.suffix;
|
||||
int op;
|
||||
if (firstTerm) {
|
||||
op = WMOP_UNION; // baseline
|
||||
firstTerm = false;
|
||||
} else {
|
||||
op = t.subtract ? WMOP_SUBTRACT : WMOP_INTERSECT;
|
||||
}
|
||||
if (!mk_addmember(memberName.c_str(), &clauseHead.l, nullptr, op)) {
|
||||
std::fprintf(stderr,"v0 mk_addmember failed clause %s member %s\n", cname, memberName.c_str());
|
||||
}
|
||||
}
|
||||
mk_comb(m_out, clauseHead.wm_name, &clauseHead.l, 0,
|
||||
"", "", nullptr, 0,0,0,0,0,0,0);
|
||||
if (!mk_addmember(clauseHead.wm_name, &head.l, nullptr, WMOP_UNION)) {
|
||||
std::fprintf(stderr,"v0 mk_addmember region %d clause %s\n", reg_num, clauseHead.wm_name);
|
||||
}
|
||||
}
|
||||
if (m_opts.verbose) colPrint(head.wm_name);
|
||||
|
||||
int sentinelTest = ComGeomReader::fieldInt(current, 0, 5);
|
||||
if (sentinelTest < 0) break;
|
||||
}
|
||||
if (m_opts.regTotal == 0) m_opts.regTotal = lastRegNum;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RegionManager::parseRegions() {
|
||||
if (m_opts.version == 0) {
|
||||
if (!parseRegionTableV0()) return false;
|
||||
for (int i=1;i<=m_opts.regTotal && i < static_cast<int>(m_regionHeads.size());++i) registerRegion(i,0,0,0,0);
|
||||
return true;
|
||||
}
|
||||
if (!parseRegionTable()) return false;
|
||||
if (m_opts.version == 1) {
|
||||
for (int i = 1; i <= m_opts.regTotal; ++i) {
|
||||
registerRegion(i, 0, 0, 0, 0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return parseIdentTable();
|
||||
}
|
||||
|
||||
bool RegionManager::parseRegionTable() {
|
||||
auto first = m_r.next("region card");
|
||||
if (!first) {
|
||||
std::fprintf(stderr, "getregion: EOF\n");
|
||||
return false;
|
||||
}
|
||||
if (ComGeomReader::fieldInt(first->s, 0, 5) != 1) {
|
||||
std::fprintf(stderr, "First region card not #1\n");
|
||||
return false;
|
||||
}
|
||||
std::string current = first->s;
|
||||
|
||||
while (true) {
|
||||
int reg_num = ComGeomReader::fieldInt(current, 0, 5);
|
||||
if (reg_num < 0) {
|
||||
return true;
|
||||
}
|
||||
if (reg_num > m_opts.regTotal) {
|
||||
std::fprintf(stderr, "%d regions > claimed %d\n", reg_num, m_opts.regTotal);
|
||||
return false;
|
||||
}
|
||||
wmember &head = m_regionHeads[static_cast<size_t>(reg_num)];
|
||||
if (!head.wm_name) {
|
||||
head.wm_name = bu_strdup(makeRegionName(reg_num).c_str());
|
||||
}
|
||||
|
||||
while (true) {
|
||||
size_t base = (m_opts.version == 1) ? 10 : 6;
|
||||
for (int i = 0; i < 9; ++i) {
|
||||
size_t off = base + i * 7;
|
||||
if (off + 7 > current.size()) break;
|
||||
|
||||
char token[8] = {0};
|
||||
int k = 0;
|
||||
for (int j = 2; j < 7; ++j) {
|
||||
if (off + j >= current.size()) break;
|
||||
char c = current[off + j];
|
||||
if (!std::isspace(static_cast<unsigned char>(c))) {
|
||||
token[k++] = c;
|
||||
}
|
||||
}
|
||||
if (k == 0) continue;
|
||||
int inst_num = std::atoi(token);
|
||||
if (inst_num == 0) continue;
|
||||
|
||||
int op = WMOP_UNION;
|
||||
bool region_ref = false;
|
||||
|
||||
if (m_opts.version == 5) {
|
||||
char flag = (off + 1 < current.size()) ? current[off + 1] : ' ';
|
||||
if (flag == 'g' || flag == 'G') {
|
||||
region_ref = true;
|
||||
} else if (flag == 'R' || flag == 'r') {
|
||||
op = WMOP_UNION;
|
||||
} else {
|
||||
if (inst_num < 0) {
|
||||
op = WMOP_SUBTRACT;
|
||||
inst_num = -inst_num;
|
||||
} else {
|
||||
op = WMOP_INTERSECT;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
char flag = (off + 1 < current.size()) ? current[off + 1] : ' ';
|
||||
if (flag == ' ') {
|
||||
if (inst_num < 0) {
|
||||
op = WMOP_SUBTRACT;
|
||||
inst_num = -inst_num;
|
||||
} else {
|
||||
op = WMOP_INTERSECT;
|
||||
}
|
||||
} else {
|
||||
op = WMOP_UNION;
|
||||
}
|
||||
}
|
||||
|
||||
std::string suf = m_opts.suffix;
|
||||
if (m_opts.legacy_trunc && suf.size() > 13) suf.resize(13);
|
||||
|
||||
std::string memberName = region_ref
|
||||
? makeRegionName(inst_num)
|
||||
: (std::string("s") + std::to_string(inst_num) + suf);
|
||||
|
||||
if (!mk_addmember(memberName.c_str(), &head.l, nullptr, op)) {
|
||||
std::fprintf(stderr, "mk_addmember failed for region %d member '%s'\n",
|
||||
reg_num, memberName.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
auto nextLine = m_r.next("region card");
|
||||
if (!nextLine) {
|
||||
std::fprintf(stderr, "getregion: EOF\n");
|
||||
return false;
|
||||
}
|
||||
std::string trimmed = ComGeomReader::rstrip(nextLine->s);
|
||||
ComGeomReader::trimLeadingSpacesInplace(trimmed);
|
||||
if (trimmed == "end") {
|
||||
m_opts.regTotal = reg_num;
|
||||
return true;
|
||||
}
|
||||
int possible = ComGeomReader::fieldInt(nextLine->s, 0, 5);
|
||||
if (possible != 0) {
|
||||
current = nextLine->s;
|
||||
break;
|
||||
}
|
||||
current = nextLine->s;
|
||||
}
|
||||
|
||||
if (m_opts.verbose) colPrint(head.wm_name);
|
||||
}
|
||||
}
|
||||
|
||||
bool RegionManager::parseIdentTable() {
|
||||
if (m_opts.version == 4) {
|
||||
(void)m_r.next("blank card");
|
||||
}
|
||||
if (m_opts.verbose) std::puts("\nRegion ident table");
|
||||
|
||||
while (true) {
|
||||
auto line = m_r.next("ident card");
|
||||
if (!line) return true;
|
||||
if (line->s.empty()) return true;
|
||||
|
||||
int reg_num, id, air, mat, los;
|
||||
if (m_opts.version == 5) {
|
||||
reg_num = ComGeomReader::fieldInt(line->s, 0, 5);
|
||||
id = ComGeomReader::fieldInt(line->s, 5, 5);
|
||||
air = ComGeomReader::fieldInt(line->s, 10, 5);
|
||||
mat = ComGeomReader::fieldInt(line->s, 15, 5);
|
||||
los = ComGeomReader::fieldInt(line->s, 20, 5);
|
||||
} else {
|
||||
reg_num = ComGeomReader::fieldInt(line->s, 0, 10);
|
||||
id = ComGeomReader::fieldInt(line->s, 10, 10);
|
||||
air = ComGeomReader::fieldInt(line->s, 20, 10);
|
||||
mat = ComGeomReader::fieldInt(line->s, 74, 3);
|
||||
los = ComGeomReader::fieldInt(line->s, 77, 3);
|
||||
}
|
||||
|
||||
if (reg_num <= 0) return true;
|
||||
registerRegion(reg_num, id, air, mat, los);
|
||||
}
|
||||
}
|
||||
|
||||
void RegionManager::registerRegion(int regNum, int id, int air, int mat, int los) {
|
||||
if (regNum <= 0 || regNum >= static_cast<int>(m_regionHeads.size())) return;
|
||||
wmember &head = m_regionHeads[static_cast<size_t>(regNum)];
|
||||
|
||||
if (BU_LIST_IS_EMPTY(&head.l)) {
|
||||
if (m_opts.verbose && head.wm_name) {
|
||||
colPrint(std::string("(") + head.wm_name + ")");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
int ret = mk_comb(m_out, head.wm_name, &head.l, 1,
|
||||
"", "", nullptr,
|
||||
id, air, mat, los, 0, 0, 1);
|
||||
if (ret < 0) {
|
||||
std::fprintf(stderr, "mk_comb failed for region %d (%s)\n", regNum, head.wm_name);
|
||||
} else {
|
||||
groupAdd(id, head.wm_name);
|
||||
if (m_opts.verbose) colPrint(head.wm_name);
|
||||
}
|
||||
}
|
||||
|
||||
void RegionManager::groupInit() {
|
||||
struct Def { const char *n; int lo; int hi; } defs[] = {
|
||||
{"g00",0,0},{"g0",1,99},{"g1",100,199},{"g2",200,299},{"g3",300,399},
|
||||
{"g4",400,499},{"g5",500,599},{"g6",600,699},{"g7",700,799},{"g8",800,899},
|
||||
{"g9",900,999},{"g10",1000,1999},{"g11",2000,2999},{"g12",3000,3999},{"g13",4000,4999},
|
||||
{"g14",5000,5999},{"g15",6000,6999},{"g16",7000,7999},{"g17",8000,8999},{"g18",9000,9999},
|
||||
{"g19",10000,32767}
|
||||
};
|
||||
for (const auto &d : defs) {
|
||||
if (m_groupCount >= kMaxGroups) {
|
||||
std::fprintf(stderr, "Too many groups (limit %d)\n", kMaxGroups);
|
||||
break;
|
||||
}
|
||||
groupRegister(d.n, d.lo, d.hi);
|
||||
}
|
||||
}
|
||||
|
||||
void RegionManager::groupRegister(const char *name, int lo, int hi) {
|
||||
Group &g = m_groups[m_groupCount];
|
||||
WMEMBER_INIT(&g.head);
|
||||
std::string suf = m_opts.suffix;
|
||||
if (m_opts.legacy_trunc && suf.size() > 13) suf.resize(13);
|
||||
std::string full = std::string(name) + suf;
|
||||
g.head.wm_name = bu_strdup(full.c_str());
|
||||
g.lo = lo;
|
||||
g.hi = hi;
|
||||
g.used = false;
|
||||
++m_groupCount;
|
||||
}
|
||||
|
||||
void RegionManager::groupAdd(int identVal, const char *regionName) {
|
||||
for (int i = m_groupCount - 1; i >= 0; --i) {
|
||||
if (identVal < m_groups[i].lo || identVal > m_groups[i].hi) continue;
|
||||
if (!mk_addmember(regionName, &m_groups[i].head.l, nullptr, WMOP_UNION)) {
|
||||
std::fprintf(stderr, "mk_addmember failed adding region '%s' to group '%s'\n",
|
||||
regionName, m_groups[i].head.wm_name);
|
||||
} else {
|
||||
m_groups[i].used = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
std::fprintf(stderr, "Unable to find group for ident %d (region %s)\n", identVal, regionName);
|
||||
}
|
||||
|
||||
bool RegionManager::writeGroups() {
|
||||
wmember allHead;
|
||||
WMEMBER_INIT(&allHead);
|
||||
allHead.wm_name = const_cast<char *>("all.g");
|
||||
|
||||
for (int i = 0; i < m_groupCount; ++i) {
|
||||
Group &g = m_groups[i];
|
||||
if (BU_LIST_IS_EMPTY(&g.head.l)) continue;
|
||||
|
||||
int ret = mk_comb(m_out, g.head.wm_name, &g.head.l, 0,
|
||||
nullptr, nullptr, nullptr,
|
||||
0, 0, 0, 0, 0, 0, 0);
|
||||
if (ret < 0) {
|
||||
std::fprintf(stderr, "mk_comb (group) failed for '%s'\n", g.head.wm_name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!mk_addmember(g.head.wm_name, &allHead.l, nullptr, WMOP_UNION)) {
|
||||
std::fprintf(stderr, "mk_addmember failed adding '%s' to all.g\n", g.head.wm_name);
|
||||
}
|
||||
if (m_opts.verbose) colPrint(g.head.wm_name);
|
||||
}
|
||||
|
||||
int retAll = mk_comb(m_out, "all.g", &allHead.l, 0,
|
||||
nullptr, nullptr, nullptr,
|
||||
0, 0, 0, 0, 0, 0, 0);
|
||||
if (retAll < 0) {
|
||||
std::fprintf(stderr, "mk_comb failed for all.g\n");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
#include "common.h"
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
extern "C" {
|
||||
#include "vmath.h"
|
||||
#include "raytrace.h"
|
||||
#include "wdb.h"
|
||||
}
|
||||
|
||||
#include "ComGeomReader.h"
|
||||
|
||||
struct RegionManagerOptions {
|
||||
int version = 5;
|
||||
long verbose = 0;
|
||||
std::string suffix;
|
||||
int regTotal = 0;
|
||||
bool legacy_trunc = false; // optional legacy suffix truncation (max 13 chars)
|
||||
};
|
||||
|
||||
/*
|
||||
* Modernized RegionManager:
|
||||
* - Uses WMEMBER_INIT for region and group heads (matches wdb.h)
|
||||
* - Avoids container reallocation for group heads (std::array)
|
||||
* - Relies on mk_addmember for node allocation and mk_comb/mk_lfcomb
|
||||
* to free membership lists (per wdb.h contract)
|
||||
* - No manual traversal freeing of member nodes (use mk_freemembers only
|
||||
* for temporary aggregation lists we explicitly build, e.g. "all.g" head)
|
||||
* - Stable addresses: region heads stored in std::vector sized once,
|
||||
* group heads in fixed array
|
||||
*/
|
||||
class RegionManager {
|
||||
public:
|
||||
RegionManager(ComGeomReader &reader, rt_wdb *outfp, const RegionManagerOptions &opts);
|
||||
~RegionManager();
|
||||
|
||||
bool parseRegions(); // read region membership cards
|
||||
bool writeGroups(); // emit group combinations + all.g
|
||||
|
||||
private:
|
||||
std::string makeRegionName(int n) const;
|
||||
void colPrint(const std::string &s);
|
||||
|
||||
bool parseRegionTable();
|
||||
bool parseRegionTableV0();
|
||||
bool parseIdentTable();
|
||||
void registerRegion(int regNum, int id, int air, int mat, int los);
|
||||
|
||||
void groupInit();
|
||||
void groupRegister(const char *name, int lo, int hi);
|
||||
void groupAdd(int identVal, const char *regionName);
|
||||
|
||||
private:
|
||||
ComGeomReader &m_r;
|
||||
rt_wdb *m_out = nullptr;
|
||||
RegionManagerOptions m_opts;
|
||||
|
||||
std::vector<wmember> m_regionHeads; // index by region number (1..regTotal)
|
||||
size_t m_colCur = 0;
|
||||
|
||||
struct Group {
|
||||
wmember head; // combination head (membership list)
|
||||
int lo = 0;
|
||||
int hi = 0;
|
||||
bool used = false; // whether at least one region fell into range
|
||||
};
|
||||
|
||||
static constexpr int kMaxGroups = 21;
|
||||
std::array<Group, kMaxGroups> m_groups;
|
||||
int m_groupCount = 0;
|
||||
};
|
||||
@@ -0,0 +1,830 @@
|
||||
#include "SolidParser.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
/* Helper: build min/max from 6 RPP values */
|
||||
static inline void vset_minmax(fastf_t *min, fastf_t *max, const fastf_t *dd) {
|
||||
VSET(min, dd[0], dd[2], dd[4]);
|
||||
VSET(max, dd[1], dd[3], dd[5]);
|
||||
}
|
||||
|
||||
SolidParser::SolidParser(ComGeomReader &reader, rt_wdb *outfp, const SolidParserOptions &opts)
|
||||
: m_r(reader), m_out(outfp), m_opts(opts)
|
||||
{}
|
||||
|
||||
std::string SolidParser::makeSolidName(int n) const {
|
||||
std::string suf = m_opts.suffix;
|
||||
if (m_opts.legacy_trunc && suf.size() > 13) suf.resize(13);
|
||||
char buf[64] = {0};
|
||||
std::snprintf(buf, sizeof(buf), "s%d%s", n, suf.c_str());
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
void SolidParser::colPrint(const std::string &s) {
|
||||
if (!m_opts.verbose) return;
|
||||
std::fputs(s.c_str(), stdout);
|
||||
m_colCur += s.size();
|
||||
while (m_colCur < 78 && (m_colCur % 10)) {
|
||||
putchar(' ');
|
||||
++m_colCur;
|
||||
}
|
||||
if (m_colCur >= 78) {
|
||||
std::fputc('\n', stdout);
|
||||
m_colCur = 0;
|
||||
}
|
||||
}
|
||||
|
||||
bool SolidParser::parseSolidHeader(const std::string &line, int &solidNum,
|
||||
std::string &typeLower, std::string &lineOutForData) {
|
||||
std::string_view sv(line);
|
||||
std::string solidType;
|
||||
switch (m_opts.version) {
|
||||
case 5:
|
||||
solidNum = ComGeomReader::fieldInt(sv, 0, 5);
|
||||
solidType = ComGeomReader::rstrip(sv.substr(5, 5));
|
||||
break;
|
||||
case 4:
|
||||
solidNum = ComGeomReader::fieldInt(sv, 0, 3);
|
||||
solidType = ComGeomReader::rstrip(sv.substr(3, 4));
|
||||
break;
|
||||
case 1:
|
||||
solidNum = ComGeomReader::fieldInt(sv, 5, 4);
|
||||
solidType = ComGeomReader::rstrip(sv.substr(2, 3));
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
ComGeomReader::toLowerInplace(solidType);
|
||||
typeLower = solidType;
|
||||
lineOutForData = line;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SolidParser::getSolData(std::vector<fastf_t> &data, int num, int solidNum,
|
||||
bool /*firstCardAlreadyRead*/, std::string lineBuf) {
|
||||
data.clear();
|
||||
data.reserve(num);
|
||||
int cd = 1;
|
||||
std::string line = std::move(lineBuf);
|
||||
while (num > 0) {
|
||||
std::string_view sv(line);
|
||||
if (cd != 1) {
|
||||
if ((m_opts.version == 5 && sv.size() > 5 && sv[5] != ' ') ||
|
||||
(m_opts.version == 4 && sv.size() > 3 && sv[3] != ' ')) {
|
||||
std::fprintf(stderr, "solid %d (continuation) card %d non-blank\n", solidNum, cd);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
int j = std::min(num, 6);
|
||||
for (int i = 0; i < j; ++i) {
|
||||
data.push_back(static_cast<fastf_t>(ComGeomReader::fieldDouble(sv, 10 + i*10, 10)));
|
||||
}
|
||||
num -= j;
|
||||
if (num <= 0) break;
|
||||
auto l = m_r.next("solid continuation card");
|
||||
if (!l) {
|
||||
std::fprintf(stderr, "too few cards for solid %d\n", solidNum);
|
||||
return false;
|
||||
}
|
||||
line = l->s;
|
||||
++cd;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SolidParser::getXSolData(std::vector<fastf_t> &data, int num, int solidNum) {
|
||||
data.clear();
|
||||
data.reserve(num);
|
||||
int cd = 1;
|
||||
while (num > 0) {
|
||||
auto l = m_r.next("x solid card");
|
||||
if (!l) {
|
||||
std::fprintf(stderr, "too few cards for solid %d\n", solidNum);
|
||||
return false;
|
||||
}
|
||||
std::string_view sv(l->s);
|
||||
if (cd != 1) {
|
||||
if ((m_opts.version == 5 && sv.size() > 5 && sv[5] != ' ') ||
|
||||
(m_opts.version == 4 && sv.size() > 3 && sv[3] != ' ')) {
|
||||
std::fprintf(stderr, "solid %d (continuation) card %d non-blank\n", solidNum, cd);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
int j = std::min(num, 6);
|
||||
for (int i = 0; i < j; ++i) {
|
||||
data.push_back(static_cast<fastf_t>(ComGeomReader::fieldDouble(sv, 10 + i*10, 10)));
|
||||
}
|
||||
num -= j;
|
||||
++cd;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SolidParser::isIntegerField(std::string_view sv, size_t start, size_t len, int &value) const {
|
||||
if (start >= sv.size()) { value = 0; return false; }
|
||||
size_t end = std::min(sv.size(), start + len);
|
||||
size_t b = start;
|
||||
while (b < end && std::isspace(static_cast<unsigned char>(sv[b]))) b++;
|
||||
if (b >= end) { value = 0; return false; }
|
||||
bool hasDigit=false; bool hasDot=false;
|
||||
for (size_t i=b;i<end;++i) {
|
||||
char c = sv[i];
|
||||
if (std::isspace(static_cast<unsigned char>(c))) break;
|
||||
if (c == '.') hasDot = true;
|
||||
if (std::isdigit(static_cast<unsigned char>(c))) hasDigit = true;
|
||||
else if (c=='-' || c=='+') continue;
|
||||
else { value=0; return false; }
|
||||
}
|
||||
if (!hasDigit || hasDot) { value=0; return false; }
|
||||
std::string token(sv.substr(b, end-b));
|
||||
value = std::atoi(token.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SolidParser::isLikelyFaceCard(const std::string &line) const {
|
||||
// Heuristic: first four 10-char fields each parse as small integers (1..8) & no decimal points.
|
||||
int vals=0;
|
||||
for (int f=0; f<4; ++f) {
|
||||
int v=0;
|
||||
if (!isIntegerField(line, 10 + f*10, 10, v)) return false;
|
||||
if (v < -8 || v > 8 || v==0) return false;
|
||||
vals++;
|
||||
}
|
||||
return (vals==4);
|
||||
}
|
||||
|
||||
/* ---------------- Primitive Handlers ---------------- */
|
||||
|
||||
SolidParseResult SolidParser::handle_ars(const std::string &name, const std::string &firstLine) {
|
||||
std::string_view sv(firstLine);
|
||||
int ncurves = ComGeomReader::fieldInt(sv, 10, 10);
|
||||
int pts_per_curve = ComGeomReader::fieldInt(sv, 20, 10);
|
||||
if (ncurves <= 0 || pts_per_curve <= 0)
|
||||
return {SolidParseResult::Error, "ars invalid counts"};
|
||||
|
||||
std::vector<std::vector<fastf_t>> curves(ncurves);
|
||||
for (int i = 0; i < ncurves; ++i) {
|
||||
curves[i].resize(static_cast<size_t>(pts_per_curve) * 3);
|
||||
if (!getXSolData(curves[i], pts_per_curve * 3, m_solWork))
|
||||
return {SolidParseResult::Error, "ars curve data read failure"};
|
||||
}
|
||||
std::vector<fastf_t*> ptrs(ncurves);
|
||||
for (int i = 0; i < ncurves; ++i) ptrs[i] = curves[i].data();
|
||||
|
||||
int ret = mk_ars(m_out, name.c_str(), ncurves, pts_per_curve, ptrs.data());
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_ars failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_rpp(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 6) return {SolidParseResult::Error, "rpp needs 6 values"};
|
||||
fastf_t min[3], max[3];
|
||||
vset_minmax(min, max, dd.data());
|
||||
int ret = mk_rpp(m_out, name.c_str(), min, max);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_rpp failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_box(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 12) return {SolidParseResult::Error, "box needs 12 values"};
|
||||
point_t v[8];
|
||||
const fastf_t *D0 = &dd[0], *D1=&dd[3], *D2=&dd[6], *D3=&dd[9];
|
||||
VMOVE(v[0], D0);
|
||||
VADD2(v[1], D0, D2);
|
||||
VADD3(v[2], D0, D2, D1);
|
||||
VADD2(v[3], D0, D1);
|
||||
VADD2(v[4], D0, D3);
|
||||
VADD3(v[5], D0, D3, D2);
|
||||
VADD4(v[6], D0, D3, D2, D1);
|
||||
VADD3(v[7], D0, D3, D1);
|
||||
int ret = mk_arb8(m_out, name.c_str(), &v[0][0]);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_arb8(box) failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_raw_wed(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 12) return {SolidParseResult::Error, "raw/wed needs 12 values"};
|
||||
point_t v[8];
|
||||
const fastf_t *D0=&dd[0], *D1=&dd[3], *D2=&dd[6], *D3=&dd[9];
|
||||
VMOVE(v[0], D0);
|
||||
VADD2(v[1], D0, D2);
|
||||
VMOVE(v[2], v[1]);
|
||||
VADD2(v[3], D0, D1);
|
||||
VADD2(v[4], D0, D3);
|
||||
VADD3(v[5], D0, D3, D2);
|
||||
VMOVE(v[6], v[5]);
|
||||
VADD3(v[7], D0, D3, D1);
|
||||
int ret = mk_arb8(m_out, name.c_str(), &v[0][0]);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_arb8(raw/wed) failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_rvw(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 7) return {SolidParseResult::Error, "rvw needs 7 values"};
|
||||
vect_t V; VMOVE(V, &dd[0]);
|
||||
double a2=dd[3], theta=dd[4], phi=dd[5], h2=dd[6];
|
||||
double angle1 = (phi + theta - 90.0) * DEG2RAD;
|
||||
double angle2 = (phi + theta) * DEG2RAD;
|
||||
double a2theta = a2 * std::tan(theta * DEG2RAD);
|
||||
vect_t a,b,c;
|
||||
VSET(a, a2theta * std::cos(angle1), a2theta * std::sin(angle1), 0.0);
|
||||
VSET(b, -a2 * std::cos(angle2), -a2 * std::sin(angle2), 0.0);
|
||||
VSET(c, 0.0, 0.0, h2);
|
||||
point_t vert[8];
|
||||
VSUB2(vert[0], V, b);
|
||||
VMOVE(vert[1], V);
|
||||
VMOVE(vert[2], vert[1]);
|
||||
VADD2(vert[3], vert[0], a);
|
||||
VADD2(vert[4], vert[0], c);
|
||||
VADD2(vert[5], vert[1], c);
|
||||
VMOVE(vert[6], vert[5]);
|
||||
VADD2(vert[7], vert[3], c);
|
||||
int ret = mk_arb8(m_out, name.c_str(), &vert[0][0]);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_arb8(rvw) failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_arw(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 12) return {SolidParseResult::Error, "arw needs 12 values"};
|
||||
point_t v[8];
|
||||
const fastf_t *D0=&dd[0], *D1=&dd[3], *D2=&dd[6], *D3=&dd[9];
|
||||
VMOVE(v[0], D0);
|
||||
VADD2(v[1], D0, D2);
|
||||
VADD3(v[2], D0, D2, D3);
|
||||
VADD2(v[3], D0, D3);
|
||||
VADD2(v[4], D0, D1);
|
||||
VMOVE(v[5], v[4]);
|
||||
VADD3(v[6], D0, D1, D3);
|
||||
VMOVE(v[7], v[6]);
|
||||
int ret = mk_arb8(m_out, name.c_str(), &v[0][0]);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_arb8(arw) failed" : ""};
|
||||
}
|
||||
|
||||
/* ---------- MAGIC v0 ARB generic handler ---------- */
|
||||
SolidParseResult SolidParser::handle_arb_magic(const std::string &name, const std::string &firstLine) {
|
||||
// Multi-card ARB parsing:
|
||||
// Collect vertex triples until a face card is detected (integer ordinals 1..8).
|
||||
std::vector<fastf_t> vertexDoubles;
|
||||
std::string line = firstLine;
|
||||
int maxVertexTriples = 8;
|
||||
|
||||
// Include data from first line if it contains numeric fields (after header)
|
||||
auto consumeVertexCard = [&](const std::string &card) {
|
||||
std::string_view sv(card);
|
||||
for (int i=0;i<6;++i) { // up to 6 fields (i.e., 2 triples per card typical)
|
||||
double val = ComGeomReader::fieldDouble(sv, 10 + i*10, 10);
|
||||
// Heuristic: consider any numeric (including 0) as part of vertex stream until a face card appears
|
||||
vertexDoubles.push_back(static_cast<fastf_t>(val));
|
||||
if ((int)vertexDoubles.size()/3 >= maxVertexTriples) break;
|
||||
}
|
||||
};
|
||||
|
||||
// First card may have header only; attempt to parse numeric fields
|
||||
consumeVertexCard(line);
|
||||
|
||||
// Read continuation cards until face card or limit reached
|
||||
while ((int)vertexDoubles.size()/3 < maxVertexTriples) {
|
||||
auto next = m_r.next("arb card");
|
||||
if (!next) break;
|
||||
if (isLikelyFaceCard(next->s)) {
|
||||
line = next->s;
|
||||
break;
|
||||
}
|
||||
consumeVertexCard(next->s);
|
||||
}
|
||||
|
||||
int vertexCount = static_cast<int>(vertexDoubles.size()/3);
|
||||
if (vertexCount < 4) return {SolidParseResult::Error, "arb v0 insufficient vertices"};
|
||||
|
||||
// Build point array
|
||||
std::vector<fastf_t> verts(vertexCount*3);
|
||||
std::copy(vertexDoubles.begin(), vertexDoubles.begin()+vertexCount*3, verts.begin());
|
||||
|
||||
// Read face cards (up to 6 faces)
|
||||
std::vector<std::vector<int>> faces;
|
||||
while (true) {
|
||||
if (!isLikelyFaceCard(line)) {
|
||||
auto peek = m_r.next("arb maybe face");
|
||||
if (!peek) break;
|
||||
line = peek->s;
|
||||
if (!isLikelyFaceCard(line)) break;
|
||||
}
|
||||
std::string_view sv(line);
|
||||
std::vector<int> f;
|
||||
for (int i=0;i<4;++i) {
|
||||
int v=0;
|
||||
if (isIntegerField(sv, 10 + i*10, 10, v) && v != 0) {
|
||||
f.push_back(v);
|
||||
}
|
||||
}
|
||||
if (!f.empty()) faces.push_back(f);
|
||||
auto next = m_r.next("arb face next");
|
||||
if (!next) break;
|
||||
if (!isLikelyFaceCard(next->s)) {
|
||||
line = next->s; // pass to main dispatcher for next solid
|
||||
break;
|
||||
}
|
||||
line = next->s;
|
||||
if ((int)faces.size() >= 6) break;
|
||||
}
|
||||
|
||||
// Validate face indices
|
||||
for (const auto &f : faces) {
|
||||
for (int idx : f) {
|
||||
int absIdx = std::abs(idx);
|
||||
if (absIdx < 1 || absIdx > vertexCount)
|
||||
return {SolidParseResult::Error, "arb face index out of range"};
|
||||
}
|
||||
}
|
||||
|
||||
// Convert based on vertex count
|
||||
SolidParseResult result;
|
||||
if (vertexCount == 4) {
|
||||
result = handle_arb4(name, verts);
|
||||
} else if (vertexCount == 5) {
|
||||
result = handle_arb5(name, verts);
|
||||
} else if (vertexCount == 6) {
|
||||
result = handle_arb6(name, verts);
|
||||
} else if (vertexCount == 7) {
|
||||
result = handle_arb7(name, verts);
|
||||
} else if (vertexCount == 8) {
|
||||
result = handle_arb8(name, verts);
|
||||
} else {
|
||||
return {SolidParseResult::Error, "arb v0 unsupported vertex count"};
|
||||
}
|
||||
|
||||
if (result.status != SolidParseResult::Ok) {
|
||||
// Fallback to ARBN if vertex-based conversion fails
|
||||
std::fprintf(stderr, "arb v0 vertex conversion failed, attempting arbn fallback\n");
|
||||
// Build plane equations from faces using indices (if enough faces)
|
||||
if (faces.size() >= 4) {
|
||||
// Simple plane extraction: pick triple for each face (first three indices)
|
||||
std::vector<plane_t> eqn(faces.size());
|
||||
for (size_t i=0;i<faces.size();++i) {
|
||||
int a = std::abs(faces[i][0]) - 1;
|
||||
int b = std::abs(faces[i][1]) - 1;
|
||||
int c = std::abs(faces[i][2]) - 1;
|
||||
if (a<0||b<0||c<0 || a>=vertexCount||b>=vertexCount||c>=vertexCount) continue;
|
||||
plane_t pl;
|
||||
point_t pa, pb, pc;
|
||||
VMOVE(pa, &verts[a*3]);
|
||||
VMOVE(pb, &verts[b*3]);
|
||||
VMOVE(pc, &verts[c*3]);
|
||||
struct bn_tol tol;
|
||||
tol.magic = BN_TOL_MAGIC;
|
||||
tol.dist = 0.0005;
|
||||
tol.dist_sq = tol.dist * tol.dist;
|
||||
tol.perp = 1e-6;
|
||||
tol.para = 1 - tol.perp;
|
||||
if (bg_make_plane_3pnts(pl, pa, pb, pc, &tol) == 0) {
|
||||
VMOVE(eqn[i], pl);
|
||||
}
|
||||
}
|
||||
int ret = mk_arbn(m_out, name.c_str(), (int)faces.size(), eqn.data());
|
||||
if (ret < 0) return {SolidParseResult::Error, "arbn fallback failed"};
|
||||
return {SolidParseResult::Ok, ""};
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_arb4(const std::string &name, const std::vector<fastf_t> &dd4) {
|
||||
if (dd4.size() < 12) return {SolidParseResult::Error, "arb4 needs 12 values"};
|
||||
int ret = mk_arb4(m_out, name.c_str(), dd4.data());
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_arb4 failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_arb5(const std::string &name, const std::vector<fastf_t> &dd5) {
|
||||
if (dd5.size() < 15) return {SolidParseResult::Error, "arb5 needs 15 values"};
|
||||
std::vector<fastf_t> dd_full(24);
|
||||
std::copy(dd5.begin(), dd5.begin()+15, dd_full.begin());
|
||||
const fastf_t *v4 = &dd5[12];
|
||||
for (int rep=5; rep<=7; ++rep) std::copy(v4, v4+3, dd_full.begin()+rep*3);
|
||||
int ret = mk_arb8(m_out, name.c_str(), dd_full.data());
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_arb8(arb5) failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_arb6(const std::string &name, const std::vector<fastf_t> &dd6) {
|
||||
if (dd6.size() < 18) return {SolidParseResult::Error, "arb6 needs 18 values"};
|
||||
std::vector<fastf_t> dd_full(24);
|
||||
std::copy(dd6.begin(), dd6.begin()+18, dd_full.begin());
|
||||
const fastf_t *D4=&dd6[12];
|
||||
const fastf_t *D5=&dd6[15];
|
||||
std::copy(D4, D4+3, dd_full.begin()+5*3); // D(5)=D(4)
|
||||
std::copy(D5, D5+3, dd_full.begin()+6*3); // D(6)=original D(5)
|
||||
std::copy(D5, D5+3, dd_full.begin()+7*3); // D(7)=original D(5)
|
||||
int ret = mk_arb8(m_out, name.c_str(), dd_full.data());
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_arb8(arb6) failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_arb7(const std::string &name, const std::vector<fastf_t> &dd7) {
|
||||
if (dd7.size() < 21) return {SolidParseResult::Error, "arb7 needs 21 values"};
|
||||
std::vector<fastf_t> dd_full(24);
|
||||
std::copy(dd7.begin(), dd7.begin()+21, dd_full.begin());
|
||||
const fastf_t *D4=&dd7[12];
|
||||
std::copy(D4, D4+3, dd_full.begin()+7*3); // D(7)=D(4)
|
||||
int ret = mk_arb8(m_out, name.c_str(), dd_full.data());
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_arb8(arb7) failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_arb8(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 24) return {SolidParseResult::Error, "arb8 needs 24 values"};
|
||||
int ret = mk_arb8(m_out, name.c_str(), dd.data());
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_arb8 failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_rcc(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 7) return {SolidParseResult::Error, "rcc needs 7 values"};
|
||||
int ret = mk_rcc(m_out, name.c_str(), dd.data(), dd.data()+3, dd[6]);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_rcc failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_rec(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 12) return {SolidParseResult::Error, "rec needs 12 values"};
|
||||
int ret = mk_tgc(m_out, name.c_str(), dd.data(), dd.data()+3, dd.data()+6, dd.data()+9, dd.data()+6, dd.data()+9);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_tgc(rec) failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_trc(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 8) return {SolidParseResult::Error, "trc needs 8 values"};
|
||||
int ret = mk_trc_h(m_out, name.c_str(), dd.data(), dd.data()+3, dd[6], dd[7]);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_trc_h failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_tec(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 13) return {SolidParseResult::Error, "tec needs 13 values"};
|
||||
double p = dd[12];
|
||||
if (NEAR_ZERO(p, SMALL)) return {SolidParseResult::Error, "tec p cannot be zero"};
|
||||
vect_t topA, topB;
|
||||
double invp = 1.0/p;
|
||||
VSCALE(topA, dd.data()+6, invp);
|
||||
VSCALE(topB, dd.data()+9, invp);
|
||||
int ret = mk_tgc(m_out, name.c_str(), dd.data(), dd.data()+3, dd.data()+6, dd.data()+9, topA, topB);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_tgc(tec) failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_tgc(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 14) return {SolidParseResult::Error, "tgc needs 14 values"};
|
||||
vect_t topA, topB;
|
||||
double magBaseA = MAGNITUDE(dd.data()+6);
|
||||
double magBaseB = MAGNITUDE(dd.data()+9);
|
||||
if (magBaseA < SMALL || magBaseB < SMALL) return {SolidParseResult::Error,"tgc zero-length A or B"};
|
||||
VSCALE(topA, dd.data()+6, dd[12]/magBaseA);
|
||||
VSCALE(topB, dd.data()+9, dd[13]/magBaseB);
|
||||
int ret = mk_tgc(m_out, name.c_str(), dd.data(), dd.data()+3, dd.data()+6, dd.data()+9, topA, topB);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_tgc failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_sph(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 4) return {SolidParseResult::Error, "sph needs 4 values"};
|
||||
int ret = mk_sph(m_out, name.c_str(), dd.data(), dd[3]);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_sph failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_wir(const std::string &name, const std::string &firstLine) {
|
||||
int numpts = ComGeomReader::fieldInt(firstLine, 8, 2);
|
||||
if (numpts <= 0) return {SolidParseResult::Error, "wir invalid point count"};
|
||||
int total = numpts*3 + 1;
|
||||
std::vector<fastf_t> raw;
|
||||
if (!getSolData(raw, total, m_solWork, true, firstLine))
|
||||
return {SolidParseResult::Error, "wir data read failure"};
|
||||
if ((int)raw.size() < total) return {SolidParseResult::Error, "wir insufficient data"};
|
||||
double radius = raw.back();
|
||||
if (radius <= 0.0) return {SolidParseResult::Error, "wir invalid radius"};
|
||||
double diameter = radius * 2.0;
|
||||
struct bu_list head;
|
||||
BU_LIST_INIT(&head);
|
||||
for (int i=0;i<numpts;++i) {
|
||||
struct wdb_pipe_pnt *ps;
|
||||
BU_ALLOC(ps, struct wdb_pipe_pnt);
|
||||
ps->l.magic = WDB_PIPESEG_MAGIC;
|
||||
VMOVE(ps->pp_coord, &raw[i*3]);
|
||||
ps->pp_id = 0;
|
||||
ps->pp_od = diameter;
|
||||
ps->pp_bendradius = diameter;
|
||||
BU_LIST_INSERT(&head, &ps->l);
|
||||
}
|
||||
int ret = mk_pipe(m_out, name.c_str(), &head);
|
||||
mk_pipe_free(&head);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_pipe failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_rpc(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 10) return {SolidParseResult::Error, "rpc needs 10 values"};
|
||||
int ret = mk_rpc(m_out, name.c_str(), dd.data(), dd.data()+3, dd.data()+6, dd[9]);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_rpc failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_rhc(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 11) return {SolidParseResult::Error, "rhc needs 11 values"};
|
||||
int ret = mk_rhc(m_out, name.c_str(), dd.data(), dd.data()+3, dd.data()+6, dd[9], dd[10]);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_rhc failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_epa(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 11) return {SolidParseResult::Error, "epa needs 11 values"};
|
||||
int ret = mk_epa(m_out, name.c_str(), dd.data(), dd.data()+3, dd.data()+6, dd[9], dd[10]);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_epa failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_ehy(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 12) return {SolidParseResult::Error, "ehy needs 12 values"};
|
||||
int ret = mk_ehy(m_out, name.c_str(), dd.data(), dd.data()+3, dd.data()+6, dd[9], dd[10], dd[11]);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_ehy failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_eto(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 11) return {SolidParseResult::Error, "eto needs 11 values"};
|
||||
int ret = mk_eto(m_out, name.c_str(), dd.data(), dd.data()+3, dd.data()+6, dd[9], dd[10]);
|
||||
return {ret < 0 ? SolidParseResult::Error : SolidParseResult::Ok, ret < 0 ? "mk_eto failed" : ""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_v4_ell_transform(const std::string &name, const std::vector<fastf_t> &foci) {
|
||||
if (foci.size() < 7) return {SolidParseResult::Error, "v4 ell needs 7 values"};
|
||||
vect_t F1,F2; VMOVE(F1,&foci[0]); VMOVE(F2,&foci[3]);
|
||||
double L = foci[6];
|
||||
vect_t V; VADD2SCALE(V,F1,F2,0.5);
|
||||
vect_t work; VSUB2(work,F2,F1);
|
||||
double m1 = MAGNITUDE(work);
|
||||
if (m1 < SMALL) return {SolidParseResult::Error,"degenerate ell"};
|
||||
double a_scale = (L*0.5)/m1;
|
||||
vect_t baseA; VSCALE(baseA, work, a_scale);
|
||||
double r = std::sqrt(MAGSQ(baseA) - (m1*0.5)*(m1*0.5));
|
||||
if (!(r>0.0)) return {SolidParseResult::Error,"invalid ell radius"};
|
||||
vect_t Vp{V[0]+M_PI, V[1]+M_PI, V[2]+M_PI};
|
||||
vect_t B; VCROSS(B,Vp,baseA); double mb=MAGNITUDE(B); if (mb<SMALL) return {SolidParseResult::Error,"degenerate B"};
|
||||
VSCALE(B,B,r/mb);
|
||||
vect_t C; VCROSS(C,baseA,B); double mc=MAGNITUDE(C); if (mc<SMALL) return {SolidParseResult::Error,"degenerate C"};
|
||||
VSCALE(C,C,r/mc);
|
||||
int ret = mk_ell(m_out, name.c_str(), V, baseA, B, C);
|
||||
return {ret<0?SolidParseResult::Error:SolidParseResult::Ok, ret<0?"mk_ell failed":""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_ell(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 7) return {SolidParseResult::Error, "ell needs 7 values"};
|
||||
vect_t V, A; VMOVE(V,&dd[0]); VMOVE(A,&dd[3]);
|
||||
double r = dd[6]; if (r <= 0.0) return {SolidParseResult::Error,"invalid ell radius"};
|
||||
vect_t Vp{V[0]+M_PI,V[1]+M_PI,V[2]+M_PI};
|
||||
vect_t B; VCROSS(B,Vp,A); double mb=MAGNITUDE(B); if (mb<SMALL) return {SolidParseResult::Error,"degenerate B"};
|
||||
VSCALE(B,B,r/mb);
|
||||
vect_t C; VCROSS(C,A,B); double mc=MAGNITUDE(C); if (mc<SMALL) return {SolidParseResult::Error,"degenerate C"};
|
||||
VSCALE(C,C,r/mc);
|
||||
int ret = mk_ell(m_out, name.c_str(), V, A, B, C);
|
||||
return {ret<0?SolidParseResult::Error:SolidParseResult::Ok, ret<0?"mk_ell failed":""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_ellg(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 12) return {SolidParseResult::Error, "ellg needs 12 values"};
|
||||
int ret = mk_ell(m_out, name.c_str(), dd.data(), dd.data()+3, dd.data()+6, dd.data()+9);
|
||||
return {ret<0?SolidParseResult::Error:SolidParseResult::Ok, ret<0?"mk_ell failed":""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_tor(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 8) return {SolidParseResult::Error, "tor needs 8 values"};
|
||||
int ret = mk_tor(m_out, name.c_str(), dd.data(), dd.data()+3, dd[6], dd[7]);
|
||||
return {ret<0?SolidParseResult::Error:SolidParseResult::Ok, ret<0?"mk_tor failed":""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_haf(const std::string &name, const std::vector<fastf_t> &dd) {
|
||||
if (dd.size() < 4) return {SolidParseResult::Error, "haf needs 4 values"};
|
||||
vect_t N; VMOVE(N,&dd[0]);
|
||||
double magN = MAGNITUDE(N);
|
||||
if (magN < SMALL) return {SolidParseResult::Error,"haf normal too small"};
|
||||
int ret = mk_half(m_out, name.c_str(), N, -dd[3]);
|
||||
return {ret<0?SolidParseResult::Error:SolidParseResult::Ok, ret<0?"mk_half failed":""};
|
||||
}
|
||||
|
||||
SolidParseResult SolidParser::handle_arbn(const std::string &name, const std::string &firstLine) {
|
||||
std::string_view sv(firstLine);
|
||||
int npt = ComGeomReader::fieldInt(sv, 10 + 0*10, 10);
|
||||
int npe = ComGeomReader::fieldInt(sv, 10 + 1*10, 10);
|
||||
int neq = ComGeomReader::fieldInt(sv, 10 + 2*10, 10);
|
||||
int nae = ComGeomReader::fieldInt(sv, 10 + 3*10, 10);
|
||||
int nface = npe + neq + nae;
|
||||
if (npt < 1) return {SolidParseResult::Error, "arbn requires >=1 point"};
|
||||
if (nface < 1) return {SolidParseResult::Error, "arbn requires >=1 face"};
|
||||
|
||||
std::vector<plane_t> eqn(static_cast<size_t>(nface));
|
||||
std::vector<int> used(static_cast<size_t>(nface), 0);
|
||||
std::vector<fastf_t> input_points;
|
||||
input_points.reserve(npt*3);
|
||||
|
||||
struct bn_tol tol;
|
||||
tol.magic = BN_TOL_MAGIC;
|
||||
tol.dist = 0.0005;
|
||||
tol.dist_sq = tol.dist * tol.dist;
|
||||
tol.perp = 1e-6;
|
||||
tol.para = 1 - tol.perp;
|
||||
|
||||
{ // vertex points
|
||||
std::vector<fastf_t> pts;
|
||||
if (!getXSolData(pts, npt*3, m_solWork))
|
||||
return {SolidParseResult::Error, "arbn vertex read failure"};
|
||||
input_points = std::move(pts);
|
||||
}
|
||||
|
||||
int cur_eq = 0;
|
||||
int symm = 0;
|
||||
|
||||
/* Planes from triples */
|
||||
for (int i=0;i<npe;i+=6) {
|
||||
auto l = m_r.next("arbn vertex point indices");
|
||||
if (!l) return {SolidParseResult::Error,"arbn EOF (point indices)"};
|
||||
std::string_view lv(l->s);
|
||||
for (int j=0;j<6 && (i+j)<npe;++j) {
|
||||
int q = ComGeomReader::fieldInt(lv, 10 + j*10 + 0, 4);
|
||||
int r = ComGeomReader::fieldInt(lv, 10 + j*10 + 4, 3);
|
||||
int s = ComGeomReader::fieldInt(lv, 10 + j*10 + 7, 3);
|
||||
if (q==0 || r==0 || s==0) continue;
|
||||
point_t a,b,c;
|
||||
auto fetch = [&](int idx, point_t &out){
|
||||
int abs_idx = std::abs(idx)-1;
|
||||
VMOVE(out, &input_points[abs_idx*3]);
|
||||
if (idx < 0) { out[Y] = -out[Y]; symm = 1; }
|
||||
};
|
||||
fetch(q,a); fetch(r,b); fetch(s,c);
|
||||
if (bg_make_plane_3pnts(eqn[cur_eq], a, b, c, &tol) < 0) {
|
||||
if (m_opts.verbose) std::fprintf(stderr,"arbn degenerate 3p plane ignored\n");
|
||||
continue;
|
||||
}
|
||||
++cur_eq;
|
||||
}
|
||||
}
|
||||
|
||||
/* Planes from equations */
|
||||
for (int i=0;i<neq;++i) {
|
||||
auto l = m_r.next("arbn plane equation card");
|
||||
if (!l) return {SolidParseResult::Error,"arbn EOF (equation)"};
|
||||
std::string_view lv(l->s);
|
||||
eqn[cur_eq][X] = ComGeomReader::fieldDouble(lv, 10, 10);
|
||||
eqn[cur_eq][Y] = ComGeomReader::fieldDouble(lv, 20, 10);
|
||||
eqn[cur_eq][Z] = ComGeomReader::fieldDouble(lv, 30, 10);
|
||||
eqn[cur_eq][W] = ComGeomReader::fieldDouble(lv, 40, 10);
|
||||
double scale = MAGNITUDE(eqn[cur_eq]);
|
||||
if (scale < SMALL) {
|
||||
if (m_opts.verbose) std::fprintf(stderr,"arbn plane normal too small (eqn)\n");
|
||||
continue;
|
||||
}
|
||||
scale = 1.0/scale;
|
||||
VSCALE(eqn[cur_eq], eqn[cur_eq], scale);
|
||||
eqn[cur_eq][W] *= scale;
|
||||
++cur_eq;
|
||||
}
|
||||
|
||||
/* Planes from az/el */
|
||||
for (int i=0;i<nae;i+=2) {
|
||||
auto l = m_r.next("arbn az/el card");
|
||||
if (!l) return {SolidParseResult::Error,"arbn EOF (az/el)"};
|
||||
std::string_view lv(l->s);
|
||||
for (int j=0;j<2 && (i+j)<nae;++j) {
|
||||
double az = ComGeomReader::fieldDouble(lv, 10 + j*30 + 0*10, 10) * DEG2RAD;
|
||||
double el = ComGeomReader::fieldDouble(lv, 10 + j*30 + 1*10, 10) * DEG2RAD;
|
||||
int vert_no = ComGeomReader::fieldInt(lv, 10 + j*30 + 2*10, 10);
|
||||
if (vert_no == 0) break;
|
||||
double cos_el = std::cos(el);
|
||||
eqn[cur_eq][X] = std::cos(az) * cos_el;
|
||||
eqn[cur_eq][Y] = std::sin(az) * cos_el;
|
||||
eqn[cur_eq][Z] = std::sin(el);
|
||||
point_t pt;
|
||||
int abs_idx = std::abs(vert_no)-1;
|
||||
VMOVE(pt, &input_points[abs_idx*3]);
|
||||
if (vert_no < 0) pt[Y] = -pt[Y];
|
||||
eqn[cur_eq][W] = VDOT(pt, eqn[cur_eq]);
|
||||
++cur_eq;
|
||||
}
|
||||
}
|
||||
|
||||
if (cur_eq != nface)
|
||||
return {SolidParseResult::Error, "arbn face count mismatch"};
|
||||
|
||||
point_t cent; VSETALL(cent, 0);
|
||||
for (int i=0;i<npt;++i) VADD2(cent, cent, &input_points[i*3]);
|
||||
VSCALE(cent, cent, 1.0/static_cast<double>(npt));
|
||||
if (symm) cent[Y] = 0.0;
|
||||
|
||||
constexpr double DIST_TOL = 1.0e-8;
|
||||
for (int i=0;i<nface;++i) {
|
||||
double dist = VDOT(eqn[i], cent) - eqn[i][W];
|
||||
if (dist < -DIST_TOL) continue;
|
||||
if (dist > DIST_TOL) {
|
||||
VREVERSE(eqn[i], eqn[i]);
|
||||
eqn[i][W] = -eqn[i][W];
|
||||
} else {
|
||||
return {SolidParseResult::Error,"arbn centroid lies on face"};
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<double> vertex;
|
||||
int last_vertex = 0;
|
||||
for (int i=0;i<nface-2;++i) {
|
||||
for (int j=i+1;j<nface-1;++j) {
|
||||
double dot = VDOT(eqn[i], eqn[j]);
|
||||
if (!NEAR_ZERO(dot, 0.999999)) continue;
|
||||
for (int k=j+1;k<nface;++k) {
|
||||
point_t pt;
|
||||
if (bg_make_pnt_3planes(pt, eqn[i], eqn[j], eqn[k]) < 0) continue;
|
||||
bool outside=false;
|
||||
for (int m=0;m<nface;++m) {
|
||||
if (m==i||m==j||m==k) continue;
|
||||
if (VDOT(pt, eqn[m]) - eqn[m][W] > DIST_TOL) { outside=true; break; }
|
||||
}
|
||||
if (outside) continue;
|
||||
bool duplicate=false;
|
||||
for (int m=0;m<last_vertex;++m) {
|
||||
vect_t dv; VSUB2(dv, pt, &vertex[m*3]);
|
||||
if (MAGSQ(dv) < 1.0e-10) { duplicate=true; break; }
|
||||
}
|
||||
if (duplicate) continue;
|
||||
vertex.resize((last_vertex+1)*3);
|
||||
VMOVE(&vertex[last_vertex*3], pt);
|
||||
++last_vertex;
|
||||
used[i]++; used[j]++; used[k]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int i=0;i<nface;++i)
|
||||
if (used[i]==0)
|
||||
return {SolidParseResult::Error,"arbn face unused (not convex)"};
|
||||
|
||||
int ret = mk_arbn(m_out, name.c_str(), nface, eqn.data());
|
||||
return {ret<0?SolidParseResult::Error:SolidParseResult::Ok, ret<0?"mk_arbn failed":""};
|
||||
}
|
||||
|
||||
/* ---------------- Dispatcher ---------------- */
|
||||
SolidParseResult SolidParser::parseNext() {
|
||||
if (m_solWork >= m_solTotal && m_solTotal > 0) {
|
||||
return {SolidParseResult::Eof, ""};
|
||||
}
|
||||
|
||||
auto l = m_r.next("solid card");
|
||||
if (!l) return {SolidParseResult::Eof, ""};
|
||||
|
||||
int solidNum = 0;
|
||||
std::string type, firstLine = l->s;
|
||||
if (!parseSolidHeader(l->s, solidNum, type, firstLine)) {
|
||||
if (m_solWork >= m_solTotal) return {SolidParseResult::Eof, ""};
|
||||
return {SolidParseResult::Error, "invalid solid header"};
|
||||
}
|
||||
|
||||
// v1 end sentinel handled in region code; in solids, "end" terminates
|
||||
if (type == "end") {
|
||||
return {SolidParseResult::Eof, ""};
|
||||
}
|
||||
|
||||
if (m_opts.version == 5) {
|
||||
int expectedNext = m_solWork + 1;
|
||||
if (solidNum != expectedNext) {
|
||||
// proceed but warn
|
||||
std::fprintf(stderr,
|
||||
"warning: solid numbering mismatch: expected %d, got %d - consuming this solid to maintain alignment\n",
|
||||
expectedNext, solidNum);
|
||||
}
|
||||
}
|
||||
|
||||
// Always advance processed count; names use sequential counter
|
||||
m_solWork++;
|
||||
|
||||
std::string name = makeSolidName(m_solWork);
|
||||
colPrint(name);
|
||||
|
||||
std::vector<fastf_t> dd;
|
||||
|
||||
if (type == "ars") return handle_ars(name, firstLine);
|
||||
if (type == "rpp") { if(!getSolData(dd,6,solidNum,true,firstLine)) return {SolidParseResult::Error,"rpp data read"}; return handle_rpp(name,dd); }
|
||||
if (type == "box") { if(!getSolData(dd,12,solidNum,true,firstLine)) return {SolidParseResult::Error,"box data read"}; return handle_box(name,dd); }
|
||||
if (type == "raw" || type == "wed") { if(!getSolData(dd,12,solidNum,true,firstLine)) return {SolidParseResult::Error,"raw/wed data read"}; return handle_raw_wed(name,dd); }
|
||||
if (type == "rvw") { if(!getSolData(dd,7,solidNum,true,firstLine)) return {SolidParseResult::Error,"rvw data read"}; return handle_rvw(name,dd); }
|
||||
if (type == "arw") { if(!getSolData(dd,12,solidNum,true,firstLine)) return {SolidParseResult::Error,"arw data read"}; return handle_arw(name,dd); }
|
||||
if (m_opts.version == 0 && type == "arb") return handle_arb_magic(name, firstLine);
|
||||
if (type == "arb4") { if(!getSolData(dd,12,solidNum,true,firstLine)) return {SolidParseResult::Error,"arb4 data read"}; return handle_arb4(name,dd); }
|
||||
if (type == "arb5") { if(!getSolData(dd,15,solidNum,true,firstLine)) return {SolidParseResult::Error,"arb5 data read"}; return handle_arb5(name,dd); }
|
||||
if (type == "arb6") { if(!getSolData(dd,18,solidNum,true,firstLine)) return {SolidParseResult::Error,"arb6 data read"}; return handle_arb6(name,dd); }
|
||||
if (type == "arb7") { if(!getSolData(dd,21,solidNum,true,firstLine)) return {SolidParseResult::Error,"arb7 data read"}; return handle_arb7(name,dd); }
|
||||
if (type == "arb8") { if(!getSolData(dd,24,solidNum,true,firstLine)) return {SolidParseResult::Error,"arb8 data read"}; return handle_arb8(name,dd); }
|
||||
if (type == "rcc") { if(!getSolData(dd,7,solidNum,true,firstLine)) return {SolidParseResult::Error,"rcc data read"}; return handle_rcc(name,dd); }
|
||||
if (type == "rec") { if(!getSolData(dd,12,solidNum,true,firstLine)) return {SolidParseResult::Error,"rec data read"}; return handle_rec(name,dd); }
|
||||
if (type == "trc") { if(!getSolData(dd,8,solidNum,true,firstLine)) return {SolidParseResult::Error,"trc data read"}; return handle_trc(name,dd); }
|
||||
if (type == "tec") { if(!getSolData(dd,13,solidNum,true,firstLine)) return {SolidParseResult::Error,"tec data read"}; return handle_tec(name,dd); }
|
||||
if (type == "tgc") { if(!getSolData(dd,14,solidNum,true,firstLine)) return {SolidParseResult::Error,"tgc data read"}; return handle_tgc(name,dd); }
|
||||
if (type == "sph") { if(!getSolData(dd,4,solidNum,true,firstLine)) return {SolidParseResult::Error,"sph data read"}; return handle_sph(name,dd); }
|
||||
if (type.rfind("wir",0)==0) return handle_wir(name, firstLine);
|
||||
if (type == "rpc") { if(!getSolData(dd,10,solidNum,true,firstLine)) return {SolidParseResult::Error,"rpc data read"}; return handle_rpc(name,dd); }
|
||||
if (type == "rhc") { if(!getSolData(dd,11,solidNum,true,firstLine)) return {SolidParseResult::Error,"rhc data read"}; return handle_rhc(name,dd); }
|
||||
if (type == "epa") { if(!getSolData(dd,11,solidNum,true,firstLine)) return {SolidParseResult::Error,"epa data read"}; return handle_epa(name,dd); }
|
||||
if (type == "ehy") { if(!getSolData(dd,12,solidNum,true,firstLine)) return {SolidParseResult::Error,"ehy data read"}; return handle_ehy(name,dd); }
|
||||
if (type == "eto") { if(!getSolData(dd,11,solidNum,true,firstLine)) return {SolidParseResult::Error,"eto data read"}; return handle_eto(name,dd); }
|
||||
if (type == "ellg") { if(!getSolData(dd,12,solidNum,true,firstLine)) return {SolidParseResult::Error,"ellg data read"}; return handle_ellg(name,dd); }
|
||||
if (type == "ell" || type == "ell1") {
|
||||
if (m_opts.version <=4 && type=="ell") {
|
||||
if (!getSolData(dd,7,solidNum,true,firstLine)) return {SolidParseResult::Error,"v4 ell data read"};
|
||||
return handle_v4_ell_transform(name, dd);
|
||||
} else {
|
||||
if (!getSolData(dd,7,solidNum,true,firstLine)) return {SolidParseResult::Error,"ell data read"};
|
||||
return handle_ell(name, dd);
|
||||
}
|
||||
}
|
||||
if (type == "tor") { if(!getSolData(dd,8,solidNum,true,firstLine)) return {SolidParseResult::Error,"tor data read"}; return handle_tor(name,dd); }
|
||||
if (type == "haf") { if(!getSolData(dd,4,solidNum,true,firstLine)) return {SolidParseResult::Error,"haf data read"}; return handle_haf(name,dd); }
|
||||
if (type == "arbn") return handle_arbn(name, firstLine);
|
||||
|
||||
std::fprintf(stderr, "getsolid: unsupported type '%s'\n", type.c_str());
|
||||
return {SolidParseResult::Error, "unsupported solid"};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#ifndef BRLCAD_SOLID_PARSER_H
|
||||
#define BRLCAD_SOLID_PARSER_H
|
||||
|
||||
#include "common.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <optional>
|
||||
|
||||
extern "C" {
|
||||
#include "vmath.h"
|
||||
#include "raytrace.h"
|
||||
#include "wdb.h"
|
||||
#include "bg.h"
|
||||
}
|
||||
|
||||
#include "ComGeomReader.h"
|
||||
|
||||
struct SolidParseResult {
|
||||
enum Status { Ok, Eof, Error } status = Error;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
struct SolidParserOptions {
|
||||
int version = 5;
|
||||
int verbose = 0;
|
||||
std::string suffix;
|
||||
bool legacy_trunc = false;
|
||||
};
|
||||
|
||||
class SolidParser {
|
||||
public:
|
||||
SolidParser(ComGeomReader &reader, rt_wdb *outfp, const SolidParserOptions &opts);
|
||||
SolidParseResult parseNext();
|
||||
int solidsExpected() const { return m_solTotal; }
|
||||
int solidsProcessed() const { return m_solWork; }
|
||||
void setSolidsExpected(int c) { m_solTotal = c; }
|
||||
|
||||
private:
|
||||
std::string makeSolidName(int n) const;
|
||||
void colPrint(const std::string &s);
|
||||
bool parseSolidHeader(const std::string &line, int &solidNum,
|
||||
std::string &typeLower, std::string &lineOutForData);
|
||||
bool getSolData(std::vector<fastf_t> &data, int num, int solidNum,
|
||||
bool firstCardAlreadyRead, std::string lineBuf);
|
||||
bool getXSolData(std::vector<fastf_t> &data, int num, int solidNum);
|
||||
|
||||
/* Helpers */
|
||||
bool isIntegerField(std::string_view sv, size_t start, size_t len, int &value) const;
|
||||
bool isLikelyFaceCard(const std::string &line) const;
|
||||
|
||||
/* Primitive handlers */
|
||||
SolidParseResult handle_ars(const std::string &name, const std::string &firstLine);
|
||||
SolidParseResult handle_rpp(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_box(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_raw_wed(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_rvw(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_arw(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_arb_magic(const std::string &name, const std::string &firstLine);
|
||||
SolidParseResult handle_arb4(const std::string &name, const std::vector<fastf_t> &dd4);
|
||||
SolidParseResult handle_arb5(const std::string &name, const std::vector<fastf_t> &dd5);
|
||||
SolidParseResult handle_arb6(const std::string &name, const std::vector<fastf_t> &dd6);
|
||||
SolidParseResult handle_arb7(const std::string &name, const std::vector<fastf_t> &dd7);
|
||||
SolidParseResult handle_arb8(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_rcc(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_rec(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_trc(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_tec(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_tgc(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_sph(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_wir(const std::string &name, const std::string &firstLine);
|
||||
SolidParseResult handle_rpc(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_rhc(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_epa(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_ehy(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_eto(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_v4_ell_transform(const std::string &name, const std::vector<fastf_t> &foci);
|
||||
SolidParseResult handle_ell(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_ellg(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_tor(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_haf(const std::string &name, const std::vector<fastf_t> &dd);
|
||||
SolidParseResult handle_arbn(const std::string &name, const std::string &firstLine);
|
||||
|
||||
private:
|
||||
ComGeomReader &m_r;
|
||||
rt_wdb *m_out = nullptr;
|
||||
SolidParserOptions m_opts;
|
||||
int m_solTotal = 0;
|
||||
int m_solWork = 0;
|
||||
size_t m_colCur = 0;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,376 @@
|
||||
================================================================================
|
||||
BRL-CAD BoT → COMGEOM ARBN Export Pipeline
|
||||
RLM (Robust Low-Poly Meshing Inspired) + HACD/V-HACD Convex Decomposition
|
||||
================================================================================
|
||||
|
||||
Purpose:
|
||||
--------
|
||||
Provide a clear, implementation-oriented blueprint for converting arbitrary
|
||||
triangle meshes (BOTs) into a set of convex COMGEOM ARBN solids suitable for
|
||||
region assembly. This document is intended both for human developers and as
|
||||
context for future GPT-assisted sessions.
|
||||
|
||||
Context:
|
||||
--------
|
||||
COMGEOM does not have a native arbitrary triangle mesh primitive. ARBN
|
||||
(primitives defined by intersection of half-spaces / planes) can represent
|
||||
convex polyhedra only. Arbitrary non-convex BOT geometry must be decomposed
|
||||
into convex parts, each described as an ARBN. To reduce complexity and noise,
|
||||
we first simplify and planarize the mesh with an RLM-inspired stage, then
|
||||
decompose using HACD/V-HACD, and finally produce ARBNs.
|
||||
|
||||
High-Level Pipeline Stages:
|
||||
---------------------------
|
||||
1. Mesh Acquisition & Validation
|
||||
2. RLM-Inspired Simplification (Planar Clustering & Polygon Extraction)
|
||||
3. Optional Primitive Recognition (Planes, Cylinders, Cones) (future enhancement)
|
||||
4. Convex Decomposition (HACD/V-HACD or fallback hull partitioning)
|
||||
5. Convex Part → Plane Set Conversion
|
||||
6. ARBN Construction via Incremental Half-Space Clipping
|
||||
7. Region Assembly (OR union of convex ARBN solids)
|
||||
8. Quality Assurance (QA) & Reporting
|
||||
9. Output COMGEOM Deck (version selectable: v1 / v4 / v5)
|
||||
10. Optional JSON summary and statistics
|
||||
|
||||
Key Design Goals:
|
||||
-----------------
|
||||
- Produce clean, planar-faced convex solids minimizing plane count.
|
||||
- Heal small cracks, near-coplanar face fragmentation, and sliver triangles.
|
||||
- Provide tunable tolerances for clustering, plane fitting, snapping, and decomposition.
|
||||
- Maintain sharp features (edges with large dihedral angles).
|
||||
- Scale to large meshes (hundreds of thousands of triangles) via segmentation.
|
||||
|
||||
Terminology:
|
||||
------------
|
||||
BOT : BRL-CAD triangle mesh primitive.
|
||||
RLM-inspired : Robust Low-Poly Meshing style operations (plane clustering, feature retention).
|
||||
HACD/V-HACD : Hierarchical Approximate Convex Decomposition algorithms.
|
||||
ARBN : COMGEOM arbitrary N-plane convex solid.
|
||||
Plane cluster : Group of triangles forming an approximate planar patch.
|
||||
Convex part : Output of HACD or grouping guaranteed to be convex.
|
||||
Clipper : Incremental half-space intersection algorithm producing ARBN vertices.
|
||||
Region : COMGEOM Boolean combination (union/intersection/subtraction) of solids.
|
||||
|
||||
Assumptions:
|
||||
------------
|
||||
- BOT input is either closed and manifold or can be repaired (hole fill / thickening).
|
||||
- Decomposition tools are available (HACD or V-HACD library; otherwise fallback convex hull).
|
||||
- Numeric tolerances are chosen relative to model scale (bbox diagonal).
|
||||
- Complexity controls (max planes per ARBN, max clusters, max convex parts) prevent runaway resource usage.
|
||||
|
||||
Environment / Flags (Proposed):
|
||||
-------------------------------
|
||||
--simplify=none|rlm : Enable RLM-stage planar clustering.
|
||||
--rlm-angle=deg : Angle threshold for normal clustering (default 2.0).
|
||||
--rlm-dist=scale : Distance tolerance for plane residual (default 1e-3 * bbox_diag).
|
||||
--rlm-snap=scale : Vertex snap tolerance (default 5e-4 * bbox_diag).
|
||||
--rlm-sharp=deg : Dihedral angle above which edges are preserved (default 30).
|
||||
--rlm-min-area=scale^2 : Minimum polygon area; discard degenerates.
|
||||
--hacd=on|off : Enable HACD decomposition stage.
|
||||
--hacd-concavity=value : HACD concavity parameter.
|
||||
--hacd-max-parts=N : Max number of convex parts.
|
||||
--hacd-min-volume=scale^3 : Minimum part volume threshold.
|
||||
--decomp-order=pre|post : RLM before or after HACD (default pre).
|
||||
--max-planes-per-arbn=N : Safety cap (default 512).
|
||||
--arbn-hash=on|off : Spatial hash for vertex deduplication (default on).
|
||||
--arbn-stats=path : Stats dump file.
|
||||
--export-version=1|4|5 : COMGEOM dialect (default 5).
|
||||
--json=path : Emit JSON export report.
|
||||
--material-map=file.json : Attribute enrichment mapping.
|
||||
--color-map=file.json : Region color mapping.
|
||||
|
||||
Stage 1: Mesh Acquisition & Validation
|
||||
--------------------------------------
|
||||
Inputs: BOT primitive (triangle list).
|
||||
Steps:
|
||||
1. Load triangles; compute bounding box.
|
||||
2. Verify manifold conditions:
|
||||
- Each edge referenced by 1 or 2 faces.
|
||||
- No zero-area triangles.
|
||||
3. If open surface and user requests solid export:
|
||||
- Either abort or apply thickening (offset + stitch) – optional future feature.
|
||||
4. Deduplicate vertices (hash by quantized position).
|
||||
5. Compute per-face normals and edge dihedral angles.
|
||||
|
||||
Outputs:
|
||||
- Clean triangle list, adjacency (half-edge or edge-to-face map).
|
||||
- BBox, scale metrics.
|
||||
|
||||
Stage 2: RLM-Inspired Simplification
|
||||
------------------------------------
|
||||
Goal: Merge nearly coplanar triangles into planar clusters.
|
||||
|
||||
Algorithm Sketch:
|
||||
1. Seed clusters: each triangle forms initial cluster with its plane.
|
||||
2. Normal clustering: merge cluster pairs if angle between planes < rlm-angle AND distance between centroids projects within rlm-dist residual.
|
||||
3. Plane fitting: For each cluster, fit plane via PCA/least squares to vertex set. Recompute residuals.
|
||||
4. Split clusters where residual > rlm-dist or non-manifold boundaries arise.
|
||||
5. Extract boundary polygon:
|
||||
- Collect boundary edges (adjacent triangle only once).
|
||||
- Project boundary vertices to plane.
|
||||
- Compute 2D convex (or general boundary) hull; remove interior points.
|
||||
6. Vertex snapping:
|
||||
- Project vertices onto fitted plane.
|
||||
- Merge vertices within rlm-snap tolerance.
|
||||
7. Sharp edges retention:
|
||||
- Do not merge clusters across edges with dihedral > rlm-sharp.
|
||||
8. Degeneracy filtering:
|
||||
- Discard polygons with area < rlm-min-area or internal angle < min_angle (~5°).
|
||||
9. Optional: Triangulate polygon if required by downstream (for HACD only if non-convex planar patch).
|
||||
|
||||
Outputs:
|
||||
- Reduced set of planar patches (clusters) with cleaned boundary polygons.
|
||||
- Mapping from original faces to cluster.
|
||||
|
||||
Stage 3: Optional Primitive Recognition (Future Enhancement)
|
||||
------------------------------------------------------------
|
||||
Idea: Detect canonical primitives (planes, cylinders, spheres, cones) before clustering to reduce complexity:
|
||||
- RANSAC or multi-pass fitting.
|
||||
- Replace large planar clusters with a BOX or RPP if axis-aligned.
|
||||
- Defer for initial implementation.
|
||||
|
||||
Stage 4: Convex Decomposition (HACD/V-HACD)
|
||||
-------------------------------------------
|
||||
If mesh (post-RLM) remains non-convex:
|
||||
1. Run HACD on simplified mesh.
|
||||
- Inputs: tolerance (concavity), max parts, min volume.
|
||||
2. Obtain convex parts (triangle subsets).
|
||||
3. For each convex part:
|
||||
- Optionally re-run a localized RLM stage again (Mode: post-hacd).
|
||||
4. If HACD disabled:
|
||||
- Option hull-only: compute global convex hull (loss of concavity details).
|
||||
- Reject or warn if non-convex and no decomposition path chosen.
|
||||
|
||||
Outputs:
|
||||
- Collection of convex parts (each either a polygonal shell).
|
||||
|
||||
Stage 5: Convex Part → Plane Set Conversion
|
||||
-------------------------------------------
|
||||
For each convex part:
|
||||
1. Compute convex hull of part (even if already convex; double-check).
|
||||
2. Extract hull faces; each face yields a plane (normal outward, distance).
|
||||
3. Normalize planes; cluster near-duplicates (angle & offset tolerance).
|
||||
4. Verify face consistency (no inverted normals; positive volume).
|
||||
5. Cap plane count; if > max-planes-per-arbn → either split, relax tolerance, or fallback to legacy enumeration (warning).
|
||||
|
||||
Outputs:
|
||||
- Plane list per convex part.
|
||||
|
||||
Stage 6: ARBN Construction
|
||||
--------------------------
|
||||
Use incremental half-space clipping (implemented arbn_clip):
|
||||
1. Seed bounding polyhedron from plane offsets (derived radius).
|
||||
2. For each plane in part:
|
||||
- Clip polyhedron (spatial hash for vertex reuse).
|
||||
- Abort if volume becomes zero (log error; fallback to hull triangulation?).
|
||||
3. Gather final vertex set and face loops.
|
||||
4. Record stats (input planes, duplicates removed, final face/vertex count).
|
||||
|
||||
Outputs:
|
||||
- ARBN solid definition: plane equations only (COMGEOM stores planes; vertex enumeration implicit).
|
||||
- Optional internal triangulation only for QA (not stored in deck).
|
||||
|
||||
Stage 7: Region Assembly
|
||||
------------------------
|
||||
Single region for entire original BOT:
|
||||
- Boolean semantics: Region = OR of all ARBN solids derived from decomposition.
|
||||
- If decomposition yields components with distinct attributes (e.g., materials), create multiple regions (each corresponding to an attribute class).
|
||||
- For COMGEOM v1: multiple cards with OR semantics; for v4/v5: parenthesized expression: ( +S1 ) OR ( +S2 ) OR ...
|
||||
|
||||
Stage 8: QA & Validation
|
||||
------------------------
|
||||
Checks:
|
||||
1. Volume > 0 and matches (within tolerance) original mesh volume (approx. via voxel sampling or Monte Carlo).
|
||||
2. Surface area near original (after simplification) minus sliver removal.
|
||||
3. Watertightness: ray shoot tests (random directions) see consistent in/out transitions.
|
||||
4. Plane count sanity (no explosion).
|
||||
5. Overlaps: sampled points belong to exactly one convex part unless intended union.
|
||||
|
||||
Stage 9: Output COMGEOM Deck
|
||||
----------------------------
|
||||
For each ARBN:
|
||||
- Solid table entries listing plane equations (v1 fixed columns or v5 token).
|
||||
Region table:
|
||||
- Union of solids, optional attributes.
|
||||
Ident table:
|
||||
- Region codes, space codes, materials, LOS (v5 attribute lines or numeric fields).
|
||||
Attribute enhancement (v5):
|
||||
- material=, color=R/G/B, aircode=, density= if available.
|
||||
|
||||
Stage 10: Reporting
|
||||
-------------------
|
||||
JSON report keys:
|
||||
{
|
||||
"input": { "triangles": N, "bbox": [...], "scale": S },
|
||||
"rlm": { "clusters": C, "merged_faces": F, "avg_residual": R },
|
||||
"hacd": { "enabled": true, "parts": P, "concavity": X },
|
||||
"arbn": { "solids": S, "avg_planes": A, "max_planes": M },
|
||||
"region": { "count": Rg, "boolean": "OR" },
|
||||
"stats": { "warnings": W, "errors": E },
|
||||
"timing": { "rlm_ms": t1, "hacd_ms": t2, "arbn_ms": t3 }
|
||||
}
|
||||
|
||||
Parameter Tuning Guidelines:
|
||||
----------------------------
|
||||
angle_tol (cluster) : 1–2 degrees (start small; larger increases merging).
|
||||
distance_tol (plane fit) : 1e-3 × bbox_diag (reduce for high precision parts).
|
||||
snap_tol (vertex merge) : 5e-4 × bbox_diag (balance healing vs distortion).
|
||||
sharp_threshold : 30 degrees (increase to preserve more edges).
|
||||
hacd_concavity : 0.002–0.01 typical; lower → more parts, higher → fewer.
|
||||
max_planes_per_arbn : 256–512 recommended safety.
|
||||
min_face_area : 1e-10 × bbox_diag^2 (filter numerical artifacts).
|
||||
min_part_volume : 1e-9 × bbox_diag^3 (discard tiny fragments).
|
||||
|
||||
Error Handling Strategy:
|
||||
------------------------
|
||||
- Non-manifold input → attempt repair; if fails, abort with message.
|
||||
- Excessive plane count → split cluster or relax clustering thresholds.
|
||||
- Zero-volume ARBN creation → fallback to convex hull or mark as skipped.
|
||||
- HACD failure → fallback to hull-only mode with warning.
|
||||
- Tolerance misconfiguration → auto-suggest adjustments in JSON report.
|
||||
|
||||
Performance Considerations:
|
||||
---------------------------
|
||||
- Spatial hashing for vertex dedup in ARBN clipper (O(1) average insert/find).
|
||||
- Parallelization possibilities:
|
||||
- RLM clustering per spatial block / octree node.
|
||||
- HACD usually internal multi-threading (depending on library).
|
||||
- ARBN construction per convex part in parallel threads.
|
||||
- Memory caps: early discard of clusters under min_face_area prevents growth.
|
||||
- Streaming: flush solids incrementally to output files.
|
||||
|
||||
Security / Robustness Notes:
|
||||
----------------------------
|
||||
- Validate all user-supplied tolerances; clamp unreasonable values.
|
||||
- Protect against extremely large mesh inputs by early bounding checks.
|
||||
- Consistent use of relative tolerances tied to model scale to avoid numeric collapse.
|
||||
|
||||
Interfaces & Extensibility:
|
||||
---------------------------
|
||||
Future flags:
|
||||
--primitive-detect=on/off : Pre-pass for cylinders/cones/spheres.
|
||||
--thicken-open-mesh=scale : Convert open surfaces to solids.
|
||||
--exact-predicates=on/off : Introduce robust orientation tests.
|
||||
--parallel=N : Explicit thread count.
|
||||
|
||||
Data Structures (Minimal Outline):
|
||||
----------------------------------
|
||||
struct RLMCluster {
|
||||
vect_t plane_normal;
|
||||
double plane_offset;
|
||||
double rms_error;
|
||||
size_t *face_indices;
|
||||
size_t face_count;
|
||||
int sharp_boundary; /* flag */
|
||||
point_t *boundary_vertices;
|
||||
size_t boundary_count;
|
||||
};
|
||||
|
||||
struct ConvexPart {
|
||||
size_t *triangle_indices;
|
||||
size_t tri_count;
|
||||
plane_t *planes;
|
||||
size_t plane_count;
|
||||
};
|
||||
|
||||
struct ARBNSolid {
|
||||
plane_t *planes;
|
||||
size_t plane_count;
|
||||
/* optional stats */
|
||||
};
|
||||
|
||||
Pseudo-Code Summary:
|
||||
--------------------
|
||||
function export_bot_to_comgeom(bot, params):
|
||||
triangles = load(bot)
|
||||
validate(triangles)
|
||||
if params.simplify == RLM:
|
||||
clusters = rlm_cluster(triangles, params)
|
||||
triangles = rebuild_from_clusters(clusters) // optional
|
||||
parts = (params.hacd ? hacd_decompose(triangles, params) : [triangles])
|
||||
solids = []
|
||||
for part in parts:
|
||||
planes = extract_planes(part, params)
|
||||
arbn = build_arbn(planes, params)
|
||||
if arbn.valid: solids.append(arbn)
|
||||
region = build_region_union(solids)
|
||||
write_comgeom(solids, region, params)
|
||||
write_report(params, stats)
|
||||
|
||||
Potential Failure Scenarios & Responses:
|
||||
----------------------------------------
|
||||
1. Input mesh massively non-manifold:
|
||||
- Response: abort early; suggest repair tool invocation.
|
||||
2. RLM merges too aggressively (loss of moderate detail):
|
||||
- Response: output warning; advise lower angle_tol.
|
||||
3. HACD produces thousands of tiny parts:
|
||||
- Response: raise concavity tolerance; filter by min_part_volume.
|
||||
4. ARBN plane overflow:
|
||||
- Response: split part along largest variance axis; recursively process.
|
||||
5. Output region expression too large:
|
||||
- Response: chunk region definitions or group solids logically by type.
|
||||
|
||||
Testing Strategy:
|
||||
-----------------
|
||||
Unit:
|
||||
- Plane fitting accuracy (synthetic coplanar sets).
|
||||
- Clustering stability under noise.
|
||||
- ARBN clipper invariants (volume positive, faces planar).
|
||||
|
||||
Integration:
|
||||
- BOT sample models (simple, moderate, complex).
|
||||
- Comparison of original vs simplified volumes (Monte Carlo sampling).
|
||||
- Performance benchmarking (timings for each stage).
|
||||
|
||||
Regression:
|
||||
- Fuzz small random perturbations in vertex positions; ensure pipeline stable.
|
||||
- Large mesh stress (e.g., 250k triangles) within acceptable memory/time.
|
||||
|
||||
JSON Report Example:
|
||||
--------------------
|
||||
{
|
||||
"input": { "triangles": 50234, "bbox": [ [0,0,0], [10,5,7] ], "scale": 12.247 },
|
||||
"rlm": { "clusters": 340, "merged_faces": 48000, "avg_residual": 0.00072 },
|
||||
"hacd": { "enabled": true, "parts": 128, "concavity": 0.005 },
|
||||
"arbn": { "solids": 128, "avg_planes": 18, "max_planes": 42 },
|
||||
"region": { "count": 1, "boolean": "OR" },
|
||||
"stats": { "warnings": 3, "errors": 0 },
|
||||
"timing": { "rlm_ms": 1834, "hacd_ms": 2975, "arbn_ms": 622 }
|
||||
}
|
||||
|
||||
Open Questions (For Future Iteration):
|
||||
--------------------------------------
|
||||
- Should we incorporate curved primitive detection before RLM to reduce plane counts further?
|
||||
- Acceptable loss thresholds for feature simplification (volume delta %, area delta %)?
|
||||
- Deterministic mode (fixed random seeds) for reproducible CI outputs?
|
||||
- Support for per-material segmentation feeding separate region definitions?
|
||||
|
||||
Recommended Initial Implementation Order:
|
||||
----------------------------------------
|
||||
Phase 1: BOT validation + adjacency + dihedral computation
|
||||
Phase 2: Basic RLM clustering (angle + residual) + boundary extraction
|
||||
Phase 3: HACD integration (API wrapper) + parameter mapping
|
||||
Phase 4: Plane extraction + ARBN clipper integration
|
||||
Phase 5: Region assembly + COMGEOM writing (v5 first)
|
||||
Phase 6: JSON reporting + stats instrumentation
|
||||
Phase 7: Tuning + advanced cluster splitting + QA benchmarks
|
||||
Phase 8: Optional primitive recognition and improved heuristics
|
||||
|
||||
Risk Summary:
|
||||
-------------
|
||||
- Over-simplification: mitigated by exposing user parameters and reporting cluster stats.
|
||||
- Performance bottlenecks: spatial hashing, early culling, parallel cluster operations.
|
||||
- Numerical instability: per-stage tolerance scaling; failover to hull approximations.
|
||||
- Large memory footprints: streaming cluster processing and releasing intermediate buffers.
|
||||
|
||||
References (Conceptual Inspirations):
|
||||
-------------------------------------
|
||||
- Robust Low-Poly Meshing (RLM) paper (site referenced).
|
||||
- HACD / V-HACD algorithms (hierarchical approximate convex decomposition).
|
||||
- Plane clustering & PCA fitting (standard computational geometry).
|
||||
- Incremental half-space intersection for convex polyhedra.
|
||||
- Shewchuk robust predicates (optional future exact arithmetic upgrade).
|
||||
- RANSAC-based primitive detection (future enhancements).
|
||||
|
||||
End of Document
|
||||
================================================================================
|
||||
@@ -0,0 +1,122 @@
|
||||
#include "common.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
extern "C" {
|
||||
#include "bu/app.h"
|
||||
#include "bu/opt.h"
|
||||
#include "bu/vls.h"
|
||||
}
|
||||
|
||||
#include "ComGeomConverter.h"
|
||||
|
||||
/* Custom arg processor to copy a string argument into a std::string */
|
||||
static int
|
||||
opt_set_string(struct bu_vls *msg, size_t argc, const char **argv, void *set_var)
|
||||
{
|
||||
BU_OPT_CHECK_ARGV0(msg, argc, argv, "string");
|
||||
if (!set_var) return -1;
|
||||
std::string *sp = static_cast<std::string *>(set_var);
|
||||
*sp = argv[0];
|
||||
return 1;
|
||||
}
|
||||
|
||||
struct CliOptions {
|
||||
ConverterOptions conv;
|
||||
int show_help = 0;
|
||||
int legacy_truncate_names = 0;
|
||||
std::vector<std::string> positionals;
|
||||
};
|
||||
|
||||
static void usage(const char *argv0, const struct bu_opt_desc *opt_defs)
|
||||
{
|
||||
std::fprintf(stderr,
|
||||
"Usage: %s [options] input_file output_file\n\n", argv0);
|
||||
|
||||
char *help = bu_opt_describe(opt_defs, NULL);
|
||||
if (help) {
|
||||
std::fprintf(stderr, "%s\n", help);
|
||||
bu_free(help, "help string");
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
"\nExamples:\n"
|
||||
" %s model.cg model.g # convert using defaults (version 5)\n"
|
||||
" %s -v 0 in_magic.cg out.g # convert MAGIC-era v0 deck\n"
|
||||
" %s -v 4 -s _cg4 in.cg out.g # convert v4, append suffix to names\n"
|
||||
" %s -t -s _longsuffix in.cg out.g # legacy suffix truncation (13 chars)\n"
|
||||
" %s -d 2 input.cg out.g # verbose debug level 2\n",
|
||||
argv0, argv0, argv0, argv0, argv0);
|
||||
}
|
||||
|
||||
int main(int argc, const char **argv)
|
||||
{
|
||||
bu_setprogname(argv[0]);
|
||||
|
||||
CliOptions co;
|
||||
|
||||
struct bu_opt_desc opt_defs[7];
|
||||
BU_OPT(opt_defs[0], "h", "help", "", NULL, &co.show_help, "Show help and exit.");
|
||||
BU_OPT(opt_defs[1], "v", "version", "N", &bu_opt_int, &co.conv.version, "Set COMGEOM input version (0,1,4,5; default 5).");
|
||||
BU_OPT(opt_defs[2], "d", "debug", "N", &bu_opt_int, &co.conv.verbose, "Set verbosity/debug level (integer).");
|
||||
BU_OPT(opt_defs[3], "s", "suffix", "STR", opt_set_string, &co.conv.suffix, "Append name suffix to all generated objects.");
|
||||
BU_OPT(opt_defs[4], "V", "verbose", "", &bu_opt_incr_long,&co.conv.verbose, "Increment verbosity (may be repeated).");
|
||||
BU_OPT(opt_defs[5], "t", "legacy-truncate-names", "", NULL, &co.legacy_truncate_names, "Enable legacy name truncation (suffix max 13 chars).");
|
||||
BU_OPT_NULL(opt_defs[6]);
|
||||
|
||||
std::vector<const char *> argvec;
|
||||
argvec.reserve(static_cast<size_t>(argc - 1));
|
||||
for (int i = 1; i < argc; ++i) argvec.push_back(argv[i]);
|
||||
|
||||
struct bu_vls msgs = BU_VLS_INIT_ZERO;
|
||||
int ret = bu_opt_parse(&msgs, argvec.size(), argvec.data(), opt_defs);
|
||||
|
||||
if (ret < 0) {
|
||||
if (BU_VLS_IS_INITIALIZED(&msgs) && bu_vls_strlen(&msgs) > 0)
|
||||
std::fprintf(stderr, "%s", bu_vls_addr(&msgs));
|
||||
usage(argv[0], opt_defs);
|
||||
bu_vls_free(&msgs);
|
||||
return 1;
|
||||
}
|
||||
|
||||
size_t unused = static_cast<size_t>(ret);
|
||||
for (size_t i = 0; i < unused; ++i)
|
||||
co.positionals.emplace_back(argvec[i]);
|
||||
|
||||
if (co.show_help) {
|
||||
usage(argv[0], opt_defs);
|
||||
bu_vls_free(&msgs);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (co.positionals.size() != 2) {
|
||||
std::fprintf(stderr,
|
||||
"Error: expected 2 positional arguments (input_file output_file), got %zu.\n",
|
||||
co.positionals.size());
|
||||
usage(argv[0], opt_defs);
|
||||
bu_vls_free(&msgs);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (co.conv.version != 0 && co.conv.version != 1 && co.conv.version != 4 && co.conv.version != 5) {
|
||||
std::fprintf(stderr, "Error: unsupported COMGEOM version %d (valid: 0,1,4,5).\n",
|
||||
co.conv.version);
|
||||
usage(argv[0], opt_defs);
|
||||
bu_vls_free(&msgs);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (BU_VLS_IS_INITIALIZED(&msgs) && bu_vls_strlen(&msgs) > 0 && co.conv.verbose)
|
||||
std::fprintf(stderr, "%s", bu_vls_addr(&msgs));
|
||||
bu_vls_free(&msgs);
|
||||
|
||||
const std::string inputPath = co.positionals[0];
|
||||
const std::string outputPath = co.positionals[1];
|
||||
|
||||
co.conv.legacy_truncate_names = (co.legacy_truncate_names != 0);
|
||||
|
||||
ComGeomConverter converter(inputPath, outputPath, co.conv);
|
||||
return converter.run();
|
||||
}
|
||||
Reference in New Issue
Block a user