mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 02:08:13 +00:00
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:
parent
7e83c00487
commit
018ee446b5
7 changed files with 428 additions and 23 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -5,11 +5,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e ".[render]" # install with rendering dependencies
|
pip install -e ".[render]" # install with rendering dependencies
|
||||||
python -m pytest tests/ # run all tests
|
pip install -e ".[service]" # install with service dependencies (includes render)
|
||||||
python -m pytest tests/test_url.py -v # run URL parser tests
|
python -m pytest tests/ # run all tests
|
||||||
ifcurl render "ifc://..." # render a URL to ifcurl-render.png
|
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 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
|
||||||
|
|
|
||||||
33
README.md
33
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 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.
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
268
ifcurl/service.py
Normal 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")
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue