diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1059f2b..8923050 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -52,13 +52,13 @@ {"id":"ifcurl-irp","title":"viewer: no render_diff preview shown on Forgejo commit pages","description":"When viewing a commit in Forgejo that contains .ifc file changes (e.g. /bruno/simple-ifc/commit/61e64e05d783e621d77869feadfc218a0858c499), no 3D diff preview image is injected. The footer.tmpl is supposed to detect .ifc diffs on commit pages and inject an img tag pointing to /render_diff. Either the detection is not firing or the render_diff request is failing.","status":"closed","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-25T22:17:59Z","created_by":"Bruno Postle","updated_at":"2026-04-25T22:57:57Z","closed_at":"2026-04-25T22:57:57Z","close_reason":"Fixed by render_diff fixes in commit 837a9d1","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-3sz","title":"preview: visibility=highlight renders non-selected elements as opaque grey instead of transparent grey","description":"In the rendered PNG, visibility=highlight shows selected elements normally but non-selected elements appear as opaque grey rather than transparent grey (semi-transparent). The highlight mode should make non-selected elements translucent so the selected elements are visually emphasised against a faded context.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T22:15:42Z","created_by":"Bruno Postle","updated_at":"2026-04-25T23:03:49Z","started_at":"2026-04-25T22:59:19Z","closed_at":"2026-04-25T23:03:49Z","close_reason":"Not a bug — opaque grey is the model's actual IFC material colors displayed normally in highlight mode, which is correct per spec","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-d7m","title":"viewer: visibility=highlight renders as isolate (non-selected elements should be transparent grey)","description":"In the viewer, visibility=highlight and visibility=isolate produce identical results — only selected elements are shown. The correct behaviour for highlight is to show selected elements normally and render all other elements as transparent grey. Isolate should hide non-selected elements entirely. The preview service already handles both modes correctly.","notes":"First fix attempt (commit 7aa4ef8) used highlightByID('select', ...) but selected elements still hidden — highlightByID may not prevent hider from hiding them, or the Highlighter 'select' style still isolates. Needs further investigation into OBC Highlighter vs Hider interaction.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T22:15:41Z","created_by":"Bruno Postle","updated_at":"2026-04-26T07:17:13Z","started_at":"2026-04-25T22:59:21Z","closed_at":"2026-04-26T07:17:13Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"ifcurl-xy9","title":"viewer: orthographic projection not implemented (works in preview)","description":"The viewer ignores the 'scale' parameter that selects orthographic projection. The preview service renders orthographic views correctly, but the 3D viewer in the browser always shows perspective. The viewer needs to switch to an orthographic camera when the IFC URL contains 'scale=' instead of 'fov='.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T22:15:38Z","created_by":"Bruno Postle","updated_at":"2026-04-26T08:51:01Z","started_at":"2026-04-26T08:51:01Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-xy9","title":"viewer: orthographic projection not implemented (works in preview)","description":"The viewer ignores the 'scale' parameter that selects orthographic projection. The preview service renders orthographic views correctly, but the 3D viewer in the browser always shows perspective. The viewer needs to switch to an orthographic camera when the IFC URL contains 'scale=' instead of 'fov='.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T22:15:38Z","created_by":"Bruno Postle","updated_at":"2026-04-26T13:08:10Z","started_at":"2026-04-26T08:51:01Z","closed_at":"2026-04-26T13:08:10Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-a9x","title":"viewer: typing in selector box does not filter or apply selector","description":"Typing an IfcOpenShell selector expression (e.g. 'IfcWall') into the selector input box has no effect. The selector should be applied to the model, updating the view to show only matching elements.","status":"closed","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:17Z","created_by":"Bruno Postle","updated_at":"2026-04-25T22:36:10Z","closed_at":"2026-04-25T22:36:10Z","close_reason":"selector box now working in viewer","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"ifcurl-66h","title":"viewer: browse branches/tags button does not navigate to selected ref","description":"The browse branches and tags button does not appear to do anything when clicked. The ref input box has a datalist populated with heads/main but selecting a branch/tag and clicking browse does not navigate to that ref.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:16Z","created_by":"Bruno Postle","updated_at":"2026-04-26T08:51:00Z","started_at":"2026-04-26T08:51:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-66h","title":"viewer: browse branches/tags button does not navigate to selected ref","description":"The browse branches and tags button does not appear to do anything when clicked. The ref input box has a datalist populated with heads/main but selecting a branch/tag and clicking browse does not navigate to that ref.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:16Z","created_by":"Bruno Postle","updated_at":"2026-04-26T13:08:09Z","started_at":"2026-04-26T08:51:00Z","closed_at":"2026-04-26T13:08:09Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-njq","title":"viewer: 'open new issue' link uses https:// but Forgejo may be on http://","description":"The 'open new issue' link hardcodes https:// scheme, but the Forgejo instance may be served over http:// (e.g. on localhost for development). Should use the same scheme as the current page or derive it from the Forgejo base URL.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:14Z","created_by":"Bruno Postle","updated_at":"2026-04-26T07:43:42Z","started_at":"2026-04-26T07:35:13Z","closed_at":"2026-04-26T07:43:42Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"ifcurl-uo9","title":"viewer: BCF export does not include preview PNG","description":"BCF export produces a valid BCF file but the viewpoint snapshot (preview PNG) is missing. BCF spec requires a snapshot image in each viewpoint. The ifcurl render service should be called to generate this.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:13Z","created_by":"Bruno Postle","updated_at":"2026-04-26T08:51:02Z","started_at":"2026-04-26T08:51:02Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"ifcurl-07d","title":"viewer: model overview only shows storeys, other element types not listed","description":"The model overview panel lists storeys which can be toggled, but no other element types appear. Expected to see walls, slabs, columns etc. The tree/overview is incomplete or only partially populated.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:12Z","created_by":"Bruno Postle","updated_at":"2026-04-26T08:50:34Z","started_at":"2026-04-26T08:50:34Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"id":"ifcurl-1pa","title":"viewer: clip planes button only changes cursor, does not add clip plane","description":"Clicking the clip button changes the cursor to a crosshair but does not actually add a clipping plane to the model. The clip plane interaction is not implemented or not wired up.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:10Z","created_by":"Bruno Postle","updated_at":"2026-04-26T08:50:58Z","started_at":"2026-04-26T08:50:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-uo9","title":"viewer: BCF export does not include preview PNG","description":"BCF export produces a valid BCF file but the viewpoint snapshot (preview PNG) is missing. BCF spec requires a snapshot image in each viewpoint. The ifcurl render service should be called to generate this.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:13Z","created_by":"Bruno Postle","updated_at":"2026-04-26T13:08:11Z","started_at":"2026-04-26T08:51:02Z","closed_at":"2026-04-26T13:08:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-07d","title":"viewer: model overview only shows storeys, other element types not listed","description":"The model overview panel lists storeys which can be toggled, but no other element types appear. Expected to see walls, slabs, columns etc. The tree/overview is incomplete or only partially populated.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:12Z","created_by":"Bruno Postle","updated_at":"2026-04-26T13:08:08Z","started_at":"2026-04-26T08:50:34Z","closed_at":"2026-04-26T13:08:08Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-1pa","title":"viewer: clip planes button only changes cursor, does not add clip plane","description":"Clicking the clip button changes the cursor to a crosshair but does not actually add a clipping plane to the model. The clip plane interaction is not implemented or not wired up.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:37:10Z","created_by":"Bruno Postle","updated_at":"2026-04-26T13:08:09Z","started_at":"2026-04-26T08:50:58Z","closed_at":"2026-04-26T13:08:09Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-2m9","title":"viewer: move 'copy IFC URL' button to immediately after URL text box","description":"The copy-to-clipboard button for the IFC URL should be placed directly adjacent to the URL text input, not elsewhere in the UI. Currently the layout separates them, which is confusing.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T21:36:47Z","created_by":"Bruno Postle","updated_at":"2026-04-26T07:43:43Z","started_at":"2026-04-26T07:35:13Z","closed_at":"2026-04-26T07:43:43Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"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} @@ -89,7 +89,7 @@ {"id":"ifcurl-0oj","title":"Phase 4: Bonsai ifc:// OS protocol handler","description":"Register ifc:// as an OS protocol handler in Bonsai on installation (Windows, macOS, Linux). On receiving a URL, resolve the git source, load the model, and apply the view state from the URL parameters (camera, selector, clipping planes, visibility mode). This is the foundation for all other Phase 4 work.","status":"deferred","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:45Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:15:09Z","defer_until":"2026-12-01T00:00:00Z","dependency_count":0,"dependent_count":3,"comment_count":0} {"id":"ifcurl-cyw","title":"Phase 3c: Verify Forgejo merge-ability check works with ifcmerge","description":"Investigate whether Forgejo's merge-ability check (the 'branches can be merged automatically' indicator) correctly reflects ifcmerge's ability to handle .ifc files. Determine if Forgejo runs a trial git merge or uses a heuristic. If needed, patch Forgejo to run a trial git merge --no-commit to get an accurate result.","status":"closed","priority":3,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:22Z","created_by":"Bruno Postle","updated_at":"2026-04-24T08:27:25Z","started_at":"2026-04-24T08:19:58Z","closed_at":"2026-04-24T08:27:25Z","close_reason":"Verified: git merge-tree --write-tree (Forgejo's code path for git ≥2.38) does invoke merge drivers. Bare repos require core.attributesFile in /etc/gitconfig — committed .gitattributes not read. Added gitattributes server-config file and updated deployment docs.","dependencies":[{"issue_id":"ifcurl-cyw","depends_on_id":"ifcurl-c69","type":"blocks","created_at":"2026-04-23T06:48:29Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"id":"ifcurl-c69","title":"Phase 3c: Register ifcmerge as git merge driver on Forgejo server","description":"Configure ifcmerge as the git merge driver for .ifc files on the Forgejo server. Add to /etc/gitconfig or ~forgejo/.gitconfig: [merge \"ifcmerge\"] name = IFC merge driver / driver = ifcmerge %O %A %B %L. Add *.ifc merge=ifcmerge to server-side gitattributes. This allows Forgejo's 'merge automatically' path to invoke ifcmerge via git when merging .ifc files.","status":"closed","priority":3,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-23T05:48:21Z","created_by":"Bruno Postle","updated_at":"2026-04-24T08:15:20Z","closed_at":"2026-04-24T08:15:20Z","close_reason":"Documented ifcmerge driver registration (gitconfig-ifcmerge), gitattributes setup, merge direction constraint, and merge-commit strategy requirement","comments":[{"id":"019dbc54-bf0a-76e2-b64d-7d1681f0c730","issue_id":"ifcurl-c69","author":"Bruno Postle","text":"ifcmerge needs to be installed as a pair of merge tools because the merge process is asymmetrical and we need to be able to prefer one branch over another (because step-ids from one branch get rewritten in the merged file). In practice we prefer origin and/or main/master branches. there is code in the ifcopenshell bonsai ifcgit module for reference.","created_at":"2026-04-23T21:52:44Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} -{"id":"ifcurl-s1o","title":"viewer: extract inline JS from viewer.html into viewer.js","description":"viewer.html is ~1250 lines, almost entirely JavaScript. This makes it hard to edit with proper tooling (no syntax highlighting, linting, or go-to-definition). Extract the main viewer logic into a separate viewer.js file alongside viewer-url.js. The build.js step would need to copy it to the assets directory.","status":"open","priority":4,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-25T23:16:36Z","created_by":"Bruno Postle","updated_at":"2026-04-25T23:16:36Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"id":"ifcurl-s1o","title":"viewer: extract inline JS from viewer.html into viewer.js","description":"viewer.html is ~1250 lines, almost entirely JavaScript. This makes it hard to edit with proper tooling (no syntax highlighting, linting, or go-to-definition). Extract the main viewer logic into a separate viewer.js file alongside viewer-url.js. The build.js step would need to copy it to the assets directory.","status":"closed","priority":4,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-25T23:16:36Z","created_by":"Bruno Postle","updated_at":"2026-04-26T14:49:05Z","started_at":"2026-04-26T13:10:02Z","closed_at":"2026-04-26T14:49:05Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-nd1","title":"Submit provisional IANA registration for ifc:// scheme","description":"Once the Phase 1 URL spec is stable, submit a provisional IANA URI scheme registration for ifc://.\\n\\nProcess: email iana@iana.org with scheme name, syntax description, security considerations, and a link to the spec (GitHub URL suffices). First Come First Served — no committee review, typically processed within days to weeks. See RFC 7595 for requirements.\\n\\nThis secures the namespace, establishes priority, and is a prerequisite if the scheme is ever proposed to buildingSMART as an open standard.","status":"open","priority":4,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-25T11:17:45Z","created_by":"Bruno Postle","updated_at":"2026-04-25T11:17:45Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-u6r","title":"Forgejo commit page: no IFC thumbnail — git log gives no visual indication of model state","description":"In the Forgejo repository commit list, each commit shows only the commit message. When a commit modifies an IFC file, there is no thumbnail showing what the model looks like. Reviewers browsing commits have no visual cues about which commits changed significant geometry versus metadata-only changes. A Forgejo webhook or CI integration could post a rendered PNG thumbnail as a commit status/check comment when .ifc files change, using the preview service. This would make the git log visually meaningful for BIM projects.","status":"closed","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-24T11:04:41Z","created_by":"Bruno Postle","updated_at":"2026-04-25T20:07:21Z","closed_at":"2026-04-25T20:07:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"id":"ifcurl-l50","title":"SPECIFICATION.md section 6: BCF-to-IFC-URL direction not implemented","description":"SPECIFICATION.md §6 defines both directions of BCF ↔ IFC URL conversion. The IFC URL → BCF direction is fully implemented (build_bcf(), /bcf endpoint, viewer BCF button). The BCF → IFC URL direction (extract GUIDs from Components/Selection, construct GUID selector, convert camera and clips to URL params) has no implementation. This would be useful for round-tripping: import a BCF viewpoint and get back a shareable ifc:// URL.","status":"open","priority":4,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-04-24T10:41:01Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:41:01Z","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/forgejo/custom/public/assets/viewer.html b/forgejo/custom/public/assets/viewer.html index 0eab33d..bb6520e 100644 --- a/forgejo/custom/public/assets/viewer.html +++ b/forgejo/custom/public/assets/viewer.html @@ -233,1165 +233,6 @@ - + diff --git a/forgejo/custom/public/assets/viewer.js b/forgejo/custom/public/assets/viewer.js new file mode 100644 index 0000000..434b365 --- /dev/null +++ b/forgejo/custom/public/assets/viewer.js @@ -0,0 +1,1160 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +import { THREE, OBC, JSZip } from "/assets/viewer-deps.js"; +import { parseIfcUrl, toRawUrl, buildIfcUrl as _buildIfcUrl } from "./viewer-url.js"; + +const params = new URLSearchParams(window.location.search); +const ifcUrl = params.get("url") ?? ""; +const statusEl = document.getElementById("status"); +const urlInput = document.getElementById("url-input"); +const fovInput = document.getElementById("fov-input"); +const toolbar = document.getElementById("toolbar"); +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 copyUrlBtn = document.getElementById("copy-url-btn"); +const newIssueBtn = document.getElementById("new-issue-btn"); +const visibilitySelect = document.getElementById("visibility-select"); +const visibilityField = document.getElementById("visibility-field"); +const propsPanel = document.getElementById("props-panel"); +const propsType = document.getElementById("props-type"); +const propsName = document.getElementById("props-name"); +const propsGuid = document.getElementById("props-guid"); +const propsCopyBtn = document.getElementById("props-copy-btn"); +const propsCloseBtn = document.getElementById("props-close-btn"); + +// Keep viewer below the full toolbar height. +const resizeObs = new ResizeObserver(() => { + document.documentElement.style.setProperty("--toolbar-h", toolbar.offsetHeight + "px"); +}); +resizeObs.observe(toolbar); + +let cameraControls = null; +let threeCamera = null; +let activeClipper = null; +let loadedModel = null; +let rendererCanvas = null; + +// Show previous-view snapshot while the new model loads. +{ + const snap = sessionStorage.getItem("viewerSnapshot"); + if (snap && ifcUrl) { + const el = document.createElement("div"); + el.id = "snapshot-overlay"; + el.style.backgroundImage = `url("${snap}")`; + document.body.appendChild(el); + } +} +function removeSnapshot() { + const el = document.getElementById("snapshot-overlay"); + if (!el) return; + el.style.opacity = "0"; + setTimeout(() => el.remove(), 550); + sessionStorage.removeItem("viewerSnapshot"); +} + +// Build an ifc:// URL from the structured form fields. +function buildIfcUrl() { + return _buildIfcUrl( + repoInput.value.trim(), refInput.value.trim(), + pathInput.value.trim(), selectorInput.value.trim() + ); +} + +// Populate structured fields from the current ifcUrl. +const parsed = parseIfcUrl(ifcUrl); +if (parsed) { + repoInput.value = parsed.repo; + refInput.value = parsed.ref; + pathInput.value = parsed.path; + selectorInput.value = parsed.selector; +} +urlInput.value = ifcUrl; + +// Initialise visibility dropdown from URL and show it when selector is non-empty. +{ + const qIdx = ifcUrl.indexOf("?"); + const visVal = qIdx < 0 ? "highlight" + : (new URLSearchParams(ifcUrl.slice(qIdx + 1)).get("visibility") ?? "highlight"); + visibilitySelect.value = visVal; +} +function syncVisibilityField() { + visibilityField.style.display = selectorInput.value.trim() ? "" : "none"; +} +selectorInput.addEventListener("input", syncVisibilityField); +syncVisibilityField(); +visibilitySelect.addEventListener("change", loadFromFields); + +// Show commit-pin indicator when the ref is a bare commit hash. +const refBadge = document.getElementById("ref-badge"); +const refSwitchBtn = document.getElementById("ref-switch-btn"); +if (parsed && isCommitHash(parsed.ref)) { + refBadge.textContent = parsed.ref.slice(0, 7); + refBadge.style.display = ""; + if (parsed.host && parsed.repoSuffix) { + fetchBranchForCommit(parsed.host, parsed.repoSuffix, parsed.ref) + .then(branchName => { + const targetRef = `heads/${branchName}`; + refSwitchBtn.textContent = `→ ${targetRef}`; + refSwitchBtn.style.display = ""; + refSwitchBtn.addEventListener("click", () => { + const qI = ifcUrl.indexOf("?"); + const base = qI < 0 ? ifcUrl : ifcUrl.slice(0, qI); + const tail = qI < 0 ? "" : ifcUrl.slice(qI); + loadUrl(base.slice(0, base.lastIndexOf("@") + 1) + targetRef + tail); + }); + }) + .catch(() => { /* silent — badge alone is sufficient */ }); + } +} + +// Populate ref datalist in background (non-blocking). +if (parsed?.host && parsed?.repoSuffix) { + populateRefList(parsed.host, parsed.repoSuffix).catch(() => { /* silent */ }); +} + +// ----------------------------------------------------------------------- +// Navigate to the viewer with a different ifc:// URL. +// Captures a canvas snapshot for the transition overlay, then fades out. +// ----------------------------------------------------------------------- +function loadUrl(url) { + if (rendererCanvas) { + try { + const snap = rendererCanvas.toDataURL("image/jpeg", 0.6); + sessionStorage.setItem("viewerSnapshot", snap); + // Cover the 3D view with the snapshot NOW, before the page unloads, + // so the browser's navigation blank is hidden behind the static image. + const cover = document.createElement("div"); + cover.style.cssText = "position:fixed;inset:0;z-index:9999;" + + "background:#1c1c1e center/contain no-repeat"; + cover.style.backgroundImage = `url("${snap}")`; + document.body.appendChild(cover); + } catch (_) { /* context-lost or cross-origin — skip */ } + } + const pageUrl = new URL(window.location.href); + pageUrl.searchParams.set("url", url); + window.location.href = pageUrl.toString(); +} + +urlInput.addEventListener("keydown", e => { + if (e.key === "Enter") { const v = urlInput.value.trim(); if (v) loadUrl(v); } +}); + +// Build URL from structured fields, preserving camera/clip/fov/scale from +// the current URL so that ref/path changes keep the viewer's camera state. +function loadFromFields() { + if (threeCamera) syncCameraUrl(threeCamera); + const structural = buildIfcUrl(); + if (!structural) return; + const current = urlInput.value.trim(); + const qI = current.indexOf("?"); + if (qI < 0) { loadUrl(structural); return; } + const currentQs = new URLSearchParams(current.slice(qI + 1)); + const qI2 = structural.indexOf("?"); + const structBase = qI2 < 0 ? structural : structural.slice(0, qI2); + const structQs = new URLSearchParams(qI2 < 0 ? "" : structural.slice(qI2 + 1)); + for (const key of ["camera", "fov", "scale"]) { + const v = currentQs.get(key); + if (v) structQs.set(key, v); else structQs.delete(key); + } + structQs.delete("clip"); + for (const v of currentQs.getAll("clip")) structQs.append("clip", v); + // Include visibility when a selector is set; omit for the default (highlight). + if (selectorInput.value.trim()) { + const vis = visibilitySelect.value; + if (vis && vis !== "highlight") structQs.set("visibility", vis); + else structQs.delete("visibility"); + } else { + structQs.delete("visibility"); + } + const qsStr = structQs.toString() + .replace(/%2C/g, ",").replace(/%2B/g, "+").replace(/%24/g, "$"); + loadUrl(structBase + (qsStr ? "?" + qsStr : "")); +} + +// Pressing Enter in any structured field rebuilds the URL and navigates. +for (const el of [repoInput, refInput, pathInput, selectorInput]) { + el.addEventListener("keydown", e => { if (e.key === "Enter") loadFromFields(); }); +} + +// ▾ 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"); +refPickBtn.addEventListener("click", () => { + if (refInput.value.trim()) { loadFromFields(); return; } + refInput.value = ""; + refInput.focus(); +}); + +// Auto-navigate when user selects a datalist option (exact match). +// Listen to both input and change events for cross-browser coverage. +function onRefInputChange() { + const dl = document.getElementById("ref-list"); + const known = [...dl.options].map(o => o.value); + if (known.includes(refInput.value.trim())) loadFromFields(); +} +refInput.addEventListener("input", onRefInputChange); +refInput.addEventListener("change", onRefInputChange); + +// Disable camera controls while any input has focus. +function onInputFocus() { if (cameraControls) cameraControls.enabled = false; } +function onInputBlur() { if (cameraControls) cameraControls.enabled = true; } +const metaBtn = document.getElementById("meta-btn"); +const metaPanel = document.getElementById("meta-panel"); +metaBtn.addEventListener("click", () => { + const open = metaPanel.style.display === "none"; + metaPanel.style.display = open ? "" : "none"; + metaBtn.classList.toggle("active", open); +}); + +const bcfBtn = document.getElementById("bcf-btn"); +const bcfRow = document.getElementById("bcf-row"); +const bcfTitleIn = document.getElementById("bcf-title"); +const bcfCommentIn = document.getElementById("bcf-comment"); +const bcfCancelBtn = document.getElementById("bcf-cancel-btn"); +const bcfExportBtn = document.getElementById("bcf-export-btn"); +const bcfImportBtn = document.getElementById("bcf-import-btn"); +const bcfImportInput = document.getElementById("bcf-import-input"); + +function bcfPanelOpen(open) { + bcfRow.style.display = open ? "" : "none"; + bcfBtn.classList.toggle("active", open); +} + +bcfBtn.addEventListener("click", () => bcfPanelOpen(bcfRow.style.display === "none")); +bcfCancelBtn.addEventListener("click", () => bcfPanelOpen(false)); +bcfExportBtn.addEventListener("click", async () => { + await generateBcf(bcfTitleIn.value.trim(), bcfCommentIn.value.trim()); + bcfPanelOpen(false); +}); + +bcfImportBtn.addEventListener("click", () => bcfImportInput.click()); +bcfImportInput.addEventListener("change", async () => { + const file = bcfImportInput.files[0]; + if (file) await importBcf(file); + bcfImportInput.value = ""; +}); + +copyUrlBtn.addEventListener("click", async () => { + if (threeCamera) syncCameraUrl(threeCamera); + const url = urlInput.value.trim(); + if (!url) return; + try { + await navigator.clipboard.writeText(url); + const saved = copyUrlBtn.textContent; + copyUrlBtn.textContent = "✓"; + setTimeout(() => { copyUrlBtn.textContent = saved; }, 1500); + } catch { + urlInput.select(); + } +}); + +newIssueBtn.addEventListener("click", () => { + if (threeCamera) syncCameraUrl(threeCamera); + const src = urlInput.value.trim(); + if (!src) return; + const p = parseIfcUrl(src); + if (!p || !p.host || !p.repoSuffix) return; + const title = p.path ? `IFC view: ${p.path}` : "IFC view"; + const issueUrl = `${window.location.origin}/${p.repoSuffix}/issues/new` + + `?title=${encodeURIComponent(title)}&body=${encodeURIComponent(src)}`; + window.open(issueUrl, "_blank", "noopener"); +}); + +document.getElementById("fit-btn").addEventListener("click", () => { + if (cameraControls && loadedModel && !loadedModel.box.isEmpty()) + cameraControls.fitToBox(loadedModel.box, true); +}); + +propsCloseBtn.addEventListener("click", () => { propsPanel.style.display = "none"; }); +propsCopyBtn.addEventListener("click", async () => { + const guid = propsGuid.textContent; + if (!guid) return; + try { + await navigator.clipboard.writeText(guid); + const saved = propsCopyBtn.textContent; + propsCopyBtn.textContent = "✓"; + setTimeout(() => { propsCopyBtn.textContent = saved; }, 1500); + } catch { /* ignore */ } +}); + +const allInputs = [urlInput, fovInput, repoInput, refInput, pathInput, selectorInput, + visibilitySelect, bcfTitleIn, bcfCommentIn]; +for (const el of allInputs) { + el.addEventListener("focus", onInputFocus); + el.addEventListener("blur", onInputBlur); + el.addEventListener("pointerdown", e => e.stopPropagation()); + el.addEventListener("mousedown", e => e.stopPropagation()); +} +refPickBtn.addEventListener("pointerdown", e => e.stopPropagation()); +refPickBtn.addEventListener("mousedown", e => e.stopPropagation()); + +fovInput.addEventListener("change", () => { + if (!threeCamera?.isPerspectiveCamera) return; + const v = parseFloat(fovInput.value); + if (isNaN(v)) return; + threeCamera.fov = Math.max(10, Math.min(120, v)); + threeCamera.updateProjectionMatrix(); + syncCameraUrl(threeCamera); +}); + +// ----------------------------------------------------------------------- +// Drag-and-drop: accept ifc:// URLs or viewer page URLs. +// ----------------------------------------------------------------------- +document.body.addEventListener("dragover", e => { + e.preventDefault(); + document.body.classList.add("drag-over"); +}); +document.body.addEventListener("dragleave", e => { + if (!e.relatedTarget) document.body.classList.remove("drag-over"); +}); +document.body.addEventListener("drop", async e => { + e.preventDefault(); + document.body.classList.remove("drag-over"); + // Check for .bcf file drops first. + for (const file of (e.dataTransfer.files || [])) { + if (file.name.toLowerCase().endsWith(".bcf")) { + await importBcf(file); + return; + } + } + const candidates = [ + e.dataTransfer.getData("text/uri-list"), + e.dataTransfer.getData("text/plain"), + ]; + for (const blob of candidates) { + for (const line of (blob || "").split(/\r?\n/)) { + const t = line.trim(); + if (!t || t.startsWith("#")) continue; + if (t.startsWith("ifc://")) { loadUrl(t); return; } + try { + const u = new URL(t, window.location.href); + const p = u.searchParams.get("url"); + if (p?.startsWith("ifc://")) { loadUrl(p); return; } + } catch { /* not a URL */ } + } + } +}); + +// ----------------------------------------------------------------------- +// Coordinate transforms: IFC (Z-up) ↔ Three.js (Y-up). +// IFC → Three.js: (x, y, z) → (x, z, -y) +// Three.js → IFC: (x, y, z) → (x, -z, y) +// ----------------------------------------------------------------------- +const toIfc = v => ({ x: v.x, y: -v.z, z: v.y }); +const toThree = (x, y, z) => new THREE.Vector3(x, z, -y); + +// ----------------------------------------------------------------------- +// Sync camera viewpoint (in IFC coords) into the URL and toolbar row 1. +// The structured fields in row 2 are not affected by camera movement. +// ----------------------------------------------------------------------- +function syncCameraUrl(camera) { + const pos = camera.position; + const dir = new THREE.Vector3(); + camera.getWorldDirection(dir); + const up = camera.up; + + const f = v => parseFloat(v.toFixed(4)); + const ip = toIfc(pos), id = toIfc(dir), iu = toIfc(up); + const cameraParam = [ + f(ip.x), f(ip.y), f(ip.z), + f(id.x), f(id.y), f(id.z), + f(iu.x), f(iu.y), f(iu.z), + ].join(","); + + const qIdx = ifcUrl.indexOf("?"); + const base = qIdx < 0 ? ifcUrl : ifcUrl.slice(0, qIdx); + const qs = new URLSearchParams(qIdx < 0 ? "" : ifcUrl.slice(qIdx + 1)); + qs.set("camera", cameraParam); + if (camera.isPerspectiveCamera) { + qs.set("fov", f(camera.fov).toString()); + qs.delete("scale"); + fovInput.value = Math.round(camera.fov); + } else { + qs.set("scale", f(camera.top - camera.bottom).toString()); + qs.delete("fov"); + fovInput.value = ""; + } + // Replace original clip= params with current interactive planes. + qs.delete("clip"); + if (activeClipper) { + for (const [, plane] of activeClipper.list) { + const n3 = plane.three.normal; + const p3 = n3.clone().multiplyScalar(-plane.three.constant); + const ni = toIfc(n3), pi = toIfc(p3); + qs.append("clip", [f(pi.x),f(pi.y),f(pi.z),f(ni.x),f(ni.y),f(ni.z)].join(",")); + } + } + + const newIfcUrl = base + "?" + qs.toString() + .replace(/%2C/g, ",").replace(/%2B/g, "+").replace(/%24/g, "$"); + + urlInput.value = newIfcUrl; + + const pageUrl = new URL(window.location.href); + pageUrl.searchParams.set("url", newIfcUrl); + history.replaceState(null, "", pageUrl.toString()); +} + +// ----------------------------------------------------------------------- +// Set up OBC.Clipper for interactive clipping planes. +// Seeds initial planes from the URL, wires toolbar buttons, and syncs +// plane state back to the URL on create/delete/drag. +// ----------------------------------------------------------------------- +function setupClipper(components, world, clips) { + const clipper = components.get(OBC.Clipper); + clipper.setup(); + activeClipper = clipper; + + const clipBtn = document.getElementById("clip-btn"); + const clipClearBtn = document.getElementById("clip-clear-btn"); + const container = document.getElementById("viewer"); + + function updateClearBtn() { + clipClearBtn.style.display = clipper.list.size > 0 ? "" : "none"; + } + + // Seed planes from URL as interactive planes. + for (const [px, py, pz, nx, ny, nz] of clips) { + clipper.createFromNormalAndCoplanarPoint( + world, toThree(nx, ny, nz).normalize(), toThree(px, py, pz) + ); + } + updateClearBtn(); + + // Double-click on model surface adds a plane when clip mode is active. + let clipMode = false; + clipBtn.addEventListener("click", () => { + clipMode = !clipMode; + clipBtn.classList.toggle("active", clipMode); + container.style.cursor = clipMode ? "crosshair" : ""; + }); + container.addEventListener("dblclick", async () => { + if (!clipMode) return; + await clipper.create(world); + syncCameraUrl(threeCamera); + updateClearBtn(); + }); + + clipClearBtn.addEventListener("click", () => { + clipper.deleteAll(); + syncCameraUrl(threeCamera); + updateClearBtn(); + }); + + // Sync URL whenever a plane is dragged. + clipper.onAfterDrag.add(() => syncCameraUrl(threeCamera)); +} + +// ----------------------------------------------------------------------- +// Apply selector= by isolating matching IFC elements. +// +// 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. +// ----------------------------------------------------------------------- +function isSimpleTypeSelector(selectorStr) { + return selectorStr.split("+").every(part => /^[Ii]fc[A-Za-z0-9]+$/.test(part.trim())); +} + +// Returns a ModelIdMap containing all items in allItems that are NOT in toRemove. +function subtractIdMap(allItems, toRemove) { + const result = {}; + for (const [modelId, allIds] of Object.entries(allItems)) { + const removeSet = new Set(toRemove[modelId] ?? []); + const remaining = []; + for (const id of allIds) { + if (!removeSet.has(id)) remaining.push(id); + } + if (remaining.length > 0) result[modelId] = remaining; + } + return result; +} + +// Apply a fragment material style to the given items via FragmentsManager. +async function applyFragmentStyle(components, items, color, opacity) { + const fragments = components.get(OBC.FragmentsManager); + await fragments.resetHighlight(); + if (Object.keys(items).length > 0) { + await fragments.highlight( + { customId: "sel", color, opacity, transparent: opacity < 1 }, + items + ); + } + await fragments.core.update(true); +} + +async function applySelector(components, model, selectorStr, srcUrl, visibility = "highlight") { + if (!selectorStr) return; + + const hider = components.get(OBC.Hider); + + 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; + if (visibility === "ghost") { + // Dim non-selected; selected appear normally. + const allItems = await classifier.find({ Categories: [...categoryGroups.keys()] }); + await applyFragmentStyle(components, subtractIdMap(allItems, matching), + new THREE.Color(0x888888), 0.15); + } else if (visibility === "isolate") { + await hider.isolate(matching); + } else { + // highlight (default): colour-overlay the selected elements, all others visible. + await applyFragmentStyle(components, matching, new THREE.Color(0xff8800), 1); + } + 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; + + const matchingMap = { [model.modelId]: new Set(localIds) }; + if (visibility === "ghost") { + const allVisible = await hider.getVisibilityMap(true); + await applyFragmentStyle(components, subtractIdMap(allVisible, matchingMap), + new THREE.Color(0x888888), 0.15); + } else if (visibility === "isolate") { + await hider.isolate(matchingMap); + } else { + await applyFragmentStyle(components, matchingMap, new THREE.Color(0xff8800), 1); + } +} + +// ----------------------------------------------------------------------- +// Place the camera from ifc:// URL camera=/fov=/scale= params. +// obcCamera is the OBC OrthoPerspectiveCamera instance (world.camera). +// ----------------------------------------------------------------------- +async function applyCameraParam(controls, obcCamera) { + const qIdx = ifcUrl.indexOf("?"); + if (qIdx < 0) return false; + const qs = new URLSearchParams(ifcUrl.slice(qIdx + 1)); + + const camStr = qs.get("camera"); + if (!camStr) return false; + const v = camStr.split(",").map(Number); + if (v.length < 9 || v.some(isNaN)) return false; + + const pos = toThree(v[0], v[1], v[2]); + const dir = toThree(v[3], v[4], v[5]); + const target = pos.clone().add(dir); + await controls.setLookAt(pos.x, pos.y, pos.z, target.x, target.y, target.z, false); + + const fovStr = qs.get("fov"); + const scaleStr = qs.get("scale"); + if (scaleStr) { + // Switch to orthographic projection and scale to match the URL's + // ViewToWorldScale (height of view in world units). + await obcCamera.projection.set("Orthographic"); + const s = parseFloat(scaleStr); + if (!isNaN(s)) { + const ortho = obcCamera.threeOrtho; + const currentHeight = ortho.top - ortho.bottom; + if (currentHeight > 0) { + const ratio = s / currentHeight; + ortho.top *= ratio; + ortho.bottom *= ratio; + ortho.left *= ratio; + ortho.right *= ratio; + ortho.updateProjectionMatrix(); + } + } + fovInput.value = ""; + } else if (fovStr) { + const persp = obcCamera.threePersp; + persp.fov = Math.max(10, Math.min(120, parseFloat(fovStr))); + persp.updateProjectionMatrix(); + fovInput.value = Math.round(persp.fov); + } + return true; +} + +// ----------------------------------------------------------------------- +// BCF 2.1 export. +// With a selector: delegates to POST /bcf so the service resolves component +// GUIDs server-side. Fails visibly if the service is unreachable — no +// silent fallback to a GUIDless file. +// Without a selector: builds the zip client-side (camera + clips only). +// ----------------------------------------------------------------------- +async function generateBcf(title, comment) { + if (threeCamera) syncCameraUrl(threeCamera); + const src = urlInput.value.trim(); + if (!src) return; + + const qIdx = src.indexOf("?"); + const qs = new URLSearchParams(qIdx < 0 ? "" : src.slice(qIdx + 1)); + + const camV = (qs.get("camera") ?? "").split(",").map(Number); + const cam = camV.length === 9 && !camV.some(isNaN) ? camV : null; + const fov = cam ? (parseFloat(qs.get("fov")) || null) : null; + const scale = cam ? (parseFloat(qs.get("scale")) || null) : null; + const clips = qs.getAll("clip") + .map(s => s.split(",").map(Number)) + .filter(v => v.length === 6 && !v.some(isNaN)); + const selector = qs.get("selector") || ""; + const visibility = qs.get("visibility") || "highlight"; + + if (selector) { + try { + const resp = await fetch("/bcf", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: src, title: title || "IFC View", comment }), + }); + if (!resp.ok) { + const detail = await resp.text().catch(() => resp.statusText); + statusEl.textContent = `BCF export failed: ${detail}`; + return; + } + const blob = await resp.blob(); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "view.bcf"; + a.click(); + URL.revokeObjectURL(a.href); + } catch (_) { + statusEl.textContent = "BCF export requires the ifcurl service — /bcf not reachable"; + } + return; + } + + // Capture a snapshot of the current view for the BCF zip. + let snapshotB64 = null; + if (rendererCanvas) { + try { + snapshotB64 = rendererCanvas.toDataURL("image/png").split(",")[1]; + } catch (_) { /* context lost or cross-origin — skip */ } + } + + const topicGuid = crypto.randomUUID(); + const vpGuid = cam ? crypto.randomUUID() : null; + const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + + function esc(s) { + return String(s).replace(/&/g,"&").replace(//g,">").replace(/"/g,"""); + } + function xyz(tag, a, b, c) { + return `<${tag}>${a}${b}${c}`; + } + + const versionXml = ` + + 2.1 +`; + + let viewpointXml = null; + if (vpGuid && cam) { + const [px,py,pz, dx,dy,dz, ux,uy,uz] = cam; + const camXml = fov != null + ? ` + ${xyz("CameraViewPoint",px,py,pz)} + ${xyz("CameraDirection",dx,dy,dz)} + ${xyz("CameraUpVector",ux,uy,uz)} + ${fov} + ` + : scale != null + ? ` + ${xyz("CameraViewPoint",px,py,pz)} + ${xyz("CameraDirection",dx,dy,dz)} + ${xyz("CameraUpVector",ux,uy,uz)} + ${scale} + ` + : ""; + const clipsXml = clips.length + ? ` + ${clips.map(([cx,cy,cz,nx,ny,nz]) => + `${xyz("Location",cx,cy,cz)}${xyz("Direction",nx,ny,nz)}` + ).join("\n ")} + ` : ""; + // Reflect visibility mode: isolate → hide everything by default, + // ghost/highlight → show everything by default. + const defaultVis = visibility === "isolate" ? "false" : "true"; + viewpointXml = ` + + + ${camXml} + ${clipsXml} +`; + } + + const desc = selector + ? `${esc(src)}\nselector: ${esc(selector)}` + : esc(src); + const vpEntry = vpGuid + ? `viewpoint.bcfv` + + (snapshotB64 ? `snapshot.png` : "") + + `` + : ""; + const cmtEntry = comment + ? ` + ${now}anonymous + ${esc(comment)} + ${vpGuid ? `` : ""} + ` : ""; + + const markupXml = ` + + + ${esc(title || "IFC View")} + ${desc} + ${now} + anonymous + ${vpEntry} + + ${cmtEntry} +`; + + const zip = new JSZip(); + zip.file("bcf.version", versionXml); + zip.folder(topicGuid).file("markup.bcf", markupXml); + if (vpGuid && viewpointXml) zip.folder(topicGuid).file("viewpoint.bcfv", viewpointXml); + if (snapshotB64) zip.folder(topicGuid).file("snapshot.png", snapshotB64, { base64: true }); + + const blob = await zip.generateAsync({ type: "blob" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "view.bcf"; + a.click(); + URL.revokeObjectURL(a.href); +} + +// ----------------------------------------------------------------------- +// BCF 2.1 import — read a .bcf zip, extract the first viewpoint, apply +// camera/clips/GUIDs to the current ifc:// URL and reload the viewer. +// ----------------------------------------------------------------------- +async function importBcf(file) { + const src = urlInput.value.trim(); + if (!src) { + statusEl.textContent = "Load a model first, then import a BCF viewpoint"; + return; + } + + statusEl.textContent = "Importing BCF…"; + try { + const zip = await JSZip.loadAsync(file); + + // Find the first viewpoint.bcfv entry. + const vpPath = Object.keys(zip.files).find(p => p.endsWith("viewpoint.bcfv")); + if (!vpPath) { + statusEl.textContent = "BCF: no viewpoint found in file"; + return; + } + + const vpXml = await zip.file(vpPath).async("text"); + const doc = new DOMParser().parseFromString(vpXml, "text/xml"); + + const getXYZ = (parent, tag) => { + const el = parent.querySelector(tag); + if (!el) return null; + const v = ["X", "Y", "Z"].map(c => parseFloat(el.querySelector(c)?.textContent)); + return v.some(isNaN) ? null : v; + }; + + // Camera + let camera = null, fov = null, scale = null; + const perspCam = doc.querySelector("PerspectiveCamera"); + const orthoCam = doc.querySelector("OrthogonalCamera"); + const camEl = perspCam || orthoCam; + if (camEl) { + const pos = getXYZ(camEl, "CameraViewPoint"); + const dir = getXYZ(camEl, "CameraDirection"); + const up = getXYZ(camEl, "CameraUpVector"); + if (pos && dir && up) { + camera = [...pos, ...dir, ...up]; + if (perspCam) { + fov = parseFloat(perspCam.querySelector("FieldOfView")?.textContent); + if (isNaN(fov)) fov = null; + } else { + scale = parseFloat(orthoCam.querySelector("ViewToWorldScale")?.textContent); + if (isNaN(scale)) scale = null; + } + } + } + + // Clipping planes + const clips = []; + for (const cp of doc.querySelectorAll("ClippingPlane")) { + const loc = getXYZ(cp, "Location"); + const dir = getXYZ(cp, "Direction"); + if (loc && dir) clips.push([...loc, ...dir]); + } + + // Component GUIDs from Selection + const guids = Array.from(doc.querySelectorAll("Components Selection Component")) + .map(el => el.getAttribute("IfcGuid")) + .filter(Boolean); + + // Build updated URL: keep repo/ref/path, replace viewpoint params. + const qIdx = src.indexOf("?"); + const base = qIdx < 0 ? src : src.slice(0, qIdx); + const qs = new URLSearchParams(qIdx < 0 ? "" : src.slice(qIdx + 1)); + + qs.delete("camera"); qs.delete("fov"); qs.delete("scale"); qs.delete("clip"); + qs.delete("selector"); qs.delete("visibility"); + + if (camera) { + const f = v => parseFloat(v.toFixed(4)); + qs.set("camera", camera.map(f).join(",")); + if (fov != null) qs.set("fov", fov.toString()); + if (scale != null) qs.set("scale", scale.toString()); + } + for (const clip of clips) { + const f = v => parseFloat(v.toFixed(4)); + qs.append("clip", clip.map(f).join(",")); + } + if (guids.length > 0) qs.set("selector", guids.join("+")); + + const newUrl = base + "?" + qs.toString() + .replace(/%2C/g, ",").replace(/%2B/g, "+").replace(/%24/g, "$"); + loadUrl(newUrl); + } catch (err) { + statusEl.textContent = `BCF import error: ${err.message}`; + console.error(err); + } +} + +// ----------------------------------------------------------------------- +// Element properties panel +// ----------------------------------------------------------------------- +function showPropsPanel(props, webIfc) { + let typeName = String(props.type ?? ""); + try { + const name = webIfc?.GetNameFromTypeCode(props.type); + if (name) typeName = name; + } catch { /* not available in this version */ } + propsType.textContent = typeName; + propsName.textContent = props.Name?.value ?? props.LongName?.value ?? ""; + propsGuid.textContent = props.GlobalId?.value ?? ""; + propsPanel.style.display = ""; +} + +// ----------------------------------------------------------------------- +// Model metadata panel population. +// ----------------------------------------------------------------------- +async function populateMetaPanel(components) { + const classifier = components.get(OBC.Classifier); + const hider = components.get(OBC.Hider); + + // --- Type breakdown --- + await classifier.byCategory(); + const categories = classifier.list.get("Categories"); + const types = []; + if (categories) { + for (const [name, groupData] of categories) { + let count = 0; + for (const ids of Object.values(groupData)) count += [...ids].length; + if (count > 0) types.push({ name, count }); + } + types.sort((a, b) => b.count - a.count); + } + + // --- Storey list --- + await classifier.byIfcBuildingStorey(); + const storeyGroups = classifier.list.get("Storeys"); + const storeyNames = storeyGroups ? [...storeyGroups.keys()] : []; + + // --- Render --- + const html = []; + + if (types.length) { + html.push("

Types

"); + for (const { name, count } of types) { + html.push( + `
` + + `${name}` + + `${count}` + + `
` + ); + } + } + + if (storeyNames.length) { + html.push("

Storeys

"); + html.push( + `
` + + `All storeys` + + `
` + ); + for (const name of storeyNames) { + html.push( + `
` + + `${name}` + + `
` + ); + } + } + + if (!types.length && !storeyNames.length) { + html.push('
No metadata
'); + } + + metaPanel.innerHTML = html.join(""); + + // Wire up storey click-to-isolate. + const allRow = metaPanel.querySelector(".storey-row[data-storey='']"); + let activeRow = allRow; + for (const row of metaPanel.querySelectorAll(".storey-row")) { + row.addEventListener("click", async () => { + const name = row.dataset.storey; + if (!name || activeRow === row) { + // "All storeys" row or clicking the active row: show all. + activeRow?.classList.remove("active"); + activeRow = allRow; + allRow?.classList.add("active"); + await hider.set(true); + } else { + activeRow?.classList.remove("active"); + activeRow = row; + row.classList.add("active"); + const found = await classifier.find({ Storeys: [name] }); + await hider.isolate(found); + } + }); + } +} + +// ----------------------------------------------------------------------- +// Ref picker: populate with branches + tags. +// ----------------------------------------------------------------------- +async function populateRefList(host, repoSuffix) { + const proto = host.startsWith("localhost") ? "http" : "https"; + const base = `${proto}://${host}/api/v1/repos/${repoSuffix}`; + const sig = AbortSignal.timeout(4000); + const [branchRes, tagRes] = await Promise.allSettled([ + fetch(`${base}/branches?limit=50`, { signal: sig }), + fetch(`${base}/tags?limit=50`, { signal: sig }), + ]); + const dl = document.getElementById("ref-list"); + if (branchRes.status === "fulfilled" && branchRes.value.ok) { + const branches = await branchRes.value.json().catch(() => []); + for (const b of (branches ?? [])) { + const opt = document.createElement("option"); + opt.value = `heads/${b.name}`; + dl.appendChild(opt); + } + } + if (tagRes.status === "fulfilled" && tagRes.value.ok) { + const tags = await tagRes.value.json().catch(() => []); + for (const t of (tags ?? [])) { + const opt = document.createElement("option"); + opt.value = `tags/${t.name}`; + dl.appendChild(opt); + } + } +} + +// ----------------------------------------------------------------------- +// Commit-pin indicator helpers. +// ----------------------------------------------------------------------- +function isCommitHash(ref) { + return /^[0-9a-f]{7,40}$/.test(ref); +} + +async function fetchBranchForCommit(host, repoSuffix, hash) { + const proto = host.startsWith("localhost") ? "http" : "https"; + const apiBase = `${proto}://${host}/api/v1/repos/${repoSuffix}`; + const sig = AbortSignal.timeout(3000); + const [branchRes, repoRes] = await Promise.allSettled([ + fetch(`${apiBase}/branches?limit=50`, { signal: sig }), + fetch(apiBase, { signal: sig }), + ]); + let defaultBranch = "main"; + if (repoRes.status === "fulfilled" && repoRes.value.ok) { + const info = await repoRes.value.json().catch(() => null); + if (info?.default_branch) defaultBranch = info.default_branch; + } + if (branchRes.status === "fulfilled" && branchRes.value.ok) { + const branches = await branchRes.value.json().catch(() => []); + const h = hash.toLowerCase(); + for (const b of (branches ?? [])) { + const id = (b.commit?.id ?? "").toLowerCase(); + if (id.startsWith(h) || h.startsWith(id.slice(0, Math.max(h.length, 7)))) { + return b.name; + } + } + } + return defaultBranch; +} + +// ----------------------------------------------------------------------- +// Main +// ----------------------------------------------------------------------- +async function main() { + if (!ifcUrl) { + statusEl.textContent = "Fill in the fields above and press Enter, or paste an ifc:// URL."; + return; + } + + statusEl.textContent = "Loading…"; + + const components = new OBC.Components(); + const worlds = components.get(OBC.Worlds); + const world = worlds.create(); + const container = document.getElementById("viewer"); + + world.scene = new OBC.SimpleScene(components); + world.renderer = new OBC.SimpleRenderer(components, container, { preserveDrawingBuffer: true }); + world.camera = new OBC.OrthoPerspectiveCamera(components); + rendererCanvas = world.renderer.three.domElement; + world.scene.setup(); + components.init(); + + // Register a raycaster for this world so OBC.Clipper can raycast + // against the model surface when creating interactive clip planes. + components.get(OBC.Raycasters).get(world); + + cameraControls = world.camera.controls; + threeCamera = world.camera.three; + + // Firefox blocks cross-origin module workers; wrap in a blob URL. + const fragments = components.get(OBC.FragmentsManager); + const workerResp = await fetch("/assets/fragments-worker.js"); + fragments.init(URL.createObjectURL( + new Blob([await workerResp.text()], { type: "application/javascript" }) + )); + + const ifcLoader = components.get(OBC.IfcLoader); + await ifcLoader.setup({ + autoSetWasm: false, + wasm: { path: "/assets/", absolute: true }, + }); + + statusEl.textContent = "Fetching IFC file…"; + let buffer; + try { + const resp = await fetch(toRawUrl(ifcUrl)); + if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`); + const contentLength = resp.headers.get("Content-Length"); + const total = contentLength ? parseInt(contentLength, 10) : 0; + const reader = resp.body?.getReader(); + if (reader) { + const chunks = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.length; + if (total > 0) { + statusEl.textContent = `Fetching… ${Math.round(received * 100 / total)}%`; + } else { + statusEl.textContent = `Fetching… ${(received / 1048576).toFixed(1)} MB`; + } + } + buffer = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { buffer.set(chunk, offset); offset += chunk.length; } + } else { + buffer = new Uint8Array(await resp.arrayBuffer()); + } + } catch (err) { + statusEl.textContent = `Fetch error: ${err.message}`; + return; + } + + statusEl.textContent = "Parsing IFC…"; + try { + const model = await ifcLoader.load(buffer, true, "model"); + loadedModel = model; + world.scene.three.add(model.object); + + const hasCam = await applyCameraParam(cameraControls, world.camera); + threeCamera = world.camera.three; // refresh: may now be ortho + if (!hasCam && !model.box.isEmpty()) { + await cameraControls.fitToBox(model.box, false); + } + model.useCamera(threeCamera); + + const qIdxPost = ifcUrl.indexOf("?"); + const qsPost = new URLSearchParams( + qIdxPost < 0 ? "" : ifcUrl.slice(qIdxPost + 1) + ); + + const clips = qsPost.getAll("clip") + .map(s => s.split(",").map(Number)) + .filter(v => v.length === 6 && !v.some(isNaN)); + setupClipper(components, world, clips); + + const visibility = qsPost.get("visibility") ?? "highlight"; + const selectorStr = qsPost.get("selector") ?? ""; + + // Set up Highlighter before selector so visibility=highlight can use it. + let highlighter = null; + try { + highlighter = components.get(OBC.Highlighter); + highlighter.setup({ world }); + highlighter.events.select.onHighlight.add(async (fragmentIdMap) => { + for (const expressIds of Object.values(fragmentIdMap)) { + const expressId = [...expressIds][0]; + if (expressId == null) continue; + const props = await model.getLocalProperties(expressId); + if (props) showPropsPanel(props, ifcLoader.webIfc); + return; + } + }); + highlighter.events.select.onClear.add(() => { + propsPanel.style.display = "none"; + }); + } catch (err) { + console.warn("Highlighter unavailable:", err.message); + } + + if (selectorStr) { + statusEl.textContent = "Applying selector…"; + await applySelector(components, model, selectorStr, ifcUrl, visibility); + } + + await fragments.core.update(true); + + syncCameraUrl(threeCamera); + cameraControls.addEventListener("rest", () => syncCameraUrl(threeCamera)); + removeSnapshot(); + + populateMetaPanel(components).catch(err => { + console.warn("Metadata panel unavailable:", err.message); + }); + + statusEl.textContent = ""; + } catch (err) { + removeSnapshot(); + statusEl.textContent = `Render error: ${err.message}`; + console.error(err); + } +} + +main();