Merge pull request #4 from brunopostle/claude/review-spec-coverage-cM3AC

SPECIFICATION.md: add query parameter and visibility=clash
This commit is contained in:
Bruno Postle 2026-05-09 09:28:50 +02:00 committed by GitHub
commit 35e064fe90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 133 additions and 5 deletions

View file

@ -96,6 +96,35 @@ selector=325Q7Fhnf67OZC$$r43uzK
When omitted, no selection is applied. When omitted, no selection is applied.
### `query`
A single attribute or property path to retrieve from the selected elements,
using dot notation to address property sets.
```
query=Name
query=Description
query=Pset_WallCommon.FireRating
query=Qto_WallBaseQuantities.NetVolume
```
The response is a JSON object whose keys are the GlobalId of each matching
element and whose values are the retrieved attribute or property value:
```json
{"325Q7Fhnf67OZC$$r43uzK": "2HR", "0t2oN7…": "1HR"}
```
Only elements that have a GlobalId are included in the response. Elements
matched by the selector that lack a GlobalId are silently excluded. Attributes
that resolve to an IFC entity reference rather than a scalar value are
undefined and excluded.
`query` may be combined with view parameters (`camera`, `fov`, `scale`,
`clip`, `visibility`). A viewer receiving both should display the 3D view and
the queried property values together. A render service producing a static image
ignores `query`.
### `camera` ### `camera`
Nine comma-separated decimal values defining the camera in IFC world Nine comma-separated decimal values defining the camera in IFC world
@ -188,9 +217,16 @@ Controls how selected and unselected elements are displayed.
| `highlight` | Show all elements, highlight selection (default) | | `highlight` | Show all elements, highlight selection (default) |
| `ghost` | Show selection normally, dim all other elements | | `ghost` | Show selection normally, dim all other elements |
| `isolate` | Show only selected elements, hide all others | | `isolate` | Show only selected elements, hide all others |
| `clash` | Show all elements, mark selection with clash-indicator styling (e.g. red) |
When omitted, `highlight` is assumed. When omitted, `highlight` is assumed.
`clash` is a visual cue only. It carries no semantic claim about the nature of
the issue; it signals to the viewer that the selected elements should be
rendered in a manner that visually distinguishes a reported clash. When
exported to BCF, `clash` is treated as `highlight` — BCF has no equivalent
structural distinction.
--- ---
## 4. Coordinate system ## 4. Coordinate system
@ -272,15 +308,21 @@ A compliant viewer must:
- Apply perspective camera (`camera` + `fov`) - Apply perspective camera (`camera` + `fov`)
- Apply orthographic camera (`camera` + `scale`) - Apply orthographic camera (`camera` + `scale`)
- Apply clipping planes (`clip`) - Apply clipping planes (`clip`)
- Apply visibility mode (`highlight`, `ghost`, `isolate`) - Apply visibility mode (`highlight`, `ghost`, `isolate`, `clash`)
- Resolve relative `IFCDOCUMENTREFERENCE` paths using the root file's repo - Resolve relative `IFCDOCUMENTREFERENCE` paths using the root file's repo
and ref context and ref context
- Resolve `ifc://` `IFCDOCUMENTREFERENCE` locations - Resolve `ifc://` `IFCDOCUMENTREFERENCE` locations
When `query` is present, a compliant viewer should retrieve the specified
attribute or property value for each selected element and display the results
alongside the 3D view. If view parameters are also present, both the 3D view
and the property values are shown. A render service producing a static image
may ignore `query`.
Optional extensions: Optional extensions:
- Semantic colouring - Semantic colouring
- Advanced visual styles beyond the three visibility modes - Advanced visual styles beyond the four visibility modes
--- ---
@ -322,6 +364,24 @@ OpenCDE document, current version, no view parameters (opens at default view):
ifc://cde.example.com/project-123?document_id=bf546064-6b97-4730-a094-c21ab929c91a ifc://cde.example.com/project-123?document_id=bf546064-6b97-4730-a094-c21ab929c91a
``` ```
Query the fire rating of all walls — returns `{"<guid>": "<value>", …}`:
```
ifc://example.com/org/project@heads/main?path=models/building.ifc&selector=IfcWall&query=Pset_WallCommon.FireRating
```
Query with a camera view — viewer shows 3D view and property table together:
```
ifc://example.com/org/project@heads/main?path=models/building.ifc&selector=IfcWall,+Name="Core+Wall"&camera=10,20,5,0,-1,0,0,0,1&fov=60&query=Name
```
Clash indicator — selected elements rendered with clash styling:
```
ifc://example.com/org/project@heads/main?path=models/building.ifc&selector=IfcWall,+Name="Core+Wall"+IfcBeam,+Name="B1"&visibility=clash
```
--- ---
## 9. OpenCDE transport ## 9. OpenCDE transport

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()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------