This commit is contained in:
Bruno Postle 2026-04-22 08:31:24 +01:00
parent 95adb177f6
commit cf935a2a49
3 changed files with 173 additions and 17 deletions

82
PLAN.md
View file

@ -118,6 +118,88 @@ Add `ifc://` link detection and preview embedding to Gitea.
--- ---
## Phase 3b — Web IFC viewer
Add a 3D viewer that opens in a separate browser window when viewing an `.ifc`
file in Forgejo.
### Architecture
- **Forgejo Go patch**: when rendering the file view for a `.ifc` file, inject
a "View in 3D" button that opens
`http://localhost:8000/viewer?url=ifc://...` in a new tab. The ifc:// URL
encodes the current host, repo, ref, and file path.
- **`/viewer` endpoint** in the ifcurl preview service: returns a self-contained
HTML page. No server-side IFC processing — the browser does all rendering.
- **Browser rendering**: the viewer page loads `@thatopen/components` (and its
`web-ifc` + Three.js peer dependencies) from a CDN via an ES module importmap.
It constructs the Forgejo raw file URL from the ifc:// URL parameters and
fetches the IFC bytes directly from Forgejo.
### Current scope (Phase 3b)
- Display the full model for whatever ref is shown in Forgejo.
- Basic orbit/pan/zoom camera controls.
- No filtering or selection UI yet.
### Future scope
- Element filtering and selection using `@thatopen/components` classifier and
highlighter APIs.
- Camera state serialised to an `ifc://` URL so the user can copy a view link
(feeds into Phase 4 Bonsai "Copy view URL").
- Diff highlighting driven by ifcmerge output (see Phase 3c).
---
## Phase 3c — ifcmerge integration
`ifcmerge` is a git custom merge driver that resolves conflicts in `.ifc` files
that git's built-in 3-way merge cannot handle. Where two branches have both
modified an IFC file (a fork), git would report an unresolvable conflict;
ifcmerge performs a semantics-aware merge and produces a valid merged IFC file.
### Git merge driver configuration
Register ifcmerge as the git merge driver for `.ifc` files on the Forgejo
server:
```
# /etc/gitconfig or ~forgejo/.gitconfig
[merge "ifcmerge"]
name = IFC merge driver
driver = ifcmerge %O %A %B %L
```
```
# .gitattributes in each repo, or server-side gitattributes
*.ifc merge=ifcmerge
```
With this in place, Forgejo's existing "merge automatically" path will invoke
ifcmerge via git when merging `.ifc` files, and the PR merge button will work
without any Go-side changes for the common case.
### Forgejo integration
- Verify that Forgejo's merge-ability check (the "these branches can be merged
automatically" indicator) correctly reflects ifcmerge's ability to merge
`.ifc` files — investigate whether the check runs a trial merge via git or
uses a heuristic.
- If needed, patch Forgejo to run a trial `git merge --no-commit` to get an
accurate result for IFC files.
### 3D diff view (future)
- After a merge completes, surface a "View merge in 3D" button in the PR using
the Phase 3b viewer.
- Use `@thatopen/components` highlighter to colour added, removed, and modified
elements differently, driven by comparing the merged IFC against either parent.
---
## Phase 4 — Bonsai integration ## Phase 4 — Bonsai integration
Add `ifc://` handling to Bonsai. Add `ifc://` handling to Bonsai.

View file

