diff --git a/CLAUDE.md b/CLAUDE.md
index 18e8db8..3b64a59 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,11 +5,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 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
+pip install -e ".[render]" # install with rendering dependencies
+pip install -e ".[service]" # install with service dependencies (includes render)
+python -m pytest tests/ # run all tests
+python -m pytest tests/test_url.py -v
+ifcurl render "ifc://..." # render a URL to ifc-url-render.png
ifcurl render "ifc://..." -o out.png
+ifcurl serve # start preview service on 127.0.0.1:8000
+ifcurl serve --host 0.0.0.0 --port 9000
```
## Project overview
diff --git a/README.md b/README.md
index 77e952e..0aacfbc 100644
--- a/README.md
+++ b/README.md
@@ -154,6 +154,39 @@ with open("output.png", "wb") as f:
---
+## Preview service
+
+ifcurl includes an HTTP preview service for use by Gitea and other consumers.
+
+```bash
+pip install "ifcurl[service]"
+ifcurl serve # 127.0.0.1:8000
+ifcurl serve --host 0.0.0.0 --port 9000
+```
+
+### Endpoint
+
+```
+POST /preview
+Content-Type: application/json
+
+{"url": "ifc://..."}
+```
+
+Returns `image/png`.
+
+### Service caching
+
+| Tier | Key | Contents | Notes |
+|---|---|---|---|
+| 2 | commit hash + path | IFC bytes | In-memory LRU, avoids repeated git blob reads |
+| 3 | commit hash + path + selector | Resolved GlobalId set | In-memory LRU, avoids re-running selector |
+| 4 | SHA-256 of full URL | Rendered PNG | Filesystem, immutable refs only |
+
+Tier 4 is never written for mutable refs (`@heads/`, `@HEAD`) since the underlying commit may change.
+
+---
+
## 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.
diff --git a/ifcurl/__init__.py b/ifcurl/__init__.py
index 2c8902c..b96f4ce 100644
--- a/ifcurl/__init__.py
+++ b/ifcurl/__init__.py
@@ -16,8 +16,8 @@
# You should have received a copy of the GNU Lesser General Public License
# along with IFC URL. If not, see .
-from ifcurl.git import fetch_ifc_bytes
+from ifcurl.git import fetch_ifc, fetch_ifc_bytes
from ifcurl.url import IfcUrl
__version__ = "0.0.0"
-__all__ = ["IfcUrl", "fetch_ifc_bytes"]
+__all__ = ["IfcUrl", "fetch_ifc", "fetch_ifc_bytes"]
diff --git a/ifcurl/__main__.py b/ifcurl/__main__.py
index bca3795..6863232 100644
--- a/ifcurl/__main__.py
+++ b/ifcurl/__main__.py
@@ -29,25 +29,95 @@ from ifcurl import render as render_mod
from ifcurl.git import fetch_ifc_bytes
from ifcurl.url import IfcUrl
+_URL_FORMAT = """
+URL format:
+ ifc://[user@]host/org/repo@[?
+
+Transport is inferred from the URL structure:
+ ifc://host/org/repo HTTPS
+ ifc://git@host/org/repo SSH
+ ifc:///path/to/repo local file
+
+Ref forms:
+ @heads/ branch tip (e.g. @heads/main)
+ @tags/ tag (e.g. @tags/v1.0)
+ @ commit hash (e.g. @abc123def)
+ @HEAD default branch
+
+Parameters (all optional):
+ path= path to the IFC file within the repository
+ selector= IfcOpenShell selector (see docs.ifcopenshell.org)
+ camera=px,py,pz,dx,dy,dz,ux,uy,uz camera in IFC world coordinates
+ fov= perspective field of view (use with camera)
+ scale= orthographic scale (use with camera)
+ clip=px,py,pz,nx,ny,nz clipping plane, normal toward visible side (repeatable)
+ visibility=highlight|ghost|isolate default: highlight
+"""
+
+_RENDER_EXAMPLES = """
+examples:
+ ifcurl render "ifc://github.com/org/repo@heads/main?path=model.ifc"
+ ifcurl render "ifc://github.com/org/repo@heads/main?path=model.ifc&selector=IfcWall&visibility=ghost" -o walls.png
+ ifcurl render "ifc://git@example.com/org/repo@abc123?path=model.ifc&camera=10,20,5,0,-1,0,0,0,1&fov=60"
+ ifcurl render "ifc:///home/alice/project@HEAD?path=model.ifc&camera=0,0,8,0,0,-1,0,1,0&scale=50&clip=0,0,3,0,0,-1"
+"""
+
def main() -> None:
parser = argparse.ArgumentParser(
prog="ifcurl",
- description="Resolve and render ifc:// URLs pointing to IFC models in git repositories",
+ description=(
+ "Fetch and render IFC building models addressed by ifc:// URLs.\n"
+ "An ifc:// URL encodes a git source, an optional element selector,\n"
+ "and an optional camera viewpoint."
+ ),
+ epilog=_URL_FORMAT,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
)
- subparsers = parser.add_subparsers(dest="command", required=True)
+ subparsers = parser.add_subparsers(dest="command")
- render_parser = subparsers.add_parser("render", help="Render an ifc:// URL to a PNG image")
+ render_parser = subparsers.add_parser(
+ "render",
+ help="Render an ifc:// URL to a PNG image",
+ description=(
+ "Fetch an IFC model from a git repository via an ifc:// URL and render it\n"
+ "to a PNG image. The selector, camera, clipping planes, and visibility mode\n"
+ "are all taken from the URL parameters."
+ ),
+ epilog=_URL_FORMAT + _RENDER_EXAMPLES,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
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)",
)
+ serve_parser = subparsers.add_parser(
+ "serve",
+ help="Start the ifcurl preview HTTP service",
+ description=(
+ "Start an HTTP service that renders ifc:// URLs to PNG images on demand.\n"
+ "Accepts POST /preview with a JSON body {\"url\": \"ifc://...\"} and returns image/png.\n"
+ "Results are cached: mutable refs (branches, HEAD) are cached in memory;\n"
+ "immutable refs (commit hashes, tags) are also cached to disk."
+ ),
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ serve_parser.add_argument("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)")
+ serve_parser.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
+
+ # Print help when called with no arguments
+ if len(sys.argv) == 1:
+ parser.print_help()
+ sys.exit(0)
+
args = parser.parse_args()
if args.command == "render":
_cmd_render(args)
+ elif args.command == "serve":
+ _cmd_serve(args)
def _cmd_render(args: argparse.Namespace) -> None:
@@ -57,7 +127,6 @@ def _cmd_render(args: argparse.Namespace) -> None:
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:
@@ -67,7 +136,6 @@ def _cmd_render(args: argparse.Namespace) -> None:
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)
@@ -83,7 +151,6 @@ def _cmd_render(args: argparse.Namespace) -> None:
except OSError:
pass
- # Render
try:
png_bytes = render_mod.render(
model,
@@ -107,5 +174,21 @@ def _cmd_render(args: argparse.Namespace) -> None:
print(f"Saved render to {out_path}", file=sys.stderr)
+def _cmd_serve(args: argparse.Namespace) -> None:
+ try:
+ import uvicorn
+
+ from ifcurl.service import app
+ except ImportError:
+ print(
+ "Error: service dependencies are not installed.\n"
+ "Install with: pip install 'ifcurl[service]'",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+
+ uvicorn.run(app, host=args.host, port=args.port)
+
+
if __name__ == "__main__":
main()
diff --git a/ifcurl/git.py b/ifcurl/git.py
index 4c342b2..0c24926 100644
--- a/ifcurl/git.py
+++ b/ifcurl/git.py
@@ -46,8 +46,11 @@ except ImportError:
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*.
+def fetch_ifc(ifc_url: IfcUrl) -> tuple[str, bytes]:
+ """Return ``(commit_hexsha, ifc_bytes)`` for the file addressed by *ifc_url*.
+
+ The commit hexsha is the resolved, immutable identifier for the ref —
+ useful as a cache key even when the URL uses a mutable ref like a branch.
:param ifc_url: A parsed :class:`IfcUrl`.
:raises ImportError: If GitPython is not installed.
@@ -59,12 +62,20 @@ def fetch_ifc_bytes(ifc_url: IfcUrl) -> bytes:
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())
+ repo = _get_repo(ifc_url)
+ return _read_commit_blob(repo, ifc_url.git_ref(), ifc_url.path)
- return _read_blob(repo, ifc_url.git_ref(), ifc_url.path)
+
+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.
+ """
+ _, data = fetch_ifc(ifc_url)
+ return data
# ---------------------------------------------------------------------------
@@ -72,6 +83,13 @@ def fetch_ifc_bytes(ifc_url: IfcUrl) -> bytes:
# ---------------------------------------------------------------------------
+def _get_repo(ifc_url: IfcUrl) -> git.Repo:
+ """Open or fetch the repository for *ifc_url*."""
+ if ifc_url.transport == "local":
+ return _open_local(ifc_url.repo_path)
+ return _open_remote(ifc_url.git_remote_url(), ifc_url.is_mutable_ref())
+
+
def _open_local(repo_path: str) -> git.Repo:
"""Open a local git repository."""
try:
@@ -122,15 +140,14 @@ def _open_remote(remote_url: str, is_mutable: bool) -> git.Repo:
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*."""
+def _read_commit_blob(repo: git.Repo, git_ref: str, file_path: str) -> tuple[str, bytes]:
+ """Return ``(commit_hexsha, bytes)`` for *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]
@@ -140,4 +157,4 @@ def _read_blob(repo: git.Repo, git_ref: str, file_path: str) -> bytes:
if obj.type != "blob":
raise ValueError(f"{file_path!r} is a directory, not a file")
- return obj.data_stream.read()
+ return commit.hexsha, obj.data_stream.read()
diff --git a/ifcurl/service.py b/ifcurl/service.py
new file mode 100644
index 0000000..df892fa
--- /dev/null
+++ b/ifcurl/service.py
@@ -0,0 +1,268 @@
+# IFC URL — resolve and render ifc:// URLs
+# Copyright (C) 2026 Bruno Postle
+#
+# This file is part of IFC URL.
+#
+# IFC URL is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# IFC URL is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with IFC URL. If not, see .
+
+"""Preview service: POST /preview → image/png.
+
+Caching tiers
+-------------
+Tier 2 (commit_hexsha, path) → IFC bytes
+ In-memory LRU. Avoids repeated git blob reads for the same commit.
+
+Tier 3 (commit_hexsha, path, selector) → frozenset of GlobalIds
+ In-memory LRU. Avoids re-running ifcopenshell selector execution.
+ The GUID set is converted back to step IDs against the loaded model on
+ each render, so it remains valid across model reloads from the same blob.
+
+Tier 4 sha256(url) → PNG bytes
+ Filesystem. Only written for immutable refs (commit hashes, tags).
+ Mutable refs (HEAD, branches) are never cached at this tier.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import os
+import tempfile
+import threading
+from collections import OrderedDict
+from pathlib import Path
+
+import ifcopenshell
+import ifcopenshell.util.selector
+from fastapi import FastAPI, HTTPException
+from fastapi.responses import Response
+from platformdirs import user_cache_dir
+from pydantic import BaseModel
+
+from ifcurl import render as render_mod
+from ifcurl.git import fetch_ifc
+from ifcurl.url import IfcUrl
+
+app = FastAPI(
+ title="ifcurl preview service",
+ description="Renders ifc:// URLs to PNG images for embedding in Gitea and other consumers.",
+ version="0.0.0",
+)
+
+
+# ---------------------------------------------------------------------------
+# Tier 2: (commit_hexsha, path) → IFC bytes
+# ---------------------------------------------------------------------------
+
+_T2_MAX = 8
+_t2_cache: OrderedDict[tuple[str, str], bytes] = OrderedDict()
+_t2_lock = threading.Lock()
+
+
+def _t2_get(hexsha: str, path: str) -> bytes | None:
+ key = (hexsha, path)
+ with _t2_lock:
+ if key not in _t2_cache:
+ return None
+ _t2_cache.move_to_end(key)
+ return _t2_cache[key]
+
+
+def _t2_put(hexsha: str, path: str, data: bytes) -> None:
+ key = (hexsha, path)
+ with _t2_lock:
+ _t2_cache[key] = data
+ _t2_cache.move_to_end(key)
+ while len(_t2_cache) > _T2_MAX:
+ _t2_cache.popitem(last=False)
+
+
+# ---------------------------------------------------------------------------
+# Tier 3: (commit_hexsha, path, selector) → frozenset[GlobalId]
+# ---------------------------------------------------------------------------
+
+_T3_MAX = 64
+_t3_cache: OrderedDict[tuple[str, str, str], frozenset[str]] = OrderedDict()
+_t3_lock = threading.Lock()
+
+
+def _t3_get(hexsha: str, path: str, selector: str) -> frozenset[str] | None:
+ key = (hexsha, path, selector)
+ with _t3_lock:
+ if key not in _t3_cache:
+ return None
+ _t3_cache.move_to_end(key)
+ return _t3_cache[key]
+
+
+def _t3_put(hexsha: str, path: str, selector: str, guids: frozenset[str]) -> None:
+ key = (hexsha, path, selector)
+ with _t3_lock:
+ _t3_cache[key] = guids
+ _t3_cache.move_to_end(key)
+ while len(_t3_cache) > _T3_MAX:
+ _t3_cache.popitem(last=False)
+
+
+def _guids_to_step_ids(model: ifcopenshell.file, guids: frozenset[str]) -> list[int]:
+ """Resolve a set of GlobalIds to step IDs in *model*."""
+ ids = []
+ for guid in guids:
+ try:
+ entity = model.by_guid(guid)
+ ids.append(entity.id())
+ except Exception:
+ pass # entity not present in this model version
+ return ids
+
+
+# ---------------------------------------------------------------------------
+# Tier 4: sha256(url) → PNG (filesystem, immutable refs only)
+# ---------------------------------------------------------------------------
+
+def _t4_path(url: str) -> Path:
+ cache_dir = Path(user_cache_dir("ifcurl")) / "renders"
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ url_hash = hashlib.sha256(url.encode()).hexdigest()
+ return cache_dir / f"{url_hash}.png"
+
+
+def _t4_get(url: str) -> bytes | None:
+ try:
+ return _t4_path(url).read_bytes()
+ except FileNotFoundError:
+ return None
+
+
+def _t4_put(url: str, png: bytes) -> None:
+ _t4_path(url).write_bytes(png)
+
+
+# ---------------------------------------------------------------------------
+# Helper: load model from bytes via a temp file
+# ---------------------------------------------------------------------------
+
+def _load_model(ifc_bytes: bytes) -> ifcopenshell.file:
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=".ifc")
+ try:
+ os.write(tmp_fd, ifc_bytes)
+ os.close(tmp_fd)
+ return ifcopenshell.open(tmp_path)
+ except Exception as exc:
+ raise HTTPException(status_code=422, detail=f"Could not parse IFC file: {exc}") from exc
+ finally:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+
+
+# ---------------------------------------------------------------------------
+# Request / response models
+# ---------------------------------------------------------------------------
+
+class PreviewRequest(BaseModel):
+ url: str
+
+
+# ---------------------------------------------------------------------------
+# Endpoint
+# ---------------------------------------------------------------------------
+
+@app.post("/preview")
+def preview(request: PreviewRequest) -> Response:
+ """Render an ifc:// URL to a PNG image.
+
+ Returns ``image/png``. For immutable refs the result is cached on disk
+ and served directly on subsequent requests without re-rendering.
+ """
+ # --- Parse ---
+ try:
+ ifc_url = IfcUrl.parse(request.url)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+ if ifc_url.path is None:
+ raise HTTPException(status_code=400, detail="URL has no 'path' parameter")
+
+ # --- Tier 4: cached PNG for immutable refs ---
+ if not ifc_url.is_mutable_ref():
+ cached_png = _t4_get(request.url)
+ if cached_png is not None:
+ return Response(content=cached_png, media_type="image/png")
+
+ # --- Fetch IFC bytes + commit hexsha ---
+ try:
+ hexsha, ifc_bytes = fetch_ifc(ifc_url)
+ except ImportError as exc:
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+
+ # --- Tier 2: populate / use byte cache ---
+ cached_bytes = _t2_get(hexsha, ifc_url.path)
+ if cached_bytes is not None:
+ ifc_bytes = cached_bytes
+ else:
+ _t2_put(hexsha, ifc_url.path, ifc_bytes)
+
+ # --- Load model ---
+ model = _load_model(ifc_bytes)
+
+ # --- Tier 3: resolve selector via GUID cache ---
+ element_ids: list[int] | None = None
+ selector_for_render: str | None = ifc_url.selector
+
+ if ifc_url.selector:
+ cached_guids = _t3_get(hexsha, ifc_url.path, ifc_url.selector)
+ if cached_guids is not None:
+ # Convert cached GUIDs to step IDs in this model instance.
+ # Pass element_ids only (no selector) — filter_elements is skipped.
+ # Note: for 'isolate' mode this iterates all geometry rather than
+ # restricting the iterator; correctness is preserved, not optimal.
+ element_ids = _guids_to_step_ids(model, cached_guids)
+ selector_for_render = None
+ else:
+ # Run selector, cache GUIDs for future requests
+ try:
+ matched = list(ifcopenshell.util.selector.filter_elements(model, ifc_url.selector))
+ except Exception as exc:
+ raise HTTPException(status_code=422, detail=f"Invalid selector: {exc}") from exc
+ guids = frozenset(
+ e.GlobalId for e in matched if hasattr(e, "GlobalId") and e.GlobalId
+ )
+ _t3_put(hexsha, ifc_url.path, ifc_url.selector, guids)
+ # Fall through: render() will re-run filter_elements via selector_for_render
+
+ # --- Render ---
+ try:
+ png_bytes = render_mod.render(
+ model,
+ selector=selector_for_render,
+ element_ids=element_ids,
+ 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:
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+
+ # --- Tier 4: store PNG for immutable refs ---
+ if not ifc_url.is_mutable_ref():
+ _t4_put(request.url, png_bytes)
+
+ return Response(content=png_bytes, media_type="image/png")
diff --git a/pyproject.toml b/pyproject.toml
index d018565..b79f85d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,6 +21,7 @@ dependencies = ["ifcopenshell", "gitpython", "platformdirs"]
[project.optional-dependencies]
render = ["pyvista", "numpy"]
+service = ["pyvista", "numpy", "fastapi", "uvicorn[standard]"]
[project.scripts]
ifcurl = "ifcurl.__main__:main"
]