mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 10:18:14 +00:00
render_service.py: new FastAPI app with /render, /select, /render_diff endpoints that wrap run_sandboxed — listens on a Unix domain socket. service.py: imports the three pipeline functions from render_service.py; delegates to render service via httpx when IFCURL_RENDER_SOCKET is set, falls back to direct run_sandboxed when unset (single-service mode). __main__.py: add 'render-service' subcommand (--socket PATH). pyproject.toml: add httpx to service optional deps. ifcurl-api.service: renamed from ifcurl-preview.service; sets IFCURL_RENDER_SOCKET, adds AF_UNIX to RestrictAddressFamilies. ifcurl-render.service: new hardened unit — AF_UNIX only, ~execve ~execveat blocked, no credentials, separate ifcurl-render user. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
343 lines
12 KiB
Python
343 lines
12 KiB
Python
# 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
|
|
|
|
_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:
|
|
parser = argparse.ArgumentParser(
|
|
prog="ifcurl",
|
|
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")
|
|
|
|
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)"
|
|
)
|
|
serve_parser.add_argument(
|
|
"--allowed-hosts",
|
|
default="",
|
|
metavar="HOSTS",
|
|
help=(
|
|
"Comma-separated list of git hostnames (with optional :port) the service "
|
|
"is permitted to fetch from, e.g. 'github.com,gitlab.example.com:3000'. "
|
|
"When omitted, all non-private remote hosts are allowed."
|
|
),
|
|
)
|
|
|
|
render_service_parser = subparsers.add_parser(
|
|
"render-service",
|
|
help="Start the ifcurl render isolation service on a Unix socket",
|
|
description=(
|
|
"Start the render isolation service that handles all IFC parsing and\n"
|
|
"rendering. This service runs with restricted privileges (no network,\n"
|
|
"no execve) and communicates with the main ifcurl-api service over a\n"
|
|
"Unix-domain socket. Configure the API service with\n"
|
|
"IFCURL_RENDER_SOCKET pointing to the same socket path."
|
|
),
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
render_service_parser.add_argument(
|
|
"--socket",
|
|
default="/run/ifcurl/render.sock",
|
|
metavar="PATH",
|
|
help="Unix socket path to listen on (default: /run/ifcurl/render.sock)",
|
|
)
|
|
|
|
cache_parser = subparsers.add_parser(
|
|
"cache",
|
|
help="Inspect and manage the local git repository cache",
|
|
description=(
|
|
"Manage bare git repositories cached under the OS cache directory\n"
|
|
"(~/.cache/ifcurl/ on Linux). Use 'list' to see what is cached,\n"
|
|
"'prune' to enforce a size limit, or 'clear' to remove everything."
|
|
),
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
cache_sub = cache_parser.add_subparsers(dest="cache_cmd")
|
|
|
|
cache_sub.add_parser(
|
|
"list", help="Show cached repos with size and last-access time"
|
|
)
|
|
|
|
prune_parser = cache_sub.add_parser(
|
|
"prune",
|
|
help="Remove oldest repos until cache is under the size limit",
|
|
)
|
|
prune_parser.add_argument(
|
|
"--max-gb",
|
|
type=float,
|
|
default=None,
|
|
metavar="GB",
|
|
help=(
|
|
"Maximum total cache size in GB. Defaults to IFCURL_CACHE_MAX_GB "
|
|
"environment variable; required if that is not set."
|
|
),
|
|
)
|
|
|
|
cache_sub.add_parser("clear", help="Remove all cached repos")
|
|
|
|
# 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)
|
|
elif args.command == "render-service":
|
|
_cmd_render_service(args)
|
|
elif args.command == "cache":
|
|
_cmd_cache(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)
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
|
|
def _cmd_serve(args: argparse.Namespace) -> None:
|
|
try:
|
|
import uvicorn
|
|
|
|
from ifcurl import service
|
|
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)
|
|
|
|
if args.allowed_hosts:
|
|
allowed = {h.strip() for h in args.allowed_hosts.split(",") if h.strip()}
|
|
service.configure_allowed_hosts(allowed)
|
|
|
|
uvicorn.run(app, host=args.host, port=args.port)
|
|
|
|
|
|
def _cmd_render_service(args: argparse.Namespace) -> None:
|
|
try:
|
|
import uvicorn
|
|
|
|
from ifcurl.render_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, uds=args.socket)
|
|
|
|
|
|
def _cmd_cache(args: argparse.Namespace) -> None:
|
|
import datetime
|
|
import shutil as _shutil
|
|
|
|
from ifcurl.git import _evict_if_needed, _get_max_cache_bytes, _repo_cache_entries
|
|
|
|
if args.cache_cmd is None or args.cache_cmd == "list":
|
|
entries = _repo_cache_entries()
|
|
if not entries:
|
|
print("No cached repositories.")
|
|
return
|
|
total = 0
|
|
for atime, size, cache_dir, url in entries:
|
|
dt = datetime.datetime.fromtimestamp(atime).strftime("%Y-%m-%d %H:%M")
|
|
mb = size / (1024**2)
|
|
total += size
|
|
print(f" {url:<55} {mb:>7.1f} MB {dt}")
|
|
total_mb = total / (1024**2)
|
|
max_bytes = _get_max_cache_bytes()
|
|
limit_str = f" limit: {max_bytes / (1024**3):.1f} GB" if max_bytes else ""
|
|
print(f"Total: {total_mb:.1f} MB ({len(entries)} repos){limit_str}")
|
|
|
|
elif args.cache_cmd == "prune":
|
|
max_gb = args.max_gb
|
|
if max_gb is None:
|
|
if os.environ.get("IFCURL_CACHE_MAX_GB"):
|
|
max_gb = float(os.environ["IFCURL_CACHE_MAX_GB"])
|
|
else:
|
|
print(
|
|
"Error: --max-gb is required when IFCURL_CACHE_MAX_GB is not set",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
os.environ["IFCURL_CACHE_MAX_GB"] = str(max_gb)
|
|
before = sum(s for _, s, _, _ in _repo_cache_entries())
|
|
_evict_if_needed()
|
|
after = sum(s for _, s, _, _ in _repo_cache_entries())
|
|
freed = (before - after) / (1024**2)
|
|
remaining = after / (1024**2)
|
|
print(f"Freed {freed:.1f} MB — {remaining:.1f} MB remaining.")
|
|
|
|
elif args.cache_cmd == "clear":
|
|
entries = _repo_cache_entries()
|
|
if not entries:
|
|
print("Nothing to clear.")
|
|
return
|
|
total = sum(s for _, s, _, _ in entries)
|
|
for _, _, cache_dir, _ in entries:
|
|
try:
|
|
_shutil.rmtree(str(cache_dir))
|
|
except OSError as exc:
|
|
print(f"Warning: could not remove {cache_dir}: {exc}", file=sys.stderr)
|
|
print(f"Cleared {total / (1024**2):.1f} MB ({len(entries)} repos).")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|