mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 18:28:14 +00:00
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:
parent
14c8412074
commit
e66460fb13
4 changed files with 185 additions and 27 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue