Preview service and improved CLI help

- ifcurl/service.py: FastAPI POST /preview → image/png with three
  caching tiers (bytes LRU, GUID-set LRU, PNG filesystem)
- ifcurl/git.py: add fetch_ifc() returning (hexsha, bytes); refactor
  around _get_repo() and _read_commit_blob() helpers
- ifcurl/__main__.py: add 'ifcurl serve' subcommand; no-args prints
  full help; all subcommands have descriptions and examples; URL format
  and parameter reference in every help page

Generated with the assistance of an AI coding tool.
This commit is contained in:
Bruno Postle 2026-04-15 21:46:04 +01:00
parent 7e83c00487
commit 018ee446b5
7 changed files with 428 additions and 23 deletions

View file

@ -6,10 +6,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
```bash ```bash
pip install -e ".[render]" # install with rendering dependencies 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/ # run all tests
python -m pytest tests/test_url.py -v # run URL parser tests python -m pytest tests/test_url.py -v
ifcurl render "ifc://..." # render a URL to ifcurl-render.png ifcurl render "ifc://..." # render a URL to ifc-url-render.png
ifcurl render "ifc://..." -o out.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 ## Project overview

View file

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

View file

@ -16,8 +16,8 @@
# You should have received a copy of the GNU Lesser General Public License # 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/>. # along with IFC URL. If not, see <http://www.gnu.org/licenses/>.
from ifcurl.git import fetch_ifc_bytes from ifcurl.git import fetch_ifc, fetch_ifc_bytes
from ifcurl.url import IfcUrl from ifcurl.url import IfcUrl
__version__ = "0.0.0" __version__ = "0.0.0"
__all__ = ["IfcUrl", "fetch_ifc_bytes"] __all__ = ["IfcUrl", "fetch_ifc", "fetch_ifc_bytes"]

View file

@ -29,25 +29,95 @@ from ifcurl import render as render_mod
from ifcurl.git import fetch_ifc_bytes from ifcurl.git import fetch_ifc_bytes
from ifcurl.url import IfcUrl from ifcurl.url import IfcUrl
_URL_FORMAT = """
URL format:
ifc://[user@]host/org/repo@<ref>?<parameters>
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> branch tip (e.g. @heads/main)
@tags/<name> tag (e.g. @tags/v1.0)
@<hash> commit hash (e.g. @abc123def)
@HEAD default branch
Parameters (all optional):
path=<file> path to the IFC file within the repository
selector=<expr> IfcOpenShell selector (see docs.ifcopenshell.org)
camera=px,py,pz,dx,dy,dz,ux,uy,uz camera in IFC world coordinates
fov=<degrees> perspective field of view (use with camera)
scale=<value> 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: def main() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="ifcurl", 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("url", help="The ifc:// URL to render")
render_parser.add_argument( render_parser.add_argument(
"-o", "--output", default="", metavar="FILE", "-o", "--output", default="", metavar="FILE",
help="Output PNG path (default: ifc-url-render.png)", 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() args = parser.parse_args()
if args.command == "render": if args.command == "render":
_cmd_render(args) _cmd_render(args)
elif args.command == "serve":
_cmd_serve(args)
def _cmd_render(args: argparse.Namespace) -> None: 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) print(f"Error: invalid URL — {exc}", file=sys.stderr)
sys.exit(1) sys.exit(1)
# Fetch IFC bytes from git
try: try:
ifc_bytes = fetch_ifc_bytes(ifc_url) ifc_bytes = fetch_ifc_bytes(ifc_url)
except ImportError as exc: except ImportError as exc:
@ -67,7 +136,6 @@ def _cmd_render(args: argparse.Namespace) -> None:
print(f"Error: {exc}", file=sys.stderr) print(f"Error: {exc}", file=sys.stderr)
sys.exit(1) sys.exit(1)
# Load model from bytes via a temp file
tmp_fd, tmp_ifc = tempfile.mkstemp(suffix=".ifc") tmp_fd, tmp_ifc = tempfile.mkstemp(suffix=".ifc")
try: try:
os.write(tmp_fd, ifc_bytes) os.write(tmp_fd, ifc_bytes)
@ -83,7 +151,6 @@ def _cmd_render(args: argparse.Namespace) -> None:
except OSError: except OSError:
pass pass
# Render
try: try:
png_bytes = render_mod.render( png_bytes = render_mod.render(
model, model,
@ -107,5 +174,21 @@ def _cmd_render(args: argparse.Namespace) -> None:
print(f"Saved render to {out_path}", file=sys.stderr) 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__": if __name__ == "__main__":
main() main()

View file

@ -46,8 +46,11 @@ except ImportError:
from ifcurl.url import IfcUrl from ifcurl.url import IfcUrl
def fetch_ifc_bytes(ifc_url: IfcUrl) -> bytes: def fetch_ifc(ifc_url: IfcUrl) -> tuple[str, bytes]:
"""Return the raw bytes of the IFC file addressed by *ifc_url*. """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`. :param ifc_url: A parsed :class:`IfcUrl`.
:raises ImportError: If GitPython is not installed. :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: if ifc_url.path is None:
raise ValueError("URL has no 'path' parameter — cannot fetch IFC file") raise ValueError("URL has no 'path' parameter — cannot fetch IFC file")
if ifc_url.transport == "local": repo = _get_repo(ifc_url)
repo = _open_local(ifc_url.repo_path) return _read_commit_blob(repo, ifc_url.git_ref(), ifc_url.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)
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: def _open_local(repo_path: str) -> git.Repo:
"""Open a local git repository.""" """Open a local git repository."""
try: try:
@ -122,15 +140,14 @@ def _open_remote(remote_url: str, is_mutable: bool) -> git.Repo:
return repo return repo
def _read_blob(repo: git.Repo, git_ref: str, file_path: str) -> bytes: def _read_commit_blob(repo: git.Repo, git_ref: str, file_path: str) -> tuple[str, bytes]:
"""Return the raw bytes of *file_path* at *git_ref* in *repo*.""" """Return ``(commit_hexsha, bytes)`` for *file_path* at *git_ref* in *repo*."""
try: try:
commit = repo.commit(git_ref) commit = repo.commit(git_ref)
except (git.exc.BadName, git.exc.BadObject) as exc: except (git.exc.BadName, git.exc.BadObject) as exc:
raise ValueError(f"Ref {git_ref!r} not found in repository") from exc raise ValueError(f"Ref {git_ref!r} not found in repository") from exc
try: try:
# Tree navigation: split path and traverse (handles nested directories)
obj = commit.tree obj = commit.tree
for part in file_path.strip("/").split("/"): for part in file_path.strip("/").split("/"):
obj = obj[part] obj = obj[part]
@ -140,4 +157,4 @@ def _read_blob(repo: git.Repo, git_ref: str, file_path: str) -> bytes:
if obj.type != "blob": if obj.type != "blob":
raise ValueError(f"{file_path!r} is a directory, not a file") raise ValueError(f"{file_path!r} is a directory, not a file")
return obj.data_stream.read() return commit.hexsha, obj.data_stream.read()

268
ifcurl/service.py Normal file
View file

@ -0,0 +1,268 @@
# 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/>.
"""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")

View file

@ -21,6 +21,7 @@ dependencies = ["ifcopenshell", "gitpython", "platformdirs"]
[project.optional-dependencies] [project.optional-dependencies]
render = ["pyvista", "numpy"] render = ["pyvista", "numpy"]
service = ["pyvista", "numpy", "fastapi", "uvicorn[standard]"]
[project.scripts] [project.scripts]
ifcurl = "ifcurl.__main__:main" ifcurl = "ifcurl.__main__:main"