service+viewer: add /select endpoint for complex selector resolution

New GET+POST /select endpoint resolves an ifc:// URL selector to a JSON
list of GlobalIds, with T3 caching. The viewer now detects non-type-name
selectors (property filters, Name=, pset notation) and falls back to a
/select round-trip, applying the returned GUIDs via getLocalIdsByGuids().

Closes ifcurl-ov6.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-04-25 20:20:41 +01:00
parent 14c8412074
commit e66460fb13
4 changed files with 185 additions and 27 deletions

View file

@ -46,7 +46,7 @@
{"id":"ifcurl-6ys","title":"Research alternative URL schemes before ifc:// is committed to","description":"The ifc:// scheme name is not yet set in stone. It's worth researching whether a different scheme would be more appropriate or less likely to conflict with existing tooling.\\n\\nLook at how other tools handle similar problems:\\n- pip uses git+https:// and git+ssh:// (prefix on top of existing schemes)\\n- Are there existing ifc:// registrations or tools that would conflict?\\n- What scheme patterns are used by other domain-specific version-controlled asset tools?\\n- Alternatives to consider: ifcgit://, git+ifc://, ifc+git://, or a vendor-prefixed form\\n- Check IANA registered schemes and common-use unregistered schemes for conflicts\\n\\nGoal: make a confident decision to keep ifc:// or switch, before the scheme is baked into any external integrations.","notes":"Research complete. Summary:\n\n- ifc is NOT registered with IANA. bcf, bim also unregistered. Clean namespace.\n- No existing tool or buildingSMART standard uses ifc:// — no conflict risk.\n- BCF (bcf://) would be confusing — BCF is a live buildingSMART standard, don't use it.\n- pip-style compound schemes (ifc+git://, ifc+https://) look appealing but hurt more than they help: they fragment the transport-inference design, improve nothing for OS handlers or terminal clickability, and are harder to explain.\n- Forgejo: CUSTOM_URL_SCHEMES defaults to allowing all schemes — ifc:// links render fine out of the box.\n- GitHub/terminals: bare custom URLs are never auto-linked regardless of scheme choice. Explicitly formatted markdown links work fine.\n\nDecision: keep ifc://\n\nAction to add to roadmap: submit a provisional IANA registration (First Come First Served, ~1 hour, just an email to iana@iana.org with scheme name, syntax summary, security notes, and a spec URL). Do this when Phase 1 spec stabilises.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T09:08:13Z","created_by":"Bruno Postle","updated_at":"2026-04-25T11:17:44Z","started_at":"2026-04-25T11:04:50Z","closed_at":"2026-04-25T11:17:44Z","close_reason":"Keep ifc://. Namespace is clean (no IANA registration, no prior art). Compound schemes (ifc+git://) would break transport-inference design with no practical benefit. Action item: provisional IANA registration when Phase 1 spec stabilises.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-gy8","title":"Viewer: 'View in 3D' uses commit hash but no UI affordance to switch to branch tip","description":"The 'View in 3D' button in footer.tmpl always generates an ifc:// URL with a commit hash (@abc123...). This is correct for reproducibility, but users who want to track 'the latest version of main' must manually change the URL from @\u003chash\u003e to @heads/main. There is no indication in the viewer that the model is pinned to a specific commit. Add a visual indicator (e.g. 'commit abc1234 · switch to heads/main') in the viewer toolbar when the ref is a commit hash, offering a one-click switch to the branch tip. The branch name could be inferred from Forgejo's API given the commit hash.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:58Z","created_by":"Bruno Postle","updated_at":"2026-04-25T18:12:57Z","started_at":"2026-04-25T18:02:24Z","closed_at":"2026-04-25T18:12:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-dag","title":"Viewer: no model metadata panel — disciplines, spatial structure, element counts","description":"Beyond individual element selection, reviewers and designers need a model overview: what disciplines/types are present, how many elements, what building storeys exist, what's the spatial structure (site/building/storey/space hierarchy). A collapsible side panel showing IFC spatial tree and type breakdown would give immediate context and allow navigating by storey or discipline. Without this, reviewers must already know the model structure before opening the viewer.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:55Z","created_by":"Bruno Postle","updated_at":"2026-04-25T19:04:25Z","started_at":"2026-04-25T18:38:17Z","closed_at":"2026-04-25T19:04:25Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-ov6","title":"Viewer selector only supports type names — property filters require server round-trip","description":"The viewer's applySelector() only supports IFC type names and their unions ('IfcWall+IfcSlab'). Full IfcOpenShell selector syntax — filter by property value (Pset_WallCommon.FireRating=2HR), by GlobalId, by name (.Name='Core Wall'), by spatial containment — is not supported. For collaboration, reviewers need to select specific elements ('all walls with fire rating 2HR that aren't compliant') not just all walls. The service's /preview endpoint already handles full selector syntax server-side. The viewer should forward complex selectors to the service and use the rendered image, or fetch the matching GlobalIds from a new /resolve-selector service endpoint and apply them in the viewer.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:38Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:04:38Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-ov6","title":"Viewer selector only supports type names — property filters require server round-trip","description":"The viewer's applySelector() only supports IFC type names and their unions ('IfcWall+IfcSlab'). Full IfcOpenShell selector syntax — filter by property value (Pset_WallCommon.FireRating=2HR), by GlobalId, by name (.Name='Core Wall'), by spatial containment — is not supported. For collaboration, reviewers need to select specific elements ('all walls with fire rating 2HR that aren't compliant') not just all walls. The service's /preview endpoint already handles full selector syntax server-side. The viewer should forward complex selectors to the service and use the rendered image, or fetch the matching GlobalIds from a new /resolve-selector service endpoint and apply them in the viewer.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:38Z","created_by":"Bruno Postle","updated_at":"2026-04-25T19:19:28Z","started_at":"2026-04-25T19:15:47Z","closed_at":"2026-04-25T19:19:28Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-f3p","title":"footer.tmpl: 'View in 3D' button absent from file history (git log) page","description":"The 'View in 3D' button is injected on the file view page (showing one commit of a file). It is not shown on the file history page (the list of commits that touched a file), which is where users naturally go to see how a model evolved over time. Each entry in the history list already has a commit hash in its href. Injecting a 'View in 3D' link or button alongside each history entry would let reviewers quickly compare model states across commits — which is a primary collaboration use case.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:23Z","created_by":"Bruno Postle","updated_at":"2026-04-24T14:04:56Z","started_at":"2026-04-24T13:21:51Z","closed_at":"2026-04-24T14:04:56Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-4rj","title":"Viewer: no branch/ref switcher — can't navigate model history or compare versions","description":"The viewer loads a single ifc:// URL and has no UI to switch to a different ref. A reviewer wanting to see 'what did this look like on main before the PR' or 'show me the v1.2 tag' must manually edit the URL. For a collaboration tool, being able to flip between 'current PR branch' and 'base branch' at the same camera view is essential for review. Consider a ref dropdown or input in the toolbar that reconstructs the URL with a different @ref, preserving camera/clip/selector state. The structured fields already expose repo/ref/path — the ref field is editable but not prominently labelled as a version picker.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:20Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:04:20Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-np8","title":"Viewer depends on CDN (unpkg, esm.sh) — broken in air-gapped or restricted networks","description":"viewer.html loads all JavaScript from public CDNs: three.js from esm.sh, @thatopen/components and fragments from esm.sh/unpkg, web-ifc WASM from unpkg, the fragment worker from unpkg, JSZip from esm.sh. In corporate BIM environments with restricted internet (common in construction/engineering), the viewer will fail to load entirely. There is no documented self-hosted path. Consider: document how to serve the dependencies from the Forgejo instance's own /assets/ directory, or provide a bundled viewer.html as a build artifact. The viewer worker fetch ('https://unpkg.com/@thatopen/fragments@3.3.1/dist/Worker/worker.mjs') is already fetched at runtime with a special workaround for Firefox — this is the most fragile CDN dependency.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:04Z","created_by":"Bruno Postle","updated_at":"2026-04-25T17:25:36Z","started_at":"2026-04-25T12:09:35Z","closed_at":"2026-04-25T17:25:36Z","close_reason":"viewer.html now loads all JS from /assets/ (bundled at build time via npm+esbuild). No CDN dependencies remain.","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -556,35 +556,64 @@
// -----------------------------------------------------------------------
// Apply selector= by isolating matching IFC elements.
// Supports simple type names and + union: "IfcWall+IfcSlab".
// Subtypes matched by prefix: IfcWall matches IfcWallStandardCase, etc.
// Attribute/property filters (.Name=, #id, [pset]) are not yet supported.
//
// Simple selectors (IFC type names, "+" union): handled client-side.
// "IfcWall" "IfcWall+IfcSlab"
// Subtypes matched by prefix: IfcWall matches IfcWallStandardCase.
//
// Complex selectors (property filters, name, GlobalId, pset): resolved
// server-side via GET /select, then applied by GUID in the viewer.
// Requires the ifcurl service to be running.
// -----------------------------------------------------------------------
async function applySelector(components, selectorStr) {
const userTypes = selectorStr
.split("+")
.map(g => g.trim().replace(/[.,\s#[].*/s, "").trim().toUpperCase())
.filter(Boolean);
if (!userTypes.length) return;
function isSimpleTypeSelector(selectorStr) {
return selectorStr.split("+").every(part => /^[Ii]fc[A-Za-z0-9]+$/.test(part.trim()));
}
const classifier = components.get(OBC.Classifier);
await classifier.byCategory();
const categoryGroups = classifier.list.get("Categories");
if (!categoryGroups) return;
const matchingCategories = [];
for (const [cat] of categoryGroups) {
if (userTypes.some(t => cat === t || cat.startsWith(t)))
matchingCategories.push(cat);
}
if (!matchingCategories.length) return;
const matching = await classifier.find({ Categories: matchingCategories });
if (!matching || Object.keys(matching).length === 0) return;
async function applySelector(components, model, selectorStr, srcUrl) {
if (!selectorStr) return;
const hider = components.get(OBC.Hider);
await hider.isolate(matching);
if (isSimpleTypeSelector(selectorStr)) {
const userTypes = selectorStr.split("+").map(p => p.trim().toUpperCase());
const classifier = components.get(OBC.Classifier);
await classifier.byCategory();
const categoryGroups = classifier.list.get("Categories");
if (!categoryGroups) return;
const matchingCategories = [];
for (const [cat] of categoryGroups) {
if (userTypes.some(t => cat === t || cat.startsWith(t)))
matchingCategories.push(cat);
}
if (!matchingCategories.length) return;
const matching = await classifier.find({ Categories: matchingCategories });
if (!matching || Object.keys(matching).length === 0) return;
await hider.isolate(matching);
return;
}
// Complex selector: ask the service to resolve it to GUIDs.
statusEl.textContent = "Resolving selector…";
let guids;
try {
const resp = await fetch(`/select?url=${encodeURIComponent(srcUrl)}`);
if (!resp.ok) {
const detail = await resp.text().catch(() => resp.statusText);
statusEl.textContent = `Selector error: ${detail}`;
return;
}
guids = (await resp.json()).guids ?? [];
} catch (_) {
statusEl.textContent = "Complex selector requires the ifcurl service — /select not reachable";
return;
}
if (!guids.length) return;
const localIds = (await model.getLocalIdsByGuids(guids)).filter(id => id !== null);
if (!localIds.length) return;
await hider.isolate({ [model.modelId]: localIds });
}
// -----------------------------------------------------------------------
@ -1078,7 +1107,7 @@
const selectorStr = qsPost.get("selector") ?? "";
if (selectorStr) {
statusEl.textContent = "Applying selector…";
await applySelector(components, selectorStr);
await applySelector(components, model, selectorStr, ifcUrl);
}
await fragments.core.update(true);

View file

@ -396,6 +396,11 @@ class PreviewRequest(BaseModel):
"""
class SelectRequest(BaseModel):
url: str
token: str | None = None
class BcfRequest(BaseModel):
url: str
title: str = "IFC View"
@ -635,6 +640,71 @@ def bcf_export(request: BcfRequest) -> Response:
)
@app.get("/select")
def select_get(url: str, token: str | None = None) -> JSONResponse:
"""GET variant of POST /select."""
return select(SelectRequest(url=url, token=token))
@app.post("/select")
def select(request: SelectRequest) -> JSONResponse:
"""Resolve an ifc:// URL selector to a list of element GlobalIds.
Returns ``{"guids": [...]}`` JSON. The selector must be present in the
URL's ``selector=`` parameter. Results are cached at Tier 3.
"""
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")
_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)
cached_guids = _t3_get(hexsha, ifc_url.path, ifc_url.selector)
if cached_guids is not None:
return JSONResponse({"guids": list(cached_guids)})
try:
if _RENDER_SOCKET:
guids = _select_via_socket(ifc_bytes, ifc_url.selector)
else:
guids = run_sandboxed(_sandboxed_select, ifc_bytes, ifc_url.selector)
except SandboxCrashError as exc:
raise HTTPException(
status_code=422, detail=f"IFC parse/select crashed: {exc}"
) from exc
except SandboxTimeoutError as exc:
raise HTTPException(status_code=503, detail=f"Select timed out: {exc}") from exc
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Invalid selector: {exc}") from exc
_t3_put(hexsha, ifc_url.path, ifc_url.selector, frozenset(guids))
return JSONResponse({"guids": guids})
@app.get("/render_diff")
def render_diff_get(base: str, head: str, token: str | None = None) -> Response:
"""GET variant of POST /render_diff for use in HTML ``<img src>`` tags.

View file

@ -400,6 +400,65 @@ class TestGetEndpoint:
assert received["token"] == "mytoken"
# ---------------------------------------------------------------------------
# /select endpoint
# ---------------------------------------------------------------------------
class TestSelectEndpoint:
def test_returns_guids_json(self, model_with_geometry):
ifc_bytes = model_with_geometry.to_string().encode()
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)):
r = client.get("/select", params={"url": SELECTOR_URL})
assert r.status_code == 200
assert r.headers["content-type"] == "application/json"
body = r.json()
assert "guids" in body
assert isinstance(body["guids"], list)
def test_missing_selector_returns_400(self):
r = client.get("/select", params={"url": MUTABLE_URL})
assert r.status_code == 400
def test_invalid_url_scheme_returns_400(self):
r = client.get("/select", params={"url": "https://example.com/model.ifc"})
assert r.status_code == 400
def test_populates_t3_cache(self, model_with_geometry):
ifc_bytes = model_with_geometry.to_string().encode()
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)):
client.get("/select", params={"url": SELECTOR_URL})
assert (FAKE_HEXSHA, "model.ifc", "IfcWall") in _t3_cache
def test_second_request_uses_t3_cache(self, model_with_geometry):
ifc_bytes = model_with_geometry.to_string().encode()
call_count = 0
real_filter = __import__(
"ifcopenshell.util.selector", fromlist=["filter_elements"]
).filter_elements
def counting_filter(model, selector):
nonlocal call_count
call_count += 1
return real_filter(model, selector)
with (
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
patch("ifcopenshell.util.selector.filter_elements", counting_filter),
):
client.get("/select", params={"url": SELECTOR_URL})
client.get("/select", params={"url": SELECTOR_URL})
assert call_count == 1
def test_post_select_returns_guids_json(self, model_with_geometry):
ifc_bytes = model_with_geometry.to_string().encode()
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)):
r = client.post("/select", json={"url": SELECTOR_URL})
assert r.status_code == 200
assert "guids" in r.json()
# ---------------------------------------------------------------------------
# Tier 2: LRU eviction
# ---------------------------------------------------------------------------