From 018ee446b502af59b5d99b50803de6835149b0a9 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Wed, 15 Apr 2026 21:46:04 +0100 Subject: [PATCH] Preview service and improved CLI help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- CLAUDE.md | 11 +- README.md | 33 ++++++ ifcurl/__init__.py | 4 +- ifcurl/__main__.py | 95 +++++++++++++++- ifcurl/git.py | 39 +++++-- ifcurl/service.py | 268 +++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 7 files changed, 428 insertions(+), 23 deletions(-) create mode 100644 ifcurl/service.py 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"