mirror of
https://github.com/brunopostle/ifcurl.git
synced 2026-07-12 10:18:14 +00:00
FIxes
This commit is contained in:
parent
95adb177f6
commit
cf935a2a49
3 changed files with 173 additions and 17 deletions
82
PLAN.md
82
PLAN.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue