Import CAD projects and cad-router v1

This commit is contained in:
Jerry
2026-07-22 19:38:36 +08:00
parent e4e0199bd5
commit af9f630c11
2253 changed files with 1994207 additions and 0 deletions
@@ -0,0 +1,15 @@
from __future__ import annotations
import sys
from pathlib import Path
TOOL_DIR = Path(__file__).resolve().parent
tool_path = str(TOOL_DIR)
if tool_path not in sys.path:
sys.path.insert(0, tool_path)
from cli import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,15 @@
from __future__ import annotations
import sys
from pathlib import Path
if __package__ in {None, ""}:
tool_dir = Path(__file__).resolve().parent
if str(tool_dir) not in sys.path:
sys.path.insert(0, str(tool_dir))
from inspect_refs.cli import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1 @@
"""Selector reference inspection helpers."""
@@ -0,0 +1,641 @@
from __future__ import annotations
import argparse
import contextlib
import io
import json
import shlex
import sys
from pathlib import Path
from typing import Sequence
if __package__ in {None, ""}:
package_dir = Path(__file__).resolve().parent
tool_dir = package_dir.parent
if str(tool_dir) not in sys.path:
sys.path.insert(0, str(tool_dir))
from cadpy.cli_logging import CliLogger
def _inspect_api():
if __package__ in {None, ""}:
from inspect_refs import inspect
else:
from . import inspect
return inspect
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="inspect",
description="Inspect selector refs, geometry facts, and measurements.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"examples:\n"
" inspect refs STEP/foo.step '#f9' --detail --facts\n"
" inspect measure STEP/foo.step --from '#f1' --to '#f2' --axis z\n"
" inspect align STEP/foo.step --moving '#f1' --target '#f2' --mode flush --axis z\n"
),
)
parser.add_argument(
"--verbose",
action="store_true",
default=False,
help="Show detailed progress and timing information.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
refs_parser = subparsers.add_parser(
"refs",
help="Resolve whole-entry or selector refs from generated GLB topology.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"examples:\n"
" inspect refs STEP/foo.step '#f9' --detail --facts\n"
" inspect refs STEP/foo.step '#f1' '#e2' --positioning\n"
" inspect refs STEP/foo.step --input-file /tmp/refs.txt --planes\n"
),
)
refs_parser.add_argument(
"inputs",
nargs="*",
help="STEP/CAD entry target followed by optional selector refs like #o1.2.f1.",
)
refs_parser.add_argument(
"--input-file",
type=Path,
help="Read token text from a file instead of CLI input or stdin.",
)
refs_parser.add_argument(
"--detail",
action="store_true",
help="Include detailed geometry facts for selected face/edge refs.",
)
refs_parser.add_argument(
"--facts",
action="store_true",
help="Include compact geometry facts for whole-entry refs and resolved selectors.",
)
refs_parser.add_argument(
"--positioning",
action="store_true",
help="Include placement-ready frame, point, plane, axis, and coordinate facts.",
)
refs_parser.add_argument(
"--planes",
action="store_true",
help="Include grouped major planar faces for each whole entry.",
)
_add_plane_report_arguments(refs_parser)
refs_parser.add_argument(
"--topology",
action="store_true",
help="Include full face/edge selector lists for whole-entry refs. Expensive on large topology GLBs.",
)
_add_output_arguments(refs_parser)
refs_parser.set_defaults(handler=run_refs)
diff_parser = subparsers.add_parser(
"diff",
help="Compare two CAD STEP refs and summarize selector-level changes.",
)
diff_parser.add_argument("left", help="Left CAD STEP path.")
diff_parser.add_argument("right", help="Right CAD STEP path.")
diff_parser.add_argument(
"--planes",
action="store_true",
help="Include major planar face groups for both sides.",
)
_add_plane_report_arguments(diff_parser)
_add_output_arguments(diff_parser)
diff_parser.set_defaults(handler=run_diff)
frame_parser = subparsers.add_parser(
"frame",
help="Return the world frame for an occurrence or selector's owning occurrence.",
)
frame_parser.add_argument("entry", help="CAD STEP path or CAD entry target.")
frame_parser.add_argument("selector", nargs="?", default="", help="Optional selector ref such as #o1.2.")
_add_output_arguments(frame_parser)
frame_parser.set_defaults(handler=run_frame)
measure_parser = subparsers.add_parser(
"measure",
help="Measure signed coordinate distance between two selectors in one STEP entry.",
)
measure_parser.add_argument("entry", help="CAD STEP path or CAD entry target.")
measure_parser.add_argument("--from", dest="from_selector", required=True, help="Moving/source selector ref.")
measure_parser.add_argument("--to", dest="to_selector", required=True, help="Target selector ref.")
measure_parser.add_argument("--axis", choices=("x", "y", "z"), help="Axis to measure along. Inferred when possible.")
_add_output_arguments(measure_parser)
measure_parser.set_defaults(handler=run_measure)
align_parser = subparsers.add_parser(
"align",
help="Calculate a read-only translation delta for simple selector alignment.",
)
align_parser.add_argument("entry", help="CAD STEP path or CAD entry target.")
align_parser.add_argument("--moving", required=True, help="Moving/source selector ref.")
align_parser.add_argument("--target", required=True, help="Target selector ref.")
align_parser.add_argument("--mode", choices=("flush", "center"), default="flush", help="Alignment mode. Default: flush.")
align_parser.add_argument("--offset", type=float, default=0.0, help="Offset in mm. For flush, applies along target normal when axis-aligned.")
align_parser.add_argument("--axis", choices=("x", "y", "z"), help="Axis to use for flush or one-axis center alignment.")
_add_output_arguments(align_parser)
align_parser.set_defaults(handler=run_align)
worker_parser = subparsers.add_parser(
"worker",
help="Run a persistent JSONL inspect worker.",
description=(
"Read JSONL requests from stdin and write one JSONL response per request. "
"Each request is an object with argv: [<inspect-subcommand>, ...] and optional id."
),
)
worker_parser.set_defaults(handler=run_worker)
batch_parser = subparsers.add_parser(
"batch",
help="Run JSONL inspect requests from stdin in one process.",
description=worker_parser.description,
)
batch_parser.set_defaults(handler=run_worker)
return parser
def _add_output_arguments(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--format", choices=("json", "text"), default="json", help="Output format. Default: json.")
parser.add_argument("--quiet", action="store_true", help="Reduce nonessential output.")
parser.add_argument(
"--verbose",
action="store_true",
default=argparse.SUPPRESS,
help="Include extra human-readable detail where available.",
)
def _add_plane_report_arguments(
parser: argparse.ArgumentParser,
*,
prefix: str = "plane-",
) -> None:
coordinate_flags = [f"--{prefix}coordinate-tolerance"]
area_flags = [f"--{prefix}min-area-ratio"]
limit_flags = [f"--{prefix}limit"]
parser.add_argument(
*coordinate_flags,
dest="plane_coordinate_tolerance",
type=float,
default=1e-3,
help="Merge planar face groups whose axis coordinate differs by at most this value. Default: 0.001",
)
parser.add_argument(
*area_flags,
dest="plane_min_area_ratio",
type=float,
default=0.05,
help="Drop planar groups smaller than this fraction of total planar area. Default: 0.05",
)
parser.add_argument(
*limit_flags,
dest="plane_limit",
type=int,
default=12,
help="Maximum number of plane groups to emit. Default: 12",
)
def run_refs(args: argparse.Namespace) -> int:
inspect = _inspect_api()
try:
entry_target, refs_text = _read_refs_input(args)
result = inspect.inspect_cad_refs(
entry_target,
refs_text,
detail=bool(args.detail),
include_topology=bool(args.topology),
facts=bool(args.facts),
positioning=bool(args.positioning),
planes=bool(args.planes),
plane_coordinate_tolerance=float(args.plane_coordinate_tolerance),
plane_min_area_ratio=float(args.plane_min_area_ratio),
plane_limit=int(args.plane_limit),
)
except inspect.CadRefError as exc:
result = {
"ok": False,
"tokens": [],
"errors": [inspect.cad_ref_error_payload(exc)],
}
_emit_result(args, result, _format_refs_text)
return 0 if bool(result.get("ok")) else 2
def run_diff(args: argparse.Namespace) -> int:
inspect = _inspect_api()
try:
result = inspect.diff_entry_targets(
args.left,
args.right,
planes=bool(args.planes),
plane_coordinate_tolerance=float(args.plane_coordinate_tolerance),
plane_min_area_ratio=float(args.plane_min_area_ratio),
plane_limit=int(args.plane_limit),
)
except inspect.CadRefError as exc:
result = {
"ok": False,
"left": {"cadPath": _safe_cad_path(args.left)},
"right": {"cadPath": _safe_cad_path(args.right)},
"errors": [inspect.cad_ref_error_payload(exc)],
}
_emit_result(args, result, _format_diff_text)
return 0 if bool(result.get("ok")) else 2
def run_frame(args: argparse.Namespace) -> int:
inspect = _inspect_api()
try:
result = inspect.inspect_target_frame(args.entry, args.selector)
except inspect.CadRefError as exc:
result = {
"ok": False,
"target": args.entry,
"errors": [inspect.cad_ref_error_payload(exc)],
}
_emit_result(args, result, _format_frame_text)
return 0 if bool(result.get("ok")) else 2
def run_measure(args: argparse.Namespace) -> int:
inspect = _inspect_api()
try:
result = inspect.measure_targets(args.entry, args.from_selector, args.to_selector, axis=args.axis)
except inspect.CadRefError as exc:
result = {
"ok": False,
"entry": args.entry,
"from": args.from_selector,
"to": args.to_selector,
"errors": [inspect.cad_ref_error_payload(exc)],
}
_emit_result(args, result, _format_measure_text)
return 0 if bool(result.get("ok")) else 2
def run_align(args: argparse.Namespace) -> int:
inspect = _inspect_api()
try:
result = inspect.align_targets(
args.entry,
args.moving,
args.target,
mode=args.mode,
offset=float(args.offset),
axis=args.axis,
)
except inspect.CadRefError as exc:
result = {
"ok": False,
"entry": args.entry,
"moving": args.moving,
"target": args.target,
"errors": [inspect.cad_ref_error_payload(exc)],
}
_emit_result(args, result, _format_align_text)
return 0 if bool(result.get("ok")) else 2
def run_worker(args: argparse.Namespace) -> int:
_ = args
for raw_line in sys.stdin:
line = raw_line.strip()
if not line:
continue
response = _worker_response(line)
print(json.dumps(response, separators=(",", ":")), flush=True)
return 0
def _worker_response(line: str) -> dict[str, object]:
request_id: object = None
try:
request = json.loads(line)
argv = _worker_request_argv(request)
if isinstance(request, dict):
request_id = request.get("id")
exit_code, result = inspect_command_result(argv)
except Exception as exc:
exit_code = 2
result = {
"ok": False,
"errors": [_exception_error_payload(exc)],
}
response: dict[str, object] = {
"ok": exit_code == 0,
"exitCode": exit_code,
"result": result,
}
if request_id is not None:
response["id"] = request_id
return response
def _worker_request_argv(request: object) -> list[str]:
if isinstance(request, dict):
raw_argv = request.get("argv")
else:
raw_argv = request
if isinstance(raw_argv, str):
return shlex.split(raw_argv)
if isinstance(raw_argv, list) and all(isinstance(item, (str, int, float)) for item in raw_argv):
return [str(item) for item in raw_argv]
raise ValueError("Worker request must be a JSON object with argv, a JSON argv array, or a shell-style argv string.")
def inspect_command_result(argv: Sequence[str]) -> tuple[int, dict[str, object]]:
command_argv = [str(item) for item in argv]
if not command_argv:
return 2, {"ok": False, "errors": [{"message": "empty inspect command"}]}
if command_argv[0] in {"worker", "batch"}:
return 2, {"ok": False, "errors": [{"message": f"Unsupported worker command: {command_argv[0]}"}]}
stderr = io.StringIO()
try:
parser = build_parser()
with contextlib.redirect_stderr(stderr):
args = parser.parse_args(command_argv)
except SystemExit as exc:
return _system_exit_result(exc, stderr=stderr.getvalue())
try:
if args.command == "refs":
if not args.inputs and not args.input_file:
raise _inspect_api().CadRefError("No STEP/CAD entry target provided.")
entry_target, refs_text = _read_refs_input(args)
inspect = _inspect_api()
result = inspect.inspect_cad_refs(
entry_target,
refs_text,
detail=bool(args.detail),
include_topology=bool(args.topology),
facts=bool(args.facts),
positioning=bool(args.positioning),
planes=bool(args.planes),
plane_coordinate_tolerance=float(args.plane_coordinate_tolerance),
plane_min_area_ratio=float(args.plane_min_area_ratio),
plane_limit=int(args.plane_limit),
)
elif args.command == "diff":
inspect = _inspect_api()
result = inspect.diff_entry_targets(
args.left,
args.right,
planes=bool(args.planes),
plane_coordinate_tolerance=float(args.plane_coordinate_tolerance),
plane_min_area_ratio=float(args.plane_min_area_ratio),
plane_limit=int(args.plane_limit),
)
elif args.command == "frame":
result = _inspect_api().inspect_target_frame(args.entry, args.selector)
elif args.command == "measure":
result = _inspect_api().measure_targets(args.entry, args.from_selector, args.to_selector, axis=args.axis)
elif args.command == "align":
result = _inspect_api().align_targets(
args.entry,
args.moving,
args.target,
mode=args.mode,
offset=float(args.offset),
axis=args.axis,
)
else:
raise _inspect_api().CadRefError(f"Unsupported inspect command: {args.command}")
except _inspect_api().CadRefError as exc:
result = {"ok": False, "errors": [_inspect_api().cad_ref_error_payload(exc)]}
except Exception as exc:
result = {"ok": False, "errors": [_exception_error_payload(exc)]}
return (0 if bool(result.get("ok")) else 2), result
def _system_exit_result(exc: SystemExit, *, stderr: str = "") -> tuple[int, dict[str, object]]:
try:
exit_code = int(exc.code or 0)
except (TypeError, ValueError):
exit_code = 2
ok = exit_code == 0
message = stderr.strip() or str(exc)
return exit_code, {"ok": ok, "errors": [] if ok else [{"message": message}]}
def _exception_error_payload(exc: Exception) -> dict[str, object]:
inspect = _inspect_api()
if isinstance(exc, inspect.CadRefError):
return inspect.cad_ref_error_payload(exc)
return {
"type": type(exc).__name__,
"message": str(exc),
}
def _emit_result(args: argparse.Namespace, result: dict[str, object], text_formatter) -> None:
if getattr(args, "format", "json") == "text":
text = text_formatter(
result,
quiet=bool(getattr(args, "quiet", False)),
verbose=bool(getattr(args, "verbose", False)),
)
if text:
print(text)
return
indent = None if bool(getattr(args, "quiet", False)) else 2
print(json.dumps(result, indent=indent, sort_keys=False))
def _format_refs_text(result: dict[str, object], *, quiet: bool, verbose: bool) -> str:
if not result.get("ok"):
return _format_errors(result)
lines: list[str] = []
for token in result.get("tokens", []):
if not isinstance(token, dict):
continue
summary = token.get("summary") if isinstance(token.get("summary"), dict) else {}
headline = f"{token.get('cadPath')} faces={summary.get('faceCount')} edges={summary.get('edgeCount')}"
lines.append(headline)
if quiet:
continue
entry_facts = token.get("entryFacts") if isinstance(token.get("entryFacts"), dict) else {}
if entry_facts:
lines.append(f" facts: {_format_entry_facts_text(entry_facts)}")
entry_positioning = token.get("entryPositioning") if isinstance(token.get("entryPositioning"), dict) else {}
if entry_positioning:
bbox_facts = entry_positioning.get("bboxFacts") if isinstance(entry_positioning.get("bboxFacts"), dict) else {}
if bbox_facts and bbox_facts != entry_facts:
lines.append(f" positioning: {_format_entry_facts_text(bbox_facts)}")
planes = token.get("planes") if isinstance(token.get("planes"), list) else []
if planes:
lines.extend(_format_planes_text(planes))
for selection in token.get("selections", []):
if isinstance(selection, dict):
lines.append(f" {selection.get('displaySelector')}: {selection.get('summary')}")
if verbose and selection.get("copyText"):
lines.append(f" {selection.get('copyText')}")
return "\n".join(lines)
def _format_number(value: object) -> str:
if isinstance(value, float):
return f"{value:.6g}"
return str(value)
def _format_vector(value: object) -> str:
if not isinstance(value, list):
return str(value)
return "[" + ", ".join(_format_number(component) for component in value) + "]"
def _format_entry_facts_text(facts: dict[str, object]) -> str:
parts: list[str] = []
for key in ("size", "center", "extentAxis", "diag", "kind"):
if key not in facts:
continue
value = facts.get(key)
if isinstance(value, list):
parts.append(f"{key}={_format_vector(value)}")
else:
parts.append(f"{key}={_format_number(value)}")
return " ".join(parts)
def _format_planes_text(planes: list[object], *, limit: int = 3) -> list[str]:
lines = [f" planes: {len(planes)} major groups"]
for plane in planes[:limit]:
if not isinstance(plane, dict):
continue
axis = plane.get("axis")
coordinate = _format_number(plane.get("coordinate"))
normal_sign = plane.get("normalSign")
face_count = plane.get("faceCount")
area = _format_number(plane.get("totalArea"))
lines.append(
f" {axis}={coordinate} normalSign={normal_sign} faces={face_count} area={area}"
)
if len(planes) > limit:
lines.append(f" ... {len(planes) - limit} more")
return lines
def _format_diff_text(result: dict[str, object], *, quiet: bool, verbose: bool) -> str:
if not result.get("ok"):
return _format_errors(result)
diff = result.get("diff") if isinstance(result.get("diff"), dict) else {}
fields = ("topologyChanged", "geometryChanged", "bboxChanged", "kindChanged")
lines = [", ".join(f"{field}={diff.get(field)}" for field in fields)]
if not quiet:
lines.append(f"faceDelta={diff.get('faceCountDelta')} edgeDelta={diff.get('edgeCountDelta')}")
if verbose:
lines.append(f"sizeDelta={diff.get('sizeDelta')} centerDelta={diff.get('centerDelta')}")
return "\n".join(lines)
def _format_frame_text(result: dict[str, object], *, quiet: bool, verbose: bool) -> str:
if not result.get("ok"):
return _format_errors(result)
frame = result.get("frame") if isinstance(result.get("frame"), dict) else {}
lines = [f"{result.get('copyText', result.get('cadPath'))} translation={frame.get('translation')}"]
if verbose and not quiet:
lines.append(f"localAxes={frame.get('localAxes')}")
return "\n".join(lines)
def _format_measure_text(result: dict[str, object], *, quiet: bool, verbose: bool) -> str:
if not result.get("ok"):
return _format_errors(result)
measurement = result.get("measurement") if isinstance(result.get("measurement"), dict) else {}
lines = [
f"axis={result.get('axis')} signed={measurement.get('signedDistance')} absolute={measurement.get('absoluteDistance')}"
]
if verbose and not quiet:
lines.append(f"euclidean={measurement.get('euclideanDistance')} vector={measurement.get('vectorRelationship')}")
return "\n".join(lines)
def _format_align_text(result: dict[str, object], *, quiet: bool, verbose: bool) -> str:
if not result.get("ok"):
return _format_errors(result)
alignment = result.get("alignment") if isinstance(result.get("alignment"), dict) else {}
lines = [f"mode={result.get('mode')} axis={result.get('axis')} translation={alignment.get('translationVector')}"]
if verbose and not quiet:
lines.append(f"transformTranslationDelta={alignment.get('transformTranslationDelta')}")
return "\n".join(lines)
def _format_errors(result: dict[str, object]) -> str:
errors = result.get("errors") if isinstance(result.get("errors"), list) else []
messages = [str(error.get("message")) for error in errors if isinstance(error, dict) and error.get("message")]
return "\n".join(messages) if messages else "error"
def _read_refs_input(args: argparse.Namespace) -> tuple[str, str]:
inspect = _inspect_api()
raw_inputs = [str(value) for value in getattr(args, "inputs", ()) if str(value).strip()]
if args.input_file:
if len(raw_inputs) != 1:
raise inspect.CadRefError("Pass exactly one STEP/CAD entry target with --input-file.")
try:
text = args.input_file.read_text(encoding="utf-8")
except OSError as exc:
raise inspect.CadRefError(f"Failed to read input file: {args.input_file}") from exc
entry_target = raw_inputs[0]
else:
if not raw_inputs:
raise inspect.CadRefError("No STEP/CAD entry target provided.")
entry_target = raw_inputs[0]
text = "\n".join(raw_inputs[1:])
try:
inspect.entry_target_from_target(entry_target)
except inspect.CadRefError as exc:
raise inspect.CadRefError(f"Invalid STEP/CAD entry target: {entry_target}") from exc
if not str(text).strip():
return entry_target, ""
nonempty_lines = [line.strip() for line in str(text).splitlines() if line.strip()]
for line in nonempty_lines:
parsed_tokens = inspect.syntax.parse_cad_tokens(line)
if len(parsed_tokens) != 1 or parsed_tokens[0].token.strip() != line:
raise inspect.CadRefError(f"Invalid selector ref {line!r}; expected #o1.2, #f1, or #o1.2.f1.")
return entry_target, "\n".join(nonempty_lines)
def _safe_cad_path(target: str) -> str:
inspect = _inspect_api()
try:
return inspect.cad_path_from_target(target)
except inspect.CadRefError:
return str(target)
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
command_label = str(getattr(args, "command", "inspect") or "inspect")
logger = CliLogger("scripts/inspect", verbose=bool(getattr(args, "verbose", False)))
try:
with logger.timed(command_label):
return int(args.handler(args))
except _inspect_api().CadRefError as exc:
_emit_result(args, {"ok": False, "errors": [_inspect_api().cad_ref_error_payload(exc)]}, _format_errors)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,988 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from cadpy import cad_ref_syntax as syntax
from cadpy.reporting import (
EntryReportOptions,
entry_facts_payload,
entry_positioning_payload,
entry_report_payload,
entry_summary_payload,
major_planes_payload,
)
from cadpy.selector_types import SelectorProfile
from cadpy.step_targets import (
CadRefError,
ResolvedStepTarget,
cad_path_from_target,
cad_ref_error_payload,
entry_target_from_target,
resolve_step_target,
step_path_from_target,
)
from cadpy import analysis
from cadpy import lookup
REPO_ROOT = Path.cwd().resolve()
@dataclass
class EntryContext:
cad_path: str
kind: str
source_path: Path
step_path: Path | None
manifest: dict[str, object]
selector_index: lookup.SelectorIndex | None
@dataclass
class TargetSelection:
context: EntryContext
selector_type: str
row: dict[str, object]
normalized_selector: str
display_selector: str
copy_text: str
EntryContextProvider = Callable[[str, SelectorProfile], EntryContext | None]
def inspect_cad_refs(
entry_target: str,
refs_text: str = "",
*,
detail: bool = False,
include_topology: bool = False,
facts: bool = False,
positioning: bool = False,
planes: bool = False,
plane_coordinate_tolerance: float = 1e-3,
plane_min_area_ratio: float = 0.05,
plane_limit: int = 12,
context_provider: EntryContextProvider | None = None,
) -> dict[str, object]:
entry = entry_target_from_target(entry_target)
parsed_tokens = _parse_entry_ref_tokens(entry.cad_path, refs_text)
if not parsed_tokens:
raise CadRefError(
"No selector ref found. Expected refs like #o<path>, #o<path>.s<n>, #o<path>.f<n>, "
"#o<path>.e<n>, #o<path>.v<n>, #m<n>, or #s<n>/#f<n>/#e<n>/#v<n> "
"for single-occurrence entries."
)
contexts: dict[str, EntryContext] = {}
errors: list[dict[str, object]] = []
token_results: list[dict[str, object]] = []
refs_required_by_cad_path: dict[str, bool] = {}
report_options = EntryReportOptions(
facts=facts,
positioning=positioning,
planes=planes,
topology=include_topology,
plane_coordinate_tolerance=plane_coordinate_tolerance,
plane_min_area_ratio=plane_min_area_ratio,
plane_limit=plane_limit,
)
for parsed in parsed_tokens:
selectors = parsed.selectors or ()
refs_required_by_cad_path[parsed.cad_path] = (
refs_required_by_cad_path.get(parsed.cad_path, False)
or bool(selectors)
or report_options.refs_required
)
for parsed in parsed_tokens:
context = contexts.get(parsed.cad_path)
if context is None:
try:
context = _load_entry_context(
parsed.cad_path,
profile=SelectorProfile.REFS if refs_required_by_cad_path.get(parsed.cad_path) else SelectorProfile.SUMMARY,
context_provider=context_provider,
)
except CadRefError as exc:
error = cad_ref_error_payload(exc)
error.setdefault("line", parsed.line)
error.setdefault("cadPath", parsed.cad_path)
error.setdefault("selector", None)
error.setdefault("kind", "input")
errors.append(error)
token_results.append(
{
"line": parsed.line,
"token": parsed.token,
"cadPath": parsed.cad_path,
"stepPath": "",
"summary": {},
"selections": [],
"warnings": [],
}
)
continue
contexts[parsed.cad_path] = context
token_result: dict[str, object] = {
"line": parsed.line,
"token": parsed.token,
"cadPath": parsed.cad_path,
"stepPath": _relative_to_repo(context.step_path) if context.step_path is not None else "",
"stepHash": context.manifest.get("stepHash"),
"summary": _entry_summary(context),
"selections": [],
"warnings": [],
}
report_payload = entry_report_payload(
context.manifest,
kind=context.kind,
options=report_options,
selector_index=context.selector_index,
)
token_result["summary"] = report_payload["summary"]
if facts and "entryFacts" in report_payload:
token_result["entryFacts"] = report_payload["entryFacts"]
if planes and "planes" in report_payload:
token_result["planes"] = report_payload["planes"]
if parsed.selectors:
for raw_selector in parsed.selectors:
selection, selection_error = _inspect_selector(
parsed.cad_path,
raw_selector,
context,
detail=detail,
facts=facts,
positioning=positioning,
)
token_result["selections"].append(selection)
if selection_error is not None:
errors.append(
{
"line": parsed.line,
"cadPath": parsed.cad_path,
"selector": raw_selector,
**selection_error,
}
)
else:
if include_topology and "topology" in report_payload:
token_result["topology"] = report_payload["topology"]
if positioning and "entryPositioning" in report_payload:
token_result["entryPositioning"] = report_payload["entryPositioning"]
token_results.append(token_result)
return {
"ok": not errors,
"tokens": token_results,
"errors": errors,
}
def _parse_entry_ref_tokens(cad_path: str, refs_text: str = "") -> list[syntax.ParsedToken]:
text = str(refs_text or "").strip()
if not text:
return [
syntax.ParsedToken(
line=1,
token=syntax.build_cad_token(cad_path),
cad_path=cad_path,
selectors=(),
)
]
tokens: list[syntax.ParsedToken] = []
for line_no, line in enumerate(text.splitlines(), start=1):
normalized_line = line.strip()
if not normalized_line:
continue
parsed_tokens = syntax.parse_cad_tokens(normalized_line)
if len(parsed_tokens) != 1 or parsed_tokens[0].token.strip() != normalized_line:
raise CadRefError(f"Invalid selector ref {normalized_line!r}; expected #o1.2, #f1, #m1, or #o1.2.f1.")
parsed = parsed_tokens[0]
tokens.append(
syntax.ParsedToken(
line=line_no,
token=parsed.token,
cad_path=cad_path,
selectors=parsed.selectors,
)
)
return tokens
def _relative_to_repo(path: Path) -> str:
resolved = path.resolve()
try:
return resolved.relative_to(REPO_ROOT).as_posix()
except ValueError:
return resolved.as_posix()
def _load_entry_context(
cad_path: str,
*,
profile: SelectorProfile,
context_provider: EntryContextProvider | None = None,
) -> EntryContext:
if context_provider is not None:
context = context_provider(cad_path, profile)
if context is not None:
return context
target = resolve_step_target(cad_path)
return _load_step_context(target, profile=profile)
def _load_step_context(
target: ResolvedStepTarget,
*,
profile: SelectorProfile,
) -> EntryContext:
from cadpy.step_artifacts import ensure_step_topology_artifact
artifact = ensure_step_topology_artifact(
target,
require_selector=(profile != SelectorProfile.SUMMARY),
)
if artifact.selector_bundle is None:
manifest = artifact.manifest
selector_index = None
else:
manifest = artifact.selector_bundle.manifest
selector_index = lookup.build_selector_index(manifest, buffers=artifact.selector_bundle.buffers)
resolved_kind = _entry_kind_from_manifest(
manifest,
fallback=_entry_kind_from_manifest(artifact.manifest, fallback=target.kind),
)
return EntryContext(
cad_path=target.cad_path,
kind=resolved_kind,
source_path=target.source_path,
step_path=target.step_path,
manifest=manifest,
selector_index=selector_index,
)
def _entry_kind_from_manifest(manifest: dict[str, object], *, fallback: str) -> str:
entry_kind = str(manifest.get("entryKind") or "").strip().lower()
if entry_kind in {"part", "assembly"}:
return entry_kind
assembly = manifest.get("assembly")
if isinstance(assembly, dict):
return "assembly"
return fallback if fallback in {"part", "assembly"} else "part"
def _entry_summary(context: EntryContext) -> dict[str, object]:
return entry_summary_payload(
context.manifest,
kind=context.kind,
selector_index=context.selector_index,
)
def _selection_label(selector_type: str, display_selector: str) -> str:
noun = {
"occurrence": "Occurrence",
"shape": "Shape",
"face": "Face",
"edge": "Edge",
"vertex": "Corner",
}.get(selector_type, "Reference")
return f"{noun} {display_selector}"
def _selection_summary(selector_type: str, row: dict[str, object]) -> str:
if selector_type == "occurrence":
name = str(row.get("name") or row.get("sourceName") or "").strip()
return name or str(row.get("id") or "")
if selector_type == "shape":
kind = str(row.get("kind") or "shape")
volume = row.get("volume")
area = row.get("area")
if volume not in {None, ""}:
return f"{kind} volume={volume}"
if area not in {None, ""}:
return f"{kind} area={area}"
return kind
if selector_type == "face":
return f"{row.get('surfaceType')} area={row.get('area')}"
if selector_type == "edge":
return f"{row.get('curveType')} length={row.get('length')}"
return f"corner edges={row.get('edgeCount')}"
def _assembly_mate_rows(manifest: dict[str, object]) -> list[dict[str, object]]:
rows = manifest.get("assemblyMates")
if not isinstance(rows, list):
return []
return [dict(row) for row in rows if isinstance(row, dict)]
def _assembly_mate_by_selector(context: EntryContext, raw_selector: str) -> dict[str, object] | None:
normalized_selector = str(raw_selector or "").strip().replace("#", "", 1)
if not normalized_selector:
return None
for row in _assembly_mate_rows(context.manifest):
if str(row.get("id") or "").strip() == normalized_selector:
return row
return None
def _looks_like_assembly_mate_selector(raw_selector: str) -> bool:
selector = str(raw_selector or "").strip().replace("#", "", 1)
return len(selector) > 1 and selector[0] == "m" and selector[1:].isdigit()
def _assembly_mate_label(row: dict[str, object], selector: str) -> str:
return (
str(row.get("sourceLabel") or "").strip()
or str(row.get("name") or "").strip()
or str(row.get("label") or "").strip()
or selector
)
def _assembly_mate_summary(row: dict[str, object]) -> str:
relation = str(row.get("type") or row.get("relation") or "mate").strip() or "mate"
fixed = str(row.get("fixed") or "").strip()
moving = str(row.get("moving") or "").strip()
endpoints = f"{fixed} -> {moving}" if fixed and moving else fixed or moving
return " ".join(part for part in (relation, endpoints) if part)
def _assembly_mate_detail(row: dict[str, object]) -> dict[str, object]:
payload: dict[str, object] = {
"id": row.get("id"),
"label": row.get("label"),
"sourceLabel": row.get("sourceLabel"),
"type": row.get("type") or row.get("relation"),
"fixed": row.get("fixed"),
"moving": row.get("moving"),
}
for key in ("parameters", "fixedEndpoint", "movingEndpoint"):
if key in row:
payload[key] = row.get(key)
return payload
def _inspect_assembly_mate(
cad_path: str,
raw_selector: str,
context: EntryContext,
*,
detail: bool,
positioning: bool,
) -> tuple[dict[str, object], dict[str, object] | None]:
selector = str(raw_selector or "").strip().replace("#", "", 1)
row = _assembly_mate_by_selector(context, selector)
if row is None:
return (
{
"status": "error",
"selectorType": "mate" if _looks_like_assembly_mate_selector(selector) else "opaque",
"normalizedSelector": selector,
"displaySelector": selector,
},
{
"kind": "selector",
"message": f"Selector '{raw_selector}' did not resolve against {cad_path}.",
},
)
mate_id = str(row.get("id") or selector).strip() or selector
selection: dict[str, object] = {
"status": "resolved",
"selectorType": "mate",
"normalizedSelector": mate_id,
"displaySelector": mate_id,
"copyText": syntax.build_cad_token(cad_path, mate_id),
"label": f"Mate {_assembly_mate_label(row, mate_id)}",
"summary": _assembly_mate_summary(row),
}
if detail:
selection["detail"] = _assembly_mate_detail(row)
if positioning:
selection["positioning"] = {
"selectorType": "mate",
"selector": mate_id,
"fixedEndpoint": row.get("fixedEndpoint"),
"movingEndpoint": row.get("movingEndpoint"),
}
return selection, None
def _occurrence_detail(row: dict[str, object], selector_index: lookup.SelectorIndex) -> dict[str, object]:
occurrence_id = str(row.get("id") or "").strip()
child_rows = [
child
for child in selector_index.occurrences
if str(child.get("parentId") or "").strip() == occurrence_id
]
descendant_ids: list[str] = []
stack = list(reversed(child_rows))
while stack:
child = stack.pop()
child_id = str(child.get("id") or "").strip()
if child_id:
descendant_ids.append(lookup.display_selector(child_id, selector_index))
stack.extend(
grandchild
for grandchild in reversed(selector_index.occurrences)
if str(grandchild.get("parentId") or "").strip() == child_id
)
return {
"path": row.get("path"),
"name": row.get("name"),
"sourceName": row.get("sourceName"),
"parentId": lookup.display_selector(str(row.get("parentId") or ""), selector_index),
"childCount": len(child_rows),
"descendantOccurrenceIds": descendant_ids,
"transform": row.get("transform"),
"bbox": row.get("bbox"),
"shapeCount": row.get("shapeCount"),
"faceCount": row.get("faceCount"),
"edgeCount": row.get("edgeCount"),
"vertexCount": row.get("vertexCount"),
}
def _shape_detail(row: dict[str, object], selector_index: lookup.SelectorIndex) -> dict[str, object]:
return {
"occurrenceId": lookup.display_selector(str(row.get("occurrenceId") or ""), selector_index),
"kind": row.get("kind"),
"bbox": row.get("bbox"),
"center": row.get("center"),
"area": row.get("area"),
"volume": row.get("volume"),
"faceCount": row.get("faceCount"),
"edgeCount": row.get("edgeCount"),
"vertexCount": row.get("vertexCount"),
}
def _face_detail(row: dict[str, object], selector_index: lookup.SelectorIndex) -> dict[str, object]:
adjacent_edges = [
lookup.display_selector(selector, selector_index)
for selector in lookup.face_adjacent_edge_selectors(row, selector_index)
]
return {
"occurrenceId": lookup.display_selector(str(row.get("occurrenceId") or ""), selector_index),
"shapeId": lookup.display_selector(str(row.get("shapeId") or ""), selector_index),
"surfaceType": row.get("surfaceType"),
"area": row.get("area"),
"center": row.get("center"),
"normal": row.get("normal"),
"bbox": row.get("bbox"),
"params": row.get("params"),
"adjacentEdgeSelectors": adjacent_edges,
}
def _edge_detail(row: dict[str, object], selector_index: lookup.SelectorIndex) -> dict[str, object]:
adjacent_faces = [
lookup.display_selector(selector, selector_index)
for selector in lookup.edge_adjacent_face_selectors(row, selector_index)
]
adjacent_vertices = [
lookup.display_selector(selector, selector_index)
for selector in lookup.edge_adjacent_vertex_selectors(row, selector_index)
]
return {
"occurrenceId": lookup.display_selector(str(row.get("occurrenceId") or ""), selector_index),
"shapeId": lookup.display_selector(str(row.get("shapeId") or ""), selector_index),
"curveType": row.get("curveType"),
"length": row.get("length"),
"center": row.get("center"),
"bbox": row.get("bbox"),
"params": row.get("params"),
"adjacentFaceSelectors": adjacent_faces,
"adjacentVertexSelectors": adjacent_vertices,
}
def _vertex_detail(row: dict[str, object], selector_index: lookup.SelectorIndex) -> dict[str, object]:
adjacent_edges = [
lookup.display_selector(selector, selector_index)
for selector in lookup.vertex_adjacent_edge_selectors(row, selector_index)
]
adjacent_faces = [
lookup.display_selector(selector, selector_index)
for selector in lookup.vertex_adjacent_face_selectors(row, selector_index)
]
return {
"occurrenceId": lookup.display_selector(str(row.get("occurrenceId") or ""), selector_index),
"shapeId": lookup.display_selector(str(row.get("shapeId") or ""), selector_index),
"center": row.get("center"),
"bbox": row.get("bbox"),
"adjacentEdgeSelectors": adjacent_edges,
"adjacentFaceSelectors": adjacent_faces,
}
def _inspect_selector(
cad_path: str,
raw_selector: str,
context: EntryContext,
*,
detail: bool,
facts: bool,
positioning: bool,
) -> tuple[dict[str, object], dict[str, object] | None]:
parsed_selector = syntax.parse_selector(raw_selector)
if parsed_selector is None:
return (
{
"status": "error",
"selectorType": "unknown",
"normalizedSelector": raw_selector,
"displaySelector": raw_selector,
},
{
"kind": "selector",
"message": f"Unsupported selector '{raw_selector}'.",
},
)
if parsed_selector.selector_type == "opaque":
mate_selection, mate_error = _inspect_assembly_mate(
cad_path,
raw_selector,
context,
detail=detail,
positioning=positioning,
)
if mate_error is None or _looks_like_assembly_mate_selector(raw_selector):
return mate_selection, mate_error
if context.selector_index is None:
raise CadRefError(f"Selector index unavailable for {cad_path}")
lookup_result = lookup.lookup_selector(raw_selector, context.selector_index)
normalized_selector = lookup.canonicalize_selector(raw_selector, context.selector_index) or parsed_selector.canonical
display_selector = lookup.display_selector(normalized_selector, context.selector_index)
if lookup_result is None:
return (
{
"status": "error",
"selectorType": parsed_selector.selector_type,
"normalizedSelector": normalized_selector,
"displaySelector": display_selector,
},
{
"kind": "selector",
"message": f"Selector '{raw_selector}' did not resolve against {cad_path}.",
},
)
selector_type, row = lookup_result
selection: dict[str, object] = {
"status": "resolved",
"selectorType": selector_type,
"normalizedSelector": normalized_selector,
"displaySelector": display_selector,
"copyText": syntax.build_cad_token(cad_path, display_selector),
"label": _selection_label(selector_type, display_selector),
"summary": _selection_summary(selector_type, row),
}
if detail:
if selector_type == "occurrence":
selection["detail"] = _occurrence_detail(row, context.selector_index)
elif selector_type == "shape":
selection["detail"] = _shape_detail(row, context.selector_index)
elif selector_type == "face":
selection["detail"] = _face_detail(row, context.selector_index)
elif selector_type == "edge":
selection["detail"] = _edge_detail(row, context.selector_index)
elif selector_type == "vertex":
selection["detail"] = _vertex_detail(row, context.selector_index)
if facts:
selection["geometryFacts"] = analysis.geometry_facts_for_row(selector_type, row, context.selector_index)
if positioning:
selection["positioning"] = analysis.positioning_facts_for_row(selector_type, row, context.selector_index)
return selection, None
def _entry_facts(context: EntryContext) -> dict[str, object]:
return entry_facts_payload(context.manifest, kind=context.kind, selector_index=context.selector_index)
def _entry_positioning(context: EntryContext) -> dict[str, object]:
return entry_positioning_payload(context.manifest, kind=context.kind, selector_index=context.selector_index)
def load_entry_context_for_target(
target: str,
*,
profile: SelectorProfile = SelectorProfile.REFS,
context_provider: EntryContextProvider | None = None,
) -> EntryContext:
return _load_entry_context(cad_path_from_target(target), profile=profile, context_provider=context_provider)
def _parse_single_target_token(entry_target_text: str, selector: str = "") -> syntax.ParsedToken:
entry_target = entry_target_from_target(entry_target_text)
selector_text = str(selector or "").strip()
if not selector_text:
selectors: tuple[str, ...] = ()
else:
parsed_tokens = syntax.parse_cad_tokens(selector_text)
if len(parsed_tokens) != 1 or parsed_tokens[0].token.strip() != selector_text:
raise CadRefError(f"Expected exactly one selector ref such as #o1.2.f1; got {selector!r}.")
selectors = parsed_tokens[0].selectors
return syntax.ParsedToken(
line=1,
token=syntax.build_cad_token(entry_target.cad_path, ",".join(selectors)),
cad_path=entry_target.cad_path,
selectors=selectors,
)
def _default_root_occurrence(index: lookup.SelectorIndex) -> dict[str, object]:
roots = [
row
for row in index.occurrences
if row.get("parentId") in {None, ""}
]
if len(roots) == 1:
return roots[0]
if len(index.occurrences) == 1:
return index.occurrences[0]
raise CadRefError("Target has no selector and does not have exactly one root occurrence.")
def resolve_target_selection(
entry_target: str,
selector: str = "",
*,
default_root_occurrence: bool = False,
context_provider: EntryContextProvider | None = None,
) -> TargetSelection:
parsed = _parse_single_target_token(entry_target, selector)
context = _load_entry_context(parsed.cad_path, profile=SelectorProfile.REFS, context_provider=context_provider)
if context.selector_index is None:
raise CadRefError(f"Selector index unavailable for {parsed.cad_path}")
selectors = parsed.selectors
if not selectors:
if not default_root_occurrence:
raise CadRefError(f"Expected a selector ref for target {entry_target!r}.")
row = _default_root_occurrence(context.selector_index)
normalized_selector = str(row.get("id") or "")
display_selector = lookup.display_selector(normalized_selector, context.selector_index)
return TargetSelection(
context=context,
selector_type="occurrence",
row=row,
normalized_selector=normalized_selector,
display_selector=display_selector,
copy_text=syntax.build_cad_token(parsed.cad_path, display_selector),
)
if len(selectors) != 1:
raise CadRefError(f"Expected exactly one selector in target {entry_target!r}.")
raw_selector = selectors[0]
lookup_result = lookup.lookup_selector(raw_selector, context.selector_index)
if lookup_result is None:
raise CadRefError(f"Selector '{raw_selector}' did not resolve against {parsed.cad_path}.")
selector_type, row = lookup_result
normalized_selector = lookup.canonicalize_selector(raw_selector, context.selector_index) or raw_selector
display_selector = lookup.display_selector(normalized_selector, context.selector_index)
return TargetSelection(
context=context,
selector_type=selector_type,
row=row,
normalized_selector=normalized_selector,
display_selector=display_selector,
copy_text=syntax.build_cad_token(parsed.cad_path, display_selector),
)
def _selection_positioning_payload(selection: TargetSelection) -> dict[str, object]:
return analysis.positioning_facts_for_row(
selection.selector_type,
selection.row,
selection.context.selector_index,
)
def _selection_result_payload(selection: TargetSelection) -> dict[str, object]:
return {
"cadPath": selection.context.cad_path,
"stepPath": _relative_to_repo(selection.context.step_path) if selection.context.step_path is not None else "",
"selectorType": selection.selector_type,
"normalizedSelector": selection.normalized_selector,
"displaySelector": selection.display_selector,
"copyText": selection.copy_text,
}
def _axis_or_infer(axis: str | None, *positioning: dict[str, object]) -> str:
if axis is not None and str(axis).strip():
normalized = str(axis).strip().lower()
if normalized not in analysis.AXIS_INDEX:
raise CadRefError(f"Axis must be one of x, y, z; got {axis!r}.")
return normalized
inferred = analysis.infer_positioning_axis(*positioning)
if inferred is None:
raise CadRefError("Could not infer a shared axis; pass --axis x, --axis y, or --axis z.")
return inferred
def _primary_vector(positioning: dict[str, object]) -> object:
for key in ("normal", "direction", "axisVector"):
value = positioning.get(key)
if value is not None and value != "":
return value
return None
def inspect_target_frame(
entry_target: str,
selector: str = "",
*,
context_provider: EntryContextProvider | None = None,
) -> dict[str, object]:
selection = resolve_target_selection(entry_target, selector, default_root_occurrence=True, context_provider=context_provider)
if selection.selector_type == "occurrence":
occurrence_row = selection.row
else:
occurrence_id = str(selection.row.get("occurrenceId") or "")
occurrence_row = selection.context.selector_index.occurrence_by_id.get(occurrence_id) if selection.context.selector_index else None
if occurrence_row is None:
raise CadRefError(f"Target {entry_target!r} does not resolve to an occurrence-backed selector.")
frame = analysis.positioning_facts_for_row("occurrence", occurrence_row, selection.context.selector_index)
result = {
"ok": True,
**_selection_result_payload(selection),
"frame": frame,
}
if selection.selector_type != "occurrence":
result["selectionPositioning"] = _selection_positioning_payload(selection)
return result
def measure_targets(
entry_target: str,
from_selector: str,
to_selector: str,
*,
axis: str | None = None,
context_provider: EntryContextProvider | None = None,
) -> dict[str, object]:
from_selection = resolve_target_selection(entry_target, from_selector, default_root_occurrence=True, context_provider=context_provider)
to_selection = resolve_target_selection(entry_target, to_selector, default_root_occurrence=True, context_provider=context_provider)
from_positioning = _selection_positioning_payload(from_selection)
to_positioning = _selection_positioning_payload(to_selection)
resolved_axis = _axis_or_infer(axis, from_positioning, to_positioning)
from_coordinate = analysis.positioning_coordinate(from_positioning, resolved_axis)
to_coordinate = analysis.positioning_coordinate(to_positioning, resolved_axis)
if from_coordinate is None or to_coordinate is None:
raise CadRefError(f"Could not compute {resolved_axis}-axis coordinates for both targets.")
from_point = analysis.positioning_point(from_positioning)
to_point = analysis.positioning_point(to_positioning)
euclidean_distance = None
if from_point is not None and to_point is not None:
euclidean_distance = sum(
(float(to_point[index]) - float(from_point[index])) ** 2
for index in range(3)
) ** 0.5
vector_relationship = analysis.vector_relationship(
_primary_vector(from_positioning),
_primary_vector(to_positioning),
)
signed_distance = float(to_coordinate[0] - from_coordinate[0])
return {
"ok": True,
"axis": resolved_axis,
"from": {
**_selection_result_payload(from_selection),
"positioning": from_positioning,
"coordinate": from_coordinate[0],
"coordinateSource": from_coordinate[1],
},
"to": {
**_selection_result_payload(to_selection),
"positioning": to_positioning,
"coordinate": to_coordinate[0],
"coordinateSource": to_coordinate[1],
},
"measurement": {
"signedDistance": signed_distance,
"absoluteDistance": abs(signed_distance),
"euclideanDistance": euclidean_distance,
"vectorRelationship": vector_relationship,
},
}
def align_targets(
entry_target: str,
moving_selector: str,
target_selector: str,
*,
mode: str = "flush",
offset: float = 0.0,
axis: str | None = None,
context_provider: EntryContextProvider | None = None,
) -> dict[str, object]:
moving_selection = resolve_target_selection(
entry_target,
moving_selector,
default_root_occurrence=True,
context_provider=context_provider,
)
target_selection = resolve_target_selection(
entry_target,
target_selector,
default_root_occurrence=True,
context_provider=context_provider,
)
moving_positioning = _selection_positioning_payload(moving_selection)
target_positioning = _selection_positioning_payload(target_selection)
normalized_mode = str(mode or "flush").strip().lower()
if normalized_mode not in {"flush", "center"}:
raise CadRefError(f"Unsupported alignment mode {mode!r}; expected 'flush' or 'center'.")
if normalized_mode == "center":
moving_point = analysis.positioning_point(moving_positioning)
target_point = analysis.positioning_point(target_positioning)
if moving_point is None or target_point is None:
raise CadRefError("Center alignment requires both targets to expose a point, center, origin, or translation.")
translation_vector = [float(target_point[index]) - float(moving_point[index]) for index in range(3)]
resolved_axis = None
if axis is not None and str(axis).strip():
resolved_axis = _axis_or_infer(axis)
axis_index = analysis.AXIS_INDEX[resolved_axis]
translation_vector = [
value if index == axis_index else 0.0
for index, value in enumerate(translation_vector)
]
else:
resolved_axis = _axis_or_infer(axis, moving_positioning, target_positioning)
moving_coordinate = analysis.positioning_coordinate(moving_positioning, resolved_axis)
target_coordinate = analysis.positioning_coordinate(target_positioning, resolved_axis)
if moving_coordinate is None or target_coordinate is None:
raise CadRefError(f"Could not compute {resolved_axis}-axis coordinates for both alignment targets.")
offset_sign = 1
target_alignment = target_positioning.get("axisAlignment")
if isinstance(target_alignment, dict) and target_alignment.get("axis") == resolved_axis:
offset_sign = int(target_alignment.get("sign") or 1)
distance = float(target_coordinate[0] + (float(offset) * offset_sign) - moving_coordinate[0])
translation_vector = [0.0, 0.0, 0.0]
translation_vector[analysis.AXIS_INDEX[resolved_axis]] = distance
vector_relationship = analysis.vector_relationship(
_primary_vector(moving_positioning),
_primary_vector(target_positioning),
)
rotation_required = None
if vector_relationship is not None:
rotation_required = vector_relationship.get("relation") != "opposed"
return {
"ok": True,
"mode": normalized_mode,
"axis": resolved_axis,
"offset": float(offset),
"moving": {
**_selection_result_payload(moving_selection),
"positioning": moving_positioning,
},
"target": {
**_selection_result_payload(target_selection),
"positioning": target_positioning,
},
"alignment": {
"translationVector": translation_vector,
"transformTranslationDelta": {
"3": translation_vector[0],
"7": translation_vector[1],
"11": translation_vector[2],
},
"vectorRelationship": vector_relationship,
"rotationRequired": rotation_required,
},
}
def diff_entry_targets(
left_target: str,
right_target: str,
*,
planes: bool = False,
plane_coordinate_tolerance: float = 1e-3,
plane_min_area_ratio: float = 0.05,
plane_limit: int = 12,
context_provider: EntryContextProvider | None = None,
) -> dict[str, object]:
left_context = load_entry_context_for_target(
left_target,
profile=SelectorProfile.REFS,
context_provider=context_provider,
)
right_context = load_entry_context_for_target(
right_target,
profile=SelectorProfile.REFS,
context_provider=context_provider,
)
diff_payload = analysis.selector_manifest_diff(left_context.manifest, right_context.manifest)
bbox_left = left_context.manifest.get("bbox")
bbox_right = right_context.manifest.get("bbox")
size_left = analysis.bbox_size(bbox_left)
size_right = analysis.bbox_size(bbox_right)
center_left = analysis.bbox_center(bbox_left)
center_right = analysis.bbox_center(bbox_right)
result: dict[str, object] = {
"ok": True,
"left": {
"cadPath": left_context.cad_path,
"kind": left_context.kind,
"stepPath": _relative_to_repo(left_context.step_path) if left_context.step_path is not None else "",
"summary": _entry_summary(left_context),
"entryFacts": _entry_facts(left_context),
},
"right": {
"cadPath": right_context.cad_path,
"kind": right_context.kind,
"stepPath": _relative_to_repo(right_context.step_path) if right_context.step_path is not None else "",
"summary": _entry_summary(right_context),
"entryFacts": _entry_facts(right_context),
},
"diff": {
"kindChanged": left_context.kind != right_context.kind,
**diff_payload,
"sizeDelta": (
[float(size_right[index] - size_left[index]) for index in range(3)]
if size_left is not None and size_right is not None
else None
),
"centerDelta": (
[float(center_right[index] - center_left[index]) for index in range(3)]
if center_left is not None and center_right is not None
else None
),
},
}
if planes and left_context.selector_index is not None and right_context.selector_index is not None:
report_options = EntryReportOptions(
planes=True,
plane_coordinate_tolerance=plane_coordinate_tolerance,
plane_min_area_ratio=plane_min_area_ratio,
plane_limit=plane_limit,
)
result["diff"]["leftMajorPlanes"] = major_planes_payload(left_context.selector_index, report_options)
result["diff"]["rightMajorPlanes"] = major_planes_payload(right_context.selector_index, report_options)
return result