Viewer: query field and results table (ifcurl-2iv, ifcurl-4u3)

viewer-url.js: parseIfcUrl returns query field; buildIfcUrl gains optional
fifth query param, included in the QS when non-empty.

viewer.html: add 'query' text field (label + input) in toolbar row 2
after the visibility selector. Add #query-panel (fixed bottom-centre)
with a scrollable table showing GlobalId | value rows, a header
displaying the query path, and a close button.

viewer.js: populate queryInput from parsed URL on load; include query
in buildIfcUrl() wrapper; wire queryInput into Enter-key and focus/blur
listeners. Add applyQuery(srcUrl, queryPath) which calls GET /query,
populates the table, and shows the panel. Called from main() after
applySelector when both selector and query params are present.

viewer-url.test.js: add tests for parseIfcUrl query field, buildIfcUrl
with query, and buildIfcUrl omitting empty query.

https://claude.ai/code/session_01NBsytCd6L7UQPiKbcLn4kn
This commit is contained in:
Claude 2026-05-09 11:09:55 +00:00
parent ea0ad56bda
commit 38161604f9
5 changed files with 123 additions and 7 deletions

View file

@ -50,8 +50,8 @@
{"_type":"issue","id":"ifcurl-i1s","title":"Test Phase 3b web viewer end-to-end","description":"After deploying the footer template (ifcurl-ach), test the full flow: open an .ifc file in Forgejo at localhost:3000, click 'View in 3D', verify the viewer loads the model. CDN versions (@thatopen/components 2.4.0, three 0.160.0, web-ifc 0.0.66) may need adjustment if the API has changed.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:01Z","created_by":"Bruno Postle","updated_at":"2026-04-23T20:05:04Z","started_at":"2026-04-23T19:54:56Z","closed_at":"2026-04-23T20:05:04Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-49o","type":"blocks","created_at":"2026-04-23T07:14:12Z","created_by":"Claude","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-4yu","type":"blocks","created_at":"2026-04-23T07:14:11Z","created_by":"Claude","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ach","type":"blocks","created_at":"2026-04-23T06:48:10Z","created_by":"Claude","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ahd","type":"blocks","created_at":"2026-04-23T07:02:54Z","created_by":"Claude","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"ifcurl-ach","title":"Deploy Phase 3b footer template to Forgejo","description":"The footer.tmpl file that injects a 'View in 3D' button on .ifc file pages has been written but not yet deployed. Deploy it to /etc/forgejo/templates/custom/footer.tmpl and verify the button appears when viewing an .ifc file on localhost:3000.","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-23T05:47:53Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:14:35Z","closed_at":"2026-04-23T06:14:35Z","close_reason":"Split into atomic tasks: ifcurl-ohq, ifcurl-49o","dependencies":[{"issue_id":"ifcurl-ach","depends_on_id":"ifcurl-ahd","type":"blocks","created_at":"2026-04-23T07:02:40Z","created_by":"Claude","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"_type":"issue","id":"ifcurl-982","title":"Viewer: implement clash visibility mode display","description":"Implement visibility=clash in the Bonsai viewer. Selected elements (GUIDs pre-identified as clashing, typically sourced from the server /clash endpoint) should be displayed with a solid red clash-indicator style (full opacity, distinct red material) while all other elements render normally — equivalent to highlight mode but using red rather than orange for selected elements. The viewer should be able to construct ifc://...\u0026visibility=clash URLs from a set of GUID pairs returned by /clash. When exporting to BCF, clash is treated as highlight (BCF has no equivalent distinction).","status":"closed","priority":3,"issue_type":"feature","assignee":"Claude","owner":"noreply@anthropic.com","created_at":"2026-05-09T08:08:45Z","created_by":"Claude","updated_at":"2026-05-09T09:03:00Z","started_at":"2026-05-09T09:02:27Z","closed_at":"2026-05-09T09:03:00Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-982","depends_on_id":"ifcurl-k0x","type":"blocks","created_at":"2026-05-09T08:28:57Z","created_by":"Claude","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"ifcurl-4u3","title":"Viewer: query creation and editing UI","description":"Add UI controls in the Bonsai viewer for composing and modifying queries. Users need: a text field for the selector expression, a text field for the query path (dot-notation, e.g. Pset_WallCommon.FireRating), and an Apply button that updates the active ifc:// URL and triggers a re-query. The UI should also provide a copy-URL button. Changing the selector or query fields should update the URL in the address bar and the displayed 3D view/table without requiring a full page reload.","status":"open","priority":3,"issue_type":"feature","owner":"noreply@anthropic.com","created_at":"2026-05-09T08:08:42Z","created_by":"Claude","updated_at":"2026-05-09T08:08:42Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"ifcurl-2iv","title":"Viewer: display query results in a table panel","description":"When an ifc:// URL contains a query parameter, the viewer (Bonsai) should call the preview service /query endpoint and display the returned {GlobalId: value} mapping in a table panel alongside the 3D view. The table should have columns for element GlobalId (or Name if resolvable from the model) and the queried value. When both query and camera/visibility parameters are present, show the 3D view and property table together. The table should update whenever the URL's selector or query changes.","status":"open","priority":3,"issue_type":"feature","owner":"noreply@anthropic.com","created_at":"2026-05-09T08:08:39Z","created_by":"Claude","updated_at":"2026-05-09T08:08:39Z","dependencies":[{"issue_id":"ifcurl-2iv","depends_on_id":"ifcurl-8rf","type":"blocks","created_at":"2026-05-09T08:08:49Z","created_by":"Claude","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"ifcurl-4u3","title":"Viewer: query creation and editing UI","description":"Add UI controls in the Bonsai viewer for composing and modifying queries. Users need: a text field for the selector expression, a text field for the query path (dot-notation, e.g. Pset_WallCommon.FireRating), and an Apply button that updates the active ifc:// URL and triggers a re-query. The UI should also provide a copy-URL button. Changing the selector or query fields should update the URL in the address bar and the displayed 3D view/table without requiring a full page reload.","status":"closed","priority":3,"issue_type":"feature","assignee":"Claude","owner":"noreply@anthropic.com","created_at":"2026-05-09T08:08:42Z","created_by":"Claude","updated_at":"2026-05-09T11:09:43Z","started_at":"2026-05-09T11:06:39Z","closed_at":"2026-05-09T11:09:43Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"ifcurl-2iv","title":"Viewer: display query results in a table panel","description":"When an ifc:// URL contains a query parameter, the viewer (Bonsai) should call the preview service /query endpoint and display the returned {GlobalId: value} mapping in a table panel alongside the 3D view. The table should have columns for element GlobalId (or Name if resolvable from the model) and the queried value. When both query and camera/visibility parameters are present, show the 3D view and property table together. The table should update whenever the URL's selector or query changes.","status":"closed","priority":3,"issue_type":"feature","assignee":"Claude","owner":"noreply@anthropic.com","created_at":"2026-05-09T08:08:39Z","created_by":"Claude","updated_at":"2026-05-09T11:09:43Z","started_at":"2026-05-09T11:06:38Z","closed_at":"2026-05-09T11:09:43Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-2iv","depends_on_id":"ifcurl-8rf","type":"blocks","created_at":"2026-05-09T08:08:49Z","created_by":"Claude","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"ifcurl-xfi","title":"Implement OpenCDE document picker (select-documents flow)","description":"The OpenCDE Documents API defines a browser-redirect flow for document discovery. Implement POST /documents/1.0/select-documents which returns a select_documents_url. That URL serves a small HTML page: owner/repo selector then file-tree browser. When user selects an IFC file, generate the document_id and redirect to the callback_url with document_ids[] params.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:16Z","created_by":"Bruno Postle","updated_at":"2026-04-29T20:38:16Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"ifcurl-bil","title":"Implement OpenCDE OAuth2 proxy routes","description":"The OpenCDE Foundation API requires OAuth2 endpoints. Forgejo already has OAuth2 support — proxy through to it. Routes needed: GET /foundation/1.0/oauth2/auth_url (return Forgejo OAuth2 auth URL), POST /foundation/1.0/oauth2/token (proxy code exchange to Forgejo token endpoint), POST /foundation/1.0/oauth2/token_refresh (proxy refresh). Requires a one-time step of registering an OAuth2 app in Forgejo admin and storing client_id/client_secret as env vars.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:15Z","created_by":"Bruno Postle","updated_at":"2026-04-29T20:38:15Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"ifcurl-k3v","title":"Add GET /documents/1.0/document-metadata/{document_id}","description":"The OpenCDE Documents API spec includes a GET endpoint for individual file metadata. Implement it: decode the document_id, fetch the file's latest info from Forgejo (name, size, last-modified), return as a metadata response.","status":"closed","priority":3,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-29T20:38:13Z","created_by":"Bruno Postle","updated_at":"2026-04-29T21:25:07Z","started_at":"2026-04-29T21:22:34Z","closed_at":"2026-04-29T21:25:07Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
@ -118,5 +118,5 @@
{"_type":"issue","id":"ifcurl-vam","title":"Phase 6: Bonsai offer ifc:// URL when saving linked model reference","description":"When saving an IFCDOCUMENTREFERENCE in Bonsai, offer the option to write the location as an ifc:// URL rather than a relative path. When a relative path would traverse above the repository root, prompt the user to convert it to an ifc:// URL. Requires Phase 4 federation (ifcurl-bqt).","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:16Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:13Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-vam","depends_on_id":"ifcurl-bqt","type":"blocks","created_at":"2026-04-23T06:49:23Z","created_by":"Claude","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"ifcurl-9k2","title":"Phase 5: IFC Viewer integration (equivalent of Phase 4)","description":"Add ifc:// OS protocol handler and 'Copy view URL' button to the IFC Viewer desktop application, equivalent to the Bonsai Phase 4 integration. Depends on Phase 4 being complete as a reference implementation.","status":"deferred","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:49:08Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:12Z","defer_until":"2026-12-01T00:00:00Z","dependencies":[{"issue_id":"ifcurl-9k2","depends_on_id":"ifcurl-0oj","type":"blocks","created_at":"2026-04-23T06:49:14Z","created_by":"Claude","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"ifcurl-prc","title":"Phase 3c: 3D diff view for IFC PR merges","description":"After a PR merge completes, surface a 'View merge in 3D' button using the Phase 3b viewer. Use @thatopen/components highlighter to colour added, removed, and modified elements differently, driven by comparing the merged IFC against either parent. Requires Phase 3b viewer (ifcurl-i1s) to be working.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:30Z","created_by":"Bruno Postle","updated_at":"2026-04-26T17:15:07Z","closed_at":"2026-04-26T17:15:07Z","close_reason":"Rendered diff already implemented in footer.tmpl Case 3; static PNG via /render_diff endpoint, not interactive 3D","dependencies":[{"issue_id":"ifcurl-prc","depends_on_id":"ifcurl-i1s","type":"blocks","created_at":"2026-04-23T06:48:36Z","created_by":"Claude","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"memory","key":"torawurl-in-viewer-url-js-must-support-github","value":"toRawUrl in viewer-url.js must support GitHub, GitLab, Forgejo/Gitea (codeberg.org, self-hosted), and localhost. Tests in forgejo/tests/viewer-url.test.js cover all four. The localhost check (http vs https) is based on host.startsWith('localhost'), so subdomain.localhost gets https."}
{"_type":"memory","key":"forgejo-build-and-deploy-procedure-go-build-c","value":"Forgejo build and deploy procedure: go build -C /home/bruno/src/forgejo -tags 'sqlite sqlite_unlock_notify' -ldflags \"-X 'forgejo.org/modules/setting.StaticRootPath=/usr/share/forgejo'\" -o /home/bruno/src/forgejo/forgejo . — then sudo cp forgejo /usr/bin/forgejo. StaticRootPath=/usr/share/forgejo. CustomPath=/var/lib/forgejo/custom. Custom templates go to /var/lib/forgejo/custom/templates/custom/ (e.g. footer.tmpl → /var/lib/forgejo/custom/templates/custom/footer.tmpl). Custom static files go to /var/lib/forgejo/custom/public/assets/ (NOT /etc/forgejo/public/assets/) and are served at /assets/*."}
{"_type":"memory","key":"torawurl-in-viewer-url-js-must-support-github","value":"toRawUrl in viewer-url.js must support GitHub, GitLab, Forgejo/Gitea (codeberg.org, self-hosted), and localhost. Tests in forgejo/tests/viewer-url.test.js cover all four. The localhost check (http vs https) is based on host.startsWith('localhost'), so subdomain.localhost gets https."}

View file

@ -28,17 +28,19 @@ export function parseIfcUrl(raw) {
host, userAt, repoSuffix, ref,
path: qs.get("path") ?? "",
selector: qs.get("selector") ?? "",
query: qs.get("query") ?? "",
camera: qs.get("camera") ?? "",
fov: qs.get("fov") ?? "",
scale: qs.get("scale") ?? "",
};
}
export function buildIfcUrl(repo, ref, path, selector) {
export function buildIfcUrl(repo, ref, path, selector, query) {
if (!repo || !ref) return null;
const qs = new URLSearchParams();
if (path) qs.set("path", path);
if (selector) qs.set("selector", selector);
if (query) qs.set("query", query);
const qsStr = qs.toString().replace(/%2C/g, ",").replace(/%2B/g, "+").replace(/%24/g, "$");
return `ifc://${repo}@${ref}${qsStr ? "?" + qsStr : ""}`;
}

View file

@ -111,6 +111,33 @@
}
.meta-row.storey-row.active .meta-name { color: #a8c8ff; }
.meta-count { font-size: 10px; color: #808088; flex-shrink: 0; }
/* Query results panel */
#query-panel {
position: fixed; bottom: 44px; left: 50%; transform: translateX(-50%);
z-index: 10;
background: rgba(28,28,30,0.92); backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.12); border-radius: 6px;
padding: 8px 10px; min-width: 320px; max-width: 640px;
max-height: 200px; overflow-y: auto;
}
#query-panel-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
}
#query-panel-header span {
font-size: 10px; color: #606068;
text-transform: uppercase; letter-spacing: 0.06em;
}
#query-table { width: 100%; border-collapse: collapse; font-size: 11px; }
#query-table th {
text-align: left; color: #808088; font-weight: normal;
padding: 1px 6px 4px 0; border-bottom: 1px solid rgba(255,255,255,0.08);
}
#query-table td {
padding: 2px 6px 2px 0; color: #f0f0f0; font-family: monospace;
border-bottom: 1px solid rgba(255,255,255,0.05);
max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Element properties panel */
#props-panel {
position: fixed; bottom: 44px; right: 12px; z-index: 10;
@ -194,6 +221,13 @@
<option value="clash">clash</option>
</select>
</div>
<div class="field" style="flex:2">
<label>query</label>
<input id="query-input" class="ti" type="text"
placeholder="Name or Pset.Property"
title="Attribute or property set value to retrieve for each selected element"
spellcheck="false">
</div>
</div>
<!-- Row 3: BCF export/import form (hidden until BCF button clicked) -->
<div id="bcf-row" class="trow" style="display:none">
@ -213,6 +247,16 @@
</div>
<div id="viewer"></div>
<div id="meta-panel" style="display:none"></div>
<div id="query-panel" style="display:none">
<div id="query-panel-header">
<span id="query-panel-title">query</span>
<button id="query-close-btn" class="tb-btn"></button>
</div>
<table id="query-table">
<thead><tr><th>GlobalId</th><th id="query-val-header-col">value</th></tr></thead>
<tbody id="query-tbody"></tbody>
</table>
</div>
<div id="status"></div>
<div id="props-panel" style="display:none">
<div id="props-panel-header">

