mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 02:08:13 +00:00
style: ruff --fix + black across ifcurl/ and tests/
Import sorting, quoted type annotation removal, and black reformatting. No logic changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fef6c16946
commit
a6206a459e
18 changed files with 538 additions and 196 deletions
|
|
@ -4,7 +4,7 @@
|
||||||
{"id":"ifcurl-362","title":"Add missing server-config files: gitconfig-ifcmerge and gitattributes","description":"forgejo/README.md documents deploying two files that don't exist in the repo: forgejo/server-config/gitconfig-ifcmerge and forgejo/server-config/gitattributes. Both are required for the ifcmerge git merge driver to work in bare Forgejo repos. The README even includes 'sudo cp forgejo/server-config/gitattributes /etc/gitattributes' and 'sudo git config --system include.path .../gitconfig-ifcmerge', but the files themselves are absent. Anyone following the deployment instructions will hit a 'file not found' error. Need to create both files with the content described in the README.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:31Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:51:47Z","started_at":"2026-04-24T10:50:15Z","closed_at":"2026-04-24T10:51:47Z","close_reason":"Files already exist: forgejo/server-config/gitconfig-ifcmerge and gitattributes are both present and correct. Missed in initial review.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-362","title":"Add missing server-config files: gitconfig-ifcmerge and gitattributes","description":"forgejo/README.md documents deploying two files that don't exist in the repo: forgejo/server-config/gitconfig-ifcmerge and forgejo/server-config/gitattributes. Both are required for the ifcmerge git merge driver to work in bare Forgejo repos. The README even includes 'sudo cp forgejo/server-config/gitattributes /etc/gitattributes' and 'sudo git config --system include.path .../gitconfig-ifcmerge', but the files themselves are absent. Anyone following the deployment instructions will hit a 'file not found' error. Need to create both files with the content described in the README.","status":"closed","priority":1,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T09:04:31Z","created_by":"Bruno Postle","updated_at":"2026-04-24T10:51:47Z","started_at":"2026-04-24T10:50:15Z","closed_at":"2026-04-24T10:51:47Z","close_reason":"Files already exist: forgejo/server-config/gitconfig-ifcmerge and gitattributes are both present and correct. Missed in initial review.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-ndf","title":"gitconfig-ifcmerge: update to use ifcmerge --prioritise-local instead of swapping LOCAL/REMOTE","description":"The current gitconfig-ifcmerge uses the old technique of swapping the order of %A and %B arguments to preserve the base branch's STEP ID space:\n\n driver = ifcmerge %O %A %B %L (standard)\n driver = ifcmerge %O %B %A %L (ours variant)\n\nBonsai's current ifcgit code (tool/ifcgit.py, config_ifcmerge) uses the newer --prioritise-local flag instead:\n\n ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED\n\nThe --prioritise-local flag tells ifcmerge to preserve LOCAL's STEP ID space without needing to swap argument order. This is cleaner, more readable, and follows the canonical ifcmerge interface.\n\nThe gitconfig-ifcmerge needs updating to:\n driver = ifcmerge --prioritise-local %O %A %B %A\n\n(where %A is both LOCAL and the output file — git merge drivers write their result to %A)\n\nThe ifcmerge_ours variant (for rebase direction where base branch arrives as %B) would become:\n driver = ifcmerge --prioritise-local %O %B %A %B\n\nVerify that the --prioritise-local flag exists in the version of ifcmerge being used before merging.","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-25T06:06:59Z","created_by":"Bruno Postle","updated_at":"2026-04-25T06:41:59Z","closed_at":"2026-04-25T06:41:59Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-ndf","title":"gitconfig-ifcmerge: update to use ifcmerge --prioritise-local instead of swapping LOCAL/REMOTE","description":"The current gitconfig-ifcmerge uses the old technique of swapping the order of %A and %B arguments to preserve the base branch's STEP ID space:\n\n driver = ifcmerge %O %A %B %L (standard)\n driver = ifcmerge %O %B %A %L (ours variant)\n\nBonsai's current ifcgit code (tool/ifcgit.py, config_ifcmerge) uses the newer --prioritise-local flag instead:\n\n ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED\n\nThe --prioritise-local flag tells ifcmerge to preserve LOCAL's STEP ID space without needing to swap argument order. This is cleaner, more readable, and follows the canonical ifcmerge interface.\n\nThe gitconfig-ifcmerge needs updating to:\n driver = ifcmerge --prioritise-local %O %A %B %A\n\n(where %A is both LOCAL and the output file — git merge drivers write their result to %A)\n\nThe ifcmerge_ours variant (for rebase direction where base branch arrives as %B) would become:\n driver = ifcmerge --prioritise-local %O %B %A %B\n\nVerify that the --prioritise-local flag exists in the version of ifcmerge being used before merging.","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-25T06:06:59Z","created_by":"Bruno Postle","updated_at":"2026-04-25T06:41:59Z","closed_at":"2026-04-25T06:41:59Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-qjd","title":"render_diff: auto-fit camera to changed elements, not whole model","description":"When rendering a diff of a large building model, the auto-fit camera fits to the entire head model. A wall added to one corner of a multi-storey building would appear as a tiny green speck. The camera should instead zoom to the bounding box of just the added + modified + removed elements so the changed area fills the viewport.\n\nImplementation sketch:\n- In render_diff(), after adding all shapes to plotter1, call plotter1.reset_camera() as now, but also compute the bounds of only the diff-coloured meshes (added + modified) and re-fit to those bounds.\n- For pass 2 (removed elements), the elements are already isolated in the iterator, so reset_camera() naturally fits to them. The pass 1 camera capture then needs to consider that pass 2 might be out of that frustum.\n- A combined approach: compute the bounding box union of added + modified + removed element meshes, then set the camera to fit that box with a small margin. This is the camera used for both passes.\n- pyvista Plotter has plotter.reset_camera(bounds=(xmin,xmax,ymin,ymax,zmin,zmax)) which can fit to an explicit box.\n- The bounds can be accumulated during the _add_shape loop for diff-coloured elements.\n\nAcceptance: a single modified wall in a large building renders with the wall clearly visible and filling most of the image.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-25T06:06:39Z","created_by":"Bruno Postle","updated_at":"2026-04-25T06:57:06Z","closed_at":"2026-04-25T06:57:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-qjd","title":"render_diff: auto-fit camera to changed elements, not whole model","description":"When rendering a diff of a large building model, the auto-fit camera fits to the entire head model. A wall added to one corner of a multi-storey building would appear as a tiny green speck. The camera should instead zoom to the bounding box of just the added + modified + removed elements so the changed area fills the viewport.\n\nImplementation sketch:\n- In render_diff(), after adding all shapes to plotter1, call plotter1.reset_camera() as now, but also compute the bounds of only the diff-coloured meshes (added + modified) and re-fit to those bounds.\n- For pass 2 (removed elements), the elements are already isolated in the iterator, so reset_camera() naturally fits to them. The pass 1 camera capture then needs to consider that pass 2 might be out of that frustum.\n- A combined approach: compute the bounding box union of added + modified + removed element meshes, then set the camera to fit that box with a small margin. This is the camera used for both passes.\n- pyvista Plotter has plotter.reset_camera(bounds=(xmin,xmax,ymin,ymax,zmin,zmax)) which can fit to an explicit box.\n- The bounds can be accumulated during the _add_shape loop for diff-coloured elements.\n\nAcceptance: a single modified wall in a large building renders with the wall clearly visible and filling most of the image.","status":"closed","priority":2,"issue_type":"bug","owner":"bruno@postle.net","created_at":"2026-04-25T06:06:39Z","created_by":"Bruno Postle","updated_at":"2026-04-25T06:57:06Z","closed_at":"2026-04-25T06:57:06Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-zxs","title":"sandbox.py: add seccomp filter to block execve and socket in render subprocess","description":"The subprocess isolation in sandbox.py (ifcurl-vm5) prevents DoS but not RCE: if a crafted IFC file achieves code execution inside the render child, that child still has full access to execve and the network. A seccomp BPF filter applied at the start of _worker() would block the two most useful post-exploitation steps even under active code execution.\n\nThe filter should:\n- Block execve/execveat (cannot exec a shell or any binary)\n- Block socket() for AF_INET/AF_INET6/AF_UNIX (cannot open a reverse shell or connect to the Forgejo Unix socket)\n- Allow clone/fork (ifcopenshell's geom.iterator spawns its own worker processes)\n- Allow everything else needed by Python + ifcopenshell + pyvista\n\nThe python-seccomp library (pip install seccomp, wraps libseccomp) is the cleanest way to do this. The filter would be installed via resource.setrlimit companion code already present in sandbox._worker().\n\nThe main risk is over-filtering: if the seccomp policy blocks a syscall that ifcopenshell or pyvista needs, renders fail with EPERM. The policy needs to be validated against the full render path (including the geometry iterator subprocesses, which inherit the filter across fork).\n\nAcceptance criteria:\n- A render subprocess cannot exec /bin/sh even with arbitrary code execution (verified by replacing fn with a ctypes execve call — should get EPERM/killed, not a shell)\n- Normal renders continue to produce correct PNG output\n- The seccomp library is optional (graceful fallback if libseccomp is absent, e.g. on macOS or minimal containers)","status":"open","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-24T20:47:00Z","created_by":"Bruno Postle","updated_at":"2026-04-24T20:47:00Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-zxs","title":"sandbox.py: add seccomp filter to block execve and socket in render subprocess","description":"The subprocess isolation in sandbox.py (ifcurl-vm5) prevents DoS but not RCE: if a crafted IFC file achieves code execution inside the render child, that child still has full access to execve and the network. A seccomp BPF filter applied at the start of _worker() would block the two most useful post-exploitation steps even under active code execution.\n\nThe filter should:\n- Block execve/execveat (cannot exec a shell or any binary)\n- Block socket() for AF_INET/AF_INET6/AF_UNIX (cannot open a reverse shell or connect to the Forgejo Unix socket)\n- Allow clone/fork (ifcopenshell's geom.iterator spawns its own worker processes)\n- Allow everything else needed by Python + ifcopenshell + pyvista\n\nThe python-seccomp library (pip install seccomp, wraps libseccomp) is the cleanest way to do this. The filter would be installed via resource.setrlimit companion code already present in sandbox._worker().\n\nThe main risk is over-filtering: if the seccomp policy blocks a syscall that ifcopenshell or pyvista needs, renders fail with EPERM. The policy needs to be validated against the full render path (including the geometry iterator subprocesses, which inherit the filter across fork).\n\nAcceptance criteria:\n- A render subprocess cannot exec /bin/sh even with arbitrary code execution (verified by replacing fn with a ctypes execve call — should get EPERM/killed, not a shell)\n- Normal renders continue to produce correct PNG output\n- The seccomp library is optional (graceful fallback if libseccomp is absent, e.g. on macOS or minimal containers)","status":"closed","priority":2,"issue_type":"task","owner":"bruno@postle.net","created_at":"2026-04-24T20:47:00Z","created_by":"Bruno Postle","updated_at":"2026-04-25T07:30:44Z","closed_at":"2026-04-25T07:30:44Z","close_reason":"systemd hardening accepted as sufficient; execve cannot be blocked per-subprocess without non-standard seccomp library; residual risk acceptable for threat model","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-9cr","title":"ifc:// URLs in Forgejo issues and PR comments not documented as a collaboration tool","description":"Forgejo's markdown extension renders ifc:// URLs as clickable 3D preview images in file README views, but it works equally in issue bodies, PR descriptions, and comment threads — which is where the actual collaboration happens. This is potentially the most useful feature: a reviewer can paste an ifc:// URL in a PR comment and everyone in the thread sees the 3D view inline. This is not mentioned anywhere in README.md, forgejo/README.md, or SPECIFICATION.md. Add a 'Collaboration workflow' section to the README showing: (1) navigate to view in viewer, (2) copy URL from address bar, (3) paste as [label](ifc://...) in a Forgejo comment/issue, (4) collaborators click the preview image to open the view.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:44Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:24Z","started_at":"2026-04-24T11:14:59Z","closed_at":"2026-04-24T11:47:24Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-9cr","title":"ifc:// URLs in Forgejo issues and PR comments not documented as a collaboration tool","description":"Forgejo's markdown extension renders ifc:// URLs as clickable 3D preview images in file README views, but it works equally in issue bodies, PR descriptions, and comment threads — which is where the actual collaboration happens. This is potentially the most useful feature: a reviewer can paste an ifc:// URL in a PR comment and everyone in the thread sees the 3D view inline. This is not mentioned anywhere in README.md, forgejo/README.md, or SPECIFICATION.md. Add a 'Collaboration workflow' section to the README showing: (1) navigate to view in viewer, (2) copy URL from address bar, (3) paste as [label](ifc://...) in a Forgejo comment/issue, (4) collaborators click the preview image to open the view.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:44Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:24Z","started_at":"2026-04-24T11:14:59Z","closed_at":"2026-04-24T11:47:24Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-n00","title":"Forgejo markdown previews fail silently for private repos — no auth path from preview service","description":"The Forgejo markdown extension generates \u003cimg src='http://localhost:8000/preview?url=ifc://...'\u003e tags. The preview service fetches the IFC file server-side using GET /preview with no authentication. For private repos, this returns HTTP 404 or 403 and the preview image shows as broken. There is no mechanism for the Forgejo markdown renderer to pass the user's session token to the preview service. Options to investigate: (1) the Forgejo integration could pass a signed request token; (2) the preview service could run as the Forgejo git user (which has read access to all repos); (3) SSH-based fetch from the service using the Forgejo server's own key. The current state is a silent failure that will confuse users.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:39Z","created_by":"Bruno Postle","updated_at":"2026-04-24T12:39:46Z","started_at":"2026-04-24T11:56:30Z","closed_at":"2026-04-24T12:39:46Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-n00","title":"Forgejo markdown previews fail silently for private repos — no auth path from preview service","description":"The Forgejo markdown extension generates \u003cimg src='http://localhost:8000/preview?url=ifc://...'\u003e tags. The preview service fetches the IFC file server-side using GET /preview with no authentication. For private repos, this returns HTTP 404 or 403 and the preview image shows as broken. There is no mechanism for the Forgejo markdown renderer to pass the user's session token to the preview service. Options to investigate: (1) the Forgejo integration could pass a signed request token; (2) the preview service could run as the Forgejo git user (which has read access to all repos); (3) SSH-based fetch from the service using the Forgejo server's own key. The current state is a silent failure that will confuse users.","status":"closed","priority":2,"issue_type":"bug","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:39Z","created_by":"Bruno Postle","updated_at":"2026-04-24T12:39:46Z","started_at":"2026-04-24T11:56:30Z","closed_at":"2026-04-24T12:39:46Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"id":"ifcurl-2y6","title":"Private Forgejo repos work in viewer via session cookie (same-origin) but this is undocumented and untested","description":"When the viewer is served at https://forgejo.example.com/assets/viewer.html, toRawUrl() constructs a same-origin URL like https://forgejo.example.com/org/repo/raw/branch/main/model.ifc. The browser automatically attaches the Forgejo session cookie to this fetch, so private repos on the same Forgejo instance should be accessible to logged-in users without any extra configuration. This is a significant capability that is not documented or tested. Needs: (1) a test or note confirming this works, (2) documentation in forgejo/README.md explaining that private repos work automatically for logged-in users when the viewer is served from the same Forgejo instance, (3) clear note that external repos (other Forgejo, GitHub, GitLab) have no auth path from the browser viewer.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:26Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:25Z","started_at":"2026-04-24T11:15:00Z","closed_at":"2026-04-24T11:47:25Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"id":"ifcurl-2y6","title":"Private Forgejo repos work in viewer via session cookie (same-origin) but this is undocumented and untested","description":"When the viewer is served at https://forgejo.example.com/assets/viewer.html, toRawUrl() constructs a same-origin URL like https://forgejo.example.com/org/repo/raw/branch/main/model.ifc. The browser automatically attaches the Forgejo session cookie to this fetch, so private repos on the same Forgejo instance should be accessible to logged-in users without any extra configuration. This is a significant capability that is not documented or tested. Needs: (1) a test or note confirming this works, (2) documentation in forgejo/README.md explaining that private repos work automatically for logged-in users when the viewer is served from the same Forgejo instance, (3) clear note that external repos (other Forgejo, GitHub, GitLab) have no auth path from the browser viewer.","status":"closed","priority":2,"issue_type":"task","assignee":"Bruno Postle","owner":"bruno@postle.net","created_at":"2026-04-24T11:03:26Z","created_by":"Bruno Postle","updated_at":"2026-04-24T11:47:25Z","started_at":"2026-04-24T11:15:00Z","closed_at":"2026-04-24T11:47:25Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,10 @@ def main() -> None:
|
||||||
)
|
)
|
||||||
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)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -98,16 +101,22 @@ def main() -> None:
|
||||||
help="Start the ifcurl preview HTTP service",
|
help="Start the ifcurl preview HTTP service",
|
||||||
description=(
|
description=(
|
||||||
"Start an HTTP service that renders ifc:// URLs to PNG images on demand.\n"
|
"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"
|
'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"
|
"Results are cached: mutable refs (branches, HEAD) are cached in memory;\n"
|
||||||
"immutable refs (commit hashes, tags) are also cached to disk."
|
"immutable refs (commit hashes, tags) are also cached to disk."
|
||||||
),
|
),
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
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(
|
serve_parser.add_argument(
|
||||||
"--allowed-hosts", default="", metavar="HOSTS",
|
"--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=(
|
help=(
|
||||||
"Comma-separated list of git hostnames (with optional :port) the service "
|
"Comma-separated list of git hostnames (with optional :port) the service "
|
||||||
"is permitted to fetch from, e.g. 'github.com,gitlab.example.com:3000'. "
|
"is permitted to fetch from, e.g. 'github.com,gitlab.example.com:3000'. "
|
||||||
|
|
@ -127,14 +136,19 @@ def main() -> None:
|
||||||
)
|
)
|
||||||
cache_sub = cache_parser.add_subparsers(dest="cache_cmd")
|
cache_sub = cache_parser.add_subparsers(dest="cache_cmd")
|
||||||
|
|
||||||
cache_sub.add_parser("list", help="Show cached repos with size and last-access time")
|
cache_sub.add_parser(
|
||||||
|
"list", help="Show cached repos with size and last-access time"
|
||||||
|
)
|
||||||
|
|
||||||
prune_parser = cache_sub.add_parser(
|
prune_parser = cache_sub.add_parser(
|
||||||
"prune",
|
"prune",
|
||||||
help="Remove oldest repos until cache is under the size limit",
|
help="Remove oldest repos until cache is under the size limit",
|
||||||
)
|
)
|
||||||
prune_parser.add_argument(
|
prune_parser.add_argument(
|
||||||
"--max-gb", type=float, default=None, metavar="GB",
|
"--max-gb",
|
||||||
|
type=float,
|
||||||
|
default=None,
|
||||||
|
metavar="GB",
|
||||||
help=(
|
help=(
|
||||||
"Maximum total cache size in GB. Defaults to IFCURL_CACHE_MAX_GB "
|
"Maximum total cache size in GB. Defaults to IFCURL_CACHE_MAX_GB "
|
||||||
"environment variable; required if that is not set."
|
"environment variable; required if that is not set."
|
||||||
|
|
@ -247,10 +261,10 @@ def _cmd_cache(args: argparse.Namespace) -> None:
|
||||||
total = 0
|
total = 0
|
||||||
for atime, size, cache_dir, url in entries:
|
for atime, size, cache_dir, url in entries:
|
||||||
dt = datetime.datetime.fromtimestamp(atime).strftime("%Y-%m-%d %H:%M")
|
dt = datetime.datetime.fromtimestamp(atime).strftime("%Y-%m-%d %H:%M")
|
||||||
mb = size / (1024 ** 2)
|
mb = size / (1024**2)
|
||||||
total += size
|
total += size
|
||||||
print(f" {url:<55} {mb:>7.1f} MB {dt}")
|
print(f" {url:<55} {mb:>7.1f} MB {dt}")
|
||||||
total_mb = total / (1024 ** 2)
|
total_mb = total / (1024**2)
|
||||||
max_bytes = _get_max_cache_bytes()
|
max_bytes = _get_max_cache_bytes()
|
||||||
limit_str = f" limit: {max_bytes / (1024**3):.1f} GB" if max_bytes else ""
|
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}")
|
print(f"Total: {total_mb:.1f} MB ({len(entries)} repos){limit_str}")
|
||||||
|
|
@ -261,14 +275,17 @@ def _cmd_cache(args: argparse.Namespace) -> None:
|
||||||
if os.environ.get("IFCURL_CACHE_MAX_GB"):
|
if os.environ.get("IFCURL_CACHE_MAX_GB"):
|
||||||
max_gb = float(os.environ["IFCURL_CACHE_MAX_GB"])
|
max_gb = float(os.environ["IFCURL_CACHE_MAX_GB"])
|
||||||
else:
|
else:
|
||||||
print("Error: --max-gb is required when IFCURL_CACHE_MAX_GB is not set", file=sys.stderr)
|
print(
|
||||||
|
"Error: --max-gb is required when IFCURL_CACHE_MAX_GB is not set",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
os.environ["IFCURL_CACHE_MAX_GB"] = str(max_gb)
|
os.environ["IFCURL_CACHE_MAX_GB"] = str(max_gb)
|
||||||
before = sum(s for _, s, _, _ in _repo_cache_entries())
|
before = sum(s for _, s, _, _ in _repo_cache_entries())
|
||||||
_evict_if_needed()
|
_evict_if_needed()
|
||||||
after = sum(s for _, s, _, _ in _repo_cache_entries())
|
after = sum(s for _, s, _, _ in _repo_cache_entries())
|
||||||
freed = (before - after) / (1024 ** 2)
|
freed = (before - after) / (1024**2)
|
||||||
remaining = after / (1024 ** 2)
|
remaining = after / (1024**2)
|
||||||
print(f"Freed {freed:.1f} MB — {remaining:.1f} MB remaining.")
|
print(f"Freed {freed:.1f} MB — {remaining:.1f} MB remaining.")
|
||||||
|
|
||||||
elif args.cache_cmd == "clear":
|
elif args.cache_cmd == "clear":
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,10 @@ from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import uuid
|
import uuid
|
||||||
import zipfile
|
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
import zipfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
_VERSION_XML = b"""\
|
_VERSION_XML = b"""\
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<Version VersionId="2.1" xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/buildingSMART/BCF-XML/release_2_1/Schemas/version.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
<Version VersionId="2.1" xsi:noNamespaceSchemaLocation="https://raw.githubusercontent.com/buildingSMART/BCF-XML/release_2_1/Schemas/version.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||||
|
|
@ -93,10 +92,13 @@ def _viewpoint_xml(
|
||||||
cps = ET.SubElement(root, "ClippingPlanes")
|
cps = ET.SubElement(root, "ClippingPlanes")
|
||||||
for clip in clips:
|
for clip in clips:
|
||||||
cp = ET.SubElement(cps, "ClippingPlane")
|
cp = ET.SubElement(cps, "ClippingPlane")
|
||||||
_xyz(cp, "Location", clip[0], clip[1], clip[2])
|
_xyz(cp, "Location", clip[0], clip[1], clip[2])
|
||||||
_xyz(cp, "Direction", clip[3], clip[4], clip[5])
|
_xyz(cp, "Direction", clip[3], clip[4], clip[5])
|
||||||
|
|
||||||
return b'<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(root, encoding="unicode").encode()
|
return (
|
||||||
|
b'<?xml version="1.0" encoding="utf-8"?>\n'
|
||||||
|
+ ET.tostring(root, encoding="unicode").encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _markup_xml(
|
def _markup_xml(
|
||||||
|
|
@ -109,8 +111,9 @@ def _markup_xml(
|
||||||
description: str = "",
|
description: str = "",
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
root = ET.Element("Markup")
|
root = ET.Element("Markup")
|
||||||
topic = ET.SubElement(root, "Topic",
|
topic = ET.SubElement(
|
||||||
Guid=topic_guid, TopicType="Coordination", TopicStatus="Open")
|
root, "Topic", Guid=topic_guid, TopicType="Coordination", TopicStatus="Open"
|
||||||
|
)
|
||||||
ET.SubElement(topic, "Title").text = title or "IFC View"
|
ET.SubElement(topic, "Title").text = title or "IFC View"
|
||||||
if description:
|
if description:
|
||||||
ET.SubElement(topic, "Description").text = description
|
ET.SubElement(topic, "Description").text = description
|
||||||
|
|
@ -128,7 +131,10 @@ def _markup_xml(
|
||||||
if vp_guid:
|
if vp_guid:
|
||||||
ET.SubElement(c, "Viewpoint", Guid=vp_guid)
|
ET.SubElement(c, "Viewpoint", Guid=vp_guid)
|
||||||
|
|
||||||
return b'<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(root, encoding="unicode").encode()
|
return (
|
||||||
|
b'<?xml version="1.0" encoding="utf-8"?>\n'
|
||||||
|
+ ET.tostring(root, encoding="unicode").encode()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_bcf(
|
def build_bcf(
|
||||||
|
|
@ -171,6 +177,8 @@ def build_bcf(
|
||||||
if vp_guid is not None:
|
if vp_guid is not None:
|
||||||
zf.writestr(
|
zf.writestr(
|
||||||
f"{topic_guid}/viewpoint.bcfv",
|
f"{topic_guid}/viewpoint.bcfv",
|
||||||
_viewpoint_xml(vp_guid, camera, fov, scale, clips or [], guids, visibility),
|
_viewpoint_xml(
|
||||||
|
vp_guid, camera, fov, scale, clips or [], guids, visibility
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ def step_ids_from_diff(diff_text: str) -> DiffIds:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def expand_step_ids(model: "ifcopenshell.file", step_ids: DiffIds) -> DiffIds:
|
def expand_step_ids(model: ifcopenshell.file, step_ids: DiffIds) -> DiffIds:
|
||||||
"""Propagate changed step IDs to the IfcProduct entities that own them.
|
"""Propagate changed step IDs to the IfcProduct entities that own them.
|
||||||
|
|
||||||
:param model: The *head* (newer) model, used to walk IFC relationships.
|
:param model: The *head* (newer) model, used to walk IFC relationships.
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,9 @@ def fetch_ifc(ifc_url: IfcUrl, token: str | None = None) -> tuple[str, bytes]:
|
||||||
reached, or the file is not found at the specified ref.
|
reached, or the file is not found at the specified ref.
|
||||||
"""
|
"""
|
||||||
if not _HAS_GITPYTHON:
|
if not _HAS_GITPYTHON:
|
||||||
raise ImportError("GitPython is not installed. Install with: pip install gitpython")
|
raise ImportError(
|
||||||
|
"GitPython is not installed. Install with: pip install gitpython"
|
||||||
|
)
|
||||||
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")
|
||||||
|
|
||||||
|
|
@ -90,7 +92,9 @@ def diff_text(base_url: IfcUrl, head_url: IfcUrl, token: str | None = None) -> s
|
||||||
cannot be produced.
|
cannot be produced.
|
||||||
"""
|
"""
|
||||||
if not _HAS_GITPYTHON:
|
if not _HAS_GITPYTHON:
|
||||||
raise ImportError("GitPython is not installed. Install with: pip install gitpython")
|
raise ImportError(
|
||||||
|
"GitPython is not installed. Install with: pip install gitpython"
|
||||||
|
)
|
||||||
if base_url.path is None or head_url.path is None:
|
if base_url.path is None or head_url.path is None:
|
||||||
raise ValueError("Both URLs must have a 'path' parameter")
|
raise ValueError("Both URLs must have a 'path' parameter")
|
||||||
if base_url.path != head_url.path:
|
if base_url.path != head_url.path:
|
||||||
|
|
@ -156,7 +160,7 @@ def _get_max_cache_bytes() -> int | None:
|
||||||
if val is None:
|
if val is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return int(float(val) * 1024 ** 3)
|
return int(float(val) * 1024**3)
|
||||||
except (ValueError, OverflowError):
|
except (ValueError, OverflowError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -232,8 +236,11 @@ def _cache_dir_for(remote_url: str) -> Path:
|
||||||
|
|
||||||
def _auth_url(remote_url: str, token: str | None) -> str:
|
def _auth_url(remote_url: str, token: str | None) -> str:
|
||||||
"""Return *remote_url* with the token injected, or the original URL."""
|
"""Return *remote_url* with the token injected, or the original URL."""
|
||||||
if token and (remote_url.startswith("https://") or remote_url.startswith("http://")):
|
if token and (
|
||||||
|
remote_url.startswith("https://") or remote_url.startswith("http://")
|
||||||
|
):
|
||||||
from ifcurl.auth import inject_token
|
from ifcurl.auth import inject_token
|
||||||
|
|
||||||
return inject_token(remote_url, token)
|
return inject_token(remote_url, token)
|
||||||
return remote_url
|
return remote_url
|
||||||
|
|
||||||
|
|
@ -258,10 +265,14 @@ def _clone_bare(auth_url: str, git_dir: Path, remote_url: str) -> git.Repo:
|
||||||
return git.Repo.clone_from(fallback, str(git_dir), bare=True)
|
return git.Repo.clone_from(fallback, str(git_dir), bare=True)
|
||||||
except git.exc.GitCommandError:
|
except git.exc.GitCommandError:
|
||||||
pass # report the original error
|
pass # report the original error
|
||||||
raise ValueError(f"Failed to clone {remote_url!r}: {exc.stderr.strip()}") from exc
|
raise ValueError(
|
||||||
|
f"Failed to clone {remote_url!r}: {exc.stderr.strip()}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
def _open_remote(remote_url: str, is_mutable: bool, token: str | None = None) -> git.Repo:
|
def _open_remote(
|
||||||
|
remote_url: str, is_mutable: bool, token: str | None = None
|
||||||
|
) -> git.Repo:
|
||||||
"""Return a GitPython Repo for *remote_url*, cloning it if necessary.
|
"""Return a GitPython Repo for *remote_url*, cloning it if necessary.
|
||||||
|
|
||||||
Bare clones are stored under the OS cache dir keyed on the clean URL.
|
Bare clones are stored under the OS cache dir keyed on the clean URL.
|
||||||
|
|
@ -309,7 +320,9 @@ def _open_remote(remote_url: str, is_mutable: bool, token: str | None = None) ->
|
||||||
return repo
|
return repo
|
||||||
|
|
||||||
|
|
||||||
def _read_commit_blob(repo: git.Repo, git_ref: str, file_path: str) -> tuple[str, bytes]:
|
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*."""
|
"""Return ``(commit_hexsha, bytes)`` for *file_path* at *git_ref* in *repo*."""
|
||||||
try:
|
try:
|
||||||
commit = repo.commit(git_ref)
|
commit = repo.commit(git_ref)
|
||||||
|
|
|
||||||
162
ifcurl/render.py
162
ifcurl/render.py
|
|
@ -58,8 +58,10 @@ except (ImportError, AttributeError):
|
||||||
_HAS_PYVISTA = False
|
_HAS_PYVISTA = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PIL import Image as _PILImage
|
|
||||||
import io as _io
|
import io as _io
|
||||||
|
|
||||||
|
from PIL import Image as _PILImage
|
||||||
|
|
||||||
_HAS_PIL = True
|
_HAS_PIL = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_HAS_PIL = False
|
_HAS_PIL = False
|
||||||
|
|
@ -114,10 +116,16 @@ def _build_geom_settings(model: ifcopenshell.file) -> ifcopenshell.geom.settings
|
||||||
settings.set("use-world-coords", True)
|
settings.set("use-world-coords", True)
|
||||||
|
|
||||||
clearance_ids = {
|
clearance_ids = {
|
||||||
c.id() for c in model.by_type("IfcGeometricRepresentationSubContext") if c.ContextIdentifier == "Clearance"
|
c.id()
|
||||||
|
for c in model.by_type("IfcGeometricRepresentationSubContext")
|
||||||
|
if c.ContextIdentifier == "Clearance"
|
||||||
}
|
}
|
||||||
if clearance_ids:
|
if clearance_ids:
|
||||||
ctx_ids = [c.id() for c in model.by_type("IfcGeometricRepresentationContext") if c.id() not in clearance_ids]
|
ctx_ids = [
|
||||||
|
c.id()
|
||||||
|
for c in model.by_type("IfcGeometricRepresentationContext")
|
||||||
|
if c.id() not in clearance_ids
|
||||||
|
]
|
||||||
if ctx_ids:
|
if ctx_ids:
|
||||||
settings.set("context-ids", ctx_ids)
|
settings.set("context-ids", ctx_ids)
|
||||||
|
|
||||||
|
|
@ -173,7 +181,12 @@ def _add_shape(
|
||||||
|
|
||||||
is_selected = selection_ids is not None and shape.product.id() in selection_ids
|
is_selected = selection_ids is not None and shape.product.id() in selection_ids
|
||||||
|
|
||||||
if diff_ids is None and visibility == "isolate" and selection_ids is not None and not is_selected:
|
if (
|
||||||
|
diff_ids is None
|
||||||
|
and visibility == "isolate"
|
||||||
|
and selection_ids is not None
|
||||||
|
and not is_selected
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
for midx, mat in enumerate(geom.materials):
|
for midx, mat in enumerate(geom.materials):
|
||||||
|
|
@ -182,7 +195,9 @@ def _add_shape(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
sub_faces = faces[tri_mask]
|
sub_faces = faces[tri_mask]
|
||||||
faces_pv = np.hstack([np.full((sub_faces.shape[0], 1), 3, dtype=int), sub_faces]).ravel()
|
faces_pv = np.hstack(
|
||||||
|
[np.full((sub_faces.shape[0], 1), 3, dtype=int), sub_faces]
|
||||||
|
).ravel()
|
||||||
mesh = pv.PolyData(verts, faces_pv)
|
mesh = pv.PolyData(verts, faces_pv)
|
||||||
|
|
||||||
# Apply clipping planes — spec: normal points toward the visible side
|
# Apply clipping planes — spec: normal points toward the visible side
|
||||||
|
|
@ -260,7 +275,14 @@ def _render_iterator(
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
_add_shape(iterator.get(), plotter, frozen_ids, visibility, clips, diff_ids=diff_ids)
|
_add_shape(
|
||||||
|
iterator.get(),
|
||||||
|
plotter,
|
||||||
|
frozen_ids,
|
||||||
|
visibility,
|
||||||
|
clips,
|
||||||
|
diff_ids=diff_ids,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # skip broken shapes, keep rendering the rest
|
pass # skip broken shapes, keep rendering the rest
|
||||||
if not iterator.next():
|
if not iterator.next():
|
||||||
|
|
@ -290,7 +312,9 @@ def _get_occurrence_class(type_entity: object) -> str:
|
||||||
return "IfcBuildingElementProxy"
|
return "IfcBuildingElementProxy"
|
||||||
|
|
||||||
|
|
||||||
def _make_type_occurrence(model: ifcopenshell.file, type_entity: object) -> object | None:
|
def _make_type_occurrence(
|
||||||
|
model: ifcopenshell.file, type_entity: object
|
||||||
|
) -> object | None:
|
||||||
rep_maps = getattr(type_entity, "RepresentationMaps", None) or []
|
rep_maps = getattr(type_entity, "RepresentationMaps", None) or []
|
||||||
if not rep_maps:
|
if not rep_maps:
|
||||||
return None
|
return None
|
||||||
|
|
@ -298,8 +322,14 @@ def _make_type_occurrence(model: ifcopenshell.file, type_entity: object) -> obje
|
||||||
mapped_items = []
|
mapped_items = []
|
||||||
for rep_map in rep_maps:
|
for rep_map in rep_maps:
|
||||||
origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
|
origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
|
||||||
transform = model.create_entity("IfcCartesianTransformationOperator3D", LocalOrigin=origin)
|
transform = model.create_entity(
|
||||||
mapped_items.append(model.create_entity("IfcMappedItem", MappingSource=rep_map, MappingTarget=transform))
|
"IfcCartesianTransformationOperator3D", LocalOrigin=origin
|
||||||
|
)
|
||||||
|
mapped_items.append(
|
||||||
|
model.create_entity(
|
||||||
|
"IfcMappedItem", MappingSource=rep_map, MappingTarget=transform
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
context = rep_maps[0].MappedRepresentation.ContextOfItems
|
context = rep_maps[0].MappedRepresentation.ContextOfItems
|
||||||
shape_rep = model.create_entity(
|
shape_rep = model.create_entity(
|
||||||
|
|
@ -309,14 +339,18 @@ def _make_type_occurrence(model: ifcopenshell.file, type_entity: object) -> obje
|
||||||
RepresentationType="MappedRepresentation",
|
RepresentationType="MappedRepresentation",
|
||||||
Items=mapped_items,
|
Items=mapped_items,
|
||||||
)
|
)
|
||||||
prod_def = model.create_entity("IfcProductDefinitionShape", Representations=[shape_rep])
|
prod_def = model.create_entity(
|
||||||
|
"IfcProductDefinitionShape", Representations=[shape_rep]
|
||||||
|
)
|
||||||
|
|
||||||
pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
|
pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
|
||||||
axis2 = model.create_entity(
|
axis2 = model.create_entity(
|
||||||
"IfcAxis2Placement3D",
|
"IfcAxis2Placement3D",
|
||||||
Location=pt,
|
Location=pt,
|
||||||
Axis=model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)),
|
Axis=model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)),
|
||||||
RefDirection=model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)),
|
RefDirection=model.create_entity(
|
||||||
|
"IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2)
|
placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2)
|
||||||
|
|
||||||
|
|
@ -372,7 +406,9 @@ def _render_with_types(
|
||||||
include.extend(tmp.by_id(e.id()) for e in selector_elements)
|
include.extend(tmp.by_id(e.id()) for e in selector_elements)
|
||||||
|
|
||||||
settings = _build_geom_settings(tmp)
|
settings = _build_geom_settings(tmp)
|
||||||
iterator = ifcopenshell.geom.iterator(settings, tmp, _WORKER_COUNT, include=include)
|
iterator = ifcopenshell.geom.iterator(
|
||||||
|
settings, tmp, _WORKER_COUNT, include=include
|
||||||
|
)
|
||||||
if not iterator.initialize():
|
if not iterator.initialize():
|
||||||
raise ValueError("Type entities have no renderable geometry")
|
raise ValueError("Type entities have no renderable geometry")
|
||||||
|
|
||||||
|
|
@ -387,7 +423,9 @@ def _render_with_types(
|
||||||
else:
|
else:
|
||||||
new_highlight.append(hid)
|
new_highlight.append(hid)
|
||||||
|
|
||||||
return _render_iterator(iterator, new_highlight, visibility, clips, camera, fov, scale)
|
return _render_iterator(
|
||||||
|
iterator, new_highlight, visibility, clips, camera, fov, scale
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
os.unlink(tmp_path)
|
os.unlink(tmp_path)
|
||||||
|
|
@ -404,7 +442,9 @@ def render(
|
||||||
model: ifcopenshell.file,
|
model: ifcopenshell.file,
|
||||||
selector: str | None = None,
|
selector: str | None = None,
|
||||||
element_ids: list[int] | None = None,
|
element_ids: list[int] | None = None,
|
||||||
camera: tuple[float, float, float, float, float, float, float, float, float] | None = None,
|
camera: (
|
||||||
|
tuple[float, float, float, float, float, float, float, float, float] | None
|
||||||
|
) = None,
|
||||||
fov: float | None = None,
|
fov: float | None = None,
|
||||||
scale: float | None = None,
|
scale: float | None = None,
|
||||||
clips: list[tuple[float, float, float, float, float, float]] | None = None,
|
clips: list[tuple[float, float, float, float, float, float]] | None = None,
|
||||||
|
|
@ -434,7 +474,9 @@ def render(
|
||||||
renderable geometry.
|
renderable geometry.
|
||||||
"""
|
"""
|
||||||
if not _HAS_PYVISTA:
|
if not _HAS_PYVISTA:
|
||||||
raise ImportError("pyvista and numpy are required for rendering. Install with: pip install pyvista numpy")
|
raise ImportError(
|
||||||
|
"pyvista and numpy are required for rendering. Install with: pip install pyvista numpy"
|
||||||
|
)
|
||||||
|
|
||||||
clips = clips or []
|
clips = clips or []
|
||||||
|
|
||||||
|
|
@ -444,7 +486,9 @@ def render(
|
||||||
if not matched:
|
if not matched:
|
||||||
raise ValueError(f"Selector {selector!r} matched no elements")
|
raise ValueError(f"Selector {selector!r} matched no elements")
|
||||||
types = [e for e in matched if e.is_a("IfcTypeProduct")]
|
types = [e for e in matched if e.is_a("IfcTypeProduct")]
|
||||||
selector_elements: list | None = [e for e in matched if not e.is_a("IfcTypeProduct")]
|
selector_elements: list | None = [
|
||||||
|
e for e in matched if not e.is_a("IfcTypeProduct")
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
types = []
|
types = []
|
||||||
selector_elements = None
|
selector_elements = None
|
||||||
|
|
@ -469,8 +513,16 @@ def render(
|
||||||
# --- Delegate to temp-copy path when any type entities are involved ---
|
# --- Delegate to temp-copy path when any type entities are involved ---
|
||||||
if types:
|
if types:
|
||||||
return _render_with_types(
|
return _render_with_types(
|
||||||
model, types, selector_elements, selection_ids, type_highlight_ids,
|
model,
|
||||||
visibility, clips, camera, fov, scale,
|
types,
|
||||||
|
selector_elements,
|
||||||
|
selection_ids,
|
||||||
|
type_highlight_ids,
|
||||||
|
visibility,
|
||||||
|
clips,
|
||||||
|
camera,
|
||||||
|
fov,
|
||||||
|
scale,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Regular element rendering ---
|
# --- Regular element rendering ---
|
||||||
|
|
@ -486,14 +538,18 @@ def render(
|
||||||
else:
|
else:
|
||||||
exclude = list(model.by_type("IfcOpeningElement"))
|
exclude = list(model.by_type("IfcOpeningElement"))
|
||||||
iterator = ifcopenshell.geom.iterator(
|
iterator = ifcopenshell.geom.iterator(
|
||||||
settings, model, _WORKER_COUNT,
|
settings,
|
||||||
|
model,
|
||||||
|
_WORKER_COUNT,
|
||||||
exclude=exclude if exclude else None,
|
exclude=exclude if exclude else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not iterator.initialize():
|
if not iterator.initialize():
|
||||||
raise ValueError("No renderable geometry found")
|
raise ValueError("No renderable geometry found")
|
||||||
|
|
||||||
return _render_iterator(iterator, selection_ids, visibility, clips, camera, fov, scale)
|
return _render_iterator(
|
||||||
|
iterator, selection_ids, visibility, clips, camera, fov, scale
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _entity_bounds(model: ifcopenshell.file, entities: list) -> list[float] | None:
|
def _entity_bounds(model: ifcopenshell.file, entities: list) -> list[float] | None:
|
||||||
|
|
@ -517,9 +573,14 @@ def _entity_bounds(model: ifcopenshell.file, entities: list) -> list[float] | No
|
||||||
if not all_verts:
|
if not all_verts:
|
||||||
return None
|
return None
|
||||||
v = np.vstack(all_verts)
|
v = np.vstack(all_verts)
|
||||||
return [float(v[:, 0].min()), float(v[:, 0].max()),
|
return [
|
||||||
float(v[:, 1].min()), float(v[:, 1].max()),
|
float(v[:, 0].min()),
|
||||||
float(v[:, 2].min()), float(v[:, 2].max())]
|
float(v[:, 0].max()),
|
||||||
|
float(v[:, 1].min()),
|
||||||
|
float(v[:, 1].max()),
|
||||||
|
float(v[:, 2].min()),
|
||||||
|
float(v[:, 2].max()),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _merge_bounds(a: list[float] | None, b: list[float] | None) -> list[float] | None:
|
def _merge_bounds(a: list[float] | None, b: list[float] | None) -> list[float] | None:
|
||||||
|
|
@ -528,16 +589,23 @@ def _merge_bounds(a: list[float] | None, b: list[float] | None) -> list[float] |
|
||||||
return b
|
return b
|
||||||
if b is None:
|
if b is None:
|
||||||
return a
|
return a
|
||||||
return [min(a[0], b[0]), max(a[1], b[1]),
|
return [
|
||||||
min(a[2], b[2]), max(a[3], b[3]),
|
min(a[0], b[0]),
|
||||||
min(a[4], b[4]), max(a[5], b[5])]
|
max(a[1], b[1]),
|
||||||
|
min(a[2], b[2]),
|
||||||
|
max(a[3], b[3]),
|
||||||
|
min(a[4], b[4]),
|
||||||
|
max(a[5], b[5]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def render_diff(
|
def render_diff(
|
||||||
model_head: ifcopenshell.file,
|
model_head: ifcopenshell.file,
|
||||||
model_base: ifcopenshell.file,
|
model_base: ifcopenshell.file,
|
||||||
diff_ids: dict[str, set[int]],
|
diff_ids: dict[str, set[int]],
|
||||||
camera: tuple[float, float, float, float, float, float, float, float, float] | None = None,
|
camera: (
|
||||||
|
tuple[float, float, float, float, float, float, float, float, float] | None
|
||||||
|
) = None,
|
||||||
fov: float | None = None,
|
fov: float | None = None,
|
||||||
scale: float | None = None,
|
scale: float | None = None,
|
||||||
clips: list[tuple[float, float, float, float, float, float]] | None = None,
|
clips: list[tuple[float, float, float, float, float, float]] | None = None,
|
||||||
|
|
@ -569,9 +637,13 @@ def render_diff(
|
||||||
:raises ValueError: If the head model has no renderable geometry.
|
:raises ValueError: If the head model has no renderable geometry.
|
||||||
"""
|
"""
|
||||||
if not _HAS_PYVISTA:
|
if not _HAS_PYVISTA:
|
||||||
raise ImportError("pyvista and numpy are required for rendering. Install with: pip install pyvista numpy")
|
raise ImportError(
|
||||||
|
"pyvista and numpy are required for rendering. Install with: pip install pyvista numpy"
|
||||||
|
)
|
||||||
if not _HAS_PIL:
|
if not _HAS_PIL:
|
||||||
raise ImportError("Pillow is required for diff rendering. Install with: pip install Pillow")
|
raise ImportError(
|
||||||
|
"Pillow is required for diff rendering. Install with: pip install Pillow"
|
||||||
|
)
|
||||||
|
|
||||||
clips = clips or []
|
clips = clips or []
|
||||||
|
|
||||||
|
|
@ -595,7 +667,9 @@ def render_diff(
|
||||||
# Both are fast because they operate on small subsets.
|
# Both are fast because they operate on small subsets.
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
if camera is None:
|
if camera is None:
|
||||||
changed_head_ids = frozen_diff.get("added", frozenset()) | frozen_diff.get("modified", frozenset())
|
changed_head_ids = frozen_diff.get("added", frozenset()) | frozen_diff.get(
|
||||||
|
"modified", frozenset()
|
||||||
|
)
|
||||||
changed_head_entities = []
|
changed_head_entities = []
|
||||||
for eid in changed_head_ids:
|
for eid in changed_head_ids:
|
||||||
try:
|
try:
|
||||||
|
|
@ -615,7 +689,9 @@ def render_diff(
|
||||||
settings_head = _build_geom_settings(model_head)
|
settings_head = _build_geom_settings(model_head)
|
||||||
exclude_head = list(model_head.by_type("IfcOpeningElement"))
|
exclude_head = list(model_head.by_type("IfcOpeningElement"))
|
||||||
iterator_head = ifcopenshell.geom.iterator(
|
iterator_head = ifcopenshell.geom.iterator(
|
||||||
settings_head, model_head, _WORKER_COUNT,
|
settings_head,
|
||||||
|
model_head,
|
||||||
|
_WORKER_COUNT,
|
||||||
exclude=exclude_head if exclude_head else None,
|
exclude=exclude_head if exclude_head else None,
|
||||||
)
|
)
|
||||||
if not iterator_head.initialize():
|
if not iterator_head.initialize():
|
||||||
|
|
@ -626,7 +702,14 @@ def render_diff(
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
_add_shape(iterator_head.get(), plotter1, None, "highlight", clips, diff_ids=frozen_diff)
|
_add_shape(
|
||||||
|
iterator_head.get(),
|
||||||
|
plotter1,
|
||||||
|
None,
|
||||||
|
"highlight",
|
||||||
|
clips,
|
||||||
|
diff_ids=frozen_diff,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if not iterator_head.next():
|
if not iterator_head.next():
|
||||||
|
|
@ -671,14 +754,25 @@ def render_diff(
|
||||||
if not iterator_base.initialize():
|
if not iterator_base.initialize():
|
||||||
return pass1_png
|
return pass1_png
|
||||||
|
|
||||||
removed_diff = {"added": frozenset(), "modified": frozenset(), "removed": frozenset(removed_ids)}
|
removed_diff = {
|
||||||
|
"added": frozenset(),
|
||||||
|
"modified": frozenset(),
|
||||||
|
"removed": frozenset(removed_ids),
|
||||||
|
}
|
||||||
|
|
||||||
plotter2 = _Plotter(off_screen=True, window_size=(1280, 960))
|
plotter2 = _Plotter(off_screen=True, window_size=(1280, 960))
|
||||||
plotter2.background_color = "white"
|
plotter2.background_color = "white"
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
_add_shape(iterator_base.get(), plotter2, None, "highlight", clips, diff_ids=removed_diff)
|
_add_shape(
|
||||||
|
iterator_base.get(),
|
||||||
|
plotter2,
|
||||||
|
None,
|
||||||
|
"highlight",
|
||||||
|
clips,
|
||||||
|
diff_ids=removed_diff,
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if not iterator_base.next():
|
if not iterator_base.next():
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ import os
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import resource as _resource
|
import resource as _resource
|
||||||
|
|
||||||
_HAS_RESOURCE = True
|
_HAS_RESOURCE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_HAS_RESOURCE = False # Windows
|
_HAS_RESOURCE = False # Windows
|
||||||
|
|
@ -70,14 +71,18 @@ class SandboxTimeoutError(SandboxError):
|
||||||
"""Child process exceeded the allowed wall-clock time."""
|
"""Child process exceeded the allowed wall-clock time."""
|
||||||
|
|
||||||
|
|
||||||
def _worker(conn: multiprocessing.connection.Connection, fn, args: tuple, kwargs: dict) -> None:
|
def _worker(
|
||||||
|
conn: multiprocessing.connection.Connection, fn, args: tuple, kwargs: dict
|
||||||
|
) -> None:
|
||||||
"""Entry point for the sandboxed child process."""
|
"""Entry point for the sandboxed child process."""
|
||||||
if _HAS_RESOURCE:
|
if _HAS_RESOURCE:
|
||||||
if _SANDBOX_MEMORY_MB > 0:
|
if _SANDBOX_MEMORY_MB > 0:
|
||||||
limit = _SANDBOX_MEMORY_MB * 1024 * 1024
|
limit = _SANDBOX_MEMORY_MB * 1024 * 1024
|
||||||
_resource.setrlimit(_resource.RLIMIT_AS, (limit, limit))
|
_resource.setrlimit(_resource.RLIMIT_AS, (limit, limit))
|
||||||
if _SANDBOX_CPU_SECS > 0:
|
if _SANDBOX_CPU_SECS > 0:
|
||||||
_resource.setrlimit(_resource.RLIMIT_CPU, (_SANDBOX_CPU_SECS, _SANDBOX_CPU_SECS))
|
_resource.setrlimit(
|
||||||
|
_resource.RLIMIT_CPU, (_SANDBOX_CPU_SECS, _SANDBOX_CPU_SECS)
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = fn(*args, **kwargs)
|
result = fn(*args, **kwargs)
|
||||||
|
|
@ -123,8 +128,14 @@ def run_sandboxed(fn, *args, timeout: int = _SANDBOX_TIMEOUT, **kwargs):
|
||||||
except EOFError:
|
except EOFError:
|
||||||
parent_conn.close()
|
parent_conn.close()
|
||||||
proc.join()
|
proc.join()
|
||||||
sig = -proc.exitcode if (proc.exitcode is not None and proc.exitcode < 0) else proc.exitcode
|
sig = (
|
||||||
raise SandboxCrashError(f"Subprocess terminated without sending result (exit {sig})")
|
-proc.exitcode
|
||||||
|
if (proc.exitcode is not None and proc.exitcode < 0)
|
||||||
|
else proc.exitcode
|
||||||
|
)
|
||||||
|
raise SandboxCrashError(
|
||||||
|
f"Subprocess terminated without sending result (exit {sig})"
|
||||||
|
)
|
||||||
|
|
||||||
parent_conn.close()
|
parent_conn.close()
|
||||||
proc.join()
|
proc.join()
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ import ipaddress
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -60,6 +59,7 @@ from ifcurl.url import IfcUrl
|
||||||
# Subprocess pipeline functions (run inside run_sandboxed — no service imports)
|
# Subprocess pipeline functions (run inside run_sandboxed — no service imports)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _sandboxed_pipeline(
|
def _sandboxed_pipeline(
|
||||||
ifc_bytes: bytes,
|
ifc_bytes: bytes,
|
||||||
selector: str | None,
|
selector: str | None,
|
||||||
|
|
@ -77,6 +77,7 @@ def _sandboxed_pipeline(
|
||||||
"""
|
"""
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
import ifcopenshell.util.selector
|
import ifcopenshell.util.selector
|
||||||
|
|
||||||
from ifcurl import render as render_mod
|
from ifcurl import render as render_mod
|
||||||
|
|
||||||
model = ifcopenshell.file.from_string(ifc_bytes.decode())
|
model = ifcopenshell.file.from_string(ifc_bytes.decode())
|
||||||
|
|
@ -134,6 +135,7 @@ def _sandboxed_diff(
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Parse both models, expand diff IDs, and render the two-pass diff image."""
|
"""Parse both models, expand diff IDs, and render the two-pass diff image."""
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
|
|
||||||
from ifcurl import render as render_mod
|
from ifcurl import render as render_mod
|
||||||
from ifcurl.diff import expand_step_ids, step_ids_from_diff
|
from ifcurl.diff import expand_step_ids, step_ids_from_diff
|
||||||
|
|
||||||
|
|
@ -182,7 +184,12 @@ def _is_private_ip(host: str) -> bool:
|
||||||
bare = host.split(":")[0].strip("[]") # strip port and IPv6 brackets
|
bare = host.split(":")[0].strip("[]") # strip port and IPv6 brackets
|
||||||
try:
|
try:
|
||||||
addr = ipaddress.ip_address(bare)
|
addr = ipaddress.ip_address(bare)
|
||||||
return addr.is_loopback or addr.is_link_local or addr.is_private or addr.is_reserved
|
return (
|
||||||
|
addr.is_loopback
|
||||||
|
or addr.is_link_local
|
||||||
|
or addr.is_private
|
||||||
|
or addr.is_reserved
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -190,12 +197,21 @@ def _is_private_ip(host: str) -> bool:
|
||||||
def _ssrf_check(ifc_url: IfcUrl) -> None:
|
def _ssrf_check(ifc_url: IfcUrl) -> None:
|
||||||
"""Raise HTTPException if the URL fails SSRF protection checks."""
|
"""Raise HTTPException if the URL fails SSRF protection checks."""
|
||||||
if ifc_url.transport == "local":
|
if ifc_url.transport == "local":
|
||||||
raise HTTPException(status_code=403, detail="Local file transport is not permitted in service mode")
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Local file transport is not permitted in service mode",
|
||||||
|
)
|
||||||
if _allowed_hosts is not None:
|
if _allowed_hosts is not None:
|
||||||
if ifc_url.host not in _allowed_hosts:
|
if ifc_url.host not in _allowed_hosts:
|
||||||
raise HTTPException(status_code=403, detail=f"Host {ifc_url.host!r} is not in the allowed-hosts list")
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Host {ifc_url.host!r} is not in the allowed-hosts list",
|
||||||
|
)
|
||||||
elif _is_private_ip(ifc_url.host):
|
elif _is_private_ip(ifc_url.host):
|
||||||
raise HTTPException(status_code=403, detail="Requests to private/loopback addresses are not permitted")
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Requests to private/loopback addresses are not permitted",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -256,6 +272,7 @@ def _t3_put(hexsha: str, path: str, selector: str, guids: frozenset[str]) -> Non
|
||||||
# Tier 4: sha256(url) → PNG (filesystem, immutable refs only, no expiry)
|
# Tier 4: sha256(url) → PNG (filesystem, immutable refs only, no expiry)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _t4_path(url: str) -> Path:
|
def _t4_path(url: str) -> Path:
|
||||||
cache_dir = Path(user_cache_dir("ifcurl")) / "renders" / "immutable"
|
cache_dir = Path(user_cache_dir("ifcurl")) / "renders" / "immutable"
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -308,6 +325,7 @@ def _t4m_put(url: str, png: bytes) -> None:
|
||||||
# Request / response models
|
# Request / response models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class PreviewRequest(BaseModel):
|
class PreviewRequest(BaseModel):
|
||||||
url: str
|
url: str
|
||||||
token: str | None = None
|
token: str | None = None
|
||||||
|
|
@ -437,7 +455,9 @@ def preview(request: PreviewRequest) -> Response:
|
||||||
ifc_url.visibility,
|
ifc_url.visibility,
|
||||||
)
|
)
|
||||||
except SandboxCrashError as exc:
|
except SandboxCrashError as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"IFC parse/render crashed: {exc}") from exc
|
raise HTTPException(
|
||||||
|
status_code=422, detail=f"IFC parse/render crashed: {exc}"
|
||||||
|
) from exc
|
||||||
except SandboxTimeoutError as exc:
|
except SandboxTimeoutError as exc:
|
||||||
raise HTTPException(status_code=503, detail=f"Render timed out: {exc}") from exc
|
raise HTTPException(status_code=503, detail=f"Render timed out: {exc}") from exc
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
|
|
@ -503,11 +523,17 @@ def bcf_export(request: BcfRequest) -> Response:
|
||||||
try:
|
try:
|
||||||
guids = run_sandboxed(_sandboxed_select, ifc_bytes, ifc_url.selector)
|
guids = run_sandboxed(_sandboxed_select, ifc_bytes, ifc_url.selector)
|
||||||
except SandboxCrashError as exc:
|
except SandboxCrashError as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"IFC parse/select crashed: {exc}") from exc
|
raise HTTPException(
|
||||||
|
status_code=422, detail=f"IFC parse/select crashed: {exc}"
|
||||||
|
) from exc
|
||||||
except SandboxTimeoutError as exc:
|
except SandboxTimeoutError as exc:
|
||||||
raise HTTPException(status_code=503, detail=f"Select timed out: {exc}") from exc
|
raise HTTPException(
|
||||||
|
status_code=503, detail=f"Select timed out: {exc}"
|
||||||
|
) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"Invalid selector: {exc}") from exc
|
raise HTTPException(
|
||||||
|
status_code=422, detail=f"Invalid selector: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
bcf_bytes = build_bcf(
|
bcf_bytes = build_bcf(
|
||||||
camera=ifc_url.camera,
|
camera=ifc_url.camera,
|
||||||
|
|
@ -555,7 +581,9 @@ def render_diff(request: DiffRequest) -> Response:
|
||||||
|
|
||||||
for label, ifc_url in (("base", base_url), ("head", head_url)):
|
for label, ifc_url in (("base", base_url), ("head", head_url)):
|
||||||
if ifc_url.path is None:
|
if ifc_url.path is None:
|
||||||
raise HTTPException(status_code=400, detail=f"{label} URL has no 'path' parameter")
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"{label} URL has no 'path' parameter"
|
||||||
|
)
|
||||||
|
|
||||||
if base_url.path != head_url.path:
|
if base_url.path != head_url.path:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -626,9 +654,13 @@ def render_diff(request: DiffRequest) -> Response:
|
||||||
head_url.clips or [],
|
head_url.clips or [],
|
||||||
)
|
)
|
||||||
except SandboxCrashError as exc:
|
except SandboxCrashError as exc:
|
||||||
raise HTTPException(status_code=422, detail=f"IFC diff render crashed: {exc}") from exc
|
raise HTTPException(
|
||||||
|
status_code=422, detail=f"IFC diff render crashed: {exc}"
|
||||||
|
) from exc
|
||||||
except SandboxTimeoutError as exc:
|
except SandboxTimeoutError as exc:
|
||||||
raise HTTPException(status_code=503, detail=f"Diff render timed out: {exc}") from exc
|
raise HTTPException(
|
||||||
|
status_code=503, detail=f"Diff render timed out: {exc}"
|
||||||
|
) from exc
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,9 @@ class IfcUrl:
|
||||||
camera: tuple[float, float, float, float, float, float, float, float, float] | None
|
camera: tuple[float, float, float, float, float, float, float, float, float] | None
|
||||||
fov: float | None # perspective field of view in degrees
|
fov: float | None # perspective field of view in degrees
|
||||||
scale: float | None # orthographic view-to-world scale (BCF ViewToWorldScale)
|
scale: float | None # orthographic view-to-world scale (BCF ViewToWorldScale)
|
||||||
clips: list[tuple[float, float, float, float, float, float]] = field(default_factory=list)
|
clips: list[tuple[float, float, float, float, float, float]] = field(
|
||||||
|
default_factory=list
|
||||||
|
)
|
||||||
visibility: str = "highlight" # 'highlight', 'ghost', or 'isolate'
|
visibility: str = "highlight" # 'highlight', 'ghost', or 'isolate'
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -99,9 +101,13 @@ class IfcUrl:
|
||||||
try:
|
try:
|
||||||
vals = [float(v) for v in qs["camera"][0].split(",")]
|
vals = [float(v) for v in qs["camera"][0].split(",")]
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(f"'camera' contains non-numeric value: {qs['camera'][0]!r}") from exc
|
raise ValueError(
|
||||||
|
f"'camera' contains non-numeric value: {qs['camera'][0]!r}"
|
||||||
|
) from exc
|
||||||
if len(vals) != 9:
|
if len(vals) != 9:
|
||||||
raise ValueError(f"'camera' requires exactly 9 values (px,py,pz,dx,dy,dz,ux,uy,uz), got {len(vals)}")
|
raise ValueError(
|
||||||
|
f"'camera' requires exactly 9 values (px,py,pz,dx,dy,dz,ux,uy,uz), got {len(vals)}"
|
||||||
|
)
|
||||||
camera = tuple(vals) # type: ignore[assignment]
|
camera = tuple(vals) # type: ignore[assignment]
|
||||||
|
|
||||||
fov: float | None = None
|
fov: float | None = None
|
||||||
|
|
@ -109,17 +115,23 @@ class IfcUrl:
|
||||||
try:
|
try:
|
||||||
fov = float(qs["fov"][0])
|
fov = float(qs["fov"][0])
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(f"'fov' must be a number, got {qs['fov'][0]!r}") from exc
|
raise ValueError(
|
||||||
|
f"'fov' must be a number, got {qs['fov'][0]!r}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
scale: float | None = None
|
scale: float | None = None
|
||||||
if "scale" in qs:
|
if "scale" in qs:
|
||||||
try:
|
try:
|
||||||
scale = float(qs["scale"][0])
|
scale = float(qs["scale"][0])
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(f"'scale' must be a number, got {qs['scale'][0]!r}") from exc
|
raise ValueError(
|
||||||
|
f"'scale' must be a number, got {qs['scale'][0]!r}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
if camera is not None and fov is None and scale is None:
|
if camera is not None and fov is None and scale is None:
|
||||||
raise ValueError("'camera' requires either 'fov' (perspective) or 'scale' (orthographic)")
|
raise ValueError(
|
||||||
|
"'camera' requires either 'fov' (perspective) or 'scale' (orthographic)"
|
||||||
|
)
|
||||||
if fov is not None and scale is not None:
|
if fov is not None and scale is not None:
|
||||||
raise ValueError("'fov' and 'scale' are mutually exclusive")
|
raise ValueError("'fov' and 'scale' are mutually exclusive")
|
||||||
|
|
||||||
|
|
@ -128,14 +140,20 @@ class IfcUrl:
|
||||||
try:
|
try:
|
||||||
vals = [float(v) for v in clip_str.split(",")]
|
vals = [float(v) for v in clip_str.split(",")]
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(f"'clip' contains non-numeric value: {clip_str!r}") from exc
|
raise ValueError(
|
||||||
|
f"'clip' contains non-numeric value: {clip_str!r}"
|
||||||
|
) from exc
|
||||||
if len(vals) != 6:
|
if len(vals) != 6:
|
||||||
raise ValueError(f"'clip' requires exactly 6 values (px,py,pz,nx,ny,nz), got {len(vals)}")
|
raise ValueError(
|
||||||
|
f"'clip' requires exactly 6 values (px,py,pz,nx,ny,nz), got {len(vals)}"
|
||||||
|
)
|
||||||
clips.append(tuple(vals)) # type: ignore[arg-type]
|
clips.append(tuple(vals)) # type: ignore[arg-type]
|
||||||
|
|
||||||
visibility_raw = qs.get("visibility", ["highlight"])[0]
|
visibility_raw = qs.get("visibility", ["highlight"])[0]
|
||||||
if visibility_raw not in ("highlight", "ghost", "isolate"):
|
if visibility_raw not in ("highlight", "ghost", "isolate"):
|
||||||
raise ValueError(f"Unknown visibility mode {visibility_raw!r}; expected 'highlight', 'ghost', or 'isolate'")
|
raise ValueError(
|
||||||
|
f"Unknown visibility mode {visibility_raw!r}; expected 'highlight', 'ghost', or 'isolate'"
|
||||||
|
)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
transport=transport,
|
transport=transport,
|
||||||
|
|
@ -176,7 +194,7 @@ class IfcUrl:
|
||||||
HEAD → HEAD
|
HEAD → HEAD
|
||||||
"""
|
"""
|
||||||
if self.ref.startswith("heads/"):
|
if self.ref.startswith("heads/"):
|
||||||
return self.ref[len("heads/"):]
|
return self.ref[len("heads/") :]
|
||||||
if self.ref.startswith("tags/"):
|
if self.ref.startswith("tags/"):
|
||||||
return f"refs/{self.ref}"
|
return f"refs/{self.ref}"
|
||||||
return self.ref # commit hash or HEAD
|
return self.ref # commit hash or HEAD
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,12 @@ import pytest
|
||||||
|
|
||||||
|
|
||||||
def _suppress_owner(f):
|
def _suppress_owner(f):
|
||||||
ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0]
|
ifcopenshell.api.owner.settings.get_user = lambda ifc: (
|
||||||
ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0]
|
ifc.by_type("IfcPersonAndOrganization") or [None]
|
||||||
|
)[0]
|
||||||
|
ifcopenshell.api.owner.settings.get_application = lambda ifc: (
|
||||||
|
ifc.by_type("IfcApplication") or [None]
|
||||||
|
)[0]
|
||||||
return f
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -30,32 +34,59 @@ def model_with_geometry():
|
||||||
f = ifcopenshell.api.project.create_file()
|
f = ifcopenshell.api.project.create_file()
|
||||||
_suppress_owner(f)
|
_suppress_owner(f)
|
||||||
|
|
||||||
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
|
project = ifcopenshell.api.root.create_entity(
|
||||||
|
f, ifc_class="IfcProject", name="TestProject"
|
||||||
|
)
|
||||||
ifcopenshell.api.unit.assign_unit(f)
|
ifcopenshell.api.unit.assign_unit(f)
|
||||||
|
|
||||||
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
|
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
|
||||||
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
|
building = ifcopenshell.api.root.create_entity(
|
||||||
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
|
f, ifc_class="IfcBuilding", name="TestBuilding"
|
||||||
|
)
|
||||||
|
storey = ifcopenshell.api.root.create_entity(
|
||||||
|
f, ifc_class="IfcBuildingStorey", name="Ground Floor"
|
||||||
|
)
|
||||||
|
|
||||||
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
|
ifcopenshell.api.aggregate.assign_object(
|
||||||
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
|
f, products=[site], relating_object=project
|
||||||
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
|
)
|
||||||
|
ifcopenshell.api.aggregate.assign_object(
|
||||||
|
f, products=[building], relating_object=site
|
||||||
|
)
|
||||||
|
ifcopenshell.api.aggregate.assign_object(
|
||||||
|
f, products=[storey], relating_object=building
|
||||||
|
)
|
||||||
|
|
||||||
model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
|
model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model")
|
||||||
body = ifcopenshell.api.context.add_context(
|
body = ifcopenshell.api.context.add_context(
|
||||||
f, context_type="Model", context_identifier="Body",
|
f,
|
||||||
target_view="MODEL_VIEW", parent=model_ctx,
|
context_type="Model",
|
||||||
|
context_identifier="Body",
|
||||||
|
target_view="MODEL_VIEW",
|
||||||
|
parent=model_ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
|
wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
|
||||||
rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2)
|
rep1 = ifcopenshell.api.geometry.add_wall_representation(
|
||||||
ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1)
|
f, context=body, length=5, height=3, thickness=0.2
|
||||||
ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey)
|
)
|
||||||
|
ifcopenshell.api.geometry.assign_representation(
|
||||||
|
f, product=wall1, representation=rep1
|
||||||
|
)
|
||||||
|
ifcopenshell.api.spatial.assign_container(
|
||||||
|
f, products=[wall1], relating_structure=storey
|
||||||
|
)
|
||||||
|
|
||||||
wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall002")
|
wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall002")
|
||||||
rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=4, height=3, thickness=0.2)
|
rep2 = ifcopenshell.api.geometry.add_wall_representation(
|
||||||
ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2)
|
f, context=body, length=4, height=3, thickness=0.2
|
||||||
ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey)
|
)
|
||||||
|
ifcopenshell.api.geometry.assign_representation(
|
||||||
|
f, product=wall2, representation=rep2
|
||||||
|
)
|
||||||
|
ifcopenshell.api.spatial.assign_container(
|
||||||
|
f, products=[wall2], relating_structure=storey
|
||||||
|
)
|
||||||
matrix = np.eye(4)
|
matrix = np.eye(4)
|
||||||
matrix[1, 3] = 3.0
|
matrix[1, 3] = 3.0
|
||||||
ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix)
|
ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ class TestGetTokenForHost:
|
||||||
assert get_token_for_host("gitlab.com") is None
|
assert get_token_for_host("gitlab.com") is None
|
||||||
|
|
||||||
def test_returns_none_when_file_missing(self, tmp_path, monkeypatch):
|
def test_returns_none_when_file_missing(self, tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr("ifcurl.auth.config_path", lambda: tmp_path / "nonexistent.json")
|
monkeypatch.setattr(
|
||||||
|
"ifcurl.auth.config_path", lambda: tmp_path / "nonexistent.json"
|
||||||
|
)
|
||||||
assert get_token_for_host("github.com") is None
|
assert get_token_for_host("github.com") is None
|
||||||
|
|
||||||
def test_returns_none_on_malformed_json(self, tmp_path, monkeypatch):
|
def test_returns_none_on_malformed_json(self, tmp_path, monkeypatch):
|
||||||
|
|
@ -40,12 +42,16 @@ class TestGetTokenForHost:
|
||||||
|
|
||||||
def test_multiple_hosts(self, tmp_path, monkeypatch):
|
def test_multiple_hosts(self, tmp_path, monkeypatch):
|
||||||
config = tmp_path / "tokens.json"
|
config = tmp_path / "tokens.json"
|
||||||
config.write_text(json.dumps({
|
config.write_text(
|
||||||
"hosts": {
|
json.dumps(
|
||||||
"github.com": "ghp_token",
|
{
|
||||||
"gitlab.example.com": "glpat_token",
|
"hosts": {
|
||||||
}
|
"github.com": "ghp_token",
|
||||||
}))
|
"gitlab.example.com": "glpat_token",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
monkeypatch.setattr("ifcurl.auth.config_path", lambda: config)
|
monkeypatch.setattr("ifcurl.auth.config_path", lambda: config)
|
||||||
assert get_token_for_host("github.com") == "ghp_token"
|
assert get_token_for_host("github.com") == "ghp_token"
|
||||||
assert get_token_for_host("gitlab.example.com") == "glpat_token"
|
assert get_token_for_host("gitlab.example.com") == "glpat_token"
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ client = TestClient(app)
|
||||||
|
|
||||||
FAKE_HEXSHA = "b" * 40
|
FAKE_HEXSHA = "b" * 40
|
||||||
MUTABLE_URL = "ifc://example.com/org/repo@heads/main?path=model.ifc"
|
MUTABLE_URL = "ifc://example.com/org/repo@heads/main?path=model.ifc"
|
||||||
CAMERA_URL = (
|
CAMERA_URL = (
|
||||||
"ifc://example.com/org/repo@heads/main"
|
"ifc://example.com/org/repo@heads/main"
|
||||||
"?path=model.ifc&camera=1,2,3,0,0,-1,0,1,0&fov=60"
|
"?path=model.ifc&camera=1,2,3,0,0,-1,0,1,0&fov=60"
|
||||||
)
|
)
|
||||||
|
|
@ -168,9 +168,9 @@ class TestBcfEndpoint:
|
||||||
assert not any(n.endswith("viewpoint.bcfv") for n in names)
|
assert not any(n.endswith("viewpoint.bcfv") for n in names)
|
||||||
|
|
||||||
def test_title_and_comment_in_markup(self):
|
def test_title_and_comment_in_markup(self):
|
||||||
r = client.post("/bcf", json={
|
r = client.post(
|
||||||
"url": CAMERA_URL, "title": "My issue", "comment": "Fix this"
|
"/bcf", json={"url": CAMERA_URL, "title": "My issue", "comment": "Fix this"}
|
||||||
})
|
)
|
||||||
markup = next(n for n in _zip_names(r.content) if n.endswith("markup.bcf"))
|
markup = next(n for n in _zip_names(r.content) if n.endswith("markup.bcf"))
|
||||||
content = _zip_read(r.content, markup)
|
content = _zip_read(r.content, markup)
|
||||||
assert "My issue" in content
|
assert "My issue" in content
|
||||||
|
|
@ -193,7 +193,9 @@ class TestBcfEndpoint:
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
def test_ssrf_private_ip_rejected(self):
|
def test_ssrf_private_ip_rejected(self):
|
||||||
r = client.post("/bcf", json={"url": "ifc://192.168.1.1/org/repo@HEAD?path=m.ifc"})
|
r = client.post(
|
||||||
|
"/bcf", json={"url": "ifc://192.168.1.1/org/repo@HEAD?path=m.ifc"}
|
||||||
|
)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
def test_invalid_url_returns_400(self):
|
def test_invalid_url_returns_400(self):
|
||||||
|
|
@ -206,7 +208,9 @@ class TestBcfEndpoint:
|
||||||
def mock_fetch(ifc_url, token=None):
|
def mock_fetch(ifc_url, token=None):
|
||||||
return FAKE_HEXSHA, ifc_bytes
|
return FAKE_HEXSHA, ifc_bytes
|
||||||
|
|
||||||
selector_url = MUTABLE_URL.replace("?path=model.ifc", "?path=model.ifc&selector=IfcWall")
|
selector_url = MUTABLE_URL.replace(
|
||||||
|
"?path=model.ifc", "?path=model.ifc&selector=IfcWall"
|
||||||
|
)
|
||||||
with patch("ifcurl.service.fetch_ifc", mock_fetch):
|
with patch("ifcurl.service.fetch_ifc", mock_fetch):
|
||||||
r = client.post("/bcf", json={"url": selector_url})
|
r = client.post("/bcf", json={"url": selector_url})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import pytest
|
||||||
|
|
||||||
from ifcurl.diff import expand_step_ids, step_ids_from_diff
|
from ifcurl.diff import expand_step_ids, step_ids_from_diff
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# step_ids_from_diff
|
# step_ids_from_diff
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -59,7 +58,11 @@ class TestStepIdsFromDiff:
|
||||||
def test_context_lines_ignored(self):
|
def test_context_lines_ignored(self):
|
||||||
diff = " #5=IFCSITE('site');\n"
|
diff = " #5=IFCSITE('site');\n"
|
||||||
ids = step_ids_from_diff(diff)
|
ids = step_ids_from_diff(diff)
|
||||||
assert 5 not in ids["added"] and 5 not in ids["removed"] and 5 not in ids["modified"]
|
assert (
|
||||||
|
5 not in ids["added"]
|
||||||
|
and 5 not in ids["removed"]
|
||||||
|
and 5 not in ids["modified"]
|
||||||
|
)
|
||||||
|
|
||||||
def test_git_header_lines_ignored(self):
|
def test_git_header_lines_ignored(self):
|
||||||
diff = "--- a/model.ifc\n+++ b/model.ifc\n"
|
diff = "--- a/model.ifc\n+++ b/model.ifc\n"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ifcurl.git import fetch_ifc, fetch_ifc_bytes
|
from ifcurl.git import fetch_ifc, fetch_ifc_bytes
|
||||||
|
|
@ -45,6 +47,7 @@ class TestFetchIfc:
|
||||||
|
|
||||||
def test_branch_ref(self, local_ifc_repo):
|
def test_branch_ref(self, local_ifc_repo):
|
||||||
import git as gitpkg
|
import git as gitpkg
|
||||||
|
|
||||||
branch = gitpkg.Repo(local_ifc_repo["path"]).active_branch.name
|
branch = gitpkg.Repo(local_ifc_repo["path"]).active_branch.name
|
||||||
url = _local_url(local_ifc_repo["path"], ref=f"heads/{branch}")
|
url = _local_url(local_ifc_repo["path"], ref=f"heads/{branch}")
|
||||||
hexsha, data = fetch_ifc(url)
|
hexsha, data = fetch_ifc(url)
|
||||||
|
|
@ -107,9 +110,10 @@ class TestInvalidRepo:
|
||||||
from ifcurl.git import _dir_size, _evict_if_needed, _repo_cache_entries, _touch_cache
|
from ifcurl.git import _dir_size, _evict_if_needed, _repo_cache_entries, _touch_cache
|
||||||
|
|
||||||
|
|
||||||
def _make_fake_repo_cache(base: "Path", url: str, size_bytes: int = 1024) -> "Path":
|
def _make_fake_repo_cache(base: Path, url: str, size_bytes: int = 1024) -> Path:
|
||||||
"""Create a fake cached-repo directory structure under *base*."""
|
"""Create a fake cached-repo directory structure under *base*."""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
key = hashlib.sha256(url.encode()).hexdigest()[:24]
|
key = hashlib.sha256(url.encode()).hexdigest()[:24]
|
||||||
entry = base / key
|
entry = base / key
|
||||||
git_dir = entry / "repo.git"
|
git_dir = entry / "repo.git"
|
||||||
|
|
@ -128,6 +132,7 @@ class TestCacheHelpers:
|
||||||
|
|
||||||
def test_touch_cache_updates_mtime(self, tmp_path):
|
def test_touch_cache_updates_mtime(self, tmp_path):
|
||||||
import time
|
import time
|
||||||
|
|
||||||
old_mtime = tmp_path.stat().st_mtime
|
old_mtime = tmp_path.stat().st_mtime
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
_touch_cache(tmp_path)
|
_touch_cache(tmp_path)
|
||||||
|
|
@ -151,9 +156,13 @@ class TestCacheHelpers:
|
||||||
def test_evict_removes_oldest_over_limit(self, tmp_path, monkeypatch):
|
def test_evict_removes_oldest_over_limit(self, tmp_path, monkeypatch):
|
||||||
monkeypatch.setattr("ifcurl.git.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
monkeypatch.setattr("ifcurl.git.user_cache_dir", lambda *a, **kw: str(tmp_path))
|
||||||
monkeypatch.setenv("IFCURL_CACHE_MAX_GB", "0.000001") # ~1 KB limit
|
monkeypatch.setenv("IFCURL_CACHE_MAX_GB", "0.000001") # ~1 KB limit
|
||||||
old = _make_fake_repo_cache(tmp_path, "https://old.example.com/repo", size_bytes=512)
|
old = _make_fake_repo_cache(
|
||||||
import time; time.sleep(0.05)
|
tmp_path, "https://old.example.com/repo", size_bytes=512
|
||||||
new = _make_fake_repo_cache(tmp_path, "https://new.example.com/repo", size_bytes=512)
|
)
|
||||||
|
time.sleep(0.05)
|
||||||
|
new = _make_fake_repo_cache(
|
||||||
|
tmp_path, "https://new.example.com/repo", size_bytes=512
|
||||||
|
)
|
||||||
_evict_if_needed()
|
_evict_if_needed()
|
||||||
assert not old.exists()
|
assert not old.exists()
|
||||||
assert new.exists()
|
assert new.exists()
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from ifcurl.render import render
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pyvista # noqa: F401
|
import pyvista # noqa: F401
|
||||||
|
|
||||||
HAS_PYVISTA = True
|
HAS_PYVISTA = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
HAS_PYVISTA = False
|
HAS_PYVISTA = False
|
||||||
|
|
@ -38,16 +39,26 @@ class TestRenderBasic:
|
||||||
f = ifcopenshell.api.project.create_file()
|
f = ifcopenshell.api.project.create_file()
|
||||||
ifcopenshell.api.owner.settings.get_user = lambda ifc: None
|
ifcopenshell.api.owner.settings.get_user = lambda ifc: None
|
||||||
ifcopenshell.api.owner.settings.get_application = lambda ifc: None
|
ifcopenshell.api.owner.settings.get_application = lambda ifc: None
|
||||||
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P")
|
project = ifcopenshell.api.root.create_entity(
|
||||||
|
f, ifc_class="IfcProject", name="P"
|
||||||
|
)
|
||||||
ifcopenshell.api.unit.assign_unit(f)
|
ifcopenshell.api.unit.assign_unit(f)
|
||||||
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite")
|
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite")
|
||||||
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding")
|
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding")
|
||||||
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey")
|
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey")
|
||||||
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
|
ifcopenshell.api.aggregate.assign_object(
|
||||||
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
|
f, products=[site], relating_object=project
|
||||||
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
|
)
|
||||||
|
ifcopenshell.api.aggregate.assign_object(
|
||||||
|
f, products=[building], relating_object=site
|
||||||
|
)
|
||||||
|
ifcopenshell.api.aggregate.assign_object(
|
||||||
|
f, products=[storey], relating_object=building
|
||||||
|
)
|
||||||
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="W")
|
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="W")
|
||||||
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
|
ifcopenshell.api.spatial.assign_container(
|
||||||
|
f, products=[wall], relating_structure=storey
|
||||||
|
)
|
||||||
with pytest.raises(ValueError, match="No renderable geometry"):
|
with pytest.raises(ValueError, match="No renderable geometry"):
|
||||||
render(f)
|
render(f)
|
||||||
|
|
||||||
|
|
@ -140,24 +151,34 @@ class TestRenderClips:
|
||||||
class TestRenderVisibility:
|
class TestRenderVisibility:
|
||||||
def test_highlight_mode(self, model_with_geometry):
|
def test_highlight_mode(self, model_with_geometry):
|
||||||
wall = model_with_geometry.by_type("IfcWall")[0]
|
wall = model_with_geometry.by_type("IfcWall")[0]
|
||||||
result = render(model_with_geometry, element_ids=[wall.id()], visibility="highlight")
|
result = render(
|
||||||
|
model_with_geometry, element_ids=[wall.id()], visibility="highlight"
|
||||||
|
)
|
||||||
assert result[:4] == PNG_MAGIC
|
assert result[:4] == PNG_MAGIC
|
||||||
|
|
||||||
def test_ghost_mode(self, model_with_geometry):
|
def test_ghost_mode(self, model_with_geometry):
|
||||||
wall = model_with_geometry.by_type("IfcWall")[0]
|
wall = model_with_geometry.by_type("IfcWall")[0]
|
||||||
result = render(model_with_geometry, element_ids=[wall.id()], visibility="ghost")
|
result = render(
|
||||||
|
model_with_geometry, element_ids=[wall.id()], visibility="ghost"
|
||||||
|
)
|
||||||
assert result[:4] == PNG_MAGIC
|
assert result[:4] == PNG_MAGIC
|
||||||
|
|
||||||
def test_isolate_mode(self, model_with_geometry):
|
def test_isolate_mode(self, model_with_geometry):
|
||||||
wall = model_with_geometry.by_type("IfcWall")[0]
|
wall = model_with_geometry.by_type("IfcWall")[0]
|
||||||
result = render(model_with_geometry, element_ids=[wall.id()], visibility="isolate")
|
result = render(
|
||||||
|
model_with_geometry, element_ids=[wall.id()], visibility="isolate"
|
||||||
|
)
|
||||||
assert result[:4] == PNG_MAGIC
|
assert result[:4] == PNG_MAGIC
|
||||||
|
|
||||||
def test_visibility_modes_produce_different_images(self, model_with_geometry):
|
def test_visibility_modes_produce_different_images(self, model_with_geometry):
|
||||||
wall = model_with_geometry.by_type("IfcWall")[0]
|
wall = model_with_geometry.by_type("IfcWall")[0]
|
||||||
highlight = render(model_with_geometry, element_ids=[wall.id()], visibility="highlight")
|
highlight = render(
|
||||||
|
model_with_geometry, element_ids=[wall.id()], visibility="highlight"
|
||||||
|
)
|
||||||
ghost = render(model_with_geometry, element_ids=[wall.id()], visibility="ghost")
|
ghost = render(model_with_geometry, element_ids=[wall.id()], visibility="ghost")
|
||||||
isolate = render(model_with_geometry, element_ids=[wall.id()], visibility="isolate")
|
isolate = render(
|
||||||
|
model_with_geometry, element_ids=[wall.id()], visibility="isolate"
|
||||||
|
)
|
||||||
# All three are valid PNG but should differ in content
|
# All three are valid PNG but should differ in content
|
||||||
assert highlight[:4] == ghost[:4] == isolate[:4] == PNG_MAGIC
|
assert highlight[:4] == ghost[:4] == isolate[:4] == PNG_MAGIC
|
||||||
assert len({highlight, ghost, isolate}) > 1
|
assert len({highlight, ghost, isolate}) > 1
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ def _segfault():
|
||||||
|
|
||||||
def _sleep_forever():
|
def _sleep_forever():
|
||||||
import time
|
import time
|
||||||
|
|
||||||
time.sleep(9999)
|
time.sleep(9999)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,14 @@ from unittest.mock import MagicMock, patch
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from ifcurl.service import _t2_cache, _t3_cache, _t2_get, _t2_put, app, configure_allowed_hosts
|
from ifcurl.service import (
|
||||||
|
_t2_cache,
|
||||||
|
_t2_get,
|
||||||
|
_t2_put,
|
||||||
|
_t3_cache,
|
||||||
|
app,
|
||||||
|
configure_allowed_hosts,
|
||||||
|
)
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
@ -75,12 +82,16 @@ class TestValidation:
|
||||||
assert r.status_code == 422
|
assert r.status_code == 422
|
||||||
|
|
||||||
def test_url_without_path_param_returns_400(self):
|
def test_url_without_path_param_returns_400(self):
|
||||||
r = client.post("/preview", json={"url": "ifc://example.com/org/repo@heads/main"})
|
r = client.post(
|
||||||
|
"/preview", json={"url": "ifc://example.com/org/repo@heads/main"}
|
||||||
|
)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert "path" in r.json()["detail"]
|
assert "path" in r.json()["detail"]
|
||||||
|
|
||||||
def test_malformed_url_returns_400(self):
|
def test_malformed_url_returns_400(self):
|
||||||
r = client.post("/preview", json={"url": "ifc://example.com/repo_no_ref?path=m.ifc"})
|
r = client.post(
|
||||||
|
"/preview", json={"url": "ifc://example.com/repo_no_ref?path=m.ifc"}
|
||||||
|
)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -92,8 +103,10 @@ class TestValidation:
|
||||||
class TestPreviewRender:
|
class TestPreviewRender:
|
||||||
def test_returns_png_content_type(self, model_with_geometry):
|
def test_returns_png_content_type(self, model_with_geometry):
|
||||||
ifc_bytes = model_with_geometry.to_string().encode()
|
ifc_bytes = model_with_geometry.to_string().encode()
|
||||||
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
|
with (
|
||||||
patch("ifcurl.render.render", return_value=FAKE_PNG):
|
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
|
||||||
|
patch("ifcurl.render.render", return_value=FAKE_PNG),
|
||||||
|
):
|
||||||
r = client.post("/preview", json={"url": MUTABLE_URL})
|
r = client.post("/preview", json={"url": MUTABLE_URL})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.headers["content-type"] == "image/png"
|
assert r.headers["content-type"] == "image/png"
|
||||||
|
|
@ -106,8 +119,10 @@ class TestPreviewRender:
|
||||||
|
|
||||||
def test_render_error_returns_422(self, model_with_geometry):
|
def test_render_error_returns_422(self, model_with_geometry):
|
||||||
ifc_bytes = model_with_geometry.to_string().encode()
|
ifc_bytes = model_with_geometry.to_string().encode()
|
||||||
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
|
with (
|
||||||
patch("ifcurl.render.render", side_effect=ValueError("No geometry")):
|
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
|
||||||
|
patch("ifcurl.render.render", side_effect=ValueError("No geometry")),
|
||||||
|
):
|
||||||
r = client.post("/preview", json={"url": MUTABLE_URL})
|
r = client.post("/preview", json={"url": MUTABLE_URL})
|
||||||
assert r.status_code == 422
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
@ -120,8 +135,10 @@ class TestPreviewRender:
|
||||||
class TestTier2Cache:
|
class TestTier2Cache:
|
||||||
def test_bytes_cached_after_first_request(self, model_with_geometry):
|
def test_bytes_cached_after_first_request(self, model_with_geometry):
|
||||||
ifc_bytes = model_with_geometry.to_string().encode()
|
ifc_bytes = model_with_geometry.to_string().encode()
|
||||||
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
|
with (
|
||||||
patch("ifcurl.render.render", return_value=FAKE_PNG):
|
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
|
||||||
|
patch("ifcurl.render.render", return_value=FAKE_PNG),
|
||||||
|
):
|
||||||
client.post("/preview", json={"url": MUTABLE_URL})
|
client.post("/preview", json={"url": MUTABLE_URL})
|
||||||
assert (FAKE_HEXSHA, "model.ifc") in _t2_cache
|
assert (FAKE_HEXSHA, "model.ifc") in _t2_cache
|
||||||
|
|
||||||
|
|
@ -137,8 +154,10 @@ class TestTier2Cache:
|
||||||
# Disable tier-4m so both requests reach fetch_ifc, letting us
|
# Disable tier-4m so both requests reach fetch_ifc, letting us
|
||||||
# verify that tier-2 serves ifc_bytes from cache on the second call.
|
# verify that tier-2 serves ifc_bytes from cache on the second call.
|
||||||
monkeypatch.setattr("ifcurl.service._T4M_TTL", 0)
|
monkeypatch.setattr("ifcurl.service._T4M_TTL", 0)
|
||||||
with patch("ifcurl.service.fetch_ifc", counting_fetch), \
|
with (
|
||||||
patch("ifcurl.render.render", return_value=FAKE_PNG):
|
patch("ifcurl.service.fetch_ifc", counting_fetch),
|
||||||
|
patch("ifcurl.render.render", return_value=FAKE_PNG),
|
||||||
|
):
|
||||||
client.post("/preview", json={"url": MUTABLE_URL})
|
client.post("/preview", json={"url": MUTABLE_URL})
|
||||||
client.post("/preview", json={"url": MUTABLE_URL})
|
client.post("/preview", json={"url": MUTABLE_URL})
|
||||||
# fetch_ifc still called both times (for the commit hexsha),
|
# fetch_ifc still called both times (for the commit hexsha),
|
||||||
|
|
@ -155,15 +174,19 @@ class TestTier2Cache:
|
||||||
class TestTier3Cache:
|
class TestTier3Cache:
|
||||||
def test_guids_cached_after_selector_request(self, model_with_geometry):
|
def test_guids_cached_after_selector_request(self, model_with_geometry):
|
||||||
ifc_bytes = model_with_geometry.to_string().encode()
|
ifc_bytes = model_with_geometry.to_string().encode()
|
||||||
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
|
with (
|
||||||
patch("ifcurl.render.render", return_value=FAKE_PNG):
|
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
|
||||||
|
patch("ifcurl.render.render", return_value=FAKE_PNG),
|
||||||
|
):
|
||||||
client.post("/preview", json={"url": SELECTOR_URL})
|
client.post("/preview", json={"url": SELECTOR_URL})
|
||||||
assert (FAKE_HEXSHA, "model.ifc", "IfcWall") in _t3_cache
|
assert (FAKE_HEXSHA, "model.ifc", "IfcWall") in _t3_cache
|
||||||
|
|
||||||
def test_cached_guids_are_frozenset(self, model_with_geometry):
|
def test_cached_guids_are_frozenset(self, model_with_geometry):
|
||||||
ifc_bytes = model_with_geometry.to_string().encode()
|
ifc_bytes = model_with_geometry.to_string().encode()
|
||||||
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
|
with (
|
||||||
patch("ifcurl.render.render", return_value=FAKE_PNG):
|
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
|
||||||
|
patch("ifcurl.render.render", return_value=FAKE_PNG),
|
||||||
|
):
|
||||||
client.post("/preview", json={"url": SELECTOR_URL})
|
client.post("/preview", json={"url": SELECTOR_URL})
|
||||||
cached = _t3_cache[(FAKE_HEXSHA, "model.ifc", "IfcWall")]
|
cached = _t3_cache[(FAKE_HEXSHA, "model.ifc", "IfcWall")]
|
||||||
assert isinstance(cached, frozenset)
|
assert isinstance(cached, frozenset)
|
||||||
|
|
@ -171,16 +194,20 @@ class TestTier3Cache:
|
||||||
def test_second_selector_request_skips_filter_elements(self, model_with_geometry):
|
def test_second_selector_request_skips_filter_elements(self, model_with_geometry):
|
||||||
ifc_bytes = model_with_geometry.to_string().encode()
|
ifc_bytes = model_with_geometry.to_string().encode()
|
||||||
filter_call_count = 0
|
filter_call_count = 0
|
||||||
real_filter = __import__("ifcopenshell.util.selector", fromlist=["filter_elements"]).filter_elements
|
real_filter = __import__(
|
||||||
|
"ifcopenshell.util.selector", fromlist=["filter_elements"]
|
||||||
|
).filter_elements
|
||||||
|
|
||||||
def counting_filter(model, selector):
|
def counting_filter(model, selector):
|
||||||
nonlocal filter_call_count
|
nonlocal filter_call_count
|
||||||
filter_call_count += 1
|
filter_call_count += 1
|
||||||
return real_filter(model, selector)
|
return real_filter(model, selector)
|
||||||
|
|
||||||
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
|
with (
|
||||||
patch("ifcurl.render.render", return_value=FAKE_PNG), \
|
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
|
||||||
patch("ifcopenshell.util.selector.filter_elements", counting_filter):
|
patch("ifcurl.render.render", return_value=FAKE_PNG),
|
||||||
|
patch("ifcopenshell.util.selector.filter_elements", counting_filter),
|
||||||
|
):
|
||||||
client.post("/preview", json={"url": SELECTOR_URL})
|
client.post("/preview", json={"url": SELECTOR_URL})
|
||||||
client.post("/preview", json={"url": SELECTOR_URL})
|
client.post("/preview", json={"url": SELECTOR_URL})
|
||||||
|
|
||||||
|
|
@ -202,8 +229,10 @@ class TestTier4Cache:
|
||||||
render_call_count += 1
|
render_call_count += 1
|
||||||
return FAKE_PNG
|
return FAKE_PNG
|
||||||
|
|
||||||
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
|
with (
|
||||||
patch("ifcurl.render.render", counting_render):
|
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
|
||||||
|
patch("ifcurl.render.render", counting_render),
|
||||||
|
):
|
||||||
r1 = client.post("/preview", json={"url": IMMUTABLE_URL})
|
r1 = client.post("/preview", json={"url": IMMUTABLE_URL})
|
||||||
r2 = client.post("/preview", json={"url": IMMUTABLE_URL})
|
r2 = client.post("/preview", json={"url": IMMUTABLE_URL})
|
||||||
|
|
||||||
|
|
@ -211,7 +240,9 @@ class TestTier4Cache:
|
||||||
assert r2.content == FAKE_PNG
|
assert r2.content == FAKE_PNG
|
||||||
assert render_call_count == 1 # render only called once
|
assert render_call_count == 1 # render only called once
|
||||||
|
|
||||||
def test_png_cached_for_mutable_ref_within_ttl(self, tmp_t4_cache, model_with_geometry):
|
def test_png_cached_for_mutable_ref_within_ttl(
|
||||||
|
self, tmp_t4_cache, model_with_geometry
|
||||||
|
):
|
||||||
ifc_bytes = model_with_geometry.to_string().encode()
|
ifc_bytes = model_with_geometry.to_string().encode()
|
||||||
render_call_count = 0
|
render_call_count = 0
|
||||||
|
|
||||||
|
|
@ -220,14 +251,18 @@ class TestTier4Cache:
|
||||||
render_call_count += 1
|
render_call_count += 1
|
||||||
return FAKE_PNG
|
return FAKE_PNG
|
||||||
|
|
||||||
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
|
with (
|
||||||
patch("ifcurl.render.render", counting_render):
|
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
|
||||||
|
patch("ifcurl.render.render", counting_render),
|
||||||
|
):
|
||||||
client.post("/preview", json={"url": MUTABLE_URL})
|
client.post("/preview", json={"url": MUTABLE_URL})
|
||||||
client.post("/preview", json={"url": MUTABLE_URL})
|
client.post("/preview", json={"url": MUTABLE_URL})
|
||||||
|
|
||||||
assert render_call_count == 1 # second request served from tier-4m cache
|
assert render_call_count == 1 # second request served from tier-4m cache
|
||||||
|
|
||||||
def test_png_not_cached_for_mutable_ref_after_ttl(self, tmp_t4_cache, model_with_geometry, monkeypatch):
|
def test_png_not_cached_for_mutable_ref_after_ttl(
|
||||||
|
self, tmp_t4_cache, model_with_geometry, monkeypatch
|
||||||
|
):
|
||||||
ifc_bytes = model_with_geometry.to_string().encode()
|
ifc_bytes = model_with_geometry.to_string().encode()
|
||||||
render_call_count = 0
|
render_call_count = 0
|
||||||
|
|
||||||
|
|
@ -237,8 +272,10 @@ class TestTier4Cache:
|
||||||
return FAKE_PNG
|
return FAKE_PNG
|
||||||
|
|
||||||
monkeypatch.setattr("ifcurl.service._T4M_TTL", 0)
|
monkeypatch.setattr("ifcurl.service._T4M_TTL", 0)
|
||||||
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
|
with (
|
||||||
patch("ifcurl.render.render", counting_render):
|
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
|
||||||
|
patch("ifcurl.render.render", counting_render),
|
||||||
|
):
|
||||||
client.post("/preview", json={"url": MUTABLE_URL})
|
client.post("/preview", json={"url": MUTABLE_URL})
|
||||||
client.post("/preview", json={"url": MUTABLE_URL})
|
client.post("/preview", json={"url": MUTABLE_URL})
|
||||||
|
|
||||||
|
|
@ -246,8 +283,10 @@ class TestTier4Cache:
|
||||||
|
|
||||||
def test_tier4_cache_file_exists_on_disk(self, tmp_t4_cache, model_with_geometry):
|
def test_tier4_cache_file_exists_on_disk(self, tmp_t4_cache, model_with_geometry):
|
||||||
ifc_bytes = model_with_geometry.to_string().encode()
|
ifc_bytes = model_with_geometry.to_string().encode()
|
||||||
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
|
with (
|
||||||
patch("ifcurl.render.render", return_value=FAKE_PNG):
|
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
|
||||||
|
patch("ifcurl.render.render", return_value=FAKE_PNG),
|
||||||
|
):
|
||||||
client.post("/preview", json={"url": IMMUTABLE_URL})
|
client.post("/preview", json={"url": IMMUTABLE_URL})
|
||||||
png_files = list(tmp_t4_cache.rglob("*.png"))
|
png_files = list(tmp_t4_cache.rglob("*.png"))
|
||||||
assert len(png_files) == 1
|
assert len(png_files) == 1
|
||||||
|
|
@ -268,13 +307,17 @@ class TestAuthentication:
|
||||||
received_token.append(token)
|
received_token.append(token)
|
||||||
return FAKE_HEXSHA, ifc_bytes
|
return FAKE_HEXSHA, ifc_bytes
|
||||||
|
|
||||||
with patch("ifcurl.service.fetch_ifc", capturing_fetch), \
|
with (
|
||||||
patch("ifcurl.render.render", return_value=FAKE_PNG):
|
patch("ifcurl.service.fetch_ifc", capturing_fetch),
|
||||||
|
patch("ifcurl.render.render", return_value=FAKE_PNG),
|
||||||
|
):
|
||||||
client.post("/preview", json={"url": MUTABLE_URL, "token": "mytoken123"})
|
client.post("/preview", json={"url": MUTABLE_URL, "token": "mytoken123"})
|
||||||
|
|
||||||
assert received_token == ["mytoken123"]
|
assert received_token == ["mytoken123"]
|
||||||
|
|
||||||
def test_config_token_used_when_no_request_token(self, model_with_geometry, monkeypatch):
|
def test_config_token_used_when_no_request_token(
|
||||||
|
self, model_with_geometry, monkeypatch
|
||||||
|
):
|
||||||
ifc_bytes = model_with_geometry.to_string().encode()
|
ifc_bytes = model_with_geometry.to_string().encode()
|
||||||
received_token = []
|
received_token = []
|
||||||
|
|
||||||
|
|
@ -282,14 +325,20 @@ class TestAuthentication:
|
||||||
received_token.append(token)
|
received_token.append(token)
|
||||||
return FAKE_HEXSHA, ifc_bytes
|
return FAKE_HEXSHA, ifc_bytes
|
||||||
|
|
||||||
monkeypatch.setattr("ifcurl.service.get_token_for_host", lambda host: "config_token")
|
monkeypatch.setattr(
|
||||||
with patch("ifcurl.service.fetch_ifc", capturing_fetch), \
|
"ifcurl.service.get_token_for_host", lambda host: "config_token"
|
||||||
patch("ifcurl.render.render", return_value=FAKE_PNG):
|
)
|
||||||
|
with (
|
||||||
|
patch("ifcurl.service.fetch_ifc", capturing_fetch),
|
||||||
|
patch("ifcurl.render.render", return_value=FAKE_PNG),
|
||||||
|
):
|
||||||
client.post("/preview", json={"url": MUTABLE_URL})
|
client.post("/preview", json={"url": MUTABLE_URL})
|
||||||
|
|
||||||
assert received_token == ["config_token"]
|
assert received_token == ["config_token"]
|
||||||
|
|
||||||
def test_request_token_overrides_config_token(self, model_with_geometry, monkeypatch):
|
def test_request_token_overrides_config_token(
|
||||||
|
self, model_with_geometry, monkeypatch
|
||||||
|
):
|
||||||
ifc_bytes = model_with_geometry.to_string().encode()
|
ifc_bytes = model_with_geometry.to_string().encode()
|
||||||
received_token = []
|
received_token = []
|
||||||
|
|
||||||
|
|
@ -297,9 +346,13 @@ class TestAuthentication:
|
||||||
received_token.append(token)
|
received_token.append(token)
|
||||||
return FAKE_HEXSHA, ifc_bytes
|
return FAKE_HEXSHA, ifc_bytes
|
||||||
|
|
||||||
monkeypatch.setattr("ifcurl.service.get_token_for_host", lambda host: "config_token")
|
monkeypatch.setattr(
|
||||||
with patch("ifcurl.service.fetch_ifc", capturing_fetch), \
|
"ifcurl.service.get_token_for_host", lambda host: "config_token"
|
||||||
patch("ifcurl.render.render", return_value=FAKE_PNG):
|
)
|
||||||
|
with (
|
||||||
|
patch("ifcurl.service.fetch_ifc", capturing_fetch),
|
||||||
|
patch("ifcurl.render.render", return_value=FAKE_PNG),
|
||||||
|
):
|
||||||
client.post("/preview", json={"url": MUTABLE_URL, "token": "request_token"})
|
client.post("/preview", json={"url": MUTABLE_URL, "token": "request_token"})
|
||||||
|
|
||||||
assert received_token == ["request_token"]
|
assert received_token == ["request_token"]
|
||||||
|
|
@ -313,8 +366,10 @@ class TestAuthentication:
|
||||||
class TestGetEndpoint:
|
class TestGetEndpoint:
|
||||||
def test_get_returns_png(self, model_with_geometry):
|
def test_get_returns_png(self, model_with_geometry):
|
||||||
ifc_bytes = model_with_geometry.to_string().encode()
|
ifc_bytes = model_with_geometry.to_string().encode()
|
||||||
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
|
with (
|
||||||
patch("ifcurl.render.render", return_value=FAKE_PNG):
|
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
|
||||||
|
patch("ifcurl.render.render", return_value=FAKE_PNG),
|
||||||
|
):
|
||||||
r = client.get("/preview", params={"url": MUTABLE_URL})
|
r = client.get("/preview", params={"url": MUTABLE_URL})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.headers["content-type"] == "image/png"
|
assert r.headers["content-type"] == "image/png"
|
||||||
|
|
@ -336,8 +391,10 @@ class TestGetEndpoint:
|
||||||
received["token"] = token
|
received["token"] = token
|
||||||
return FAKE_HEXSHA, ifc_bytes
|
return FAKE_HEXSHA, ifc_bytes
|
||||||
|
|
||||||
with patch("ifcurl.service.fetch_ifc", mock_fetch), \
|
with (
|
||||||
patch("ifcurl.render.render", return_value=FAKE_PNG):
|
patch("ifcurl.service.fetch_ifc", mock_fetch),
|
||||||
|
patch("ifcurl.render.render", return_value=FAKE_PNG),
|
||||||
|
):
|
||||||
r = client.get("/preview", params={"url": MUTABLE_URL, "token": "mytoken"})
|
r = client.get("/preview", params={"url": MUTABLE_URL, "token": "mytoken"})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert received["token"] == "mytoken"
|
assert received["token"] == "mytoken"
|
||||||
|
|
@ -362,7 +419,7 @@ class TestTier2Eviction:
|
||||||
monkeypatch.setattr("ifcurl.service._T2_MAX", 2)
|
monkeypatch.setattr("ifcurl.service._T2_MAX", 2)
|
||||||
_t2_put("sha_a", "a.ifc", b"data_a")
|
_t2_put("sha_a", "a.ifc", b"data_a")
|
||||||
_t2_put("sha_b", "b.ifc", b"data_b")
|
_t2_put("sha_b", "b.ifc", b"data_b")
|
||||||
_t2_get("sha_a", "a.ifc") # promote sha_a to most-recent
|
_t2_get("sha_a", "a.ifc") # promote sha_a to most-recent
|
||||||
_t2_put("sha_c", "c.ifc", b"data_c") # should evict sha_b (now oldest)
|
_t2_put("sha_c", "c.ifc", b"data_c") # should evict sha_b (now oldest)
|
||||||
assert _t2_get("sha_a", "a.ifc") == b"data_a"
|
assert _t2_get("sha_a", "a.ifc") == b"data_a"
|
||||||
assert _t2_get("sha_b", "b.ifc") is None
|
assert _t2_get("sha_b", "b.ifc") is None
|
||||||
|
|
@ -392,36 +449,49 @@ class TestSSRF:
|
||||||
def test_host_in_allowlist_permitted(self, model_with_geometry):
|
def test_host_in_allowlist_permitted(self, model_with_geometry):
|
||||||
configure_allowed_hosts({"example.com"})
|
configure_allowed_hosts({"example.com"})
|
||||||
ifc_bytes = model_with_geometry.to_string().encode()
|
ifc_bytes = model_with_geometry.to_string().encode()
|
||||||
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
|
with (
|
||||||
patch("ifcurl.render.render", return_value=FAKE_PNG):
|
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
|
||||||
|
patch("ifcurl.render.render", return_value=FAKE_PNG),
|
||||||
|
):
|
||||||
r = client.post("/preview", json={"url": MUTABLE_URL})
|
r = client.post("/preview", json={"url": MUTABLE_URL})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
def test_no_allowlist_permits_public_host(self, model_with_geometry):
|
def test_no_allowlist_permits_public_host(self, model_with_geometry):
|
||||||
ifc_bytes = model_with_geometry.to_string().encode()
|
ifc_bytes = model_with_geometry.to_string().encode()
|
||||||
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
|
with (
|
||||||
patch("ifcurl.render.render", return_value=FAKE_PNG):
|
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
|
||||||
|
patch("ifcurl.render.render", return_value=FAKE_PNG),
|
||||||
|
):
|
||||||
r = client.post("/preview", json={"url": MUTABLE_URL})
|
r = client.post("/preview", json={"url": MUTABLE_URL})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
def test_loopback_ip_rejected_without_allowlist(self):
|
def test_loopback_ip_rejected_without_allowlist(self):
|
||||||
r = client.post("/preview", json={"url": "ifc://127.0.0.1/org/repo@HEAD?path=m.ifc"})
|
r = client.post(
|
||||||
|
"/preview", json={"url": "ifc://127.0.0.1/org/repo@HEAD?path=m.ifc"}
|
||||||
|
)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
def test_private_ip_rejected_without_allowlist(self):
|
def test_private_ip_rejected_without_allowlist(self):
|
||||||
r = client.post("/preview", json={"url": "ifc://192.168.1.1/org/repo@HEAD?path=m.ifc"})
|
r = client.post(
|
||||||
|
"/preview", json={"url": "ifc://192.168.1.1/org/repo@HEAD?path=m.ifc"}
|
||||||
|
)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
def test_link_local_ip_rejected_without_allowlist(self):
|
def test_link_local_ip_rejected_without_allowlist(self):
|
||||||
r = client.post("/preview", json={"url": "ifc://169.254.169.254/org/repo@HEAD?path=m.ifc"})
|
r = client.post(
|
||||||
|
"/preview", json={"url": "ifc://169.254.169.254/org/repo@HEAD?path=m.ifc"}
|
||||||
|
)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
|
|
||||||
def test_allowlist_with_port(self, model_with_geometry):
|
def test_allowlist_with_port(self, model_with_geometry):
|
||||||
configure_allowed_hosts({"localhost:3000"})
|
configure_allowed_hosts({"localhost:3000"})
|
||||||
ifc_bytes = model_with_geometry.to_string().encode()
|
ifc_bytes = model_with_geometry.to_string().encode()
|
||||||
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
|
with (
|
||||||
patch("ifcurl.render.render", return_value=FAKE_PNG):
|
patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)),
|
||||||
r = client.post("/preview", json={
|
patch("ifcurl.render.render", return_value=FAKE_PNG),
|
||||||
"url": "ifc://localhost:3000/org/repo@heads/main?path=model.ifc"
|
):
|
||||||
})
|
r = client.post(
|
||||||
|
"/preview",
|
||||||
|
json={"url": "ifc://localhost:3000/org/repo@heads/main?path=model.ifc"},
|
||||||
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,9 @@ def test_selector():
|
||||||
|
|
||||||
|
|
||||||
def test_selector_percent_encoded():
|
def test_selector_percent_encoded():
|
||||||
url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc&selector=IfcWall%2C%2BName%3D%22X%22")
|
url = IfcUrl.parse(
|
||||||
|
"ifc://example.com/org/repo@HEAD?path=m.ifc&selector=IfcWall%2C%2BName%3D%22X%22"
|
||||||
|
)
|
||||||
assert url.selector == 'IfcWall,+Name="X"'
|
assert url.selector == 'IfcWall,+Name="X"'
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -218,7 +220,9 @@ def test_immutable_tag():
|
||||||
|
|
||||||
|
|
||||||
def test_spec_example_default_view():
|
def test_spec_example_default_view():
|
||||||
url = IfcUrl.parse("ifc://example.com/org/project@heads/main?path=models/building.ifc")
|
url = IfcUrl.parse(
|
||||||
|
"ifc://example.com/org/project@heads/main?path=models/building.ifc"
|
||||||
|
)
|
||||||
assert url.transport == "https"
|
assert url.transport == "https"
|
||||||
assert url.ref == "heads/main"
|
assert url.ref == "heads/main"
|
||||||
assert url.path == "models/building.ifc"
|
assert url.path == "models/building.ifc"
|
||||||
|
|
@ -228,10 +232,10 @@ def test_spec_example_default_view():
|
||||||
|
|
||||||
def test_spec_example_perspective():
|
def test_spec_example_perspective():
|
||||||
url = IfcUrl.parse(
|
url = IfcUrl.parse(
|
||||||
'ifc://git@example.com/org/project@abc123def'
|
"ifc://git@example.com/org/project@abc123def"
|
||||||
'?path=models/building.ifc'
|
"?path=models/building.ifc"
|
||||||
'&selector=IfcWall%2C%2BName%3D%22Core%2BWall%22'
|
"&selector=IfcWall%2C%2BName%3D%22Core%2BWall%22"
|
||||||
'&camera=10,20,5,0,-1,0,0,0,1&fov=60&visibility=ghost'
|
"&camera=10,20,5,0,-1,0,0,0,1&fov=60&visibility=ghost"
|
||||||
)
|
)
|
||||||
assert url.transport == "ssh"
|
assert url.transport == "ssh"
|
||||||
assert url.fov == 60.0
|
assert url.fov == 60.0
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue