Compare commits

..

7 Commits

Author SHA1 Message Date
Ryan Schultz 6ca69d20d9 dev-notes: record the material usage trap in should_map_representations
The note under "assign_type preserves per-instance" carried a caveat that
skipping the mapping also keeps the occurrence's own profile/material, "fine
when all types share one profile -- revisit if reassigning across different
profiles". That is exactly what bit: map_material_usages lives inside the same
if-block, so the occurrence kept a usage pointing at the old type's profile set
and the material panel edited the old type.

Replace the caveat with what actually happens, why the fix reuses
material.assign_material rather than setting ForProfileSet by hand, and where
the coverage lives. Tick the matching checklist item.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:37:34 -05:00
Ryan Schultz 158a9cb63b Bonsai: cover the material usage re-point on retype
Three tests over core.assign_type with a real IFC file: a per-instance profile
occurrence retyped from one profile type to another must end up with its own
usage pointing at the new type's IfcMaterialProfileSet, must keep its own
non-mapped body at its own extrusion depth while picking up the new type's
profile, and must leave the old type untouched.

The first two fail against the parent commit, with the occurrence's material
resolving to the old type's set and SweptArea never moving off the old profile.
The third passes either way; it is a guard against a future fix that reaches
sideways into the old type.

Placed under test/bim rather than test/core even though the subject is
bonsai.core: Prophecy cannot reach this branch because _is_per_instance_profile
calls ifcopenshell.util directly on the element, so a real entity is required
and Prophecy serialises call arguments to JSON. Driving a real file then imports
ifcopenshell.api.material and so mathutils, which make test-core deliberately
runs without. No bpy is touched.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:36:11 -05:00
Ryan Schultz d41aec733c Bonsai: update the core assign_type tests for should_map_representations
assign_type started passing should_map_representations to api type.assign_type,
but the two core tests still predicted the old call. Prophecy serializes kwargs
into the prediction key, so both failed with "Interface 'bonsai.core.tool.Ifc'
was not called with run".

Both cases use a plain string stand-in for the element, which has no material,
so _is_per_instance_profile returns False and the flag is True.

test/core is green again: 385 passed.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:20:55 -05:00
Ryan Schultz c1515fe2bb Bonsai: re-point an occurrence's material usage when the mapping is skipped
assign_type passes should_map_representations=False for a per-instance profile
occurrence so the new type's shared representation is not mapped over its own
body. That flag gates more than the representation: in api type.assign_type it
also guards map_material_usages, the step that re-points an occurrence's
IfcMaterialProfileSetUsage at the new type's material set.

The occurrence therefore kept a usage whose ForProfileSet still referenced the
OLD type's IfcMaterialProfileSet. get_material(should_skip_usage=True) follows
that pointer, so the material panel showed, and edited, the old type's profile.
Duplicating a profile-based type and then changing the copy's profile silently
rewrote the original's profile instead, taking every other occurrence of the
original with it.

Re-point the usage here when the mapping is suppressed. Reusing
material.assign_material rather than setting ForProfileSet by hand matters: its
update_representation_profile rewrites SweptArea to the new type's profile but
never touches the extrusion depth, so the per-instance length this branch exists
to protect still survives. restore_material_usage_attributes runs afterwards, so
recorded usage attributes land on the new usage.

Traced in Blender against a duplicated IfcFurnitureType: before the fix the
profile edit reported the source type's IfcMaterialProfile, after it the copy's,
while the occurrence's representation entity ids were unchanged across the
retype.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:14:07 -05:00
Ryan Schultz 44339de804 Bonsai: toggle a profile type's length between typed and per-instance
Lets an occurrence's extrusion length be either driven by the type
(typed/shared, e.g. Revit-mapped mullions) or owned by the occurrence
(per-instance, editable), and lets the user switch between the two.

- bim.make_profile_length_per_instance: un-maps a shared mapped body into a
  per-instance SweptSolid (shared IfcProfileDef kept; the old mapped body is
  orphaned, not deleted, to avoid cascading to sibling instances), gives the
  occurrence its own IfcMaterialProfileSetUsage (so the Length UI appears), and
  normalizes the placement so the extrusion runs local Z 0->depth -- Revit puts
  the object and extrusion origins on opposite ends, which made extend_profile
  flip the origin.
- bim.make_profile_length_type_driven: the inverse -- re-maps the occurrence
  onto the type's shared representation, promoting the occurrence's body onto
  the type first if the type has no RepresentationMap.
- A "Per-instance Length" checkbox in the Type panel (a get/set property that
  reads the current mode straight from the IFC, so there is no stored state to
  desync).
- New occurrences of a mapped/typed profile type default to per-instance.
- recreate_profile now skips mapped bodies (reloading the mesh instead of
  regenerating), so assigning a length-driven type keeps the occurrence typed
  rather than un-mapping it mid-assign and emptying sibling instances via the
  shared-representation removal cascade.
- assign_type preserves a per-instance occurrence's own geometry/length
  (should_map_representations=False) instead of converting it to typed.

Design/working notes under docs/dev-notes/profile-length-per-instance.md.

Closes #8657
Closes #8656

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:39:47 -05:00
Ryan Schultz dac8563ccf Bonsai: guard change_data against a None mesh datablock
reimport_element_representations' change_data already handles an empty ->
mesh transition (recreate_object_with_data when the datatypes differ), but the
leftover-data cleanup then called has_data_users(old_data) with old_data=None,
crashing with "'NoneType' object has no attribute 'users'". Guard the cleanup
so an object with no prior mesh reloads cleanly.

Closes #8655

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:39:47 -05:00
Ryan Schultz 1c421d01c3 Bonsai: add "Make Length Per-Instance" for typed/mapped profile types
Adds bim.make_profile_length_per_instance, which converts profile
occurrences whose length is "typed" (shared via the type's mapped
RepresentationMap) into per-instance editable extrusions.

