Files
Jason Stedwell c1abe36822 phase 0
2026-06-17 16:03:26 -05:00

336 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
query_engine.py — Natural language geometric query handler.
Supports both single-query (--query "...") and interactive REPL (--repl).
REPL keeps the model in memory between queries for speed.
All output is formatted ASCII tables.
Supported query types (see SKILL.md for full reference):
bounding box overall model extents
face count total faces by type
all parts full assembly listing
list all holes all cylindrical through-features
list all mounting holes cylinders dia < 15mm, axis ⊥ primary face
holes diameter N filter by diameter
wall thickness min distance between opposing parallel faces
largest face largest planar face area
help list supported queries
exit / quit exit REPL
"""
import logging
import re
import textwrap
from typing import Optional
from .loader import StepModel
logger = logging.getLogger("step_processor.query")
# Regex patterns for query routing
_BBOX_RE = re.compile(r"\b(bounding.?box|extents|dimensions|overall.?size)\b", re.I)
_FACECOUNT_RE = re.compile(r"\b(face.?count|how many faces|number of faces)\b", re.I)
_PARTS_RE = re.compile(r"\ball.?parts|part.?list|assembly|components\b", re.I)
_HOLES_RE = re.compile(r"\bholes?\b", re.I)
_MOUNTING_RE = re.compile(r"\bmounting\b", re.I)
_DIA_RE = re.compile(r"diameter\s+([\d.]+)\s*mm?", re.I)
_WALL_RE = re.compile(r"\bwall.?thickness\b", re.I)
_LARGEST_RE = re.compile(r"\blargest.?face\b", re.I)
_HELP_RE = re.compile(r"\bhelp\b", re.I)
_EXIT_RE = re.compile(r"\b(exit|quit|q)\b", re.I)
def run_query(model: StepModel, query: str) -> str:
"""Dispatch a query string and return formatted output."""
q = query.strip()
if _EXIT_RE.search(q):
return "EXIT"
if _HELP_RE.search(q):
return _help_text()
if _BBOX_RE.search(q):
return _query_bounding_box(model)
if _FACECOUNT_RE.search(q):
return _query_face_count(model)
if _PARTS_RE.search(q):
return _query_all_parts(model)
if _HOLES_RE.search(q):
dia_match = _DIA_RE.search(q)
dia_filter = float(dia_match.group(1)) if dia_match else None
mounting_only = bool(_MOUNTING_RE.search(q))
return _query_holes(model, mounting_only=mounting_only, dia_filter=dia_filter)
if _WALL_RE.search(q):
return _query_wall_thickness(model)
if _LARGEST_RE.search(q):
return _query_largest_face(model)
return (f"Query not recognized: '{q}'\n"
f"Type 'help' to see supported queries.")
def repl(model: StepModel, step_path):
"""Launch interactive REPL. Returns when user types exit/quit."""
print(f"\nSTEP Query REPL — {step_path.name}")
print("Type 'help' for supported queries, 'exit' to quit.\n")
while True:
try:
q = input("> ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if not q:
continue
result = run_query(model, q)
if result == "EXIT":
break
print(result)
print()
# ── Query implementations ─────────────────────────────────────────────────────
def _query_bounding_box(model: StepModel) -> str:
try:
if model.backend == "build123d":
bb = model.shape.bounding_box()
x = round(bb.size.X, 2)
y = round(bb.size.Y, 2)
z = round(bb.size.Z, 2)
else:
bb = model.shape.bounding_box
x = round(bb.XMax - bb.XMin, 2)
y = round(bb.YMax - bb.YMin, 2)
z = round(bb.ZMax - bb.ZMin, 2)
return _table(
f"BOUNDING BOX — {model.path.name}",
["Axis", "Dimension"],
[["Width (X)", f"{x} mm ({x/25.4:.3f} in)"],
["Depth (Y)", f"{y} mm ({y/25.4:.3f} in)"],
["Height (Z)", f"{z} mm ({z/25.4:.3f} in)"]]
)
except Exception as e:
return f"Bounding box query failed: {e}"
def _query_face_count(model: StepModel) -> str:
if model.backend != "build123d":
return f"Face count query requires build123d (loaded via {model.backend})"
try:
from build123d import Compound
all_faces = model.shape.faces()
planar = sum(1 for f in all_faces if f.geom_type() == "PLANE")
cylindrical = sum(1 for f in all_faces if f.geom_type() == "CYLINDER")
other = len(all_faces) - planar - cylindrical
return _table(
f"FACE COUNT — {model.path.name}",
["Type", "Count"],
[["Planar", str(planar)],
["Cylindrical", str(cylindrical)],
["Other", str(other)],
["Total", str(len(all_faces))]]
)
except Exception as e:
return f"Face count failed: {e}"
def _query_all_parts(model: StepModel) -> str:
if not model.parts:
return (f"No assembly structure found in {model.path.name}.\n"
f"File appears to be a single solid body.")
rows = []
for p in model.parts:
rows.append([
p.get("part_number", ""),
p.get("name", ""),
str(p.get("quantity", 1)),
str(p.get("level", 0)),
p.get("parent", ""),
])
return _table(
f"ALL PARTS — {model.path.name}",
["#", "Name", "Qty", "Level", "Parent"],
rows
) + f"\nTotal: {len(model.parts)} parts"
def _query_holes(model: StepModel, mounting_only: bool = False,
dia_filter: Optional[float] = None) -> str:
if model.backend != "build123d":
return f"Hole query requires build123d (loaded via {model.backend})"
try:
holes = _find_holes(model, mounting_only=mounting_only, dia_filter=dia_filter)
if not holes:
label = "mounting holes" if mounting_only else "holes"
qualifier = f"{dia_filter}mm" if dia_filter else ""
return f"No {label}{qualifier} found in {model.path.name}."
header = "MOUNTING HOLES" if mounting_only else "ALL HOLES"
# Group by diameter bucket for summary view
from collections import Counter
dia_counts = Counter(round(h["dia"], 1) for h in holes)
MAX_ROWS = 50
display_holes = holes[:MAX_ROWS]
rows = []
for i, h in enumerate(display_holes, 1):
rows.append([
str(i),
f"{h['dia']:.2f} mm",
f"{h['depth']:.2f} mm" if h["depth"] else "",
f"({h['x']:.1f}, {h['y']:.1f}, {h['z']:.1f})",
])
result = _table(
f"{header}{model.path.name}",
["#", "Diameter", "Depth", "Position (x,y,z)"],
rows
)
result += f"\nShowing {len(display_holes)} of {len(holes)} unique hole locations"
result += "\n\nDIAMETER SUMMARY"
result += "\n" + "" * 30
for dia, count in sorted(dia_counts.items()):
result += f"\n {dia:.1f} mm ×{count}"
result += "\n" + "" * 30
return result
except Exception as e:
return f"Hole query failed: {e}"
def _find_holes(model: StepModel, mounting_only: bool, dia_filter):
"""Extract and deduplicate cylindrical faces from build123d model.
Deduplication: round axis position to 1mm grid, group by (dia_bucket, x, y, z).
This collapses multiple cylindrical faces from the same physical hole
(e.g. inner + outer surface of same cylinder) into one entry.
"""
from OCP.BRepAdaptor import BRepAdaptor_Surface
from OCP.GeomAbs import GeomAbs_Cylinder
seen = {} # key → best entry
try:
faces = model.shape.faces()
except Exception:
return []
for face in faces:
try:
adaptor = BRepAdaptor_Surface(face.wrapped)
if adaptor.GetType() != GeomAbs_Cylinder:
continue
cyl = adaptor.Cylinder()
r = cyl.Radius()
dia = round(r * 2, 2)
# Diameter filters
if mounting_only and dia > 15.0:
continue
if dia_filter and abs(dia - dia_filter) > 0.5:
continue
axis_pt = cyl.Location()
# Round to 1mm grid for deduplication
gx = round(axis_pt.X())
gy = round(axis_pt.Y())
gz = round(axis_pt.Z())
dia_bucket = round(dia, 1)
key = (dia_bucket, gx, gy, gz)
if key not in seen:
bb = face.bounding_box()
depth = round(max(bb.size.X, bb.size.Y, bb.size.Z), 2)
seen[key] = {
"dia": dia,
"depth": depth,
"x": round(axis_pt.X(), 1),
"y": round(axis_pt.Y(), 1),
"z": round(axis_pt.Z(), 1),
}
except Exception:
continue
return list(seen.values())
def _query_wall_thickness(model: StepModel) -> str:
if model.backend != "build123d":
return f"Wall thickness query requires build123d (loaded via {model.backend})"
try:
faces = model.shape.faces()
planar = [f for f in faces if f.geom_type() == "PLANE"]
if len(planar) < 2:
return "Insufficient planar faces to determine wall thickness."
# Heuristic: find minimum non-zero distance between parallel opposing faces
min_t = None
for i, f1 in enumerate(planar):
n1 = f1.normal_at()
for f2 in planar[i+1:]:
n2 = f2.normal_at()
# Parallel if normals are anti-parallel
dot = abs(n1.dot(n2))
if dot > 0.99:
c1 = f1.center()
c2 = f2.center()
dist = round(abs((c1 - c2).dot(n1)), 3)
if dist > 0.01:
if min_t is None or dist < min_t:
min_t = dist
if min_t is None:
return "Could not determine wall thickness from available faces."
return _table(
f"WALL THICKNESS — {model.path.name}",
["Measurement", "Value"],
[["Minimum wall thickness",
f"{min_t} mm ({min_t/25.4:.3f} in)"]]
)
except Exception as e:
return f"Wall thickness query failed: {e}"
def _query_largest_face(model: StepModel) -> str:
if model.backend != "build123d":
return f"Largest face query requires build123d (loaded via {model.backend})"
try:
faces = model.shape.faces()
planar = [(f, f.area()) for f in faces if f.geom_type() == "PLANE"]
if not planar:
return "No planar faces found."
largest, area = max(planar, key=lambda x: x[1])
bb = largest.bounding_box()
return _table(
f"LARGEST PLANAR FACE — {model.path.name}",
["Property", "Value"],
[["Area", f"{round(area, 2)} mm²"],
["Width", f"{round(bb.size.X, 2)} mm"],
["Height", f"{round(bb.size.Z, 2)} mm"]]
)
except Exception as e:
return f"Largest face query failed: {e}"
# ── Formatting helpers ─────────────────────────────────────────────────────────
def _table(title: str, headers: list, rows: list) -> str:
col_widths = [len(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
col_widths[i] = max(col_widths[i], len(str(cell)))
sep = "" * (sum(col_widths) + 3 * len(headers) - 1)
lines = [title, sep]
header_line = " ".join(h.ljust(col_widths[i]) for i, h in enumerate(headers))
lines.append(header_line)
lines.append(sep)
for row in rows:
lines.append(" ".join(str(cell).ljust(col_widths[i]) for i, cell in enumerate(row)))
lines.append(sep)
return "\n".join(lines)
def _help_text() -> str:
return textwrap.dedent("""\
SUPPORTED QUERIES
─────────────────────────────────────────────────────────────
bounding box Overall model extents (W×D×H in mm)
face count Faces by type (planar, cylindrical, other)
all parts Full assembly listing with quantities
list all holes All cylindrical through-features
list all mounting holes Holes smaller than 15mm diameter
holes diameter 4.2mm Filter holes by specific diameter
wall thickness Minimum wall thickness estimate
largest face Largest planar face area
help Show this message
exit Exit the REPL
─────────────────────────────────────────────────────────────
Tip: geometry queries require build123d backend.
If the file loaded via FreeCAD fallback, only bounding box
and parts list are available.""")