ifcurl/ifcurl/__main__.py
Bruno Postle 018ee446b5 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.
2026-04-15 21:46:50 +01:00

194 lines
6.5 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)")
# 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:
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.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()