Python core library and ifcurl CLI

- URL parser (IfcUrl) handling all ifc:// forms: SSH, HTTPS, local;
  all ref types; camera, selector, clip, visibility parameters
- Git fetcher using GitPython with OS-appropriate bare-clone cache
  (platformdirs) and fetch-on-mutable-ref behaviour
- Renderer adapted from ifcquery: explicit camera, clipping planes,
  highlight/ghost/isolate visibility modes, type entity support
- ifcurl CLI: `ifcurl render "<url>" [-o output.png]`
- LICENSE file (LGPLv3+)

Generated with the assistance of an AI coding tool.
This commit is contained in:
Bruno Postle 2026-04-15 21:15:27 +01:00
parent 3d2ff476d6
commit 7e83c00487
12 changed files with 1655 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
__pycache__/
*.pyc
*.egg-info/
dist/
build/
.pytest_cache/

65
CLAUDE.md Normal file
View file

@ -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@<ref>?<parameters>
```
Transport is inferred: `user@host` → SSH, bare `host` → HTTPS, empty authority (`ifc:///path`) → local file. Refs use git namespace form: `@heads/<branch>`, `@tags/<tag>`, `@<hash>`, `@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 36 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 "<url>"`
## 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.

165
LICENSE Normal file
View file

@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.

176
README.md Normal file
View file

@ -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@<ref>?<parameters>
```
### 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

23
ifcurl/__init__.py Normal file
View file

@ -0,0 +1,23 @@
# IFC URL — resolve and render ifc:// URLs
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# 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/>.
from ifcurl.git import fetch_ifc_bytes
from ifcurl.url import IfcUrl
__version__ = "0.0.0"
__all__ = ["IfcUrl", "fetch_ifc_bytes"]

111
ifcurl/__main__.py Normal file
View file

@ -0,0 +1,111 @@
# IFC URL — resolve and render ifc:// URLs
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# 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/>.
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()

143
ifcurl/git.py Normal file
View file

@ -0,0 +1,143 @@
# IFC URL — resolve and render ifc:// URLs
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# 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/>.
"""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/<hash>
macOS ~/Library/Caches/ifcurl/<hash>
Windows %LOCALAPPDATA%\\ifcurl\\Cache\\<hash>
"""
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/<url-hash>. 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()

0
ifcurl/py.typed Normal file
View file

444
ifcurl/render.py Normal file
View file

@ -0,0 +1,444 @@
# IFC URL — resolve and render ifc:// URLs
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# 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/>.
"""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)

176
ifcurl/url.py Normal file
View file

@ -0,0 +1,176 @@
# IFC URL — resolve and render ifc:// URLs
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# 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/>.
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@<ref>?<parameters>
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/")

63
pyproject.toml Normal file
View file

@ -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
]

283
tests/test_url.py Normal file
View file

@ -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")