mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 16:01:36 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60063ac1c7 | |||
| f2d3e226b4 | |||
| c0889c7f10 | |||
| e609f10559 | |||
| e70ce17431 | |||
| 256d5a63f1 | |||
| c4605f2a8f | |||
| 4a62ffe9ca | |||
| d5e890bccd | |||
| bba11aa619 | |||
| 9f848a73e1 | |||
| 4fb8af2278 | |||
| 78653a1708 | |||
| 216092150a | |||
| ade03b171a | |||
| b5c1b81ede | |||
| 47a20f0c7c | |||
| 52d894298e | |||
| 206cd6bbe1 |
@@ -1,400 +0,0 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
|
||||
# Linked file features — queries, styles, transforms, and multi-linking for linked IFC models
|
||||
|
||||
> **Living dev note** for the `Linked_File_Features` branch/PR. Read before working
|
||||
> on the feature; append decisions and findings as the PR is refined. This is *not* user
|
||||
> documentation — at merge it is removed or its durable parts promoted to code comments.
|
||||
> See [README.md](README.md) for the convention (introduced on the
|
||||
> `opening-template-on-type` branch; not yet on this branch's base).
|
||||
|
||||
## Problem
|
||||
|
||||
Linked IFC models (`bim.link_ifc`) had several gaps that made them hard to use as a
|
||||
"reference in other trades' models" workflow:
|
||||
|
||||
- One shared `.ifc.cache.blend` per IFC file meant the **same file could not be linked
|
||||
twice with different selector queries** — both links showed whichever query was cached
|
||||
first in-session, and whichever was cached last after reopening (Blender reuses one
|
||||
library datablock per path).
|
||||
- The selector query was not durably stored anywhere in the host IFC, so save → reopen
|
||||
lost or cross-wired the filter; a scripted `bpy.ops.bim.reload_link()` also wiped it.
|
||||
- Linked geometry got **flat diffuse-only materials** — external `.blend` styles
|
||||
(`IfcExternallyDefinedSurfaceStyle`) and per-layer materials (layerset slicing) that
|
||||
the normal import applies were ignored.
|
||||
- Moving a linked model required an explicit enable-edit → move → save dance on the
|
||||
active link only, with save/cancel buttons in the panel header.
|
||||
- The Explore tool's highlight broke (GPU type errors), drew at the link's *original*
|
||||
location when the link had been moved, and `bim.append_inspected_linked_element`
|
||||
placed appended elements at the original location too.
|
||||
|
||||
## Key facts established
|
||||
|
||||
- **Cache architecture**: `LoadLink.link_ifc` generates a Python script and runs a
|
||||
background Blender subprocess that executes `bim.load_linked_project` and saves a
|
||||
`.ifc.cache.blend`. The host session then *links* (not appends) the `IfcProject/...`
|
||||
collection from that blend and instances it via an empty (the link "handle").
|
||||
Georeferencing metadata lives in a sidecar `.cache.json`; extracted properties in
|
||||
`.cache.sqlite` (whole file, query-independent — deliberately shared across queries).
|
||||
- **Blender reuses an in-session library per path.** Loading the same blend path twice
|
||||
yields the same library/collection. This is what broke multi-query linking with a
|
||||
shared cache filename, and why per-query *filenames* (not cache invalidation) are the
|
||||
fix.
|
||||
- **Last-used operator properties** are reused on the next *interactive* invocation
|
||||
(UI button), while scripted `bpy.ops` calls always start from defaults. LoadLink's
|
||||
internal `self.query = link.query` fallback assignment was remembered by Blender and
|
||||
leaked into the next button click (`operator_query='IfcWindow'` for the door link).
|
||||
Any `is_property_set()`-based logic is corrupted the same way. Fix: `SKIP_SAVE` on
|
||||
volatile props. **A GUI-only bug like this is invisible to scripted repro** — both
|
||||
headless and windowed `--python` test runs passed while the manual flow failed.
|
||||
- **`IfcDocumentReference`** per link: attribute index 1 (`Identification`) already
|
||||
stores the link's 4×4 transformation (existing Bonsai convention). `Description`
|
||||
(IFC4+; **absent in IFC2X3**) now stores the selector query. One
|
||||
`IfcDocumentInformation` (Scope `LINKED_MODEL`) per file, one reference per link.
|
||||
- **Geometry iterator materials**: `material.instance_id()` is the STEP id of the
|
||||
`IfcSurfaceStyle` — or of an `IfcMaterial` when the item has a material but no style,
|
||||
hence the `is_a("IfcSurfaceStyle")` guard when resolving external styles.
|
||||
- **External styles**: `IfcExternallyDefinedSurfaceStyle.Location` (`.blend`, relative
|
||||
paths resolve against the *linked* IFC, not the host) + `Identification` in
|
||||
`data_block_type/name` form (e.g. `materials/Brick`), same convention as
|
||||
`bim.activate_external_style`.
|
||||
- **Chunk pipeline dedups materials by RGBA color** (`np.unique` on a color array), so
|
||||
style identity must ride along as an extra column to survive — added only for styles
|
||||
that actually resolve to an external material, so plain colored styles dedupe exactly
|
||||
as before.
|
||||
- **`slice_layerset_mesh` needs a local-space, per-element mesh** (bisect planes are in
|
||||
object space), which the chunk path can't provide (world-space, many elements per
|
||||
mesh) — hence routing multi-layer elements through the instanced path. Its
|
||||
`dissolve_limit` produces **ngons**, which broke the Explore highlight's
|
||||
triangles-from-`polygon.vertices` assumption downstream.
|
||||
- **ID properties round-trip as `IDPropertyArray`**, not plain lists (verified in
|
||||
4.5.7: empty list → flat `IDPropertyArray`; nested lists → list of `IDPropertyArray`
|
||||
items), and `GPUIndexBuf` rejects them — selection geometry must be converted to
|
||||
plain tuples on read.
|
||||
- **`scene.ray_cast` returns the hit instance's world matrix** (link empty matrix
|
||||
included). For instanced occurrence objects the object's own local matrix is *not*
|
||||
identity, so resolving the instancing empty must compare against
|
||||
`empty.matrix_world @ obj.matrix_world`, not the empty's matrix alone.
|
||||
- **Link matrix math**: the handle empty's matrix is `inv(L) @ T @ G` (L = host local
|
||||
matrix from georef props, T = stored transformation, G = linked model's global
|
||||
matrix from the cache json). The world-space displacement of a moved link is
|
||||
therefore `inv(L) @ T @ L` — no json read needed (`calculate_link_delta_matrix`).
|
||||
- **Undo consistency of auto-saved moves**: Blender undo of a handle move fires another
|
||||
depsgraph update, so the handler re-saves the reverted matrix — stored state stays
|
||||
consistent without transactions (a handler can't open one).
|
||||
|
||||
## Design
|
||||
|
||||
### Per-query caches + query persistence (multi-linking)
|
||||
|
||||
`tool.Project.get_link_cache_paths(filepath, query)` appends `.md5(query)[:8]` to the
|
||||
cache blend/json names; the empty query keeps the legacy un-suffixed names so existing
|
||||
caches stay valid. Every cache-path consumer goes through it — `link_ifc` build and
|
||||
invalidation, the subprocess json write, model-origin/georef indicator reads,
|
||||
`calculate_link_matrix`, `save_link_transformation`, and the per-link
|
||||
selectability/wireframe/visibility toggles (which match collections *by library
|
||||
filepath* and would otherwise affect every link of the file at once).
|
||||
|
||||
The query persists on each link's `IfcDocumentReference.Description` (written by
|
||||
`LinkIfc` and `ReloadLink`); `load_linked_models_from_ifc` restores from it, with a
|
||||
legacy-JSON fallback that only applies when the file has a **single** link (with
|
||||
several links the shared JSON can't say which link it belonged to). IFC2X3 hosts have
|
||||
no `Description` — custom queries are not restorable there (accepted).
|
||||
|
||||
`LoadLink`/`ReloadLink` volatile properties are `SKIP_SAVE` (see key facts). Cache
|
||||
clearing tolerates a missing blend (a reload with a brand-new query points at a
|
||||
not-yet-existing filename).
|
||||
|
||||
### Include/Exclude filter pair
|
||||
|
||||
The selector grammar's only cross-group combiner is `+` (union) and the `parent`
|
||||
facet cannot express "not under X" (its `!=`/regex paths also match by GlobalId, so
|
||||
negation removes everything with any parent), which makes set differences like
|
||||
"group members minus the slabs under aggregate X" structurally inexpressible in one
|
||||
query string. Links therefore carry an **Exclude** query beside the include —
|
||||
mirroring `EPset_Drawing`'s Include/Exclude pattern: final set = include (or the
|
||||
default set when empty) − exclude, applied in `LoadLinkedProject` and per link in
|
||||
`create_drawing`.
|
||||
|
||||
- **Cache key**: `get_link_cache_paths` hashes `md5(query + "\0" + exclude)` when an
|
||||
exclude exists; include-only filters keep the pre-exclude `md5(query)` so existing
|
||||
caches stay valid; empty filter keeps legacy un-suffixed names. Keying on query
|
||||
alone would let same-include/different-exclude links silently serve each other's
|
||||
geometry.
|
||||
- **Persistence**: `encode_link_filter`/`decode_link_filter` — a plain include is
|
||||
stored in `Description` as-is (backwards compatible); an exclude, a `loaded`
|
||||
state or a custom display name promotes the value to
|
||||
`{"include": …, "exclude": …, "loaded": …, "name": …}` JSON. Decode treats
|
||||
non-JSON as a legacy include string. The display name (`Link.display_name`,
|
||||
double-click the list row to rename; file path shows as placeholder while
|
||||
unset) exists to tell apart several links of the same file.
|
||||
- Exclude applies on top of the **default** element set too, so
|
||||
"everything except X" needs no explicit include.
|
||||
- UI labels are **Include**/**Exclude** (matching the drawing pattern), but the
|
||||
property identifier stays `query` for script (`bpy.ops.bim.link_ifc(query=…)`)
|
||||
and persistence compatibility.
|
||||
- Verified headless: `query=""`/`exclude="IfcDoor"` loads only the window;
|
||||
same file with a different filter gets its own cache; both filters survive
|
||||
save → reopen → reload.
|
||||
|
||||
### Auto-load on open
|
||||
|
||||
Links that were **loaded and visible** at IFC save time auto-load when the project
|
||||
is reopened. `ExportIFC` calls `tool.Project.update_linked_models_state()`, which
|
||||
rewrites each reference's `Description` with a `loaded` flag
|
||||
(`is_loaded and not is_hidden`); `load_linked_models_from_ifc` replays flagged
|
||||
links via `load_link` after restoring the list (missing files warn and skip so
|
||||
they can't break project open). The flag extends the same JSON blob as the
|
||||
exclude — plain legacy strings decode as no-autoload. Trade-off: project open
|
||||
pays the link-load cost up front (fast on cache hit; a missing cache rebuilds in
|
||||
a background Blender, same as clicking Load). Verified headless: loaded+visible
|
||||
auto-loads; unloaded and loaded-but-hidden links stay unloaded.
|
||||
|
||||
### Long-term serialization target: STEP Part 21 Edition 3
|
||||
|
||||
STEP p21e3 defines the standards-track version of this feature's persistence:
|
||||
`ANCHOR`/`REFERENCE` sections (clauses 9–10) let one file import entities from
|
||||
another via URI + fragment, and **anchor tags** (`{tagname: value}`) are the
|
||||
designated slot for out-of-schema metadata — a cleaner home than the
|
||||
`Description` JSON blob (see the review-round discussion). ifcopenshell does not
|
||||
implement these sections yet ([#668](https://github.com/IfcOpenShell/IfcOpenShell/issues/668),
|
||||
open, unassigned); if it ever does, the migration path is: link →
|
||||
`REFERENCE` to the linked file's project anchor, filter/transform/loaded
|
||||
metadata → anchor tags. Keeping the blob behind
|
||||
`encode_link_filter`/`decode_link_filter` makes that a two-function change.
|
||||
|
||||
Two p21e3 design points this branch already conforms to:
|
||||
|
||||
- **Identity**: p21e3 distinguishes volatile file-scoped entity numbers
|
||||
(`#100` fragments) from durable anchors/UUIDs — the same lesson behind our
|
||||
STEP-id collision fixes (GUID-based matching, `element.file` guards). Raw
|
||||
STEP ids must never cross a file boundary; IFC GlobalIds map 1:1 onto
|
||||
p21e3 UUID anchors.
|
||||
- **Transport** (clause A.4): exchange structures plus referenced resources
|
||||
can ship as one ZIP archive with references resolving inside it. Our posix,
|
||||
optionally relative `Location`s resolved via `resolve_uri` are exactly the
|
||||
invariants a future "package project with links" export would need.
|
||||
|
||||
Even full p21e3 support would not cover per-link transforms, filters, or load
|
||||
state — a `REFERENCE` imports entities, it does not place a model — so the
|
||||
app-level metadata remains; only its container would change.
|
||||
|
||||
### External styles + layerset slicing in the linked loader
|
||||
|
||||
`LoadLinkedProject.get_external_material(style_id)` resolves a style id → appended
|
||||
Blender material from the external `.blend`, cached two ways (per style id; per
|
||||
appended data-block, so styles sharing one material don't append duplicates). Appended
|
||||
materials get their stale `ifc_definition_id` cleared (the source `.blend` may have
|
||||
been authored in a Bonsai session; the id would be misread in the linked file *and*
|
||||
in the host once the cache links in). Applied in both loading paths — instanced
|
||||
occurrences directly, chunks via the style-id column.
|
||||
|
||||
Multi-layer elements (`IfcMaterialLayerSetUsage`, >1 layer) route through the
|
||||
instanced path and get `slice_layerset_mesh`, which gained a pluggable
|
||||
`style_to_material` resolver (defaults to the old `tool.Ifc.get_object` for the normal
|
||||
import) — the linked resolver prefers the external material, falling back to a flat
|
||||
diffuse from the style's shading colour. Also fixed there: newly appended layer
|
||||
materials are registered in the dedup dict (two layers sharing one style used to
|
||||
append it twice).
|
||||
|
||||
Trade-off: layered walls become individual instanced objects instead of chunk members;
|
||||
meshes shared between elements (same geometry id) bake the slice from the first
|
||||
element's layerset usage — same behaviour as the normal importer.
|
||||
|
||||
### Reload Link dialog
|
||||
|
||||
`bim.reload_link` now exposes File Path (+ browse button), Use Relative Path
|
||||
(defaulting to the stored path form), Use Cache (default off = old always-rebuild
|
||||
behaviour), the False Origin Mode project props, and Query. A file browser can't open
|
||||
from inside a props dialog, so the browse button runs `bim.select_link_filepath`
|
||||
(fileselect) which *reopens* the reload dialog with the chosen path, carrying the
|
||||
in-progress dialog state through the round trip (op props are baked at draw time).
|
||||
Path changes update `link.name`/`filepath` and, with a host IFC, the reference
|
||||
`Location` + document name — which is why `ReloadLink` became a `tool.Ifc.Operator`.
|
||||
Script calls without arguments preserve all stored link values via `is_property_set`.
|
||||
|
||||
`bim.reload_all_links` (refresh button beside Link IFC in the panel header) reloads
|
||||
every *loaded* link via argument-less `reload_link` calls — each link's stored
|
||||
path/query/exclude replay and its cache rebuilds from disk. Unloaded links are left
|
||||
alone. Deliberately expensive: one background cache rebuild per link.
|
||||
|
||||
### Per-row lock toggle + auto-saved transforms
|
||||
|
||||
Link editing moved from the panel header into each list row as a lock/unlock icon:
|
||||
unlock (`bim.enable_editing_link`) frees the handle; **any movement is persisted
|
||||
immediately** by a `depsgraph_update_post` handler (lazy — ticks without transform
|
||||
updates cost ~nothing); lock (`bim.disable_editing_link`) saves and locks.
|
||||
`bim.edit_link` and the explicit save step are **removed**; cancel/restore semantics
|
||||
no longer exist (undo or move it back). The save math lives in
|
||||
`tool.Project.save_link_transformation`. Enable/disable take a `link_index`
|
||||
(default −1 = active link) so several links can be edited at once and script calls
|
||||
stay compatible.
|
||||
|
||||
### Explore tool + append fixes for moved links
|
||||
|
||||
- Highlight triangles come from `mesh.calc_loop_triangles()` filtered to the queried
|
||||
element's polygon range (ngon-safe); edges keep `polygon.edge_keys` (no diagonals).
|
||||
- `get_selected_geometry` converts the ID-prop round trip to plain tuples (GPU
|
||||
rejects `IDPropertyArray`); TRIS drawing gated on its own data.
|
||||
- `QueryLinkedElement` passes the ray-cast instance matrix through;
|
||||
`find_obj_root` compares it against `empty @ obj_local` and falls back to the
|
||||
collection's only instance when no matrix is available (select-by-GUID flow).
|
||||
- `bim.append_inspected_linked_element` pre-multiplies the imported object's matrix by
|
||||
`calculate_link_delta_matrix(link)`, matching the link by the queried instance's
|
||||
root empty first (filepath alone is ambiguous with several links per file). The
|
||||
element's IFC placement syncs to the moved location on save — intended.
|
||||
|
||||
### Drawings (`create_drawing`) — moved links and per-link queries
|
||||
|
||||
- The linework serializer opened linked IFCs raw, so a moved link's elements were
|
||||
drawn at their *original* coordinates (usually outside the drawing extents —
|
||||
"linked objects disappear from prints after moving the link").
|
||||
- The stored link transformation is already the **model-space** delta (that is how
|
||||
`save_link_transformation` derives it), which is exactly the space the serializer
|
||||
works in — so it can be baked straight into the geometry iterator via the existing
|
||||
`model-offset`/`model-rotation` settings. The mapping composes
|
||||
`Trans(model-offset) @ Rot(model-rotation)` (see `mapping.cpp`), matching the
|
||||
`Trans(t) @ Rot(R)` decomposition of the rigid link matrix; `model-rotation` is a
|
||||
quaternion passed as `(x, y, z, w)`. The pre-existing 2mm plan-view Z-offset simply
|
||||
adds onto the translation (translations commute).
|
||||
- The serialization loop previously collected files in a dict keyed by filepath, which
|
||||
**collapsed same-file links into one pass** (one transform — the last link's — and
|
||||
no query awareness): with two links of one file, only one showed in the drawing.
|
||||
It now iterates one entry per link (`(path, file, transform, query)` tuples), and
|
||||
intersects each link's drawing elements with
|
||||
`ifcopenshell.util.selector.filter_elements(ifc, link.query)` so the drawing shows
|
||||
what that link actually displays in the viewport.
|
||||
- `tool.Project.get_link_transformation_matrix(link)` is the shared accessor for the
|
||||
stored 4×4 (None when identity/absent).
|
||||
- Verified headless with the window/door kit: moved window offset in the SVG by
|
||||
exactly 5m × scale; unmoved door at its native position; both links present.
|
||||
|
||||
### Drawings — `.cut` styling for linked models (BISECT cut mode)
|
||||
|
||||
- The default **BISECT** cut mode deletes the OpenCASCADE serializer's cut linework
|
||||
(`remove_cut_linework`) and regenerates cuts by bisecting **Blender mesh objects**
|
||||
(`generate_bisect_linework` over `context.visible_objects`). Linked models are
|
||||
instanced collections with no mesh objects, so their cuts were deleted and never
|
||||
regenerated — linked elements only ever appeared as `projection`, and the `.cut`
|
||||
CSS rule never applied to them. Long-standing gap, unrelated to moved links
|
||||
(A/B-tested against pre-branch code: identical).
|
||||
- Fix: `remove_cut_linework` only removes cut groups whose guid resolves in the
|
||||
**host** file — linked elements keep the serializer's cut geometry, which the
|
||||
merge step then classes as `cut`.
|
||||
- **Cross-file STEP-id collision**: `tool.Ifc.get_object(linked_entity)` resolves the
|
||||
entity's STEP id against the *host* session's id map and can return an arbitrary
|
||||
host object (in the test project: the drawing camera, crashing
|
||||
`generate_material_layers` with "expected 'Mesh' found 'Camera'"). Guarded via
|
||||
`element.file is tool.Ifc.get()` in `generate_material_layers` and the merge step.
|
||||
- **Paint order**: the projection-under-cut convention was enforced only in
|
||||
OPENCASCADE mode (`move_projection_to_bottom`); BISECT appends its own cut paths
|
||||
last so it never needed it — but the retained serializer cuts of linked models are
|
||||
emitted *before* the projections. BISECT now runs the same pass; `BringToFront`
|
||||
(`move_elements_to_top`) still gets the final say.
|
||||
- Known limitation: linked cut paths are raw serializer output — they skip the
|
||||
shapely path-closing/merging and the material-layer hatching pass (both need host
|
||||
Blender objects). Stroke + fill from `.cut` CSS apply; layered hatching inside
|
||||
linked cuts is a candidate follow-up.
|
||||
- Debugging note: merged cut groups carry member guids as CSS *classes*, not as the
|
||||
`ifcopenshell:guid` attribute — inspect both when checking cut output.
|
||||
|
||||
## Deferred refactors (deliberate)
|
||||
|
||||
- **Upstream `exclude=` on `filter_elements`** — the include−exclude set difference
|
||||
is hand-rolled twice (links, drawings) because the selector grammar has no
|
||||
difference operator and `parent` negation is broken by design (its `!=`/regex
|
||||
paths also match GlobalIds, so negation strips everything that has a parent).
|
||||
The right home is an `exclude=` parameter on
|
||||
`ifcopenshell.util.selector.filter_elements`, documented in
|
||||
`selector_syntax.rst` together with the `parent`-negation limitation. Deferred
|
||||
to a separate ifcopenshell-python PR (different review audience; would widen
|
||||
this PR mid-review). Once it lands, both Bonsai call sites collapse.
|
||||
- **Core/tool ceremony skipped** — the new `tool.Project` methods have no
|
||||
`core/tool.py` interface declarations and no `bonsai/core` orchestration
|
||||
functions, matching the pre-existing linked-model code (which bypasses the
|
||||
core layer wholesale; `LoadLinkedProject` is flagged "prototyping" upstream).
|
||||
Interfaces nobody calls through wouldn't add testability — the pure helpers
|
||||
(`encode_link_filter`/`decode_link_filter`, `get_link_cache_paths`) are
|
||||
covered directly in `test/tool/test_project.py` instead. Revisit if the
|
||||
linked-model subsystem is ever promoted out of prototype status.
|
||||
|
||||
## Review round 1 (PR #8242, falken10vdl) — decisions
|
||||
|
||||
- **Path-form mismatch → duplicate documents (confirmed bug, fixed).**
|
||||
`get_linked_models_documents()` keyed documents by the *stored* `Location`, so
|
||||
linking the same file first relative then absolute (or vice versa) created a second
|
||||
`IfcDocumentInformation`. Both sides of the lookup now normalize through
|
||||
`tool.Ifc.resolve_uri()` before matching.
|
||||
- **`Description` for the query — kept.** It is implementation metadata in an IFC
|
||||
attribute, but consistent with the existing convention on these same references
|
||||
(`Identification` stores the 4×4 transformation, a bigger stretch). References are
|
||||
Bonsai-managed (`Scope="LINKED_MODEL"`), so user-description collisions are unlikely.
|
||||
A cleaner consolidated convention (query + transform + options in one serialized
|
||||
attribute) is a candidate follow-up, deliberately out of scope here.
|
||||
- **`md5(query)[:8]` — kept.** 32 bits ≈ birthday collision at ~65k distinct queries
|
||||
*per file*; and a collision is not silent: the cache JSON stores the full query and
|
||||
`should_clear_cache()` compares it, so a colliding cache is detected and rebuilt
|
||||
(self-healing).
|
||||
- **Depsgraph autosave vs save-on-lock — autosave kept.** Save-on-lock alone loses the
|
||||
"what you see is what's saved" guarantee (move + save project without locking =
|
||||
silently dropped move) and loses undo tracking (undo fires a depsgraph update that
|
||||
re-saves the reverted transform). The handler early-outs when no links exist and only
|
||||
works on ticks containing an object-transform update while a link is unlocked.
|
||||
|
||||
## Status — implemented (verified in Blender, incl. headless + GUI repro runs)
|
||||
|
||||
Six commits on `Linked_File_Features`:
|
||||
|
||||
- `0096c0f6a2` reload_link without a query preserves the stored one.
|
||||
- `40db55e52d` external styles + layerset slicing for linked models
|
||||
(`project/operator.py`, `tool/loader.py`).
|
||||
- `d210d4c814` full Reload Link dialog + `bim.select_link_filepath`.
|
||||
- `3dc161f0f2` per-row lock toggle, auto-save handler, `edit_link` removed
|
||||
(`project/operator.py`, `project/ui.py`, `project/__init__.py`, `tool/project.py`).
|
||||
- `0571d22855` Explore highlight (ngons, IDPropertyArray), moved-link highlight,
|
||||
append placement (`tool/project.py`, `project/operator.py`, `project/decorator.py`).
|
||||
- `c14592ec0a` per-query caches, Description persistence, SKIP_SAVE.
|
||||
|
||||
Plus:
|
||||
|
||||
- `ee43ed5526` review-round path normalization in `get_linked_models_documents` /
|
||||
`LinkIfc` (see Review round 1).
|
||||
- `1669cbcd43` drawing support for moved links and per-link queries in
|
||||
`create_drawing` (`drawing/operator.py`, `tool/project.py`).
|
||||
- `.cut` styling for linked models in BISECT cut mode + STEP-id collision guards +
|
||||
paint order (`drawing/operator.py`) — committed together with this note update.
|
||||
|
||||
End-to-end verified with a two-links-one-file kit (window/door, distinct queries):
|
||||
correct visuals on load, after save → reopen → reload, in both headless and windowed
|
||||
Blender.
|
||||
|
||||
## Things to test / verify
|
||||
|
||||
- **IFC2X3 host**: `Description` doesn't exist — link queries silently not restored on
|
||||
reopen (legacy fallback only for single-link files). Acceptable? Warn?
|
||||
- **Relative-path links** (`use_relative_path`) through the whole cycle: cache paths,
|
||||
reference `Location`, reload path change, query restore. The duplicate-document case
|
||||
(same file linked relative then absolute) is fixed — verify one document with two
|
||||
references via `IfcDocumentInformation.HasDocumentReferences`.
|
||||
- Same file linked twice, **both moved differently**: Explore highlight and append
|
||||
placement per instance (root-empty matching), per-link visibility toggles.
|
||||
- External styles with **image textures**: paths relative to the style's source
|
||||
`.blend` may not resolve from the cache blend's location (shared limitation with the
|
||||
normal import path).
|
||||
- Stale cache orphans: per-query filenames accumulate one blend+json pair per distinct
|
||||
query next to the IFC; nothing auto-deletes them. Cleanup on unlink? Document?
|
||||
- Mid-drag auto-save writes the IFC reference outside Bonsai's transaction system —
|
||||
confirm no undo-stack weirdness in longer editing sessions.
|
||||
- Layerset slicing on meshes shared by elements with *different* usages (offset/sense)
|
||||
bakes the first element's slice — same as normal import, but worth a look with types.
|
||||
- `bim.select_link_filepath` round trip when the reload dialog was opened for a
|
||||
non-active link, and dialog-state carry-over after editing the query *then* browsing.
|
||||
- **Drawing SVG guid cache vs moved links**: `create_drawing` skips elements whose
|
||||
guids already exist in the drawing's SVG (`cached_linework`, invalidated only for
|
||||
*edited host objects*). Moving a link does not invalidate its elements, so a
|
||||
regenerated drawing keeps their old positions until the SVG is deleted. Candidate
|
||||
fix: subtract a moved link's guids from `cached_linework` (compare stored transform
|
||||
against the one recorded at last generation).
|
||||
- Same element appearing in two links of one file (overlapping queries) serializes
|
||||
twice with different transforms; the SVG guid cache keeps whichever came first on
|
||||
regeneration. Degenerate case — probably fine to ignore, but note it.
|
||||
@@ -120,10 +120,10 @@ class IfcExporter:
|
||||
# updata_representation will run edit_object_placement if object is scaled
|
||||
# and had no openings.
|
||||
return element
|
||||
if not tool.Ifc.is_moved(obj):
|
||||
return
|
||||
if element.is_a("IfcGridAxis"):
|
||||
return self.sync_grid_axis_object_placement(obj, element)
|
||||
if not tool.Ifc.is_moved(obj):
|
||||
return
|
||||
if not hasattr(element, "ObjectPlacement"):
|
||||
return
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
|
||||
@@ -134,7 +134,8 @@ class IfcExporter:
|
||||
grid_obj = tool.Ifc.get_object(grid)
|
||||
if grid_obj:
|
||||
self.sync_object_placement(grid_obj)
|
||||
if grid_obj.matrix_world != obj.matrix_world:
|
||||
matrices_differ = grid_obj.matrix_world != obj.matrix_world
|
||||
if matrices_differ:
|
||||
bpy.ops.bim.update_representation(obj=obj.name)
|
||||
tool.Geometry.record_object_position(obj)
|
||||
|
||||
|
||||
@@ -82,7 +82,15 @@ import math
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, ClassVar, Literal, Optional, Protocol, runtime_checkable
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
ClassVar,
|
||||
Literal,
|
||||
Optional,
|
||||
Protocol,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
import blf
|
||||
import bpy
|
||||
@@ -105,6 +113,9 @@ from mathutils.kdtree import KDTree
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing.shaders import ExtrusionGuidesShader
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import bmesh
|
||||
|
||||
SNAP_POINT_SIZE = 10.0
|
||||
SNAP_POINT_COLOR = (1.0, 0.5, 0.0, 1.0)
|
||||
SNAP_MAX_RADIUS = 50.0
|
||||
@@ -2035,7 +2046,9 @@ class TexturedQuadGizmoMixin(StaticTrisGizmoMixin):
|
||||
|
||||
def setup(self) -> None:
|
||||
super().setup()
|
||||
from bonsai.bim.module.drawing import gizmo_textures
|
||||
from bonsai.bim.module.drawing import (
|
||||
gizmo_textures, # ty: ignore[unresolved-import]
|
||||
)
|
||||
|
||||
self._quad_batch = batch_for_shader(
|
||||
gizmo_textures.get_shader(),
|
||||
@@ -2044,7 +2057,9 @@ class TexturedQuadGizmoMixin(StaticTrisGizmoMixin):
|
||||
)
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
from bonsai.bim.module.drawing import gizmo_textures
|
||||
from bonsai.bim.module.drawing import (
|
||||
gizmo_textures, # ty: ignore[unresolved-import]
|
||||
)
|
||||
|
||||
texture = gizmo_textures.get_icon_texture(self.icon_name)
|
||||
if texture is None:
|
||||
|
||||
@@ -602,7 +602,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
context_type: Literal["body", "annotation"],
|
||||
drawing_elements: set[ifcopenshell.entity_instance],
|
||||
target_view: str,
|
||||
link_transform: Optional[np.ndarray] = None,
|
||||
link_matrix: Optional[Matrix] = None,
|
||||
) -> None:
|
||||
drawing_elements = drawing_elements.copy()
|
||||
contexts_: list[list[int]] = getattr(contexts, context_type)
|
||||
@@ -614,22 +614,19 @@ class CreateDrawing(bpy.types.Operator):
|
||||
geom_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
|
||||
geom_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
|
||||
|
||||
offset = np.zeros(3)
|
||||
if ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view:
|
||||
is_plan = ifc.by_id(context[0]).ContextType == "Plan" and "PLAN_VIEW" in target_view
|
||||
z_offset = (0.002 if target_view == "PLAN_VIEW" else -0.002) if is_plan else 0.0
|
||||
|
||||
if link_matrix is not None:
|
||||
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc)
|
||||
t = link_matrix.to_translation()
|
||||
offset = (t.x / unit_scale, t.y / unit_scale, t.z / unit_scale + z_offset)
|
||||
geom_settings.set("model-offset", offset)
|
||||
q = link_matrix.to_quaternion()
|
||||
geom_settings.set("model-rotation", (q.x, q.y, q.z, q.w))
|
||||
elif z_offset:
|
||||
# A 2mm Z offset to combat Z-fighting in plan or RCPs
|
||||
offset[2] = 0.002 if target_view == "PLAN_VIEW" else -0.002
|
||||
if link_transform is not None:
|
||||
# Bake a moved link's transformation into the geometry. The
|
||||
# mapping composes Trans(model-offset) @ Rot(model-rotation),
|
||||
# matching the Trans(t) @ Rot(R) decomposition of the rigid
|
||||
# link matrix, so the Z offset above simply adds on.
|
||||
offset += link_transform[:3, 3]
|
||||
quaternion = Matrix(link_transform.tolist()).to_quaternion()
|
||||
geom_settings.set(
|
||||
"model-rotation", (quaternion.x, quaternion.y, quaternion.z, quaternion.w)
|
||||
)
|
||||
if offset.any():
|
||||
geom_settings.set("model-offset", tuple(float(o) for o in offset))
|
||||
geom_settings.set("model-offset", (0.0, 0.0, z_offset))
|
||||
|
||||
geom_settings.set("context-ids", context)
|
||||
it = ifcopenshell.geom.iterator(
|
||||
@@ -682,10 +679,6 @@ class CreateDrawing(bpy.types.Operator):
|
||||
if "projection" in el.get("class", "").split():
|
||||
continue
|
||||
element = self.get_element_by_guid(el.get("{http://www.ifcopenshell.org/ns}guid"))
|
||||
if element is None or element.file is not tool.Ifc.get():
|
||||
# Linked model element - no Blender object to bisect, and its
|
||||
# STEP id must not be resolved against the host session.
|
||||
continue
|
||||
if not (obj := tool.Ifc.get_object(element)):
|
||||
continue
|
||||
if not (material := ifcopenshell.util.element.get_material(element)):
|
||||
@@ -941,25 +934,16 @@ class CreateDrawing(bpy.types.Operator):
|
||||
|
||||
bim_props = tool.Blender.get_bim_props()
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
# Map ifc_path → (ifc_file, link_matrix); main file has no link_matrix (None)
|
||||
files: dict[str, tuple[ifcopenshell.file, Optional[Matrix]]] = {bim_props.ifc_file: (tool.Ifc.get(), None)}
|
||||
|
||||
props = tool.Project.get_project_props()
|
||||
# One entry per file *and* per link - the same file can be linked
|
||||
# several times with different queries and transformations, so links
|
||||
# cannot be collapsed into a dict keyed by filepath.
|
||||
# Each entry is (path, file, link transformation or None, link query, link exclude).
|
||||
file_entries: list[tuple[str, ifcopenshell.file, Optional[np.ndarray], str, str]] = [
|
||||
(bim_props.ifc_file, tool.Ifc.get(), None, "", "")
|
||||
]
|
||||
for link in props.get_loaded_links_for_drawings():
|
||||
file_entries.append(
|
||||
(
|
||||
link.filepath,
|
||||
self.get_linked_file(link),
|
||||
tool.Project.get_link_transformation_matrix(link),
|
||||
link.query,
|
||||
link.exclude,
|
||||
)
|
||||
)
|
||||
try:
|
||||
link_matrix = tool.Project.calculate_link_matrix(link)
|
||||
except Exception:
|
||||
link_matrix = None
|
||||
files[link.filepath] = (self.get_linked_file(link), link_matrix)
|
||||
|
||||
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
|
||||
self.setup_serialiser(target_view)
|
||||
@@ -973,7 +957,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
raycast_objs = set()
|
||||
elements_with_faces = set()
|
||||
|
||||
for ifc_path, ifc, link_transform, link_query, link_exclude in file_entries:
|
||||
for ifc_path, (ifc, link_matrix) in files.items():
|
||||
# Don't use draw.main() just whilst we're prototyping and experimenting
|
||||
# TODO: hash paths are never used
|
||||
ifc_hash = hashlib.md5(ifc_path.encode("utf-8")).hexdigest()
|
||||
@@ -981,11 +965,6 @@ class CreateDrawing(bpy.types.Operator):
|
||||
|
||||
self.serialiser.setFile(ifc)
|
||||
drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element, ifc_file=ifc)
|
||||
if link_query:
|
||||
# Draw only what the link's selector filter loaded in the viewport.
|
||||
drawing_elements &= ifcopenshell.util.selector.filter_elements(ifc, link_query)
|
||||
if link_exclude:
|
||||
drawing_elements -= ifcopenshell.util.selector.filter_elements(ifc, link_exclude)
|
||||
|
||||
if self.cprops.fill_mode == "SHAPELY":
|
||||
for element in drawing_elements.copy():
|
||||
@@ -1001,11 +980,9 @@ class CreateDrawing(bpy.types.Operator):
|
||||
# A drawing prioritises a target view context first, followed by a model view context as a fallback.
|
||||
# Specifically for PLAN_VIEW and REFLECTED_PLAN_VIEW, any Plan context is also prioritised.
|
||||
contexts = self.get_linework_contexts(ifc, target_view)
|
||||
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view, link_matrix)
|
||||
self.serialize_contexts_elements(
|
||||
ifc, tree, contexts, "body", drawing_elements, target_view, link_transform
|
||||
)
|
||||
self.serialize_contexts_elements(
|
||||
ifc, tree, contexts, "annotation", drawing_elements, target_view, link_transform
|
||||
ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix
|
||||
)
|
||||
|
||||
if tool.Ifc.get() == ifc and self.camera_element not in drawing_elements:
|
||||
@@ -1061,10 +1038,6 @@ class CreateDrawing(bpy.types.Operator):
|
||||
if self.cprops.generate_material_layers:
|
||||
self.generate_material_layers(context, root)
|
||||
self.merge_linework_and_add_metadata(root)
|
||||
# Bisect cut linework is appended after the projections, but the
|
||||
# retained serializer cuts of linked models precede them - enforce
|
||||
# the projection-under-cut convention like OPENCASCADE mode does.
|
||||
self.move_projection_to_bottom(root)
|
||||
self.move_elements_to_top(root)
|
||||
elif self.cprops.cut_mode == "OPENCASCADE":
|
||||
self.move_projection_to_bottom(root)
|
||||
@@ -1460,21 +1433,9 @@ class CreateDrawing(bpy.types.Operator):
|
||||
continue
|
||||
|
||||
def remove_cut_linework(self, root):
|
||||
"""Remove host elements' cut linework so bisecting can regenerate it.
|
||||
|
||||
Linked model elements keep the serializer's cut geometry - bisect
|
||||
linework is generated from Blender mesh objects, and linked models
|
||||
are instanced collections without any.
|
||||
"""
|
||||
ifc_file = tool.Ifc.get()
|
||||
for el in root.findall(".//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}guid]"):
|
||||
if "projection" in el.get("class", "").split():
|
||||
continue
|
||||
try:
|
||||
ifc_file.by_guid(el.get("{http://www.ifcopenshell.org/ns}guid"))
|
||||
except RuntimeError:
|
||||
continue # Linked model element.
|
||||
el.getparent().remove(el)
|
||||
if "projection" not in el.get("class", "").split():
|
||||
el.getparent().remove(el)
|
||||
|
||||
def merge_linework_and_add_metadata(self, root):
|
||||
join_criteria = ifcopenshell.util.element.get_pset(self.camera_element, "EPset_Drawing", "JoinCriteria")
|
||||
@@ -1509,9 +1470,7 @@ class CreateDrawing(bpy.types.Operator):
|
||||
classes.append("cut")
|
||||
el.set("class", " ".join(classes))
|
||||
|
||||
# Resolving a linked element's STEP id against the host session
|
||||
# would return an arbitrary host object.
|
||||
obj = tool.Ifc.get_object(element) if element is not None and element.file is tool.Ifc.get() else None
|
||||
obj = tool.Ifc.get_object(element)
|
||||
|
||||
if not obj: # This is a linked model object. For now, do nothing.
|
||||
continue
|
||||
|
||||
@@ -27,6 +27,7 @@ import bonsai.tool as tool
|
||||
from . import (
|
||||
array,
|
||||
covering,
|
||||
decorator,
|
||||
door,
|
||||
external,
|
||||
grid,
|
||||
|
||||
@@ -38,6 +38,7 @@ import numpy as np
|
||||
from ifcopenshell.util.shape_builder import ShapeBuilder
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
|
||||
@@ -138,7 +138,7 @@ def update_bbim_railing_pset(element: ifcopenshell.entity_instance, railing_data
|
||||
|
||||
def generate_wall_mounted_handrail_preview(
|
||||
obj: bpy.types.Object,
|
||||
props: "BIMRailingProperties",
|
||||
props: "prop.BIMRailingProperties",
|
||||
path_data: dict[str, Any],
|
||||
si_conversion: float,
|
||||
) -> None:
|
||||
@@ -860,7 +860,9 @@ class GizmoRailingSchematic(bpy.types.GizmoGroup, gizmo.BaseSchematicGizmoGroup)
|
||||
terminal_world = anchor + billboard_rot @ view_rotation @ terminal_local
|
||||
self.terminal_gizmo.matrix_basis = gizmo.billboarded_at(terminal_world, billboard_rot, 0.18)
|
||||
|
||||
def update_editing_gizmos(self, context: bpy.types.Context, mw: "Matrix", props: "BIMRailingProperties") -> None:
|
||||
def update_editing_gizmos(
|
||||
self, context: bpy.types.Context, mw: "Matrix", props: "prop.BIMRailingProperties"
|
||||
) -> None:
|
||||
"""Hide the pen gizmo while polyline path-edit is active; reposition the cycle icon.
|
||||
|
||||
The base class shows the pen gizmo whenever ``is_editing`` is False,
|
||||
|
||||
@@ -45,6 +45,7 @@ classes = (
|
||||
operator.DisableEditingHeader,
|
||||
operator.DisableEditingLink,
|
||||
operator.EditHeader,
|
||||
operator.EditLink,
|
||||
operator.EditProjectLibrary,
|
||||
operator.EnableCulling,
|
||||
operator.EnableEditingHeader,
|
||||
@@ -66,7 +67,6 @@ classes = (
|
||||
operator.QueryLinkedElement,
|
||||
operator.RefreshClippingPlanes,
|
||||
operator.RefreshLibrary,
|
||||
operator.ReloadAllLinks,
|
||||
operator.ReloadLink,
|
||||
operator.RemoveProjectLibrary,
|
||||
operator.RevertProject,
|
||||
@@ -74,7 +74,6 @@ classes = (
|
||||
operator.SaveLibraryFile,
|
||||
operator.SelectLibraryFile,
|
||||
operator.SelectLinkedModelElement,
|
||||
operator.SelectLinkFilepath,
|
||||
operator.SelectLinkHandle,
|
||||
operator.ToggleFilterCategories,
|
||||
operator.ToggleLinkSelectability,
|
||||
@@ -110,45 +109,12 @@ classes = (
|
||||
addon_keymaps = []
|
||||
|
||||
|
||||
@bpy.app.handlers.persistent
|
||||
def _autosave_link_transforms(scene, depsgraph):
|
||||
"""Persist link transformations whenever an editing link's handle is moved.
|
||||
|
||||
Deliberate exemption from the transaction rule in
|
||||
docs/guides/development/undo_system.rst: a handler cannot run inside
|
||||
execute_ifc_operator, so this IFC write is not undo-tracked. It stays
|
||||
consistent anyway because undoing the move fires another depsgraph
|
||||
update, which re-saves the reverted matrix.
|
||||
"""
|
||||
import bonsai.tool as tool
|
||||
|
||||
props = tool.Project.get_project_props()
|
||||
if not props.links:
|
||||
return
|
||||
handles = None
|
||||
for update in depsgraph.updates:
|
||||
if not update.is_updated_transform or not isinstance(update.id, bpy.types.Object):
|
||||
continue
|
||||
if handles is None:
|
||||
# Built lazily so ticks without transform updates stay cheap.
|
||||
handles = {}
|
||||
for link in props.links:
|
||||
if link.is_loaded and link.is_editing and (handle := tool.Project.get_link_empty_handle(link)):
|
||||
handles[handle] = link
|
||||
if not handles:
|
||||
return
|
||||
if link := handles.get(update.id.original):
|
||||
tool.Project.save_link_transformation(link)
|
||||
|
||||
|
||||
def register():
|
||||
if not bpy.app.background:
|
||||
bpy.utils.register_tool(workspace.ExploreTool, after={"builtin.transform"}, separator=True, group=False)
|
||||
bpy.types.Scene.BIMProjectProperties = bpy.props.PointerProperty(type=prop.BIMProjectProperties)
|
||||
bpy.types.Scene.MeasureToolSettings = bpy.props.PointerProperty(type=prop.MeasureToolSettings)
|
||||
bpy.app.handlers.load_post.append(decorator.toggle_decorations_on_load)
|
||||
if _autosave_link_transforms not in bpy.app.handlers.depsgraph_update_post:
|
||||
bpy.app.handlers.depsgraph_update_post.append(_autosave_link_transforms)
|
||||
bpy.types.TOPBAR_MT_file_import.append(ui.file_import_menu)
|
||||
bpy.types.TOPBAR_MT_file.prepend(ui.file_menu)
|
||||
bpy.types.TOPBAR_MT_file_context_menu.prepend(ui.file_menu)
|
||||
@@ -173,8 +139,6 @@ def unregister():
|
||||
del bpy.types.Scene.BIMProjectProperties
|
||||
del bpy.types.Scene.MeasureToolSettings
|
||||
bpy.app.handlers.load_post.remove(decorator.toggle_decorations_on_load)
|
||||
if _autosave_link_transforms in bpy.app.handlers.depsgraph_update_post:
|
||||
bpy.app.handlers.depsgraph_update_post.remove(_autosave_link_transforms)
|
||||
bpy.types.TOPBAR_MT_file.remove(ui.file_menu)
|
||||
bpy.types.TOPBAR_MT_file_context_menu.remove(ui.file_menu)
|
||||
|
||||
|
||||
@@ -99,7 +99,6 @@ class ProjectDecorator:
|
||||
|
||||
if geom.selected_edges:
|
||||
self.draw_batch("LINES", selected_vertices, selected_elements_color, geom.selected_edges)
|
||||
if geom.selected_tris:
|
||||
self.draw_batch(
|
||||
"TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), geom.selected_tris
|
||||
)
|
||||
|
||||
@@ -1360,18 +1360,10 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
|
||||
)
|
||||
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
|
||||
query: bpy.props.StringProperty(
|
||||
name="Include",
|
||||
name="Query",
|
||||
description=(
|
||||
"Selector query for the elements to load from the linked model. E.g. 'IfcElement'.\n\n"
|
||||
"Default when empty - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
|
||||
),
|
||||
)
|
||||
exclude: bpy.props.StringProperty(
|
||||
name="Exclude",
|
||||
description=(
|
||||
"Selector query whose matches are excluded from the loaded elements.\n\n"
|
||||
"Applied on top of the query (or the default set), providing the set "
|
||||
"difference a single query cannot express. E.g. 'IfcSlab, parent=\"X\"'."
|
||||
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
|
||||
"Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1385,7 +1377,6 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
|
||||
use_relative_path: bool
|
||||
use_cache: bool
|
||||
query: str
|
||||
exclude: str
|
||||
|
||||
def draw(self, context):
|
||||
assert self.layout
|
||||
@@ -1404,7 +1395,6 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
|
||||
row = self.layout.row()
|
||||
row.prop(pprops, "project_north")
|
||||
self.layout.prop(self, "query", placeholder="IfcElement")
|
||||
self.layout.prop(self, "exclude", placeholder='IfcSlab, parent="..."')
|
||||
|
||||
def _execute(self, context):
|
||||
start = time.time()
|
||||
@@ -1427,26 +1417,18 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
|
||||
|
||||
new = props.links.add()
|
||||
if tool.Ifc.get():
|
||||
# Look up by resolved absolute path so a file already linked
|
||||
# with a relative Location (or vice versa) reuses its document.
|
||||
resolved_filepath = Path(tool.Ifc.resolve_uri(filepath)).as_posix()
|
||||
if not (document := existing_links.get(resolved_filepath)):
|
||||
if not (document := existing_links.get(filepath)):
|
||||
document = ifcopenshell.api.document.add_information(tool.Ifc.get())
|
||||
document.Name = Path(filepath).name
|
||||
document.Scope = "LINKED_MODEL"
|
||||
reference = ifcopenshell.api.document.add_reference(tool.Ifc.get(), information=document)
|
||||
reference[1] = ",".join([str(o) for o in np.eye(4).flatten().tolist()])
|
||||
reference.Location = filepath.replace("\\", "/")
|
||||
# Persist the filter per reference (Description is IFC4+ only).
|
||||
description = tool.Project.encode_link_filter(self.query, self.exclude, loaded=True)
|
||||
if description and hasattr(reference, "Description"):
|
||||
reference.Description = description
|
||||
new.ifc_definition_id = reference.id()
|
||||
new.name = filepath
|
||||
new.filepath = filepath
|
||||
new.query = self.query
|
||||
new.exclude = self.exclude
|
||||
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query, exclude=self.exclude)
|
||||
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query)
|
||||
|
||||
|
||||
class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
|
||||
@@ -1505,28 +1487,21 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Load the selected file"
|
||||
|
||||
# SKIP_SAVE: Blender reuses an operator's last-used property values on the
|
||||
# next interactive invocation, which would leak one link's query/cache
|
||||
# settings into another link's load.
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True, options={"SKIP_SAVE"})
|
||||
query: bpy.props.StringProperty(options={"SKIP_SAVE"})
|
||||
exclude: bpy.props.StringProperty(options={"SKIP_SAVE"})
|
||||
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
|
||||
query: bpy.props.StringProperty()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
link_index: int
|
||||
use_cache: bool
|
||||
query: str
|
||||
exclude: str
|
||||
|
||||
def _execute(self, context):
|
||||
self.link = tool.Project.get_project_props().links[self.link_index]
|
||||
# Fall back to the Link's stored filter so callers that omit it
|
||||
# Fall back to the Link's stored query so callers that omit it
|
||||
# still replay the filter the link was created with.
|
||||
if not self.query and self.link.query:
|
||||
self.query = self.link.query
|
||||
if not self.exclude and self.link.exclude:
|
||||
self.exclude = self.link.exclude
|
||||
filepath = Path(tool.Ifc.resolve_uri(self.link.filepath))
|
||||
if not filepath.exists():
|
||||
self.report({"ERROR"}, f"File does not exist: '{filepath}'")
|
||||
@@ -1563,21 +1538,22 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
self.link.is_loaded = False
|
||||
|
||||
def link_ifc(self) -> Union[set[str], None]:
|
||||
blend_filepath, json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query, self.exclude)
|
||||
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
|
||||
h5_filepath = self.filepath_.with_suffix(".ifc.cache.h5")
|
||||
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
|
||||
|
||||
def should_clear_cache() -> bool:
|
||||
if not self.use_cache:
|
||||
return True
|
||||
if not blend_filepath.exists():
|
||||
return False
|
||||
if not json_filepath.exists():
|
||||
return True
|
||||
data = json.loads(json_filepath.read_text())
|
||||
# Empty 'query' - model loaded without custom query.
|
||||
# Missing 'query' - model was loaded before custom queries were introduced in Bonsai.
|
||||
return data.get("query", "") != self.query or data.get("exclude", "") != self.exclude
|
||||
query = data.get("query", "")
|
||||
return query != self.query
|
||||
|
||||
if should_clear_cache() and blend_filepath.exists():
|
||||
if should_clear_cache():
|
||||
os.remove(blend_filepath)
|
||||
|
||||
if not blend_filepath.exists():
|
||||
@@ -1605,7 +1581,7 @@ def run():
|
||||
pprops.project_north = "{pprops.project_north}"
|
||||
# Use absolute path to be safe from cwd changes.
|
||||
try:
|
||||
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}", query={repr(self.query)}, exclude={repr(self.exclude)})
|
||||
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}", query={repr(self.query)})
|
||||
except RuntimeError as e:
|
||||
# Operator failed (returned CANCELLED with error report)
|
||||
print(f"Failed to load linked project: {{e}}")
|
||||
@@ -1654,7 +1630,7 @@ except Exception as e:
|
||||
if len(tool.Project.get_project_props().links) > 1:
|
||||
return # Only the first link sets the origin
|
||||
|
||||
json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query, self.exclude)[1]
|
||||
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
|
||||
if not json_filepath.exists():
|
||||
return
|
||||
|
||||
@@ -1673,7 +1649,8 @@ except Exception as e:
|
||||
if not (crs_name := (ifcopenshell.util.geolocation.get_crs(tool.Ifc.get()) or {}).get("Name", "")):
|
||||
self.link.georeferenced = "NONE"
|
||||
return
|
||||
json_filepath = tool.Project.get_link_cache_paths(self.filepath_, self.query, self.exclude)[1]
|
||||
reference = tool.Ifc.get().by_id(self.link.ifc_definition_id)
|
||||
json_filepath = Path(reference.Location).with_suffix(".ifc.cache.json")
|
||||
if not json_filepath.exists():
|
||||
self.link.georeferenced = "NONE"
|
||||
return
|
||||
@@ -1685,211 +1662,43 @@ except Exception as e:
|
||||
self.link.georeferenced = "FULL_COMPATIBLE" if crs_name == data["model_crs"] else "NOT_COMPATIBLE"
|
||||
|
||||
|
||||
class ReloadLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class ReloadLink(bpy.types.Operator):
|
||||
bl_idname = "bim.reload_link"
|
||||
bl_label = "Reload Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Reload the selected file, optionally changing its file path and load options"
|
||||
bl_description = "Reload the selected file"
|
||||
|
||||
# SKIP_SAVE: this operator distinguishes "provided" from "unset" properties
|
||||
# via is_property_set, so last-used property retention between interactive
|
||||
# invocations would leak one link's settings into another's reload.
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
filepath: bpy.props.StringProperty(
|
||||
name="File Path",
|
||||
description="Path to the linked IFC file",
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
use_relative_path: bpy.props.BoolProperty(
|
||||
name="Use Relative Path",
|
||||
description="Whether to store linked model path relative to the currently opened IFC file.",
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
use_cache: bpy.props.BoolProperty(
|
||||
name="Use Cache",
|
||||
description="Reuse the cached geometry if it's still valid instead of reprocessing the IFC",
|
||||
default=False,
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
query: bpy.props.StringProperty(
|
||||
name="Include",
|
||||
name="Query",
|
||||
description=(
|
||||
"Selector query for the elements to load from the linked model. E.g. 'IfcElement'.\n\n"
|
||||
"Default when empty - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
|
||||
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
|
||||
"Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
|
||||
),
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
exclude: bpy.props.StringProperty(
|
||||
name="Exclude",
|
||||
description=(
|
||||
"Selector query whose matches are excluded from the loaded elements.\n\n"
|
||||
"Applied on top of the query (or the default set), providing the set "
|
||||
"difference a single query cannot express. E.g. 'IfcSlab, parent=\"X\"'."
|
||||
),
|
||||
options={"SKIP_SAVE"},
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
link_index: int
|
||||
filepath: str
|
||||
use_relative_path: bool
|
||||
use_cache: bool
|
||||
query: str
|
||||
exclude: str
|
||||
|
||||
def invoke(self, context, event):
|
||||
link = tool.Project.get_project_props().links[self.link_index]
|
||||
# Properties may arrive pre-set when the dialog is reopened
|
||||
# by bim.select_link_filepath - don't clobber them.
|
||||
if not self.properties.is_property_set("filepath"):
|
||||
self.filepath = link.filepath
|
||||
if not self.properties.is_property_set("use_relative_path"):
|
||||
self.use_relative_path = not Path(link.filepath).is_absolute()
|
||||
if not self.properties.is_property_set("query"):
|
||||
self.query = link.query
|
||||
if not self.properties.is_property_set("exclude"):
|
||||
self.exclude = link.exclude
|
||||
self.query = link.query
|
||||
return context.window_manager.invoke_props_dialog(self)
|
||||
|
||||
def draw(self, context):
|
||||
assert self.layout
|
||||
pprops = tool.Project.get_project_props()
|
||||
row = self.layout.row(align=True)
|
||||
row.prop(self, "filepath")
|
||||
op = row.operator("bim.select_link_filepath", text="", icon="FILEBROWSER")
|
||||
op.link_index = self.link_index
|
||||
# Carry the current dialog state through the file browser round-trip.
|
||||
op.use_relative_path = self.use_relative_path
|
||||
op.use_cache = self.use_cache
|
||||
op.query = self.query
|
||||
op.exclude = self.exclude
|
||||
row = self.layout.row()
|
||||
row.prop(self, "use_relative_path")
|
||||
row = self.layout.row()
|
||||
row.prop(self, "use_cache")
|
||||
row = self.layout.row()
|
||||
row.label(text="False Origin Mode:")
|
||||
row = self.layout.row()
|
||||
row.prop(pprops, "false_origin_mode", text="")
|
||||
if pprops.false_origin_mode == "MANUAL":
|
||||
row = self.layout.row()
|
||||
row.prop(pprops, "false_origin")
|
||||
row = self.layout.row()
|
||||
row.prop(pprops, "project_north")
|
||||
self.layout.prop(self, "query", placeholder="IfcElement")
|
||||
self.layout.prop(self, "exclude", placeholder='IfcSlab, parent="..."')
|
||||
|
||||
def _execute(self, context):
|
||||
def execute(self, context):
|
||||
link = tool.Project.get_project_props().links[self.link_index]
|
||||
# Unset properties mean the operator was called without the dialog
|
||||
# (e.g. from a script) - preserve the link's stored values instead
|
||||
# of overwriting them with the defaults.
|
||||
# An unset query means the operator was called without the dialog
|
||||
# (e.g. from a script) - preserve the link's stored query instead
|
||||
# of overwriting it with the empty default.
|
||||
if self.properties.is_property_set("query"):
|
||||
link.query = self.query
|
||||
if self.properties.is_property_set("exclude"):
|
||||
link.exclude = self.exclude
|
||||
|
||||
filepath = self.filepath if self.properties.is_property_set("filepath") else link.filepath
|
||||
if self.properties.is_property_set("use_relative_path"):
|
||||
use_relative_path = self.use_relative_path
|
||||
else:
|
||||
use_relative_path = not Path(link.filepath).is_absolute()
|
||||
|
||||
abs_filepath = Path(tool.Ifc.resolve_uri(filepath))
|
||||
if not abs_filepath.exists():
|
||||
self.report({"ERROR"}, f"File does not exist: '{abs_filepath}'")
|
||||
return {"CANCELLED"}
|
||||
filepath = tool.Ifc.get_uri(abs_filepath, use_relative_path=use_relative_path)
|
||||
if filepath != link.filepath:
|
||||
link.name = filepath
|
||||
link.filepath = filepath
|
||||
if tool.Ifc.get() and link.ifc_definition_id:
|
||||
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
|
||||
reference.Location = filepath.replace("\\", "/")
|
||||
if document := tool.Document.get_reference_document(reference):
|
||||
document.Name = Path(filepath).name
|
||||
if tool.Ifc.get() and link.ifc_definition_id:
|
||||
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
|
||||
if hasattr(reference, "Description"):
|
||||
reference.Description = tool.Project.encode_link_filter(
|
||||
link.query, link.exclude, loaded=True, display_name=link.display_name
|
||||
)
|
||||
|
||||
bpy.ops.bim.unload_link(link_index=self.link_index)
|
||||
return bpy.ops.bim.load_link(
|
||||
link_index=self.link_index, use_cache=self.use_cache, query=link.query, exclude=link.exclude
|
||||
) or {"FINISHED"}
|
||||
|
||||
|
||||
class ReloadAllLinks(bpy.types.Operator):
|
||||
bl_idname = "bim.reload_all_links"
|
||||
bl_label = "Reload All Links"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Reload all loaded linked models from disk, rebuilding their caches"
|
||||
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
if not any(link.is_loaded for link in tool.Project.get_project_props().links):
|
||||
cls.poll_message_set("No loaded links to reload.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Project.get_project_props()
|
||||
reloaded = 0
|
||||
for i, link in enumerate(props.links):
|
||||
if not link.is_loaded:
|
||||
continue
|
||||
# Called without filter properties, reload_link preserves each
|
||||
# link's stored path, query and exclude.
|
||||
bpy.ops.bim.reload_link(link_index=i)
|
||||
reloaded += 1
|
||||
self.report({"INFO"}, f"Reloaded {reloaded} linked model(s).")
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class SelectLinkFilepath(bpy.types.Operator):
|
||||
bl_idname = "bim.select_link_filepath"
|
||||
bl_label = "Select Link File Path"
|
||||
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
|
||||
bl_description = "Select a new file path for the linked model and return to the reload dialog"
|
||||
|
||||
link_index: bpy.props.IntProperty(name="Link Index")
|
||||
filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"})
|
||||
filter_glob: bpy.props.StringProperty(default="*.ifc", options={"HIDDEN"})
|
||||
# Reload dialog state carried through the file browser round-trip.
|
||||
use_relative_path: bpy.props.BoolProperty(options={"HIDDEN"})
|
||||
use_cache: bpy.props.BoolProperty(options={"HIDDEN"})
|
||||
query: bpy.props.StringProperty(options={"HIDDEN"})
|
||||
exclude: bpy.props.StringProperty(options={"HIDDEN"})
|
||||
|
||||
if TYPE_CHECKING:
|
||||
link_index: int
|
||||
filepath: str
|
||||
filter_glob: str
|
||||
use_relative_path: bool
|
||||
use_cache: bool
|
||||
query: str
|
||||
exclude: str
|
||||
|
||||
def invoke(self, context, event):
|
||||
link = tool.Project.get_project_props().links[self.link_index]
|
||||
self.filepath = tool.Ifc.resolve_uri(link.filepath)
|
||||
context.window_manager.fileselect_add(self)
|
||||
return {"RUNNING_MODAL"}
|
||||
|
||||
def execute(self, context):
|
||||
bpy.ops.bim.reload_link(
|
||||
"INVOKE_DEFAULT",
|
||||
link_index=self.link_index,
|
||||
filepath=self.filepath,
|
||||
use_relative_path=self.use_relative_path,
|
||||
use_cache=self.use_cache,
|
||||
query=self.query,
|
||||
exclude=self.exclude,
|
||||
)
|
||||
return {"FINISHED"}
|
||||
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False, query=link.query) or {"FINISHED"}
|
||||
|
||||
|
||||
class ToggleLinkSelectability(bpy.types.Operator):
|
||||
@@ -1907,7 +1716,7 @@ class ToggleLinkSelectability(bpy.types.Operator):
|
||||
props = tool.Project.get_project_props()
|
||||
link = props.links[self.link_index]
|
||||
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
|
||||
tool.Project.get_link_cache_paths(link.filepath, link.query, link.exclude)[0]
|
||||
Path(link.filepath).with_suffix(".ifc.cache.blend")
|
||||
)
|
||||
link.is_selectable = (is_selectable := not link.is_selectable)
|
||||
for collection in self.get_linked_collections():
|
||||
@@ -1944,7 +1753,7 @@ class ToggleLinkVisibility(bpy.types.Operator):
|
||||
props = tool.Project.get_project_props()
|
||||
link = props.links[self.link_index]
|
||||
self.library_filepath = tool.Blender.ensure_blender_path_is_abs(
|
||||
tool.Project.get_link_cache_paths(link.filepath, link.query, link.exclude)[0]
|
||||
Path(link.filepath).with_suffix(".ifc.cache.blend")
|
||||
)
|
||||
if self.mode == "WIREFRAME":
|
||||
self.toggle_wireframe(link)
|
||||
@@ -1986,16 +1795,10 @@ class EnableEditingLink(bpy.types.Operator):
|
||||
bl_idname = "bim.enable_editing_link"
|
||||
bl_label = "Enable Editing Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Unlock the link's position for editing. Any movement is saved automatically"
|
||||
|
||||
link_index: bpy.props.IntProperty(name="Link Index", default=-1)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
link_index: int
|
||||
bl_description = "Enable editing link location"
|
||||
|
||||
def execute(self, context):
|
||||
props = tool.Project.get_project_props()
|
||||
link = props.active_link if self.link_index == -1 else props.links[self.link_index]
|
||||
link = tool.Project.get_project_props().active_link
|
||||
assert link
|
||||
link.is_editing = True
|
||||
obj = tool.Project.get_link_empty_handle(link)
|
||||
@@ -2004,25 +1807,70 @@ class EnableEditingLink(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class DisableEditingLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
class DisableEditingLink(bpy.types.Operator):
|
||||
bl_idname = "bim.disable_editing_link"
|
||||
bl_label = "Disable Editing Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Lock the link at its current location"
|
||||
bl_description = "Disable editing link and restore to previously saved location"
|
||||
|
||||
link_index: bpy.props.IntProperty(name="Link Index", default=-1)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
link_index: int
|
||||
|
||||
def _execute(self, context):
|
||||
props = tool.Project.get_project_props()
|
||||
link = props.active_link if self.link_index == -1 else props.links[self.link_index]
|
||||
def execute(self, context):
|
||||
link = tool.Project.get_project_props().active_link
|
||||
assert link
|
||||
link.is_editing = False
|
||||
obj = tool.Project.get_link_empty_handle(link)
|
||||
assert obj
|
||||
tool.Project.save_link_transformation(link)
|
||||
obj.matrix_world = tool.Project.calculate_link_matrix(link)
|
||||
tool.Geometry.lock_object(obj)
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class EditLink(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.edit_link"
|
||||
bl_label = "Edit Link"
|
||||
bl_options = {"REGISTER", "UNDO"}
|
||||
bl_description = "Disable editing link and restore to previously saved location"
|
||||
|
||||
def _execute(self, context):
|
||||
link = tool.Project.get_project_props().active_link
|
||||
assert link
|
||||
link.is_editing = False
|
||||
obj = tool.Project.get_link_empty_handle(link)
|
||||
assert obj
|
||||
new_obj_matrix = obj.matrix_world
|
||||
|
||||
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
|
||||
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
|
||||
radians(-float(metadata["model_project_north"])), 4, "Z"
|
||||
)
|
||||
global_matrix = rot @ np.eye(4)
|
||||
global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
|
||||
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
|
||||
local_matrix = rot @ np.eye(4)
|
||||
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
|
||||
|
||||
# obj_matrix is typically calculated as:
|
||||
# obj_matrix = np.linalg.inv(local_matrix) @ transformation @ global_matrix
|
||||
identity_blender_matrix = np.linalg.inv(local_matrix) @ global_matrix
|
||||
if np.allclose(np.array(new_obj_matrix), identity_blender_matrix, atol=1e-5):
|
||||
link.has_transformation = False
|
||||
transformation = ",".join(map(str, np.eye(4).reshape(-1)))
|
||||
else:
|
||||
transformed_global_matrix = local_matrix @ np.array(new_obj_matrix)
|
||||
transformation = transformed_global_matrix @ np.linalg.inv(global_matrix)
|
||||
link.has_transformation = True
|
||||
transformation = ",".join(map(str, transformation.reshape(-1)))
|
||||
|
||||
if tool.Ifc.get():
|
||||
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
|
||||
reference[1] = transformation
|
||||
else:
|
||||
link.transformation = transformation
|
||||
|
||||
obj.matrix_world = tool.Project.calculate_link_matrix(link)
|
||||
tool.Geometry.lock_object(obj)
|
||||
|
||||
@@ -2164,8 +2012,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
|
||||
# gizmo polls gate on each preview's is_active flag, and a stuck flag
|
||||
# persisted through the save would silently hide them on reload.
|
||||
preview_base.discard_pending_previews(context.scene)
|
||||
# Links loaded and visible right now auto-load on the next open.
|
||||
tool.Project.update_linked_models_state()
|
||||
# Suffix is appended to the IFC save-success report below so the auto-commit
|
||||
# info isn't immediately overwritten by the success message in Blender's
|
||||
# status bar (only the latest self.report({"INFO"}, ...) sticks).
|
||||
@@ -2273,23 +2119,14 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
|
||||
query: bpy.props.StringProperty()
|
||||
"""See ``bim.link_ifc``."""
|
||||
exclude: bpy.props.StringProperty()
|
||||
"""See ``bim.link_ifc``."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
query: str
|
||||
exclude: str
|
||||
|
||||
file: ifcopenshell.file
|
||||
meshes: dict[str, bpy.types.Mesh]
|
||||
# Material names is derived from diffuse as in 'r-g-b-a'.
|
||||
blender_mats: dict[str, bpy.types.Material]
|
||||
# Materials appended from external .blend styles, keyed by style id.
|
||||
# None means the style has no loadable external .blend style.
|
||||
external_style_mats: dict[int, Union[bpy.types.Material, None]]
|
||||
# Appended data-blocks keyed by (filepath, data_block_type, name)
|
||||
# so styles sharing the same external material don't append duplicates.
|
||||
appended_external_blocks: dict[tuple[str, str, str], Union[bpy.types.Material, None]]
|
||||
|
||||
def invoke(self, context, event):
|
||||
# Invoke is for debugging purposes, users are not intended to use this method really.
|
||||
@@ -2346,9 +2183,6 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
else:
|
||||
self.elements |= set(self.file.by_type("IfcSpatialElement"))
|
||||
self.elements -= set(self.file.by_type("IfcFeatureElement"))
|
||||
if self.exclude:
|
||||
# The set difference a single selector query cannot express.
|
||||
self.elements -= ifcopenshell.util.selector.filter_elements(self.file, self.exclude)
|
||||
|
||||
if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin:
|
||||
tool.Loader.set_manual_blender_offset(self.file)
|
||||
@@ -2356,7 +2190,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
tool.Loader.guess_false_origin(self.file)
|
||||
|
||||
tool.Georeference.set_model_origin()
|
||||
self.json_filepath = str(tool.Project.get_link_cache_paths(self.filepath, self.query, self.exclude)[1])
|
||||
self.json_filepath = self.filepath + ".cache.json"
|
||||
data = {
|
||||
"model_is_georeferenced": gprops.model_is_georeferenced,
|
||||
"model_crs": gprops.model_crs,
|
||||
@@ -2374,14 +2208,10 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
"false_origin": pprops.false_origin,
|
||||
"project_north": pprops.project_north,
|
||||
"query": self.query,
|
||||
"exclude": self.exclude,
|
||||
}
|
||||
with open(self.json_filepath, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
self.external_style_mats = {}
|
||||
self.appended_external_blocks = {}
|
||||
|
||||
for settings in tool.Loader.settings.context_settings:
|
||||
if not self.elements:
|
||||
break
|
||||
@@ -2421,10 +2251,8 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
mat = tuple(mat)
|
||||
blender_mat = blender_mats.get(mat, None)
|
||||
if not blender_mat:
|
||||
blender_mat = self.get_external_material(int(mat[4]))
|
||||
if not blender_mat:
|
||||
blender_mat = bpy.data.materials.new("Chunk")
|
||||
blender_mat.diffuse_color = mat[:4]
|
||||
blender_mat = bpy.data.materials.new("Chunk")
|
||||
blender_mat.diffuse_color = mat
|
||||
blender_mats[mat] = blender_mat
|
||||
mat_results.append(blender_mat)
|
||||
|
||||
@@ -2442,16 +2270,11 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
while True: # Main loop.
|
||||
shape = iterator.get()
|
||||
assert isinstance(shape, W.TriangulationElement)
|
||||
element = self.file.by_id(shape.id)
|
||||
results.add(element)
|
||||
results.add(self.file.by_id(shape.id))
|
||||
geometry = shape.geometry
|
||||
|
||||
# Elements with a lot of geometry benefit from instancing to save memory.
|
||||
# Multi-layer elements also take this path as they need their own
|
||||
# local-space mesh to be sliced into per-layer materials.
|
||||
if ifcopenshell.util.shape.get_faces(geometry).shape[0] > 333 or self.is_multilayer_element(
|
||||
element
|
||||
): # 333 tris
|
||||
# Elements with a lot of geometry benefit from instancing to save memory
|
||||
if ifcopenshell.util.shape.get_faces(geometry).shape[0] > 333: # 333 tris
|
||||
self.process_occurrence(shape)
|
||||
if not iterator.next():
|
||||
if not chunked_verts:
|
||||
@@ -2468,15 +2291,9 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
|
||||
ms = np.vstack([default_mat, ifcopenshell.util.shape.get_material_colors(shape.geometry)])
|
||||
mi = ifcopenshell.util.shape.get_faces_material_style_ids(shape.geometry)
|
||||
# Style ids ride along as a 5th column so styles with
|
||||
# external .blend materials survive the per-color dedup.
|
||||
style_ids = np.zeros((len(ms), 1))
|
||||
for geom_material_idx, geom_material in enumerate(shape.geometry.materials):
|
||||
if not geom_material.instance_id():
|
||||
ms[geom_material_idx + 1] = (0.8, 0.8, 0.8, 1)
|
||||
elif self.get_external_material(geom_material.instance_id()):
|
||||
style_ids[geom_material_idx + 1] = geom_material.instance_id()
|
||||
ms = np.hstack((ms, style_ids))
|
||||
chunked_materials.append(ms)
|
||||
chunked_material_ids.append(mi + material_offset + 1)
|
||||
material_offset += len(ms)
|
||||
@@ -2563,14 +2380,12 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
diffuse = (material.diffuse.r(), material.diffuse.g(), material.diffuse.b(), alpha)
|
||||
else:
|
||||
diffuse = (0.8, 0.8, 0.8, 1) # Blender's default material
|
||||
blender_mat = self.get_external_material(material.instance_id())
|
||||
material_name = f"{diffuse[0]}-{diffuse[1]}-{diffuse[2]}-{diffuse[3]}"
|
||||
blender_mat = self.blender_mats.get(material_name, None)
|
||||
if not blender_mat:
|
||||
material_name = f"{diffuse[0]}-{diffuse[1]}-{diffuse[2]}-{diffuse[3]}"
|
||||
blender_mat = self.blender_mats.get(material_name, None)
|
||||
if not blender_mat:
|
||||
blender_mat = bpy.data.materials.new(material_name)
|
||||
blender_mat.diffuse_color = diffuse
|
||||
self.blender_mats[material_name] = blender_mat
|
||||
blender_mat = bpy.data.materials.new(material_name)
|
||||
blender_mat.diffuse_color = diffuse
|
||||
self.blender_mats[material_name] = blender_mat
|
||||
slot_index = mesh.materials.find(material.name)
|
||||
if slot_index == -1:
|
||||
mesh.materials.append(blender_mat)
|
||||
@@ -2583,8 +2398,6 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
mesh.polygons.foreach_set("material_index", material_index)
|
||||
mesh.update()
|
||||
|
||||
mesh = tool.Loader.slice_layerset_mesh(element, mesh, style_to_material=self.get_style_material)
|
||||
|
||||
self.meshes[geometry.id] = mesh
|
||||
|
||||
obj = bpy.data.objects.new(tool.Loader.get_name(element), mesh)
|
||||
@@ -2597,88 +2410,6 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
|
||||
|
||||
self.collection.objects.link(obj)
|
||||
|
||||
def get_external_material(self, style_id: int) -> Union[bpy.types.Material, None]:
|
||||
"""Get the Blender material referenced by a style's external .blend style, if it has one.
|
||||
|
||||
The material is appended from the external .blend file on first use and
|
||||
cached, so it ends up saved inside the link's .cache.blend.
|
||||
"""
|
||||
if not style_id:
|
||||
return None
|
||||
if style_id in self.external_style_mats:
|
||||
return self.external_style_mats[style_id]
|
||||
|
||||
material = None
|
||||
# instance_id may also refer to an IfcMaterial when the item has
|
||||
# a material but no style, hence the class check.
|
||||
style = self.file.by_id(style_id)
|
||||
external = None
|
||||
if style.is_a("IfcSurfaceStyle"):
|
||||
external = next((s for s in style.Styles if s.is_a("IfcExternallyDefinedSurfaceStyle")), None)
|
||||
|
||||
if (
|
||||
external
|
||||
and external.Location
|
||||
and external.Location.endswith(".blend")
|
||||
and external.Identification
|
||||
and "/" in external.Identification
|
||||
):
|
||||
location = Path(external.Location)
|
||||
if not location.is_absolute():
|
||||
# Relative locations are relative to the linked IFC, not the host.
|
||||
location = Path(self.filepath).parent / location
|
||||
data_block_type, data_block = external.Identification.split("/", 1)
|
||||
key = (str(location), data_block_type, data_block)
|
||||
if key in self.appended_external_blocks:
|
||||
material = self.appended_external_blocks[key]
|
||||
elif not location.exists():
|
||||
print(f"WARNING. External style file not found for {style}: '{location}'")
|
||||
self.appended_external_blocks[key] = None
|
||||
else:
|
||||
db = tool.Blender.append_data_block(str(location), data_block_type, data_block)
|
||||
material = db["data_block"]
|
||||
if not isinstance(material, bpy.types.Material):
|
||||
print(f"WARNING. Failed to load external style for {style}: {db['msg'] or 'not a material'}")
|
||||
material = None
|
||||
else:
|
||||
# The source .blend may have been authored in a Bonsai session -
|
||||
# unlink any stale IFC id so it's not misinterpreted here or in the host.
|
||||
tool.Style.get_material_style_props(material).ifc_definition_id = 0
|
||||
self.appended_external_blocks[key] = material
|
||||
|
||||
self.external_style_mats[style_id] = material
|
||||
return material
|
||||
|
||||
def is_multilayer_element(self, element: ifcopenshell.entity_instance) -> bool:
|
||||
material = ifcopenshell.util.element.get_material(element)
|
||||
return bool(
|
||||
material and material.is_a("IfcMaterialLayerSetUsage") and len(material.ForLayerSet.MaterialLayers) > 1
|
||||
)
|
||||
|
||||
def get_style_material(self, style: ifcopenshell.entity_instance) -> Union[bpy.types.Material, None]:
|
||||
"""Resolve a style to a Blender material for slice_layerset_mesh.
|
||||
|
||||
Prefers the style's external .blend material, falling back to a flat
|
||||
diffuse material as used for the rest of the linked geometry.
|
||||
"""
|
||||
if material := self.get_external_material(style.id()):
|
||||
return material
|
||||
# IfcSurfaceStyleRendering is a subclass of IfcSurfaceStyleShading.
|
||||
shading = next((s for s in style.Styles if s.is_a("IfcSurfaceStyleShading")), None)
|
||||
if shading:
|
||||
colour = shading.SurfaceColour
|
||||
alpha = 1.0 - (getattr(shading, "Transparency", None) or 0.0)
|
||||
diffuse = (colour.Red, colour.Green, colour.Blue, alpha)
|
||||
else:
|
||||
diffuse = (0.8, 0.8, 0.8, 1.0)
|
||||
material_name = f"{diffuse[0]}-{diffuse[1]}-{diffuse[2]}-{diffuse[3]}"
|
||||
material = self.blender_mats.get(material_name, None)
|
||||
if not material:
|
||||
material = bpy.data.materials.new(material_name)
|
||||
material.diffuse_color = diffuse
|
||||
self.blender_mats[material_name] = material
|
||||
return material
|
||||
|
||||
def create_object(
|
||||
self,
|
||||
verts: np.ndarray,
|
||||
@@ -2752,7 +2483,7 @@ class QueryLinkedElement(bpy.types.Operator):
|
||||
|
||||
guid = tool.Project.Link.get_guid_by_face_index(obj, face_index)
|
||||
assert guid is not None
|
||||
tool.Project.Link.select_linked_element(context, obj, guid, instance_matrix)
|
||||
tool.Project.Link.select_linked_element(context, obj, guid)
|
||||
|
||||
self.report({"INFO"}, f"Loaded data for {guid}")
|
||||
ProjectDecorator.install(bpy.context)
|
||||
@@ -2877,27 +2608,6 @@ class AppendInspectedLinkedElement(AppendLibraryElement):
|
||||
if element_type and tool.Ifc.get_object(element_type) is None:
|
||||
self.import_type_from_ifc(element_type, context)
|
||||
|
||||
# If the link was moved, place the appended element where the link
|
||||
# is displayed rather than at its original coordinates.
|
||||
obj = tool.Ifc.get_object(element)
|
||||
if isinstance(obj, bpy.types.Object):
|
||||
# Prefer matching the link by the queried instance's root empty -
|
||||
# the same file may be linked several times (different queries)
|
||||
# and moved to different locations.
|
||||
root = props.queried_obj_root
|
||||
linked_filepath = Path(queried_obj["ifc_filepath"])
|
||||
link_match = None
|
||||
for link in props.links:
|
||||
if root is not None and tool.Project.get_link_empty_handle(link) == root:
|
||||
link_match = link
|
||||
break
|
||||
if link_match is None and Path(tool.Ifc.resolve_uri(link.filepath)) == linked_filepath:
|
||||
link_match = link
|
||||
if link_match:
|
||||
delta = tool.Project.calculate_link_delta_matrix(link_match)
|
||||
if not delta.is_identity:
|
||||
obj.matrix_world = delta @ obj.matrix_world
|
||||
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
|
||||
@@ -261,21 +261,8 @@ class Link(PropertyGroup):
|
||||
default=0,
|
||||
)
|
||||
query: StringProperty(
|
||||
name="Include",
|
||||
description="Selector query for the elements to load from the linked model",
|
||||
default="",
|
||||
)
|
||||
exclude: StringProperty(
|
||||
name="Exclude",
|
||||
description="Selector query whose matches are excluded when loading the linked model",
|
||||
default="",
|
||||
)
|
||||
display_name: StringProperty(
|
||||
name="Name",
|
||||
description=(
|
||||
"Optional display name to tell links apart (e.g. when the same file "
|
||||
"is linked several times). Shows the file path when empty"
|
||||
),
|
||||
name="Query",
|
||||
description="Selector query used to filter elements when loading the linked model",
|
||||
default="",
|
||||
)
|
||||
|
||||
@@ -294,8 +281,6 @@ class Link(PropertyGroup):
|
||||
empty_handle: Union[bpy.types.Object, None]
|
||||
ifc_definition_id: int
|
||||
query: str
|
||||
exclude: str
|
||||
display_name: str
|
||||
|
||||
|
||||
class EditedObj(PropertyGroup):
|
||||
|
||||
@@ -492,13 +492,17 @@ class BIM_PT_links(Panel):
|
||||
|
||||
row = self.layout.row(align=True)
|
||||
row.operator("bim.link_ifc")
|
||||
row.operator("bim.reload_all_links", text="", icon="FILE_REFRESH")
|
||||
if self.props.links:
|
||||
if self.props.active_link:
|
||||
row = self.layout.row(align=True)
|
||||
row.alignment = "RIGHT"
|
||||
index = self.props.active_link_index
|
||||
if self.props.active_link.is_loaded:
|
||||
if self.props.active_link.is_editing:
|
||||
row.operator("bim.edit_link", text="", icon="CHECKMARK")
|
||||
row.operator("bim.disable_editing_link", text="", icon="CANCEL")
|
||||
else:
|
||||
row.operator("bim.enable_editing_link", text="", icon="GREASEPENCIL")
|
||||
row.operator("bim.select_linked_model_element", icon="VIEWZOOM", text="")
|
||||
row.operator("bim.select_link_handle", text="", icon="OBJECT_DATA").link_index = index
|
||||
row.operator("bim.unload_link", text="", icon="UNLINKED").link_index = index
|
||||
@@ -639,12 +643,7 @@ class BIM_UL_links(UIList):
|
||||
if item.has_transformation:
|
||||
row.label(text="", icon="OBJECT_ORIGIN")
|
||||
|
||||
# Double-click to rename; shows the file path while unset.
|
||||
row.prop(item, "display_name", text="", emboss=False, placeholder=item.filepath)
|
||||
if item.is_editing:
|
||||
row.operator("bim.disable_editing_link", text="", icon="UNLOCKED", emboss=False).link_index = index
|
||||
else:
|
||||
row.operator("bim.enable_editing_link", text="", icon="LOCKED", emboss=False).link_index = index
|
||||
row.label(text=item.filepath)
|
||||
icon = "RESTRICT_SELECT_OFF" if item.is_selectable else "RESTRICT_SELECT_ON"
|
||||
row.operator("bim.toggle_link_selectability", text="", icon=icon, emboss=False).link_index = index
|
||||
icon = "CUBE" if item.is_wireframe else "MESH_CUBE"
|
||||
@@ -656,7 +655,7 @@ class BIM_UL_links(UIList):
|
||||
op.link_index = index
|
||||
op.mode = "VISIBLE"
|
||||
else:
|
||||
row.prop(item, "display_name", text="", emboss=False, placeholder=item.filepath)
|
||||
row.label(text=item.filepath)
|
||||
|
||||
|
||||
class BIM_PT_purge(Panel):
|
||||
|
||||
@@ -128,7 +128,7 @@ def update_grid_is_locked(self: "BIMGridProperties", context: bpy.types.Context)
|
||||
if tool.Ifc.get().schema in ("IFC2X3", "IFC4"):
|
||||
elements = tool.Ifc.get().by_type("IfcGrid") + tool.Ifc.get().by_type("IfcGridAxis")
|
||||
else:
|
||||
elements = tool.Ifc.get().by_type("IfcPositioningElement")
|
||||
elements = tool.Ifc.get().by_type("IfcPositioningElement") + tool.Ifc.get().by_type("IfcGridAxis")
|
||||
for element in elements:
|
||||
if obj := tool.Ifc.get_object(element):
|
||||
if self.is_locked:
|
||||
|
||||
@@ -302,23 +302,25 @@ def add_drawing(
|
||||
context=drawing.get_body_context(),
|
||||
ifc_representation_class=None,
|
||||
)
|
||||
|
||||
|
||||
drawings_parent_group = None
|
||||
for group in ifc.get().by_type("IfcGroup"):
|
||||
if group.Name == "DRAWINGS" and group.ObjectType == "DRAWINGS":
|
||||
drawings_parent_group = group
|
||||
break
|
||||
|
||||
|
||||
if not drawings_parent_group:
|
||||
drawings_parent_group = ifc.run("group.add_group")
|
||||
ifc.run("group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"})
|
||||
|
||||
ifc.run(
|
||||
"group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"}
|
||||
)
|
||||
|
||||
group = ifc.run("group.add_group")
|
||||
ifc.run("group.edit_group", group=group, attributes={"Name": drawing_name, "ObjectType": "DRAWING"})
|
||||
ifc.run("group.assign_group", group=group, products=[element])
|
||||
|
||||
|
||||
ifc.run("group.assign_group", group=drawings_parent_group, products=[group])
|
||||
|
||||
|
||||
collector.assign(camera)
|
||||
pset = ifc.run("pset.add_pset", product=element, name="EPset_Drawing")
|
||||
if drawing.get_unit_system() == "METRIC":
|
||||
@@ -355,7 +357,7 @@ def add_drawing(
|
||||
if document.Name == "DRAWINGS" and document.Scope == "DRAWINGS":
|
||||
drawings_parent_document = document
|
||||
break
|
||||
|
||||
|
||||
if not drawings_parent_document:
|
||||
drawings_parent_document = ifc.run("document.add_information")
|
||||
if ifc.get_schema() == "IFC2X3":
|
||||
@@ -363,7 +365,7 @@ def add_drawing(
|
||||
else:
|
||||
attributes = {"Identification": "DRAWINGS", "Name": "DRAWINGS", "Scope": "DRAWINGS"}
|
||||
ifc.run("document.edit_information", information=drawings_parent_document, attributes=attributes)
|
||||
|
||||
|
||||
information = ifc.run("document.add_information", parent=drawings_parent_document)
|
||||
uri = drawing.get_default_drawing_path(drawing_name)
|
||||
reference = ifc.run("document.add_reference", information=information)
|
||||
@@ -392,17 +394,19 @@ def duplicate_drawing(
|
||||
drawing_tool.set_name(new_drawing, drawing_name)
|
||||
group = drawing_tool.get_drawing_group(new_drawing)
|
||||
ifc.run("group.unassign_group", group=group, products=[new_drawing])
|
||||
|
||||
|
||||
drawings_parent_group = None
|
||||
for parent_group in ifc.get().by_type("IfcGroup"):
|
||||
if parent_group.Name == "DRAWINGS" and parent_group.ObjectType == "DRAWINGS":
|
||||
drawings_parent_group = parent_group
|
||||
break
|
||||
|
||||
|
||||
if not drawings_parent_group:
|
||||
drawings_parent_group = ifc.run("group.add_group")
|
||||
ifc.run("group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"})
|
||||
|
||||
ifc.run(
|
||||
"group.edit_group", group=drawings_parent_group, attributes={"Name": "DRAWINGS", "ObjectType": "DRAWINGS"}
|
||||
)
|
||||
|
||||
new_group = ifc.run("group.add_group")
|
||||
ifc.run("group.edit_group", group=new_group, attributes={"Name": drawing_name, "ObjectType": "DRAWING"})
|
||||
ifc.run("group.assign_group", group=new_group, products=[new_drawing])
|
||||
@@ -427,7 +431,7 @@ def duplicate_drawing(
|
||||
if document.Name == "DRAWINGS" and document.Scope == "DRAWINGS":
|
||||
drawings_parent_document = document
|
||||
break
|
||||
|
||||
|
||||
if not drawings_parent_document:
|
||||
drawings_parent_document = ifc.run("document.add_information")
|
||||
if ifc.get_schema() == "IFC2X3":
|
||||
@@ -622,7 +626,8 @@ def sync_references(
|
||||
|
||||
for reference_element in potential_reference_elements:
|
||||
if not drawing_tool.get_drawing_reference_annotation(drawing, reference_element):
|
||||
if annotation := drawing_tool.generate_reference_annotation(drawing, reference_element, context):
|
||||
annotation = drawing_tool.generate_reference_annotation(drawing, reference_element, context)
|
||||
if annotation:
|
||||
ifc.run("drawing.assign_product", relating_product=reference_element, related_object=annotation)
|
||||
ifc.run("group.assign_group", group=group, products=[annotation])
|
||||
collector.assign(ifc.get_object(annotation))
|
||||
|
||||
@@ -50,14 +50,15 @@ def copy_z_rotation_to_selected(
|
||||
flip: bool = False,
|
||||
) -> int:
|
||||
"""Apply ``active``'s Z-Euler rotation to each target."""
|
||||
source_z = surveyor.get_z_rotation(active)
|
||||
source_z = surveyor.get_z_rotation(active) # ty: ignore[missing-argument]
|
||||
if flip:
|
||||
source_z += math.pi
|
||||
rotated = 0
|
||||
for obj in targets:
|
||||
if abs(_z_rotation_diff(surveyor.get_z_rotation(obj), source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
|
||||
target_z = surveyor.get_z_rotation(obj) # ty: ignore[missing-argument]
|
||||
if abs(_z_rotation_diff(target_z, source_z)) < Z_ROTATION_ALIGNMENT_TOLERANCE:
|
||||
continue
|
||||
surveyor.set_z_rotation(obj, source_z)
|
||||
surveyor.set_z_rotation(obj, source_z) # ty: ignore[missing-argument]
|
||||
rotated += 1
|
||||
if ifc.get_entity(obj) is not None:
|
||||
bonsai.core.geometry.edit_object_placement(ifc, geometry, surveyor, obj=obj)
|
||||
|
||||
@@ -804,7 +804,7 @@ class Profile:
|
||||
|
||||
@interface
|
||||
class Parametric:
|
||||
def get_geom_generation(cls) -> int: pass
|
||||
def get_geom_generation(cls): pass
|
||||
def refresh_post_commit(cls, operator) -> None: pass
|
||||
|
||||
|
||||
|
||||
@@ -120,7 +120,8 @@ class Collector(bonsai.core.tool.Collector):
|
||||
project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0])
|
||||
cls.link_collection_child_safe(tool.Blender.get_object_bim_props(project_obj).collection, collection)
|
||||
elif element.is_a("IfcAnnotation") and (drawing_obj := cls.get_annotation_drawing_obj(element)):
|
||||
cls.link_collection_object_safe(tool.Blender.get_object_bim_props(drawing_obj).collection, obj)
|
||||
target_collection = tool.Blender.get_object_bim_props(drawing_obj).collection
|
||||
cls.link_collection_object_safe(target_collection, obj)
|
||||
elif container := ifcopenshell.util.element.get_container(element):
|
||||
while container.is_a("IfcSpace"):
|
||||
container = ifcopenshell.util.element.get_aggregate(container)
|
||||
|
||||
@@ -1953,19 +1953,29 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
if camera.data.type != "ORTHO":
|
||||
return
|
||||
|
||||
settings = ifcopenshell.geom.settings()
|
||||
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
|
||||
geometry = ifcopenshell.geom.create_shape(settings, axis.AxisCurve)
|
||||
verts = ifcopenshell.util.shape.get_vertices(geometry)
|
||||
grid = (axis.PartOfU or axis.PartOfV or axis.PartOfW)[0]
|
||||
m = ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement)
|
||||
axis_obj = tool.Ifc.get_object(axis)
|
||||
if axis_obj and axis_obj.data and len(axis_obj.data.vertices) >= 2:
|
||||
m = np.array(axis_obj.matrix_world)
|
||||
verts = [np.array(v.co) for v in axis_obj.data.vertices[:2]]
|
||||
else:
|
||||
settings = ifcopenshell.geom.settings()
|
||||
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
|
||||
geometry = ifcopenshell.geom.create_shape(settings, axis.AxisCurve)
|
||||
verts = list(ifcopenshell.util.shape.get_vertices(geometry)[:2])
|
||||
grid_obj = tool.Ifc.get_object(grid)
|
||||
if grid_obj:
|
||||
m = np.array(grid_obj.matrix_world)
|
||||
else:
|
||||
m = ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement)
|
||||
im = camera.matrix_world.inverted()
|
||||
v1, v2 = [im @ Vector((m @ np.append(v, 1.0))[:3]) for v in verts[:2]]
|
||||
v1, v2 = [im @ Vector((m @ np.append(v[:3], 1.0))[:3]) for v in verts]
|
||||
|
||||
target_view = tool.Drawing.get_drawing_target_view(drawing)
|
||||
if target_view in ("PLAN_VIEW", "REFLECTED_PLAN_VIEW"):
|
||||
bounds = helper.ortho_view_frame(camera.data)
|
||||
if not (points := helper.clip_segment(bounds, [v1, v2])):
|
||||
points = helper.clip_segment(bounds, [v1, v2])
|
||||
if not points:
|
||||
return
|
||||
elif target_view in ("ELEVATION_VIEW", "SECTION_VIEW"):
|
||||
bounds = helper.ortho_view_frame(camera.data)
|
||||
@@ -2183,6 +2193,7 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
def sync_object_placement(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]:
|
||||
blender_matrix = np.array(obj.matrix_world)
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
is_moved = tool.Ifc.is_moved(obj)
|
||||
if tool.Geometry.is_scaled(obj):
|
||||
bpy.ops.bim.update_representation(obj=obj.name)
|
||||
return element
|
||||
@@ -2199,7 +2210,8 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
grid_obj = tool.Ifc.get_object(grid)
|
||||
if grid_obj:
|
||||
cls.sync_object_placement(grid_obj)
|
||||
if grid_obj.matrix_world != obj.matrix_world:
|
||||
matrices_differ = grid_obj.matrix_world != obj.matrix_world
|
||||
if matrices_differ:
|
||||
bpy.ops.bim.update_representation(obj=obj.name)
|
||||
tool.Geometry.record_object_position(obj)
|
||||
|
||||
|
||||
@@ -2670,6 +2670,11 @@ class Geometry(bonsai.core.tool.Geometry):
|
||||
# copy the actual class
|
||||
new = bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj)
|
||||
|
||||
# Give each duplicated IfcGridAxis its own AxisCurve so it doesn't
|
||||
# share geometry with the source axis.
|
||||
if new and new.is_a("IfcGridAxis"):
|
||||
tool.Model.create_axis_curve(new_obj, new)
|
||||
|
||||
# clean up the orphaned mesh with ifc id of the original object to avoid confusion
|
||||
# IfcGridAxis keeps the same mesh data (it's pointing to ifc id 0, so it's not a problem)
|
||||
if new and temp_data and not new.is_a("IfcGridAxis"):
|
||||
|
||||
@@ -23,7 +23,7 @@ import os
|
||||
import re
|
||||
from math import atan, radians
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, Union, cast
|
||||
from typing import Any, Optional, Union, cast
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
@@ -1073,19 +1073,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
return mesh
|
||||
|
||||
@classmethod
|
||||
def slice_layerset_mesh(
|
||||
cls,
|
||||
element: ifcopenshell.entity_instance,
|
||||
mesh: bpy.types.Mesh,
|
||||
style_to_material: Optional[Callable[[ifcopenshell.entity_instance], Union[bpy.types.Material, None]]] = None,
|
||||
) -> bpy.types.Mesh:
|
||||
"""Bisect a layerset element's mesh at layer boundaries and assign each layer its material style.
|
||||
|
||||
:param style_to_material: Callback resolving an IfcSurfaceStyle to a Blender material.
|
||||
Defaults to the IFC-linked material, which only works for the actively edited project.
|
||||
"""
|
||||
if style_to_material is None:
|
||||
style_to_material = tool.Ifc.get_object
|
||||
def slice_layerset_mesh(cls, element: ifcopenshell.entity_instance, mesh: bpy.types.Mesh) -> bpy.types.Mesh:
|
||||
if not (material := ifcopenshell.util.element.get_material(element)):
|
||||
return mesh
|
||||
elif material.is_a("IfcMaterialLayerSetUsage"):
|
||||
@@ -1133,8 +1121,7 @@ class Loader(bonsai.core.tool.Loader):
|
||||
continue
|
||||
if (material_index := styles.get(style, None)) is None:
|
||||
material_index = len(mesh.materials)
|
||||
mesh.materials.append(style_to_material(style))
|
||||
styles[style] = material_index
|
||||
mesh.materials.append(tool.Ifc.get_object(style))
|
||||
if i == last_i:
|
||||
for face in bisect_geom["geom"]:
|
||||
if isinstance(face, bmesh.types.BMFace):
|
||||
|
||||
@@ -59,6 +59,7 @@ from ifcopenshell.util.shape_builder import ShapeBuilder, np_to_3d
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.core.model
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim import import_ifc
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -90,90 +89,10 @@ class Project(bonsai.core.tool.Project):
|
||||
else:
|
||||
link.empty_handle = empty
|
||||
|
||||
@classmethod
|
||||
def get_link_cache_paths(cls, filepath: Union[Path, str], query: str, exclude: str = "") -> tuple[Path, Path]:
|
||||
"""Get the (blend, json) cache paths for a linked model's filter.
|
||||
|
||||
Cache files are per-filter so the same IFC file can be linked several
|
||||
times with different include/exclude queries without the caches
|
||||
overwriting each other. An empty filter keeps the legacy un-suffixed
|
||||
names, and an include-only filter keeps the pre-exclude hash so
|
||||
existing caches stay valid.
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
if not query and not exclude:
|
||||
suffix = ""
|
||||
elif not exclude:
|
||||
suffix = "." + hashlib.md5(query.encode("utf-8")).hexdigest()[:8]
|
||||
else:
|
||||
suffix = "." + hashlib.md5(f"{query}\0{exclude}".encode("utf-8")).hexdigest()[:8]
|
||||
return (
|
||||
filepath.with_suffix(f".ifc.cache{suffix}.blend"),
|
||||
filepath.with_suffix(f".ifc.cache{suffix}.json"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def encode_link_filter(
|
||||
cls, query: str, exclude: str, loaded: bool = False, display_name: str = ""
|
||||
) -> Union[str, None]:
|
||||
"""Serialize a link's filter and state for IfcDocumentReference.Description.
|
||||
|
||||
A plain include query is stored as-is (backwards compatible); an
|
||||
exclude, a loaded state or a display name promotes the value to a
|
||||
small JSON blob. The loaded flag makes the link auto-load on the
|
||||
next project open.
|
||||
"""
|
||||
if exclude or loaded or display_name:
|
||||
return json.dumps({"include": query, "exclude": exclude, "loaded": loaded, "name": display_name})
|
||||
return query or None
|
||||
|
||||
@classmethod
|
||||
def decode_link_filter(cls, description: Union[str, None]) -> tuple[str, str, bool, str]:
|
||||
"""Get (query, exclude, loaded, display_name) from a Description written by encode_link_filter."""
|
||||
if not description:
|
||||
return "", "", False, ""
|
||||
if description.startswith("{"):
|
||||
try:
|
||||
data = json.loads(description)
|
||||
if isinstance(data, dict):
|
||||
return (
|
||||
data.get("include", "") or "",
|
||||
data.get("exclude", "") or "",
|
||||
bool(data.get("loaded", False)),
|
||||
data.get("name", "") or "",
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return description, "", False, ""
|
||||
|
||||
@classmethod
|
||||
def update_linked_models_state(cls) -> None:
|
||||
"""Persist each link's loaded/visible state onto its document reference.
|
||||
|
||||
Called at IFC save time so links that were loaded and visible
|
||||
auto-load the next time the project is opened.
|
||||
"""
|
||||
if not tool.Ifc.get():
|
||||
return
|
||||
for link in cls.get_project_props().links:
|
||||
if not link.ifc_definition_id:
|
||||
continue
|
||||
try:
|
||||
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
|
||||
except RuntimeError:
|
||||
continue
|
||||
if hasattr(reference, "Description"):
|
||||
reference.Description = cls.encode_link_filter(
|
||||
link.query,
|
||||
link.exclude,
|
||||
loaded=link.is_loaded and not link.is_hidden,
|
||||
display_name=link.display_name,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def calculate_link_matrix(cls, link: Link) -> Matrix:
|
||||
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
|
||||
with open(cls.get_link_cache_paths(filepath, link.query, link.exclude)[1], "r") as f:
|
||||
with open(filepath.with_suffix(".ifc.cache.json"), "r") as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
|
||||
@@ -198,86 +117,6 @@ class Project(bonsai.core.tool.Project):
|
||||
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
|
||||
return Matrix(np.linalg.inv(local_matrix) @ global_matrix)
|
||||
|
||||
@classmethod
|
||||
def get_link_transformation_matrix(cls, link: Link) -> Union[npt.NDArray[np.float64], None]:
|
||||
"""Get the link's saved 4x4 transformation in model coordinates, or None when identity."""
|
||||
if tool.Ifc.get():
|
||||
transformation = tool.Ifc.get().by_id(link.ifc_definition_id)[1] # Identification
|
||||
else:
|
||||
transformation = link.transformation
|
||||
if not transformation:
|
||||
return None
|
||||
matrix = np.fromstring(transformation, sep=",", dtype=np.float64).reshape(4, 4)
|
||||
if np.allclose(matrix, np.eye(4)):
|
||||
return None
|
||||
return matrix
|
||||
|
||||
@classmethod
|
||||
def calculate_link_delta_matrix(cls, link: Link) -> Matrix:
|
||||
"""Get the matrix mapping the link's unmoved world positions to its moved ones.
|
||||
|
||||
Returns identity when the link has no saved transformation.
|
||||
"""
|
||||
if tool.Ifc.get():
|
||||
transformation = tool.Ifc.get().by_id(link.ifc_definition_id)[1] # Identification
|
||||
else:
|
||||
transformation = link.transformation
|
||||
|
||||
if not transformation:
|
||||
return Matrix.Identity(4)
|
||||
transformation = np.fromstring(transformation, sep=",", dtype=np.float64).reshape(4, 4)
|
||||
if np.allclose(transformation, np.eye(4)):
|
||||
return Matrix.Identity(4)
|
||||
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
|
||||
local_matrix = rot @ np.eye(4)
|
||||
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
|
||||
|
||||
# Link empty matrix is inv(local) @ transformation @ global (see
|
||||
# calculate_link_matrix), so moved = inv(local) @ T @ local @ unmoved.
|
||||
return Matrix(np.linalg.inv(local_matrix) @ transformation @ local_matrix)
|
||||
|
||||
@classmethod
|
||||
def save_link_transformation(cls, link: Link) -> None:
|
||||
"""Persist the link handle's current world matrix as the link's saved transformation."""
|
||||
obj = cls.get_link_empty_handle(link)
|
||||
assert obj
|
||||
new_obj_matrix = np.array(obj.matrix_world)
|
||||
|
||||
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
|
||||
with open(cls.get_link_cache_paths(filepath, link.query, link.exclude)[1], "r") as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(
|
||||
radians(-float(metadata["model_project_north"])), 4, "Z"
|
||||
)
|
||||
global_matrix = rot @ np.eye(4)
|
||||
global_matrix[:, 3][:3] = [float(o) for o in metadata["model_origin_si"].split(",")]
|
||||
|
||||
gprops = tool.Georeference.get_georeference_props()
|
||||
rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z")
|
||||
local_matrix = rot @ np.eye(4)
|
||||
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
|
||||
|
||||
# obj_matrix is typically calculated as:
|
||||
# obj_matrix = np.linalg.inv(local_matrix) @ transformation @ global_matrix
|
||||
identity_blender_matrix = np.linalg.inv(local_matrix) @ global_matrix
|
||||
if np.allclose(new_obj_matrix, identity_blender_matrix, atol=1e-5):
|
||||
link.has_transformation = False
|
||||
transformation = ",".join(map(str, np.eye(4).reshape(-1)))
|
||||
else:
|
||||
transformed_global_matrix = local_matrix @ new_obj_matrix
|
||||
transformation = transformed_global_matrix @ np.linalg.inv(global_matrix)
|
||||
link.has_transformation = True
|
||||
transformation = ",".join(map(str, transformation.reshape(-1)))
|
||||
|
||||
if tool.Ifc.get():
|
||||
reference = tool.Ifc.get().by_id(link.ifc_definition_id)
|
||||
reference[1] = transformation
|
||||
else:
|
||||
link.transformation = transformation
|
||||
|
||||
@classmethod
|
||||
def append_all_types_from_template(cls, template: str) -> None:
|
||||
# TODO refactor
|
||||
@@ -470,17 +309,11 @@ class Project(bonsai.core.tool.Project):
|
||||
|
||||
@classmethod
|
||||
def get_linked_models_documents(cls) -> dict[str, ifcopenshell.entity_instance]:
|
||||
"""Get linked model documents keyed by resolved absolute filepath (posix form).
|
||||
|
||||
Locations are stored either relative or absolute depending on how the
|
||||
link was created - resolving before keying ensures both forms of the
|
||||
same file match one document.
|
||||
"""
|
||||
linked_docs = {}
|
||||
for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
|
||||
if doc.Scope == "LINKED_MODEL":
|
||||
for reference in tool.Drawing.get_document_references(doc):
|
||||
linked_docs[Path(tool.Ifc.resolve_uri(reference.Location)).as_posix()] = doc
|
||||
linked_docs[Path(reference.Location).as_posix()] = doc
|
||||
break
|
||||
return linked_docs
|
||||
|
||||
@@ -488,52 +321,27 @@ class Project(bonsai.core.tool.Project):
|
||||
def load_linked_models_from_ifc(cls) -> None:
|
||||
links = tool.Project.get_project_props().links
|
||||
links.clear()
|
||||
references: list[ifcopenshell.entity_instance] = []
|
||||
for doc in tool.Ifc.get().by_type("IfcDocumentInformation"):
|
||||
if doc.Scope != "LINKED_MODEL":
|
||||
continue
|
||||
references.extend(tool.Drawing.get_document_references(doc))
|
||||
location_counts: defaultdict[str, int] = defaultdict(int)
|
||||
for reference in references:
|
||||
location_counts[reference.Location] += 1
|
||||
autoload_indices: list[int] = []
|
||||
for reference in references:
|
||||
filepath = reference.Location
|
||||
link = links.add()
|
||||
link.name = filepath
|
||||
link.filepath = filepath
|
||||
link.ifc_definition_id = reference.id()
|
||||
link.has_transformation = False
|
||||
if reference[1]:
|
||||
m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4)
|
||||
link.has_transformation = not np.allclose(m, np.eye(4))
|
||||
# The selector filter used at link time is persisted per
|
||||
# reference in its Description (IFC4+); restore it so
|
||||
# Reload/Load replay the filter.
|
||||
query, exclude, loaded, display_name = cls.decode_link_filter(getattr(reference, "Description", None))
|
||||
if not query and not exclude and location_counts[filepath] == 1:
|
||||
# Fall back to the legacy sidecar cache JSON where older
|
||||
# versions persisted the query. Only unambiguous: with
|
||||
# several links to one file the shared JSON can't say
|
||||
# which link it belonged to.
|
||||
for reference in tool.Drawing.get_document_references(doc):
|
||||
filepath = reference.Location
|
||||
link = links.add()
|
||||
link.name = filepath
|
||||
link.filepath = filepath
|
||||
link.ifc_definition_id = reference.id()
|
||||
link.has_transformation = False
|
||||
if reference[1]:
|
||||
m = np.fromstring(reference[1], sep=",", dtype=np.float64).reshape(4, 4)
|
||||
link.has_transformation = not np.allclose(m, np.eye(4))
|
||||
# The selector query used at link time is persisted only in the
|
||||
# sidecar cache JSON; restore it so Reload/Load replay the filter.
|
||||
json_filepath = Path(tool.Ifc.resolve_uri(filepath)).with_suffix(".ifc.cache.json")
|
||||
if json_filepath.exists():
|
||||
try:
|
||||
query = json.loads(json_filepath.read_text()).get("query", "")
|
||||
link.query = json.loads(json_filepath.read_text()).get("query", "")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
link.query = query
|
||||
link.exclude = exclude
|
||||
link.display_name = display_name
|
||||
if loaded:
|
||||
autoload_indices.append(len(links) - 1)
|
||||
|
||||
# Links that were loaded and visible at save time load automatically.
|
||||
for i in autoload_indices:
|
||||
if not Path(tool.Ifc.resolve_uri(links[i].filepath)).exists():
|
||||
print(f"WARNING: Not auto-loading missing linked model: {links[i].filepath}")
|
||||
continue
|
||||
bpy.ops.bim.load_link(link_index=i)
|
||||
|
||||
@classmethod
|
||||
def get_project_library_elements(
|
||||
@@ -1050,16 +858,9 @@ class Project(bonsai.core.tool.Project):
|
||||
|
||||
selected_vertices = [obj.matrix_world @ mesh.vertices[vi].co for vi in vert_map]
|
||||
for polygon in guid_polygons:
|
||||
selected_tris.append(tuple(vert_map[vi] for vi in polygon.vertices))
|
||||
selected_edges.extend(tuple([vert_map[vi] for vi in e]) for e in polygon.edge_keys)
|
||||
|
||||
# Polygons are not necessarily triangles (e.g. layerset-sliced
|
||||
# meshes contain ngons), so triangles come from the loop triangles.
|
||||
mesh.calc_loop_triangles()
|
||||
polygon_range = range(*slice_.indices(len(mesh.polygons)))
|
||||
for tri in mesh.loop_triangles:
|
||||
if tri.polygon_index in polygon_range:
|
||||
selected_tris.append(tuple(vert_map[vi] for vi in tri.vertices))
|
||||
|
||||
obj["selected_vertices"] = selected_vertices
|
||||
obj["selected_edges"] = selected_edges
|
||||
obj["selected_tris"] = selected_tris
|
||||
@@ -1096,9 +897,11 @@ class Project(bonsai.core.tool.Project):
|
||||
from bonsai.bim.module.project.data import LinksData
|
||||
from bonsai.bim.module.project.decorator import ProjectDecorator
|
||||
|
||||
# `instance_matrix` is the world matrix of the hit collection instance
|
||||
# from `ray_cast` (link empty matrix included). Without it, the root
|
||||
# empty is resolved as the collection's only instance.
|
||||
# Not sure if there's a difference between `instance_matrix` coming from `ray_cast`
|
||||
# and usual `matrix_world`, maybe we can just get it from object always.
|
||||
if instance_matrix is None:
|
||||
instance_matrix = obj.matrix_world
|
||||
|
||||
cls.deselect_queried_linked_element()
|
||||
cls.set_queried_linked_element(obj, guid, instance_matrix)
|
||||
cls.select_linked_element_geom(obj, guid)
|
||||
@@ -1155,7 +958,7 @@ class Project(bonsai.core.tool.Project):
|
||||
ProjectDecorator.install(context)
|
||||
|
||||
@classmethod
|
||||
def set_queried_linked_element(cls, obj: bpy.types.Object, guid: str, instance_matrix: Matrix | None) -> None:
|
||||
def set_queried_linked_element(cls, obj: bpy.types.Object, guid: str, instance_matrix: Matrix) -> None:
|
||||
props = tool.Project.get_project_props()
|
||||
props.queried_obj = obj
|
||||
props.queried_obj_root = cls.find_obj_root(obj, instance_matrix)
|
||||
@@ -1174,22 +977,17 @@ class Project(bonsai.core.tool.Project):
|
||||
del obj[field]
|
||||
|
||||
@classmethod
|
||||
def find_obj_root(cls, obj: bpy.types.Object, matrix: Matrix | None) -> bpy.types.Object | None:
|
||||
def find_obj_root(cls, obj: bpy.types.Object, matrix: Matrix) -> bpy.types.Object | None:
|
||||
collections = set(obj.users_collection)
|
||||
candidates = [
|
||||
o
|
||||
for o in bpy.data.objects
|
||||
if o.type == "EMPTY" and o.instance_type == "COLLECTION" and o.instance_collection in collections
|
||||
]
|
||||
if matrix is not None:
|
||||
# `matrix` is the instance's world matrix - the instancing
|
||||
# empty's matrix combined with the object's own local matrix
|
||||
# (non-identity for instanced occurrence objects).
|
||||
for o in candidates:
|
||||
if np.allclose(matrix, np.array(o.matrix_world) @ np.array(obj.matrix_world), atol=1e-4):
|
||||
return o
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
for o in bpy.data.objects:
|
||||
if (
|
||||
o.type != "EMPTY"
|
||||
or o.instance_type != "COLLECTION"
|
||||
or o.instance_collection not in collections
|
||||
or not np.allclose(matrix, o.matrix_world, atol=1e-4)
|
||||
):
|
||||
continue
|
||||
return o
|
||||
|
||||
class SelectedGeometry(NamedTuple):
|
||||
selected_vertices: list[tuple[float, float, float]]
|
||||
@@ -1198,11 +996,8 @@ class Project(bonsai.core.tool.Project):
|
||||
|
||||
@classmethod
|
||||
def get_selected_geometry(cls, obj: bpy.types.Object) -> SelectedGeometry:
|
||||
# ID properties are returned as IDPropertyArrays (the whole
|
||||
# property when empty, the items otherwise), which the GPU module
|
||||
# rejects as batch indices - convert to plain tuples.
|
||||
return cls.SelectedGeometry(
|
||||
[tuple(v) for v in obj["selected_vertices"]],
|
||||
[tuple(e) for e in obj["selected_edges"]],
|
||||
[tuple(t) for t in obj["selected_tris"]],
|
||||
obj["selected_vertices"],
|
||||
obj["selected_edges"],
|
||||
obj["selected_tris"],
|
||||
)
|
||||
|
||||
@@ -920,6 +920,21 @@ Scenario: Export IFC - with moved grid axis location synchronised
|
||||
And I load previously saved IFC project
|
||||
Then the object "IfcGridAxis/01" bottom left corner is at "1,-2,0"
|
||||
|
||||
Scenario: Export IFC - with duplicate-of-duplicate grid axis locations preserved
|
||||
Given an empty IFC project
|
||||
And I press "bim.add_grid"
|
||||
And I set "scene.BIMGridProperties.is_locked" to "False"
|
||||
And the object "IfcGridAxis/01" is selected
|
||||
And I duplicate the selected objects
|
||||
And the object "IfcGridAxis/01.001" is moved to "1,0,0"
|
||||
And the object "IfcGridAxis/01.001" is selected
|
||||
And I duplicate the selected objects
|
||||
And the object "IfcGridAxis/01.002" is moved to "2,0,0"
|
||||
When I save IFC project
|
||||
And I load previously saved IFC project
|
||||
Then the object "IfcGridAxis/01.001" bottom left corner is at "1,-2,0"
|
||||
And the object "IfcGridAxis/01.002" bottom left corner is at "2,-2,0"
|
||||
|
||||
Scenario: Export IFC - with changed object scale ignored
|
||||
Given an empty IFC project
|
||||
And I add a cube
|
||||
|
||||
@@ -35,6 +35,7 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.pset
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
@@ -37,6 +37,8 @@ from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.util.element
|
||||
import pytest
|
||||
|
||||
import bonsai.tool as tool
|
||||
|
||||
@@ -146,7 +146,9 @@ def test_fit_flow_segments_with_single_segment_dispatches_obstruction():
|
||||
mep.tool.Model, "get_flow_segment_profile", return_value=segment_profile
|
||||
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
|
||||
mep.MEPAddBend, "_execute", return_value=None
|
||||
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
|
||||
) as bend, patch.object(
|
||||
mep.MEPAddTransition, "_execute", return_value=None
|
||||
) as transition:
|
||||
mep.FitFlowSegments._execute(op, context=context)
|
||||
|
||||
assert obstruction.call_count == 1
|
||||
@@ -178,7 +180,9 @@ def test_fit_flow_segments_refuses_mixed_pipe_and_duct():
|
||||
mep.tool.Model, "get_flow_segment_profile", return_value=profile
|
||||
), patch.object(mep.MEPAddObstruction, "_execute", return_value=None) as obstruction, patch.object(
|
||||
mep.MEPAddBend, "_execute", return_value=None
|
||||
) as bend, patch.object(mep.MEPAddTransition, "_execute", return_value=None) as transition:
|
||||
) as bend, patch.object(
|
||||
mep.MEPAddTransition, "_execute", return_value=None
|
||||
) as transition:
|
||||
mep.FitFlowSegments._execute(op, context=context)
|
||||
|
||||
obstruction.assert_not_called()
|
||||
|
||||
@@ -173,8 +173,9 @@ def test_gizmo_group_class_wiring(gizmo_cls_name, bl_idname, is_element_predicat
|
||||
predicate = getattr(tool.Parametric, is_element_predicate)
|
||||
fake_element = Mock()
|
||||
fake_element.is_a.return_value = True
|
||||
with patch.object(tool.Parametric, is_element_predicate, side_effect=predicate) as p, patch.object(
|
||||
tool.System, "has_parametric_body", return_value=True
|
||||
with (
|
||||
patch.object(tool.Parametric, is_element_predicate, side_effect=predicate) as p,
|
||||
patch.object(tool.System, "has_parametric_body", return_value=True),
|
||||
):
|
||||
cls.is_element_type(fake_element)
|
||||
assert p.called, f"{gizmo_cls_name}.is_element_type did not delegate to Parametric.{is_element_predicate}"
|
||||
|
||||
@@ -139,6 +139,5 @@ def test_every_cancel_ops_entry_has_a_real_preview_propertygroup() -> None:
|
||||
orphaned = [attr for attr, _op in preview_base.PREVIEW_CANCEL_OPS if attr not in declared_attrs]
|
||||
assert not orphaned, (
|
||||
"PREVIEW_CANCEL_OPS contains entries whose PointerProperty child no longer "
|
||||
f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n "
|
||||
+ "\n ".join(orphaned)
|
||||
f"exists on {UMBRELLA_CLASS}. Drop the stale tuple(s):\n " + "\n ".join(orphaned)
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ import time
|
||||
|
||||
import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.util.element
|
||||
import pytest
|
||||
|
||||
from bonsai import tool as tool
|
||||
|
||||
@@ -23,6 +23,7 @@ import bpy
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.geometry
|
||||
import ifcopenshell.api.material
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.api.root
|
||||
import ifcopenshell.api.style
|
||||
import ifcopenshell.api.type
|
||||
|
||||
@@ -501,72 +501,3 @@ class TestGettingLinkedElementGeomSlice:
|
||||
obj = cast(bpy.types.Object, obj)
|
||||
slice_ = subject.Link.get_linked_element_geom_slice(obj, "aaa")
|
||||
assert range(15)[slice_] == range(5)
|
||||
|
||||
|
||||
class TestEncodeDecodeLinkFilter:
|
||||
def test_plain_include_round_trip(self):
|
||||
assert subject.encode_link_filter("IfcWall", "") == "IfcWall"
|
||||
assert subject.decode_link_filter("IfcWall") == ("IfcWall", "", False, "")
|
||||
|
||||
def test_empty_filter_encodes_to_none(self):
|
||||
assert subject.encode_link_filter("", "") is None
|
||||
assert subject.decode_link_filter(None) == ("", "", False, "")
|
||||
assert subject.decode_link_filter("") == ("", "", False, "")
|
||||
|
||||
def test_exclude_promotes_to_json(self):
|
||||
encoded = subject.encode_link_filter('IfcElement, group="X"', 'IfcSlab, parent="Y"')
|
||||
assert encoded.startswith("{")
|
||||
assert subject.decode_link_filter(encoded) == ('IfcElement, group="X"', 'IfcSlab, parent="Y"', False, "")
|
||||
|
||||
def test_loaded_promotes_to_json(self):
|
||||
encoded = subject.encode_link_filter("IfcWall", "", loaded=True)
|
||||
assert encoded.startswith("{")
|
||||
assert subject.decode_link_filter(encoded) == ("IfcWall", "", True, "")
|
||||
|
||||
def test_loaded_without_filter(self):
|
||||
encoded = subject.encode_link_filter("", "", loaded=True)
|
||||
assert subject.decode_link_filter(encoded) == ("", "", True, "")
|
||||
|
||||
def test_legacy_non_json_decodes_as_include(self):
|
||||
legacy = 'IfcElement, location="House - Type B"'
|
||||
assert subject.decode_link_filter(legacy) == (legacy, "", False, "")
|
||||
|
||||
def test_malformed_json_decodes_as_include(self):
|
||||
assert subject.decode_link_filter("{not json") == ("{not json", "", False, "")
|
||||
|
||||
def test_display_name_promotes_to_json(self):
|
||||
encoded = subject.encode_link_filter("IfcWall", "", display_name="North Wing")
|
||||
assert encoded.startswith("{")
|
||||
assert subject.decode_link_filter(encoded) == ("IfcWall", "", False, "North Wing")
|
||||
|
||||
|
||||
class TestGetLinkCachePaths:
|
||||
def test_empty_filter_keeps_legacy_names(self):
|
||||
blend, json_ = subject.get_link_cache_paths("/x/File A.ifc", "")
|
||||
assert blend.name == "File A.ifc.cache.blend"
|
||||
assert json_.name == "File A.ifc.cache.json"
|
||||
|
||||
def test_include_only_hash_matches_pre_exclude_formula(self):
|
||||
# Existing caches were keyed by md5(query)[:8]; they must stay valid.
|
||||
import hashlib
|
||||
|
||||
blend, _ = subject.get_link_cache_paths("/x/File A.ifc", "IfcWall")
|
||||
expected = hashlib.md5(b"IfcWall").hexdigest()[:8]
|
||||
assert blend.name == f"File A.ifc.cache.{expected}.blend"
|
||||
|
||||
def test_blend_and_json_share_a_suffix(self):
|
||||
blend, json_ = subject.get_link_cache_paths("/x/File A.ifc", "IfcWall", "IfcDoor")
|
||||
assert blend.name.removesuffix("blend") == json_.name.removesuffix("json")
|
||||
|
||||
def test_same_include_different_exclude_do_not_collide(self):
|
||||
# The reason the cache key hashes both strings: same-include links
|
||||
# with different excludes must not serve each other's geometry.
|
||||
a, _ = subject.get_link_cache_paths("/x/f.ifc", "IfcElement", "IfcSlab")
|
||||
b, _ = subject.get_link_cache_paths("/x/f.ifc", "IfcElement", "IfcDoor")
|
||||
c, _ = subject.get_link_cache_paths("/x/f.ifc", "IfcElement", "")
|
||||
assert len({a.name, b.name, c.name}) == 3
|
||||
|
||||
def test_exclude_only_distinct_from_empty_filter(self):
|
||||
a, _ = subject.get_link_cache_paths("/x/f.ifc", "", "IfcDoor")
|
||||
b, _ = subject.get_link_cache_paths("/x/f.ifc", "", "")
|
||||
assert a.name != b.name
|
||||
|
||||
@@ -57,7 +57,8 @@ class CsvHeader(TypedDict):
|
||||
|
||||
# Formula
|
||||
Formula: NotRequired[str]
|
||||
#QuantityClass: NotRequired[str]
|
||||
# QuantityClass: NotRequired[str]
|
||||
|
||||
|
||||
# Currently we assume that if column is not part of the main header,
|
||||
# then it is a cost value category. So here we list any additional column
|
||||
@@ -97,7 +98,8 @@ class CostItem(TypedDict):
|
||||
Query: Union[str, None]
|
||||
|
||||
Formula: Union[str, None]
|
||||
#QuantityClass: Union[str, None]
|
||||
# QuantityClass: Union[str, None]
|
||||
|
||||
|
||||
class Csv2Ifc:
|
||||
# Inputs.
|
||||
@@ -420,7 +422,7 @@ class Csv2Ifc:
|
||||
products=results,
|
||||
formula=cost_item["Formula"],
|
||||
ifc_class=ifc_quantity_class,
|
||||
)
|
||||
)
|
||||
|
||||
self.create_cost_items(cost_item["children"], cost_item["ifc"])
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ def open(
|
||||
kwargs = {"mmap": mmap}
|
||||
if logger is not None:
|
||||
kwargs["logger"] = logger
|
||||
f = ifcopenshell_wrapper.open(str(path.absolute()), **kwargs) # ty: ignore[unknown-argument]
|
||||
f = ifcopenshell_wrapper.open(str(path.absolute()), **kwargs)
|
||||
else:
|
||||
f = ifcopenshell_wrapper.open(str(path.absolute()), False, *((logger,) if logger is not None else ()))
|
||||
return file(f)
|
||||
|
||||
@@ -49,6 +49,7 @@ Future versions of this API may support:
|
||||
|
||||
from ._get_segment_start_point_label import register_referent_name_callback
|
||||
from .add_stationing_referent import add_stationing_referent
|
||||
from .add_positioning_referent import add_positioning_referent
|
||||
from .add_vertical_layout import add_vertical_layout
|
||||
from .add_zero_length_segment import add_zero_length_segment
|
||||
from .create import create
|
||||
@@ -94,6 +95,7 @@ from .util import *
|
||||
|
||||
__all__ = [
|
||||
"add_stationing_referent",
|
||||
"add_positioning_referent",
|
||||
"add_vertical_layout",
|
||||
"add_zero_length_segment",
|
||||
"create",
|
||||
|
||||
@@ -22,8 +22,6 @@ import numpy as np
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper
|
||||
import ifcopenshell.util.unit
|
||||
from ifcopenshell import entity_instance
|
||||
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
|
||||
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
|
||||
|
||||
@@ -22,28 +22,11 @@ import numpy as np
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
from ifcopenshell.api.alignment import _map_alignment_cant_segment
|
||||
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
|
||||
import ifcopenshell.api.nest
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.util.alignment
|
||||
import ifcopenshell.util.unit
|
||||
from ifcopenshell import entity_instance, ifcopenshell_wrapper
|
||||
from ifcopenshell import entity_instance
|
||||
from ifcopenshell.api.alignment._add_segment_to_curve import _add_segment_to_curve
|
||||
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
|
||||
from ifcopenshell.api.alignment._get_segment_start_point_label import (
|
||||
_get_segment_start_point_label,
|
||||
)
|
||||
from ifcopenshell.api.alignment._map_alignment_cant_segment import (
|
||||
_map_alignment_cant_segment,
|
||||
)
|
||||
from ifcopenshell.api.alignment._map_alignment_horizontal_segment import (
|
||||
_map_alignment_horizontal_segment,
|
||||
)
|
||||
from ifcopenshell.api.alignment._map_alignment_vertical_segment import (
|
||||
_map_alignment_vertical_segment,
|
||||
)
|
||||
|
||||
|
||||
def _add_segment_to_layout(
|
||||
|
||||
@@ -18,11 +18,7 @@
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.util.alignment
|
||||
from ifcopenshell import entity_instance
|
||||
from ifcopenshell.api.alignment._get_segment_start_point_label import (
|
||||
_get_segment_start_point_label,
|
||||
)
|
||||
|
||||
|
||||
def _add_zero_length_segment(file: ifcopenshell.file, layout: entity_instance) -> None:
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.geom
|
||||
from ifcopenshell import entity_instance, ifcopenshell_wrapper
|
||||
from ifcopenshell.api.alignment._map_alignment_segment import _map_alignment_segment
|
||||
from typing import Union
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.guid
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
def add_positioning_referent(
|
||||
file: ifcopenshell.file,
|
||||
name: str,
|
||||
alignment: entity_instance,
|
||||
distance_along: float,
|
||||
station: float,
|
||||
positioned_product: entity_instance,
|
||||
) -> entity_instance:
|
||||
"""
|
||||
Semantically defines the position of a product along an alignment by adding an IfcReferent to the alignment that defines the stationing system.
|
||||
|
||||
:param alignment: the alignment to receive the referent
|
||||
:param distance_along: distance along the alignment basis curve
|
||||
:param station: station value
|
||||
:param name: name to assign to IfcReferent.Name, typically a stringized version of the station value
|
||||
:param positioned_product: the product whose position is informed by the referent
|
||||
:return: referent
|
||||
|
||||
Example:
|
||||
|
||||
.. code:: python
|
||||
|
||||
alignment = model.by_type("IfcAlignment")[0]
|
||||
pier = model.by_type("IfcBridgePart")[0]
|
||||
ifcopenshell.api.alignment.add_positioning_referent(model,name="Pier 1 Sta 1+00",alignment=alignment,distance_along=0.0,station=100.0,positioned_product=pier)
|
||||
"""
|
||||
|
||||
curve = ifcopenshell.api.alignment.get_curve(alignment)
|
||||
|
||||
object_placement = None
|
||||
representation = None
|
||||
if curve and curve.is_a("IfcCompositeCurve") and 0 < len(curve.Segments):
|
||||
object_placement = file.createIfcLinearPlacement(
|
||||
RelativePlacement=file.createIfcAxis2PlacementLinear(
|
||||
Location=file.createIfcPointByDistanceExpression(
|
||||
DistanceAlong=file.createIfcLengthMeasure(distance_along),
|
||||
OffsetLateral=None,
|
||||
OffsetVertical=None,
|
||||
OffsetLongitudinal=None,
|
||||
BasisCurve=curve,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
update_fallback_position(file, object_placement)
|
||||
else:
|
||||
object_placement = file.createIfcLocalPlacement(
|
||||
PlacementRelTo=None,
|
||||
RelativePlacement=file.createIfcAxis2Placement2D(
|
||||
Location=file.createIfcCartesianPoint(alignment.ObjectPlacement.RelativePlacement.Location.Coordinates)
|
||||
),
|
||||
)
|
||||
|
||||
# this commented out code is what you would do to add a geometric representation of the referent
|
||||
# the example is a circle. a better way would be to pass a representation into the function
|
||||
# representation = file.create_entity(
|
||||
# name="IfcCircle",
|
||||
# position=file.createIfcAxis2Placement2D(Location=file.createIfcCartesianPoint(Coordinates=(0.0, 0.0)),
|
||||
# radius=1.0)
|
||||
# )
|
||||
|
||||
# create referent for the station
|
||||
referent = file.createIfcReferent(
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
OwnerHistory=None,
|
||||
Name=name,
|
||||
Description=None,
|
||||
ObjectType=None,
|
||||
ObjectPlacement=object_placement,
|
||||
Representation=representation,
|
||||
PredefinedType="POSITION",
|
||||
)
|
||||
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station})
|
||||
|
||||
if len(referent.Positions) == 0:
|
||||
rel_positions = file.createIfcRelPositions(
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
RelatingPositioningElement=referent,
|
||||
RelatedProducts=[
|
||||
positioned_product,
|
||||
],
|
||||
)
|
||||
else:
|
||||
referent.Positions[0].RelatedProducts += (positioned_product,)
|
||||
|
||||
return referent
|
||||
@@ -16,35 +16,35 @@
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import numpy as np
|
||||
from typing import Optional
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
from ifcopenshell.api.alignment.update_fallback_position import update_fallback_position
|
||||
import ifcopenshell.api.pset
|
||||
import ifcopenshell.geom
|
||||
import ifcopenshell.guid
|
||||
import ifcopenshell.util.element
|
||||
import ifcopenshell.util.unit
|
||||
from ifcopenshell import entity_instance, ifcopenshell_wrapper
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
def add_stationing_referent(
|
||||
file: ifcopenshell.file,
|
||||
name: str,
|
||||
alignment: entity_instance,
|
||||
distance_along: float,
|
||||
station: float,
|
||||
name: str,
|
||||
positioned_product: entity_instance,
|
||||
incoming_station: Optional[float] = None,
|
||||
on_basis_curve: Optional[bool] = None,
|
||||
) -> entity_instance:
|
||||
"""
|
||||
Adds an IfcReferent to the alignment with the Pset_Stationing property set.
|
||||
Adds an IfcReferent to the alignment that defines the stationing system.
|
||||
|
||||
:param name: name to assign to IfcReferent.Name, typically a stringized version of the station value
|
||||
:param alignment: the alignment to receive the referent
|
||||
:param distance_along: distance along the alignment basis curve
|
||||
:param station: station value
|
||||
:param name: name to assign to IfcReferent.Name, typically a stringized version of the station value
|
||||
:param positioned_product: the product whose position is informed by the referent
|
||||
:param incoming_station: station value of the incoming segment, only set to specify a station equation
|
||||
:param on_basis_curve: whether the referent is positioned on the basis curve or the alignment curve, if None the function will default to the basis curve
|
||||
:return: referent
|
||||
|
||||
Example:
|
||||
@@ -52,14 +52,21 @@ def add_stationing_referent(
|
||||
.. code:: python
|
||||
|
||||
alignment = model.by_type("IfcAlignment")[0]
|
||||
ifcopenshell.api.alignment.add_stationing_referent(model,alignment=alignment,distance_along=0.0,station=100.0)
|
||||
ifcopenshell.api.alignment.add_stationing_referent(model,name="1+00.0",alignment=alignment,distance_along=0.0,station=100.0)
|
||||
"""
|
||||
|
||||
basis_curve = ifcopenshell.api.alignment.get_basis_curve(alignment)
|
||||
if on_basis_curve is None:
|
||||
on_basis_curve = True
|
||||
|
||||
curve = (
|
||||
ifcopenshell.api.alignment.get_basis_curve(alignment)
|
||||
if on_basis_curve
|
||||
else ifcopenshell.api.alignment.get_curve(alignment)
|
||||
)
|
||||
|
||||
object_placement = None
|
||||
representation = None
|
||||
if basis_curve and basis_curve.is_a("IfcCompositeCurve") and 0 < len(basis_curve.Segments):
|
||||
if curve and curve.is_a("IfcCompositeCurve") and 0 < len(curve.Segments):
|
||||
object_placement = file.createIfcLinearPlacement(
|
||||
RelativePlacement=file.createIfcAxis2PlacementLinear(
|
||||
Location=file.createIfcPointByDistanceExpression(
|
||||
@@ -67,7 +74,7 @@ def add_stationing_referent(
|
||||
OffsetLateral=None,
|
||||
OffsetVertical=None,
|
||||
OffsetLongitudinal=None,
|
||||
BasisCurve=basis_curve,
|
||||
BasisCurve=curve,
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -100,8 +107,12 @@ def add_stationing_referent(
|
||||
Representation=representation,
|
||||
PredefinedType="STATION",
|
||||
)
|
||||
properties = {"Station": station}
|
||||
if incoming_station is not None:
|
||||
properties["IncomingStation"] = incoming_station
|
||||
|
||||
pset_stationing = ifcopenshell.api.pset.add_pset(file, product=referent, name="Pset_Stationing")
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties={"Station": station})
|
||||
ifcopenshell.api.pset.edit_pset(file, pset=pset_stationing, properties=properties)
|
||||
|
||||
nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
if nest is None:
|
||||
@@ -115,15 +126,4 @@ def add_stationing_referent(
|
||||
nest.RelatedObjects, key=lambda x: ifcopenshell.util.element.get_pset(x, name="Pset_Stationing", prop="Station")
|
||||
)
|
||||
|
||||
if len(referent.Positions) == 0:
|
||||
rel_positions = file.createIfcRelPositions(
|
||||
GlobalId=ifcopenshell.guid.new(),
|
||||
RelatingPositioningElement=referent,
|
||||
RelatedProducts=[
|
||||
positioned_product,
|
||||
],
|
||||
)
|
||||
else:
|
||||
referent.Positions[0].RelatedProducts += (positioned_product,)
|
||||
|
||||
return referent
|
||||
|
||||
@@ -51,18 +51,6 @@ def _move_vertical_layout_to_child_alignment(
|
||||
# aggregate the child alignment to the parent alignment
|
||||
ifcopenshell.api.aggregate.assign_object(file, products=[child_alignment], relating_object=parent_alignment)
|
||||
|
||||
# move all referents positioning segments of the vertical layout to the referent nest of the child alignment
|
||||
child_referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, child_alignment)
|
||||
parent_referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, parent_alignment)
|
||||
for referent in parent_referent_nest.RelatedObjects:
|
||||
for product in referent.Positions[0].RelatedProducts:
|
||||
if product.is_a("IfcAlignmentSegment") and product.Nests[0].RelatingObject == vertical_layout:
|
||||
# ifcopenshell.api.nest.change_nest(file,referent,child_alignment) - this doesn't work because referent is assigned to child_alignment.IsNestedBy[0].RelatedObjects
|
||||
# and it needs to be assigned to child_alignment.IsNestedBy[1].RelatedObjects
|
||||
# move the referent manually - unassign it and add it to the child alignment's referent nest
|
||||
ifcopenshell.api.nest.unassign_object(file, [referent])
|
||||
child_referent_nest.RelatedObjects += (referent,)
|
||||
|
||||
# if the parent alignment has a representation, move the Axis/Curve3D represention to the child alignment
|
||||
base_curve = ifcopenshell.api.alignment.get_basis_curve(parent_alignment)
|
||||
if base_curve:
|
||||
|
||||
@@ -23,18 +23,8 @@ import ifcopenshell.api.alignment
|
||||
from ifcopenshell.api.alignment._get_segment_endpoint import _get_segment_endpoint
|
||||
from ifcopenshell.api.alignment._update_zero_length_segment_placement import _update_zero_length_segment_placement
|
||||
import ifcopenshell.api.nest
|
||||
import ifcopenshell.ifcopenshell_wrapper as wrapper
|
||||
import ifcopenshell.util.unit
|
||||
from ifcopenshell import entity_instance
|
||||
from ifcopenshell.api.alignment._get_segment_start_point_label import (
|
||||
_get_segment_start_point_label,
|
||||
)
|
||||
from ifcopenshell.api.alignment._map_alignment_horizontal_segment import (
|
||||
_map_alignment_horizontal_segment,
|
||||
)
|
||||
from ifcopenshell.api.alignment._map_alignment_vertical_segment import (
|
||||
_map_alignment_vertical_segment,
|
||||
)
|
||||
from ifcopenshell.api.alignment._update_curve_segment_transition_code import (
|
||||
_update_curve_segment_transition_code,
|
||||
)
|
||||
|
||||
@@ -87,9 +87,7 @@ def create(
|
||||
_create_geometric_representation(file, alignment)
|
||||
|
||||
referent_name = ifcopenshell.util.alignment.station_as_string(file, start_station)
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||
file, alignment, 0.0, start_station, referent_name, alignment
|
||||
)
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(file, referent_name, alignment, 0.0, start_station)
|
||||
|
||||
for layout in alignment_layouts:
|
||||
_add_zero_length_segment(file, layout)
|
||||
|
||||
@@ -141,7 +141,7 @@ def create_as_polyline(
|
||||
|
||||
# define stationing
|
||||
name = ifcopenshell.util.alignment.station_as_string(file, start_station)
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(file, alignment, 0.0, start_station, name, alignment)
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(file, name, alignment, 0.0, start_station)
|
||||
|
||||
# IFC 4.1.4.1.1 Alignment Aggregation To Project
|
||||
project = file.by_type("IfcProject")[0]
|
||||
|
||||
@@ -21,9 +21,7 @@ from typing import Union
|
||||
import numpy as np
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.geom
|
||||
from ifcopenshell import entity_instance, ifcopenshell_wrapper
|
||||
from ifcopenshell import entity_instance
|
||||
from ifcopenshell.api.alignment._add_segment_to_layout import _add_segment_to_layout
|
||||
|
||||
|
||||
|
||||
@@ -16,23 +16,47 @@
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.util.element
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
def distance_along_from_station(file: ifcopenshell.file, alignment: entity_instance, station: float) -> float:
|
||||
def _distance_along_of_referent(referent: entity_instance) -> float:
|
||||
placement = referent.ObjectPlacement
|
||||
if placement.is_a("IfcLinearPlacement"):
|
||||
return placement.RelativePlacement.Location.DistanceAlong.wrappedValue
|
||||
# IfcLocalPlacement fallback (e.g. semantic-only alignment, or the placement could not yet
|
||||
# be expressed relative to a basis curve) carries no DistanceAlong; it is only ever used for
|
||||
# the starting referent, at distance 0.0.
|
||||
return 0.0
|
||||
|
||||
|
||||
def distance_along_from_station(file: ifcopenshell.file, alignment: entity_instance, station: float) -> Optional[float]:
|
||||
"""
|
||||
Given a station, returns the distance along the horizontal alignment.
|
||||
|
||||
If the alignment does not have stationing defined with an IfcReferent, the start of the alignment is assumed
|
||||
to be at station 0.0. That is, the station is the distance along.
|
||||
|
||||
.. note:: The current implementation does not account for station equations and assumes stationing is increasing along the alignment.
|
||||
Station equations (where Pset_Stationing.IncomingStation is set on a referent) are taken into account.
|
||||
For each STATION referent nested to the alignment, DistanceAlong (D) and the outgoing station (S, i.e.
|
||||
Pset_Stationing.Station) are read off, sorted by DistanceAlong. The requested station is located within
|
||||
the segment defined by the last referent whose outgoing station is less than or equal to it, and the
|
||||
distance along is computed as D + (station - S) for that referent.
|
||||
|
||||
If the station falls within a gap introduced by a forward (gap) station equation - that is, it was skipped
|
||||
over by the equation - there is no distance along that corresponds to it, and None is returned.
|
||||
|
||||
Note that an overlap (backward) station equation causes a range of stations to correspond to two distinct
|
||||
distances along the alignment, one on either side of the equation. This implementation returns the distance
|
||||
along in the segment following the equation (i.e. the outgoing side).
|
||||
|
||||
:param alignment: the alignment
|
||||
:param station: station value
|
||||
:return: distance along the horizontal alignment
|
||||
:return: distance along the horizontal alignment, or None if the station falls inside a station equation gap
|
||||
|
||||
Example:
|
||||
|
||||
@@ -43,6 +67,36 @@ def distance_along_from_station(file: ifcopenshell.file, alignment: entity_insta
|
||||
print(dist_along) # 100.00
|
||||
"""
|
||||
|
||||
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
||||
dist_along = station - start_station
|
||||
return dist_along
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
if referent_nest is None:
|
||||
start_station = ifcopenshell.api.alignment.get_alignment_start_station(file, alignment)
|
||||
return station - start_station
|
||||
|
||||
stations = [
|
||||
(
|
||||
_distance_along_of_referent(referent),
|
||||
ifcopenshell.util.element.get_pset(referent, name="Pset_Stationing", prop="Station"),
|
||||
)
|
||||
for referent in referent_nest.RelatedObjects
|
||||
]
|
||||
stations.sort(key=lambda entry: entry[0])
|
||||
|
||||
index = None
|
||||
for i, (distance_along, outgoing_station) in enumerate(stations):
|
||||
if outgoing_station <= station:
|
||||
index = i
|
||||
|
||||
if index is None:
|
||||
# station precedes the alignment's starting station; extrapolate from the first referent
|
||||
distance_along, outgoing_station = stations[0]
|
||||
return distance_along + (station - outgoing_station)
|
||||
|
||||
distance_along, outgoing_station = stations[index]
|
||||
|
||||
if index + 1 < len(stations):
|
||||
next_distance_along, _ = stations[index + 1]
|
||||
if station - outgoing_station > next_distance_along - distance_along:
|
||||
# the station was skipped over by a forward (gap) station equation
|
||||
return None
|
||||
|
||||
return distance_along + (station - outgoing_station)
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import numpy as np
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.util.placement
|
||||
from ifcopenshell import entity_instance
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ def assign_cost_item_quantity(
|
||||
"products": products or [],
|
||||
"prop_name": prop_name,
|
||||
"formula": formula,
|
||||
"ifc_class" : ifc_class
|
||||
"ifc_class": ifc_class,
|
||||
}
|
||||
return usecase.execute()
|
||||
|
||||
@@ -134,7 +134,7 @@ class Usecase:
|
||||
continue
|
||||
self.assign_cost_control(related_object=product, cost_item=self.settings["cost_item"])
|
||||
if self.settings["formula"]:
|
||||
tree = ast.parse(self.settings["formula"], mode = "eval")
|
||||
tree = ast.parse(self.settings["formula"], mode="eval")
|
||||
collector = VariableExtractor()
|
||||
collector.visit(tree)
|
||||
variables = collector.variables
|
||||
@@ -144,10 +144,10 @@ class Usecase:
|
||||
value = getter(product, variable)
|
||||
|
||||
if value is None:
|
||||
print(
|
||||
f"WARNING: Variable '{variable}' in product '{product.Name}' "
|
||||
f"is missing (None). Check Pset/Qset or property name."
|
||||
)
|
||||
print(
|
||||
f"WARNING: Variable '{variable}' in product '{product.Name}' "
|
||||
f"is missing (None). Check Pset/Qset or property name."
|
||||
)
|
||||
elif value == 0:
|
||||
print(
|
||||
f"WARNING: Variable '{variable}' in product '{product.Name}' "
|
||||
@@ -159,7 +159,9 @@ class Usecase:
|
||||
|
||||
new_quantity = None
|
||||
for quantity in self.quantities:
|
||||
if quantity.Formula == self.settings["formula"] and len(self.settings["products"]) == 1: #Todo improve it
|
||||
if (
|
||||
quantity.Formula == self.settings["formula"] and len(self.settings["products"]) == 1
|
||||
): # Todo improve it
|
||||
new_quantity = quantity
|
||||
self.settings["ifc_class"] = quantity.is_a()
|
||||
continue
|
||||
@@ -184,23 +186,23 @@ class Usecase:
|
||||
self.update_cost_item_count()
|
||||
|
||||
def get_value_from_pset(
|
||||
self,
|
||||
product:ifcopenshell.entity_instance,
|
||||
v: str,
|
||||
self,
|
||||
product: ifcopenshell.entity_instance,
|
||||
v: str,
|
||||
) -> float:
|
||||
pset_name = v.split(".")[0]
|
||||
pset = ifcopenshell.util.element.get_pset(product, pset_name)
|
||||
pset_property_name = v.split(".")[1]
|
||||
return (pset or {}).get(pset_property_name,None)
|
||||
return (pset or {}).get(pset_property_name, None)
|
||||
|
||||
def get_value_from_qset(
|
||||
self,
|
||||
product:ifcopenshell.entity_instance,
|
||||
v: str,
|
||||
self,
|
||||
product: ifcopenshell.entity_instance,
|
||||
v: str,
|
||||
) -> float:
|
||||
qtos = ifcopenshell.util.element.get_psets(product, qtos_only = True)
|
||||
qtos = ifcopenshell.util.element.get_psets(product, qtos_only=True)
|
||||
quantities = next(iter(qtos.values()), {})
|
||||
return (quantities or {}).get(v,None)
|
||||
return (quantities or {}).get(v, None)
|
||||
|
||||
def assign_cost_control(
|
||||
self, related_object: ifcopenshell.entity_instance, cost_item: ifcopenshell.entity_instance
|
||||
@@ -243,6 +245,7 @@ class Usecase:
|
||||
count += 1
|
||||
quantity[3] = count
|
||||
|
||||
|
||||
OPERATORS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
@@ -252,18 +255,20 @@ OPERATORS = {
|
||||
ast.USub: operator.neg,
|
||||
}
|
||||
|
||||
|
||||
def build_full_name(node):
|
||||
#used for variables with dots
|
||||
# used for variables with dots
|
||||
parts = []
|
||||
while isinstance(node, ast.Attribute):
|
||||
parts.append(node.attr)
|
||||
node = node.value
|
||||
parts.append(node.attr)
|
||||
node = node.value
|
||||
|
||||
if isinstance(node, ast.Name):
|
||||
parts.append(node.id)
|
||||
|
||||
return ".".join(reversed(parts))
|
||||
|
||||
|
||||
class VariableExtractor(ast.NodeVisitor):
|
||||
def __init__(self):
|
||||
self.variables = set()
|
||||
@@ -274,6 +279,7 @@ class VariableExtractor(ast.NodeVisitor):
|
||||
def visit_Attribute(self, node):
|
||||
self.variables.add(build_full_name(node))
|
||||
|
||||
|
||||
class FormulaEvaluator(ast.NodeVisitor):
|
||||
def __init__(self, values):
|
||||
self.values = values
|
||||
@@ -281,7 +287,7 @@ class FormulaEvaluator(ast.NodeVisitor):
|
||||
def visit_BinOp(self, node):
|
||||
left = self.visit(node.left)
|
||||
right = self.visit(node.right)
|
||||
return OPERATORS[type(node.op)](left, right)
|
||||
return OPERATORS[type(node.op)](left, right) # ty: ignore[too-many-positional-arguments]
|
||||
|
||||
def visit_Name(self, node):
|
||||
return self.values[node.id]
|
||||
|
||||
@@ -78,7 +78,8 @@ def create_axis_curve(
|
||||
points /= unit_scale
|
||||
|
||||
grid = next(i for i in file.get_inverse(grid_axis) if i.is_a("IfcGrid"))
|
||||
grid_matrix_i = np.linalg.inv(ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement))
|
||||
grid_placement = ifcopenshell.util.placement.get_local_placement(grid.ObjectPlacement)
|
||||
grid_matrix_i = np.linalg.inv(grid_placement)
|
||||
p1, p2 = ifc_safe_vector_type(np_apply_matrix(points, grid_matrix_i))
|
||||
grid_axis.AxisCurve = file.create_entity(
|
||||
"IfcPolyline",
|
||||
@@ -88,5 +89,5 @@ def create_axis_curve(
|
||||
),
|
||||
)
|
||||
|
||||
if existing_curve:
|
||||
if existing_curve and file.get_total_inverses(existing_curve) == 0:
|
||||
ifcopenshell.util.element.remove_deep2(file, existing_curve)
|
||||
|
||||
@@ -221,8 +221,7 @@ for id in to_emit:
|
||||
statements.append("%s << %s" % (id, stmt))
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(
|
||||
r"""
|
||||
print(r"""
|
||||
# This file is generated by IfcOpenShell ifcexpressparser bootstrap.py
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -261,6 +260,4 @@ if __name__ == "__main__":
|
||||
mdl = importlib.import_module(output)
|
||||
mdl.Generator(m).emit()
|
||||
sys.stdout.write(m.schema.name)
|
||||
"""
|
||||
% ("\n ".join(statements))
|
||||
)
|
||||
""" % ("\n ".join(statements)))
|
||||
|
||||
@@ -695,6 +695,7 @@ codegen_rule("MOD", lambda context: "%")
|
||||
codegen_rule("TRUE", lambda context: "True")
|
||||
codegen_rule("FALSE", lambda context: "False")
|
||||
|
||||
|
||||
def _dotted_name(node: ast.AST):
|
||||
"""Return dotted name for Name/Attribute chains, else None."""
|
||||
if isinstance(node, ast.Name):
|
||||
@@ -704,6 +705,7 @@ def _dotted_name(node: ast.AST):
|
||||
return f"{base}.{node.attr}" if base else node.attr
|
||||
return None
|
||||
|
||||
|
||||
class AttributeGetattrTransformer(ast.NodeTransformer):
|
||||
def visit_Attribute(self, node):
|
||||
parents = []
|
||||
@@ -720,7 +722,7 @@ class AttributeGetattrTransformer(ast.NodeTransformer):
|
||||
if isinstance(node.ctx, ast.Store):
|
||||
return node
|
||||
|
||||
if _dotted_name(node) in ('ifcopenshell.create_entity', 'str.lower'):
|
||||
if _dotted_name(node) in ("ifcopenshell.create_entity", "str.lower"):
|
||||
return node
|
||||
|
||||
if node.attr.startswith("__"):
|
||||
|
||||
@@ -363,24 +363,18 @@ class EarlyBoundCodeWriter:
|
||||
)
|
||||
)
|
||||
|
||||
self.statements[self.statements.index("{factory_placeholder}")] = (
|
||||
"""
|
||||
self.statements[self.statements.index("{factory_placeholder}")] = """
|
||||
class %(schema_name)s_instance_factory : public IfcParse::instance_factory {
|
||||
virtual IfcUtil::IfcBaseClass* operator()(const IfcParse::declaration* decl, IfcEntityInstanceData&& data) const {
|
||||
%(instance_mapping)s
|
||||
}
|
||||
};
|
||||
"""
|
||||
% locals()
|
||||
)
|
||||
""" % locals()
|
||||
|
||||
""
|
||||
self.statements[self.statements.index("{string_pool_placeholder}")] = (
|
||||
"""
|
||||
self.statements[self.statements.index("{string_pool_placeholder}")] = """
|
||||
const std::string strings[] = {%s};
|
||||
"""
|
||||
% ",".join(map(lambda s: '"%s"s' % s, self.strings))
|
||||
)
|
||||
""" % ",".join(map(lambda s: '"%s"s' % s, self.strings))
|
||||
|
||||
def __str__(self):
|
||||
return "\n".join(self.statements)
|
||||
|
||||
@@ -145,8 +145,7 @@ class configuration:
|
||||
config.set(
|
||||
"snippets",
|
||||
"print all wall ids",
|
||||
self.config_encode(
|
||||
"""
|
||||
self.config_encode("""
|
||||
###########################################################################
|
||||
# A simple script that iterates over all walls in the current model #
|
||||
# and prints their Globally unique IDs (GUIDS) to the console window #
|
||||
@@ -154,15 +153,13 @@ class configuration:
|
||||
|
||||
for wall in model.by_type("IfcWall"):
|
||||
print ("wall with global id: "+str(wall.GlobalId))
|
||||
""".lstrip()
|
||||
),
|
||||
""".lstrip()),
|
||||
)
|
||||
|
||||
config.set(
|
||||
"snippets",
|
||||
"print properties of current selection",
|
||||
self.config_encode(
|
||||
"""
|
||||
self.config_encode("""
|
||||
###########################################################################
|
||||
# A simple script that iterates over all IfcPropertySets of the currently #
|
||||
# selected object and prints them to the console #
|
||||
@@ -180,8 +177,7 @@ if selection:
|
||||
for prop in relDefinesByProperties.RelatingPropertyDefinition.HasProperties:
|
||||
print ("{:<20} :{}".format(prop.Name,prop.NominalValue.wrappedValue))
|
||||
print ("\\n")
|
||||
""".lstrip()
|
||||
),
|
||||
""".lstrip()),
|
||||
)
|
||||
with open(conf_file, "w") as configfile:
|
||||
config.write(configfile)
|
||||
|
||||
@@ -1697,10 +1697,16 @@ class uninitialized_tag: ...
|
||||
|
||||
def arrange_polygons(settings, polygons): ...
|
||||
def clear_schemas(): ...
|
||||
def construct_iterator(geometry_library, settings, file, num_threads): ...
|
||||
def construct_iterator_with_include_exclude(geometry_library, settings, file, elems, include, num_threads): ...
|
||||
def construct_iterator_with_include_exclude_globalid(geometry_library, settings, file, elems, include, num_threads): ...
|
||||
def construct_iterator_with_include_exclude_id(geometry_library, settings, file, elems, include, num_threads): ...
|
||||
def construct_iterator(geometry_library, settings, file, num_threads, logger=...): ...
|
||||
def construct_iterator_with_include_exclude(
|
||||
geometry_library, settings, file, elems, include, num_threads, logger=...
|
||||
): ...
|
||||
def construct_iterator_with_include_exclude_globalid(
|
||||
geometry_library, settings, file, elems, include, num_threads, logger=...
|
||||
): ...
|
||||
def construct_iterator_with_include_exclude_id(
|
||||
geometry_library, settings, file, elems, include, num_threads, logger=...
|
||||
): ...
|
||||
def convert_loop_to_function_item(loop): ...
|
||||
def create_box(*args): ...
|
||||
def create_epeck(*args): ...
|
||||
@@ -1717,8 +1723,8 @@ def line_segments_to_polygons(s, eps, segments): ...
|
||||
def map_shape(settings, instance): ...
|
||||
def nary_union(sequence): ...
|
||||
def new_IfcBaseClass(schema_identifier: str, name: str) -> entity_instance: ...
|
||||
def open(fn: str, readonly: bool = False) -> file: ...
|
||||
def parse_ifcxml(filename): ...
|
||||
def open(fn: str, readonly: bool = False, logger=...) -> file: ...
|
||||
def parse_ifcxml(filename, logger=...): ...
|
||||
def polygons_to_svg(*args): ...
|
||||
def read(data): ...
|
||||
def register_schema(arg1): ...
|
||||
|
||||
@@ -56,7 +56,7 @@ def append_zero_length_segments(file: ifcopenshell.file) -> ifcopenshell.file:
|
||||
for alignment in alignments:
|
||||
layouts = ifcopenshell.api.alignment.get_alignment_layouts(alignment)
|
||||
for layout in layouts:
|
||||
ifcopenshell.api.alignment.add_zero_length_segment(patched_file, layout, include_referent=False)
|
||||
ifcopenshell.api.alignment.add_zero_length_segment(patched_file, layout)
|
||||
curve = ifcopenshell.api.alignment.get_layout_curve(layout)
|
||||
if curve:
|
||||
ifcopenshell.api.alignment.add_zero_length_segment(patched_file, curve)
|
||||
|
||||
@@ -355,8 +355,7 @@ def get_cost_rate(
|
||||
|
||||
class CostValueUnserialiser:
|
||||
def parse(self, formula: str):
|
||||
l = lark.Lark(
|
||||
"""start: formula
|
||||
l = lark.Lark("""start: formula
|
||||
formula: operand (operator operand)*
|
||||
operand: value | category "(" formula ")"
|
||||
value: NUMBER?
|
||||
@@ -393,8 +392,7 @@ class CostValueUnserialiser:
|
||||
NEWLINE: (CR? LF)+
|
||||
|
||||
%ignore WS // Disregard spaces in text
|
||||
"""
|
||||
)
|
||||
""")
|
||||
start = l.parse(formula)
|
||||
return self.get_formula(start.children[0])
|
||||
|
||||
|
||||
@@ -39,8 +39,7 @@ import ifcopenshell.util.shape
|
||||
import ifcopenshell.util.system
|
||||
import ifcopenshell.util.unit
|
||||
|
||||
filter_elements_grammar = lark.Lark(
|
||||
"""start: filter_group
|
||||
filter_elements_grammar = lark.Lark("""start: filter_group
|
||||
filter_group: facet_list ("+" facet_list)*
|
||||
facet_list: facet ("," facet)*
|
||||
|
||||
@@ -111,11 +110,9 @@ filter_elements_grammar = lark.Lark(
|
||||
NEWLINE: (CR? LF)+
|
||||
|
||||
%ignore WS // Disregard spaces in text
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
get_element_grammar = lark.Lark(
|
||||
"""start: keys
|
||||
get_element_grammar = lark.Lark("""start: keys
|
||||
|
||||
keys: key ("." key)*
|
||||
key: quoted_string | regex_string | unquoted_string
|
||||
@@ -130,11 +127,9 @@ get_element_grammar = lark.Lark(
|
||||
WS: /[ \\t\\f\\r\\n]/+
|
||||
|
||||
%ignore WS // Disregard spaces in text
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
format_grammar = lark.Lark(
|
||||
"""start: expression
|
||||
format_grammar = lark.Lark("""start: expression
|
||||
|
||||
?expression: add_sub
|
||||
?add_sub: mul_div
|
||||
@@ -193,8 +188,7 @@ format_grammar = lark.Lark(
|
||||
NEWLINE: (CR? LF)+
|
||||
|
||||
%ignore WS // Disregard spaces in text
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
class FormatTransformer(lark.Transformer):
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.unit
|
||||
import ifcopenshell.util.element
|
||||
|
||||
|
||||
def test_add_positioning_referent():
|
||||
file = ifcopenshell.file(schema="IFC4X3")
|
||||
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
|
||||
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
|
||||
file,
|
||||
context_type="Model",
|
||||
context_identifier="Axis",
|
||||
target_view="MODEL_VIEW",
|
||||
parent=geometric_representation_context,
|
||||
)
|
||||
|
||||
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0)
|
||||
|
||||
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0]
|
||||
|
||||
referent = ifcopenshell.api.alignment.add_positioning_referent(
|
||||
file, "P.C.", alignment, distance_along=0.0, station=2000.0, positioned_product=segment
|
||||
)
|
||||
|
||||
assert referent.is_a("IfcReferent")
|
||||
assert referent.PredefinedType == "POSITION"
|
||||
assert referent.Name == "P.C."
|
||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing")
|
||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == 2000.0
|
||||
assert referent.ObjectPlacement != None
|
||||
|
||||
assert len(referent.Positions) == 1
|
||||
rel_positions = referent.Positions[0]
|
||||
assert rel_positions.is_a("IfcRelPositions")
|
||||
assert rel_positions.RelatingPositioningElement == referent
|
||||
assert rel_positions.RelatedProducts == (segment,)
|
||||
|
||||
|
||||
def test_add_positioning_referent_creates_separate_referent_per_call():
|
||||
file = ifcopenshell.file(schema="IFC4X3")
|
||||
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
|
||||
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
|
||||
file,
|
||||
context_type="Model",
|
||||
context_identifier="Axis",
|
||||
target_view="MODEL_VIEW",
|
||||
parent=geometric_representation_context,
|
||||
)
|
||||
|
||||
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", start_station=2000.0)
|
||||
|
||||
horizontal_layout = ifcopenshell.api.alignment.get_horizontal_layout(alignment)
|
||||
segment = ifcopenshell.api.alignment.get_layout_segments(horizontal_layout)[0]
|
||||
|
||||
first_referent = ifcopenshell.api.alignment.add_positioning_referent(
|
||||
file, "P.C.", alignment, distance_along=0.0, station=2000.0, positioned_product=segment
|
||||
)
|
||||
|
||||
other_product = file.createIfcBuildingElementProxy(GlobalId=ifcopenshell.guid.new(), Name="Sign")
|
||||
second_referent = ifcopenshell.api.alignment.add_positioning_referent(
|
||||
file, "P.C.", alignment, distance_along=0.0, station=2000.0, positioned_product=other_product
|
||||
)
|
||||
|
||||
# each call creates its own IfcReferent, each with its own IfcRelPositions to the product passed in
|
||||
assert first_referent != second_referent
|
||||
assert len(first_referent.Positions) == 1
|
||||
assert first_referent.Positions[0].RelatedProducts == (segment,)
|
||||
assert len(second_referent.Positions) == 1
|
||||
assert second_referent.Positions[0].RelatedProducts == (other_product,)
|
||||
|
||||
|
||||
test_add_positioning_referent()
|
||||
test_add_positioning_referent_creates_separate_referent_per_call()
|
||||
@@ -0,0 +1,115 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2025 Thomas Krijnen <thomas@aecgeeks.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.unit
|
||||
import ifcopenshell.util.element
|
||||
|
||||
|
||||
def _create_test_file():
|
||||
file = ifcopenshell.file(schema="IFC4X3")
|
||||
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
length = ifcopenshell.api.unit.add_si_unit(file, unit_type="LENGTHUNIT")
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
|
||||
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
|
||||
file,
|
||||
context_type="Model",
|
||||
context_identifier="Axis",
|
||||
target_view="MODEL_VIEW",
|
||||
parent=geometric_representation_context,
|
||||
)
|
||||
return file
|
||||
|
||||
|
||||
def _create_test_alignment_with_vertical(file):
|
||||
# include_vertical=True so that get_curve() (IfcGradientCurve, on the "Axis" representation)
|
||||
# and get_basis_curve() (IfcCompositeCurve, on the "FootPrint" representation) are different
|
||||
# entities, letting the on_basis_curve option be observed.
|
||||
alignment = ifcopenshell.api.alignment.create(file, "TestAlignment", include_vertical=True, start_station=0.0)
|
||||
assert ifcopenshell.api.alignment.get_basis_curve(alignment).is_a("IfcCompositeCurve")
|
||||
assert ifcopenshell.api.alignment.get_curve(alignment).is_a("IfcGradientCurve")
|
||||
assert ifcopenshell.api.alignment.get_basis_curve(alignment) != ifcopenshell.api.alignment.get_curve(alignment)
|
||||
return alignment
|
||||
|
||||
|
||||
def _assert_common_referent_asserts(referent, name, station):
|
||||
assert referent.is_a("IfcReferent")
|
||||
assert referent.PredefinedType == "STATION"
|
||||
assert referent.Name == name
|
||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing")
|
||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == station
|
||||
assert referent.ObjectPlacement != None
|
||||
|
||||
|
||||
def test_add_stationing_referent_on_basis_curve_none_defaults_to_basis_curve():
|
||||
# on_basis_curve=None should behave the same as on_basis_curve=True
|
||||
file = _create_test_file()
|
||||
alignment = _create_test_alignment_with_vertical(file)
|
||||
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||
file, "1+00.000", alignment, distance_along=100.0, station=100.0, on_basis_curve=None
|
||||
)
|
||||
|
||||
_assert_common_referent_asserts(referent, "1+00.000", 100.0)
|
||||
|
||||
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
|
||||
assert referent.ObjectPlacement.RelativePlacement.Location.BasisCurve == ifcopenshell.api.alignment.get_basis_curve(
|
||||
alignment
|
||||
)
|
||||
|
||||
|
||||
def test_add_stationing_referent_on_basis_curve_true():
|
||||
file = _create_test_file()
|
||||
alignment = _create_test_alignment_with_vertical(file)
|
||||
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||
file, "1+00.000", alignment, distance_along=100.0, station=100.0, on_basis_curve=True
|
||||
)
|
||||
|
||||
_assert_common_referent_asserts(referent, "1+00.000", 100.0)
|
||||
|
||||
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
|
||||
assert referent.ObjectPlacement.RelativePlacement.Location.BasisCurve == ifcopenshell.api.alignment.get_basis_curve(
|
||||
alignment
|
||||
)
|
||||
|
||||
|
||||
def test_add_stationing_referent_on_basis_curve_false():
|
||||
# with a vertical layout present, on_basis_curve=False positions the referent on the
|
||||
# alignment curve (IfcGradientCurve) rather than on the basis curve (IfcCompositeCurve).
|
||||
file = _create_test_file()
|
||||
alignment = _create_test_alignment_with_vertical(file)
|
||||
|
||||
referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||
file, "1+00.000", alignment, distance_along=100.0, station=100.0, on_basis_curve=False
|
||||
)
|
||||
|
||||
_assert_common_referent_asserts(referent, "1+00.000", 100.0)
|
||||
|
||||
assert referent.ObjectPlacement.is_a("IfcLinearPlacement")
|
||||
basis_curve = referent.ObjectPlacement.RelativePlacement.Location.BasisCurve
|
||||
assert basis_curve == ifcopenshell.api.alignment.get_curve(alignment)
|
||||
assert basis_curve != ifcopenshell.api.alignment.get_basis_curve(alignment)
|
||||
|
||||
|
||||
test_add_stationing_referent_on_basis_curve_none_defaults_to_basis_curve()
|
||||
test_add_stationing_referent_on_basis_curve_true()
|
||||
test_add_stationing_referent_on_basis_curve_false()
|
||||
@@ -48,5 +48,26 @@ def test_add_stationing_to_alignment():
|
||||
assert ifcopenshell.util.element.get_pset(element=referent, name="Pset_Stationing", prop="Station") == 2000.0
|
||||
assert referent.ObjectPlacement != None
|
||||
|
||||
# add a station equation at 1000 distance along. this is station 3+000 in coming and 4+000 outgoing.
|
||||
# this is a gap equation.
|
||||
second_referent = ifcopenshell.api.alignment.add_stationing_referent(
|
||||
file, "4+000.000", alignment, distance_along=1000.0, station=4000.0, incoming_station=3000.0
|
||||
)
|
||||
|
||||
referent_nest = ifcopenshell.api.alignment.get_referent_nest(file, alignment)
|
||||
assert len(referent_nest.RelatedObjects) == 2
|
||||
|
||||
assert second_referent == referent_nest.RelatedObjects[1]
|
||||
|
||||
assert second_referent.PredefinedType == "STATION"
|
||||
assert second_referent.Name == "4+000.000"
|
||||
assert ifcopenshell.util.element.get_pset(element=second_referent, name="Pset_Stationing")
|
||||
assert ifcopenshell.util.element.get_pset(element=second_referent, name="Pset_Stationing", prop="Station") == 4000.0
|
||||
assert (
|
||||
ifcopenshell.util.element.get_pset(element=second_referent, name="Pset_Stationing", prop="IncomingStation")
|
||||
== 3000.0
|
||||
)
|
||||
assert second_referent.ObjectPlacement != None
|
||||
|
||||
|
||||
test_add_stationing_to_alignment()
|
||||
|
||||
@@ -21,9 +21,12 @@ import math
|
||||
|
||||
import pytest
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api.aggregate
|
||||
import ifcopenshell.api.alignment
|
||||
import ifcopenshell.api.context
|
||||
import ifcopenshell.api.spatial
|
||||
import ifcopenshell.api.unit
|
||||
import numpy as np
|
||||
import ifcopenshell.util.unit
|
||||
|
||||
|
||||
def test_create_representation():
|
||||
|
||||
@@ -53,4 +53,56 @@ def test_distance_along_from_station():
|
||||
assert ifcopenshell.api.alignment.distance_along_from_station(file, alignment, 17525.36) == pytest.approx(7525.36)
|
||||
|
||||
|
||||
def test_distance_along_from_station_with_station_equations():
|
||||
# Reproduces the worked example from the IFC Alignment Geometry Implementation Guide, chapter 9.2.6:
|
||||
# a gap equation (P3: incoming 14+00.00, outgoing 17+00.00) and an overlap equation
|
||||
# (P4: incoming 19+00.00, outgoing 18+50.00).
|
||||
file = ifcopenshell.file(schema="IFC4X3")
|
||||
project = file.createIfcProject(GlobalId=ifcopenshell.guid.new(), Name="Test")
|
||||
length = ifcopenshell.api.unit.add_conversion_based_unit(file, name="foot")
|
||||
ifcopenshell.api.unit.assign_unit(file, units=[length])
|
||||
geometric_representation_context = ifcopenshell.api.context.add_context(file, context_type="Model")
|
||||
axis_model_representation_subcontext = ifcopenshell.api.context.add_context(
|
||||
file,
|
||||
context_type="Model",
|
||||
context_identifier="Axis",
|
||||
target_view="MODEL_VIEW",
|
||||
parent=geometric_representation_context,
|
||||
)
|
||||
|
||||
coordinates = [(500.0, 2500.0), (3340.0, 660.0), (4340.0, 5000.0), (7600.0, 4560.0), (8480.0, 2010.0)]
|
||||
radii = [(1000.0), (1250.0), (950.0)]
|
||||
vpoints = [(0.0, 100.0), (2000.0, 135.0), (5000.0, 105.0), (7400.0, 153.0), (9800.0, 105.0), (12800.0, 90.0)]
|
||||
lengths = [(1600.0), (1200.0), (2000.0), (800.0)]
|
||||
|
||||
alignment = ifcopenshell.api.alignment.create_by_pi_method(
|
||||
file, "TestAlignment", coordinates, radii, vpoints, lengths, start_station=1000.0
|
||||
)
|
||||
|
||||
ifcopenshell.api.alignment.add_stationing_referent(
|
||||
file, "P3", alignment, distance_along=400.0, station=1700.0, incoming_station=1400.0
|
||||
)
|
||||
ifcopenshell.api.alignment.add_stationing_referent(
|
||||
file, "P4", alignment, distance_along=600.0, station=1850.0, incoming_station=1900.0
|
||||
)
|
||||
|
||||
distance_along_from_station = ifcopenshell.api.alignment.distance_along_from_station
|
||||
|
||||
# between P2 and P3: Sta. 13+00.00
|
||||
assert distance_along_from_station(file, alignment, 1300.0) == pytest.approx(300.0)
|
||||
|
||||
# between P3 and P4: Sta. 18+00.00
|
||||
assert distance_along_from_station(file, alignment, 1800.0) == pytest.approx(500.0)
|
||||
|
||||
# between P4 and P5: Sta. 19+25.00
|
||||
assert distance_along_from_station(file, alignment, 1925.0) == pytest.approx(675.0)
|
||||
|
||||
# Sta. 15+00.00 falls inside the gap opened by the equation at P3 and has no corresponding distance along
|
||||
assert distance_along_from_station(file, alignment, 1500.0) is None
|
||||
|
||||
# Sta. 18+75.00 falls inside the overlap zone at P4; the post-equation (outgoing) match is returned
|
||||
assert distance_along_from_station(file, alignment, 1875.0) == pytest.approx(625.0)
|
||||
|
||||
|
||||
test_distance_along_from_station()
|
||||
test_distance_along_from_station_with_station_equations()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import numpy as np
|
||||
|
||||
import ifcopenshell.api.grid
|
||||
import test.bootstrap
|
||||
|
||||
|
||||
class TestCreateAxisCurve(test.bootstrap.IFC4):
|
||||
def make_grid_with_axis(self, axis_tag="A"):
|
||||
grid = self.file.createIfcGrid()
|
||||
grid.ObjectPlacement = self.file.createIfcLocalPlacement(
|
||||
RelativePlacement=self.file.createIfcAxis2Placement3D(
|
||||
Location=self.file.createIfcCartesianPoint([0.0, 0.0, 0.0])
|
||||
)
|
||||
)
|
||||
axis = ifcopenshell.api.grid.create_grid_axis(
|
||||
self.file, axis_tag=axis_tag, same_sense=True, uvw_axes="UAxes", grid=grid
|
||||
)
|
||||
return grid, axis
|
||||
|
||||
def test_creates_a_polyline_axis_curve(self):
|
||||
_, axis = self.make_grid_with_axis()
|
||||
ifcopenshell.api.grid.create_axis_curve(
|
||||
self.file, p1=np.array([0.0, 0.0, 0.0]), p2=np.array([10.0, 0.0, 0.0]), grid_axis=axis
|
||||
)
|
||||
assert axis.AxisCurve is not None
|
||||
assert axis.AxisCurve.is_a("IfcPolyline")
|
||||
assert len(axis.AxisCurve.Points) == 2
|
||||
|
||||
def test_replaces_existing_curve_when_unshared(self):
|
||||
"""Calling create_axis_curve again on the same axis replaces the old curve
|
||||
and removes the old curve from the file when nothing else references it."""
|
||||
_, axis = self.make_grid_with_axis()
|
||||
ifcopenshell.api.grid.create_axis_curve(
|
||||
self.file, p1=np.array([0.0, 0.0, 0.0]), p2=np.array([10.0, 0.0, 0.0]), grid_axis=axis
|
||||
)
|
||||
old_curve_id = axis.AxisCurve.id()
|
||||
ifcopenshell.api.grid.create_axis_curve(
|
||||
self.file, p1=np.array([1.0, 0.0, 0.0]), p2=np.array([11.0, 0.0, 0.0]), grid_axis=axis
|
||||
)
|
||||
assert axis.AxisCurve.id() != old_curve_id
|
||||
assert self.file.by_id(old_curve_id) is None
|
||||
|
||||
def test_does_not_remove_shared_curve(self):
|
||||
"""When two axes share the same AxisCurve (e.g. after a shallow copy during
|
||||
duplication), updating one axis must not destroy the curve still referenced
|
||||
by the other axis."""
|
||||
grid, axis = self.make_grid_with_axis()
|
||||
axis2 = ifcopenshell.api.grid.create_grid_axis(
|
||||
self.file, axis_tag="B", same_sense=True, uvw_axes="UAxes", grid=grid
|
||||
)
|
||||
ifcopenshell.api.grid.create_axis_curve(
|
||||
self.file, p1=np.array([0.0, 0.0, 0.0]), p2=np.array([10.0, 0.0, 0.0]), grid_axis=axis
|
||||
)
|
||||
shared_curve = axis.AxisCurve
|
||||
shared_curve_id = shared_curve.id()
|
||||
# Simulate what copy_class produces: a duplicate axis that shares the
|
||||
# source's AxisCurve rather than having its own copy.
|
||||
axis2.AxisCurve = shared_curve
|
||||
assert self.file.get_total_inverses(shared_curve) == 2
|
||||
|
||||
# Updating axis1's curve must not remove the curve that axis2 still needs.
|
||||
ifcopenshell.api.grid.create_axis_curve(
|
||||
self.file, p1=np.array([1.0, 0.0, 0.0]), p2=np.array([11.0, 0.0, 0.0]), grid_axis=axis
|
||||
)
|
||||
assert axis2.AxisCurve.id() == shared_curve_id
|
||||
assert self.file.by_id(shared_curve_id) is not None
|
||||
|
||||
|
||||
class TestCreateAxisCurveIFC2X3(test.bootstrap.IFC2X3, TestCreateAxisCurve):
|
||||
pass
|
||||
@@ -1,5 +1,6 @@
|
||||
import ifcopenshell
|
||||
|
||||
|
||||
def test_skip_over_non_entity_instance():
|
||||
data = """
|
||||
ISO-10303-21;
|
||||
|
||||
@@ -46,4 +46,4 @@ def test_file(filename):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main(["-sx", __file__, '--import-mode=importlib'])
|
||||
pytest.main(["-sx", __file__, "--import-mode=importlib"])
|
||||
|
||||
@@ -111,7 +111,7 @@ class Patcher(ifcpatch.BasePatcher):
|
||||
if element.is_a("IfcProject"):
|
||||
proj = self.new.add(element)
|
||||
for ctx in element.RepresentationContexts or ():
|
||||
for coop in getattr(ctx, 'HasCoordinateOperation', ()):
|
||||
for coop in getattr(ctx, "HasCoordinateOperation", ()):
|
||||
self.new.add(coop)
|
||||
return proj
|
||||
return ifcopenshell.api.project.append_asset(
|
||||
|
||||
@@ -33,9 +33,7 @@ class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4):
|
||||
Points=point_list,
|
||||
Segments=segments,
|
||||
)
|
||||
self.file.create_entity(
|
||||
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
|
||||
)
|
||||
self.file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
|
||||
return curve
|
||||
|
||||
def test_run_without_segments(self):
|
||||
@@ -80,9 +78,7 @@ class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4):
|
||||
Points=point_list,
|
||||
Segments=[self.file.createIfcLineIndex((1, 2, 3, 4, 1))],
|
||||
)
|
||||
self.file.create_entity(
|
||||
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
|
||||
)
|
||||
self.file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
|
||||
ifcpatch.execute(
|
||||
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
|
||||
)
|
||||
@@ -110,9 +106,7 @@ class TestDowngradeIndexedPolyCurve(test.bootstrap.IFC4):
|
||||
self.file.createIfcLineIndex((3, 4)),
|
||||
],
|
||||
)
|
||||
self.file.create_entity(
|
||||
"IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve
|
||||
)
|
||||
self.file.create_entity("IfcArbitraryClosedProfileDef", ProfileType="AREA", OuterCurve=curve)
|
||||
ifcpatch.execute(
|
||||
{"input": "input.ifc", "file": self.file, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}
|
||||
)
|
||||
|
||||
@@ -977,12 +977,14 @@ struct ShapeRTTI : public boost::static_visitor<PyObject*>
|
||||
if (item == nullptr) {
|
||||
throw IfcParse::IfcException("Failed to convert placement");
|
||||
}
|
||||
/*
|
||||
if (st.get<ifcopenshell::geometry::settings::ConvertBackUnits>().get()) {
|
||||
// we pass the settings to the Transformation object, but access the data just offloads to the
|
||||
// generic cartesian_base<Matrix4> so there's no time to apply the settings to the translation part.
|
||||
item = ifcopenshell::geometry::taxonomy::matrix4::ptr(item->clone_());
|
||||
item->components().col(3).head<3>() /= kernel.settings().get<ifcopenshell::geometry::settings::LengthUnit>().get();
|
||||
}
|
||||
*/
|
||||
return new IfcGeom::Transformation(kernel.settings(), item);
|
||||
} else {
|
||||
if (!representation) {
|
||||
|
||||
Reference in New Issue
Block a user