mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 02:08:13 +00:00
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:
parent
3d2ff476d6
commit
7e83c00487
12 changed files with 1655 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.pytest_cache/
|
||||||
65
CLAUDE.md
Normal file
65
CLAUDE.md
Normal 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 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 "<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
165
LICENSE
Normal 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
176
README.md
Normal 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"
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
23
ifcurl/__init__.py
Normal 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
111
ifcurl/__main__.py
Normal 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
143
ifcurl/git.py
Normal 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
0
ifcurl/py.typed
Normal file
444
ifcurl/render.py
Normal file
444
ifcurl/render.py
Normal 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
176
ifcurl/url.py
Normal 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
63
pyproject.toml
Normal 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
283
tests/test_url.py
Normal 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")
|
||||||
Loading…
Add table
Reference in a new issue