Add ifcurl-open and ifcurl-register: ifc:// OS protocol handler

ifcurl-open dispatches ifc:// URLs to a configured viewer (bonsai or
ifcviewer preset, or a custom command list). ifcurl-register installs
it as the OS-level handler: .desktop + xdg-mime on Linux, app bundle
+ lsregister on macOS, HKCU registry keys on Windows (no admin needed).
Data templates in ifcurl/data/. Both support --unregister.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bruno Postle 2026-06-07 09:36:20 +01:00
parent 7c25e0624a
commit d975658515
7 changed files with 466 additions and 6 deletions

View file

@ -3,10 +3,12 @@
{"id":"ifcurl-p60","title":"Viewer: no element click-to-identify — can't get GlobalId or properties","description":"There is no way to click on a mesh in the viewer and see which IFC element it represents. Reviewers can see geometry but cannot reference specific elements — they can't discover a GlobalId to put in a selector URL, can't see the element name/type/Psets, and can't say 'this specific wall' in a BCF or Forgejo comment. This breaks the collaboration workflow at its foundation: every useful feedback comment needs to identify specific elements. Need: click or hover on a surface to highlight it and show a properties panel (name, type, GlobalId, key Psets). The GlobalId should be copyable into the selector field or URL. The ThatOpen components library provides the tools for this (OBC.IfcRelationsIndexer, element property queries).","status":"closed","priority":1,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:02:33Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:23Z","started_at":"2026-04-24T11:14:57Z","closed_at":"2026-04-24T11:47:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-4l7","title":"viewer.html: scale= URLs don't load in orthographic mode","description":"applyCameraParam() reads the camera= and fov= parameters from the URL and applies them, but never reads scale= or switches the camera to orthographic (parallel) projection. URLs like ?camera=...\u0026scale=50 will load in perspective mode instead of orthographic. The OBC OrthoPerspectiveCamera can switch modes, but the code path to set parallel projection and apply the scale value is missing from applyCameraParam.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:51Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:56:29Z","started_at":"2026-04-24T10:50:19Z","closed_at":"2026-04-24T10:56:29Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-362","title":"Add missing server-config files: gitconfig-ifcmerge and gitattributes","description":"forgejo/README.md documents deploying two files that don't exist in the repo: forgejo/server-config/gitconfig-ifcmerge and forgejo/server-config/gitattributes. Both are required for the ifcmerge git merge driver to work in bare Forgejo repos. The README even includes 'sudo cp forgejo/server-config/gitattributes /etc/gitattributes' and 'sudo git config --system include.path .../gitconfig-ifcmerge', but the files themselves are absent. Anyone following the deployment instructions will hit a 'file not found' error. Need to create both files with the content described in the README.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:31Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:51:47Z","started_at":"2026-04-24T10:50:15Z","closed_at":"2026-04-24T10:51:47Z","close_reason":"Files already exist: forgejo/server-config/gitconfig-ifcmerge and gitattributes are both present and correct. Missed in initial review.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-coh","title":"ifcurl-register: register ifc:// OS protocol handler on Linux, macOS, Windows","description":"A ifcurl-register command that installs ifcurl-open as the OS-level ifc:// protocol handler. Linux: write ~/.local/share/applications/ifc-url-handler.desktop and run xdg-mime default. macOS: install a minimal app bundle with CFBundleURLTypes in Info.plist. Windows: write registry key under HKEY_CLASSES_ROOT\\ifc\\shell\\open\\command. Ship desktop/plist/reg templates in data/. Add ifcurl-register as a pyproject.toml entry point.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:43:04Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:43:04Z","dependency_count":0,"dependent_count":1,"comment_count":0}
{"id":"ifcurl-7db","title":"ifcurl-open: OS protocol handler dispatcher for ifc:// URLs","description":"A small ifcurl-open executable that acts as the registered OS-level ifc:// protocol handler. Receives a URL from the OS (e.g. launched by Firefox), reads user preference from ~/.config/ifcurl/config.toml ([handler] default_viewer), and launches the appropriate viewer (bonsai/ifcviewer) with the URL. If a running instance is detected via the viewer socket (see socket IPC issue), sends the URL there instead of launching fresh. Add as a pyproject.toml entry point.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:43:02Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:43:02Z","dependencies":[{"issue_id":"ifcurl-7db","depends_on_id":"ifcurl-coh","type":"blocks","created_at":"2026-06-05T15:43:24Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"id":"ifcurl-m0y","title":"Phase 4: Bonsai integration — open ifc:// URLs natively","description":"Bonsai (the Blender-based IFC authoring tool) should be able to receive and handle ifc:// URLs natively, delegating all git fetch/cache/reconstruction work to the ifcurl Python library.\n\n## What Bonsai needs to implement\n\n**1. ifcurl library integration**\nBonsai calls ifcurl.resolve(url) → local IFC file path, and ifcurl.parse(url) → view state (camera position/direction/up, selector expression, visibility mode, clipping planes). The ifcurl library handles all transport, caching, and file reconstruction — Bonsai sees only a local path.\n\nIf the ifcurl library is not installed, all ifc:// features degrade gracefully: menu entries are hidden or disabled, CLI URL arguments are ignored with a logged warning, and BCF export omits ifc:// fields silently. No errors or broken UI.\n\n**2. 'Open ifc:// URL…' operator and menu entry**\nA Blender operator that accepts an ifc:// URL string, resolves it via ifcurl, opens the resulting IFC file, and applies the view state (camera, selector, visibility). Exposed as a menu entry (File \u003e Open ifc:// URL…) so users can paste a URL directly. This is also the entry point used when launched from ifcurl-open. Hidden when ifcurl is not installed.\n\n**3. CLI / launch argument handling**\nWhen Bonsai is launched with an ifc:// URL as a CLI argument (forwarded by ifcurl-open), the add-on detects it and invokes the same operator. ifcurl-open always launches a new Bonsai instance — there is no mechanism to send a URL to an already-running instance. Silently ignored if ifcurl is not installed.\n\n**4. 'Copy view URL' action**\nSerialise the current Bonsai camera position, active selector/filter, and visibility state into an ifc:// URL pointing to the current file's git repo and ref. Requires the file to have been opened from a git-tracked location. Hidden when ifcurl is not installed.\n\n**5. BCF viewpoint embedding**\nWhen Bonsai exports a BCF file, viewpoints that reference a git-tracked IFC file should include an ifc:// URL in the viewpoint metadata alongside the standard BCF camera fields. Omitted silently when ifcurl is not installed.\n\n**6. Federation: IFCDOCUMENTREFERENCE resolution**\nWhen Bonsai encounters an IFCDOCUMENTREFERENCE whose Location is an ifc:// URL, call ifcurl.resolve() to fetch and open the referenced model. This is the long-term federation goal. Falls back to ignoring the reference when ifcurl is not installed.\n\n## Out of scope here\n- The ifcurl Python library API itself (ifcurl.resolve, ifcurl.parse) — that is Phase 1 work\n- OS handler registration (ifcurl-coh) and dispatcher (ifcurl-7db)","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-07T07:20:36Z","created_by":"Bruno Postle","updated_at":"2026-06-07T07:39:58Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-8x4","title":"Deploy and test BCF export","description":"The BCF export was rewritten as pure client-side (commit 7c25e06). The query panel row-click highlight was also added (ifcurl-yhp). Deploy the updated JS assets to Forgejo and run the browser test checklist items 13 and 14 to verify BCF export and round-trip import still work correctly. Also test that clicking a row in the query panel highlights the corresponding element in the 3D view.","status":"open","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-07T06:32:36Z","created_by":"Bruno Postle","updated_at":"2026-06-07T07:47:27Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-coh","title":"ifcurl-register: register ifc:// OS protocol handler on Linux, macOS, Windows","description":"A ifcurl-register command that installs ifcurl-open as the OS-level ifc:// protocol handler. Linux: write ~/.local/share/applications/ifc-url-handler.desktop and run xdg-mime default. macOS: install a minimal app bundle with CFBundleURLTypes in Info.plist. Windows: write registry key under HKEY_CLASSES_ROOT\\ifc\\shell\\open\\command. Ship desktop/plist/reg templates in data/. Add ifcurl-register as a pyproject.toml entry point.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:43:04Z","created_by":"Bruno Postle","updated_at":"2026-06-07T08:33:09Z","started_at":"2026-06-07T07:49:58Z","closed_at":"2026-06-07T08:33:09Z","close_reason":"Closed","dependency_count":0,"dependent_count":1,"comment_count":0}
{"id":"ifcurl-7db","title":"ifcurl-open: OS protocol handler dispatcher for ifc:// URLs","description":"A small ifcurl-open executable that acts as the registered OS-level ifc:// protocol handler. Receives a raw ifc:// URL from the OS (e.g. launched by Firefox), reads user preference from ~/.config/ifcurl/config.toml ([handler] default_viewer = bonsai|ifcviewer), and forwards the raw URL to the chosen viewer as a CLI argument.\n\nifcurl-open does NOT pre-fetch or resolve the URL — that is the viewer's responsibility via the ifcurl Python library. The viewer calls ifcurl.resolve(url) → local path, then applies camera/selection from the parsed URL.\n\nifcurl-open always launches a new viewer instance. Instance reuse is the viewer's own concern and is not handled here.\n\nAdd ifcurl-open as a pyproject.toml entry point.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:43:02Z","created_by":"Bruno Postle","updated_at":"2026-06-07T08:33:09Z","started_at":"2026-06-07T07:50:01Z","closed_at":"2026-06-07T08:33:09Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-7db","depends_on_id":"ifcurl-coh","type":"blocks","created_at":"2026-06-05T15:43:24Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"id":"ifcurl-bq6","title":"connector.json manifest and entry point for viewer discovery","description":"Ship a connector.json manifest alongside the ifcurl-connector executable so that ifcviewer and bonsai can discover the connector via their configured connector search paths. Format per CLOUD_SYNC_PROTOCOL.md: {id: ifcurl, name: IFC URL, version: ..., exec: /path/to/ifcurl-connector}. The manifest path should be resolvable relative to the installed package. Add ifcurl-connector as a pyproject.toml entry point.","status":"open","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-06-05T14:43:00Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:43:00Z","dependencies":[{"issue_id":"ifcurl-bq6","depends_on_id":"ifcurl-242","type":"blocks","created_at":"2026-06-05T15:43:23Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-242","title":"connector.py: JSON-RPC connector implementing CLOUD_SYNC_PROTOCOL.md interface","description":"Implement the ifcurl connector as a JSON-RPC server per CLOUD_SYNC_PROTOCOL.md. Methods: download_models (resolve ifc:// source URLs to local file paths via working checkout), upload_model (receive a modified IFC file — the connector writes it into the working checkout, ready for the user to branch/commit/push with standard git tools). The connector is a thin JSON-RPC wrapper over checkout.py and git.py; all resolution logic stays in existing modules.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:59Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:42:59Z","dependencies":[{"issue_id":"ifcurl-242","depends_on_id":"ifcurl-5oo","type":"blocks","created_at":"2026-06-05T15:43:19Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-242","depends_on_id":"ifcurl-oef","type":"blocks","created_at":"2026-06-05T15:43:21Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
{"id":"ifcurl-242","title":"connector.py: JSON-RPC connector implementing CLOUD_SYNC_PROTOCOL.md interface","description":"Implement the ifcurl connector as a JSON-RPC server per CLOUD_SYNC_PROTOCOL.md. Methods: download_models (resolve ifc:// source URLs to local file paths via working checkout), upload_model (receive a modified IFC file — the connector writes it into the working checkout, ready for the user to branch/commit/push with standard git tools). The connector is a thin JSON-RPC wrapper over checkout.py and git.py; all resolution logic stays in existing modules.","notes":"Protocol is still in flux on the IfcViewer side (as of 2026-06-07). Do not start implementation until the JSON-RPC interface stabilises. Key open questions:\n\n1. Built-in vs. JSON-RPC: CLOUD_SYNC_PROTOCOL.md describes 'ifcurl' as a built-in connector (like 'local') that bypasses JSON-RPC for resolving ifc:// URLs embedded in .ifcfed files. It is unclear whether ifcurl also needs a full JSON-RPC connector for the 'Open from Cloud' browse/save workflow, or whether the built-in path is sufficient.\n\n2. Scope of methods: The minimal set is download_models (ifc:// URL → local path via checkout.py) and upload_model (write modified IFC into working checkout). Whether open_ifcfed, download_ifcfed, upload_ifcfed, add_models are needed for the git/ifcurl use case is unclear.\n\n3. upload_model semantics: The connector writes the file into the working checkout — the user then branches, commits, and pushes with standard git tools. This is intentional: ifcurl does not handle the commit/push workflow, only the file placement. The correct remote configuration (ifcurl-oef) is what makes this useful.\n\n4. Transport: The protocol doc implies stdio JSON-RPC. Confirm with IfcViewer team before implementing.\n\n5. Connector discovery: ifcurl-bq6 (connector.json manifest) depends on this — the manifest format may also change.","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:59Z","created_by":"Bruno Postle","updated_at":"2026-06-07T07:29:17Z","dependencies":[{"issue_id":"ifcurl-242","depends_on_id":"ifcurl-5oo","type":"blocks","created_at":"2026-06-05T15:43:19Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-242","depends_on_id":"ifcurl-oef","type":"blocks","created_at":"2026-06-05T15:43:21Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0}
{"id":"ifcurl-oef","title":"Remote origin remapping: set working checkout origin to user local repo","description":"When a working checkout is created for an ifc:// URL and a local repo has been identified (via discovery or config), set the checkout's origin remote to the local repo path and add an upstream remote pointing to the original ifc:// source URL. This mirrors standard fork workflow: fetch from upstream, push to origin (local repo), which the user then pushes to their remote and opens a PR. If no local repo is found, origin stays as the upstream (read-only without credentials).","status":"open","priority":2,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:57Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:42:57Z","dependencies":[{"issue_id":"ifcurl-oef","depends_on_id":"ifcurl-egi","type":"blocks","created_at":"2026-06-05T15:43:18Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"id":"ifcurl-egi","title":"Local repo discovery: scan configured paths for repos matching genesis commit","description":"When opening an ifc:// URL, scan user-configured search paths (e.g. ~/projects/) for local git repos whose genesis commit matches the project being opened. If a match is found, that local repo becomes the push target (origin) for the working checkout. Search paths are configured in ~/.config/ifcurl/config.toml under [repo_search] paths. Results are cached per genesis commit to avoid repeated scanning.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:54Z","created_by":"Bruno Postle","updated_at":"2026-06-05T22:05:37Z","started_at":"2026-06-05T22:00:19Z","closed_at":"2026-06-05T22:05:37Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-egi","depends_on_id":"ifcurl-4d4","type":"blocks","created_at":"2026-06-05T15:43:14Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
{"id":"ifcurl-5oo","title":"checkout.py: writable working tree management on top of bare cache","description":"Add a checkout.py module that manages full (non-bare) working tree checkouts for ifc:// URLs, alongside the existing bare cache clone. The checkout is created via git clone from the bare cache (no extra network fetch). It is checked out at a specific ref as a detached HEAD, which forces the user to create a branch before committing. The checkout includes a .gitignore covering *.ifcview, *.rdbview and other viewer artefacts. The connector and local-file materialisation both build on this.","status":"closed","priority":2,"issue_type":"feature","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:52Z","created_by":"Bruno Postle","updated_at":"2026-06-05T22:38:17Z","started_at":"2026-06-05T22:08:23Z","closed_at":"2026-06-05T22:38:17Z","close_reason":"Closed","dependencies":[{"issue_id":"ifcurl-5oo","depends_on_id":"ifcurl-i07","type":"blocks","created_at":"2026-06-05T15:43:13Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
@ -62,11 +64,11 @@
{"id":"ifcurl-ei8","title":"Fix syncCameraUrl: convert WebGL coords to IFC world space","description":"syncCameraUrl in viewer.py currently records camera.position/direction/up directly from Three.js, which are in WebGL/Three.js coordinate space (Y-up, different scale/orientation than IFC). The ifc:// URL spec expects IFC world coordinates (Z-up, matching BCF viewpoint conventions). Need to find the IFC→Three.js transform that @thatopen/components applies when loading a model, then apply its inverse in syncCameraUrl before serialising the camera params. Repos cloned for reference: ~/src/engine_components and ~/src/engine_fragment.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-23T05:53:06Z","created_by":"Bruno Postle","updated_at":"2026-04-23T06:14:36Z","closed_at":"2026-04-23T06:14:36Z","close_reason":"Split into atomic tasks: ifcurl-ldk, ifcurl-j2s, ifcurl-cmd","dependency_count":0,"dependent_count":0,"comment_count":0}
{"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":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-4yu","type":"blocks","created_at":"2026-04-23T07:14:11Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ach","type":"blocks","created_at":"2026-04-23T06:48:10Z","created_by":"Bruno Postle","metadata":"{}"},{"issue_id":"ifcurl-i1s","depends_on_id":"ifcurl-ahd","type":"blocks","created_at":"2026-04-23T07:02:54Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0}
{"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":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}
{"id":"ifcurl-bsw","title":"Socket IPC: dispatch ifc:// URLs to already-running viewer instance","description":"When a viewer (bonsai or ifcviewer) is running, ifcurl-open should send the URL to the existing instance rather than launching a new process. Protocol: on startup the viewer opens a Unix socket at ~/.cache/ifcurl/\u003cviewer-name\u003e.sock. ifcurl-open tries the socket first; if it connects it sends the URL as a newline-terminated string and exits. If no socket exists or connection fails, falls back to launching fresh. Bonsai and ifcviewer each need to implement the socket listener.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:43:06Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:43:06Z","dependencies":[{"issue_id":"ifcurl-bsw","depends_on_id":"ifcurl-7db","type":"blocks","created_at":"2026-06-05T15:43:26Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-bsw","title":"Socket IPC: dispatch ifc:// URLs to already-running viewer instance","description":"When a viewer (bonsai or ifcviewer) is running, ifcurl-open should send the URL to the existing instance rather than launching a new process. Protocol: on startup the viewer opens a Unix socket at ~/.cache/ifcurl/\u003cviewer-name\u003e.sock. ifcurl-open tries the socket first; if it connects it sends the URL as a newline-terminated string and exits. If no socket exists or connection fails, falls back to launching fresh. Bonsai and ifcviewer each need to implement the socket listener.","status":"closed","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:43:06Z","created_by":"Bruno Postle","updated_at":"2026-06-07T07:34:25Z","closed_at":"2026-06-07T07:34:25Z","close_reason":"Descoped: ifcurl-open will always launch a new viewer instance. Socket IPC is complex, not idiomatic for Blender, and can be added later if there is demand. Instance reuse policy is left to the viewer application.","dependencies":[{"issue_id":"ifcurl-bsw","depends_on_id":"ifcurl-7db","type":"blocks","created_at":"2026-06-05T15:43:26Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-77u","title":"Ambiguity resolution: ask user when multiple local repos match same genesis commit","description":"When local repo discovery finds more than one local repo with the same genesis commit (user has two side-by-side clones of the same project, by mistake or intent), there is no reliable heuristic for choosing. The system should ask the user once, then cache the choice keyed by genesis commit in ~/.config/ifcurl/config.toml under [project_origins]. The user can also set false to mark a project as read-only (no local push target).","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-06-05T14:42:56Z","created_by":"Bruno Postle","updated_at":"2026-06-05T14:42:56Z","dependencies":[{"issue_id":"ifcurl-77u","depends_on_id":"ifcurl-egi","type":"blocks","created_at":"2026-06-05T15:43:16Z","created_by":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-987","title":"UI: no way to remove clipping planes without editing the URL","description":"Once clip= parameters have been added to the ifc:// URL (e.g. via BCF import or manual URL edit), there is no UI control to remove them. The only workaround is to manually edit the URL in the toolbar and delete the clip= parameters. The viewer should provide a button or control to clear all active clipping planes.","status":"closed","priority":3,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-05-17T23:30:53Z","created_by":"Bruno Postle","updated_at":"2026-05-17T23:34:40Z","closed_at":"2026-05-17T23:34:40Z","close_reason":"Button to remove clipping planes already exists in the viewer","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-2dj","title":"Clearance representations visible as backfaces despite hide logic","description":"There is code to hide Clearance representations of elements, but they remain visible as backfaces — the back face of the clearance geometry is rendered through the model. The hide logic is not suppressing backface rendering, so the representation bleeds through.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-17T08:45:41Z","created_by":"Bruno Postle","updated_at":"2026-05-17T09:55:26Z","started_at":"2026-05-17T09:36:23Z","closed_at":"2026-05-17T09:55:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-yhp","title":"Query panel: clicking an element in the results list should highlight it in the 3D view","description":"When a selector query is active, the query panel lists matched elements. Clicking a row in this list should highlight (or isolate) that specific element in the 3D canvas, so the user can locate it spatially. This mirrors the click-to-inspect flow but driven from the panel rather than the canvas.","status":"open","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-05-17T08:45:31Z","created_by":"Bruno Postle","updated_at":"2026-05-17T08:45:31Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-yhp","title":"Query panel: clicking an element in the results list should highlight it in the 3D view","description":"When a selector query is active, the query panel lists matched elements. Clicking a row in this list should highlight (or isolate) that specific element in the 3D canvas, so the user can locate it spatially. This mirrors the click-to-inspect flow but driven from the panel rather than the canvas.","status":"closed","priority":3,"issue_type":"feature","owner":"bruno@postle.net","created_at":"2026-05-17T08:45:31Z","created_by":"Bruno Postle","updated_at":"2026-06-07T07:46:10Z","closed_at":"2026-06-07T07:46:10Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-6xq","title":"Query panel does not appear after selector resolves","description":"When a selector is set in the viewer and a query= parameter is also present, the query results table (showing GUID → value pairs) should appear in a side panel. This panel never appears. The applyQuery() function in viewer.js fetches /query from the ifcurl service and populates queryTbody, but the panel remains hidden. Needs investigation: check whether the query param is being passed correctly, whether the /query endpoint returns data, and whether queryPanel.style.display is being set.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-16T13:24:59Z","created_by":"Bruno Postle","updated_at":"2026-05-16T20:02:21Z","started_at":"2026-05-16T14:14:52Z","closed_at":"2026-05-16T20:02:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"id":"ifcurl-hwk","title":"Meta panel: missing type counts, icon is hamburger not (i)","description":"The metadata panel works for storey isolation but has two issues: (1) The toolbar button is a hamburger icon, not an (i) icon as documented in CLAUDE.md check #17. Update the checklist text to match reality. (2) The type counts breakdown (IfcWall: N, IfcSlab: N, etc.) does not appear in the panel — only the storey list is shown. Investigate whether populateMetaPanel() is failing silently or the type classifier isn't running correctly.","status":"closed","priority":3,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-05-16T13:24:45Z","created_by":"Bruno Postle","updated_at":"2026-05-16T20:02:21Z","started_at":"2026-05-16T14:15:06Z","closed_at":"2026-05-16T20:02:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"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}
@ -135,6 +137,6 @@
{"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":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"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":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"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":"Bruno Postle","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
{"_type":"memory","key":"ifcurl-service-names-are-ifcurl-api-service-and","value":"ifcurl service names are ifcurl-api.service and ifcurl-render.service (not ifcurl.service). Install path for Python package: /opt/ifcurl/lib/python3.14/site-packages/ifcurl/. Python binary: /opt/ifcurl/bin/python3."}
{"_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":"ifcurl-service-names-are-ifcurl-api-service-and","value":"ifcurl service names are ifcurl-api.service and ifcurl-render.service (not ifcurl.service). Install path for Python package: /opt/ifcurl/lib/python3.14/site-packages/ifcurl/. Python binary: /opt/ifcurl/bin/python3."}
{"_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

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>net.postle.ifcurl.UrlHandler</string>
<key>CFBundleName</key>
<string>IfcUrlHandler</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>CFBundleExecutable</key>
<string>ifcurl-open-shim</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>IFC URL</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ifc</string>
</array>
</dict>
</array>
<key>LSBackgroundOnly</key>
<true/>
</dict>
</plist>

0
ifcurl/data/__init__.py Normal file
View file

View file

@ -0,0 +1,7 @@
[Desktop Entry]
Name=IFC URL Handler
Comment=Open ifc:// URLs in the configured viewer
Exec=IFCURL_OPEN_EXEC %u
Type=Application
NoDisplay=true
MimeType=x-scheme-handler/ifc;

120
ifcurl/open.py Normal file
View file

@ -0,0 +1,120 @@
# IFC URL — resolve and render ifc:// URLs
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
# SPDX-License-Identifier: LGPL-3.0-or-later
#
# This file is part of IFC URL.
#
# IFC URL is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IFC URL is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IFC URL. If not, see <http://www.gnu.org/licenses/>.
"""ifcurl-open: OS protocol handler dispatcher for ifc:// URLs.
Receives a raw ifc:// URL from the OS (launched by a browser or file manager),
reads the configured viewer from ``~/.config/ifcurl/config.toml``, and
forwards the URL to that viewer as a command-line argument.
Config example (~/.config/ifcurl/config.toml)::
[handler]
default_viewer = "bonsai" # or "ifcviewer"
# Or provide a fully custom command template:
# command = ["myviewer", "--open", "{url}"]
The viewer is expected to call ``ifcurl.resolve(url)`` internally to fetch
and render the model. ifcurl-open does not pre-fetch the URL.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
try:
import tomllib
except ImportError:
import tomli as tomllib # type: ignore[no-redef]
from platformdirs import user_config_dir
_CONFIG_PATH = Path(user_config_dir("ifcurl")) / "config.toml"
# Known viewer presets: list of argv tokens, with {url} as a placeholder.
# Phase 4 will refine the bonsai invocation once the Bonsai API is settled.
_KNOWN_VIEWERS: dict[str, list[str]] = {
"bonsai": ["blender", "--python-expr", "import bonsai; bonsai.open_ifc_url('{url}')"],
"ifcviewer": ["ifcviewer", "{url}"],
}
def main() -> None:
parser = argparse.ArgumentParser(
prog="ifcurl-open",
description="Open an ifc:// URL in the configured viewer.",
)
parser.add_argument("url", help="The ifc:// URL to open")
args = parser.parse_args()
cmd = _build_command(args.url)
try:
subprocess.Popen(cmd)
except FileNotFoundError:
print(f"Error: viewer executable not found — {cmd[0]}", file=sys.stderr)
sys.exit(1)
def _build_command(url: str) -> list[str]:
config = _read_config()
handler = config.get("handler", {})
if "command" in handler:
template = handler["command"]
if not isinstance(template, list):
print(
f"Error: [handler] command in {_CONFIG_PATH} must be a TOML array of strings",
file=sys.stderr,
)
sys.exit(1)
return [tok.replace("{url}", url) for tok in template]
viewer = handler.get("default_viewer")
if viewer is None:
print(
"Error: no viewer configured.\n"
f"Add to {_CONFIG_PATH}:\n\n"
" [handler]\n"
' default_viewer = "bonsai" # or "ifcviewer"',
file=sys.stderr,
)
sys.exit(1)
if viewer not in _KNOWN_VIEWERS:
known = ", ".join(f'"{v}"' for v in _KNOWN_VIEWERS)
print(
f"Error: unknown viewer '{viewer}' in {_CONFIG_PATH}.\n"
f"Known presets: {known}\n"
"Or use [handler] command = [\"myviewer\", \"{url}\"] for a custom command.",
file=sys.stderr,
)
sys.exit(1)
return [tok.replace("{url}", url) for tok in _KNOWN_VIEWERS[viewer]]
def _read_config() -> dict:
if not _CONFIG_PATH.exists():
return {}
with open(_CONFIG_PATH, "rb") as f:
return tomllib.load(f)

301
ifcurl/register.py Normal file
View file

@ -0,0 +1,301 @@
# IFC URL — resolve and render ifc:// URLs
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
# SPDX-License-Identifier: LGPL-3.0-or-later
#
# This file is part of IFC URL.
#
# IFC URL is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IFC URL is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IFC URL. If not, see <http://www.gnu.org/licenses/>.
"""ifcurl-register: install ifcurl-open as the OS ifc:// protocol handler.
Platform support:
- **Linux**: writes a ``.desktop`` file to ``~/.local/share/applications/``
and registers it with ``xdg-mime``.
- **macOS**: installs a minimal ``.app`` bundle in ``~/Applications/`` with
``CFBundleURLTypes`` for the ``ifc://`` scheme, then calls ``lsregister``.
The bundle launcher requires ``pyobjc-framework-Cocoa`` to receive URL
open events from the OS.
- **Windows**: writes per-user registry keys under
``HKCU\\SOFTWARE\\Classes\\ifc`` (no administrator privileges required).
"""
from __future__ import annotations
import argparse
import importlib.resources
import shutil
import subprocess
import sys
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(
prog="ifcurl-register",
description="Register ifcurl-open as the OS-level ifc:// protocol handler.",
)
parser.add_argument(
"--unregister",
action="store_true",
help="Remove the ifc:// protocol handler registration.",
)
args = parser.parse_args()
ifcurl_open = _find_ifcurl_open()
platform = sys.platform
if platform.startswith("linux"):
if args.unregister:
_unregister_linux()
else:
_register_linux(ifcurl_open)
elif platform == "darwin":
if args.unregister:
_unregister_macos()
else:
_register_macos(ifcurl_open)
elif platform == "win32":
if args.unregister:
_unregister_windows()
else:
_register_windows(ifcurl_open)
else:
print(f"Error: unsupported platform '{platform}'", file=sys.stderr)
sys.exit(1)
# ---------------------------------------------------------------------------
# Linux
# ---------------------------------------------------------------------------
def _register_linux(ifcurl_open: str) -> None:
apps_dir = Path.home() / ".local" / "share" / "applications"
apps_dir.mkdir(parents=True, exist_ok=True)
template = _read_data("ifc-url-handler.desktop")
content = template.replace("IFCURL_OPEN_EXEC", ifcurl_open)
desktop_path = apps_dir / "ifc-url-handler.desktop"
desktop_path.write_text(content)
print(f"Wrote {desktop_path}")
try:
subprocess.run(
["xdg-mime", "default", "ifc-url-handler.desktop", "x-scheme-handler/ifc"],
check=True,
)
print("Registered ifc:// with xdg-mime")
except FileNotFoundError:
print("Warning: xdg-mime not found; registration may be incomplete", file=sys.stderr)
except subprocess.CalledProcessError as exc:
print(f"Warning: xdg-mime failed: {exc}", file=sys.stderr)
# Non-critical: update the desktop database so the entry is immediately visible
subprocess.run(["update-desktop-database", str(apps_dir)], check=False)
print("Done. ifc:// URLs will open with ifcurl-open.")
def _unregister_linux() -> None:
desktop_path = Path.home() / ".local" / "share" / "applications" / "ifc-url-handler.desktop"
if desktop_path.exists():
desktop_path.unlink()
print(f"Removed {desktop_path}")
subprocess.run(["update-desktop-database", str(desktop_path.parent)], check=False)
else:
print("ifc-url-handler.desktop not found; nothing to remove.")
# ---------------------------------------------------------------------------
# macOS
# ---------------------------------------------------------------------------
_LSREGISTER = (
"/System/Library/Frameworks/CoreServices.framework"
"/Versions/A/Frameworks/LaunchServices.framework"
"/Versions/A/Support/lsregister"
)
_MACOS_SHIM = '''\
#!/usr/bin/env python3
"""macOS URL handler shim for ifc:// URLs.
Receives URLs via AppleEvents when invoked by the OS.
Requires pyobjc-framework-Cocoa for AppleEvents support.
Falls back to a command-line argument if PyObjC is unavailable.
"""
import subprocess, sys
from pathlib import Path
def _dispatch(url_str):
bin_dir = Path(sys.executable).parent
for name in ("ifcurl-open",):
exe = bin_dir / name
if exe.exists():
subprocess.Popen([str(exe), url_str])
return
subprocess.Popen([sys.executable, "-m", "ifcurl.open", url_str])
try:
from AppKit import NSApplication, NSObject
from PyObjCTools import AppHelper
class _Delegate(NSObject):
def applicationWillFinishLaunching_(self, _n):
pass
def application_openURLs_(self, _app, urls):
for url in urls:
_dispatch(url.absoluteString())
AppHelper.stopEventLoop()
_app = NSApplication.sharedApplication()
_app.setDelegate_(_Delegate.alloc().init())
AppHelper.runEventLoop()
except ImportError:
if len(sys.argv) > 1:
_dispatch(sys.argv[1])
else:
print(
"Install pyobjc-framework-Cocoa for macOS ifc:// URL scheme support.",
file=sys.stderr,
)
sys.exit(1)
'''
def _register_macos(ifcurl_open: str) -> None:
app_bundle = Path.home() / "Applications" / "IfcUrlHandler.app"
contents = app_bundle / "Contents"
macos_dir = contents / "MacOS"
macos_dir.mkdir(parents=True, exist_ok=True)
plist_text = _read_data("IfcUrlHandler-Info.plist")
(contents / "Info.plist").write_text(plist_text)
print(f"Wrote {contents / 'Info.plist'}")
shim_path = macos_dir / "ifcurl-open-shim"
shim_path.write_text(_MACOS_SHIM)
shim_path.chmod(0o755)
print(f"Wrote {shim_path}")
if Path(_LSREGISTER).exists():
try:
subprocess.run([_LSREGISTER, "-f", str(app_bundle)], check=True)
print(f"Registered {app_bundle} with Launch Services")
except subprocess.CalledProcessError as exc:
print(f"Warning: lsregister failed: {exc}", file=sys.stderr)
else:
print("Warning: lsregister not found; you may need to log out and back in.", file=sys.stderr)
print("Done. ifc:// URLs will open with IfcUrlHandler.app.")
print("Note: install pyobjc-framework-Cocoa for full AppleEvents support.")
def _unregister_macos() -> None:
app_bundle = Path.home() / "Applications" / "IfcUrlHandler.app"
if app_bundle.exists():
if Path(_LSREGISTER).exists():
subprocess.run([_LSREGISTER, "-u", str(app_bundle)], check=False)
shutil.rmtree(app_bundle)
print(f"Removed {app_bundle}")
else:
print(f"{app_bundle} not found; nothing to remove.")
# ---------------------------------------------------------------------------
# Windows
# ---------------------------------------------------------------------------
def _register_windows(ifcurl_open: str) -> None:
try:
import winreg
except ImportError:
print("Error: winreg is only available on Windows", file=sys.stderr)
sys.exit(1)
command = f'"{ifcurl_open}" "%1"'
# HKCU\SOFTWARE\Classes\ifc (per-user, no admin required)
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Classes\ifc") as key:
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, "URL:IFC Protocol")
winreg.SetValueEx(key, "URL Protocol", 0, winreg.REG_SZ, "")
with winreg.CreateKey(
winreg.HKEY_CURRENT_USER, r"SOFTWARE\Classes\ifc\shell\open\command"
) as key:
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, command)
print(f"Registered ifc:// → {command}")
print("Done. ifc:// URLs will open with ifcurl-open.")
def _unregister_windows() -> None:
try:
import winreg
except ImportError:
print("Error: winreg is only available on Windows", file=sys.stderr)
sys.exit(1)
for subkey in (
r"SOFTWARE\Classes\ifc\shell\open\command",
r"SOFTWARE\Classes\ifc\shell\open",
r"SOFTWARE\Classes\ifc\shell",
r"SOFTWARE\Classes\ifc",
):
try:
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, subkey)
except FileNotFoundError:
pass
print("Removed ifc:// registry keys.")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _find_ifcurl_open() -> str:
"""Return the absolute path to the ifcurl-open executable."""
# Prefer the executable in the same bin directory as the current interpreter
bin_dir = Path(sys.executable).parent
for name in ("ifcurl-open", "ifcurl-open.exe"):
candidate = bin_dir / name
if candidate.exists():
return str(candidate)
# Fall back to PATH search
found = shutil.which("ifcurl-open")
if found:
return found
# Last resort: invoke as a module
return f"{sys.executable} -m ifcurl.open"
def _read_data(filename: str) -> str:
"""Read a file from the ifcurl/data/ package directory."""
try:
# Python 3.9+
ref = importlib.resources.files("ifcurl.data").joinpath(filename)
return ref.read_text(encoding="utf-8")
except AttributeError:
# Python 3.8 fallback
with importlib.resources.open_text("ifcurl.data", filename) as f:
return f.read()

View file

@ -25,6 +25,8 @@ service = ["pyvista", "numpy", "Pillow", "fastapi", "uvicorn[standard]", "python
[project.scripts]
ifcurl = "ifcurl.__main__:main"
ifcurl-open = "ifcurl.open:main"
ifcurl-register = "ifcurl.register:main"
[project.urls]
Homepage = "https://github.com/brunopostle/ifcurl"
@ -36,6 +38,7 @@ exclude = ["test*"]
[tool.setuptools.package-data]
ifcurl = ["py.typed"]
"ifcurl.data" = ["*.desktop", "*.plist"]
[tool.pytest.ini_options]
testpaths = ["tests"]