Per selected occurrence it:
- swaps the mapped body for its own SweptSolid copy (geometry/length
  preserved exactly, shared IfcProfileDef kept), leaving the old mapped
  IfcShapeRepresentation as a harmless orphan rather than deleting it (a
  delete would either cascade to sibling instances via the shared
  RepresentationMap or leave Bonsai's Blender-side links dangling);
- gives it its own IfcMaterialProfileSetUsage, since occurrences that only
  inherit the type's IfcMaterialProfileSet report get_usage_type()==None and
  never get the per-instance Length UI.

Sibling instances and the shared MappingSource/MappedRepresentation are left
untouched. The operator is idempotent: re-running repairs partial conversions
(e.g. adds a missing usage to an already-un-mapped occurrence).

A notice + "Make Length Per-Instance" button in the Type panel surfaces the
conversion when the active occurrence is still typed-length.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:17:31 -05:00
160 changed files with 1300 additions and 2444 deletions
+7 -16
View File
@@ -55,17 +55,11 @@ jobs:
black --diff --check . | black-codeclimate | python .github/workflows/black_to_github_annotations.py
continue-on-error: true
- name: ty check (venv setup)
run: poe ty-venv
- name: ty check (bonsai)
id: ty-bonsai
run: poe ty-bonsai
continue-on-error: true
- name: ty check (ios)
id: ty-ios
run: poe ty-ios
- name: ty check
id: ty
run: |
poe ty-venv
poe ty
continue-on-error: true
- name: Ruff check
@@ -115,10 +109,7 @@ jobs:
if [ "${{ steps.ruff.outcome }}" != "success" ]; then
echo "::error::Ruff check failed, see Summary or 'ruff' step for the details." && ERROR=1
fi
if [ "${{ steps.ty-bonsai.outcome }}" != "success" ]; then
echo "::error::ty check (bonsai) failed, see 'ty check (bonsai)' step for the details." && ERROR=1
fi
if [ "${{ steps.ty-ios.outcome }}" != "success" ]; then
echo "::error::ty check (ios) failed, see 'ty check (ios)' step for the details." && ERROR=1
if [ "${{ steps.ty.outcome }}" != "success" ]; then
echo "::error::ty check failed, see 'ty check' step for the details." && ERROR=1
fi
exit $ERROR
+1 -1
View File
@@ -18,7 +18,7 @@ and many other libraries, CLI apps, and more. Support is also provided for auxil
For more information, see:
* [IfcOpenShell Website](https://ifcopenshell.org)
* [IfcOpenShell Website](http://ifcopenshell.org)
* [IfcOpenShell Documentation](https://docs.ifcopenshell.org)
* [IfcOpenShell C++ Installation](https://docs.ifcopenshell.org/ifcopenshell/installation.html)
* [IfcOpenShell Python Installation](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html)
+28
View File
@@ -0,0 +1,28 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# Developer notes (in-progress features)
This directory holds **living design/working notes for unmerged feature branches**,
one Markdown file per feature, named after its branch (e.g.
`opening-template-on-type.md`).
## Purpose
A shared scratchpad so collaborators — and the AI agents they work with — can pick up
the context behind an in-progress branch: the problem, the design decisions and the
*why*, dead ends already ruled out, and what still needs testing. Because the note is
committed on the branch, it travels with the PR and shows up in the diff, so it is
discoverable without anyone being told where to look.
## How to use it (humans and agents)
- **Before working on a feature branch**, read its note here if one exists.
- **As the PR is refined**, keep the note current — append decisions, correct things
that changed, update the test checklist.
- **One file per feature**, named after the branch.
## Lifecycle
These are *not* permanent user documentation. When a PR merges, either remove its note
or promote the durable parts (the load-bearing "why") into code comments or the regular
docs, so stale notes do not accumulate on the default branch.
@@ -0,0 +1,154 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# Profile length per-instance — typed vs per-instance length for profile types
> **Living dev note** for the `profile-length-per-instance` 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.
>
> Tracks feature **#8657**; depends on bug fixes **#8655** (`change_data` None) and
> **#8656** (mapped-profile assign cascade).
## Problem
Revit-exported profile members (e.g. curtain-wall mullions) come in **typed-length**:
the extrusion — including its **length** — lives on the `IfcMemberType` as an
`IfcRepresentationMap`, and every occurrence shares it via an `IfcMappedItem`. Bonsai's
profile tools assume each occurrence owns an editable extrusion (`IfcMemberStandardCase`
style), so on these mapped occurrences the length UI never appears, editing/joining
crashes, changing the type-material bleeds across all instances, and `extend_profile`
flips the origin.
Goal: make **Length** a typed-vs-per-instance property of a profile type, toggle-able in
both directions, without corrupting the shared geometry.
- **Typed length** = geometry (incl. length) on the type's `RepresentationMap`;
occurrences `IfcMappedItem` it. One length for all instances.
- **Per-instance length** = occurrence has its **own** `SweptSolid` body + its **own**
`IfcMaterialProfileSetUsage`. Each instance edits its own length (`IfcMemberStandardCase`).
## Key facts established
- **`tool.Model.get_usage_type(el)` returns `"PROFILE"` iff `el` carries an
`IfcMaterialProfileSet(Usage)`** (checked with `should_inherit=False`). Bonsai gates the
per-instance profile UI (Length control) on this. A mapped occurrence that only
*inherits* the type's profile set reports `None` → no Length UI. This is why un-mapping
must also give the occurrence its **own** `IfcMaterialProfileSetUsage`.
- **The mapped representation is shared.** `bonsai.core.geometry.remove_representation`,
when handed a mapped rep, resolves it and loops `get_elements_of_type`
`switch_from_representation`, i.e. it **cascades to every sibling instance of the type**,
leaving them empty. Any code that removes an occurrence's mapped body triggers this. This
is the single biggest hazard and the root of most crashes we saw.
- **Typed length is spec-valid.** A plain `IfcMember` with a mapped body + a profile-set
usage is valid IFC (just not the `IfcMemberStandardCase` subtype). `IfcMaterialProfileSet`
has **no length**; length is always in the geometry. So offering typed length is a
legitimate mode, not a workaround (verified against local IFC4 ADD2 TC1).
- **Two origins, on opposite ends.** Revit places the object's `ObjectPlacement` origin at
one end of the mullion and the extrusion's own `Position` origin at the other, with the
object's local **+Z pointing away** from the sweep (local Z runs `-depth → 0`).
`get_profile_axis` (object bound-box local-Z range) + `DumbProfileJoiner.recreate_profile`
(which plants the new origin at `body[0]` = min-local-Z) then relocate the origin to the
far end on extend/join — the "flip". Natively-authored profiles run local Z `0 → depth`,
so `body[0]` is already the origin and nothing moves.
- **`create_profile` double-bodies mapped types.** `assign_type` (default
`should_map_representations=True`) maps the type's shared body onto a new occurrence, then
`DumbProfileGenerator.create_profile` adds a per-instance extrusion on top → two Body reps
/ typed-by-default.
- The wrapper/core were **not** the cause of the crashes we chased for a while — a
`git reset --hard v0.8.0` reproduced clean, our applied changes reproduced the crash. The
installed environment is now matched (repo source + release wrapper `3e7b739`, via
`dev_environment.py`).
## Design
Two explicit operators + a Type-panel toggle, plus defaults/guards so the mapped hazard is
never hit implicitly.
- **`bim.make_profile_length_per_instance`** (un-map; `MakeProfileLengthPerInstance`):
1. Copy the mapped extrusion items into a new per-instance `SweptSolid` body, **keeping
the shared `IfcProfileDef`** (`copy_deep(..., exclude=["IfcProfileDef"])`).
2. **Orphan** the old mapped body (retarget the product shape, do **not** delete it) —
deleting cascades to siblings, and the raw delete dangles Bonsai's Blender-side links.
3. Give the occurrence its **own** `IfcMaterialProfileSetUsage` (else no Length UI).
4. **Normalize placement**: if the object's local +Z points away from the sweep (origin at
the max-local-Z end), flip 180° about local X and rebuild the extrusion via
`add_profile_representation` so local Z runs `0 → depth` from the (unchanged) origin.
This is what stops `extend_profile` flipping the origin.
- Idempotent / repair-capable: re-running adds a missing usage to an already-un-mapped
occurrence. Only identity mapping transforms are handled (others are skipped).
- **`bim.make_profile_length_type_driven`** (re-map; `MakeProfileLengthTypeDriven`):
drop the occurrence's own usage, then `ifcopenshell.api.type.map_type_representations` to
map the type's shared geometry back on. If the type has **no** `RepresentationMap`
(Bonsai-authored profile type), first **promote** a copy of the occurrence's body onto the
type as a `RepresentationMap` (so the first toggled occurrence defines the type's length;
siblings snap to it).
- **UI toggle** (`type/ui.py` `draw_product_ui`, `type/data.py`, `type/prop.py`): a single
**Per-instance Length** checkbox. Backed by a `get`/`set` `BoolProperty`
(`length_per_instance`) — `get` reads the current mode from cached `TypeData` flags
(`is_typed_length_profile` / `can_make_length_type_driven`), `set` runs the matching
operator. No stored state to desync.
- **New occurrences default to per-instance** (`DumbProfileGenerator.create_profile`): after
`assign_type`, drop the inherited mapped reps so only the per-instance extrusion remains.
No-op for types without a `RepresentationMap`.
- **`recreate_profile` mapped guard** (`DumbProfileJoiner.recreate_profile`): if the body is
mapped, **skip** the per-instance rebuild (leaving typed geometry alone — a length-driven
type should stay typed) **but `switch_representation` to reload** so the Blender mesh
reflects a just-mapped type's geometry. This kills the "assign a length-driven type ⇒
Failed to set value + sibling turns empty" cascade *and* keeps the typed display fresh.
Fixes issue **#8656**.
- **`assign_type` preserves per-instance** (`core/type.py`): if the occurrence is already
per-instance (own non-mapped body + own profile usage), pass
`should_map_representations=False` so reassigning a type keeps its own geometry/length
instead of converting it to typed.
- **…but that flag gates the material usage too** (`core/type.py`): in `api type.assign_type`
the `map_material_usages` call sits *inside* the same `if should_map_representations:`
block. Suppressing the mapping therefore also suppressed the step that re-points the
occurrence's `IfcMaterialProfileSetUsage` at the new type's set, leaving a usage whose
`ForProfileSet` still referenced the **old** type. `get_material(should_skip_usage=True)`
follows that pointer, so the material panel showed — and edited — the old type's profile:
duplicate a profile type, change the copy's profile, and the *original's* profile was
rewritten instead, taking every other occurrence of the original with it. This is the
"revisit if reassigning across different profiles" caveat that used to sit here, and it
bit in the wild.
Fixed by re-pointing the usage in `core/type.py` immediately after the `ifc.run`. Reuse
`material.assign_material` rather than setting `ForProfileSet` by hand: its
`update_representation_profile` rewrites `SweptArea` to the new type's profile but never
touches the extrusion depth, so the per-instance length this whole branch exists to
protect still survives. `restore_material_usage_attributes` runs after it and lands on the
new usage. Covered by `test/bim/module/type/test_assign_type_material_usage.py`.
## Supporting bug fixes (separate from the feature)
- **`tool/geometry.py` `change_data`** — guard `has_data_users`/`delete_data` against a
`None` `old_data` (empty→mesh reload path). Real Bonsai bug, exposed by the re-map reload;
filed as **#8655** — worth its own commit.
- **`ifcopenshell/util/placement.py` `get_axis2placement`** — numpy-2.x `x.resize(3)` fix
for 2D `RefDirection`. **Duplicate of open PRs #8307 / #8586** — kept locally only so
profile editing works during testing; **do not commit**, drop when #8307 merges.
## Dead ends (ruled out)
- Auto-un-mapping during `type.assign_type`/`regenerate_profile` → sibling cascade. Un-map
is an **explicit** action only.
- Deriving depth from `obj.bound_box` for un-map → unreliable when several instances share a
Blender mesh; use the copied extrusion / native rebuild instead.
- Blaming the compiled wrapper / core (`3e7b739`) for the profile crashes — it was our code.
## Test checklist / what's left before merge
- [ ] **Strip debug prints**: `make_length_per_instance` / `make_length_type_driven`,
`DumbProfileJoiner.recreate_profile` + `get_profile_axis`, and `core/type.py`
`assign_type`.
- [ ] Un-map a mullion → own length, Length UI, geometry unchanged, siblings untouched.
- [ ] `extend_profile('T')` on an un-mapped mullion → origin **stays** (no flip).
- [ ] Toggle checkbox both ways → round-trips; typed snaps to type length.
- [ ] New occurrence of a mapped type → defaults to per-instance.
- [ ] Assign a length-driven type to a **typed** occurrence → adopts type length, no cascade.
- [ ] Assign another type to a **per-instance** occurrence → stays per-instance, keeps length.
- [x] Reassigning across types with **different** profiles — was broken (the occurrence's
usage kept the old type's profile set, so editing it mutated the old type). Fixed in
`core/type.py`; covered by `test_assign_type_material_usage.py`.
- [ ] Edge cases untested: non-identity mapping transforms; non-centroid cardinal point vs
the 180° flip.
- [ ] Commit layout: feature on `1c421d0`; separate `change_data` fix; exclude `placement.py`.
-1
View File
@@ -68,7 +68,6 @@ def unpack_dependencies(install_dir: Path) -> None:
if __name__ == "__main__":
action = None
if len(sys.argv) != 2 or (action := sys.argv[1].lower()) not in ("pack", "unpack"):
print(__doc__)
sys.exit(1)
+5
View File
@@ -82,6 +82,8 @@ ignore = [
all = "error"
# Structural rules (no deep type inference needed, easier to adapt).
# Has false positives due to ty walrus operator bug.
possibly-unresolved-reference = "ignore"
# Maybe later, requires to specify element types for all generics.
missing-type-argument = "ignore"
# Conflicts with `bpy` props defined using annotations.
@@ -109,6 +111,7 @@ no-matching-overload = "ignore"
not-subscriptable = "ignore"
unsupported-dynamic-base = "ignore"
unsupported-operator = "ignore"
type-assertion-failure = "ignore"
[tool.ty.environment]
extra-paths = [
@@ -192,6 +195,7 @@ format.sequence = ["black", "ruff"]
cmake-format = "gersemi . --in-place"
[tool.poe.tasks.ty-ios]
# --ignore unresolved-reference: walrus operator false positives in ty.
cmd = """
ty check
nix/
@@ -209,6 +213,7 @@ cmd = """
src/ifcpatch
src/ifctester
--python=src/ifcopenshell-python/.venv
--ignore unresolved-reference
"""
[tool.poe.tasks.bonsai-deps]
+1 -1
View File
@@ -1,5 +1,5 @@
black==26.3.1
ruff==0.15.12
poethepoet
ty==0.0.61
ty==0.0.59
gersemi==0.26.1
-6
View File
@@ -188,8 +188,6 @@ class BcfClient:
response.raise_for_status()
return response.status_code, response.text
except requests.exceptions.HTTPError as errh:
response = errh.response
assert response is not None
print(f"message: {response.reason}' '{response.status_code}, {errh}")
return response.status_code, response.reason
@@ -208,8 +206,6 @@ class BcfClient:
response.raise_for_status()
return response.status_code, response.text
except requests.exceptions.HTTPError as errh:
response = errh.response
assert response is not None
print(f"message: {response.reason}' '{response.status_code}, {errh}")
return response.status_code, response.reason
@@ -226,8 +222,6 @@ class BcfClient:
response.raise_for_status()
return response.status_code, response.text
except requests.exceptions.HTTPError as errh:
response = errh.response
assert response is not None
print(f"message: {response.reason}' '{response.status_code}, {errh}")
return response.status_code, response.reason
+4 -22
View File
@@ -24,28 +24,10 @@ a text, a tspan { fill: blue !important; text-decoration: underline;}
a:hover { cursor: pointer; }
.cut { fill: black; stroke: black; stroke-linecap: 'round'; stroke-width: 0.35; fill-rule: evenodd; }
.projection { fill: white; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
/* SVG edge classification (issue #3668): see edge-classification.md. These select directly on
the <path> element (each classified projection edge carries its own class), so they win over
the inherited .projection rule above regardless of specificity. */
path.outline { stroke: black; stroke-width: 0.35; stroke-opacity: 1; }
path.boundary { stroke: black; stroke-width: 0.3; stroke-opacity: 0.9; }
path.crease { stroke: black; stroke-width: 0.25; stroke-opacity: 0.85; }
path.sharp { stroke: black; stroke-width: 0.18; stroke-opacity: 0.7; }
path.flush { stroke: black; stroke-width: 0.1; stroke-opacity: 0.4; }
/* Debug CSS for troubleshooting edge classification */
/*
path.outline { stroke: black; stroke-width: 0.35; stroke-opacity: 1; }
path.boundary { stroke: orange; stroke-width: 0.3; stroke-opacity: 0.9; }
path.crease { stroke: green; stroke-width: 0.25; stroke-opacity: 0.85; }
path.sharp { stroke: red; stroke-width: 0.18; stroke-opacity: 0.7; }
path.flush { stroke: blue; stroke-width: 0.1; stroke-opacity: 0.4; }
*/
.surface {fill: white; stroke-width: 0.1;}
.annotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.3; }
.IfcAnnotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.3; }
/* .IfcGeographicElement { fill: none; stroke: rgb(150, 150, 150); stroke-linecap: 'round'; stroke-dasharray: 1, 2;} */
.surface { stroke: none; fill: #fff; fill-rule: evenodd; }
.annotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
.IfcAnnotation { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 0.25; }
.IfcGeographicElement { fill: none; stroke: black; stroke-linecap: 'round'; stroke-width: 1; }
.PredefinedType-LINEWORK { stroke: black; stroke-width: 0.25; }
.PredefinedType-LINEWORK.dashed { stroke-dasharray: 3, 2; }
.PredefinedType-LINEWORK.fine { stroke-width: 0.18; stroke: #777777; }
@@ -5,7 +5,7 @@ FILE_NAME('EPset_Drawing.ifc','2020-01-01T00:00:00',$,$,'EPset_Drawing','EPset_D
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2,#31,#32,#33,#34,#35,#36));
#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2));
#2=IFCSIMPLEPROPERTYTEMPLATE('23JavTMk98ZxXhrUEnjAcf',$,'TargetView','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#3=IFCSIMPLEPROPERTYTEMPLATE('1yVWUt5H9DAOuu0OaMMLpe',$,'Scale','The scale of this drawing represented as a numerator and denominator, such as 1/100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
#4=IFCSIMPLEPROPERTYTEMPLATE('3gsuPBtU93b8f0gg1pjkq6',$,'HumanScale','The scale of this drawing in human readable format, such as 1:100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
@@ -35,11 +35,5 @@ DATA;
#28=IFCSIMPLEPROPERTYTEMPLATE('1YSnFzurrEyRNtoLdmmddP',$,'BringToFront','The objects with these SVG classes will render in front of all other objects.Ex: IfcBeam, IfcColumn',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
#29=IFCSIMPLEPROPERTYTEMPLATE('0lP6Y8q9v2QhDnR4sT7uVx',$,'PerspectiveShiftX','Horizontal perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
#30=IFCSIMPLEPROPERTYTEMPLATE('2mR8b1NcW5EoFyG7hJ9kLp',$,'PerspectiveShiftY','Vertical perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
#31=IFCSIMPLEPROPERTYTEMPLATE('1cFVJnqT13m8ItkMHaI1tp',$,'UseEdgeClassification','Enable the boundary/outline/sharp/crease/flush SVG edge classification scheme (issue #3668). When false, drawings use the original unclassified linework.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#32=IFCSIMPLEPROPERTYTEMPLATE('2kB$mxBgnBUvhjh0Ti0c4P',$,'RenderCreases','Whether to render ''crease'' (concave) edges. Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#33=IFCSIMPLEPROPERTYTEMPLATE('3MSIJNW$T8r9Hl12kk0BY$',$,'ValleyAngleMinDegrees','Minimum concave dihedral deviation from flat, in degrees, for a projection edge to be classified as ''crease'' rather than ''flush''.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
#34=IFCSIMPLEPROPERTYTEMPLATE('2epSGfC4bFM9gb1X7zBIp4',$,'RenderSharp','Whether to render ''sharp'' (convex) edges. Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
#35=IFCSIMPLEPROPERTYTEMPLATE('3TZwsEjkr5WRDKcgrYzSIA',$,'RidgeAngleMinDegrees','Minimum convex dihedral deviation from flat, in degrees, for a projection edge to be classified as ''sharp'' rather than ''flush''.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.);
#36=IFCSIMPLEPROPERTYTEMPLATE('2Jua$lO754vgZOkBoHM2gA',$,'RenderFlush','Whether to render ''flush'' edges (dihedral deviation below both ridge/valley thresholds). Only relevant when UseEdgeClassification is enabled.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
ENDSEC;
END-ISO-10303-21;
-2
View File
@@ -1103,14 +1103,12 @@ class IfcImporter:
vertices = [[v[i], v[i + 1], v[i + 2], 1] for i in range(0, len(v), 3)]
edges = [[e[i], e[i + 1]] for i in range(0, len(e), 2)]
v2 = None
polyline = None
for edge in edges:
v1 = vertices[edge[0]]
if v1 != v2:
polyline = curve.splines.new("POLY")
polyline.points[-1].co = mathutils.Vector(v1)
v2 = vertices[edge[1]]
assert polyline is not None
polyline.points.add(1)
polyline.points[-1].co = mathutils.Vector(v2)
edges_item_ids = ifcopenshell.util.shape.get_edges_representation_item_ids(geometry).tolist()
@@ -843,6 +843,7 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
settings = ifcopenshell.geom.settings()
shape = ifcopenshell.geom.create_shape(settings, opening)
mat = Matrix(ifcopenshell.util.shape.get_shape_matrix(shape))
mat.translation = (0, 0, 0)
opening_bm = bmesh.new()
verts = ifcopenshell.util.shape.get_vertices(shape.geometry)
for vert in verts:
@@ -1059,7 +1060,6 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
return tool.Ifc.get().createIfcConnectionSurfaceGeometry(surface)
def export_surface(self, polygon, target_face_matrix):
ifc_file = tool.Ifc.get()
x_axis = target_face_matrix.col[0][:3]
z_axis = target_face_matrix.col[2][:3]
p1 = target_face_matrix.translation
@@ -1072,20 +1072,18 @@ class AddBoundary(bpy.types.Operator, tool.Ifc.Operator):
placement = builder.create_axis2_placement_3d([o / self.unit_scale for o in p1], z_axis, x_axis)
surface.BasisSurface = tool.Ifc.get().create_entity("IfcPlane", placement)
schema = ifc_file.schema
if schema != "IFC2X3":
if tool.Ifc.get().schema != "IFC2X3":
points = [tool.Model.convert_si_to_unit(list(co)) for co in polygon.exterior.coords]
point_list = tool.Ifc.get().createIfcCartesianPointList2D(points)
outer_boundary = tool.Ifc.get().createIfcIndexedPolyCurve(point_list, None, False)
inner_boundaries: list[ifcopenshell.entity_instance] = []
inner_boundaries = []
for interior in polygon.interiors:
points = [tool.Model.convert_si_to_unit(list(co)) for co in interior.coords]
point_list = tool.Ifc.get().createIfcCartesianPointList2D(points)
inner_boundaries.append(tool.Ifc.get().createIfcIndexedPolyCurve(point_list, None, False))
else:
# TODO:
raise NotImplementedError(schema)
pass # TODO
surface.OuterBoundary = outer_boundary
surface.InnerBoundaries = inner_boundaries
@@ -400,7 +400,6 @@ class CadOffset(bpy.types.Operator):
[verts.update(e.verts) for e in edges]
# Use the viewport angle to determine the offset direction
wp = None
for area in bpy.context.screen.areas:
if area.type == "VIEW_3D":
# Don't ask me, I don't know.
@@ -410,7 +409,6 @@ class CadOffset(bpy.types.Operator):
z = area.spaces.active.region_3d.view_rotation @ Vector((0, 0, 1))
wp = Matrix([x, y, z, Vector((0, 0, 0))]).to_4x4().transposed()
break
assert wp is not None
rotation = Matrix.Rotation(pi / 2, 2, "Z")
rotation_i = Matrix.Rotation(-pi / 2, 2, "Z")
@@ -478,7 +478,6 @@ class ChangeClassificationLevel(bpy.types.Operator):
def execute(self, context):
props = tool.Classification.get_classification_props()
props.available_library_references.clear()
reference = None
for reference in IfcStore.classification_file.by_id(self.parent_id).HasReferences:
new = props.available_library_references.add()
new.identification = reference.Identification or ""
@@ -486,7 +485,6 @@ class ChangeClassificationLevel(bpy.types.Operator):
new.ifc_definition_id = reference.id()
new.has_references = bool(reference.HasReferences)
new.referenced_source
assert reference
if reference.ReferencedSource.is_a("IfcClassificationReference"):
props.active_library_referenced_source = reference.ReferencedSource.ReferencedSource.id()
else:
@@ -156,8 +156,6 @@ class CostSchedulesData:
values = root_element.CostValues
elif root_element.is_a("IfcConstructionResource"):
values = root_element.BaseCosts
else:
assert False, root_element
for cost_value in values or []:
cls._load_cost_value(root_element, data, cost_value)
# data["CostValues"].append(cost_value.id())
@@ -425,12 +425,10 @@ class BaseDecorator:
blf.size(font_id, font_size_px)
w, h = None, None
if box_alignment or center or vcenter:
w, h = blf.dimensions(font_id, text)
if box_alignment:
assert w is not None and h is not None
box_alignment_offset = Vector((0, 0))
if "bottom" in box_alignment:
pass
@@ -452,12 +450,10 @@ class BaseDecorator:
else:
# horizontal centering
if center:
assert w is not None
pos -= Vector((cos, sin)) * w * 0.5
# vertical centering
if vcenter:
assert h is not None
pos -= Vector((-sin, cos)) * h * 0.5
# side-shifting
@@ -1005,8 +1001,6 @@ class FallDecorator(BaseDecorator):
O = A.copy()
O.z = B.z
run = (B - O).length
angle_tg = None
if run != 0:
angle_tg = rise / run
angle = round(degrees(atan(angle_tg)))
@@ -1024,7 +1018,6 @@ class FallDecorator(BaseDecorator):
elif object_type == "SLOPE_PERCENT":
if angle == 90:
return "-"
assert angle_tg is not None
return f"{round(angle_tg * 100)} %"
return "NO DATA"
@@ -1256,7 +1249,6 @@ class SectionLevelDecorator(BaseDecorator):
}
# process edges
text_position, text_dir = None, None
for edge in edges_original:
v0, v1 = winspace_verts[edge[0]], winspace_verts[edge[1]]
start_i = len(output_verts)
@@ -1562,39 +1554,32 @@ class SectionDecorator(BaseDecorator):
v0, v1 = winspace_verts[edge[0]], winspace_verts[edge[1]]
start_i = len(output_verts)
circle_head = None
if display_start_circle or display_end_circle:
circle_head = get_circle_head(circle_size)
triangle_head, divider_offset, edge_dir_circle = None, None, None
display_symbol = display_start_symbol or display_end_symbol
if display_symbol or connect_markers:
if display_start_symbol or display_end_symbol or connect_markers:
edge_dir = (v1 - v0).normalized()
side = (edge_dir.yx * Vector((1, -1))).to_3d()
edge_dir_circle = edge_dir * circle_size
if display_symbol:
triangle_head = get_triangle_head(edge_dir, -side, triangle_length, triangle_width)
divider_offset = []
divider_offset.append(edge_dir_circle if connect_markers else edge_dir_circle * 3)
divider_offset.append(edge_dir_circle)
if display_start_symbol or display_end_symbol:
triangle_head = get_triangle_head(edge_dir, -side, triangle_length, triangle_width)
divider_offset = []
divider_offset.append(edge_dir_circle if connect_markers else edge_dir_circle * 3)
divider_offset.append(edge_dir_circle)
if display_start_circle:
assert circle_head is not None
start_i = add_verts_sequence([v + v0 for v in circle_head], start_i, **out_kwargs, closed=True)
# circle middle divider
if not display_start_symbol:
assert divider_offset is not None
start_i = add_verts_sequence(
[v0 + divider_offset[0], v0 - divider_offset[1]], start_i, **out_kwargs
)
if display_start_symbol:
assert triangle_head is not None
start_i = add_verts_sequence([v + v0 for v in triangle_head], start_i, **out_kwargs, closed=True)
if display_end_circle:
assert circle_head is not None
start_i = add_verts_sequence([v + v1 for v in circle_head], start_i, **out_kwargs, closed=True)
# circle middle divider
if not display_end_symbol:
@@ -1603,11 +1588,9 @@ class SectionDecorator(BaseDecorator):
)
if display_end_symbol:
assert triangle_head is not None
start_i = add_verts_sequence([v + v1 for v in triangle_head], start_i, **out_kwargs, closed=True)
if connect_markers:
assert edge_dir_circle is not None
gap = []
gap.append(edge_dir_circle if display_start_symbol else Vector((0, 0, 0)))
gap.append(edge_dir_circle if display_end_symbol else Vector((0, 0, 0)))
@@ -1694,12 +1677,6 @@ class CutDecorator:
selected_elements_color = self.addon_prefs.decorator_color_selected
self.fallback_colour = (0.3, 0.3, 0.3, 1)
# Evaluate camera movement once per redraw rather than twice per object: is_camera_moved()
# runs eval()/numpy on the camera matrix and, as a side effect, refreshes the stored
# checksum on the first True result - so calling it per object also made the second call
# (fill) see an already-updated checksum and skip recalculating when it shouldn't.
self.camera_moved = self.is_camera_moved()
all_vertices = []
all_edges = []
selected_vertices = []
@@ -1825,35 +1802,23 @@ class CutDecorator:
# Currently selected objects must be recalculated as they may be being moved / edited.
# If the camera is selected, we also recalculate as the user may be moving the camera.
is_selected = obj.select_get()
recalc_cut = not has_cut_cache or is_selected or self.camera_moved
recalc_fill = not has_fill_cache or is_selected or self.camera_moved
if not (recalc_cut or recalc_fill):
return
# The intersection test builds a bmesh and scans every vertex; both recalculations need
# the same answer, so compute it once here rather than once in each.
is_intersecting = tool.Drawing.is_intersecting_camera(obj, context.scene.camera)
if recalc_cut:
self.recalculate_cut(context, obj, element, is_intersecting)
if recalc_fill:
self.recalculate_fill(context, obj, element, is_intersecting)
if not has_cut_cache or obj.select_get() or self.is_camera_moved():
self.recalculate_cut(context, obj, element)
if not has_fill_cache or obj.select_get() or self.is_camera_moved():
self.recalculate_fill(context, obj, element)
def recalculate_cut(
self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance, is_intersecting: bool
) -> None:
if is_intersecting:
def recalculate_cut(self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
if tool.Drawing.is_intersecting_camera(obj, context.scene.camera):
verts, edges = tool.Drawing.bisect_mesh(obj, context.scene.camera)
DecoratorData.cut_cache[element.id()] = (verts, edges)
else:
DecoratorData.cut_cache[element.id()] = (False, False)
def recalculate_fill(
self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance, is_intersecting: bool
) -> None:
def recalculate_fill(self, context, obj: bpy.types.Object, element: ifcopenshell.entity_instance) -> None:
element_id = element.id()
if not is_intersecting:
if not tool.Drawing.is_intersecting_camera(obj, context.scene.camera):
DecoratorData.fill_cache[element_id] = {}
return
@@ -1906,8 +1871,6 @@ class CutDecorator:
layer_set = material
offset = 0
sense_factor = 1
else:
assert False, material
if len(layer_set.MaterialLayers) == 1:
material = layer_set.MaterialLayers[0].Material
@@ -1934,8 +1897,6 @@ class CutDecorator:
co = Vector((0.0, 0.0, offset))
no = tool.Drawing.get_extrusion_vector(element).normalized()
no = Vector([1.0, 0.0, 0.0])
else:
assert False, usage
no *= sense_factor
last_i = len(layer_set.MaterialLayers) - 1
@@ -225,11 +225,9 @@ def format_distance(
unit_system, unit_length, unit_fraction = unit_mapping[custom_unit]
value *= unit_scale
tx_dist = None
# Imperial Formatting
if unit_system == "IMPERIAL":
toInches = None
if in_unit_length:
if unit_length == "INCHES":
toInches = 1
@@ -243,7 +241,6 @@ def format_distance(
toInches = 1550
inPerFoot = 144
assert toInches is not None
decInches = value * toInches
decFeet = decInches / 12
@@ -386,7 +383,6 @@ def format_distance(
if precision and isinstance(precision, float):
value = precision * round(float(value) / precision)
fmt = None
if decimal_places is not None:
fmt = "%1." + str(decimal_places) + "f"
@@ -469,7 +465,6 @@ def format_distance(
assert f"Unexpected unit_system - '{unit_system}'."
# tx_dist = fmt % value
assert tx_dist is not None
return tx_dist
@@ -698,8 +698,6 @@ class CreateDrawing(bpy.types.Operator):
layer_set = material
offset = 0
sense_factor = 1
else:
assert False, material
camera_matrix_i = context.scene.camera.matrix_world.inverted()
@@ -724,6 +722,7 @@ class CreateDrawing(bpy.types.Operator):
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.000001)
bmesh.ops.triangle_fill(bm, use_dissolve=True, edges=bm.edges)
prev_co = None
if not usage:
sense_factor = 1 # Assume the extrusion vector points in the direction sense
no = tool.Drawing.get_extrusion_vector(element).normalized()
@@ -740,8 +739,6 @@ class CreateDrawing(bpy.types.Operator):
co = Vector((0.0, 0.0, offset))
no = tool.Drawing.get_extrusion_vector(element).normalized()
no = Vector([1.0, 0.0, 0.0])
else:
assert False, usage
no *= sense_factor
last_i = len(layer_set.MaterialLayers) - 1
for i, layer in enumerate(layer_set.MaterialLayers):
@@ -909,10 +906,6 @@ class CreateDrawing(bpy.types.Operator):
if os.path.isfile(svg_path) and self.props.should_use_linework_cache:
return svg_path
ifc = tool.Ifc.get()
semantics = None
pairs = None
# in case of printing multiple drawings we need to sync just once
if self.sync and self.drawing_index == 0:
with profile("sync"):
@@ -1316,18 +1309,6 @@ class CreateDrawing(bpy.types.Operator):
self.svg_settings = ifcopenshell.geom.settings()
self.svg_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)
self.svg_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
# SVG edge classification (issue #3668). See edge-classification.md. Settings are
# per-drawing, stored in EPset_Drawing and read into self.cprops by import_camera_props.
try:
self.svg_settings.set("svg-use-edge-classification", self.cprops.use_edge_classification)
self.svg_settings.set("svg-render-crease-edges", self.cprops.render_creases)
self.svg_settings.set("svg-valley-angle-min-degrees", self.cprops.valley_angle_min_degrees)
self.svg_settings.set("svg-render-sharp-edges", self.cprops.render_sharp)
self.svg_settings.set("svg-ridge-angle-min-degrees", self.cprops.ridge_angle_min_degrees)
self.svg_settings.set("svg-emit-flush-edges", self.cprops.render_flush)
except Exception:
# Backwards compatibility with older ifcopenshell builds that don't expose these keys.
pass
self.svg_buffer = ifcopenshell.geom.serializers.buffer()
self.serialiser_settings = ifcopenshell.geom.serializer_settings()
self.serialiser = ifcopenshell.geom.serializers.svg(
@@ -536,50 +536,6 @@ class BIMCameraProperties(PropertyGroup):
default=True,
update=get_update_layer_callback("has_annotation", "HasAnnotation"),
)
use_edge_classification: BoolProperty(
name="Use Edge Classification",
description="Classify projection edges into boundary/outline/sharp/crease/flush "
"instead of drawing all linework identically. See edge-classification.md",
default=False,
update=get_update_layer_callback("use_edge_classification", "UseEdgeClassification"),
)
render_creases: BoolProperty(
name="Render Creases",
description="Render 'crease' (concave) projection edges",
default=True,
update=get_update_layer_callback("render_creases", "RenderCreases"),
)
valley_angle_min_degrees: FloatProperty(
name="Valley Angle Minimum",
description="Minimum concave dihedral deviation from flat, in degrees, for a projection "
"edge to be classified as 'crease' rather than 'flush'",
default=12.0,
min=0.0,
max=180.0,
update=get_update_layer_callback("valley_angle_min_degrees", "ValleyAngleMinDegrees"),
)
render_sharp: BoolProperty(
name="Render Sharp",
description="Render 'sharp' (convex) projection edges",
default=True,
update=get_update_layer_callback("render_sharp", "RenderSharp"),
)
ridge_angle_min_degrees: FloatProperty(
name="Ridge Angle Minimum",
description="Minimum convex dihedral deviation from flat, in degrees, for a projection "
"edge to be classified as 'sharp' rather than 'flush'",
default=45.0,
min=0.0,
max=180.0,
update=get_update_layer_callback("ridge_angle_min_degrees", "RidgeAngleMinDegrees"),
)
render_flush: BoolProperty(
name="Render Flush",
description="Render 'flush' projection edges (dihedral deviation below both ridge/valley "
"thresholds). Omitted by default",
default=False,
update=get_update_layer_callback("render_flush", "RenderFlush"),
)
target_view: EnumProperty(
name="Target View",
default="PLAN_VIEW",
@@ -110,14 +110,12 @@ class Scheduler:
y = self.margin
rows = list(sheet.iter_rows())
total_rows = len(rows)
x = None
for i, row in enumerate(rows):
# The last row may contain only null values
if i == (total_rows - 1) and not [c for c in row if c.value is not None]:
continue
x = self.margin
unmerged_height = None
for cell in row:
if isinstance(cell, openpyxl.cell.cell.MergedCell):
column_letter = openpyxl.utils.get_column_letter(cell.column)
@@ -232,11 +230,8 @@ class Scheduler:
)
x += unmerged_width
assert unmerged_height is not None
y += unmerged_height
assert x is not None
total_width = x + self.margin
total_height = y + self.margin
self.svg["width"] = "{}mm".format(total_width)
@@ -380,7 +375,6 @@ class Scheduler:
tri = 0
stop_iterating_over_rows = False
# TODO: row spans support?
x = None
for tr in table.getElementsByType(TableRow):
if stop_iterating_over_rows:
break
@@ -497,7 +491,6 @@ class Scheduler:
tri += 1
y += height
assert x is not None
total_width = x + self.margin
total_height = y + self.margin
self.svg["width"] = "{}mm".format(total_width)
@@ -102,16 +102,16 @@ void angle_circle_head(
in vec4 circle_start, in float circle_angle,
in bool counterclockwise,
out vec4 head[CIRCLE_SEGS+1], out float angle_segs) {
// 1 added to CIRCLE_SEGS because we're number of vertices
// for n segments is n+1
float angle_d;
angle_d = PI * 2 / CIRCLE_SEGS; // 30d
// need to bottom clamp it to 1, otherwise it causes Blender crash at extruding the curve
angle_segs = max(1, ceil(circle_angle / angle_d));
angle_d = circle_angle / angle_segs;
for(int i = 0; i < (angle_segs + 1); i++) {
float angle = angle_d * i;
if (counterclockwise) {
@@ -143,7 +143,7 @@ void cross_head(in vec4 dir, in float size, out vec4 head[3]) {
#define do_vertex(pos, e) (do_vertex_util(pos, vec2(-(e).y, (e).x) / winsize.xy))
#define do_vertex_win(pos, e) ( do_vertex( WIN2CLIP( pos ), e ) )
// if vertex is shared by two segments of the line still need to emit it twice
// if vertex is shared by two segments of the line still need to emit it twice
// to avoid smoothing artifacts
// don't forget to initialize `vec2 EDGE_DIR` for macro to work
// `pos0` / `pos1` - vertex position in clip space
@@ -197,13 +197,10 @@ void do_circle_head(vec4 pos_w, vec4 head[CIRCLE_SEGS]) {
def add_verts_sequence(verts, start_i, output_verts, output_edges, closed=False):
"""Add sequence of verts to output lists, returns next vertex index"""
i = None
for i, v in enumerate(verts[:-1], start_i):
output_verts.append(v)
output_edges.append((i, i + 1))
output_verts.append(verts[-1])
assert i is not None
if closed:
output_edges.append((i + 1, start_i))
return i + 2
@@ -276,7 +273,7 @@ class BaseShader:
FRAG_GLSL = """
uniform vec4 color;
uniform float lineWidth;
in float smoothline;
out vec4 fragColor;
void main() {
@@ -1449,7 +1449,6 @@ class SvgWriter:
angle_tg = rise / run
angle = round(degrees(atan(angle_tg)))
else:
angle_tg = None
angle = 90
# ues SLOPE_ANGLE as default
@@ -1463,7 +1462,6 @@ class SvgWriter:
elif object_type == "SLOPE_PERCENT":
if angle == 90:
return "-"
assert angle_tg is not None
return f"{round(angle_tg * 100)} %"
tag = element.Description or get_label_text()
+2 -15
View File
@@ -113,19 +113,6 @@ class BIM_PT_camera(Panel):
row.prop(props, "fill_mode")
row = self.layout.row()
row.prop(props, "cut_mode")
row = self.layout.row()
row.prop(props, "use_edge_classification")
if props.use_edge_classification:
row = self.layout.row()
row.prop(props, "render_creases")
row.prop(props, "valley_angle_min_degrees")
row = self.layout.row()
row.prop(props, "render_sharp")
row.prop(props, "ridge_angle_min_degrees")
row = self.layout.row()
row.prop(props, "render_flush")
row = self.layout.row()
row.prop(props, "width")
row = self.layout.row()
@@ -977,14 +964,14 @@ class BIM_UL_sheets(bpy.types.UIList):
if self.filter_name:
filter_name = self.filter_name.lower()
active_sheet_index = None
active_sheet = None
for sheet in data.sheets:
if sheet.is_sheet:
active_sheet = sheet
active_sheet_index = len(flt_flags)
if filter_name in sheet.name.lower() or filter_name in sheet.identification.lower():
flt_flags.append(self.bitflag_filter_item)
if not sheet.is_sheet:
assert active_sheet_index is not None
flt_flags[active_sheet_index] = self.bitflag_filter_item
else:
flt_flags.append(0)
@@ -75,13 +75,9 @@ class Helper:
for face in bm.faces:
if len(face.verts) > 4:
potential_faces.append(face)
# TODO: replace with next(..., None)
face = None
for face in potential_faces:
if face.normal.z < -0.1:
break
assert face is not None
profile = [l.vert.index for l in face.loops]
extrusion = self.detect_extrusion_edge(bm, face)
@@ -112,12 +108,10 @@ class Helper:
if not potential_faces:
potential_faces = bm.faces
# TODO: replace with next(..., None)
face = None
for face in potential_faces:
if face.normal.z < -0.1:
break
assert face is not None
profile = [l.vert.index for l in face.loops]
extrusion = self.detect_extrusion_edge(bm, face)
@@ -151,12 +145,9 @@ class Helper:
if total_verts > 4:
potential_faces.append(face)
# TODO: replace with next(..., None)
face = None
for face in potential_faces:
if face.normal.z < -0.1:
break
assert face is not None
end_faces = []
end_face_normal = face.normal
@@ -581,11 +581,7 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
if has_openings and not self.apply_openings:
# Meshlike things with openings can only be updated without openings applied.
if self.from_ui:
self.report(
{"ERROR"},
f"Object '{obj.name}' has openings. "
"ALT+click the button to bake the openings into the new representation.",
)
self.report({"ERROR"}, f"Object '{obj.name}' has openings - representation cannot be updated.")
return
if not product.is_a("IfcGridAxis"):
@@ -3527,15 +3523,12 @@ class EditRepresentationItemShapeAspect(bpy.types.Operator, tool.Ifc.Operator):
if props.representation_item_shape_aspect == "NEW":
active_representation = tool.Geometry.get_active_representation(obj)
# find IfcProductRepresentationSelect based on current representation
product_shape = None
if hasattr(element, "Representation"): # IfcProduct
product_shape = element.Representation
else: # IfcTypeProduct
for representation_map in element.RepresentationMaps:
if representation_map.MappedRepresentation == active_representation:
product_shape = representation_map
assert product_shape is not None
previous_shape_aspect_id = props.active_item.shape_aspect_id
# will be None if item didn't had a shape aspect
previous_shape_aspect = tool.Ifc.get_entity_by_id(previous_shape_aspect_id)
@@ -3885,8 +3878,6 @@ class AddSweptAreaSolidItem(bpy.types.Operator, tool.Ifc.Operator):
curve = builder.rectangle(size=Vector((0.5, 0.5)) / unit_scale)
elif self.shape == "CYLINDER":
curve = builder.circle(radius=0.25 / unit_scale)
else:
assert False, self.shape
item = builder.extrude(
curve,
magnitude=0.5 / unit_scale,
@@ -4124,31 +4115,6 @@ class OverrideMoveSelect(bpy.types.Operator):
self.new_active_obj = obj
return {"FINISHED"}
# Get arrays
ifc_file = tool.Ifc.get()
array_parents_to_move: list[bpy.types.Object] = []
for obj in list(context.selected_objects):
element = tool.Ifc.get_entity(obj)
if not element:
continue
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
if not pset:
continue
parent_element = ifc_file.by_guid(pset["Parent"])
parent_obj = tool.Ifc.get_object(parent_element)
if parent_obj not in array_parents_to_move:
array_parents_to_move.append(parent_obj)
if element.GlobalId != pset["Parent"]:
obj.select_set(False)
if array_parents_to_move:
for parent_obj in array_parents_to_move:
parent_element = tool.Ifc.get_entity(parent_obj)
for array_obj in tool.Array.get_all_objects(parent_element):
array_obj.select_set(True)
self.new_active_obj = parent_obj
return {"FINISHED"}
# Get nests
props = tool.Nest.get_nest_props()
not_editing_objs = [o.obj for o in props.not_editing_objects]
@@ -156,7 +156,6 @@ class RadianceRender(bpy.types.Operator):
print(f"Quality: {quality}, Detail: {detail}, Variability: {variability}")
print(f"Output directory: {output_dir}")
hdr_image_path, hdr_mask_path, sky_map_cal_path = None, None
if use_hdr:
hdr_image = "noon_grass_2k.hdr"
hdr_mask = "noon_grass_2k_mask.hdr"
@@ -255,9 +254,6 @@ class RadianceRender(bpy.types.Operator):
# 4 0 0 -1 180
if use_hdr and choose_hdr_image == "Noon":
assert hdr_image_path is not None
assert hdr_mask_path is not None
assert sky_map_cal_path is not None
with open(sky_file_path, "w") as f:
f.write(sky_description_str)
@@ -151,6 +151,8 @@ classes = (
profile.EditExtrusionAxis,
profile.EnableEditingExtrusionAxis,
profile.ExtendProfile,
profile.MakeProfileLengthPerInstance,
profile.MakeProfileLengthTypeDriven,
profile.RecalculateProfile,
profile.Rotate90,
profile.PatchNonParametricMepSegment,
@@ -564,7 +564,6 @@ class SelectAllArrayObjects(bpy.types.Operator):
except RuntimeError:
self.report({"ERROR"}, f"Objects that don't have an array parent, were deselected.")
object.select_set(False)
continue
array_objects = tool.Array.get_all_objects(parent_element)
tool.Blender.set_objects_selection(
@@ -408,8 +408,6 @@ class MEPGenerator:
compare = tool.Cad.is_x(requested_value, fitting_value, compare_precision)
elif isinstance(fitting_value, list):
compare = tool.Cad.are_vectors_equal(requested_value, Vector(fitting_value), precision)
else:
assert False, f"{key} {second_key}"
return compare
ignore_keys = []
@@ -478,13 +476,11 @@ class MEPGenerator:
if predefined_type == "OBSTRUCTION":
return packed_data
start_port = None
for port in ports:
port_local_position = V(*port.ObjectPlacement.RelativePlacement.Location.Coordinates)
if tool.Cad.is_x(port_local_position.length, 0.0):
start_port = port
break
assert start_port is not None
connected_port = tool.System.get_connected_port(start_port)
connected_element = tool.System.get_port_relating_element(connected_port)
@@ -325,7 +325,7 @@ class AddOccurrence(bpy.types.Operator, tool.Ifc.Operator):
if self.from_invoke and str(self.relating_type_id) in AuthoringData.data["relating_type_id"]:
props.relating_type_id = str(self.relating_type_id)
building_obj, building_element = None, None
building_obj = None
if len(context.selected_objects) == 1 and context.active_object:
building_obj = context.active_object
building_element = tool.Ifc.get_entity(building_obj)
+220 -7
View File
@@ -21,8 +21,10 @@ from math import atan2, degrees, pi, radians
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
import bpy
import numpy as np
import ifcopenshell
import ifcopenshell.api.geometry
import ifcopenshell.api.material
import ifcopenshell.api.pset
import ifcopenshell.api.type
import ifcopenshell.util.element
@@ -142,6 +144,17 @@ class DumbProfileGenerator:
material = ifcopenshell.util.element.get_material(element)
material.CardinalPoint = self.cardinal_point
# assign_type maps the type's shared geometry onto the occurrence when the type has a
# RepresentationMap (e.g. Revit-exported profiles). We build a per-instance extrusion below,
# so drop those inherited mapped representations first -- new occurrences then default to
# per-instance (editable) length. The IfcMaterialProfileSetUsage assign_type created is kept.
if element.Representation:
for representation in list(element.Representation.Representations):
ifcopenshell.api.geometry.unassign_representation(
tool.Ifc.get(), product=element, representation=representation
)
ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=representation)
obj.matrix_world = matrix_world
bpy.context.view_layer.update()
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
@@ -443,6 +456,21 @@ class DumbProfileJoiner:
self.recreate_profile(element2, profile2, axis2, axis2)
def recreate_profile(self, element: ifcopenshell.entity_instance, obj: bpy.types.Object, axis=None, body=None):
_body_rep = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
_mapped = bool(_body_rep and any(i.is_a("IfcMappedItem") for i in (_body_rep.Items or [])))
# A mapped/shared body means this occurrence is typed-length (its geometry is driven by the
# type's RepresentationMap). Regenerating a per-instance profile here would remove the shared
# mapped body, which cascades to every sibling instance of the type (leaving them empty) and
# surfaces as a generic "Failed to set value". Leave typed geometry alone; converting to
# per-instance is an explicit action (bim.make_profile_length_per_instance).
if _mapped:
# The mapped body may have just changed (e.g. assign_type mapped a new type's geometry),
# but the Blender mesh is stale. Reload it so the display matches the typed geometry
# instead of skipping silently.
bonsai.core.geometry.switch_representation(
tool.Ifc, tool.Geometry, obj=obj, representation=_body_rep, apply_openings=True
)
return
if axis is None or body is None:
axis = body = self.get_profile_axis(obj)
self.axis = copy.deepcopy(axis)
@@ -593,8 +621,6 @@ class DumbProfileJoiner:
axisl = (profile2.matrix_world.inverted() @ axis1[1]) - (profile2.matrix_world.inverted() @ axis1[0])
elif connection1 == "ATSTART":
axisl = (profile2.matrix_world.inverted() @ axis1[0]) - (profile2.matrix_world.inverted() @ axis1[1])
else:
assert False, connection1
xy_angle = degrees(Vector((1, 0)).angle_signed(axisl.normalized().to_2d()))
if xy_angle >= -135 and xy_angle <= -45:
closest_plane = "bottom"
@@ -619,8 +645,6 @@ class DumbProfileJoiner:
axisl = (profile1.matrix_world.inverted() @ axis2[1]) - (profile1.matrix_world.inverted() @ axis2[0])
elif connection2 == "ATSTART":
axisl = (profile1.matrix_world.inverted() @ axis2[0]) - (profile1.matrix_world.inverted() @ axis2[1])
else:
assert False, connection2
xy_angle2 = degrees(Vector((1, 0)).angle_signed(axisl.normalized().to_2d()))
if xy_angle2 >= -135 and xy_angle2 <= -45:
closest_plane2 = "bottom"
@@ -848,8 +872,6 @@ class DumbProfileJoiner:
else:
y_axis = obj.matrix_world.to_quaternion() @ Vector((0, 1, 0))
z_axis = obj.matrix_world.to_quaternion() @ Vector((-1, 0, 0))
else:
assert False, plane
return self.create_matrix(p, x_axis, y_axis, z_axis)
def create_matrix(self, p: Vector, x: Vector, y: Vector, z: Vector) -> Matrix:
@@ -857,10 +879,11 @@ class DumbProfileJoiner:
def get_profile_axis(self, obj: bpy.types.Object) -> list[Vector]:
z_values = [v[2] for v in obj.bound_box]
return [
axis = [
(obj.matrix_world @ Vector((0.0, 0.0, min(z_values)))),
(obj.matrix_world @ Vector((0.0, 0.0, max(z_values)))),
]
return axis
class RecalculateProfile(bpy.types.Operator, tool.Ifc.Operator):
@@ -877,6 +900,196 @@ class RecalculateProfile(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class MakeProfileLengthPerInstance(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.make_profile_length_per_instance"
bl_label = "Make Profile Length Per Instance"
bl_description = (
"Un-map typed/shared profile geometry so each selected occurrence gets its own body "
"(the extrusion length lives on the instance instead of being typed/shared via a "
"RepresentationMap). Geometry is preserved exactly and sibling instances are left untouched"
)
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return bool(context.selected_objects)
def _execute(self, context):
ifc_file = tool.Ifc.get()
unmapped = 0
for obj in context.selected_objects:
element = tool.Ifc.get_entity(obj)
if not element:
continue
if self.unmap_element_body(ifc_file, element, obj):
unmapped += 1
self.report({"INFO"}, f"Length made per-instance for {unmapped} object(s)")
return {"FINISHED"}
def unmap_element_body(
self, ifc_file: ifcopenshell.file, element: ifcopenshell.entity_instance, obj: bpy.types.Object
) -> bool:
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not body:
return False
changed = False
# (1) Un-map the body if it is a shared/mapped representation.
mapped_items = [i for i in (body.Items or []) if i.is_a("IfcMappedItem")]
if mapped_items:
# Only identity mapping transforms are handled; refuse rather than move geometry.
for mi in mapped_items:
if not np.allclose(ifcopenshell.util.placement.get_mappeditem_transformation(mi), np.eye(4)):
return False
# Give this object its own mesh so sibling instances (sharing the mapped mesh) are not disturbed.
if obj.data is not None and obj.data.users > 1:
obj.data = obj.data.copy()
# Per-instance copy of the mapped geometry items, keeping the shared IfcProfileDef.
new_items = []
for mi in mapped_items:
for src_item in (mi.MappingSource.MappedRepresentation.Items or []):
new_items.append(ifcopenshell.util.element.copy_deep(ifc_file, src_item, exclude=["IfcProfileDef"]))
# New per-instance SweptSolid body replacing the mapped wrapper; retarget the product shape to it.
# The old mapped IfcShapeRepresentation is left as a harmless orphan (purgeable later) rather than
# deleted -- deleting it would either cascade to siblings (via the shared RepresentationMap) or
# leave Bonsai's Blender-side links dangling.
new_body = ifcopenshell.util.element.copy(ifc_file, body)
new_body.Items = new_items
new_body.RepresentationType = "SweptSolid"
for inverse in ifc_file.get_inverse(body):
ifcopenshell.util.element.replace_attribute(inverse, body, new_body)
bonsai.core.geometry.switch_representation(
tool.Ifc, tool.Geometry, obj=obj, representation=new_body, apply_openings=True
)
changed = True
# (2) Give the occurrence its OWN IfcMaterialProfileSetUsage if it only inherits one. Bonsai gates the
# per-instance profile-editing UI on get_usage_type(should_inherit=False) == "PROFILE", so an occurrence
# that merely inherits the type's IfcMaterialProfileSet never shows the Length control. This runs even
# when the body was already un-mapped, so re-running repairs earlier partial conversions.
if not ifcopenshell.util.element.get_material(element, should_inherit=False):
profile_set = ifcopenshell.util.element.get_material(element, should_inherit=True)
if profile_set and profile_set.is_a("IfcMaterialProfileSet"):
usage = ifcopenshell.api.material.assign_material(
ifc_file, products=[element], type="IfcMaterialProfileSetUsage", material=profile_set
)
usage = usage[0] if isinstance(usage, (list, tuple)) else usage
if usage is not None and usage.is_a("IfcMaterialProfileSetUsage"):
usage.CardinalPoint = 5 # geometric centroid (Bonsai default)
changed = True
# (3) Normalize a freshly un-mapped body to Bonsai's native profile convention. The copied Revit
# extrusion can leave the object's local +Z pointing AWAY from the extrusion (geometry in local
# -Z, origin at the far end). get_profile_axis/recreate_profile then plant the origin at the
# min-local-Z end, so extend/join relocate ("flip") the origin. Detect that case, flip the
# object 180deg about local X so local +Z runs along the axis (origin unchanged), and rebuild a
# native 0 -> depth extrusion. Only touches the just-un-mapped (per-instance, non-cascading) body.
if mapped_items:
z_vals = [v[2] for v in obj.bound_box]
length = (max(z_vals) - min(z_vals)) if z_vals else 0.0
if length > 1e-6 and abs(max(z_vals)) <= abs(min(z_vals)):
usage = ifcopenshell.util.element.get_material(element, should_inherit=False)
profile_set = usage.ForProfileSet if usage and usage.is_a("IfcMaterialProfileSetUsage") else None
profile = (profile_set.CompositeProfile or profile_set.MaterialProfiles[0].Profile) if profile_set else None
body_context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
if profile and body_context:
obj.matrix_world = obj.matrix_world @ Matrix.Rotation(pi, 4, "X")
bpy.context.view_layer.update()
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj)
native_body = ifcopenshell.api.geometry.add_profile_representation(
ifc_file,
context=body_context,
profile=profile,
depth=length,
cardinal_point=(usage.CardinalPoint or 5),
)
old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
for inverse in ifc_file.get_inverse(old_body):
ifcopenshell.util.element.replace_attribute(inverse, old_body, native_body)
ifcopenshell.api.geometry.remove_representation(ifc_file, old_body)
bonsai.core.geometry.switch_representation(
tool.Ifc, tool.Geometry, obj=obj, representation=native_body, apply_openings=True
)
return changed
class MakeProfileLengthTypeDriven(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.make_profile_length_type_driven"
bl_label = "Make Profile Length Type Driven"
bl_description = (
"Re-map selected profile occurrences onto their type's shared representation so the extrusion "
"length is driven by the type again (typed length). The per-instance length is discarded and "
"the occurrence snaps to the type's length. This is the inverse of 'Make Length Per-Instance'"
)
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return bool(context.selected_objects)
def _execute(self, context):
ifc_file = tool.Ifc.get()
remapped = 0
for obj in context.selected_objects:
element = tool.Ifc.get_entity(obj)
if not element:
continue
if self.remap_element_body(ifc_file, element, obj):
remapped += 1
self.report({"INFO"}, f"Length made type-driven for {remapped} object(s)")
return {"FINISHED"}
def remap_element_body(
self, ifc_file: ifcopenshell.file, element: ifcopenshell.entity_instance, obj: bpy.types.Object
) -> bool:
element_type = ifcopenshell.util.element.get_type(element)
if not element_type:
return False
# Ensure the type carries a body representation to drive the length. If it has none (e.g. a
# Bonsai-authored profile type, where occurrences carry their own geometry), promote a copy of
# this occurrence's current body onto the type. Siblings toggled later snap to this length.
if not getattr(element_type, "RepresentationMaps", None):
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not body:
return False
template = ifcopenshell.util.element.copy_deep(
ifc_file, body, exclude=["IfcProfileDef", "IfcGeometricRepresentationContext"]
)
origin = ifc_file.create_entity(
"IfcAxis2Placement3D",
Location=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
)
rep_map = ifc_file.create_entity(
"IfcRepresentationMap", MappingOrigin=origin, MappedRepresentation=template
)
element_type.RepresentationMaps = list(element_type.RepresentationMaps or []) + [rep_map]
# Drop the occurrence's own material association so it inherits the type's IfcMaterialProfileSet again.
if ifcopenshell.util.element.get_material(element, should_inherit=False):
ifcopenshell.api.material.unassign_material(ifc_file, products=[element])
# Replace the occurrence's per-instance representation(s) with mapped items pointing at the type's
# RepresentationMaps (this removes the per-instance body and maps the shared/typed geometry).
ifcopenshell.api.type.map_type_representations(
ifc_file, related_object=element, relating_type=element_type
)
# Reload the object's geometry from the (now mapped) body representation.
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if body:
bonsai.core.geometry.switch_representation(
tool.Ifc, tool.Geometry, obj=obj, representation=body, apply_openings=True
)
return True
class DumbProfileRecalculator:
def recalculate(self, profiles):
"`profiles` is a list of blender profile objects"
@@ -508,7 +508,6 @@ class EditSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
converter.run()
profile = tool.Ifc.get().createIfcArbitraryClosedProfileDef("AREA")
curve = None
for path in converter.paths:
points = []
lines = path[0]
@@ -518,7 +517,6 @@ class EditSketchExtrusionProfile(bpy.types.Operator, tool.Ifc.Operator):
points.append(tool.Ifc.get().createIfcCartesianPoint(local_point))
points.append(points[0])
curve = tool.Ifc.get().createIfcPolyline(points)
assert curve
profile.OuterCurve = curve
old_profile = extrusion.SweptArea
@@ -1577,7 +1577,6 @@ class DumbWallJoiner:
# Get the ATEND connection from wall1 to use it in wall2
relating_element = None
connections = element1.ConnectedTo
relating_connection, description = ..., ...
for conn in connections:
if conn.is_a("IfcRelConnectsPathElements") and conn.RelatingConnectionType == "ATEND":
relating_element = conn.RelatedElement
@@ -1592,7 +1591,6 @@ class DumbWallJoiner:
description = conn.Description
bonsai.core.geometry.remove_connection(tool.Geometry, connection=conn)
if relating_element:
assert relating_connection is not ... and description is not ...
ifcopenshell.api.geometry.connect_path(
tool.Ifc.get(),
relating_element=relating_element,
@@ -714,8 +714,6 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
representations = element.RepresentationMaps or []
elif element.is_a("IfcProduct"):
representations = [element.Representation] if element.Representation else []
else:
assert False, element
for representation in representations or []:
for element in self.file.traverse(representation):
if not element.is_a("IfcRepresentationItem") or not element.StyledByItem:
@@ -2031,7 +2029,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
project_props = tool.Project.get_project_props()
prefs = tool.Blender.get_addon_preferences()
project_props.use_relative_project_path = self.use_relative_path
old_history_size, old_undo_steps = None, None
if prefs.should_disable_undo_on_save:
old_history_size = tool.Ifc.get().history_size
old_undo_steps = context.preferences.edit.undo_steps
@@ -2039,7 +2036,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
context.preferences.edit.undo_steps = 0
IfcStore.execute_ifc_operator(self, context)
if prefs.should_disable_undo_on_save:
assert old_history_size is not None and old_undo_steps is not None
tool.Ifc.get().history_size = old_history_size
context.preferences.edit.undo_steps = old_undo_steps
return {"FINISHED"}
@@ -113,8 +113,6 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator):
elif props.active_pset_type == "QTO":
pset = ifcopenshell.api.pset.add_qto(self.file, product=element, name=props.active_pset_name)
props.active_pset_id = pset.id()
else:
assert False
if self.properties:
properties = json.loads(self.properties)
@@ -228,8 +228,6 @@ def get_qto_name(self: "PsetProperties", context: bpy.types.Context) -> tool.Ble
if "bpy.data.objects" in pset_type:
if prop_type == "PsetProperties":
results = get_object_qto_name(self, context)
else:
assert False
elif prop_type == "TaskPsetProperties":
results = get_task_qto_names(self, context)
elif prop_type == "ResourcePsetProperties":
-1
View File
@@ -480,7 +480,6 @@ class BIM_PT_material_psets(Panel):
def draw(self, context):
assert self.layout
props = tool.Material.get_material_props()
ifc_definition_id = None
if material := props.active_material:
ifc_definition_id = material.ifc_definition_id
+8 -26
View File
@@ -27,7 +27,6 @@ import ifcopenshell.api.material
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.util.schema
import ifcopenshell.util.shape_builder
import ifcopenshell.util.type
@@ -130,25 +129,13 @@ class ReassignClass(bpy.types.Operator, tool.Ifc.Operator):
same_ifc_product = element.is_a(ifc_product)
if not same_ifc_product:
# A spatial element (e.g. IfcSite) anchors the containment
# hierarchy, so only allow reassigning it to another family when
# it actually carries geometry - i.e. it's a real modelled thing
# (a bench dropped onto IfcSite -> IfcFurniture) rather than an
# empty spatial container we'd be turning into a loose element.
# IfcSpatialStructureElement covers IFC2X3, which has no
# IfcSpatialElement supertype.
is_spatial = element.is_a("IfcSpatialElement") or element.is_a("IfcSpatialStructureElement")
if is_spatial:
has_geometry = (
next(ifcopenshell.util.representation.get_representations_iter(element), None) is not None
if not (element.is_a("IfcElement") and ifc_product == "IfcElementType") and not (
element.is_a("IfcElementType") and ifc_product == "IfcElement"
):
self.report(
{"ERROR"}, f"Not supported class reassignment for object '{obj.name}' -> {ifc_product}."
)
if not has_geometry:
self.report(
{"ERROR"},
f"Cannot reassign '{obj.name}' ({element.is_a()}) to {ifc_product}: "
"a spatial element can only be reassigned to another class when it has geometry.",
)
return {"CANCELLED"}
return {"CANCELLED"}
props = tool.Blender.get_object_bim_props(obj)
props.is_reassigning_class = False
@@ -630,13 +617,10 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
local_z = wall_matrix.to_3x3() @ Vector((0, 0, 1))
direction_sense = getattr(usage, "DirectionSense", "POSITIVE")
layer_set_direction = usage.LayerSetDirection
if layer_set_direction == "AXIS2":
if usage.LayerSetDirection == "AXIS2":
z_axis = tuple(local_y) if direction_sense == "POSITIVE" else tuple(-local_y)
elif layer_set_direction == "AXIS3":
elif usage.LayerSetDirection == "AXIS3":
z_axis = tuple(local_z) if direction_sense == "POSITIVE" else tuple(-local_z)
else:
assert False, layer_set_direction
item = builder.extrude(
profile,
@@ -766,8 +750,6 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator):
WebThickness=default_web_thickness / unit_scale,
FlangeThickness=default_flange_thickness / unit_scale,
)
else:
assert False, representation_template
rel = ifcopenshell.api.material.assign_material(
tool.Ifc.get(), products=[element], type="IfcMaterialProfileSet"
@@ -1009,7 +1009,6 @@ class ColourByProperty(Operator):
palette = props.palette
is_qualitative = palette in ("tab10", "paired")
colours = None
if is_qualitative:
colours = tool.Search.get_qualitative_palette(palette)
@@ -1036,7 +1035,6 @@ class ColourByProperty(Operator):
if value in colourscheme:
colourscheme[value]["total"] += 1
else:
assert colours is not None
colourscheme[value] = {"colour": next(colours)[0:3], "total": 1}
obj.color = (*colourscheme[value]["colour"], 1)
else:
@@ -1141,7 +1139,6 @@ class SelectByProperty(Operator):
is_qualitative = palette in ("tab10", "paired")
values = None
if not is_qualitative:
values = []
for colour in props.colourscheme:
+2 -4
View File
@@ -281,11 +281,11 @@ class BIM_PT_work_schedules(Panel):
def draw_task_operators(self) -> None:
row = self.layout.row(align=True)
row.alignment = "RIGHT"
task, ifc_definition_id = None, None
ifc_definition_id = None
if self.tprops.tasks and self.props.active_task_index < len(self.tprops.tasks):
task = self.tprops.tasks[self.props.active_task_index]
ifc_definition_id = task.ifc_definition_id
if task and ifc_definition_id:
if ifc_definition_id:
if self.props.active_task_id:
if self.props.editing_task_type == "TASKTIME":
row.operator("bim.edit_task_time", text="", icon="CHECKMARK")
@@ -341,8 +341,6 @@ class BIM_PT_work_schedules(Panel):
row.prop(self.props, "other_columns", text="")
column_type, name = self.props.other_columns.split(".")
data_type = "string"
else:
assert False, column_type
row.operator("bim.set_task_sort_column", text="", icon="SORTALPHA").column = f"{column_type}.{name}"
row.prop(
self.props, "is_sort_reversed", text="", icon="SORT_DESC" if self.props.is_sort_reversed else "SORT_ASC"
@@ -516,7 +516,7 @@ class SetContainerVisibility(bpy.types.Operator):
if self.mode == "ISOLATE":
if tool.Ifc.get_schema() == "IFC2X3":
containers = tool.Ifc.get().by_type("IfcSpatialStructureElement")
else:
elif tool.Ifc.get_schema() != "IFC2X3":
containers = set(tool.Ifc.get().by_type("IfcSpatialElement"))
containers -= set(tool.Ifc.get().by_type("IfcSpatialZone"))
for container in containers:
@@ -125,7 +125,6 @@ class BIM_PT_spatial_decomposition(Panel):
row.label(text="Warning: No Default Container", icon="ERROR")
row.operator("bim.import_spatial_decomposition", icon="FILE_REFRESH", text="")
ifc_definition_id = None
if self.props.active_container:
ifc_definition_id = self.props.active_container.ifc_definition_id
row = self.layout.row(align=True)
@@ -171,7 +170,6 @@ class BIM_PT_spatial_decomposition(Panel):
if not self.props.active_container:
return
assert ifc_definition_id is not None
container_has_elements = bool(self.props.total_elements)
if container_has_elements:
-1
View File
@@ -102,7 +102,6 @@ class BIM_PT_styles(Panel):
# style ui tools
if active_style:
style = active_style
row = self.layout.row(align=True)
if material := style.blender_material:
msprops = tool.Style.get_material_style_props(material)
+39
View File
@@ -18,6 +18,7 @@
import bpy
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.util.type
from ifcopenshell.util.doc import get_entity_doc
@@ -44,9 +45,47 @@ class TypeData:
"total_instances": cls.total_instances(),
"relating_type": cls.relating_type(),
"relating_type_attributes": cls.relating_type_attributes(),
"is_typed_length_profile": cls.is_typed_length_profile(),
"can_make_length_type_driven": cls.can_make_length_type_driven(),
}
)
@classmethod
def can_make_length_type_driven(cls):
"""True if the active occurrence is a per-instance profile (its own body + usage) that has a
type, so its length can be re-driven by the type. If the type has no shared representation
yet, the conversion promotes this occurrence's body onto the type."""
if not (obj := bpy.context.active_object):
return False
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcProduct"):
return False
own_material = ifcopenshell.util.element.get_material(element, should_inherit=False)
if not (own_material and "Profile" in own_material.is_a()):
return False
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if body and any(i.is_a("IfcMappedItem") for i in (body.Items or [])):
return False # already mapped/typed
return bool(ifcopenshell.util.element.get_type(element))
@classmethod
def is_typed_length_profile(cls):
"""True if the active occurrence is a profile-based element whose length is 'typed'/shared
(its body is a mapped/shared representation and/or it only inherits the type's material
profile set), so it could be converted to per-instance editable length."""
if not (obj := bpy.context.active_object):
return False
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcProduct"):
return False
material = ifcopenshell.util.element.get_material(element, should_inherit=True)
if not (material and "Profile" in material.is_a()): # IfcMaterialProfileSet(Usage)
return False
has_own_usage = bool(ifcopenshell.util.element.get_material(element, should_inherit=False))
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
body_is_mapped = bool(body and any(i.is_a("IfcMappedItem") for i in (body.Items or [])))
return (not has_own_usage) or body_is_mapped
@classmethod
def relating_type_classes(cls):
results = []
+38 -152
View File
@@ -20,7 +20,6 @@ from typing import TYPE_CHECKING
import bpy
import ifcopenshell.api.attribute
import ifcopenshell.api.material
import ifcopenshell.api.type
import ifcopenshell.util.element
import ifcopenshell.util.representation
@@ -116,96 +115,51 @@ class UnassignType(bpy.types.Operator, tool.Ifc.Operator):
if TYPE_CHECKING:
related_object: str
@staticmethod
def _reattach_styles(
file: ifcopenshell.file, copied_entities: dict[int, ifcopenshell.entity_instance]
) -> None:
"""copy_deep only follows forward references, so IfcStyledItem (an inverse,
``StyledByItem``) is not carried onto the copied geometry. Re-create a
styled item on each copy that points at the same presentation styles as
the original, so the unmapped occurrence keeps its appearance."""
for original_id, copied in copied_entities.items():
original = file.by_id(original_id)
for styled_item in getattr(original, "StyledByItem", None) or []:
file.create_entity(
"IfcStyledItem",
Item=copied,
Styles=styled_item.Styles,
Name=styled_item.Name,
)
@staticmethod
def unassign_and_unmap(obj: bpy.types.Object) -> None:
"""Unassign the type from ``obj`` and bake a private copy of any mapped
representation onto it, so the occurrence keeps its geometry, styles, and
material once the type (the source of all three) is gone."""
def _execute(self, context):
def exclude_callback(attribute):
return attribute.is_a("IfcProfileDef") and attribute.ProfileName
file = tool.Ifc.get()
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcObject"):
return
# Capture the material inherited from the type before we sever the link,
# but only if the occurrence has no material of its own to override it.
own_material = ifcopenshell.util.element.get_material(element, should_inherit=False)
inherited_material = ifcopenshell.util.element.get_material(element, should_inherit=True)
ifcopenshell.api.type.unassign_type(file, related_objects=[element])
if element.Representation:
new_active_representation = None
active_representation = tool.Geometry.get_active_representation(obj)
active_context = active_representation.ContextOfItems
representations = []
for representation in element.Representation.Representations:
resolved_representation = ifcopenshell.util.representation.resolve_representation(representation)
if representation == resolved_representation:
representations.append(representation)
else:
# We must unmap representations, carrying over their styles.
copied_entities: dict[int, ifcopenshell.entity_instance] = {}
copied_representation = ifcopenshell.util.element.copy_deep(
file,
resolved_representation,
exclude=["IfcGeometricRepresentationContext"],
exclude_callback=exclude_callback,
copied_entities=copied_entities,
)
UnassignType._reattach_styles(file, copied_entities)
representations.append(copied_representation)
if representation.ContextOfItems == active_context:
new_active_representation = copied_representation
element.Representation.Representations = representations
if new_active_representation:
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=new_active_representation,
)
# Bake the inherited material down onto the occurrence now that its type
# link (and, in the delete-type case, the type itself) is gone. Usages are
# occurrence-specific and never inherited, so they need no handling here.
if inherited_material is not None and own_material is None:
material_type = inherited_material.is_a()
if material_type not in ("IfcMaterialLayerSetUsage", "IfcMaterialProfileSetUsage"):
ifcopenshell.api.material.assign_material(
file, products=[element], type=material_type, material=inherited_material
)
def _execute(self, context):
self.file = tool.Ifc.get()
if self.related_object:
related_objects = [bpy.data.objects[self.related_object]]
else:
related_objects = tool.Blender.get_selected_objects()
for obj in related_objects:
self.unassign_and_unmap(obj)
element = tool.Ifc.get_entity(obj)
if not element or not element.is_a("IfcObject"):
continue
ifcopenshell.api.type.unassign_type(self.file, related_objects=[element])
if element.Representation:
new_active_representation = None
active_representation = tool.Geometry.get_active_representation(obj)
active_context = active_representation.ContextOfItems
representations = []
for representation in element.Representation.Representations:
resolved_representation = ifcopenshell.util.representation.resolve_representation(representation)
if representation == resolved_representation:
representations.append(representation)
else:
# We must unmap representations.
copied_representation = ifcopenshell.util.element.copy_deep(
tool.Ifc.get(),
resolved_representation,
exclude=["IfcGeometricRepresentationContext"],
exclude_callback=exclude_callback,
)
representations.append(copied_representation)
if representation.ContextOfItems == active_context:
new_active_representation = copied_representation
element.Representation.Representations = representations
if new_active_representation:
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=new_active_representation,
)
return {"FINISHED"}
@@ -351,82 +305,14 @@ class SelectTypeObjects(bpy.types.Operator):
class RemoveType(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_type"
bl_label = "Delete Type"
bl_description = (
"Delete this type. Its occurrences are kept but become untyped.\n\n"
"SHIFT+Click to also delete every occurrence of this type in the project"
)
bl_label = "Remove Type"
bl_options = {"REGISTER", "UNDO"}
element: bpy.props.IntProperty()
also_delete_instances: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
if TYPE_CHECKING:
element: int
also_delete_instances: bool
@staticmethod
def _detach_type_material_set(element: ifcopenshell.entity_instance) -> None:
"""Cascade-free removal of the type's IfcMaterialLayerSet / IfcMaterialProfileSet
association, called just before the type is deleted.
``remove_product`` would otherwise route the type's material association
through ``unassign_material``, which deletes *every* usage of that set
across the model (documented behaviour, with an upstream TODO calling it
too aggressive) stripping the material off the very occurrences we are
trying to keep. By unhooking the type<->set link by hand here, the type
has no material at delete time, so that cascade never fires and the set
plus the occurrences' usages survive intact."""
file = tool.Ifc.get()
material = ifcopenshell.util.element.get_material(element, should_inherit=False)
if not material or material.is_a() not in ("IfcMaterialLayerSet", "IfcMaterialProfileSet"):
return
for rel in list(getattr(element, "HasAssociations", None) or []):
if not (rel.is_a("IfcRelAssociatesMaterial") and rel.RelatingMaterial == material):
continue
remaining = [o for o in rel.RelatedObjects if o != element]
if remaining:
rel.RelatedObjects = remaining
else:
history = rel.OwnerHistory
file.remove(rel)
if history:
ifcopenshell.util.element.remove_deep2(file, history)
def invoke(self, context, event):
self.also_delete_instances = event.shift
if self.also_delete_instances:
element = tool.Ifc.get().by_id(self.element)
count = len(ifcopenshell.util.element.get_types(element))
return context.window_manager.invoke_confirm(
self,
event,
title="Delete Type and Occurrences",
message=f"This will delete the type and all {count} of its occurrences.",
confirm_text="Delete",
)
return self.execute(context)
def _execute(self, context):
element = tool.Ifc.get().by_id(self.element)
occurrences = ifcopenshell.util.element.get_types(element)
if self.also_delete_instances:
for occurrence in occurrences:
occurrence_obj = tool.Ifc.get_object(occurrence)
if occurrence_obj:
tool.Geometry.delete_ifc_object(occurrence_obj)
else:
# Keep the occurrences: bake their (previously type-mapped) geometry,
# styles, and inherited material onto each one so nothing is lost when
# the type is deleted...
for occurrence in occurrences:
occ_obj = tool.Ifc.get_object(occurrence)
if occ_obj:
UnassignType.unassign_and_unmap(occ_obj)
# ...and keep any layer/profile-set material usages alive across the deletion.
self._detach_type_material_set(element)
obj = tool.Ifc.get_object(element)
if obj:
tool.Geometry.delete_ifc_object(obj)
tool.Geometry.delete_ifc_object(obj)
class RenameType(bpy.types.Operator, tool.Ifc.Operator):
+26
View File
@@ -45,6 +45,22 @@ def get_relating_type(self: "BIMTypeProperties", context: bpy.types.Context) ->
return TypeData.data["relating_types"]
def get_length_per_instance(self: "BIMTypeProperties") -> bool:
# Reflect the current IFC state (per-instance vs typed length) so the checkbox has no stored
# state to keep in sync. TypeData caches the flag; the panel loads it before drawing.
if not TypeData.is_loaded:
TypeData.load()
return bool(TypeData.data.get("can_make_length_type_driven"))
def set_length_per_instance(self: "BIMTypeProperties", value: bool) -> None:
# Ticking the box makes the length per-instance; unticking re-maps it back to the type.
if value:
bpy.ops.bim.make_profile_length_per_instance()
else:
bpy.ops.bim.make_profile_length_type_driven()
def update_relating_type_class(self: "BIMTypeProperties", context: bpy.types.Context) -> None:
TypeData.is_loaded = False
@@ -90,6 +106,15 @@ class BIMTypeProperties(PropertyGroup):
)
is_editing_type_attributes: BoolProperty(name="Is Editing Type Attributes")
type_attributes: CollectionProperty(type=Attribute, name="Type Attributes")
length_per_instance: BoolProperty(
name="Per-instance Length",
description=(
"Ticked: this profile occurrence has its own editable extrusion length. "
"Unticked: the length is typed/shared (driven by the type's representation)"
),
get=get_length_per_instance,
set=set_length_per_instance,
)
if TYPE_CHECKING:
is_editing_type: bool
@@ -98,3 +123,4 @@ class BIMTypeProperties(PropertyGroup):
relating_type_object: Union[bpy.types.Object, None]
is_editing_type_attributes: bool
type_attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
length_per_instance: bool
+6 -3
View File
@@ -107,6 +107,11 @@ class BIM_PT_type(Panel):
row.label(text="No Relating Type")
row.operator("bim.enable_editing_type", icon="GREASEPENCIL", text="")
# Length checkbox: ticked = per-instance editable length, unticked = typed/shared length.
# The checkbox state is read from the IFC (get/set property); ticking runs the conversion.
if TypeData.data.get("is_typed_length_profile") or TypeData.data.get("can_make_length_type_driven"):
layout.row(align=True).prop(props, "length_per_instance", text="Per-instance Length")
class BIM_PT_type_attributes(Panel):
bl_label = "Type Attributes"
@@ -144,10 +149,8 @@ class BIM_PT_type_attributes(Panel):
bonsai.bim.helper.draw_attributes(props.type_attributes, layout)
else:
row = layout.row(align=True)
row = layout.row()
row.operator("bim.enable_editing_type_attributes", icon="GREASEPENCIL", text="Edit")
op = row.operator("bim.remove_type", icon="TRASH", text="")
op.element = TypeData.data["relating_type"]["id"]
for attribute in TypeData.data["relating_type_attributes"]:
row = layout.row(align=True)
@@ -72,7 +72,6 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
opening_objects = [obj for obj in selected_objects if obj != target_object]
obj1 = ...
for opening_obj in opening_objects:
element1 = tool.Ifc.get_entity(target_object)
obj1 = target_object
@@ -197,7 +196,6 @@ class AddOpening(bpy.types.Operator, tool.Ifc.Operator):
bpy.data.objects.remove(obj2)
tool.Model.purge_scene_openings()
assert obj1 is not ...
context.view_layer.objects.active = obj1
return {"FINISHED"}
-4
View File
@@ -284,13 +284,11 @@ class GizmoPreferences(bpy.types.PropertyGroup):
draw_gizmos_in_3d_viewport: bool
_gizmo_pref_entry = None
for _gizmo_pref_entry in tool.Parametric.EDIT_TYPES:
GizmoPreferences.__annotations__[_gizmo_pref_entry.name] = BoolProperty(
name=_gizmo_pref_entry.name.replace("_", " ").title(),
default=True,
)
assert _gizmo_pref_entry is not None
del _gizmo_pref_entry
@@ -396,14 +394,12 @@ class DefaultParameters(bpy.types.PropertyGroup):
and gives the create operator a preset to copy from."""
_default_params_entry = None
for _default_params_entry in tool.Parametric.EDIT_TYPES:
if not _default_params_entry.has_default_parameters:
continue
DefaultParameters.__annotations__[_default_params_entry.name] = bpy.props.PointerProperty(
type=getattr(_model_prop, _default_params_entry.props_attr),
)
assert _default_params_entry is not None
del _default_params_entry
+3 -7
View File
@@ -74,11 +74,10 @@ def add_instance_ceiling_covering_from_cursor(
if not relating_type.is_a("IfcCoveringType"):
relating_type = None
ceiling_height = None
if selected_objects and active_obj:
x, y, z, _, _ = spatial.get_x_y_z_h_mat_from_obj(active_obj)
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_obj(active_obj)
else:
x, y, z, _, _ = spatial.get_x_y_z_h_mat_from_cursor()
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_cursor()
ceiling_height = covering.get_z_from_ceiling_height()
space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y)
@@ -88,7 +87,6 @@ def add_instance_ceiling_covering_from_cursor(
obj = spatial.create_object("Covering")
spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj)
assert ceiling_height is not None
spatial.translate_obj_to_z_location(obj, z + ceiling_height)
spatial.assign_type_to_obj(obj)
spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True)
@@ -102,9 +100,7 @@ def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spa
selected_objects = spatial.get_selected_objects()
if selected_objects and active_obj:
x, y, _, _, _ = spatial.get_x_y_z_h_mat_from_obj(active_obj)
else:
assert False, "Object has to be active and selected."
x, y, z, h, mat = spatial.get_x_y_z_h_mat_from_obj(active_obj)
space_polygon = spatial.get_space_polygon_from_context_visible_objects(x, y)
-1
View File
@@ -497,7 +497,6 @@ def add_annotation(
drawing_tool.show_decorations()
obj = drawing_tool.create_annotation_object(drawing, object_type)
element = ifc.get_entity(obj)
relating_type_rep = None
if not element: # Brand new annotation
relating_type_rep = drawing_tool.get_annotation_representation(relating_type) if relating_type else None
element = drawing_tool.run_root_assign_class(
+34 -1
View File
@@ -33,8 +33,41 @@ def assign_type(
element: ifcopenshell.entity_instance,
type: ifcopenshell.entity_instance,
) -> None:
import ifcopenshell.util.element
import ifcopenshell.util.representation
# A per-instance profile occurrence (its own non-mapped body + own profile usage) should keep its
# own geometry/length when reassigned to another type. Mapping the new type's shared representation
# over it (the default) would convert it to typed/length-driven. Detect this and skip the mapping.
def _is_per_instance_profile(el: ifcopenshell.entity_instance) -> bool:
mat = ifcopenshell.util.element.get_material(el, should_inherit=False)
if not (mat and "Profile" in mat.is_a()):
return False
body = ifcopenshell.util.representation.get_representation(el, "Model", "Body", "MODEL_VIEW")
return bool(body) and not any(i.is_a("IfcMappedItem") for i in (body.Items or []))
should_map = not _is_per_instance_profile(element)
usage_attributes = type_tool.record_material_usage_attributes(element)
ifc.run("type.assign_type", related_objects=[element], relating_type=type)
ifc.run(
"type.assign_type",
related_objects=[element],
relating_type=type,
should_map_representations=should_map,
)
if not should_map:
# should_map_representations=False also suppresses api type.assign_type's
# map_material_usages, which is what re-points an occurrence's usage at the new type's
# material set. Skipping it leaves the occurrence's IfcMaterialProfileSetUsage pointing
# at the OLD type's set, so the material panel then edits the old type's profile and
# every other occurrence of that type moves with it. Re-point it here instead: this
# rewrites SweptArea to the new type's profile but leaves the extrusion depth alone,
# so the per-instance length this branch exists to protect still survives.
type_material = ifcopenshell.util.element.get_material(type)
if type_material and (material_class := type_material.is_a()) in (
"IfcMaterialLayerSet",
"IfcMaterialProfileSet",
):
ifc.run("material.assign_material", products=[element], type=f"{material_class}Usage")
obj = ifc.get_object(element)
if (usage := model.get_usage_type(type)) and usage_attributes:
type_tool.restore_material_usage_attributes(element, usage_attributes)
-2
View File
@@ -981,7 +981,6 @@ class Cad:
has_found_connected_edge = True
loops.append(loop)
new_verts = None
for loop in loops:
all_verts = {v.index for e in loop for v in e.verts}
possible_v1s = []
@@ -1085,7 +1084,6 @@ class Cad:
break
v1 = v2
assert new_verts is not None
return new_verts
-2
View File
@@ -280,8 +280,6 @@ class Cost(bonsai.core.tool.Cost):
new = props.cost_item_processes.add()
elif related_object.is_a("IfcResource"):
new = props.cost_item_resources.add()
else:
assert False, related_object
new.ifc_definition_id = related_object.id()
new.name = related_object.Name or "Unnamed"
+4 -20
View File
@@ -1072,12 +1072,6 @@ class Drawing(bonsai.core.tool.Drawing):
camera_props.has_annotation = True
camera_props.target_view = "PLAN_VIEW"
camera_props.is_nts = False
camera_props.use_edge_classification = False
camera_props.render_creases = True
camera_props.valley_angle_min_degrees = 12.0
camera_props.render_sharp = True
camera_props.ridge_angle_min_degrees = 45.0
camera_props.render_flush = False
camera.shift_x = 0.0
camera.shift_y = 0.0
@@ -1107,18 +1101,6 @@ class Drawing(bonsai.core.tool.Drawing):
camera_props.has_annotation = bool(pset["HasAnnotation"])
if "IsNTS" in pset:
camera_props.is_nts = bool(pset["IsNTS"])
if "UseEdgeClassification" in pset:
camera_props.use_edge_classification = bool(pset["UseEdgeClassification"])
if "RenderCreases" in pset:
camera_props.render_creases = bool(pset["RenderCreases"])
if "ValleyAngleMinDegrees" in pset:
camera_props.valley_angle_min_degrees = float(pset["ValleyAngleMinDegrees"])
if "RenderSharp" in pset:
camera_props.render_sharp = bool(pset["RenderSharp"])
if "RidgeAngleMinDegrees" in pset:
camera_props.ridge_angle_min_degrees = float(pset["RidgeAngleMinDegrees"])
if "RenderFlush" in pset:
camera_props.render_flush = bool(pset["RenderFlush"])
if "DPI" in pset:
camera_props.dpi = int(pset["DPI"])
if "LineworkMode" in pset:
@@ -2593,15 +2575,16 @@ class Drawing(bonsai.core.tool.Drawing):
if not obj:
continue
current_representation = tool.Geometry.get_active_representation(obj)
current_representation_subcontext = None
if current_representation:
subcontext = current_representation.ContextOfItems
current_representation_subcontext = tool.Geometry.get_subcontext_parameters(subcontext)
has_context = False
for subcontext in subcontexts:
# prioritize already active representation if it matches the subcontext
# (element could have multiple representations in the same subcontext)
if current_representation_subcontext and subcontext == current_representation_subcontext:
if current_representation and subcontext == current_representation_subcontext:
has_context = True
break
priority_representation = ifcopenshell.util.representation.get_representation(element, *subcontext)
if priority_representation:
@@ -2611,6 +2594,7 @@ class Drawing(bonsai.core.tool.Drawing):
obj=obj,
representation=priority_representation,
)
has_context = True
break
linked_handles: set[bpy.types.Object] = set()
+2 -2
View File
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING
import bpy
import ifcopenshell.api.feature
import ifcopenshell.util.representation
import bonsai.core.geometry
import bonsai.core.tool
@@ -49,7 +50,6 @@ class Feature(bonsai.core.tool.Feature):
has_visible_openings = True
break
element_had_openings = None
for feature_obj in feature_objs:
feature_element = tool.Ifc.get_entity(feature_obj)
@@ -58,6 +58,7 @@ class Feature(bonsai.core.tool.Feature):
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=featured_obj)
element_had_openings = tool.Geometry.has_openings(featured_element)
body_context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body")
ifcopenshell.api.feature.add_feature(tool.Ifc.get(), feature=feature_element, element=featured_element)
if tool.Ifc.is_moved(feature_obj):
@@ -72,7 +73,6 @@ class Feature(bonsai.core.tool.Feature):
if voided_obj.data:
if tool.Ifc.is_edited(voided_obj):
voided_element_ = tool.Ifc.get_entity(voided_obj)
assert element_had_openings is not None
if element_had_openings or (voided_element_ != featured_element and voided_element_.HasOpenings):
voided_obj.scale = (1.0, 1.0, 1.0)
tool.Ifc.finish_edit(voided_obj)
+4 -7
View File
@@ -757,7 +757,6 @@ class Geometry(bonsai.core.tool.Geometry):
# its centroid not obscured (tested via raycasting) by any other
# face.
distance = max(obj.dimensions.xyz)
min_y, max_z = None, None
if axis == "+Z":
max_z = max([co[2] for co in obj.bound_box]) + 0.002
direction = Vector((0, 0, -1))
@@ -772,10 +771,8 @@ class Geometry(bonsai.core.tool.Geometry):
if direction.dot(face.normal) > 0:
continue
if axis == "+Z":
assert max_z is not None
face_centroid_at_max = Vector((*face.calc_center_median().xy, max_z))
elif axis == "-Y":
assert min_y is not None
centroid = face.calc_center_median()
face_centroid_at_max = Vector((centroid.x, min_y, centroid.z))
face_centroid_at_max = obj.matrix_world @ face_centroid_at_max
@@ -1097,7 +1094,10 @@ class Geometry(bonsai.core.tool.Geometry):
else:
obj = cls.recreate_object_with_data(obj, data, is_global=False)
cls.record_object_materials(obj)
if not cls.has_data_users(old_data):
# old_data is None when the object had no mesh (e.g. an EMPTY being given geometry);
# recreate_object_with_data already handled that transition above, so there is nothing to
# clean up. Guard has_data_users/delete_data, which both assume a non-None datablock.
if old_data is not None and not cls.has_data_users(old_data):
cls.delete_data(old_data)
cls.clear_modifiers(obj)
cls.clear_cache(element)
@@ -1888,7 +1888,6 @@ class Geometry(bonsai.core.tool.Geometry):
"""NOTE: we assume that all items belonged to the same representation and to the same shape aspect"""
ifc_file = tool.Ifc.get()
previous_shape_aspect = None
base_representation = None
for inverse in ifc_file.get_inverse(representation_items[0]):
if inverse.is_a("IfcShapeRepresentation"):
if inverse.OfShapeAspect:
@@ -1898,7 +1897,6 @@ class Geometry(bonsai.core.tool.Geometry):
previous_shape_aspect = inverse.OfShapeAspect[0]
else:
base_representation = inverse
assert base_representation
# remove item from previous shape aspect
if previous_shape_aspect:
@@ -2216,7 +2214,6 @@ class Geometry(bonsai.core.tool.Geometry):
assert item
obj.data.clear_geometry()
cartesian_point_offset = None
if item.is_a("IfcHalfSpaceSolid"):
bm = bmesh.new()
bmesh.ops.create_grid(bm, size=0.5)
+3 -10
View File
@@ -1087,21 +1087,18 @@ class Loader(bonsai.core.tool.Loader):
bm = bmesh.new()
bm.from_mesh(mesh)
prev_co = None
layer_set_direction = usage.LayerSetDirection
if layer_set_direction == "AXIS2":
if usage.LayerSetDirection == "AXIS2":
co = Vector((0.0, offset, 0.0))
no = cls.get_extrusion_vector(element).normalized()
no = no.cross(Vector([1.0, 0.0, 0.0]))
elif layer_set_direction == "AXIS3":
elif usage.LayerSetDirection == "AXIS3":
co = Vector((0.0, 0.0, offset))
no = cls.get_extrusion_vector(element).normalized()
no = Vector([0.0, 0.0, 1.0])
elif layer_set_direction == "AXIS1":
elif usage.LayerSetDirection == "AXIS1":
co = Vector((0.0, 0.0, offset))
no = cls.get_extrusion_vector(element).normalized()
no = Vector([1.0, 0.0, 0.0])
else:
assert False, layer_set_direction
no *= sense_factor
# Cache this
body = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
@@ -1111,7 +1108,6 @@ class Loader(bonsai.core.tool.Loader):
if style := tool.Ifc.get_entity(material):
styles[style] = i
last_i = len(layer_set.MaterialLayers) - 1
bisect_geom = None
for i, layer in enumerate(layer_set.MaterialLayers):
if i != last_i:
prev_co = co.copy()
@@ -1125,7 +1121,6 @@ class Loader(bonsai.core.tool.Loader):
if (material_index := styles.get(style, None)) is None:
material_index = len(mesh.materials)
mesh.materials.append(tool.Ifc.get_object(style))
assert bisect_geom is not None
if i == last_i:
for face in bisect_geom["geom"]:
if isinstance(face, bmesh.types.BMFace):
@@ -1291,7 +1286,6 @@ class Loader(bonsai.core.tool.Loader):
polyline.material_index = material_index
return polyline
item = None
for item_data, item_style in zip(rep_items, item_styles):
item = item_data["item"]
@@ -1319,7 +1313,6 @@ class Loader(bonsai.core.tool.Loader):
polyline.points.add(1)
polyline.points[-1].co = native_data["matrix"] @ Vector(v2)
assert item is not None
curve.bevel_depth = unit_scale * item.Radius
thickness = None
if (inner_radius := item.InnerRadius) and (thickness := max(item.Radius - inner_radius, 0)):
-2
View File
@@ -220,12 +220,10 @@ class Misc(bonsai.core.tool.Misc):
related_objects.append((element, ifcopenshell.util.placement.get_storey_elevation(element)))
related_objects = sorted(related_objects, key=lambda e: e[1])
storey_elevation = None
i = None
for i, related_object in enumerate(related_objects):
if related_object[0] == storey:
storey_elevation = related_object[1]
break
assert i is not None
if i + total_storeys < len(related_objects):
next_storey_elevation = related_objects[i + total_storeys][1]
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
-2
View File
@@ -641,8 +641,6 @@ del _edit_type_names
# call sites can reference ``tool.Parametric.ROOF`` directly. Renaming a
# registry entry renames the constant; a typo at the call site surfaces as
# AttributeError at module load.
_entry = None
for _entry in Parametric.EDIT_TYPES:
setattr(Parametric, _entry.name.upper(), _entry)
assert _entry is not None
del _entry
-2
View File
@@ -168,7 +168,6 @@ class Polyline(bonsai.core.tool.Polyline):
distance = (mouse_vector - last_point).length
if distance < 0:
return
angle, orientation_angle, angle_round_threshold = None, None
if distance > 0:
angle = tool.Cad.angle_3_vectors(
second_to_last_point, last_point, mouse_vector, new_angle=None, degrees=True
@@ -189,7 +188,6 @@ class Polyline(bonsai.core.tool.Polyline):
angle = 0
orientation_angle = 0
if input_ui:
assert angle is not None and orientation_angle is not None and angle_round_threshold is not None
if should_round:
angle_snap = tool.Snap.get_angle_snap_value(context)
angle = angle_snap * round(angle / angle_snap) if distance < angle_round_threshold else angle
+3 -6
View File
@@ -370,21 +370,18 @@ class Project(bonsai.core.tool.Project):
props = cls.get_project_props()
active_library_breadcrumb = props.get_active_library_breadcrumb()
change_back = False
breadcrumb = None
if active_library_breadcrumb:
name = active_library_breadcrumb.name
breadcrumb_type = active_library_breadcrumb.breadcrumb_type
library_id = active_library_breadcrumb.library_id
breadcrumb = (name, breadcrumb_type, library_id)
change_back = True
bpy.ops.bim.rewind_library()
if change_back:
assert breadcrumb
bpy.ops.bim.change_library_element(
element_name=breadcrumb[0],
breadcrumb_type=breadcrumb[1],
library_id=breadcrumb[2],
element_name=name,
breadcrumb_type=breadcrumb_type,
library_id=library_id,
)
@classmethod
-3
View File
@@ -124,9 +124,6 @@ class Pset(bonsai.core.tool.Pset):
return bpy.context.scene.GroupPsetProperties
elif obj_type == "Zone":
return bpy.context.scene.ZonePsetProperties
elif obj_type == "Cost":
# No psets for cost items currently.
assert False, obj_type
assert_never(obj_type)
@classmethod
-4
View File
@@ -505,8 +505,6 @@ class Search(bonsai.core.tool.Search):
(0.773, 0.922, 0.816),
(0.871, 0.957, 0.894),
]
else:
assert False, theme
if value < min_val:
value = min_val
@@ -576,10 +574,8 @@ class ImportFilterQueryTransformer(lark.Transformer):
new = self.filter_groups.add()
global_ids = []
is_first_group = len(self.filter_groups) == 1
new2 = None
for filter_index, arg in enumerate(args):
if arg["type"] == "instance" and global_ids:
assert new2
if "bpy.data.texts" in new2.value:
data_name = new2.value.split("bpy.data.texts")[1][2:-2]
bpy.data.texts[data_name].write("," + arg["value"])
+2 -5
View File
@@ -23,7 +23,7 @@ import re
from collections.abc import Iterable
from datetime import datetime
from datetime import time as datetime_time
from typing import TYPE_CHECKING, Any, Literal, Optional, Union, assert_never
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
import bpy
import ifcopenshell
@@ -1127,8 +1127,7 @@ class Sequence(bonsai.core.tool.Sequence):
@classmethod
def load_default_animation_color_scheme(cls):
GroupType = Literal["CREATION", "OPERATION", "MOVEMENT_TO", "DESTRUCTION", "MOVEMENT_FROM", "USERDEFINED"]
groups: dict[GroupType, dict[str, Any]] = {
groups = {
"CREATION": {
"PredefinedType": ["CONSTRUCTION", "INSTALLATION"],
"Color": (0.0, 1.0, 0.0),
@@ -1168,8 +1167,6 @@ class Sequence(bonsai.core.tool.Sequence):
predefined_type_item2 = props.task_output_colors.add()
predefined_type_item2.name = predefined_type
predefined_type_item2.color = data["Color"]
else:
assert_never(group)
# TO DO: consider cases where users confuses inputs and outputs
predefined_type_item.name = predefined_type
predefined_type_item.color = data["Color"]
-5
View File
@@ -225,7 +225,6 @@ class Snap(bonsai.core.tool.Snap):
# Get axis that are closer than the stick factor threshold
elegible_axis = []
axis = None
for axis in snap_axis:
if not axis:
continue
@@ -327,7 +326,6 @@ class Snap(bonsai.core.tool.Snap):
detected_snaps: list[dict[str, Any]] = []
def select_plane_method():
plane_origin, plane_normal = None, None
if not last_polyline_point:
plane_origin = Vector((0, 0, 0))
plane_normal = Vector((0, 0, 1))
@@ -359,7 +357,6 @@ class Snap(bonsai.core.tool.Snap):
plane_origin = Vector((last_polyline_point.x, last_polyline_point.y, last_polyline_point.z))
plane_normal = Vector((1, 0, 0))
assert plane_origin and plane_normal
plane_normal = tool.Polyline.use_transform_orientations(plane_normal)
return plane_origin, plane_normal
@@ -586,7 +583,6 @@ class Snap(bonsai.core.tool.Snap):
snaps_by_group = filter_snapping_points_by_group(detected_snaps)
edges = [] # Get edges to create edge-intersection snap
axis_start, axis_end = ..., ...
for snapping_point in snaps_by_group:
if snapping_point["group"] in {"Polyline", "Measure", "Wireframe", "Object"}:
if snapping_point["type"] == "Edge":
@@ -611,7 +607,6 @@ class Snap(bonsai.core.tool.Snap):
if point["type"] == "Axis":
if ordered_snaps[0]["type"] not in {"Axis", "Plane"}:
obj = ordered_snaps[0]["object"]
assert axis_start is not ... and axis_end is not ...
mixed_snap = cls.mix_snap_and_axis(ordered_snaps[0], axis_start, axis_end)
for mixed_point in mixed_snap:
snap_point = {
+1 -8
View File
@@ -304,14 +304,12 @@ class Spatial(bonsai.core.tool.Spatial):
while True:
has_parent = None
new_current_results = None
for key in current_results:
if flat_key.startswith(key):
has_parent = True
new_current_results = current_results[key]["children"]
break
if has_parent:
assert new_current_results is not None
current_results = new_current_results
else:
break
@@ -980,24 +978,19 @@ class Spatial(bonsai.core.tool.Spatial):
interiors_list = []
if union_geom.geom_type == "MultiPolygon":
poly = None
for poly in union_geom.geoms:
interiors_list = cls.get_poly_valid_interior_list(
poly=poly, min_area=min_area, interiors_list=interiors_list
)
assert poly
new_poly = Polygon(poly.exterior.coords, holes=interiors_list)
elif union_geom.geom_type == "Polygon":
if union_geom.geom_type == "Polygon":
interiors_list = cls.get_poly_valid_interior_list(
poly=union_geom, min_area=min_area, interiors_list=interiors_list
)
new_poly = Polygon(union_geom.exterior.coords, holes=interiors_list)
else:
assert False, union_geom.geom_type
return new_poly
@classmethod
-5
View File
@@ -360,10 +360,6 @@ class Style(bonsai.core.tool.Style):
material_output = tool.Blender.get_material_node(obj, "OUTPUT_MATERIAL", {"is_active_output": True})
surface_output = get_input_node(material_output, "Surface")
# TODO: this variable is not really needed,
# just workaround a for ty issue detecting unresolved refs.
bsdf = None
if surface_output and surface_output.type == "MIX_SHADER":
mix_shader = surface_output
if (
@@ -392,7 +388,6 @@ class Style(bonsai.core.tool.Style):
and (bsdf := get_input_node(surface_output, input_index=1, of_type="BSDF_PRINCIPLED"))
)
):
assert bsdf
report(f"Because of {BLUE}BSDF_PRINCIPLED{R} node reflectance method identified as {BLUE}PHYSICAL{R}")
attributes["ReflectanceMethod"] = "NOTDEFINED" if tool.Ifc.get_schema() != "IFC4X3" else "PHYSICAL"
@@ -216,7 +216,6 @@ def update_translations_from_po(po_directory: Path, translations_module: Path):
if BPY_IS_LOADED:
import bpy
class SetupTranslationUI(bpy.types.Operator):
bl_idname = "bim.setup_translation_ui"
@@ -82,7 +82,6 @@ class Generator:
}
""".replace("{entity}", location.split("#")[-1]))
# filter parents for the brick entity
parent = None
for row in query:
parent = row.get("parent").toPython()
if "brickschema.org" in parent and parent in references.keys():
@@ -1036,7 +1036,6 @@ class LibraryGenerator:
seat_width_offset = 0.7 * width / 2 if cistern_depth else width / 2
seat_start_width_offset = 0.6 * width
cistern_3d = None
if cistern_height:
cistern = builder.rectangle(size=V(width, cistern_depth), position=shift_to_center)
cistern_3d = ifcopenshell.util.element.copy_deep(self.file, cistern)
@@ -1119,7 +1118,6 @@ class LibraryGenerator:
# cistern
if cistern_height:
assert cistern_3d
cistern_3d = builder.extrude(
cistern_3d, cistern_height + seat_level / 2, position=V(0, 0, seat_level / 2)
)
@@ -143,7 +143,6 @@ class LibraryGenerator:
if "unused" in ifc_params:
del ifc_params["unused"]
profiles_gap = ...
if prof_type == "profile_hollow*_square":
ifc_params["YDim"] = ifc_params["XDim"]
elif ifc_profile_name == "IfcCircleHollowProfileDef":
@@ -161,7 +160,6 @@ class LibraryGenerator:
profile = self.file.create_entity(ifc_profile_name, ProfileName=prof_name, ProfileType="AREA", **ifc_params)
if prof_type == "profile_l*lbeam_2l":
assert profiles_gap is not ...
profile.ProfileName = None # to avoid name confusion
mode = "SLBB" if prof_name.endswith("_SLBB") else "LLBB"
profile = self.create_double_l_profile(profile, prof_name, profiles_gap, mode)
@@ -0,0 +1,236 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai 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 General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Regression test: retyping a per-instance profile occurrence must move its
material usage to the new type.
``core.assign_type`` passes ``should_map_representations=False`` for an
occurrence that owns its body, so the new type's shared representation is not
mapped over it. That flag also gates ``map_material_usages`` inside
``api type.assign_type``, which is the step that re-points the occurrence's
``IfcMaterialProfileSetUsage`` at the new type's material set.
Left unhandled, the occurrence kept a usage whose ``ForProfileSet`` still
referenced the OLD type's set. ``get_material(should_skip_usage=True)`` follows
that pointer, so the material panel showed and edited the old type's
profile: duplicating a profile-based type and changing the copy's profile
silently rewrote the original's, and every other occurrence of the original
moved with it.
The fix must re-point the usage without undoing what the flag exists for, so
the occurrence's own extrusion (and its length) has to survive intact.
These live here rather than in ``test/core``, even though the subject is
``bonsai.core.type``. The Prophecy harness ``test/core`` uses cannot reach this
branch: ``_is_per_instance_profile`` calls ``ifcopenshell.util`` directly on the
element, so a real entity is needed, and Prophecy serialises call arguments to
JSON. Driving a real IFC file in turn imports ``ifcopenshell.api.material`` and
so ``mathutils``, which ``make test-core`` (``pytest -p no:pytest-blender
test/core``) deliberately runs without. Nothing here touches ``bpy`` itself.
"""
from unittest import mock
import pytest
pytestmark = pytest.mark.type
DEPTH = 3.0
class _IfcStub:
"""Stand-in for ``tool.Ifc``. ``core.assign_type`` only reaches for ``run``
and ``get_object``; ``run`` dispatches to the real API so the assertions are
made against genuine IFC state rather than recorded calls."""
def __init__(self, ifc_file):
self._file = ifc_file
def run(self, usecase, **kwargs):
import ifcopenshell.api
return ifcopenshell.api.run(usecase, self._file, **kwargs)
def get_object(self, element):
return None
def _add_profile_type(ifc_file, body_context, name, profile_name):
"""An element type carrying its own IfcMaterialProfileSet."""
import ifcopenshell.api.material
import ifcopenshell.api.root
element_type = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcColumnType", name=name)
material = ifcopenshell.api.material.add_material(ifc_file, name="STEEL")
profile_set = ifcopenshell.api.material.add_material_set(
ifc_file, name=f"{name} set", set_type="IfcMaterialProfileSet"
)
profile = ifc_file.create_entity(
"IfcRectangleProfileDef", ProfileType="AREA", ProfileName=profile_name, XDim=0.1, YDim=0.2
)
ifcopenshell.api.material.add_profile(ifc_file, profile_set=profile_set, material=material, profile=profile)
ifcopenshell.api.material.assign_material(ifc_file, products=[element_type], material=profile_set)
return element_type, profile_set, profile
@pytest.fixture
def model():
"""Two profile types, and one occurrence of the first that owns its body:
its own non-mapped extrusion plus its own usage pointing at type A's set.
That is what makes ``_is_per_instance_profile`` true."""
import ifcopenshell
import ifcopenshell.api.material
import ifcopenshell.api.root
import ifcopenshell.api.type
ifc_file = ifcopenshell.file(schema="IFC4")
ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcProject")
context = ifc_file.create_entity(
"IfcGeometricRepresentationContext",
ContextType="Model",
CoordinateSpaceDimension=3,
WorldCoordinateSystem=ifc_file.create_entity(
"IfcAxis2Placement3D", Location=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
),
)
body_context = ifc_file.create_entity(
"IfcGeometricRepresentationSubContext",
ContextIdentifier="Body",
ContextType="Model",
ParentContext=context,
TargetView="MODEL_VIEW",
)
type_a, profile_set_a, profile_a = _add_profile_type(ifc_file, body_context, "Type A", "RECT_A")
type_b, profile_set_b, profile_b = _add_profile_type(ifc_file, body_context, "Type B", "RECT_B")
occurrence = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcColumn", name="Column")
ifcopenshell.api.type.assign_type(
ifc_file, related_objects=[occurrence], relating_type=type_a, should_map_representations=False
)
ifcopenshell.api.material.assign_material(
ifc_file, products=[occurrence], type="IfcMaterialProfileSetUsage", material=profile_set_a
)
solid = ifc_file.create_entity(
"IfcExtrudedAreaSolid",
SweptArea=profile_a,
Position=ifc_file.create_entity(
"IfcAxis2Placement3D", Location=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
),
ExtrudedDirection=ifc_file.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)),
Depth=DEPTH,
)
representation = ifc_file.create_entity(
"IfcShapeRepresentation",
ContextOfItems=body_context,
RepresentationIdentifier="Body",
RepresentationType="SweptSolid",
Items=[solid],
)
occurrence.Representation = ifc_file.create_entity(
"IfcProductDefinitionShape", Representations=[representation]
)
return {
"file": ifc_file,
"occurrence": occurrence,
"type_a": type_a,
"type_b": type_b,
"profile_set_a": profile_set_a,
"profile_set_b": profile_set_b,
"profile_b": profile_b,
"solid": solid,
}
def _retype(model, new_type):
"""Drive ``core.assign_type`` with the real IFC file behind ``ifc.run``.
``model``/``type_tool`` are mocked: they only drive Blender-side refresh,
which has no bearing on the IFC state under test."""
import bonsai.core.type as subject
model_tool = mock.Mock()
model_tool.get_usage_type.return_value = None
type_tool = mock.Mock()
type_tool.record_material_usage_attributes.return_value = None
type_tool.get_object_data.return_value = None
subject.assign_type(
_IfcStub(model["file"]),
model_tool,
type_tool,
element=model["occurrence"],
type=new_type,
)
def test_usage_follows_the_new_type(model):
"""The occurrence's own usage must point at the new type's profile set.
Before the fix it still referenced type A's set, so the material panel
edited type A's profile while the user believed they were editing type B's."""
import ifcopenshell.util.element
_retype(model, model["type_b"])
usage = ifcopenshell.util.element.get_material(model["occurrence"], should_inherit=False)
assert usage is not None, "occurrence must keep a material usage of its own"
assert usage.is_a("IfcMaterialProfileSetUsage")
assert usage.ForProfileSet == model["profile_set_b"], (
"usage must follow the new type; still pointing at the old type's set means editing "
"the occurrence's profile would mutate the old type"
)
resolved = ifcopenshell.util.element.get_material(model["occurrence"], should_skip_usage=True)
assert resolved == model["profile_set_b"], "the set the material panel resolves to must be the new type's"
def test_per_instance_geometry_survives_the_retype(model):
"""Re-pointing the usage must not undo what should_map_representations=False
exists for: the occurrence keeps its own non-mapped body at its own length,
picking up only the new type's profile."""
import ifcopenshell.util.element
import ifcopenshell.util.representation
_retype(model, model["type_b"])
body = ifcopenshell.util.representation.get_representation(
model["occurrence"], "Model", "Body", "MODEL_VIEW"
)
assert body is not None
assert not any(
item.is_a("IfcMappedItem") for item in body.Items
), "occurrence must keep its own body, not be mapped onto the type's shared representation"
assert model["solid"].Depth == DEPTH, "per-instance extrusion length must be preserved"
assert model["solid"].SweptArea == model["profile_b"], "body must pick up the new type's profile"
def test_old_type_is_left_alone(model):
"""The type the occurrence came from must not be touched by the retype."""
import ifcopenshell.util.element
profile_set_a = model["profile_set_a"]
profile_a = profile_set_a.MaterialProfiles[0].Profile
_retype(model, model["type_b"])
assert ifcopenshell.util.element.get_material(model["type_a"], should_skip_usage=True) == profile_set_a
assert profile_set_a.MaterialProfiles[0].Profile == profile_a, "old type's profile must be untouched"
+12 -2
View File
@@ -23,7 +23,12 @@ from test.core.bootstrap import geometry, ifc, model, type
class TestAssignType:
def test_assigning_and_switching_to_an_existing_type_data(self, ifc, model, type):
type.record_material_usage_attributes("element").should_be_called().will_return(None)
ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called()
ifc.run(
"type.assign_type",
related_objects=["element"],
relating_type="type",
should_map_representations=True,
).should_be_called()
model.get_usage_type("type").should_be_called(2).will_return(None)
ifc.get_object("type").should_be_called().will_return("type_obj")
type.get_object_data("type_obj").should_be_called().will_return("type_obj_data")
@@ -34,7 +39,12 @@ class TestAssignType:
def test_assigning_and_not_changing_data_if_the_type_has_no_data(self, ifc, model, type):
type.record_material_usage_attributes("element").should_be_called().will_return(None)
ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called()
ifc.run(
"type.assign_type",
related_objects=["element"],
relating_type="type",
should_map_representations=True,
).should_be_called()
model.get_usage_type("type").should_be_called(2).will_return(None)
ifc.get_object("type").should_be_called().will_return("type_obj")
type.get_object_data("type_obj").should_be_called().will_return(None)
-45
View File
@@ -112,51 +112,6 @@ class TestImportCameraProps(NewFile):
assert camera.shift_x == 0.0
assert camera.shift_y == 0.0
def test_defaults_edge_classification_props_when_pset_is_absent(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
camera = bpy.data.cameras.new("Camera")
subject.import_camera_props(drawing, camera)
props = subject.get_camera_props(camera)
assert props.use_edge_classification is False
assert props.render_creases is True
assert props.valley_angle_min_degrees == pytest.approx(12.0)
assert props.render_sharp is True
assert props.ridge_angle_min_degrees == pytest.approx(45.0)
assert props.render_flush is False
def test_imports_edge_classification_props_from_drawing_pset(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
drawing = ifc.createIfcAnnotation(ObjectType="DRAWING")
pset = ifcopenshell.api.pset.add_pset(ifc, product=drawing, name="EPset_Drawing")
ifcopenshell.api.pset.edit_pset(
ifc,
pset=pset,
properties={
"UseEdgeClassification": True,
"RenderCreases": False,
"ValleyAngleMinDegrees": 8.0,
"RenderSharp": False,
"RidgeAngleMinDegrees": 30.0,
"RenderFlush": True,
},
)
camera = bpy.data.cameras.new("Camera")
subject.import_camera_props(drawing, camera)
props = subject.get_camera_props(camera)
assert props.use_edge_classification is True
assert props.render_creases is False
assert props.valley_angle_min_degrees == pytest.approx(8.0)
assert props.render_sharp is False
assert props.ridge_angle_min_degrees == pytest.approx(30.0)
assert props.render_flush is True
class TestSyncPerspectiveCameraShifts(NewFile):
def test_round_trips_perspective_camera_shifts_through_drawing_pset(self):
-8
View File
@@ -27,12 +27,10 @@ flatten = itertools.chain.from_iterable
def get_element_data(model, name, element):
if element["geometry_type"] == "Edge":
cell_tags, cell_block = None, None
for i, cell_block in enumerate(model.cells):
if cell_block.type == "line":
cell_tags = model.cell_data["cell_tags"][i]
break
assert cell_tags is not None and cell_block is not None
rows = []
for i_row, i in enumerate(cell_tags):
if i == 0:
@@ -61,7 +59,6 @@ def get_element_data(model, name, element):
elif element["geometry_type"] == "Face":
triangle_cell_tags = None
quad_cell_tags = None
points, cell_block = None, None
for i, cell_block in enumerate(model.cells):
if cell_block.type == "triangle":
triangle_cell_tags = model.cell_data["cell_tags"][i]
@@ -81,10 +78,8 @@ def get_element_data(model, name, element):
if not len(rows):
points = []
else:
assert cell_block is not None
points = list(flatten([cell_block.data[c] for c in rows]))
cell_block = None
for i, cell_block in enumerate(model.cells):
if cell_block.type == "quad":
quad_cell_tags = model.cell_data["cell_tags"][i]
@@ -102,7 +97,6 @@ def get_element_data(model, name, element):
rows.append(i_row)
break
if len(rows):
assert cell_block is not None and points is not None
points.extend(list(flatten([cell_block.data[c] for c in rows])))
points = list(set(points))
@@ -178,8 +172,6 @@ def results_to_ifc(ifc_file, ifc_model, rmed_path, global_case, field_types, dat
model_cases = data["load_cases"]
elif global_case == "COMB":
model_cases = data["load_combinations"]
else:
assert False, global_case
for field in field_types:
if field == "InternalForces":
_parsed_data = internal_forces_to_ifc(ifc_file, ifc_model, result, model_cases, data["elements"])
+2 -19
View File
@@ -283,7 +283,7 @@ class Ifc2CA:
geometry = [x.EdgeStart.VertexGeometry.Coordinates for x in repr_item.Bounds[0].Bound.EdgeList]
else:
assert False, representation
print(representation)
return geometry
def parse_material(self, material: ios.entity_instance):
@@ -399,9 +399,6 @@ class Ifc2CA:
elif element.is_a("IfcStructuralSurfaceMember"):
placement = ifcopenshell.util.placement.get_axis2placement(repr_item.FaceSurface.Position)
else:
assert False, element
origin, orientation = self.parse_transformation_matrix(placement)
data["origin"] = origin
data["orientation"] = orientation
@@ -439,7 +436,7 @@ class Ifc2CA:
for i, v in enumerate(placement[:3]):
v[3] = data["geometry"][i]
elif connection.is_a("IfcStructuralCurveConnection"):
if connection.is_a("IfcStructuralCurveConnection"):
placement = ifcopenshell.util.placement.a2p(
data["geometry"][0],
connection.Axis.DirectionRatios,
@@ -449,9 +446,6 @@ class Ifc2CA:
elif connection.is_a("IfcStructuralSurfaceConnection"):
placement = ifcopenshell.util.placement.get_axis2placement(repr_item.FaceSurface.Position)
else:
assert False, connection
origin, orientation = self.parse_transformation_matrix(placement)
data["origin"] = origin
data["orientation"] = orientation
@@ -558,9 +552,6 @@ class Ifc2CA:
},
}
else:
assert False, element["geometry_type"]
for action in actions:
self.add_action_loads(element, action, data, load_cases)
@@ -595,7 +586,6 @@ class Ifc2CA:
data["actions"].append(action.get_info() | {"AppliedLoad": action.AppliedLoad.get_info()})
if element["geometry_type"] in ["Vertex", "Edge"]:
force_projection_coeff, moment_projection_coeff = None, None
if action.is_a("IfcStructuralPointAction") and load.is_a("IfcStructuralLoadSingleForce"):
FX = tempFX = load.ForceX if load.ForceX is not None else 0.0
FY = tempFY = load.ForceY if load.ForceY is not None else 0.0
@@ -649,12 +639,8 @@ class Ifc2CA:
force_projection_coeff = 1.0
moment_projection_coeff = 1.0
else:
assert False, action
for iLC, load_case in enumerate(load_cases):
if load_case.id() in active_load_case_ids:
assert force_projection_coeff is not None and moment_projection_coeff is not None
load_case_coeff = 1.0 if load_case.Coefficient is None else load_case.Coefficient
data["loadGroups"].append(load_group.Name)
data["loadsLC"]["FX"][iLC] += FX * load_group_coeff * load_case_coeff * force_projection_coeff
@@ -686,9 +672,6 @@ class Ifc2CA:
else:
force_projection_coeff = 1.0
else:
assert False, action
for iLC, load_case in enumerate(load_cases):
if load_case.id() in active_load_case_ids:
load_case_coeff = 1.0 if load_case.Coefficient is None else load_case.Coefficient
-2
View File
@@ -109,8 +109,6 @@ class ifc5D2json:
values = root_element.CostValues
elif root_element.is_a("IfcConstructionResource"):
values = root_element.BaseCosts
else:
assert False, root_element
for cost_value in values or []:
self.extract_cost_value(root_element, data, cost_value)
# data["CostValues"].append(cost_value.id())
-4
View File
@@ -478,8 +478,6 @@ class Ifc5DOdsWriter(Ifc5Dwriter):
cell.addElement(P(text=value))
elif type == "formula":
cell = TableCell(formula=value, stylename=style)
else:
assert False, type
row.addElement(cell)
def add_cost_item_rows(table, cost_data):
@@ -717,8 +715,6 @@ if __name__ == "__main__":
writer = Ifc5DOdsWriter(args["input"], args["output"])
elif args["format"] == "XLSX":
writer = Ifc5DXlsxWriter(args["input"], args["output"])
else:
assert False, args
writer.write()
logger.info("Finished conversion in %ss", time.time() - start)
+10 -10
View File
@@ -675,7 +675,7 @@ int main(int argc, char** argv) {
time_t start, end;
time(&start);
if (output_extension == XML) {
XmlSerializer s(ifc_file, IfcUtil::path::to_utf8(output_temp_filename), &logger);
XmlSerializer s(ifc_file, IfcUtil::path::to_utf8(output_temp_filename), logger);
logger.Status("Writing XML output...");
s.finalize();
} else {
@@ -834,14 +834,14 @@ int main(int argc, char** argv) {
if (output_extension == OBJ) {
// Do not use temp file for MTL as it's such a small file.
const path_t mtl_filename = change_extension(output_filename, MTL);
serializer = boost::make_shared<WaveFrontOBJSerializer>(IfcUtil::path::to_utf8(output_temp_filename), IfcUtil::path::to_utf8(mtl_filename), geometry_settings, serializer_settings, &logger);
serializer = boost::make_shared<WaveFrontOBJSerializer>(IfcUtil::path::to_utf8(output_temp_filename), IfcUtil::path::to_utf8(mtl_filename), geometry_settings, serializer_settings, logger);
#ifdef WITH_OPENCOLLADA
} else if (output_extension == DAE) {
serializer = boost::make_shared<ColladaSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, &logger);
serializer = boost::make_shared<ColladaSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
#endif
#ifdef WITH_GLTF
} else if (output_extension == GLB) {
serializer = boost::make_shared<GltfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, &logger);
serializer = boost::make_shared<GltfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
#endif
#ifdef WITH_USD
} else if (output_extension == USD || output_extension == USDA || output_extension == USDC) {
@@ -858,15 +858,15 @@ int main(int argc, char** argv) {
serializer = boost::make_shared<IgesSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
} else if (output_extension == SVG) {
geometry_settings.get<ifcopenshell::geometry::settings::IteratorOutput>().value = ifcopenshell::geometry::settings::NATIVE;
serializer = boost::make_shared<SvgSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, &logger);
serializer = boost::make_shared<SvgSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
#ifdef WITH_HDF5
} else if (output_extension == HDF) {
geometry_settings.get<ifcopenshell::geometry::settings::IteratorOutput>().value = ifcopenshell::geometry::settings::NATIVE;
serializer = boost::make_shared<HdfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, false, &logger);
#endif
serializer = boost::make_shared<HdfSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, false, logger);
#endif
#endif
} else if (output_extension == TTL) {
serializer = boost::make_shared<TtlWktSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, &logger);
serializer = boost::make_shared<TtlWktSerializer>(IfcUtil::path::to_utf8(output_temp_filename), geometry_settings, serializer_settings, logger);
} else {
cerr_ << "[Error] Unknown output filename extension '" << output_extension << "'\n";
write_log(!quiet);
@@ -1011,7 +1011,7 @@ int main(int argc, char** argv) {
if (!vmap.count("cache-file")) {
cache_file = input_filename + CACHE + HDF;
}
cache.reset(new HdfSerializer(IfcUtil::path::to_utf8(cache_file), geometry_settings, serializer_settings, false, &logger));
cache.reset(new HdfSerializer(IfcUtil::path::to_utf8(cache_file), geometry_settings, serializer_settings, false, logger));
context_iterator->set_cache(cache.get());
}
#endif
@@ -1290,7 +1290,7 @@ bool init_input_file(const std::string& filename, IfcParse::IfcFile*& ifc_file,
#ifdef WITH_IFCXML
if (boost::ends_with(boost::to_lower_copy(filename), ".ifcxml")) {
ifc_file = IfcParse::parse_ifcxml(filename, &logger);
ifc_file = IfcParse::parse_ifcxml(filename, logger);
} else
#endif
{
+4 -10
View File
@@ -25,10 +25,9 @@ import re
from collections import defaultdict
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Union, cast
from typing import TYPE_CHECKING, Any, Literal, Union
import ifcopenshell.util.selector
from typing_extensions import assert_never
try:
from openpyxl import Workbook
@@ -101,9 +100,8 @@ class Parser:
def parse(self, ifc_file: ifcopenshell.file, name=None):
for category_name, category_config in self.config["categories"].items():
for element in category_config["get_category_elements"](ifc_file):
get_element_data = cast(
Union[GetElementDataCallBack, dict[str, Any]], category_config["get_element_data"]
)
get_element_data: Union[GetElementDataCallBack, dict[str, Any]]
get_element_data = category_config["get_element_data"]
if isinstance(get_element_data, dict):
data = {}
@@ -111,18 +109,14 @@ class Parser:
data[key] = ifcopenshell.util.selector.get_element_value(element, query)
elif isinstance(get_element_data, Callable):
data = get_element_data(ifc_file, element) or {}
else:
assert_never(get_element_data)
get_custom_element_data = self.get_custom_element_data.get(category_name, lambda *_: None)
get_custom_element_data = self.get_custom_element_data.get(category_name, lambda x, y: None)
if isinstance(get_custom_element_data, dict):
custom_data = {}
for key, query in get_custom_element_data.items():
custom_data[key] = ifcopenshell.util.selector.get_element_value(element, query)
elif isinstance(get_custom_element_data, Callable):
custom_data = get_custom_element_data(ifc_file, element) or {}
else:
assert_never(get_custom_element_data)
data.update(custom_data)
-2
View File
@@ -271,8 +271,6 @@ def get_contact_data(ifc_file: ifcopenshell.file, element: ifcopenshell.entity_i
pao = the_actor
person = the_actor.ThePerson
organization = the_actor.TheOrganization
else:
assert False, the_actor
email = get_email_from_pao(person, organization)
-1
View File
@@ -20,7 +20,6 @@ dependencies = [
"openpyxl",
"odfpy",
"pandas",
"typing-extensions",
]
[project.urls]
+1 -37
View File
@@ -371,42 +371,6 @@ namespace ifcopenshell {
static constexpr double defaultvalue = -1.;
};
struct SvgRidgeAngleMinDegrees : public SettingBase<SvgRidgeAngleMinDegrees, double> {
static constexpr const char* const name = "svg-ridge-angle-min-degrees";
static constexpr const char* const description = "SVG edge classification (issue #3668): minimum convex dihedral deviation from flat, in degrees, for a projection edge to be classified as 'sharp' rather than 'flush'.";
static constexpr double defaultvalue = 45.;
};
struct SvgValleyAngleMinDegrees : public SettingBase<SvgValleyAngleMinDegrees, double> {
static constexpr const char* const name = "svg-valley-angle-min-degrees";
static constexpr const char* const description = "SVG edge classification (issue #3668): minimum concave dihedral deviation from flat, in degrees, for a projection edge to be classified as 'crease' rather than 'flush'.";
static constexpr double defaultvalue = 12.;
};
struct SvgEmitFlushEdges : public SettingBase<SvgEmitFlushEdges, bool> {
static constexpr const char* const name = "svg-emit-flush-edges";
static constexpr const char* const description = "SVG edge classification (issue #3668): whether to emit 'flush' projection edges (dihedral deviation below both ridge/valley thresholds). Defaults to false, i.e. flush edges are omitted from the output.";
static constexpr bool defaultvalue = false;
};
struct SvgUseEdgeClassification : public SettingBase<SvgUseEdgeClassification, bool> {
static constexpr const char* const name = "svg-use-edge-classification";
static constexpr const char* const description = "SVG edge classification (issue #3668): enable the 5-class boundary/outline/sharp/crease/flush scheme. When false (the default), falls back to the original unclassified linework.";
static constexpr bool defaultvalue = false;
};
struct SvgRenderCreaseEdges : public SettingBase<SvgRenderCreaseEdges, bool> {
static constexpr const char* const name = "svg-render-crease-edges";
static constexpr const char* const description = "SVG edge classification (issue #3668): whether to emit 'crease' (concave) projection edges. Only relevant when svg-use-edge-classification is enabled.";
static constexpr bool defaultvalue = true;
};
struct SvgRenderSharpEdges : public SettingBase<SvgRenderSharpEdges, bool> {
static constexpr const char* const name = "svg-render-sharp-edges";
static constexpr const char* const description = "SVG edge classification (issue #3668): whether to emit 'sharp' (convex) projection edges. Only relevant when svg-use-edge-classification is enabled.";
static constexpr bool defaultvalue = true;
};
struct KeepBoundingBoxes : public SettingBase<KeepBoundingBoxes, bool> {
static constexpr const char* const name = "keep-bounding-boxes";
static constexpr const char* const description =
@@ -689,7 +653,7 @@ namespace ifcopenshell {
};
class Settings : public SettingsContainer<
std::tuple<MesherLinearDeflection, MesherAngularDeflection, ReorientShells, LengthUnit, PlaneUnit, Precision, OutputDimensionality, LayersetFirst, DisableBooleanResult, NoWireIntersectionCheck, NoWireIntersectionTolerance, PrecisionFactor, DebugBooleanOperations, BooleanAttempt2d, SurfaceColour, WeldVertices, UseWorldCoords, UnifyShapes, UseMaterialNames, ConvertBackUnits, ContextIds, ContextTypes, ContextIdentifiers, IteratorOutput, DisableOpeningSubtractions, ApplyDefaultMaterials, DontEmitNormals, GenerateUvs, ApplyLayerSets, UseElementHierarchy, ValidateQuantities, EdgeArrows, BuildingLocalPlacement, SiteLocalPlacement, ForceSpaceTransparency, CircleSegments, CgalSmoothAngleDegrees, SvgRidgeAngleMinDegrees, SvgValleyAngleMinDegrees, SvgEmitFlushEdges, SvgUseEdgeClassification, SvgRenderCreaseEdges, SvgRenderSharpEdges, KeepBoundingBoxes, ComputeCurvature, FunctionStepType, FunctionStepParam, NoParallelMapping, PermissiveShapeReuse, ModelOffset, ModelRotation, TriangulationType, CgalEmitOriginalEdges, OcctNoCleanTriangulation, CacheShapes, DeferProcessingFirstElement, MaxOffset, MaxOffsetDeviation, ApplyOffset, MakeVolume>
std::tuple<MesherLinearDeflection, MesherAngularDeflection, ReorientShells, LengthUnit, PlaneUnit, Precision, OutputDimensionality, LayersetFirst, DisableBooleanResult, NoWireIntersectionCheck, NoWireIntersectionTolerance, PrecisionFactor, DebugBooleanOperations, BooleanAttempt2d, SurfaceColour, WeldVertices, UseWorldCoords, UnifyShapes, UseMaterialNames, ConvertBackUnits, ContextIds, ContextTypes, ContextIdentifiers, IteratorOutput, DisableOpeningSubtractions, ApplyDefaultMaterials, DontEmitNormals, GenerateUvs, ApplyLayerSets, UseElementHierarchy, ValidateQuantities, EdgeArrows, BuildingLocalPlacement, SiteLocalPlacement, ForceSpaceTransparency, CircleSegments, CgalSmoothAngleDegrees, KeepBoundingBoxes, ComputeCurvature, FunctionStepType, FunctionStepParam, NoParallelMapping, PermissiveShapeReuse, ModelOffset, ModelRotation, TriangulationType, CgalEmitOriginalEdges, OcctNoCleanTriangulation, CacheShapes, DeferProcessingFirstElement, MaxOffset, MaxOffsetDeviation, ApplyOffset, MakeVolume>
>
{};
}
@@ -48,7 +48,6 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity*
bst.attempt_2d = settings_.get<settings::BooleanAttempt2d>().get();
bst.debug = settings_.get<settings::DebugBooleanOperations>().get();
bst.precision = settings_.get<settings::Precision>().get();
bst.logger = &logger_;
std::vector< std::pair<double, TopoDS_Shape> > opening_vector;
@@ -119,7 +118,7 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity*
auto it3_shape = std::static_pointer_cast<OpenCascadeShape>(it3->Shape())->shape();
if (it3_shape.IsNull()) {
logger_.Error("GEO", 187, "Null operand");
Logger::Root().Error("GEO", 187, "Null operand");
continue;
}
@@ -144,7 +143,7 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity*
IfcGeom::util::create_solid_from_faces(list, entity_part, settings_.get<settings::Precision>().get(), true);
is_manifold = util::is_manifold(entity_part);
if (is_manifold) {
logger_.Warning("GEO", 188, "Successfully sewed non-manifold first operand");
Logger::Root().Warning("GEO", 188, "Successfully sewed non-manifold first operand");
}
}
@@ -162,17 +161,17 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity*
failure = "Empty result (no faces) for BOPAlgo_MakerVolume; original was " + std::to_string(IfcGeom::util::count(entity_part, TopAbs_FACE));
} else {
is_manifold = util::is_manifold(entity_part_2);
logger_.Warning("GEO", 189, std::string("Sucessfully detected exterior volume to non-manifold first operand; shape is now ") + (is_manifold ? std::string("manifold") : std::string("non-manifold")));
Logger::Root().Warning("GEO", 189, std::string("Sucessfully detected exterior volume to non-manifold first operand; shape is now ") + (is_manifold ? std::string("manifold") : std::string("non-manifold")));
entity_part = entity_part_2;
}
} catch (const Standard_Failure& e) {
failure.emplace(e.GetMessageString());
}
if (failure) {
logger_.Warning("GEO", 190, "MakeVolume failed: " + *failure, entity);
Logger::Root().Warning("GEO", 190, "MakeVolume failed: " + *failure, entity);
}
} else {
logger_.Warning("GEO", 191, "Non-manifold first operand, use --make-volume to try and make manifold");
Logger::Root().Warning("GEO", 191, "Non-manifold first operand, use --make-volume to try and make manifold");
}
}
@@ -215,7 +214,7 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity*
if (util::boolean_operation(bst, result, opening_list, BOPAlgo_CUT, intermediate_result)) {
result = intermediate_result;
} else {
logger_.Message(Logger::LOG_ERROR, "GEO", 192, "Opening subtraction failed for " + boost::lexical_cast<std::string>(std::distance(jt, it)) + " openings", entity);
Logger::Root().Message(Logger::LOG_ERROR, "GEO", 192, "Opening subtraction failed for " + boost::lexical_cast<std::string>(std::distance(jt, it)) + " openings", entity);
}
jt = it;
@@ -236,7 +235,7 @@ bool IfcGeom::OpenCascadeKernel::convert_openings(const IfcUtil::IfcBaseEntity*
// where we keep the first operand as is (a compound of faces probably,
// unless --orient-shells was activated in which case we're already lost).
if (!is_manifold) {
logger_.Warning("GEO", 193, "Retrying boolean operation on individual faces");
Logger::Root().Warning("GEO", 193, "Retrying boolean operation on individual faces");
}
continue;
}
@@ -118,14 +118,14 @@ bool OpenCascadeKernel::convert_impl(const taxonomy::boolean_result::ptr br, Con
const double first_operand_volume = util::shape_volume(a);
if (first_operand_volume <= ALMOST_ZERO) {
logger_.Message(Logger::LOG_WARNING, "GEO", 119, "Empty solid for:", c->instance);
Logger::Root().Message(Logger::LOG_WARNING, "GEO", 119, "Empty solid for:", c->instance);
}
} else {
for (auto& r : cr) {
auto S = std::static_pointer_cast<OpenCascadeShape>(r.Shape())->shape();
if (S.IsNull()) {
logger_.Error("GEO", 120, "Null operand");
Logger::Root().Error("GEO", 120, "Null operand");
continue;
}
gp_GTrsf trsf;
@@ -140,7 +140,7 @@ bool OpenCascadeKernel::convert_impl(const taxonomy::boolean_result::ptr br, Con
// #2665 we also set a precision-independent threshold, because in the boolean op routine
// the working fuzziness might still be increased.
if (d < tol * 20. || d < 0.00002) {
logger_.Message(Logger::LOG_WARNING, "GEO", 121, "Halfspace subtraction yields unchanged volume:", c->instance);
Logger::Root().Message(Logger::LOG_WARNING, "GEO", 121, "Halfspace subtraction yields unchanged volume:", c->instance);
continue;
} else {
S = result;
@@ -159,7 +159,6 @@ bool OpenCascadeKernel::convert_impl(const taxonomy::boolean_result::ptr br, Con
bst.attempt_2d = settings_.get<settings::BooleanAttempt2d>().get();
bst.debug = settings_.get<settings::DebugBooleanOperations>().get();
bst.precision = settings_.get<settings::Precision>().get();
bst.logger = &logger_;
TopoDS_Shape r;
@@ -405,7 +405,7 @@ bool IfcGeom::util::is_extrusion(const gp_Vec & v, const TopoDS_Shape & s, TopoD
return true;
}
int IfcGeom::util::eliminate_narrow_operands(double prec, const NCollection_List<TopoDS_Shape>& bs, NCollection_List<TopoDS_Shape> & c, Logger& logger) {
int IfcGeom::util::eliminate_narrow_operands(double prec, const NCollection_List<TopoDS_Shape>& bs, NCollection_List<TopoDS_Shape> & c) {
int N = 0;
NCollection_List<TopoDS_Shape>::Iterator it(bs);
for (; it.More(); it.Next()) {
@@ -418,7 +418,7 @@ int IfcGeom::util::eliminate_narrow_operands(double prec, const NCollection_List
bool is_narrow = min_dimension < prec;
logger.Notice("GEO", 122, "Min OBB dimension of operand = " + std::to_string(min_dimension));
Logger::Root().Notice("GEO", 122, "Min OBB dimension of operand = " + std::to_string(min_dimension));
if (!is_narrow) {
c.Append(it.Value());
@@ -573,7 +573,7 @@ int IfcGeom::util::eliminate_touching_operands(double prec, const TopoDS_Shape &
return N;
}
bool IfcGeom::util::boolean_subtraction_2d_using_builder(const TopoDS_Shape & a_input, const NCollection_List<TopoDS_Shape> & b_input, TopoDS_Shape & result, double eps, Logger& logger) {
bool IfcGeom::util::boolean_subtraction_2d_using_builder(const TopoDS_Shape & a_input, const NCollection_List<TopoDS_Shape> & b_input, TopoDS_Shape & result, double eps) {
IfcGeom::impl::tree<int> edge_tree;
NCollection_List<TopoDS_Shape> ab_input = b_input;
@@ -703,7 +703,7 @@ bool IfcGeom::util::boolean_subtraction_2d_using_builder(const TopoDS_Shape & a_
if (u11 < U1 && U1 < u12 && u21 < U2 && U2 < u22) {
// Edge curves belonging to different operands intersect, don't process
// using builder.
logger.Notice("GEO", 123, "Intersecting boundaries");
Logger::Root().Notice("GEO", 123, "Intersecting boundaries");
return false;
}
}
@@ -750,7 +750,7 @@ bool IfcGeom::util::boolean_subtraction_2d_using_builder(const TopoDS_Shape & a_
// any effect and marked as redundant. Feeding it to the builder algo
// will likely cause problems.
redundant[std::distance(wires.begin(), it)] = true;
logger.Notice("GEO", 124, "Subtraction operand outside of outer bound");
Logger::Root().Notice("GEO", 124, "Subtraction operand outside of outer bound");
}
}
@@ -790,7 +790,7 @@ bool IfcGeom::util::boolean_subtraction_2d_using_builder(const TopoDS_Shape & a_
if (wire_clss[wire_index]->Perform(p2d) == TopAbs_IN) {
// A wire is contained within another operand
redundant[other_index] = true;
logger.Notice("GEO", 125, "Subtraction operand contained in other");
Logger::Root().Notice("GEO", 125, "Subtraction operand contained in other");
}
}
}
@@ -848,7 +848,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
std::stringstream ss;
ss << "bool-" << std::this_thread::get_id() << "-" << (operation_counter_++);
debug_identifier = ss.str();
settings.log().Notice("GEO", 126, "Boolean debug identifier: " + debug_identifier);
Logger::Root().Notice("GEO", 126, "Boolean debug identifier: " + debug_identifier);
}
if (fuzziness < 0.) {
@@ -884,7 +884,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
a = unify(a_input, fuzziness * 1000.);
settings.log().Message(
Logger::Root().Message(
Logger::LOG_DEBUG, "GEO", 127,
"Simplified operand A from "s +
std::to_string(count(a_input, TopAbs_FACE)) +
@@ -896,7 +896,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
NCollection_List<TopoDS_Shape>::Iterator it(b_input);
for (; it.More(); it.Next()) {
b.Append(unify(it.Value(), fuzziness));
settings.log().Message(
Logger::Root().Message(
Logger::LOG_DEBUG, "GEO", 128,
"Simplified operand B from "s +
std::to_string(count(it.Value(), TopAbs_FACE)) +
@@ -924,7 +924,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
auto N = bounding_box_overlap(fuzziness, a, b, b_tmp);
if (N) {
settings.log().Notice("GEO", 129, "Eliminated " + std::to_string(N) + " disjoint operands");
Logger::Root().Notice("GEO", 129, "Eliminated " + std::to_string(N) + " disjoint operands");
std::swap(b, b_tmp);
}
}
@@ -935,7 +935,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
b_tmp.Clear();
auto N = eliminate_touching_operands(fuzziness, a, b, b_tmp);
if (N) {
settings.log().Notice("GEO", 130, "Eliminated " + std::to_string(N) + " touching operands");
Logger::Root().Notice("GEO", 130, "Eliminated " + std::to_string(N) + " touching operands");
std::swap(b, b_tmp);
}
}
@@ -944,9 +944,9 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
PERF("boolean subtraction: eliminate narrow");
b_tmp.Clear();
auto N = eliminate_narrow_operands(fuzziness, b, b_tmp, settings.log());
auto N = eliminate_narrow_operands(fuzziness, b, b_tmp);
if (N) {
settings.log().Notice("GEO", 131, "Eliminated " + std::to_string(N) + " narrow operands");
Logger::Root().Notice("GEO", 131, "Eliminated " + std::to_string(N) + " narrow operands");
std::swap(b, b_tmp);
}
}
@@ -960,21 +960,21 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
}
if (b.Extent() == 0) {
settings.log().Warning("GEO", 132, "No other operands remaining, using first operand");
Logger::Root().Warning("GEO", 132, "No other operands remaining, using first operand");
result = a;
return true;
}
if (!is_2d && Logger::LOG_NOTICE >= settings.log().Verbosity()) {
if (!is_2d && Logger::LOG_NOTICE >= Logger::Root().Verbosity()) {
PERF("preliminary manifoldness check");
if (!a.IsNull()) {
settings.log().Notice("GEO", 133, "Operand A is " + (is_manifold(a) ? ""s : "non-"s) + "manifold");
Logger::Root().Notice("GEO", 133, "Operand A is " + (is_manifold(a) ? ""s : "non-"s) + "manifold");
}
NCollection_List<TopoDS_Shape>::Iterator it(b);
for (int i = 0; it.More(); it.Next(), ++i) {
settings.log().Notice("GEO", 134, "Operand B " + std::to_string(i) + " is " + (is_manifold(it.Value()) ? ""s : "non-"s) + "manifold");
Logger::Root().Notice("GEO", 134, "Operand B " + std::to_string(i) + " is " + (is_manifold(it.Value()) ? ""s : "non-"s) + "manifold");
}
}
@@ -1014,7 +1014,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
const double fuzz = (std::min)(min_length_orig / 3., fuzziness);
settings.log().Notice("GEO", 135, "Used fuzziness: " + std::to_string(fuzz));
Logger::Root().Notice("GEO", 135, "Used fuzziness: " + std::to_string(fuzz));
const double new_fuzziness = fuzziness * 10.;
const bool allow_retry = new_fuzziness - 1e-15 <= settings.precision * 10000. && new_fuzziness < min_length_orig;
@@ -1048,7 +1048,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
}
if (is_extrusion_a) {
settings.log().Notice("GEO", 136, "Operand A 1/1 is an extrusion");
Logger::Root().Notice("GEO", 136, "Operand A 1/1 is an extrusion");
NCollection_List<TopoDS_Shape>::Iterator it(b);
for (int nb = 1; it.More(); it.Next(), ++nb) {
@@ -1064,10 +1064,10 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
}
if (is_extrusion_b) {
settings.log().Notice("GEO", 137, "Operand B " + std::to_string(nb) + "/" + std::to_string(b.Extent()) + " is an extrusion");
Logger::Root().Notice("GEO", 137, "Operand B " + std::to_string(nb) + "/" + std::to_string(b.Extent()) + " is an extrusion");
if (b_interval.first < a_interval.first + (fuzz * 100.) && b_interval.second > a_interval.second - (fuzz * 100.)) {
settings.log().Notice("GEO", 138, "Operand B creates a through hole");
Logger::Root().Notice("GEO", 138, "Operand B creates a through hole");
// Align b with a operand
gp_Trsf trsf;
@@ -1091,7 +1091,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
PERF("boolean operation: 2d builder");
// First try using face builder
boolean_op_2d_success = boolean_subtraction_2d_using_builder(a_face, b_faces, face_result, fuzziness, settings.log());
boolean_op_2d_success = boolean_subtraction_2d_using_builder(a_face, b_faces, face_result, fuzziness);
}
if (!boolean_op_2d_success) {
@@ -1107,23 +1107,23 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
BRepPrimAPI_MakePrism mp(face_result, gp_Vec(gp::DY()) * (a_interval.second - a_interval.first));
if (mp.IsDone()) {
if (b_remainder_3d.Extent()) {
settings.log().Notice("GEO", 139, std::to_string(b_remainder_3d.Extent()) + " operands remaining to process in 3D");
Logger::Root().Notice("GEO", 139, std::to_string(b_remainder_3d.Extent()) + " operands remaining to process in 3D");
b = b_remainder_3d;
s1s.Clear();
s1s.Append(mp.Shape());
} else {
settings.log().Notice("GEO", 140, "Processed fully in 2D");
Logger::Root().Notice("GEO", 140, "Processed fully in 2D");
result = mp.Shape();
return true;
}
} else {
settings.log().Notice("GEO", 141, "Failed to extrude 2D boolean result. Retrying in 3D.");
Logger::Root().Notice("GEO", 141, "Failed to extrude 2D boolean result. Retrying in 3D.");
}
} else {
settings.log().Notice("GEO", 142, "Failed to perform 2D boolean operation. Retrying in 3D.");
Logger::Root().Notice("GEO", 142, "Failed to perform 2D boolean operation. Retrying in 3D.");
}
} else {
settings.log().Notice("GEO", 143, "No second operands can be processed as 2D inner bounds. Retrying in 3D.");
Logger::Root().Notice("GEO", 143, "No second operands can be processed as 2D inner bounds. Retrying in 3D.");
}
}
}
@@ -1145,7 +1145,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
}
if (builder->IsDone()) {
if (false && builder->DSFiller()->HasWarning(STANDARD_TYPE(BOPAlgo_AlertAcquiredSelfIntersection))) {
settings.log().Notice("GEO", 144, "Builder reports self-intersection in output");
Logger::Root().Notice("GEO", 144, "Builder reports self-intersection in output");
success = false;
/*
@@ -1159,7 +1159,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
}
*/
} else if(builder->DSFiller()->HasWarning(STANDARD_TYPE(BOPAlgo_AlertBadPositioning)) && !TopoDS_Iterator(*builder).More()) {
settings.log().Notice("GEO", 145, "Builder reports bad positioning and result is empty");
Logger::Root().Notice("GEO", 145, "Builder reports bad positioning and result is empty");
success = false;
} else {
TopoDS_Shape r = *builder;
@@ -1173,7 +1173,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
fix.Perform();
r = fix.Shape();
} catch (...) {
settings.log().Error("GEO", 146, "Shape healing failed on boolean result");
Logger::Root().Error("GEO", 146, "Shape healing failed on boolean result");
}
}
@@ -1184,7 +1184,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
success = ana.IsValid() != 0;
if (!success) {
settings.log().Notice("GEO", 147, "Boolean operation yields invalid result");
Logger::Root().Notice("GEO", 147, "Boolean operation yields invalid result");
std::stringstream str;
bool any_emitted = false;
@@ -1214,7 +1214,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
dump(r);
settings.log().Notice("GEO", 148, str.str());
Logger::Root().Notice("GEO", 148, str.str());
}
}
@@ -1334,7 +1334,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
if (op == BOPAlgo_CUT && has_open_shells && all_faces_included_in_result && result_n_faces > first_op_n_faces) {
success = false;
settings.log().Notice("GEO", 149, "Boolean result discarded because subtractions results in only the addition of faces");
Logger::Root().Notice("GEO", 149, "Boolean result discarded because subtractions results in only the addition of faces");
} else {
// when there are edges or vertex-edge distances close to the used fuzziness, the
// output is not trusted and the operation is attempted with a higher fuzziness.
@@ -1380,7 +1380,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
static const char* const reason_strings[] = { "edge length", "vertex-edge", "face-face" };
std::stringstream str;
str << "Boolean operation result failing " << reason_strings[reason] << " interference check, with fuzziness " << fuzziness << " with length " << v;
settings.log().Notice("GEO", 150, str.str());
Logger::Root().Notice("GEO", 150, str.str());
}
}
@@ -1389,7 +1389,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
}
} else {
settings.log().Notice("GEO", 151, "Boolean operation yields non-manifold result");
Logger::Root().Notice("GEO", 151, "Boolean operation yields non-manifold result");
}
}
}
@@ -1399,7 +1399,7 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
#if OCC_VERSION_HEX >= 0x70200
if (builder->HasError(STANDARD_TYPE(BOPAlgo_AlertBOPNotAllowed))) {
settings.log().Error("GEO", 152, "Invalid operands. Using first operand");
Logger::Root().Error("GEO", 152, "Invalid operands. Using first operand");
result = a;
success = true;
}
@@ -1412,14 +1412,14 @@ bool IfcGeom::util::boolean_operation(const boolean_settings& settings, const To
#endif
std::string str_str = str.str();
if (str_str.size()) {
settings.log().Notice("GEO", 153, str_str);
Logger::Root().Notice("GEO", 153, str_str);
}
}
if (!success) {
if (allow_retry) {
return boolean_operation(settings, a, b, op, result, new_fuzziness);
} else {
settings.log().Notice("GEO", 154, "No longer attempting boolean operation with higher fuzziness");
Logger::Root().Notice("GEO", 154, "No longer attempting boolean operation with higher fuzziness");
}
}
return success && !result.IsNull();
@@ -35,7 +35,6 @@
#include <BOPAlgo_Operation.hxx>
#include "../../../ifcparse/IfcLogger.h"
#include "../ifc_geomlibrary_api.h"
namespace IfcGeom {
@@ -89,19 +88,13 @@ namespace IfcGeom {
int eliminate_touching_operands(double prec, const TopoDS_Shape& a, const NCollection_List<TopoDS_Shape>& bs, NCollection_List<TopoDS_Shape>& c);
int eliminate_narrow_operands(double prec, const NCollection_List<TopoDS_Shape>& bs, NCollection_List<TopoDS_Shape> & c, Logger& logger = Logger::Root());
int eliminate_narrow_operands(double prec, const NCollection_List<TopoDS_Shape>& bs, NCollection_List<TopoDS_Shape> & c);
bool boolean_subtraction_2d_using_builder(const TopoDS_Shape& a_input, const NCollection_List<TopoDS_Shape>& b_input, TopoDS_Shape& result, double eps, Logger& logger = Logger::Root());
bool boolean_subtraction_2d_using_builder(const TopoDS_Shape& a_input, const NCollection_List<TopoDS_Shape>& b_input, TopoDS_Shape& result, double eps);
struct boolean_settings {
bool debug, attempt_2d;
double precision;
// Set by callers that carry a per-conversion Logger (e.g. kernels deriving
// from AbstractKernel). Falls back to the global Logger::Root() singleton,
// which IfcConvert never wires to its --log-file output, so messages logged
// through that fallback are effectively silently dropped.
Logger* logger = nullptr;
Logger& log() const { return logger ? *logger : Logger::Root(); }
};
bool boolean_operation(const boolean_settings& settings, const TopoDS_Shape&, const NCollection_List<TopoDS_Shape>&, BOPAlgo_Operation, TopoDS_Shape&, double fuzziness = -1.);
@@ -128,12 +128,7 @@ bool OpenCascadeKernel::convert(const taxonomy::sweep_along_curve::ptr scs, Topo
}
}
// Build the wire from curve, which is the directrix offset toward the origin
// when applied_temporary_offset is set. Using scs->curve here left the wire
// far from the origin yet still translated the result back by +mean, which
// misplaced sweeps far from the origin (#4848). When no offset is applied
// curve aliases scs->curve, so near-origin geometry is unaffected.
auto w = convert_curve(curve);
auto w = convert_curve(scs->curve);
if (w.which() != 2) {
Logger::Root().Error("UNS", 9, "Unsupported directrix");
return false;
+1 -1
View File
@@ -55,7 +55,7 @@ PLATFORMTAG:=win_amd64
endif
BINARY_VERSION:=0.8.6
BUILD_COMMIT:=e333c1c
BUILD_COMMIT:=3e7b739
IOS_URL:=https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-$(PYNUMBER)-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip
IFCCONVERT_URL:=https://s3.amazonaws.com/ifcopenshell-builds/IfcConvert-v$(BINARY_VERSION)-$(BUILD_COMMIT)-$(PLATFORM).zip
@@ -97,19 +97,7 @@ from .file import file as _file
from .sql import sqlite, sqlite_entity
get_log = ifcopenshell_wrapper.get_log
logger = ifcopenshell_wrapper.logger if hasattr(ifcopenshell_wrapper, "logger") else None
if hasattr(ifcopenshell_wrapper, "logger_or_root"):
logger_or_root = ifcopenshell_wrapper.logger_or_root
else:
def logger_or_root(_logger: ifcopenshell_wrapper.logger | None) -> None:
return None
# TODO: drop this function and all callsites after we migrate to the new build.
def optional_logger_args(logger: ifcopenshell_wrapper.logger | None) -> tuple[logger] | tuple[()]:
return (logger,) if logger is not None else ()
logger = getattr(ifcopenshell_wrapper, "logger", None)
# explicitly specify available imported symbols
# (it's a requirement for a typed library)
@@ -206,9 +194,10 @@ def open(
raise FileNotFoundError(f"Path does not exist: '{path}'.")
if format is None:
format = guess_format(path)
logger = logger_or_root(logger)
if logger is None and (logger_type := getattr(ifcopenshell_wrapper, "logger", None)):
logger = logger_type.Root()
if format == ".ifcXML":
f = ifcopenshell_wrapper.parse_ifcxml(str(path.absolute()), *optional_logger_args(logger))
f = ifcopenshell_wrapper.parse_ifcxml(str(path.absolute()), *((logger,) if logger is not None else ()))
if f:
return file(f)
raise OSError(f"Failed to parse .ifcXML file from {path}")
@@ -225,9 +214,11 @@ def open(
if should_stream:
return stream(path)
if readonly: # Temporary conditional see #7131. Remove once newer builds don't segfault on Linux.
f = ifcopenshell_wrapper.open(str(path.absolute()), readonly, *optional_logger_args(logger))
f = ifcopenshell_wrapper.open(str(path.absolute()), readonly, *((logger,) if logger is not None else ()))
elif bypass_types:
f = ifcopenshell_wrapper.file(ifcopenshell_wrapper.uninitialized_tag(), *optional_logger_args(logger))
f = ifcopenshell_wrapper.file(
ifcopenshell_wrapper.uninitialized_tag(), *((logger,) if logger is not None else ())
)
for ty in bypass_types:
f.bypass_type(ty)
if mmap:
@@ -242,7 +233,7 @@ def open(
kwargs["logger"] = logger
f = ifcopenshell_wrapper.open(str(path.absolute()), **kwargs)
else:
f = ifcopenshell_wrapper.open(str(path.absolute()), False, *optional_logger_args(logger))
f = ifcopenshell_wrapper.open(str(path.absolute()), False, *((logger,) if logger is not None else ()))
return file(f)
@@ -314,13 +305,12 @@ def schema_by_name(
you are testing non-ISO IFC releases.
:return: Schema definition object.
"""
import ifcopenshell.util.schema
assert schema_version or schema, "Either schema or schema_version must be specified."
if schema_version:
schema = ifcopenshell.util.schema.get_schema_name_from_version(schema_version)
prefixes = ("IFC", "X", "_ADD", "_TC")
schema = "".join("".join(map(str, t)) if t[1] else "" for t in zip(prefixes, schema_version))
else:
schema = ifcopenshell.util.schema.get_schema_identifier(schema)
schema = {"IFC4X3": "IFC4X3_ADD2"}.get(schema, schema)
return ifcopenshell_wrapper.schema_by_name(schema)
@@ -159,10 +159,8 @@ def _add_segment_to_curve(
else:
assert False
end_point = ...
for mapped_segment in mapped_segments:
if mapped_segment:
end_point = _add_curve_segment_to_composite_curve(file, layout_segment, mapped_segment, curve)
assert end_point is not ...
return end_point
@@ -308,7 +308,5 @@ def _get_segment_start_point_label(prev_segment: entity_instance, segment: entit
label = _cant_callback(prev_segment, segment)
else:
label = _cant_label(prev_segment, segment)
else:
assert False, s.DesignParameters
return label
@@ -46,7 +46,6 @@ def _create_layout(file: ifcopenshell.file, alignment: entity_instance, points:
ifcopenshell.api.nest.assign_object(file, related_objects=alignment_layouts, relating_object=alignment)
start_dist_along = 0.0
gradient = None
for p1, p2 in zip(points, points[1:]):
x1, y1, z1 = p1.Coordinates
x2, y2, z2 = p2.Coordinates
@@ -101,7 +100,6 @@ def _create_layout(file: ifcopenshell.file, alignment: entity_instance, points:
ifcopenshell.api.nest.assign_object(file, related_objects=[hsegment], relating_object=alignment_layouts[0])
if include_vertical:
assert gradient is not None
vsegment = file.createIfcAlignmentSegment(
ifcopenshell.guid.new(),
DesignParameters=file.createIfcAlignmentVerticalSegment(
@@ -57,7 +57,6 @@ def create_from_csv(file: ifcopenshell.file, filepath: str) -> entity_instance:
:param filepath: path the to CSV file
:return: IfcAlignment
"""
alignment = None
with open(filepath, newline="") as csvfile:
reader = csv.reader(csvfile)
row_count = 0
@@ -90,14 +89,9 @@ def create_from_csv(file: ifcopenshell.file, filepath: str) -> entity_instance:
)
else:
# add all subsequent vertical alignments
assert alignment is not None
vertical_layout = ifcopenshell.api.alignment.add_vertical_layout(file, alignment)
ifcopenshell.api.alignment.layout_vertical_alignment_by_pi_method(
file, vertical_layout, coordinates, radii
)
if row_count == 0:
raise ValueError(f"CSV file '{filepath}' is empty; expected at least one row for the horizontal alignment.")
assert alignment is not None
return alignment
@@ -182,8 +182,6 @@ class Usecase:
if not reference:
migrator = ifcopenshell.util.schema.Migrator()
old_referenced_source = ...
existing_classification = None
if self.settings["is_lightweight"]:
old_referenced_source = self.settings["reference"].ReferencedSource
self.settings["reference"].ReferencedSource = None
@@ -196,7 +194,6 @@ class Usecase:
reference = migrator.migrate(self.settings["reference"], self.file)
if self.settings["is_lightweight"]:
assert old_referenced_source is not ...
reference.ReferencedSource = self.settings["classification"]
self.settings["reference"].ReferencedSource = old_referenced_source
elif existing_classification:
@@ -88,8 +88,6 @@ def bearing2dd(bearing: str) -> float:
elif cY == "S" and cX == "W":
angle = 270.0
sign = -1.0
else:
assert False, (cY, cX)
try:
dms = ifcopenshell.util.geolocation.dms2dd(d, m, s, ms)
@@ -112,84 +112,76 @@ def assign_cost_item_quantity(
"""
usecase = Usecase()
usecase.file = file
return usecase.execute(
cost_item=cost_item,
products=products or [],
prop_name=prop_name,
formula=formula,
ifc_class=ifc_class,
)
usecase.settings = {
"cost_item": cost_item,
"products": products or [],
"prop_name": prop_name,
"formula": formula,
"ifc_class": ifc_class,
}
return usecase.execute()
class Usecase:
file: ifcopenshell.file
settings: dict[str, Any]
def execute(
self,
cost_item: ifcopenshell.entity_instance,
products: list[ifcopenshell.entity_instance],
prop_name: str,
formula: str,
ifc_class: str,
):
self.cost_item = cost_item
self.prop_name = prop_name
if self.prop_name or formula:
self.quantities = set(cost_item.CostQuantities or [])
for product in products:
def execute(self):
if self.settings["prop_name"] or self.settings["formula"]:
self.quantities = set(self.settings["cost_item"].CostQuantities or [])
for product in self.settings["products"]:
if product.is_a("IfcSpatialElement"):
continue
ifcopenshell.api.control.assign_control(
self.file,
related_objects=[product],
relating_control=cost_item,
)
if formula:
tree = ast.parse(formula, mode="eval")
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")
collector = VariableExtractor()
collector.visit(tree)
variables = collector.variables
values: dict[str, float | None] = {}
for variable in variables:
getter = self.get_value_from_pset if "." in variable else self.get_value_from_qset
value = getter(product, variable)
values[variable] = value
if value is None:
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}' "
f"has value 0. Verify if this is correct."
)
if value is None:
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}' "
f"has value 0. Verify if this is correct."
)
evaluator = FormulaEvaluator(values)
result = evaluator.visit(tree.body)
new_quantity = None
for quantity in self.quantities:
if quantity.Formula == formula and len(products) == 1: # Todo improve it
if (
quantity.Formula == self.settings["formula"] and len(self.settings["products"]) == 1
): # Todo improve it
new_quantity = quantity
ifc_class = quantity.is_a()
self.settings["ifc_class"] = quantity.is_a()
continue
if new_quantity is None:
new_quantity = self.file.create_entity(ifc_class, Name="Unnamed")
new_quantity.Formula = formula
new_quantity = self.file.create_entity(self.settings["ifc_class"], Name="Unnamed")
new_quantity.Formula = self.settings["formula"]
self.quantities.add(new_quantity)
new_quantity[3] = result
continue
if self.prop_name:
if cost_item.CostQuantities and cost_item.CostQuantities[0].Name.lower() != self.prop_name.lower():
if self.settings["prop_name"]:
if (
self.settings["cost_item"].CostQuantities
and self.settings["cost_item"].CostQuantities[0].Name.lower() != self.settings["prop_name"].lower()
):
continue
self.add_quantity_from_related_object(product)
if self.prop_name or formula:
cost_item.CostQuantities = list(self.quantities)
if self.settings["prop_name"] or self.settings["formula"]:
self.settings["cost_item"].CostQuantities = list(self.quantities)
else:
self.update_cost_item_count()
@@ -197,7 +189,7 @@ class Usecase:
self,
product: ifcopenshell.entity_instance,
v: str,
) -> float | None:
) -> float:
pset_name = v.split(".")[0]
pset = ifcopenshell.util.element.get_pset(product, pset_name)
pset_property_name = v.split(".")[1]
@@ -207,11 +199,20 @@ class Usecase:
self,
product: ifcopenshell.entity_instance,
v: str,
) -> float | None:
) -> float:
qtos = ifcopenshell.util.element.get_psets(product, qtos_only=True)
quantities = next(iter(qtos.values()), {})
return (quantities or {}).get(v, None)
def assign_cost_control(
self, related_object: ifcopenshell.entity_instance, cost_item: ifcopenshell.entity_instance
) -> ifcopenshell.entity_instance:
return ifcopenshell.api.control.assign_control(
self.file,
related_objects=[related_object],
relating_control=cost_item,
)
def add_quantity_from_related_object(self, element: ifcopenshell.entity_instance) -> None:
for relationship in element.IsDefinedBy:
if relationship.is_a("IfcRelDefinesByProperties"):
@@ -221,24 +222,23 @@ class Usecase:
if not qto.is_a("IfcElementQuantity"):
return
for prop in qto.Quantities:
if prop.is_a("IfcPhysicalSimpleQuantity") and prop.Name.lower() == self.prop_name.lower():
if prop.is_a("IfcPhysicalSimpleQuantity") and prop.Name.lower() == self.settings["prop_name"].lower():
self.quantities.add(prop)
def update_cost_item_count(self):
cost_item = self.cost_item
# This is a bold assumption
# https://forums.buildingsmart.org/t/how-does-a-cost-item-know-that-it-is-counting-a-controlled-product/3564
if not cost_item.CostQuantities:
if not self.settings["cost_item"].CostQuantities:
ifcopenshell.api.cost.add_cost_item_quantity(
self.file,
cost_item=cost_item,
cost_item=self.settings["cost_item"],
ifc_class="IfcQuantityCount",
)
if len(cost_item.CostQuantities) == 1:
quantity = cost_item.CostQuantities[0]
if len(self.settings["cost_item"].CostQuantities) == 1:
quantity = self.settings["cost_item"].CostQuantities[0]
if quantity.is_a("IfcQuantityCount"):
count = 0
for rel in cost_item.Controls:
for rel in self.settings["cost_item"].Controls:
for obj in rel.RelatedObjects:
# Only increment if not a resource
if not obj.is_a("IfcConstructionResource"):
@@ -256,7 +256,7 @@ OPERATORS = {
}
def build_full_name(node: ast.expr) -> str:
def build_full_name(node):
# used for variables with dots
parts = []
while isinstance(node, ast.Attribute):
@@ -270,33 +270,33 @@ def build_full_name(node: ast.expr) -> str:
class VariableExtractor(ast.NodeVisitor):
def __init__(self) -> None:
self.variables: set[str] = set()
def __init__(self):
self.variables = set()
def visit_Name(self, node: ast.Name) -> None:
def visit_Name(self, node):
self.variables.add(node.id)
def visit_Attribute(self, node: ast.Attribute) -> None:
def visit_Attribute(self, node):
self.variables.add(build_full_name(node))
class FormulaEvaluator(ast.NodeVisitor):
def __init__(self, values: dict[str, float | None]):
def __init__(self, values):
self.values = values
def visit_BinOp(self, node: ast.BinOp) -> float:
def visit_BinOp(self, node):
left = self.visit(node.left)
right = self.visit(node.right)
return OPERATORS[type(node.op)](left, right) # ty: ignore[too-many-positional-arguments]
def visit_Name(self, node: ast.Name) -> float | None:
def visit_Name(self, node):
return self.values[node.id]
def visit_Attribute(self, node: ast.Attribute) -> float | None:
def visit_Attribute(self, node):
return self.values[build_full_name(node)]
def visit_Constant(self, node: ast.Constant) -> Any:
def visit_Constant(self, node):
return node.value
def generic_visit(self, node: ast.AST) -> Any:
def generic_visit(self, node):
raise ValueError(f"Operation not permitted: {type(node).__name__}")
@@ -116,8 +116,6 @@ def add_feature(
return ifcopenshell.api.aggregate.assign_object(file, [feature], element)
rels = feature.AdheresToElement
ifc_class = "IfcRelAdheresToElement"
else:
assert False, feature
if rels:
if rels[0][4] == element:
@@ -54,8 +54,6 @@ def remove_feature(file: ifcopenshell.file, feature: ifcopenshell.entity_instanc
rels = []
else:
rels = feature.ProjectsElements
else:
assert False, feature
for rel in rels:
history = rel.OwnerHistory
file.remove(rel)

Some files were not shown because too many files have changed in this diff Show More