diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0900c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +*.egg-info/ +dist/ +build/ +.pytest_cache/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..18e8db8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +pip install -e ".[render]" # install with rendering dependencies +python -m pytest tests/ # run all tests +python -m pytest tests/test_url.py -v # run URL parser tests +ifcurl render "ifc://..." # render a URL to ifcurl-render.png +ifcurl render "ifc://..." -o out.png +``` + +## Project overview + +`ifcurl` implements the `ifc://` URL scheme — a way to address a specific view of an IFC model stored in a Git repository. A single URL encodes the git source, an optional IfcOpenShell selector, and an optional camera viewpoint. + +Full spec: `SPECIFICATION.md`. Full development plan: `PLAN.md`. + +## URL structure + +``` +ifc://[user@]host/org/repo@? +``` + +Transport is inferred: `user@host` → SSH, bare `host` → HTTPS, empty authority (`ifc:///path`) → local file. Refs use git namespace form: `@heads/`, `@tags/`, `@`, `@HEAD`. + +Key query parameters: `path` (IFC file in repo), `selector` (IfcOpenShell selector expression), `camera` (9 floats: position, direction, up), `fov` (perspective) or `scale` (orthographic), `clip` (repeatable clipping plane, 6 floats), `visibility` (`highlight`/`ghost`/`isolate`). + +## Development phases + +| Phase | Description | Language | +|---|---|---| +| 1 | Python core library + CLI | Python | +| 2 | HTTP preview service with caching | Python | +| 3 | Gitea plugin for inline preview embedding | Go | +| 4 | Bonsai integration (OS protocol handler + "Copy view URL") | Python | +| 5 | IFC Viewer integration | Python | +| 6 | `IFCDOCUMENTREFERENCE` federation extension | Python | + +Phase 1 is the foundation for all other phases. Phases 3–6 cannot begin until Phase 1 exists. + +## Phase 1 — Python core responsibilities + +- Parse `ifc://` URL into components +- Infer git transport and fetch the IFC file at the specified ref +- Execute an IfcOpenShell selector against the loaded model +- Apply camera, clipping planes, and visibility mode +- Render a static PNG (headless / software OpenGL) + +CLI entry point: `ifcurl render ""` + +## Caching (Phase 2) + +Tier 2: commit hash → parsed model structure (shared across requests) +Tier 3: commit hash + selector → resolved GUID set +Tier 4: full URL hash → rendered PNG (**never** applied to mutable refs like `@heads/` or `@HEAD`) + +## Key domain facts + +- All spatial values (`camera`, `clip`) are in IFC world coordinates, matching BCF viewpoint conventions — not georeferenced survey coordinates. +- The `selector` parameter uses IfcOpenShell selector syntax. `+` joins selector groups (union); `,` separates filters within a group. +- `IFCDOCUMENTREFERENCE` location fields may be relative paths (resolved within the same repo/ref), absolute local paths (desktop only), or `ifc://` URLs (cross-repo federation). +- BCF ↔ IFC URL conversion is defined in `SPECIFICATION.md` §6. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0a04128 --- /dev/null +++ b/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/README.md b/README.md new file mode 100644 index 0000000..77e952e --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# ifcurl + +**ifcurl** is a command-line tool and Python library for resolving `ifc://` URLs — a URL scheme for addressing a specific view of an IFC building model stored in a git repository. + +A single URL encodes the model source (git host, repository, ref), an optional element selection, and an optional camera viewpoint. ifcurl fetches the model and renders a PNG, the way curl fetches a web resource. + +``` +ifcurl render "ifc://github.com/brunopostle/simple-ifc@heads/main?path=_test_simple.ifc" +``` + +![Example render](https://raw.githubusercontent.com/brunopostle/simple-ifc/main/_test_simple.png) + +--- + +## URL format + +``` +ifc://[user@]host/org/repo@? +``` + +### Transport + +Transport is inferred from the URL structure — no prefix required: + +| URL form | Transport | +|---|---| +| `ifc://host/org/repo` | HTTPS | +| `ifc://git@host/org/repo` | SSH | +| `ifc:///path/to/repo` | Local file | + +### Ref + +| Form | Meaning | +|---|---| +| `@heads/main` | Branch tip | +| `@tags/v1.2` | Tag | +| `@abc123def` | Commit hash | +| `@HEAD` | Default branch | + +### Parameters + +| Parameter | Description | +|---|---| +| `path=` | Path to the IFC file within the repository | +| `selector=` | [IfcOpenShell selector](https://docs.ifcopenshell.org/ifcopenshell-python/selector_syntax.html) expression to filter elements | +| `camera=px,py,pz,dx,dy,dz,ux,uy,uz` | Camera position, view direction, and up vector in IFC world coordinates | +| `fov=60` | Perspective field of view in degrees (use with `camera`) | +| `scale=50` | Orthographic view-to-world scale (use with `camera`) | +| `clip=px,py,pz,nx,ny,nz` | Clipping plane — repeatable | +| `visibility=` | `highlight` (default), `ghost`, or `isolate` | + +### Visibility modes + +| Mode | Behaviour | +|---|---| +| `highlight` | All elements shown; selected elements shown in highlight colour | +| `ghost` | Selected elements shown normally; all others dimmed | +| `isolate` | Only selected elements shown | + +--- + +## Examples + +Open a model at the default isometric view: + +``` +ifcurl render "ifc://github.com/brunopostle/simple-ifc@heads/main?path=_test_simple.ifc" +``` + +Walls only, ghost mode: + +``` +ifcurl render "ifc://github.com/brunopostle/simple-ifc@heads/main?path=_test_simple.ifc&selector=IfcWall&visibility=ghost" +``` + +Perspective view with a clipping plane (section cut at z=3): + +``` +ifcurl render "ifc://example.com/org/project@abc123def?path=models/building.ifc&camera=10,20,5,0,-1,0,0,0,1&fov=60&clip=0,0,3,0,0,-1" +``` + +Orthographic plan view from above: + +``` +ifcurl render "ifc://example.com/org/project@tags/v2.0?path=models/building.ifc&camera=0,0,8,0,0,-1,0,1,0&scale=50" +``` + +Local repository: + +``` +ifcurl render "ifc:///home/alice/projects/office@HEAD?path=model.ifc" +``` + +Save to a specific file: + +``` +ifcurl render "ifc://..." -o output.png +``` + +--- + +## Installation + +```bash +pip install ifcurl +``` + +For rendering support (required for `ifcurl render`): + +```bash +pip install "ifcurl[render]" +``` + +**Dependencies:** + +- [ifcopenshell](https://ifcopenshell.org) — IFC parsing and selector execution +- [GitPython](https://gitpython.readthedocs.io) — git repository access +- [platformdirs](https://platformdirs.readthedocs.io) — OS-appropriate cache directory +- [pyvista](https://pyvista.org) + [numpy](https://numpy.org) — 3D rendering (optional, required for `render`) + +--- + +## Python library + +```python +from ifcurl import IfcUrl, fetch_ifc_bytes +import ifcopenshell +import tempfile, os +from ifcurl import render + +url = IfcUrl.parse("ifc://github.com/brunopostle/simple-ifc@heads/main?path=_test_simple.ifc&selector=IfcWall&visibility=ghost") + +ifc_bytes = fetch_ifc_bytes(url) + +tmp_fd, tmp_path = tempfile.mkstemp(suffix=".ifc") +os.write(tmp_fd, ifc_bytes) +os.close(tmp_fd) +model = ifcopenshell.open(tmp_path) +os.unlink(tmp_path) + +png_bytes = render.render( + model, + selector=url.selector, + camera=url.camera, + fov=url.fov, + scale=url.scale, + clips=url.clips or None, + visibility=url.visibility, +) + +with open("output.png", "wb") as f: + f.write(png_bytes) +``` + +--- + +## Remote repository caching + +Remote repositories are cloned as bare repos to the OS cache directory (`~/.cache/ifcurl/` on Linux, `~/Library/Caches/ifcurl/` on macOS, `%LOCALAPPDATA%\ifcurl\Cache\` on Windows) on first use. Subsequent calls to the same repository reuse the cache. Mutable refs (`@heads/`, `@HEAD`) trigger a `git fetch` to pick up upstream changes; immutable refs (commit hashes, tags) use the cache as-is. + +--- + +## Development + +```bash +git clone https://github.com/brunopostle/ifcurl +cd ifcurl +pip install -e ".[render]" +python -m pytest tests/ +``` + +--- + +## Licence + +LGPL-3.0-or-later diff --git a/ifcurl/__init__.py b/ifcurl/__init__.py new file mode 100644 index 0000000..2c8902c --- /dev/null +++ b/ifcurl/__init__.py @@ -0,0 +1,23 @@ +# IFC URL — resolve and render ifc:// URLs +# Copyright (C) 2026 Bruno Postle +# +# 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 . + +from ifcurl.git import fetch_ifc_bytes +from ifcurl.url import IfcUrl + +__version__ = "0.0.0" +__all__ = ["IfcUrl", "fetch_ifc_bytes"] diff --git a/ifcurl/__main__.py b/ifcurl/__main__.py new file mode 100644 index 0000000..bca3795 --- /dev/null +++ b/ifcurl/__main__.py @@ -0,0 +1,111 @@ +# IFC URL — resolve and render ifc:// URLs +# Copyright (C) 2026 Bruno Postle +# +# 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 . + +from __future__ import annotations + +import argparse +import os +import sys +import tempfile + +import ifcopenshell + +from ifcurl import render as render_mod +from ifcurl.git import fetch_ifc_bytes +from ifcurl.url import IfcUrl + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="ifcurl", + description="Resolve and render ifc:// URLs pointing to IFC models in git repositories", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + render_parser = subparsers.add_parser("render", help="Render an ifc:// URL to a PNG image") + render_parser.add_argument("url", help="The ifc:// URL to render") + render_parser.add_argument( + "-o", "--output", default="", metavar="FILE", + help="Output PNG path (default: ifc-url-render.png)", + ) + + args = parser.parse_args() + + if args.command == "render": + _cmd_render(args) + + +def _cmd_render(args: argparse.Namespace) -> None: + try: + ifc_url = IfcUrl.parse(args.url) + except ValueError as exc: + print(f"Error: invalid URL — {exc}", file=sys.stderr) + sys.exit(1) + + # Fetch IFC bytes from git + try: + ifc_bytes = fetch_ifc_bytes(ifc_url) + except ImportError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + # Load model from bytes via a temp file + tmp_fd, tmp_ifc = tempfile.mkstemp(suffix=".ifc") + try: + os.write(tmp_fd, ifc_bytes) + os.close(tmp_fd) + try: + model = ifcopenshell.open(tmp_ifc) + except Exception as exc: + print(f"Error: could not open IFC file — {exc}", file=sys.stderr) + sys.exit(1) + finally: + try: + os.unlink(tmp_ifc) + except OSError: + pass + + # Render + try: + png_bytes = render_mod.render( + model, + selector=ifc_url.selector, + camera=ifc_url.camera, + fov=ifc_url.fov, + scale=ifc_url.scale, + clips=ifc_url.clips or None, + visibility=ifc_url.visibility, + ) + except ImportError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + out_path = args.output or "ifc-url-render.png" + with open(out_path, "wb") as f: + f.write(png_bytes) + print(f"Saved render to {out_path}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/ifcurl/git.py b/ifcurl/git.py new file mode 100644 index 0000000..4c342b2 --- /dev/null +++ b/ifcurl/git.py @@ -0,0 +1,143 @@ +# IFC URL — resolve and render ifc:// URLs +# Copyright (C) 2026 Bruno Postle +# +# 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 . + +"""Fetch IFC file bytes from a git repository described by an IfcUrl. + +Remote repositories are cloned as bare repos to a per-URL cache directory +under ~/.cache/ifcurl/. Mutable refs (HEAD, branches) trigger a fetch on +each call to pick up upstream changes; immutable refs (commit hashes, tags) +reuse the cached clone without fetching. +""" + +from __future__ import annotations + +import hashlib + +# allows git import even if git executable isn't found during import +import os +from pathlib import Path + +from platformdirs import user_cache_dir + +os.environ.setdefault("GIT_PYTHON_REFRESH", "quiet") +try: + import git + import git.exc + + _HAS_GITPYTHON = True +except ImportError: + _HAS_GITPYTHON = False + +from ifcurl.url import IfcUrl + + +def fetch_ifc_bytes(ifc_url: IfcUrl) -> bytes: + """Return the raw bytes of the IFC file addressed by *ifc_url*. + + :param ifc_url: A parsed :class:`IfcUrl`. + :raises ImportError: If GitPython is not installed. + :raises ValueError: If ``ifc_url.path`` is unset, the repo cannot be + reached, or the file is not found at the specified ref. + """ + if not _HAS_GITPYTHON: + raise ImportError("GitPython is not installed. Install with: pip install gitpython") + if ifc_url.path is None: + raise ValueError("URL has no 'path' parameter — cannot fetch IFC file") + + if ifc_url.transport == "local": + repo = _open_local(ifc_url.repo_path) + else: + repo = _open_remote(ifc_url.git_remote_url(), ifc_url.is_mutable_ref()) + + return _read_blob(repo, ifc_url.git_ref(), ifc_url.path) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _open_local(repo_path: str) -> git.Repo: + """Open a local git repository.""" + try: + return git.Repo(repo_path, search_parent_directories=True) + except git.exc.InvalidGitRepositoryError as exc: + raise ValueError(f"Not a git repository: {repo_path!r}") from exc + except git.exc.NoSuchPathError as exc: + raise ValueError(f"Repository path does not exist: {repo_path!r}") from exc + + +def _cache_dir_for(remote_url: str) -> Path: + """Return (and create) the per-URL cache directory for *remote_url*. + + Uses the OS-appropriate cache location: + Linux ~/.cache/ifcurl/ + macOS ~/Library/Caches/ifcurl/ + Windows %LOCALAPPDATA%\\ifcurl\\Cache\\ + """ + url_hash = hashlib.sha256(remote_url.encode()).hexdigest()[:24] + cache_dir = Path(user_cache_dir("ifcurl")) / url_hash + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir + + +def _open_remote(remote_url: str, is_mutable: bool) -> git.Repo: + """Return a GitPython Repo for *remote_url*, cloning it if necessary. + + Bare clones are stored under ~/.cache/ifcurl/. For mutable refs + (branches, HEAD) the remote is fetched on every call so the cache stays + current. Immutable refs (commit hashes, tags) skip the fetch. + """ + cache_dir = _cache_dir_for(remote_url) + git_dir = cache_dir / "repo.git" + + if not git_dir.exists(): + try: + repo = git.Repo.clone_from(remote_url, str(git_dir), bare=True) + except git.exc.GitCommandError as exc: + raise ValueError(f"Failed to clone {remote_url!r}: {exc.stderr.strip()}") from exc + else: + repo = git.Repo(str(git_dir)) + if is_mutable: + try: + repo.git.fetch("origin") + except git.exc.GitCommandError: + pass # offline or no remote — use cached data + + return repo + + +def _read_blob(repo: git.Repo, git_ref: str, file_path: str) -> bytes: + """Return the raw bytes of *file_path* at *git_ref* in *repo*.""" + try: + commit = repo.commit(git_ref) + except (git.exc.BadName, git.exc.BadObject) as exc: + raise ValueError(f"Ref {git_ref!r} not found in repository") from exc + + try: + # Tree navigation: split path and traverse (handles nested directories) + obj = commit.tree + for part in file_path.strip("/").split("/"): + obj = obj[part] + except KeyError as exc: + raise ValueError(f"File {file_path!r} not found at ref {git_ref!r}") from exc + + if obj.type != "blob": + raise ValueError(f"{file_path!r} is a directory, not a file") + + return obj.data_stream.read() diff --git a/ifcurl/py.typed b/ifcurl/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/ifcurl/render.py b/ifcurl/render.py new file mode 100644 index 0000000..512194d --- /dev/null +++ b/ifcurl/render.py @@ -0,0 +1,444 @@ +# IFC URL — resolve and render ifc:// URLs +# Copyright (C) 2026 Bruno Postle +# +# 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 . + +"""Render an IFC model to a PNG using pyvista (off-screen). + +Adapted from ifcquery/render.py (Bruno Postle, 2026). Key differences: + - Camera is set from explicit URL parameters (position, direction, up, + fov/scale) rather than named views. + - Clip planes are applied per-mesh before adding to the scene. + - Three visibility modes: highlight, ghost, isolate. + - Type entities (IfcTypeProduct) are handled via a temporary model copy, + as in ifcquery. +""" + +from __future__ import annotations + +import multiprocessing +import os +import tempfile + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.guid +import ifcopenshell.util.selector + +try: + import numpy as np + import pyvista as pv + + _HAS_PYVISTA = True +except ImportError: + _HAS_PYVISTA = False + +# Highlight colour used in 'highlight' visibility mode (orange). +_HIGHLIGHT_COLOR = (255, 140, 0) + + +# --------------------------------------------------------------------------- +# Camera +# --------------------------------------------------------------------------- + + +def _apply_camera( + plotter: pv.Plotter, + camera: tuple[float, ...], + fov: float | None, + scale: float | None, +) -> None: + """Set the pyvista camera from ifc:// URL camera parameters. + + *camera* is (px, py, pz, dx, dy, dz, ux, uy, uz) in IFC world coordinates. + Exactly one of *fov* (perspective) or *scale* (orthographic) must be given. + """ + px, py, pz, dx, dy, dz, ux, uy, uz = camera + plotter.camera.position = (px, py, pz) + plotter.camera.focal_point = (px + dx, py + dy, pz + dz) + plotter.camera.up = (ux, uy, uz) + if fov is not None: + plotter.camera.parallel_projection = False + plotter.camera.view_angle = fov + else: + plotter.camera.parallel_projection = True + plotter.camera.parallel_scale = scale + + +# --------------------------------------------------------------------------- +# Geometry settings (shared with ifcquery) +# --------------------------------------------------------------------------- + + +def _build_geom_settings(model: ifcopenshell.file) -> ifcopenshell.geom.settings: + """Build geometry settings, excluding Clearance subcontexts.""" + settings = ifcopenshell.geom.settings() + settings.set("use-world-coords", True) + + clearance_ids = { + c.id() for c in model.by_type("IfcGeometricRepresentationSubContext") if c.ContextIdentifier == "Clearance" + } + if clearance_ids: + ctx_ids = [c.id() for c in model.by_type("IfcGeometricRepresentationContext") if c.id() not in clearance_ids] + if ctx_ids: + settings.set("context-ids", ctx_ids) + + return settings + + +# --------------------------------------------------------------------------- +# Shape rendering +# --------------------------------------------------------------------------- + + +def _material_color_and_opacity(mat: object) -> tuple[tuple[int, int, int], float]: + """Return (RGB tuple, opacity) from an IfcOpenShell material.""" + diffuse = np.clip(np.array(mat.diffuse.components), 0.0, 1.0) + color = tuple((diffuse * 255).astype(np.uint8)) + transparency = mat.transparency if mat.transparency == mat.transparency else 0.0 + opacity = float(np.clip(1.0 - transparency, 0.0, 1.0)) + return color, opacity # type: ignore[return-value] + + +def _add_shape( + shape: object, + plotter: pv.Plotter, + selection_ids: frozenset[int] | None, + visibility: str, + clips: list[tuple[float, ...]], +) -> None: + """Triangulate a geometry shape, apply clips, and add it to the plotter. + + Visibility modes: + highlight — non-selected shown normally, selected shown in highlight colour + ghost — selected shown normally, non-selected shown as grey + translucent + isolate — only selected elements are added (non-selected are skipped) + """ + geom = shape.geometry + verts = np.array(geom.verts, dtype=float).reshape(-1, 3) + if verts.size == 0: + return + + raw_faces = np.array(geom.faces, dtype=int) + if raw_faces.size == 0 or raw_faces.size % 3 != 0: + return + + faces = raw_faces.reshape(-1, 3) + material_ids = np.array(geom.material_ids, dtype=int) + + is_selected = selection_ids is not None and shape.product.id() in selection_ids + + if visibility == "isolate" and selection_ids is not None and not is_selected: + return + + for midx, mat in enumerate(geom.materials): + tri_mask = material_ids == midx + if not np.any(tri_mask): + continue + + sub_faces = faces[tri_mask] + faces_pv = np.hstack([np.full((sub_faces.shape[0], 1), 3, dtype=int), sub_faces]).ravel() + mesh = pv.PolyData(verts, faces_pv) + + # Apply clipping planes — spec: normal points toward the visible side + for px, py, pz, nx, ny, nz in clips: + mesh = mesh.clip(normal=(nx, ny, nz), origin=(px, py, pz), invert=False) + if mesh.n_points == 0: + break + if mesh.n_points == 0: + continue + + # Determine colour and opacity + if selection_ids is None: + color, opacity = _material_color_and_opacity(mat) + elif visibility == "highlight": + if is_selected: + color, opacity = _HIGHLIGHT_COLOR, 1.0 + else: + color, opacity = _material_color_and_opacity(mat) + elif visibility == "ghost": + if is_selected: + color, opacity = _material_color_and_opacity(mat) + else: + color, opacity = (180, 180, 180), 0.10 + else: # isolate — non-selected already returned above + color, opacity = _material_color_and_opacity(mat) + + plotter.add_mesh(mesh, color=color, opacity=opacity, show_edges=False) + + +# --------------------------------------------------------------------------- +# Iterator rendering +# --------------------------------------------------------------------------- + + +def _render_iterator( + iterator: object, + selection_ids: list[int] | None, + visibility: str, + clips: list[tuple[float, ...]], + camera: tuple[float, ...] | None, + fov: float | None, + scale: float | None, +) -> bytes: + """Drive a geometry iterator into a pyvista plotter and return PNG bytes.""" + plotter = pv.Plotter(off_screen=True, window_size=(1280, 960)) + plotter.background_color = "white" + + frozen_ids = frozenset(selection_ids) if selection_ids else None + + while True: + try: + _add_shape(iterator.get(), plotter, frozen_ids, visibility, clips) + except Exception: + pass # skip broken shapes, keep rendering the rest + if not iterator.next(): + break + + if camera is not None: + _apply_camera(plotter, camera, fov, scale) + else: + plotter.reset_camera() + plotter.camera.up = (0, 0, 1) + + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".png") + os.close(tmp_fd) + try: + plotter.show(screenshot=tmp_path, auto_close=True) + with open(tmp_path, "rb") as f: + return f.read() + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + +# --------------------------------------------------------------------------- +# Type entity handling (adapted from ifcquery) +# --------------------------------------------------------------------------- + + +def _get_occurrence_class(type_entity: object) -> str: + type_class = type_entity.is_a() + if type_class.endswith("Type"): + return type_class[:-4] + return "IfcBuildingElementProxy" + + +def _make_type_occurrence(model: ifcopenshell.file, type_entity: object) -> object | None: + rep_maps = getattr(type_entity, "RepresentationMaps", None) or [] + if not rep_maps: + return None + + mapped_items = [] + for rep_map in rep_maps: + origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) + transform = model.create_entity("IfcCartesianTransformationOperator3D", LocalOrigin=origin) + mapped_items.append(model.create_entity("IfcMappedItem", MappingSource=rep_map, MappingTarget=transform)) + + context = rep_maps[0].MappedRepresentation.ContextOfItems + shape_rep = model.create_entity( + "IfcShapeRepresentation", + ContextOfItems=context, + RepresentationIdentifier="Body", + RepresentationType="MappedRepresentation", + Items=mapped_items, + ) + prod_def = model.create_entity("IfcProductDefinitionShape", Representations=[shape_rep]) + + pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) + axis2 = model.create_entity( + "IfcAxis2Placement3D", + Location=pt, + Axis=model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)), + RefDirection=model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)), + ) + placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2) + + occ_class = _get_occurrence_class(type_entity) + try: + return model.create_entity( + occ_class, + GlobalId=ifcopenshell.guid.new(), + Name=f"_type_preview_{type_entity.id()}", + ObjectPlacement=placement, + Representation=prod_def, + ) + except Exception: + return model.create_entity( + "IfcBuildingElementProxy", + GlobalId=ifcopenshell.guid.new(), + Name=f"_type_preview_{type_entity.id()}", + ObjectPlacement=placement, + Representation=prod_def, + ) + + +def _render_with_types( + model: ifcopenshell.file, + types: list, + selector_elements: list | None, + element_ids: list[int] | None, + type_highlight_ids: set[int], + visibility: str, + clips: list[tuple[float, ...]], + camera: tuple[float, ...] | None, + fov: float | None, + scale: float | None, +) -> bytes: + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".ifc") + os.close(tmp_fd) + try: + model.write(tmp_path) + tmp = ifcopenshell.open(tmp_path) + + type_id_to_occ_id: dict[int, int] = {} + for t in types: + tmp_type = tmp.by_id(t.id()) + occ = _make_type_occurrence(tmp, tmp_type) + if occ: + type_id_to_occ_id[t.id()] = occ.id() + + if not type_id_to_occ_id: + raise ValueError("Type entities have no RepresentationMaps to render") + + include = [tmp.by_id(occ_id) for occ_id in type_id_to_occ_id.values()] + if selector_elements: + include.extend(tmp.by_id(e.id()) for e in selector_elements) + + settings = _build_geom_settings(tmp) + iterator = ifcopenshell.geom.iterator(settings, tmp, multiprocessing.cpu_count(), include=include) + if not iterator.initialize(): + raise ValueError("Type entities have no renderable geometry") + + new_highlight = None + if element_ids: + new_highlight = [] + for hid in element_ids: + if hid in type_highlight_ids: + mapped = type_id_to_occ_id.get(hid) + if mapped: + new_highlight.append(mapped) + else: + new_highlight.append(hid) + + return _render_iterator(iterator, new_highlight, visibility, clips, camera, fov, scale) + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def render( + model: ifcopenshell.file, + selector: str | None = None, + element_ids: list[int] | None = None, + camera: tuple[float, float, float, float, float, float, float, float, float] | None = None, + fov: float | None = None, + scale: float | None = None, + clips: list[tuple[float, float, float, float, float, float]] | None = None, + visibility: str = "highlight", +) -> bytes: + """Render IFC model geometry to a PNG image. + + :param model: The in-memory IFC model. + :param selector: IfcOpenShell selector to restrict which elements are + considered the "selection" for visibility purposes. When omitted the + whole model is rendered with no selection. + :param element_ids: Step IDs to treat as the selection (alternative to + *selector*; both may be combined). + :param camera: Nine floats (px,py,pz,dx,dy,dz,ux,uy,uz) defining the + camera in IFC world coordinates. When ``None`` the camera is fitted + to the scene automatically. + :param fov: Perspective field of view in degrees. Required when *camera* + is given and orthographic projection is not wanted. + :param scale: Orthographic view-to-world scale (BCF ViewToWorldScale). + Required when *camera* is given and perspective is not wanted. + :param clips: List of clipping planes, each ``(px,py,pz,nx,ny,nz)``. + The normal points toward the *visible* side. + :param visibility: One of ``'highlight'``, ``'ghost'``, or ``'isolate'``. + :return: PNG image as raw bytes. + :raises ImportError: If pyvista or numpy are not installed. + :raises ValueError: If the selector matches nothing or there is no + renderable geometry. + """ + if not _HAS_PYVISTA: + raise ImportError("pyvista and numpy are required for rendering. Install with: pip install pyvista numpy") + + clips = clips or [] + + # --- Partition selector results into types and ordinary elements --- + if selector: + matched = list(ifcopenshell.util.selector.filter_elements(model, selector)) + if not matched: + raise ValueError(f"Selector {selector!r} matched no elements") + types = [e for e in matched if e.is_a("IfcTypeProduct")] + selector_elements: list | None = [e for e in matched if not e.is_a("IfcTypeProduct")] + else: + types = [] + selector_elements = None + + # --- Collect type entities from element_ids --- + type_highlight_ids: set[int] = set() + if element_ids: + for eid in element_ids: + entity = model.by_id(eid) + if entity.is_a("IfcTypeProduct"): + type_highlight_ids.add(eid) + if eid not in {t.id() for t in types}: + types.append(entity) + + # Build the unified selection ID set used for visibility colouring. + selection_ids: list[int] | None = None + if selector_elements is not None or element_ids: + selection_ids = list(element_ids or []) + if selector_elements: + selection_ids.extend(e.id() for e in selector_elements) + + # --- Delegate to temp-copy path when any type entities are involved --- + if types: + return _render_with_types( + model, types, selector_elements, selection_ids, type_highlight_ids, + visibility, clips, camera, fov, scale, + ) + + # --- Regular element rendering --- + settings = _build_geom_settings(model) + + if selector_elements is not None: + if not selector_elements: + raise ValueError(f"Selector {selector!r} matched only type entities") + iterator = ifcopenshell.geom.iterator( + settings, model, multiprocessing.cpu_count(), include=selector_elements + ) + else: + exclude = list(model.by_type("IfcOpeningElement")) + iterator = ifcopenshell.geom.iterator( + settings, model, multiprocessing.cpu_count(), + exclude=exclude if exclude else None, + ) + + if not iterator.initialize(): + raise ValueError("No renderable geometry found") + + return _render_iterator(iterator, selection_ids, visibility, clips, camera, fov, scale) diff --git a/ifcurl/url.py b/ifcurl/url.py new file mode 100644 index 0000000..fb5c677 --- /dev/null +++ b/ifcurl/url.py @@ -0,0 +1,176 @@ +# IFC URL — resolve and render ifc:// URLs +# Copyright (C) 2026 Bruno Postle +# +# 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 . + +from __future__ import annotations + +from dataclasses import dataclass, field +from urllib.parse import parse_qs, unquote, urlparse + + +@dataclass +class IfcUrl: + """Parsed representation of an ifc:// URL. + + URL structure: ifc://[user@]host/org/repo@? + + Transport is inferred: + user@host → SSH + bare host → HTTPS + empty authority (ifc:///path) → local file + """ + + transport: str # 'ssh', 'https', or 'local' + host: str # hostname, or '' for local + user: str | None # SSH username, or None + repo_path: str # e.g. 'org/repo' (remote) or '/home/alice/project' (local) + ref: str # e.g. 'heads/main', 'tags/v1.2', 'abc123def', 'HEAD' + path: str | None # IFC file path within the repository + selector: str | None # IfcOpenShell selector expression (percent-decoded) + camera: tuple[float, float, float, float, float, float, float, float, float] | None + fov: float | None # perspective field of view in degrees + scale: float | None # orthographic view-to-world scale (BCF ViewToWorldScale) + clips: list[tuple[float, float, float, float, float, float]] = field(default_factory=list) + visibility: str = "highlight" # 'highlight', 'ghost', or 'isolate' + + @classmethod + def parse(cls, url_string: str) -> IfcUrl: + """Parse an ifc:// URL string. + + Raises ValueError for malformed URLs. + """ + parsed = urlparse(url_string) + if parsed.scheme != "ifc": + raise ValueError(f"Expected ifc:// scheme, got {parsed.scheme!r}") + + # --- Transport and authority --- + if parsed.username: + transport = "ssh" + user: str | None = parsed.username + host = parsed.hostname or "" + elif parsed.netloc: + transport = "https" + user = None + host = parsed.hostname or "" + else: + transport = "local" + user = None + host = "" + + # --- Split path into repo_path and ref at the first '@' --- + # For remote: /org/repo@heads/main → repo_path='org/repo', ref='heads/main' + # For local: /home/alice/project@HEAD → repo_path='/home/alice/project', ref='HEAD' + raw_path = parsed.path + if "@" not in raw_path: + raise ValueError(f"URL missing ref separator '@' in path: {url_string!r}") + repo_part, ref = raw_path.split("@", 1) + if not ref: + raise ValueError(f"URL has empty ref after '@': {url_string!r}") + repo_path = repo_part if transport == "local" else repo_part.lstrip("/") + + # --- Query parameters --- + qs = parse_qs(parsed.query, keep_blank_values=False) + + path = qs["path"][0] if "path" in qs else None + selector = unquote(qs["selector"][0]) if "selector" in qs else None + + camera: tuple[float, ...] | None = None + if "camera" in qs: + try: + vals = [float(v) for v in qs["camera"][0].split(",")] + except ValueError as exc: + raise ValueError(f"'camera' contains non-numeric value: {qs['camera'][0]!r}") from exc + if len(vals) != 9: + raise ValueError(f"'camera' requires exactly 9 values (px,py,pz,dx,dy,dz,ux,uy,uz), got {len(vals)}") + camera = tuple(vals) # type: ignore[assignment] + + fov: float | None = None + if "fov" in qs: + try: + fov = float(qs["fov"][0]) + except ValueError as exc: + raise ValueError(f"'fov' must be a number, got {qs['fov'][0]!r}") from exc + + scale: float | None = None + if "scale" in qs: + try: + scale = float(qs["scale"][0]) + except ValueError as exc: + raise ValueError(f"'scale' must be a number, got {qs['scale'][0]!r}") from exc + + if camera is not None and fov is None and scale is None: + raise ValueError("'camera' requires either 'fov' (perspective) or 'scale' (orthographic)") + if fov is not None and scale is not None: + raise ValueError("'fov' and 'scale' are mutually exclusive") + + clips: list[tuple[float, ...]] = [] + for clip_str in qs.get("clip", []): + try: + vals = [float(v) for v in clip_str.split(",")] + except ValueError as exc: + raise ValueError(f"'clip' contains non-numeric value: {clip_str!r}") from exc + if len(vals) != 6: + raise ValueError(f"'clip' requires exactly 6 values (px,py,pz,nx,ny,nz), got {len(vals)}") + clips.append(tuple(vals)) # type: ignore[arg-type] + + visibility_raw = qs.get("visibility", ["highlight"])[0] + if visibility_raw not in ("highlight", "ghost", "isolate"): + raise ValueError(f"Unknown visibility mode {visibility_raw!r}; expected 'highlight', 'ghost', or 'isolate'") + + return cls( + transport=transport, + host=host, + user=user, + repo_path=repo_path, + ref=ref, + path=path, + selector=selector, + camera=camera, # type: ignore[arg-type] + fov=fov, + scale=scale, + clips=clips, # type: ignore[arg-type] + visibility=visibility_raw, + ) + + def git_remote_url(self) -> str: + """Return the git remote URL for this IFC URL (SSH or HTTPS form).""" + if self.transport == "local": + return self.repo_path + if self.transport == "ssh": + return f"ssh://{self.user}@{self.host}/{self.repo_path}" + return f"https://{self.host}/{self.repo_path}" + + def git_ref(self) -> str: + """Convert the ifc:// ref to a git ref string. + + heads/main → main + tags/v1.2 → refs/tags/v1.2 + abc123 → abc123 + HEAD → HEAD + """ + if self.ref.startswith("heads/"): + return self.ref[len("heads/"):] + if self.ref.startswith("tags/"): + return f"refs/{self.ref}" + return self.ref # commit hash or HEAD + + def is_mutable_ref(self) -> bool: + """Return True if the ref may point to a different commit over time. + + Mutable refs (HEAD, branches) must not be cached at the rendered-PNG tier. + """ + return self.ref == "HEAD" or self.ref.startswith("heads/") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d018565 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,63 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ifcurl" +version = "0.0.0" +authors = [ + { name="Bruno Postle", email="bruno@postle.net" }, +] +description = "Resolve and render ifc:// URLs pointing to IFC models in git repositories" +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.10" +keywords = ["IFC", "BIM", "URL"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", +] +dependencies = ["ifcopenshell", "gitpython", "platformdirs"] + +[project.optional-dependencies] +render = ["pyvista", "numpy"] + +[project.scripts] +ifcurl = "ifcurl.__main__:main" + +[project.urls] +Homepage = "https://github.com/brunopostle/ifcurl" +Issues = "https://github.com/brunopostle/ifcurl/issues" + +[tool.setuptools.packages.find] +include = ["ifcurl*"] +exclude = ["test*"] + +[tool.setuptools.package-data] +ifcurl = ["py.typed"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +preview = true +select = [ + "E9", # io errors + "FA", # future annotations + "UP", # pyupgrade + "I", # import sorting + "RUF015", + "RUF022", +] +ignore = [ + "UP007", # Optional to X | Y + "UP045", # Optional to X | None + "UP015", # Unnecessary mode argument + "UP028", # yield for -> yield from + "UP030", # implicit references for positional format fields + "UP031", # Replace % with .format + "UP032", # Replace .format with f-string +] diff --git a/tests/test_url.py b/tests/test_url.py new file mode 100644 index 0000000..9f6ded2 --- /dev/null +++ b/tests/test_url.py @@ -0,0 +1,283 @@ +"""Tests for IfcUrl.parse().""" + +import pytest + +from ifcurl.url import IfcUrl + +# --------------------------------------------------------------------------- +# Transport inference +# --------------------------------------------------------------------------- + + +def test_https_transport(): + url = IfcUrl.parse("ifc://example.com/org/repo@heads/main?path=model.ifc") + assert url.transport == "https" + assert url.host == "example.com" + assert url.user is None + + +def test_ssh_transport(): + url = IfcUrl.parse("ifc://git@example.com/org/repo@abc123?path=model.ifc") + assert url.transport == "ssh" + assert url.host == "example.com" + assert url.user == "git" + + +def test_local_transport(): + url = IfcUrl.parse("ifc:///home/alice/project@HEAD?path=model.ifc") + assert url.transport == "local" + assert url.host == "" + assert url.user is None + + +# --------------------------------------------------------------------------- +# Repo path and ref +# --------------------------------------------------------------------------- + + +def test_repo_path_and_branch_ref(): + url = IfcUrl.parse("ifc://example.com/org/repo@heads/main?path=model.ifc") + assert url.repo_path == "org/repo" + assert url.ref == "heads/main" + + +def test_tag_ref(): + url = IfcUrl.parse("ifc://example.com/org/repo@tags/v1.2?path=model.ifc") + assert url.ref == "tags/v1.2" + + +def test_commit_hash_ref(): + url = IfcUrl.parse("ifc://example.com/org/repo@abc123def?path=model.ifc") + assert url.ref == "abc123def" + + +def test_head_ref(): + url = IfcUrl.parse("ifc:///home/alice/project@HEAD?path=model.ifc") + assert url.ref == "HEAD" + assert url.repo_path == "/home/alice/project" + + +# --------------------------------------------------------------------------- +# git_ref() conversion +# --------------------------------------------------------------------------- + + +def test_git_ref_branch(): + url = IfcUrl.parse("ifc://example.com/org/repo@heads/main?path=model.ifc") + assert url.git_ref() == "main" + + +def test_git_ref_tag(): + url = IfcUrl.parse("ifc://example.com/org/repo@tags/v2.0?path=model.ifc") + assert url.git_ref() == "refs/tags/v2.0" + + +def test_git_ref_commit(): + url = IfcUrl.parse("ifc://example.com/org/repo@abc123def?path=model.ifc") + assert url.git_ref() == "abc123def" + + +def test_git_ref_head(): + url = IfcUrl.parse("ifc:///home/alice/repo@HEAD?path=model.ifc") + assert url.git_ref() == "HEAD" + + +# --------------------------------------------------------------------------- +# Query parameters +# --------------------------------------------------------------------------- + + +def test_path_parameter(): + url = IfcUrl.parse("ifc://example.com/org/repo@heads/main?path=models/building.ifc") + assert url.path == "models/building.ifc" + + +def test_no_path(): + url = IfcUrl.parse("ifc://example.com/org/repo@heads/main") + assert url.path is None + + +def test_selector(): + url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc&selector=IfcWall") + assert url.selector == "IfcWall" + + +def test_selector_percent_encoded(): + url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc&selector=IfcWall%2C%2BName%3D%22X%22") + assert url.selector == 'IfcWall,+Name="X"' + + +def test_camera_perspective(): + url = IfcUrl.parse( + "ifc://example.com/org/repo@HEAD?path=m.ifc" + "&camera=10,20,5,0,-1,0,0,0,1&fov=60" + ) + assert url.camera == (10.0, 20.0, 5.0, 0.0, -1.0, 0.0, 0.0, 0.0, 1.0) + assert url.fov == 60.0 + assert url.scale is None + + +def test_camera_orthographic(): + url = IfcUrl.parse( + "ifc://example.com/org/repo@HEAD?path=m.ifc" + "&camera=0,0,8,0,0,-1,0,1,0&scale=50" + ) + assert url.scale == 50.0 + assert url.fov is None + + +def test_clip_single(): + url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc&clip=0,0,3,0,0,-1") + assert url.clips == [(0.0, 0.0, 3.0, 0.0, 0.0, -1.0)] + + +def test_clip_multiple(): + url = IfcUrl.parse( + "ifc://example.com/org/repo@HEAD?path=m.ifc" + "&clip=0,0,3,0,0,-1&clip=0,0,0,0,0,1" + ) + assert len(url.clips) == 2 + assert url.clips[0] == (0.0, 0.0, 3.0, 0.0, 0.0, -1.0) + assert url.clips[1] == (0.0, 0.0, 0.0, 0.0, 0.0, 1.0) + + +def test_visibility_default(): + url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc") + assert url.visibility == "highlight" + + +def test_visibility_ghost(): + url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc&visibility=ghost") + assert url.visibility == "ghost" + + +def test_visibility_isolate(): + url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc&visibility=isolate") + assert url.visibility == "isolate" + + +# --------------------------------------------------------------------------- +# git_remote_url() +# --------------------------------------------------------------------------- + + +def test_git_remote_url_https(): + url = IfcUrl.parse("ifc://example.com/org/repo@heads/main?path=m.ifc") + assert url.git_remote_url() == "https://example.com/org/repo" + + +def test_git_remote_url_ssh(): + url = IfcUrl.parse("ifc://git@example.com/org/repo@heads/main?path=m.ifc") + assert url.git_remote_url() == "ssh://git@example.com/org/repo" + + +def test_git_remote_url_local(): + url = IfcUrl.parse("ifc:///home/alice/project@HEAD?path=m.ifc") + assert url.git_remote_url() == "/home/alice/project" + + +# --------------------------------------------------------------------------- +# is_mutable_ref() +# --------------------------------------------------------------------------- + + +def test_mutable_head(): + assert IfcUrl.parse("ifc://h/o/r@HEAD?path=m.ifc").is_mutable_ref() is True + + +def test_mutable_branch(): + assert IfcUrl.parse("ifc://h/o/r@heads/main?path=m.ifc").is_mutable_ref() is True + + +def test_immutable_commit(): + assert IfcUrl.parse("ifc://h/o/r@abc123def?path=m.ifc").is_mutable_ref() is False + + +def test_immutable_tag(): + assert IfcUrl.parse("ifc://h/o/r@tags/v1.0?path=m.ifc").is_mutable_ref() is False + + +# --------------------------------------------------------------------------- +# Spec examples +# --------------------------------------------------------------------------- + + +def test_spec_example_default_view(): + url = IfcUrl.parse("ifc://example.com/org/project@heads/main?path=models/building.ifc") + assert url.transport == "https" + assert url.ref == "heads/main" + assert url.path == "models/building.ifc" + assert url.selector is None + assert url.camera is None + + +def test_spec_example_perspective(): + url = IfcUrl.parse( + 'ifc://git@example.com/org/project@abc123def' + '?path=models/building.ifc' + '&selector=IfcWall%2C%2BName%3D%22Core%2BWall%22' + '&camera=10,20,5,0,-1,0,0,0,1&fov=60&visibility=ghost' + ) + assert url.transport == "ssh" + assert url.fov == 60.0 + assert url.visibility == "ghost" + + +def test_spec_example_orthographic_with_clip(): + url = IfcUrl.parse( + "ifc://example.com/org/project@tags/v2.0" + "?path=models/building.ifc" + "&camera=0,0,8,0,0,-1,0,1,0&scale=50&clip=0,0,5,0,0,-1" + ) + assert url.scale == 50.0 + assert url.clips == [(0.0, 0.0, 5.0, 0.0, 0.0, -1.0)] + + +def test_spec_example_local(): + url = IfcUrl.parse("ifc:///home/alice/projects/office@HEAD?path=model.ifc") + assert url.transport == "local" + assert url.repo_path == "/home/alice/projects/office" + assert url.ref == "HEAD" + + +# --------------------------------------------------------------------------- +# Error cases +# --------------------------------------------------------------------------- + + +def test_wrong_scheme(): + with pytest.raises(ValueError, match="scheme"): + IfcUrl.parse("https://example.com/org/repo@HEAD") + + +def test_missing_ref(): + with pytest.raises(ValueError, match="ref separator"): + IfcUrl.parse("ifc://example.com/org/repo?path=model.ifc") + + +def test_camera_wrong_count(): + with pytest.raises(ValueError, match="9 values"): + IfcUrl.parse("ifc://example.com/o/r@HEAD?path=m.ifc&camera=1,2,3&fov=60") + + +def test_camera_without_projection(): + with pytest.raises(ValueError, match="fov.*scale"): + IfcUrl.parse("ifc://example.com/o/r@HEAD?path=m.ifc&camera=0,0,0,0,0,1,0,1,0") + + +def test_fov_and_scale_exclusive(): + with pytest.raises(ValueError, match="mutually exclusive"): + IfcUrl.parse( + "ifc://example.com/o/r@HEAD?path=m.ifc" + "&camera=0,0,0,0,0,1,0,1,0&fov=60&scale=50" + ) + + +def test_clip_wrong_count(): + with pytest.raises(ValueError, match="6 values"): + IfcUrl.parse("ifc://example.com/o/r@HEAD?path=m.ifc&clip=0,0,1") + + +def test_unknown_visibility(): + with pytest.raises(ValueError, match="visibility"): + IfcUrl.parse("ifc://example.com/o/r@HEAD?path=m.ifc&visibility=wireframe")