url.py, test_url.py: add query parameter and visibility=clash

- IfcUrl gains a query field (str | None) for dot-notation
  attribute/property paths (e.g. Pset_WallCommon.FireRating)
- visibility now accepts 'clash' in addition to the existing three modes
- to_string() serialises both new fields
- 10 new tests covering query parsing, pset paths, combined query+view,
  clash visibility, roundtrip serialisation, and the new spec examples

https://claude.ai/code/session_01CKpkhWhzkzR5K5PDSR8N5F
This commit is contained in:
Claude 2026-05-09 07:27:35 +00:00
parent 105824eb80
commit 8ab6603a44
2 changed files with 71 additions and 3 deletions

View file

@ -41,13 +41,14 @@ class IfcUrl:
ref: str # e.g. 'heads/main', 'tags/v1.2', 'abc123def', 'HEAD' ref: str # e.g. 'heads/main', 'tags/v1.2', 'abc123def', 'HEAD'
path: str | None # IFC file path within the repository path: str | None # IFC file path within the repository
selector: str | None # IfcOpenShell selector expression (percent-decoded) selector: str | None # IfcOpenShell selector expression (percent-decoded)
query: str | None # dot-notation attribute/property path, e.g. 'Pset_WallCommon.FireRating'
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( clips: list[tuple[float, float, float, float, float, float]] = field(
default_factory=list default_factory=list
) )
visibility: str = "highlight" # 'highlight', 'ghost', or 'isolate' visibility: str = "highlight" # 'highlight', 'ghost', 'isolate', or 'clash'
@classmethod @classmethod
def parse(cls, url_string: str) -> IfcUrl: def parse(cls, url_string: str) -> IfcUrl:
@ -95,6 +96,7 @@ class IfcUrl:
path = qs["path"][0] if "path" in qs else None path = qs["path"][0] if "path" in qs else None
selector = unquote(qs["selector"][0]) if "selector" in qs else None selector = unquote(qs["selector"][0]) if "selector" in qs else None
query = qs["query"][0] if "query" in qs else None
camera: tuple[float, ...] | None = None camera: tuple[float, ...] | None = None
if "camera" in qs: if "camera" in qs:
@ -150,9 +152,9 @@ class IfcUrl:
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", "clash"):
raise ValueError( raise ValueError(
f"Unknown visibility mode {visibility_raw!r}; expected 'highlight', 'ghost', or 'isolate'" f"Unknown visibility mode {visibility_raw!r}; expected 'highlight', 'ghost', 'isolate', or 'clash'"
) )
return cls( return cls(
@ -163,6 +165,7 @@ class IfcUrl:
ref=ref, ref=ref,
path=path, path=path,
selector=selector, selector=selector,
query=query,
camera=camera, # type: ignore[arg-type] camera=camera, # type: ignore[arg-type]
fov=fov, fov=fov,
scale=scale, scale=scale,
@ -227,6 +230,8 @@ class IfcUrl:
parts.append(f"path={quote(self.path, safe='/')}") parts.append(f"path={quote(self.path, safe='/')}")
if self.selector: if self.selector:
parts.append(f"selector={quote(self.selector, safe='$')}") parts.append(f"selector={quote(self.selector, safe='$')}")
if self.query:
parts.append(f"query={self.query}")
if self.camera: if self.camera:
vals = ",".join(repr(v) for v in self.camera) vals = ",".join(repr(v) for v in self.camera)
parts.append(f"camera={vals}") parts.append(f"camera={vals}")

View file

@ -158,6 +158,38 @@ def test_visibility_isolate():
assert url.visibility == "isolate" assert url.visibility == "isolate"
def test_visibility_clash():
url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc&visibility=clash")
assert url.visibility == "clash"
def test_query_attribute():
url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc&selector=IfcWall&query=Name")
assert url.query == "Name"
def test_query_pset():
url = IfcUrl.parse(
"ifc://example.com/org/repo@HEAD?path=m.ifc&selector=IfcWall&query=Pset_WallCommon.FireRating"
)
assert url.query == "Pset_WallCommon.FireRating"
def test_query_absent():
url = IfcUrl.parse("ifc://example.com/org/repo@HEAD?path=m.ifc")
assert url.query is None
def test_query_with_view_params():
url = IfcUrl.parse(
"ifc://example.com/org/repo@HEAD?path=m.ifc"
"&selector=IfcWall&camera=10,20,5,0,-1,0,0,0,1&fov=60&query=Name"
)
assert url.query == "Name"
assert url.camera is not None
assert url.fov == 60.0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# git_remote_url() # git_remote_url()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -302,6 +334,37 @@ def test_unknown_visibility():
IfcUrl.parse("ifc://example.com/o/r@HEAD?path=m.ifc&visibility=wireframe") IfcUrl.parse("ifc://example.com/o/r@HEAD?path=m.ifc&visibility=wireframe")
def test_to_string_roundtrip_query():
raw = "ifc://example.com/org/repo@heads/main?path=m.ifc&selector=IfcWall&query=Pset_WallCommon.FireRating"
assert IfcUrl.parse(raw).to_string() == raw
def test_to_string_roundtrip_visibility_clash():
raw = "ifc://example.com/org/repo@heads/main?path=m.ifc&selector=IfcWall&visibility=clash"
assert IfcUrl.parse(raw).to_string() == raw
def test_spec_example_query():
url = IfcUrl.parse(
"ifc://example.com/org/project@heads/main"
"?path=models/building.ifc&selector=IfcWall&query=Pset_WallCommon.FireRating"
)
assert url.query == "Pset_WallCommon.FireRating"
assert url.selector == "IfcWall"
assert url.camera is None
def test_spec_example_clash():
url = IfcUrl.parse(
"ifc://example.com/org/project@heads/main"
"?path=models/building.ifc"
"&selector=IfcWall%2C%2BName%3D%22Core%2BWall%22%2BIfcBeam%2C%2BName%3D%22B1%22"
"&visibility=clash"
)
assert url.visibility == "clash"
assert url.selector is not None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# to_string() # to_string()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------