@ -39,6 +39,7 @@ import hashlib
import os import os
import tempfile import tempfile
import threading import threading
import time
from collections import OrderedDict from collections import OrderedDict
from pathlib import Path from pathlib import Path
@ -128,11 +129,11 @@ def _guids_to_step_ids(model: ifcopenshell.file, guids: frozenset[str]) -> list[
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tier 4: sha256(url) → PNG (filesystem, immutable refs only) # 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" 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)
url_hash = hashlib.sha256(url.encode()).hexdigest() url_hash = hashlib.sha256(url.encode()).hexdigest()
return cache_dir / f"{url_hash}.png" return cache_dir / f"{url_hash}.png"
@ -149,6 +150,36 @@ def _t4_put(url: str, png: bytes) -> None:
_t4_path(url).write_bytes(png) _t4_path(url).write_bytes(png)
# ---------------------------------------------------------------------------
# Tier 4m: sha256(url) → PNG (filesystem, mutable refs, 5-minute TTL)
# ---------------------------------------------------------------------------
_T4M_TTL = 300 # seconds
def _t4m_path(url: str) -> Path:
cache_dir = Path(user_cache_dir("ifcurl")) / "renders" / "mutable"
cache_dir.mkdir(parents=True, exist_ok=True)
url_hash = hashlib.sha256(url.encode()).hexdigest()
return cache_dir / f"{url_hash}.png"
def _t4m_get(url: str) -> tuple[bytes, int] | None:
"""Return (png_bytes, remaining_seconds) or None if missing/expired."""
path = _t4m_path(url)
try:
remaining = int(os.path.getmtime(path) + _T4M_TTL - time.time())
if remaining <= 0:
return None
return path.read_bytes(), remaining
except (FileNotFoundError, OSError):
return None
def _t4m_put(url: str, png: bytes) -> None:
_t4m_path(url).write_bytes(png)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helper: load model from bytes via a temp file # Helper: load model from bytes via a temp file
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -213,11 +244,24 @@ def preview(request: PreviewRequest) -> Response:
if ifc_url.path is None: if ifc_url.path is None:
raise HTTPException(status_code=400, detail="URL has no 'path' parameter") raise HTTPException(status_code=400, detail="URL has no 'path' parameter")
# --- Tier 4: cached PNG for immutable refs --- # --- Tier 4 / 4m: cached PNG ---
if not ifc_url.is_mutable_ref(): if ifc_url.is_mutable_ref():
t4m_hit = _t4m_get(request.url)
if t4m_hit is not None:
cached_png, remaining = t4m_hit
return Response(
content=cached_png,
media_type="image/png",
headers={"Cache-Control": f"public, max-age={remaining}"},
)
else:
cached_png = _t4_get(request.url) cached_png = _t4_get(request.url)
if cached_png is not None: if cached_png is not None:
return Response(content=cached_png, media_type="image/png") return Response(
content=cached_png,
media_type="image/png",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
# --- Resolve authentication token --- # --- Resolve authentication token ---
token = request.token token = request.token
@ -284,8 +328,18 @@ def preview(request: PreviewRequest) -> Response:
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
# --- Tier 4: store PNG for immutable refs --- # --- Tier 4 / 4m: store PNG and return with appropriate cache headers ---
if not ifc_url.is_mutable_ref(): if ifc_url.is_mutable_ref():
_t4m_put(request.url, png_bytes)
return Response(
content=png_bytes,
media_type="image/png",
headers={"Cache-Control": f"public, max-age={_T4M_TTL}"},
)
else:
_t4_put(request.url, png_bytes) _t4_put(request.url, png_bytes)
return Response(
return Response(content=png_bytes, media_type="image/png") content=png_bytes,
media_type="image/png",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)

View file

@ -24,8 +24,9 @@ SELECTOR_URL = "ifc://example.com/org/repo@heads/main?path=model.ifc&selector=If
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def clear_caches(): def clear_caches(tmp_path, monkeypatch):
"""Reset in-memory caches and tier-4 filesystem cache between tests.""" """Reset in-memory caches and redirect filesystem PNG cache between tests."""
monkeypatch.setattr("ifcurl.service.user_cache_dir", lambda *a, **kw: str(tmp_path))
_t2_cache.clear() _t2_cache.clear()
_t3_cache.clear() _t3_cache.clear()
yield yield
@ -34,9 +35,8 @@ def clear_caches():
@pytest.fixture() @pytest.fixture()
def tmp_t4_cache(tmp_path, monkeypatch): def tmp_t4_cache(tmp_path):
"""Redirect tier-4 PNG cache to a temp directory.""" """Return the temp cache directory (already redirected by clear_caches)."""
monkeypatch.setattr("ifcurl.service.user_cache_dir", lambda *a, **kw: str(tmp_path))
return tmp_path return tmp_path
@ -109,7 +109,7 @@ class TestTier2Cache:
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
def test_second_request_uses_cached_bytes(self, model_with_geometry): def test_second_request_uses_cached_bytes(self, model_with_geometry, monkeypatch):
ifc_bytes = model_with_geometry.to_string().encode() ifc_bytes = model_with_geometry.to_string().encode()
call_count = 0 call_count = 0
@ -118,6 +118,9 @@ class TestTier2Cache:
call_count += 1 call_count += 1
return FAKE_HEXSHA, ifc_bytes return FAKE_HEXSHA, ifc_bytes
# Disable tier-4m so both requests reach fetch_ifc, letting us
# verify that tier-2 serves ifc_bytes from cache on the second call.
monkeypatch.setattr("ifcurl.service._T4M_TTL", 0)
with patch("ifcurl.service.fetch_ifc", counting_fetch), \ with patch("ifcurl.service.fetch_ifc", counting_fetch), \
patch("ifcurl.service.render_mod.render", return_value=FAKE_PNG): patch("ifcurl.service.render_mod.render", return_value=FAKE_PNG):
client.post("/preview", json={"url": MUTABLE_URL}) client.post("/preview", json={"url": MUTABLE_URL})
@ -192,7 +195,7 @@ 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_not_cached_for_mutable_ref(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
@ -206,7 +209,24 @@ class TestTier4Cache:
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 == 2 # no tier-4 cache for mutable ref 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):
ifc_bytes = model_with_geometry.to_string().encode()
render_call_count = 0
def counting_render(*args, **kwargs):
nonlocal render_call_count
render_call_count += 1
return FAKE_PNG
monkeypatch.setattr("ifcurl.service._T4M_TTL", 0)
with patch("ifcurl.service.fetch_ifc", _mock_fetch(ifc_bytes)), \
patch("ifcurl.service.render_mod.render", counting_render):
client.post("/preview", json={"url": MUTABLE_URL})
client.post("/preview", json={"url": MUTABLE_URL})
assert render_call_count == 2 # TTL=0 means cache always expired
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()