Implement clash visibility mode and /query endpoint (ifcurl-19h, ifcurl-8rf)

render.py: add _CLASH_COLOR and clash branch in _add_shape — selected
elements rendered in red, non-selected in normal material colours.

render_service.py: add _sandboxed_query(ifc_bytes, selector, query_path)
and /query endpoint. Dot-notation query_path splits into pset/property
(Pset_WallCommon.FireRating) or falls back to direct IFC attribute (Name).
Returns {GlobalId: str(value)}, excluding None and entity references.

service.py: add QueryRequest model, _query_via_socket() for socket
delegation, and GET/POST /query endpoints reusing Tier 2 byte cache.

https://claude.ai/code/session_01NBsytCd6L7UQPiKbcLn4kn
This commit is contained in:
Claude 2026-05-09 08:46:26 +00:00
parent 08582549ec
commit 1739c12f52
No known key found for this signature in database
4 changed files with 163 additions and 2 deletions

View file

@ -3,7 +3,7 @@
{"_type":"issue","id":"ifcurl-4l7","title":"viewer.html: scale= URLs don't load in orthographic mode","description":"applyCameraParam() reads the camera= and fov= parameters from the URL and applies them, but never reads scale= or switches the camera to orthographic (parallel) projection. URLs like ?camera=...\u0026scale=50 will load in perspective mode instead of orthographic. The OBC OrthoPerspectiveCamera can switch modes, but the code path to set parallel projection and apply the scale value is missing from applyCameraParam.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:51Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:29Z","started_at":"2026-04-24T10:50:19Z","closed_at":"2026-04-24T10:56:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"ifcurl-4l7","title":"viewer.html: scale= URLs don't load in orthographic mode","description":"applyCameraParam() reads the camera= and fov= parameters from the URL and applies them, but never reads scale= or switches the camera to orthographic (parallel) projection. URLs like ?camera=...\u0026scale=50 will load in perspective mode instead of orthographic. The OBC OrthoPerspectiveCamera can switch modes, but the code path to set parallel projection and apply the scale value is missing from applyCameraParam.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:51Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:29Z","started_at":"2026-04-24T10:50:19Z","closed_at":"2026-04-24T10:56:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"ifcurl-362","title":"Add missing server-config files: gitconfig-ifcmerge and gitattributes","description":"forgejo/README.md documents deploying two files that don't exist in the repo: forgejo/server-config/gitconfig-ifcmerge and forgejo/server-config/gitattributes. Both are required for the ifcmerge git merge driver to work in bare Forgejo repos. The README even includes 'sudo cp forgejo/server-config/gitattributes /etc/gitattributes' and 'sudo git config --system include.path .../gitconfig-ifcmerge', but the files themselves are absent. Anyone following the deployment instructions will hit a 'file not found' error. Need to create both files with the content described in the README.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:31Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:51:47Z","started_at":"2026-04-24T10:50:15Z","closed_at":"2026-04-24T10:51:47Z","close_reason":"Files already exist: forgejo/server-config/gitconfig-ifcmerge and gitattributes are both present and correct. Missed in initial review.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"ifcurl-362","title":"Add missing server-config files: gitconfig-ifcmerge and gitattributes","description":"forgejo/README.md documents deploying two files that don't exist in the repo: forgejo/server-config/gitconfig-ifcmerge and forgejo/server-config/gitattributes. Both are required for the ifcmerge git merge driver to work in bare Forgejo repos. The README even includes 'sudo cp forgejo/server-config/gitattributes /etc/gitattributes' and 'sudo git config --system include.path .../gitconfig-ifcmerge', but the files themselves are absent. Anyone following the deployment instructions will hit a 'file not found' error. Need to create both files with the content described in the README.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:31Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:51:47Z","started_at":"2026-04-24T10:50:15Z","closed_at":"2026-04-24T10:51:47Z","close_reason":"Files already exist: forgejo/server-config/gitconfig-ifcmerge and gitattributes are both present and correct. Missed in initial review.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"ifcurl-k0x","title":"Server: add /clash endpoint for geometry intersection detection","description":"Add GET and POST /clash endpoints to service.py. The endpoint accepts an ifc:// URL with two selectors (selector_a and selector_b query parameters; if selector_b is omitted, test selector_a elements against all other model elements). It runs IfcOpenShell geometry intersection detection in the sandbox: bounding-box pre-filter to find candidate pairs, then mesh-level intersection test for confirmed clashes. Returns JSON {pairs: [[guidA, guidB], ...]} listing every clashing element pair. The combined set of clashing GUIDs can then be used to construct a visibility=clash URL. Sandbox the detection via a new _sandboxed_clash(ifc_bytes, selector_a, selector_b) function in render_service.py. Results for immutable refs may be cached at Tier 4.","status":"open","priority":2,"issue_type":"feature","owner":"noreply@anthropic.com","created_at":"2026-05-09T08:28:49Z","created_by":"Claude","updated_at":"2026-05-09T08:28:49Z","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"ifcurl-k0x","title":"Server: add /clash endpoint for geometry intersection detection","description":"Add GET and POST /clash endpoints to service.py. The endpoint accepts an ifc:// URL with two selectors (selector_a and selector_b query parameters; if selector_b is omitted, test selector_a elements against all other model elements). It runs IfcOpenShell geometry intersection detection in the sandbox: bounding-box pre-filter to find candidate pairs, then mesh-level intersection test for confirmed clashes. Returns JSON {pairs: [[guidA, guidB], ...]} listing every clashing element pair. The combined set of clashing GUIDs can then be used to construct a visibility=clash URL. Sandbox the detection via a new _sandboxed_clash(ifc_bytes, selector_a, selector_b) function in render_service.py. Results for immutable refs may be cached at Tier 4.","status":"open","priority":2,"issue_type":"feature","owner":"noreply@anthropic.com","created_at":"2026-05-09T08:28:49Z","created_by":"Claude","updated_at":"2026-05-09T08:28:49Z","dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"ifcurl-19h","title":"Renderer: implement clash visibility mode","description":"In render.py _add_shape, add a clash branch between the ghost and isolate/else cases. Clash should render selected elements in a distinct red colour (define _CLASH_COLOR e.g. (220, 50, 50)) and non-selected elements in their normal material colours (same as highlight mode but for non-selected). Currently the clash value falls through to the else/isolate branch incorrectly. Update the _add_shape docstring to list clash as a supported mode. Also update the render() docstring to mention clash in the visibility parameter description.","status":"open","priority":2,"issue_type":"feature","owner":"noreply@anthropic.com","created_at":"2026-05-09T08:08:32Z","created_by":"Claude","updated_at":"2026-05-09T08:08:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"ifcurl-19h","title":"Renderer: implement clash visibility mode","description":"In render.py _add_shape, add a clash branch between the ghost and isolate/else cases. Clash should render selected elements in a distinct red colour (define _CLASH_COLOR e.g. (220, 50, 50)) and non-selected elements in their normal material colours (same as highlight mode but for non-selected). Currently the clash value falls through to the else/isolate branch incorrectly. Update the _add_shape docstring to list clash as a supported mode. Also update the render() docstring to mention clash in the visibility parameter description.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Claude","owner":"noreply@anthropic.com","created_at":"2026-05-09T08:08:32Z","created_by":"Claude","updated_at":"2026-05-09T08:44:22Z","started_at":"2026-05-09T08:44:22Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"ifcurl-8rf","title":"Server: add /query endpoint for attribute/property retrieval","description":"Add GET and POST /query endpoints to service.py (mirroring the /select pattern). The endpoint resolves an ifc:// URL's selector, then for each matched element retrieves the attribute or property value named by the query parameter (dot-notation: Name, Pset_WallCommon.FireRating). Returns JSON {GlobalId: value, ...}, excluding elements with no GlobalId and attributes that resolve to entity references rather than scalars. Add _sandboxed_query(ifc_bytes, selector, query_path) to render_service.py and a matching _query_via_socket() delegation path. Reuse Tier 2/3 caches for IFC bytes and GUID sets. No Tier 4 caching — responses depend on the query path, not just the URL hash.","status":"open","priority":2,"issue_type":"feature","owner":"noreply@anthropic.com","created_at":"2026-05-09T08:08:29Z","created_by":"Claude","updated_at":"2026-05-09T08:08:29Z","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"ifcurl-8rf","title":"Server: add /query endpoint for attribute/property retrieval","description":"Add GET and POST /query endpoints to service.py (mirroring the /select pattern). The endpoint resolves an ifc:// URL's selector, then for each matched element retrieves the attribute or property value named by the query parameter (dot-notation: Name, Pset_WallCommon.FireRating). Returns JSON {GlobalId: value, ...}, excluding elements with no GlobalId and attributes that resolve to entity references rather than scalars. Add _sandboxed_query(ifc_bytes, selector, query_path) to render_service.py and a matching _query_via_socket() delegation path. Reuse Tier 2/3 caches for IFC bytes and GUID sets. No Tier 4 caching — responses depend on the query path, not just the URL hash.","status":"open","priority":2,"issue_type":"feature","owner":"noreply@anthropic.com","created_at":"2026-05-09T08:08:29Z","created_by":"Claude","updated_at":"2026-05-09T08:08:29Z","dependency_count":0,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"ifcurl-b1c","title":"Implement OpenCDE BCF API on Forgejo","description":"Expose a BCF 3.0 REST API as a Python route group in the preview service, proxied at /bcf/3.0/ on the Forgejo hostname via nginx/Caddy.\n\n## Architecture\n\nPython routes added to service.py alongside the existing /preview and /bcf endpoints. No new Forgejo Go code — the service forwards the user's Authorization: Bearer header verbatim to Forgejo's REST API on localhost, so Forgejo's own auth middleware enforces per-user permissions. No service account or machine token required.\n\nBCF \u003c-\u003e ifc:// conversion stays entirely in Python: bcf.py (ifc:// -\u003e BCF, already exists) and the reverse direction implemented in ifcurl-l50.\n\n## Design\n\nThe BCF API is a stateless translation layer over Forgejo's REST API. No Forgejo recompilation, no schema changes, no new Go code.\n\n**Storage:** ifc:// URLs in issue and comment bodies are the viewpoint format. This is already what the viewer Issue button produces. The BCF\u003c-\u003eifc:// conversion is specified in SPECIFICATION.md §6.\n\n**Mapping:**\n\n| BCF resource | Forgejo |\n|---|---|\n| Project | Repository (project_id = owner/repo) |\n| Topic | Issue |\n| Topic guid | Derived from issue ID |\n| Comment | Issue comment |\n| Viewpoint | ifc:// URL found in issue/comment body |\n| Viewpoint guid | Derived from comment ID |\n| Snapshot | ifc:// URL proxied through preview service |\n\n**Auth:** BCF client sends Authorization: Bearer \u003cforgejo-token\u003e. Service forwards it to Forgejo REST API. No credential storage, no service token.\n\n**Read viewpoints:** scan issue/comment bodies for ifc:// URLs, convert each to a BCF viewpoint object on the fly via the §6 mapping.\n\n**Write viewpoints:** convert incoming BCF viewpoint to an ifc:// URL (ifcurl-l50), post as a new Forgejo comment.\n\n**Snapshot:** GET .../viewpoints/{guid}/snapshot proxies the stored ifc:// URL through the existing /preview endpoint.\n\n## Minimum viable endpoints\n\n- GET /bcf/3.0/projects\n- GET /bcf/3.0/projects/{id}\n- GET/POST /bcf/3.0/projects/{id}/topics\n- GET/PUT /bcf/3.0/projects/{id}/topics/{guid}\n- GET/POST /bcf/3.0/projects/{id}/topics/{guid}/comments\n- GET/POST /bcf/3.0/projects/{id}/topics/{guid}/viewpoints\n- GET /bcf/3.0/projects/{id}/topics/{guid}/viewpoints/{guid}\n- GET /bcf/3.0/projects/{id}/topics/{guid}/viewpoints/{guid}/snapshot\n- GET /bcf/3.0/projects/{id}/extensions (static minimal response)\n\n## Prerequisites\n\n- ifcurl-lwl: Foundation API discovery so BCF clients can locate this endpoint via OpenCDE auto-discovery\n- ifcurl-l50: BCF -\u003e ifc:// URL conversion needed for POST /viewpoints","status":"closed","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-28T22:55:11Z","created_by":"Bruno Postle","updated_at":"2026-04-29T18:39:11Z","closed_at":"2026-04-29T18:39:11Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-b1c","depends_on_id":"ifcurl-l50","type":"blocks","created_at":"2026-04-29T18:44:06Z","created_by":"Claude","metadata":"{}"},{"issue_id":"ifcurl-b1c","depends_on_id":"ifcurl-lwl","type":"blocks","created_at":"2026-04-29T18:44:05Z","created_by":"Claude","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"ifcurl-b1c","title":"Implement OpenCDE BCF API on Forgejo","description":"Expose a BCF 3.0 REST API as a Python route group in the preview service, proxied at /bcf/3.0/ on the Forgejo hostname via nginx/Caddy.\n\n## Architecture\n\nPython routes added to service.py alongside the existing /preview and /bcf endpoints. No new Forgejo Go code — the service forwards the user's Authorization: Bearer header verbatim to Forgejo's REST API on localhost, so Forgejo's own auth middleware enforces per-user permissions. No service account or machine token required.\n\nBCF \u003c-\u003e ifc:// conversion stays entirely in Python: bcf.py (ifc:// -\u003e BCF, already exists) and the reverse direction implemented in ifcurl-l50.\n\n## Design\n\nThe BCF API is a stateless translation layer over Forgejo's REST API. No Forgejo recompilation, no schema changes, no new Go code.\n\n**Storage:** ifc:// URLs in issue and comment bodies are the viewpoint format. This is already what the viewer Issue button produces. The BCF\u003c-\u003eifc:// conversion is specified in SPECIFICATION.md §6.\n\n**Mapping:**\n\n| BCF resource | Forgejo |\n|---|---|\n| Project | Repository (project_id = owner/repo) |\n| Topic | Issue |\n| Topic guid | Derived from issue ID |\n| Comment | Issue comment |\n| Viewpoint | ifc:// URL found in issue/comment body |\n| Viewpoint guid | Derived from comment ID |\n| Snapshot | ifc:// URL proxied through preview service |\n\n**Auth:** BCF client sends Authorization: Bearer \u003cforgejo-token\u003e. Service forwards it to Forgejo REST API. No credential storage, no service token.\n\n**Read viewpoints:** scan issue/comment bodies for ifc:// URLs, convert each to a BCF viewpoint object on the fly via the §6 mapping.\n\n**Write viewpoints:** convert incoming BCF viewpoint to an ifc:// URL (ifcurl-l50), post as a new Forgejo comment.\n\n**Snapshot:** GET .../viewpoints/{guid}/snapshot proxies the stored ifc:// URL through the existing /preview endpoint.\n\n## Minimum viable endpoints\n\n- GET /bcf/3.0/projects\n- GET /bcf/3.0/projects/{id}\n- GET/POST /bcf/3.0/projects/{id}/topics\n- GET/PUT /bcf/3.0/projects/{id}/topics/{guid}\n- GET/POST /bcf/3.0/projects/{id}/topics/{guid}/comments\n- GET/POST /bcf/3.0/projects/{id}/topics/{guid}/viewpoints\n- GET /bcf/3.0/projects/{id}/topics/{guid}/viewpoints/{guid}\n- GET /bcf/3.0/projects/{id}/topics/{guid}/viewpoints/{guid}/snapshot\n- GET /bcf/3.0/projects/{id}/extensions (static minimal response)\n\n## Prerequisites\n\n- ifcurl-lwl: Foundation API discovery so BCF clients can locate this endpoint via OpenCDE auto-discovery\n- ifcurl-l50: BCF -\u003e ifc:// URL conversion needed for POST /viewpoints","status":"closed","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-28T22:55:11Z","created_by":"Bruno Postle","updated_at":"2026-04-29T18:39:11Z","closed_at":"2026-04-29T18:39:11Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-b1c","depends_on_id":"ifcurl-l50","type":"blocks","created_at":"2026-04-29T18:44:06Z","created_by":"Claude","metadata":"{}"},{"issue_id":"ifcurl-b1c","depends_on_id":"ifcurl-lwl","type":"blocks","created_at":"2026-04-29T18:44:05Z","created_by":"Claude","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"ifcurl-962","title":"forgejo: replace Go markdown extension with JS, eliminate required Go compile","description":"ifc_url.go is a Goldmark AST extension that requires patching and recompiling Forgejo to work. This is painful when dev environment (Fedora) differs from deployment (Docker) — compiler flags, static linking, build tags must all match. The goal is to make the ifc:// preview feature work with zero Go compilation, via pure JS asset deployment.\n\nPlan:\n1. Add querySelectorAll('a[href^=\"ifc://\"]') scanning to ifcurl.js — replace matched anchors with figure elements (preview image + viewer link + ifc:// URL). Goldmark already renders [title](ifc://...) as \u003ca href=\"ifc://...\"\u003etitle\u003c/a\u003e without any extension, so this covers all markdown link syntax.\n2. Change the Issue button in viewer.js to emit [view](ifc://...) markdown link syntax instead of a bare URL — so that user-generated issue bodies produce linkable anchors Goldmark will render, not plain text.\n3. The existing Go extension (ifc_url.go) and its markdown.go registration can be removed on the next Forgejo upgrade; the current patched binary continues to work in the meantime.\n\nAcceptance: after this change, deploying new ifcurl features only requires copying JS assets — no Forgejo rebuild needed.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T06:58:15Z","created_by":"Bruno Postle","updated_at":"2026-04-27T07:25:09Z","started_at":"2026-04-27T06:58:48Z","closed_at":"2026-04-27T07:25:09Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"ifcurl-962","title":"forgejo: replace Go markdown extension with JS, eliminate required Go compile","description":"ifc_url.go is a Goldmark AST extension that requires patching and recompiling Forgejo to work. This is painful when dev environment (Fedora) differs from deployment (Docker) — compiler flags, static linking, build tags must all match. The goal is to make the ifc:// preview feature work with zero Go compilation, via pure JS asset deployment.\n\nPlan:\n1. Add querySelectorAll('a[href^=\"ifc://\"]') scanning to ifcurl.js — replace matched anchors with figure elements (preview image + viewer link + ifc:// URL). Goldmark already renders [title](ifc://...) as \u003ca href=\"ifc://...\"\u003etitle\u003c/a\u003e without any extension, so this covers all markdown link syntax.\n2. Change the Issue button in viewer.js to emit [view](ifc://...) markdown link syntax instead of a bare URL — so that user-generated issue bodies produce linkable anchors Goldmark will render, not plain text.\n3. The existing Go extension (ifc_url.go) and its markdown.go registration can be removed on the next Forgejo upgrade; the current patched binary continues to work in the meantime.\n\nAcceptance: after this change, deploying new ifcurl features only requires copying JS assets — no Forgejo rebuild needed.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-27T06:58:15Z","created_by":"Bruno Postle","updated_at":"2026-04-27T07:25:09Z","started_at":"2026-04-27T06:58:48Z","closed_at":"2026-04-27T07:25:09Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -69,6 +69,9 @@ except ImportError:
# Highlight colour used in 'highlight' visibility mode (orange). # Highlight colour used in 'highlight' visibility mode (orange).
_HIGHLIGHT_COLOR = (255, 140, 0) _HIGHLIGHT_COLOR = (255, 140, 0)
# Clash colour used in 'clash' visibility mode (red).
_CLASH_COLOR = (220, 50, 50)
# Diff colours: green=added, blue=modified, red=removed, grey=unchanged ghost. # Diff colours: green=added, blue=modified, red=removed, grey=unchanged ghost.
_DIFF_ADDED_COLOR = (50, 200, 50) _DIFF_ADDED_COLOR = (50, 200, 50)
_DIFF_MODIFIED_COLOR = (50, 100, 200) _DIFF_MODIFIED_COLOR = (50, 100, 200)
@ -160,6 +163,7 @@ def _add_shape(
highlight non-selected shown normally, selected shown in highlight colour highlight non-selected shown normally, selected shown in highlight colour
ghost selected shown normally, non-selected shown as grey + translucent ghost selected shown normally, non-selected shown as grey + translucent
isolate only selected elements are added (non-selected are skipped) isolate only selected elements are added (non-selected are skipped)
clash non-selected shown normally, selected shown in clash colour (red)
When *diff_ids* is provided the diff colouring takes precedence: When *diff_ids* is provided the diff colouring takes precedence:
green step ID in diff_ids["added"] green step ID in diff_ids["added"]
@ -231,6 +235,11 @@ def _add_shape(
color, opacity = _material_color_and_opacity(mat) color, opacity = _material_color_and_opacity(mat)
else: else:
color, opacity = (180, 180, 180), 0.10 color, opacity = (180, 180, 180), 0.10
elif visibility == "clash":
if is_selected:
color, opacity = _CLASH_COLOR, 1.0
else:
color, opacity = _material_color_and_opacity(mat)
else: # isolate — non-selected already returned above else: # isolate — non-selected already returned above
color, opacity = _material_color_and_opacity(mat) color, opacity = _material_color_and_opacity(mat)

View file

@ -106,6 +106,42 @@ def _sandboxed_select(ifc_bytes: bytes, selector: str) -> list[str]:
return [e.GlobalId for e in matched if hasattr(e, "GlobalId") and e.GlobalId] return [e.GlobalId for e in matched if hasattr(e, "GlobalId") and e.GlobalId]
def _sandboxed_query(ifc_bytes: bytes, selector: str, query_path: str) -> dict[str, str]:
"""Parse model, run selector, retrieve query_path value for each element.
query_path uses dot notation: a bare name is an IFC attribute (e.g. 'Name');
a dotted name is 'PsetName.PropertyName' (e.g. 'Pset_WallCommon.FireRating').
Returns {GlobalId: str(value)} for elements that have a GlobalId and a
scalar value at query_path. Entity references and None values are excluded.
"""
import ifcopenshell
import ifcopenshell.util.element
import ifcopenshell.util.selector
model = ifcopenshell.file.from_string(ifc_bytes.decode())
matched = list(ifcopenshell.util.selector.filter_elements(model, selector))
result: dict[str, str] = {}
if "." in query_path:
pset_name, prop_name = query_path.split(".", 1)
else:
pset_name, prop_name = None, query_path
for element in matched:
guid = getattr(element, "GlobalId", None)
if not guid:
continue
if pset_name is not None:
value = ifcopenshell.util.element.get_pset(element, pset_name, prop=prop_name)
else:
value = getattr(element, prop_name, None)
if value is None or hasattr(value, "id"):
continue
result[guid] = str(value)
return result
def _sandboxed_diff( def _sandboxed_diff(
head_bytes: bytes, head_bytes: bytes,
base_bytes: bytes, base_bytes: bytes,
@ -229,6 +265,30 @@ async def select_endpoint(
) )
@app.post("/query")
async def query_endpoint(
ifc: UploadFile = File(...),
selector: str = Form(...),
query_path: str = Form(...),
) -> Response:
"""Retrieve a property/attribute value for each selected element.
Returns JSON ``{"<GlobalId>": "<value>", }``.
"""
ifc_bytes = await ifc.read()
try:
result = run_sandboxed(_sandboxed_query, ifc_bytes, selector, query_path)
except SandboxCrashError as exc:
raise HTTPException(status_code=422, detail=f"IFC parse/query crashed: {exc}") from exc
except SandboxTimeoutError as exc:
raise HTTPException(status_code=503, detail=f"Query timed out: {exc}") from exc
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Query failed: {exc}") from exc
return Response(content=json.dumps(result), media_type="application/json")
@app.post("/render_diff") @app.post("/render_diff")
async def render_diff_endpoint( async def render_diff_endpoint(
head_ifc: UploadFile = File(...), head_ifc: UploadFile = File(...),

View file

@ -56,7 +56,7 @@ from ifcurl.bcf_api import router as bcf_router
from ifcurl.documents_api import router as documents_router from ifcurl.documents_api import router as documents_router
from ifcurl.git import diff_text as git_diff_text from ifcurl.git import diff_text as git_diff_text
from ifcurl.git import fetch_ifc from ifcurl.git import fetch_ifc
from ifcurl.render_service import _sandboxed_diff, _sandboxed_pipeline, _sandboxed_select from ifcurl.render_service import _sandboxed_diff, _sandboxed_pipeline, _sandboxed_query, _sandboxed_select
from ifcurl.sandbox import SandboxCrashError, SandboxTimeoutError, run_sandboxed from ifcurl.sandbox import SandboxCrashError, SandboxTimeoutError, run_sandboxed
from ifcurl.url import IfcUrl from ifcurl.url import IfcUrl
@ -126,6 +126,24 @@ def _select_via_socket(ifc_bytes: bytes, selector: str) -> list[str]:
return resp.json()["guids"] return resp.json()["guids"]
def _query_via_socket(ifc_bytes: bytes, selector: str, query_path: str) -> dict[str, str]:
"""Delegate query execution to the render service over the Unix socket."""
import httpx
try:
with httpx.Client(transport=httpx.HTTPTransport(uds=_RENDER_SOCKET)) as client:
resp = client.post(
"http://render/query",
files={"ifc": ifc_bytes},
data={"selector": selector, "query_path": query_path},
timeout=_RENDER_TIMEOUT,
)
except httpx.TransportError as exc:
raise HTTPException(status_code=503, detail=f"Render service unavailable: {exc}") from exc
_check_render_response(resp)
return resp.json()
def _diff_via_socket( def _diff_via_socket(
head_bytes: bytes, head_bytes: bytes,
base_bytes: bytes, base_bytes: bytes,
@ -405,6 +423,11 @@ class SelectRequest(BaseModel):
token: str | None = None token: str | None = None
class QueryRequest(BaseModel):
url: str
token: str | None = None
class BcfRequest(BaseModel): class BcfRequest(BaseModel):
url: str url: str
title: str = "IFC View" title: str = "IFC View"
@ -709,6 +732,75 @@ def select(request: SelectRequest) -> JSONResponse:
return JSONResponse({"guids": guids}) return JSONResponse({"guids": guids})
@app.get("/query")
def query_get(url: str, token: str | None = None) -> JSONResponse:
"""GET variant of POST /query."""
return query(QueryRequest(url=url, token=token))
@app.post("/query")
def query(request: QueryRequest) -> JSONResponse:
"""Retrieve a property or attribute value for each element matched by the selector.
The URL must contain both ``selector=`` and ``query=`` parameters. The
``query`` value uses dot notation: a bare name is an IFC attribute
(e.g. ``Name``); a dotted name addresses a property set
(e.g. ``Pset_WallCommon.FireRating``).
Returns ``{"<GlobalId>": "<value>", }`` JSON. Elements with no GlobalId
or whose query path resolves to an entity reference are excluded.
Results are not cached at Tier 4 the response depends on the query path
as well as the URL, making a shared cache key impractical.
"""
try:
ifc_url = IfcUrl.parse(request.url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if ifc_url.path is None:
raise HTTPException(status_code=400, detail="URL has no 'path' parameter")
if not ifc_url.selector:
raise HTTPException(status_code=400, detail="URL has no 'selector' parameter")
if not ifc_url.query:
raise HTTPException(status_code=400, detail="URL has no 'query' parameter")
_ssrf_check(ifc_url)
token = request.token
if token is None and ifc_url.host:
token = get_token_for_host(ifc_url.host)
try:
hexsha, ifc_bytes, _ = fetch_ifc(ifc_url, token=token)
except ImportError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_check_ifc_size(ifc_bytes)
cached_bytes = _t2_get(hexsha, ifc_url.path)
ifc_bytes = cached_bytes if cached_bytes is not None else ifc_bytes
if cached_bytes is None:
_t2_put(hexsha, ifc_url.path, ifc_bytes)
try:
if _RENDER_SOCKET:
result = _query_via_socket(ifc_bytes, ifc_url.selector, ifc_url.query)
else:
result = run_sandboxed(_sandboxed_query, ifc_bytes, ifc_url.selector, ifc_url.query)
except SandboxCrashError as exc:
raise HTTPException(
status_code=422, detail=f"IFC parse/query crashed: {exc}"
) from exc
except SandboxTimeoutError as exc:
raise HTTPException(status_code=503, detail=f"Query timed out: {exc}") from exc
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Query failed: {exc}") from exc
return JSONResponse(result)
@app.get("/foundation/versions") @app.get("/foundation/versions")
def foundation_versions(request: Request) -> JSONResponse: def foundation_versions(request: Request) -> JSONResponse:
"""OpenCDE Foundation API discovery endpoint. """OpenCDE Foundation API discovery endpoint.