View file

@ -14,6 +14,12 @@ const repoInput = document.getElementById("repo-input");
const refInput = document.getElementById("ref-input");
const pathInput = document.getElementById("path-input");
const selectorInput = document.getElementById("selector-input");
const queryInput = document.getElementById("query-input");
const queryPanel = document.getElementById("query-panel");
const queryTbody = document.getElementById("query-tbody");
const queryPanelTitle = document.getElementById("query-panel-title");
const queryValHeader = document.getElementById("query-val-header-col");
const queryCloseBtn = document.getElementById("query-close-btn");
const copyUrlBtn = document.getElementById("copy-url-btn");
const newIssueBtn = document.getElementById("new-issue-btn");
const visibilitySelect = document.getElementById("visibility-select");
@ -59,7 +65,8 @@ function removeSnapshot() {
function buildIfcUrl() {
return _buildIfcUrl(
repoInput.value.trim(), refInput.value.trim(),
pathInput.value.trim(), selectorInput.value.trim()
pathInput.value.trim(), selectorInput.value.trim(),
queryInput.value.trim()
);
}
@ -70,6 +77,7 @@ if (parsed) {
refInput.value = parsed.ref;
pathInput.value = parsed.path;
selectorInput.value = parsed.selector;
queryInput.value = parsed.query;
}
urlInput.value = ifcUrl;
@ -175,10 +183,12 @@ function loadFromFields() {
}
// Pressing Enter in any structured field rebuilds the URL and navigates.
for (const el of [repoInput, refInput, pathInput, selectorInput]) {
for (const el of [repoInput, refInput, pathInput, selectorInput, queryInput]) {
el.addEventListener("keydown", e => { if (e.key === "Enter") loadFromFields(); });
}
queryCloseBtn.addEventListener("click", () => { queryPanel.style.display = "none"; });
// ▾ button: if the ref field already has a value, navigate; otherwise clear
// + focus so the datalist shows all refs.
const refPickBtn = document.getElementById("ref-pick-btn");
@ -282,7 +292,7 @@ propsCopyBtn.addEventListener("click", async () => {
});
const allInputs = [urlInput, fovInput, repoInput, refInput, pathInput, selectorInput,
visibilitySelect, bcfTitleIn, bcfCommentIn];
queryInput, visibilitySelect, bcfTitleIn, bcfCommentIn];
for (const el of allInputs) {
el.addEventListener("focus", onInputFocus);
el.addEventListener("blur", onInputBlur);
@ -545,6 +555,45 @@ async function applySelector(components, model, selectorStr, srcUrl, visibility
}
}
// -----------------------------------------------------------------------
// Fetch /query results and display them in the query panel.
// -----------------------------------------------------------------------
async function applyQuery(srcUrl, queryPath) {
statusEl.textContent = "Querying…";
let data;
try {
const resp = await fetch(`/query?url=${encodeURIComponent(srcUrl)}`);
if (!resp.ok) {
const detail = await resp.text().catch(() => resp.statusText);
statusEl.textContent = `Query error: ${detail}`;
return;
}
data = await resp.json();
} catch (_) {
statusEl.textContent = "Query requires the ifcurl service — /query not reachable";
return;
}
statusEl.textContent = "";
const entries = Object.entries(data);
if (!entries.length) return;
queryPanelTitle.textContent = queryPath;
queryValHeader.textContent = queryPath;
queryTbody.innerHTML = "";
for (const [guid, value] of entries) {
const tr = document.createElement("tr");
const tdGuid = document.createElement("td");
tdGuid.textContent = guid;
const tdVal = document.createElement("td");
tdVal.textContent = value;
tr.appendChild(tdGuid);
tr.appendChild(tdVal);
queryTbody.appendChild(tr);
}
queryPanel.style.display = "";
}
// -----------------------------------------------------------------------
// Place the camera from ifc:// URL camera=/fov=/scale= params.
// obcCamera is the OBC OrthoPerspectiveCamera instance (world.camera).
@ -1093,6 +1142,7 @@ async function main() {
const visibility = qsPost.get("visibility") ?? "highlight";
const selectorStr = qsPost.get("selector") ?? "";
const queryStr = qsPost.get("query") ?? "";
// Set up Highlighter before selector so visibility=highlight can use it.
let highlighter = null;
@ -1131,6 +1181,10 @@ async function main() {
await applySelector(components, model, selectorStr, ifcUrl, visibility);
}
if (selectorStr && queryStr) {
await applyQuery(ifcUrl, queryStr);
}
await fragments.core.update(true);
syncCameraUrl(threeCamera);

View file

@ -56,6 +56,11 @@ describe("parseIfcUrl", () => {
assert.equal(r.selector, "IfcWall");
});
test("parses query parameter", () => {
const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?selector=IfcWall&query=Pset_WallCommon.FireRating");
assert.equal(r.query, "Pset_WallCommon.FireRating");
});
test("parses camera parameter", () => {
const r = parseIfcUrl("ifc://example.com/org/repo@heads/main?camera=1,2,3,4,5,6,7,8,9");
assert.equal(r.camera, "1,2,3,4,5,6,7,8,9");
@ -75,6 +80,7 @@ describe("parseIfcUrl", () => {
const r = parseIfcUrl("ifc://example.com/org/repo@heads/main");
assert.equal(r.path, "");
assert.equal(r.selector, "");
assert.equal(r.query, "");
assert.equal(r.camera, "");
assert.equal(r.fov, "");
assert.equal(r.scale, "");
@ -121,6 +127,16 @@ describe("buildIfcUrl", () => {
assert.equal(url, "ifc://example.com/org/repo@heads/main?path=model.ifc&selector=IfcWall");
});
test("includes query parameter", () => {
const url = buildIfcUrl("example.com/org/repo", "heads/main", "model.ifc", "IfcWall", "Pset_WallCommon.FireRating");
assert.equal(url, "ifc://example.com/org/repo@heads/main?path=model.ifc&selector=IfcWall&query=Pset_WallCommon.FireRating");
});
test("omits query when empty", () => {
const url = buildIfcUrl("example.com/org/repo", "heads/main", "model.ifc", "IfcWall", "");
assert.equal(url, "ifc://example.com/org/repo@heads/main?path=model.ifc&selector=IfcWall");
});
test("does not percent-encode commas in selector", () => {
const url = buildIfcUrl("example.com/org/repo", "heads/main", "", "IfcWall,Name=\"Core\"");
assert.ok(url.includes(","), "comma should not be encoded");