Compare commits

..
6 Commits
Author SHA1 Message Date
Clifford Yapp 1ab8c379e9 Stash some thoughts about what to do with BoTs. 2026-09-18 10:12:00 -04:00
Clifford Yapp c12e55973a Stash some info gleaned from old reports and manuals. 2026-09-18 10:12:00 -04:00
Clifford Yapp 1968ba02a3 Quick stab at adding some support for pre-GIFT COMGEOM
Only known surviving examples would be fuzzy printouts in old report scans -
would need really good OCR work to get those set up to actually test, so in the
meantime just checkpointing the V0 work to revisit later.
2026-09-18 10:12:00 -04:00
Clifford Yapp 40621be317 Various minor adjustments 2026-09-18 10:12:00 -04:00
Clifford Yapp 92ba4caa42 Use correct type for opt target 2026-09-18 10:12:00 -04:00
Clifford Yapp ef75af075a Initial checkpoint of C++ rework of comgeom-g 2026-09-18 10:12:00 -04:00
24 changed files with 3140 additions and 3665 deletions
-8
View File
@@ -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
)
-619
View File
@@ -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())
-127
View File
@@ -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())
-501
View File
@@ -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())
-939
View File
@@ -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())
-288
View File
@@ -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())
-449
View File
@@ -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": []
}
}
}
]
}
-508
View File
@@ -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.
+4 -2
View File
@@ -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)
+28
View File
@@ -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
+498
View File
@@ -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.
+350
View File
@@ -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
+186
View File
@@ -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;
}
+41
View File
@@ -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
+76
View File
@@ -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);
}
+38
View File
@@ -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
+424
View File
@@ -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;
}
+74
View File
@@ -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;
};
+830
View File
@@ -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"};
}
+93
View File
@@ -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
================================================================================
+122
View File
@@ -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();
}