Simplify /clash: single selector, detect collisions within selected set

Drop selector_b — the endpoint now takes the URL's selector= and tests all
matched elements against each other, consistent with the spec's model.
Self-pairs and duplicates are filtered; requires at least 2 matched elements.

https://claude.ai/code/session_01NBsytCd6L7UQPiKbcLn4kn
This commit is contained in:
Claude 2026-05-09 08:57:54 +00:00
parent 6a1959e3d0
commit 277c74e3d3
No known key found for this signature in database
2 changed files with 19 additions and 45 deletions

View file

@ -106,10 +106,8 @@ def _sandboxed_select(ifc_bytes: bytes, selector: str) -> list[str]:
return [e.GlobalId for e in matched if hasattr(e, "GlobalId") and e.GlobalId]
def _sandboxed_clash(
ifc_bytes: bytes, selector_a: str, selector_b: str | None
) -> list[list[str]]:
"""Detect geometric clashes between two sets of IFC elements.
def _sandboxed_clash(ifc_bytes: bytes, selector: str) -> list[list[str]]:
"""Detect geometric clashes within the elements matched by selector.
Uses ifcopenshell.geom.tree.clash_collision_many for mesh-level
interpenetration detection (hard clashes only, touching excluded).
@ -124,32 +122,21 @@ def _sandboxed_clash(
settings = ifcopenshell.geom.settings()
settings.set("use-world-coords", True)
elems_a = list(ifcopenshell.util.selector.filter_elements(model, selector_a))
ids_a = {e.id() for e in elems_a}
if selector_b:
elems_b = list(ifcopenshell.util.selector.filter_elements(model, selector_b))
else:
elems_b = [
e
for e in model.by_type("IfcProduct")
if e.id() not in ids_a and getattr(e, "GlobalId", None)
]
if not elems_a or not elems_b:
elems = list(ifcopenshell.util.selector.filter_elements(model, selector))
if len(elems) < 2:
return []
tree = ifcopenshell.geom.tree()
tree.add_file(model, settings)
clashes = tree.clash_collision_many(elems_a, elems_b, allow_touching=False)
clashes = tree.clash_collision_many(elems, elems, allow_touching=False)
pairs: list[list[str]] = []
seen: set[frozenset[str]] = set()
for c in clashes:
guid_a = getattr(c.a, "GlobalId", None)
guid_b = getattr(c.b, "GlobalId", None)
if not guid_a or not guid_b:
if not guid_a or not guid_b or guid_a == guid_b:
continue
key = frozenset((guid_a, guid_b))
if key not in seen:
@ -321,20 +308,16 @@ async def select_endpoint(
@app.post("/clash")
async def clash_endpoint(
ifc: UploadFile = File(...),
selector_a: str = Form(...),
selector_b: str = Form(""),
selector: str = Form(...),
) -> Response:
"""Detect clashing element pairs.
"""Detect clashing element pairs within the selected set.
Returns JSON ``{"pairs": [["guidA", "guidB"], ...]}``.
An empty ``selector_b`` means test set A against all other model elements.
"""
ifc_bytes = await ifc.read()
try:
pairs = run_sandboxed(
_sandboxed_clash, ifc_bytes, selector_a, selector_b or None
)
pairs = run_sandboxed(_sandboxed_clash, ifc_bytes, selector)
except SandboxCrashError as exc:
raise HTTPException(status_code=422, detail=f"IFC clash detection crashed: {exc}") from exc
except SandboxTimeoutError as exc:

View file

@ -126,9 +126,7 @@ def _select_via_socket(ifc_bytes: bytes, selector: str) -> list[str]:
return resp.json()["guids"]
def _clash_via_socket(
ifc_bytes: bytes, selector_a: str, selector_b: str | None
) -> list[list[str]]:
def _clash_via_socket(ifc_bytes: bytes, selector: str) -> list[list[str]]:
"""Delegate clash detection to the render service over the Unix socket."""
import httpx
@ -137,7 +135,7 @@ def _clash_via_socket(
resp = client.post(
"http://render/clash",
files={"ifc": ifc_bytes},
data={"selector_a": selector_a, "selector_b": selector_b or ""},
data={"selector": selector},
timeout=_RENDER_TIMEOUT,
)
except httpx.TransportError as exc:
@ -450,8 +448,6 @@ class QueryRequest(BaseModel):
class ClashRequest(BaseModel):
url: str
selector_b: str | None = None
"""Second element set to test against. When omitted, set A is tested against all other model elements."""
token: str | None = None
@ -829,21 +825,18 @@ def query(request: QueryRequest) -> JSONResponse:
@app.get("/clash")
def clash_get(url: str, selector_b: str | None = None, token: str | None = None) -> JSONResponse:
def clash_get(url: str, token: str | None = None) -> JSONResponse:
"""GET variant of POST /clash."""
return clash(ClashRequest(url=url, selector_b=selector_b, token=token))
return clash(ClashRequest(url=url, token=token))
@app.post("/clash")
def clash(request: ClashRequest) -> JSONResponse:
"""Detect geometric clashes between two sets of elements.
"""Detect geometric clashes within the elements matched by the selector.
Set A is defined by the URL's ``selector=`` parameter. Set B is defined
by ``selector_b``; when omitted, set A is tested against all other
model elements.
Uses ifcopenshell hard-clash (interpenetration) detection. Returns
``{"pairs": [["<guidA>", "<guidB>"], ]}``.
The URL's ``selector=`` parameter defines the set of elements to test
against each other. Uses ifcopenshell hard-clash (interpenetration)
detection. Returns ``{"pairs": [["<guidA>", "<guidB>"], ]}``.
"""
try:
ifc_url = IfcUrl.parse(request.url)
@ -877,11 +870,9 @@ def clash(request: ClashRequest) -> JSONResponse:
try:
if _RENDER_SOCKET:
pairs = _clash_via_socket(ifc_bytes, ifc_url.selector, request.selector_b)
pairs = _clash_via_socket(ifc_bytes, ifc_url.selector)
else:
pairs = run_sandboxed(
_sandboxed_clash, ifc_bytes, ifc_url.selector, request.selector_b
)
pairs = run_sandboxed(_sandboxed_clash, ifc_bytes, ifc_url.selector)
except SandboxCrashError as exc:
raise HTTPException(
status_code=422, detail=f"IFC clash detection crashed: {exc}"