python-materialsdb 0.3.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/PKG-INFO +1 -1
  2. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/pyproject.toml +1 -1
  3. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/gui/listener.py +15 -4
  4. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/ifc/material_builder.py +54 -24
  5. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/python_materialsdb.egg-info/PKG-INFO +1 -1
  6. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_bonsai_insert.py +199 -0
  7. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_listener_payload.py +26 -0
  8. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_material_builder.py +46 -0
  9. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/LICENSE.md +0 -0
  10. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/README.md +0 -0
  11. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/setup.cfg +0 -0
  12. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/__init__.py +0 -0
  13. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/cache.py +0 -0
  14. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/classes.py +0 -0
  15. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/config.py +0 -0
  16. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/construction.py +0 -0
  17. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/gui/__init__.py +0 -0
  18. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/gui/__main__.py +0 -0
  19. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/gui/discovery.py +0 -0
  20. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/gui/server.py +0 -0
  21. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/gui/static/app-constructions.js +0 -0
  22. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/gui/static/app.js +0 -0
  23. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/gui/static/constructions.html +0 -0
  24. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/gui/static/index.html +0 -0
  25. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/gui/static/picker-core.js +0 -0
  26. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/ifc/__init__.py +0 -0
  27. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/ifc/material_psets.json +0 -0
  28. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/ifc/project_library.py +0 -0
  29. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/query.py +0 -0
  30. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/schema/MaterialsDBIndex100.xsd +0 -0
  31. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/schema/materialsdb102.xsd +0 -0
  32. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/schema/materialsdb103.xsd +0 -0
  33. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/serialiser.py +0 -0
  34. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/store.py +0 -0
  35. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/summary.py +0 -0
  36. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/materialsdb/utils.py +0 -0
  37. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/python_materialsdb.egg-info/SOURCES.txt +0 -0
  38. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/python_materialsdb.egg-info/dependency_links.txt +0 -0
  39. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/python_materialsdb.egg-info/entry_points.txt +0 -0
  40. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/python_materialsdb.egg-info/requires.txt +0 -0
  41. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/src/python_materialsdb.egg-info/top_level.txt +0 -0
  42. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_bonsai_discovery.py +0 -0
  43. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_build_bonsai_addon.py +0 -0
  44. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_cache.py +0 -0
  45. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_construction.py +0 -0
  46. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_construction_frontend.py +0 -0
  47. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_construction_ifc.py +0 -0
  48. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_gui_server.py +0 -0
  49. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_picker_frontend.py +0 -0
  50. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_project_library.py +0 -0
  51. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_query.py +0 -0
  52. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_serialiser.py +0 -0
  53. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_store.py +0 -0
  54. {python_materialsdb-0.3.1 → python_materialsdb-0.3.2}/tests/test_summary.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-materialsdb
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: A library to work with materialsdb.org open standard for building materials.
5
5
  Author-email: Cyril Waechter <cyrwae@hotmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-materialsdb"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "A library to work with materialsdb.org open standard for building materials."
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
@@ -5,7 +5,7 @@ MaterialBuilder would create (one entry per IfcMaterial); the Bonsai add-on
5
5
  maps the payload onto its open file without importing this library."""
6
6
 
7
7
  from materialsdb import config, utils
8
- from materialsdb.ifc.material_builder import PSETS, get_value
8
+ from materialsdb.ifc.material_builder import PSETS, get_value, style_for
9
9
 
10
10
 
11
11
  def _resolved_psets(layer, country) -> dict:
@@ -50,6 +50,10 @@ def build_add_materials_payload(store_, items, country=None, lang=None):
50
50
  description = str(utils.get_material_description(material, lang))
51
51
  category = str(getattr(material.information, "group", "") or "")
52
52
  color = getattr(material.information, "color", None)
53
+ color = int(color) if color else None
54
+ # Resolve the surface style server-side: the add-on runs in Blender's
55
+ # interpreter without access to the materialsdb colour scheme.
56
+ style_name, style_rgb = style_for(color, category)
53
57
  entries = []
54
58
  for layer in utils.get_material_layers(material):
55
59
  if wanted is not None and str(layer.id) not in wanted:
@@ -63,7 +67,9 @@ def build_add_materials_payload(store_, items, country=None, lang=None):
63
67
  "name": name,
64
68
  "description": description,
65
69
  "category": category,
66
- "color": int(color) if color else None,
70
+ "color": color,
71
+ "style_name": style_name,
72
+ "style_color": [round(component, 6) for component in style_rgb],
67
73
  "identity": {
68
74
  "material_id": material_id,
69
75
  "company_id": str(summary.company_id or ""),
@@ -120,6 +126,9 @@ def build_add_construction_payload(store_, body, country=None, lang=None):
120
126
  problems.append(f"unknown material id: {layer.material_id}")
121
127
  continue
122
128
  color = getattr(material.information, "color", None)
129
+ color = int(color) if color else None
130
+ category = str(getattr(material.information, "group", "") or "")
131
+ style_name, style_rgb = style_for(color, category)
123
132
  layers.append(
124
133
  {
125
134
  "material_id": layer.material_id,
@@ -128,8 +137,10 @@ def build_add_construction_payload(store_, body, country=None, lang=None):
128
137
  "source_id": layer.material_id,
129
138
  "name": str(utils.get_material_name(material, lang)),
130
139
  "description": str(utils.get_material_description(material, lang)),
131
- "category": str(getattr(material.information, "group", "") or ""),
132
- "color": int(color) if color else None,
140
+ "category": category,
141
+ "color": color,
142
+ "style_name": style_name,
143
+ "style_color": [round(component, 6) for component in style_rgb],
133
144
  "identity": {
134
145
  "material_id": layer.material_id,
135
146
  "company_id": str(summary.company_id or ""),
@@ -2,12 +2,21 @@
2
2
 
3
3
  import json
4
4
  from pathlib import Path
5
+ from typing import TypedDict
5
6
 
6
7
  from materialsdb import config, utils
7
8
 
8
9
  MATERIALSDB_PSET = "materialsdb"
9
10
 
10
- CATEGORIES = {
11
+
12
+ class CategoryStyle(TypedDict):
13
+ """One palette entry. `hatch` is reserved for future pattern support."""
14
+
15
+ hatch: str
16
+ color: tuple[int, int, int]
17
+
18
+
19
+ CATEGORIES: dict[str, CategoryStyle] = {
11
20
  "Others": {"hatch": "", "color": (255, 255, 255)},
12
21
  "Water_Proof": {"hatch": "", "color": (255, 255, 255)},
13
22
  "Vapour_Proof": {"hatch": "", "color": (0, 0, 0)},
@@ -27,6 +36,29 @@ CATEGORIES = {
27
36
  "Soil": {"hatch": "", "color": (142, 84, 52)},
28
37
  }
29
38
 
39
+ # Named colour schemes: a category -> palette. Only the default ships today;
40
+ # more schemes (and a GUI selector) are planned.
41
+ SCHEMES: dict[str, dict[str, CategoryStyle]] = {"Lesosai": CATEGORIES}
42
+ DEFAULT_SCHEME = "Lesosai"
43
+
44
+
45
+ def _rgb_int_components(color: int) -> tuple[float, float, float]:
46
+ """Decimal 0xRRGGBB -> normalised (r, g, b) in 0..1 (IfcColourRgb units)."""
47
+ return (((color >> 16) & 255) / 255, ((color >> 8) & 255) / 255, (color & 255) / 255)
48
+
49
+
50
+ def style_for(color, category, scheme=None):
51
+ """Resolve a material's (IfcSurfaceStyle.Name, (r, g, b) in 0..1).
52
+
53
+ The producer colour wins when present; otherwise the category colour from
54
+ the scheme (default 'Lesosai' = CATEGORIES). Unknown categories fall back
55
+ to 'Others'."""
56
+ if color:
57
+ return f"color {int(color)}", _rgb_int_components(int(color))
58
+ palette = SCHEMES.get(scheme or DEFAULT_SCHEME, CATEGORIES)
59
+ category = category if category in palette else "Others"
60
+ return f"category {category}", tuple(component / 255 for component in palette[category]["color"])
61
+
30
62
 
31
63
  def clean_psets(psets):
32
64
  new_dict = {}
@@ -71,6 +103,7 @@ class MaterialBuilder:
71
103
  name = utils.get_material_name(material, self.lang)
72
104
  description = utils.get_material_description(material, self.lang)
73
105
  category = str(material.information.group or "")
106
+ color = material.information.color
74
107
  if not with_layers:
75
108
  # reuse an already-present material instead of duplicating it;
76
109
  # its identity pset is already in place
@@ -79,15 +112,9 @@ class MaterialBuilder:
79
112
  return [existing]
80
113
  ifc_material = self.file.createIfcMaterial(name, str(description), str(category))
81
114
  self._create_identity_pset(ifc_material, material, company_id, company, verxml)
115
+ self._add_style_chain(color, category)
82
116
  return [ifc_material]
83
- color = material.information.color
84
- surface_style = self.get_surface_style(color, category)
85
- styled_item = self.file.createIfcStyledItem(Styles=[surface_style])
86
- self.file.createIfcStyledRepresentation(
87
- ContextOfItems=self._get_context(),
88
- RepresentationIdentifier="Body",
89
- Items=[styled_item],
90
- )
117
+ self._add_style_chain(color, category)
91
118
  created = []
92
119
  wanted = {str(g) for g in layer_ids} if layer_ids is not None else None
93
120
  for layer in utils.get_material_layers(material):
@@ -191,29 +218,32 @@ class MaterialBuilder:
191
218
  Material=ifc_material,
192
219
  )
193
220
 
194
- def get_surface_style(self, color, category):
195
- if color:
196
- name = f"color {color}"
197
- else:
198
- if not category or category not in CATEGORIES:
199
- category = "Others"
200
- name = f"category {category}"
221
+ def _add_style_chain(self, color, category, scheme=None):
222
+ """Create the floating styled chain MaterialBuilder uses for a
223
+ material's colour (shared style + one styled item per build)."""
224
+ surface_style = self.get_surface_style(color, category, scheme=scheme)
225
+ styled_item = self.file.createIfcStyledItem(Styles=[surface_style])
226
+ self.file.createIfcStyledRepresentation(
227
+ ContextOfItems=self._get_context(),
228
+ RepresentationIdentifier="Body",
229
+ Items=[styled_item],
230
+ )
231
+
232
+ def get_surface_style(self, color, category, scheme=None):
233
+ name, rgb = style_for(color, category, scheme=scheme)
201
234
  if name in self._styles and self._styles[name] in self.file:
202
235
  return self._styles[name]
203
- if color:
204
- style = self.file.createIfcSurfaceStyleShading(SurfaceColour=self.color_xml_to_ifc(color))
205
- else:
206
- style = self.file.createIfcSurfaceStyleShading(
207
- SurfaceColour=self.file.createIfcColourRgb(None, *CATEGORIES[category]["color"]),
208
- )
209
- surface_style = self.file.createIfcSurfaceStyle(Name=name, Side="BOTH", Styles=[style])
236
+ shading = self.file.createIfcSurfaceStyleShading(
237
+ SurfaceColour=self.file.createIfcColourRgb(None, *rgb),
238
+ )
239
+ surface_style = self.file.createIfcSurfaceStyle(Name=name, Side="BOTH", Styles=[shading])
210
240
  self._styles[name] = surface_style
211
241
  return surface_style
212
242
 
213
243
  def color_xml_to_ifc(self, color: int):
214
244
  """Color definition in xml is obscur. We assume that it is a decimal color.
215
245
  See: https://stackoverflow.com/a/2262152/4098083"""
216
- return self.file.createIfcColourRgb(Blue=color & 255, Green=(color >> 8) & 255, Red=(color >> 16) & 255)
246
+ return self.file.createIfcColourRgb(None, *_rgb_int_components(int(color)))
217
247
 
218
248
 
219
249
  def _materials_of(pset):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-materialsdb
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: A library to work with materialsdb.org open standard for building materials.
5
5
  Author-email: Cyril Waechter <cyrwae@hotmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -1,3 +1,4 @@
1
+ import copy
1
2
  import sys
2
3
  from pathlib import Path
3
4
 
@@ -8,6 +9,7 @@ pytest.importorskip("ifcopenshell")
8
9
  import ifcopenshell
9
10
  import ifcopenshell.api
10
11
  import ifcopenshell.util.element
12
+ import ifcopenshell.util.representation
11
13
 
12
14
  from materialsdb.gui.listener import build_add_materials_payload
13
15
  from materialsdb.store import MaterialStore
@@ -45,6 +47,31 @@ def _payload(store_):
45
47
  return payload
46
48
 
47
49
 
50
+ def _file_with_body_context():
51
+ """A scratch IFC4 file carrying a Model/Body subcontext so _apply_style
52
+ proceeds (plain scratch files have no context and skip styling)."""
53
+ file = ifcopenshell.file(schema="IFC4")
54
+ model = file.create_entity(
55
+ "IfcGeometricRepresentationContext",
56
+ ContextIdentifier=None,
57
+ ContextType="Model",
58
+ CoordinateSpaceDimension=3,
59
+ Precision=1e-5,
60
+ )
61
+ file.create_entity(
62
+ "IfcGeometricRepresentationSubContext",
63
+ ContextIdentifier="Body",
64
+ ContextType="Model",
65
+ ParentContext=model,
66
+ TargetView="MODEL_VIEW",
67
+ )
68
+ return file
69
+
70
+
71
+ def _shading_of(style):
72
+ return next(item for item in style.Styles if item.is_a("IfcSurfaceStyleShading"))
73
+
74
+
48
75
  def test_apply_creates_materials_psets_layers(store):
49
76
  file = ifcopenshell.file(schema="IFC4")
50
77
 
@@ -91,6 +118,178 @@ def test_apply_add_materials_creates_schema_valid_entities(store):
91
118
  assert len(identity) == 2
92
119
 
93
120
 
121
+ def test_apply_add_materials_writes_valid_surface_style(store):
122
+ """Regression: style.add_style + add_surface_style left IfcSurfaceStyle
123
+ with Styles=$ when add_surface_style raised, and the malformed entity was
124
+ later reused via assign_material_style (crash on next model open). The
125
+ style must be built in one shot and land on the material."""
126
+ file = _file_with_body_context()
127
+
128
+ apply_add_materials(file, _payload(store))
129
+
130
+ styles = file.by_type("IfcSurfaceStyle")
131
+ assert styles, "the payload carries a colour, so a style must exist"
132
+ assert all(style.Styles for style in styles) # IFC4 Styles is SET [1:?], never NULL/empty
133
+ assert _shading_of(styles[0]).SurfaceColour is not None
134
+
135
+ material = file.by_type("IfcMaterial")[0]
136
+ assert material.HasRepresentation, "the style must be assigned to the material"
137
+ styled_rep = material.HasRepresentation[0].Representations[0]
138
+ body = ifcopenshell.util.representation.get_context(file, "Model", "Body", "MODEL_VIEW")
139
+ assert styled_rep.ContextOfItems == body
140
+ styled_items = [item for item in styled_rep.Items if item.is_a("IfcStyledItem")]
141
+ assert any(styles[0] in item.Styles for item in styled_items)
142
+
143
+
144
+ def test_apply_add_materials_shares_one_style_per_color(store):
145
+ file = _file_with_body_context()
146
+
147
+ apply_add_materials(file, _payload(store)) # both layers share colour 16711680
148
+
149
+ assert len(file.by_type("IfcSurfaceStyle")) == 1
150
+ assert len(file.by_type("IfcStyledItem")) == 2 # one assignment per material
151
+
152
+
153
+ def test_apply_never_reuses_malformed_surface_style(store):
154
+ file = _file_with_body_context()
155
+ malformed = ifcopenshell.api.run("style.add_style", file, name="color 16711680")
156
+ assert not malformed.Styles
157
+
158
+ apply_add_materials(file, _payload(store))
159
+
160
+ assigned = [
161
+ item
162
+ for rep in file.by_type("IfcMaterialDefinitionRepresentation")
163
+ for representation in rep.Representations
164
+ for item in representation.Items
165
+ if item.is_a("IfcStyledItem")
166
+ ]
167
+ assert assigned, "a styled item must be created"
168
+ assert all(malformed not in item.Styles for item in assigned)
169
+ assert any(style.Styles and style.Name == "color 16711680" for style in file.by_type("IfcSurfaceStyle"))
170
+
171
+
172
+ def _strip_representations(file):
173
+ for material in file.by_type("IfcMaterial"):
174
+ for representation in list(material.HasRepresentation or ()):
175
+ file.remove(representation)
176
+
177
+
178
+ def test_apply_add_materials_styles_reused_material(store):
179
+ """A material already present (identity pset) but colourless -- e.g. pushed
180
+ by the pre-fix add-on -- must get its style on a later push."""
181
+ file = _file_with_body_context()
182
+ payload = _payload(store)
183
+ apply_add_materials(file, payload)
184
+ _strip_representations(file)
185
+ assert not file.by_type("IfcMaterialDefinitionRepresentation")
186
+
187
+ assert apply_add_materials(file, payload) == 0 # nothing re-created
188
+
189
+ for material in file.by_type("IfcMaterial"):
190
+ assert material.HasRepresentation
191
+ assert [style.Name for style in file.by_type("IfcSurfaceStyle")] == ["color 16711680"]
192
+ assert all(style.Styles for style in file.by_type("IfcSurfaceStyle"))
193
+
194
+
195
+ def test_apply_add_construction_styles_reused_material(store):
196
+ file = _file_with_body_context()
197
+ apply_add_materials(file, _payload(store)) # Isolant A already in the model
198
+ _strip_representations(file)
199
+
200
+ summary = apply_add_construction(file, CONSTRUCTION_PAYLOAD)
201
+
202
+ assert summary["materials_created"] == 1 # Beton B only; Isolant A reused
203
+ isolant = next(material for material in file.by_type("IfcMaterial") if material.Name == "Isolant A")
204
+ assert isolant.HasRepresentation
205
+
206
+
207
+ def test_apply_add_material_uses_category_scheme_colour(store):
208
+ from materialsdb.ifc.material_builder import CATEGORIES
209
+
210
+ file = _file_with_body_context()
211
+ payload, missing = build_add_materials_payload(store, [{"id": "00000000-0000-0000-0000-000000000002"}])
212
+ assert missing == [] # Beton B carries no producer colour
213
+
214
+ apply_add_materials(file, payload)
215
+
216
+ styles = file.by_type("IfcSurfaceStyle")
217
+ assert [style.Name for style in styles] == ["category Concrete"]
218
+ colour = _shading_of(styles[0]).SurfaceColour
219
+ expected = CATEGORIES["Concrete"]["color"]
220
+ assert (round(colour.Red * 255), round(colour.Green * 255), round(colour.Blue * 255)) == expected
221
+
222
+
223
+ def test_apply_preserves_third_party_style_on_reused_material(store):
224
+ file = _file_with_body_context()
225
+ payload = _payload(store)
226
+ apply_add_materials(file, payload)
227
+ material = file.by_type("IfcMaterial")[0]
228
+ styled_item = next(
229
+ item
230
+ for representation in material.HasRepresentation
231
+ for styled in representation.Representations
232
+ for item in styled.Items
233
+ if item.is_a("IfcStyledItem")
234
+ )
235
+ custom = ifcopenshell.api.run("style.add_style", file, name="MyCustom")
236
+ ifcopenshell.api.run(
237
+ "style.add_surface_style",
238
+ file,
239
+ style=custom,
240
+ ifc_class="IfcSurfaceStyleShading",
241
+ attributes={"SurfaceColour": {"Name": None, "Red": 0.1, "Green": 0.2, "Blue": 0.3}},
242
+ )
243
+ styled_item.Styles = (custom,)
244
+
245
+ apply_add_materials(file, payload) # existing key -> would re-style
246
+
247
+ assert styled_item.Styles[0].Name == "MyCustom" # third-party style kept
248
+
249
+
250
+ def test_apply_updates_our_style_on_colour_change(store):
251
+ file = _file_with_body_context()
252
+ payload = _payload(store)
253
+ apply_add_materials(file, payload)
254
+ material = file.by_type("IfcMaterial")[0]
255
+
256
+ changed = copy.deepcopy(payload)
257
+ for entry in changed["materials"]:
258
+ entry["color"] = 255
259
+ entry["style_name"] = "color 255"
260
+ entry["style_color"] = [0.0, 0.0, 1.0]
261
+ apply_add_materials(file, changed)
262
+
263
+ styled_item = next(
264
+ item
265
+ for representation in material.HasRepresentation
266
+ for styled in representation.Representations
267
+ for item in styled.Items
268
+ if item.is_a("IfcStyledItem")
269
+ )
270
+ assert styled_item.Styles[0].Name == "color 255" # our style refreshed
271
+
272
+
273
+ def test_apply_styling_idempotent(store):
274
+ file = _file_with_body_context()
275
+ payload = _payload(store)
276
+
277
+ apply_add_materials(file, payload)
278
+ counts = (
279
+ len(file.by_type("IfcSurfaceStyle")),
280
+ len(file.by_type("IfcStyledItem")),
281
+ len(file.by_type("IfcMaterialDefinitionRepresentation")),
282
+ )
283
+
284
+ apply_add_materials(file, payload)
285
+
286
+ assert (
287
+ len(file.by_type("IfcSurfaceStyle")),
288
+ len(file.by_type("IfcStyledItem")),
289
+ len(file.by_type("IfcMaterialDefinitionRepresentation")),
290
+ ) == counts
291
+
292
+
94
293
  def test_apply_is_idempotent(store):
95
294
  file = ifcopenshell.file(schema="IFC4")
96
295
  payload = _payload(store)
@@ -260,3 +260,29 @@ def test_construction_payload_no_u_values_when_lambda_unresolvable(store):
260
260
 
261
261
  assert payload is None
262
262
  assert problems == ["unknown material id: 00000000-0000-0000-0000-000000000004"]
263
+
264
+
265
+ def test_payload_carries_resolved_producer_style(store):
266
+ payload, missing = build_add_materials_payload(store, [{"id": "00000000-0000-0000-0000-000000000001"}])
267
+
268
+ assert missing == []
269
+ first = payload["materials"][0]
270
+ assert first["style_name"] == "color 16711680" # producer colour wins
271
+ assert first["style_color"] == [1.0, 0.0, 0.0] # normalised 0xFF0000
272
+
273
+
274
+ def test_construction_payload_carries_category_scheme_style(store):
275
+ from materialsdb.ifc.material_builder import CATEGORIES
276
+
277
+ payload, problems = build_add_construction_payload(
278
+ store,
279
+ body={
280
+ "name": "wall",
281
+ "layers": [{"material_id": "00000000-0000-0000-0000-000000000002", "thickness_m": 0.15}],
282
+ },
283
+ )
284
+
285
+ assert problems == []
286
+ material = payload["construction"]["layers"][0]["material"]
287
+ assert material["style_name"] == "category Concrete" # no producer colour -> scheme
288
+ assert material["style_color"] == [round(component / 255, 6) for component in CATEGORIES["Concrete"]["color"]]
@@ -303,3 +303,49 @@ def test_org_layer_pset_exposes_layer_id_and_thickness(mini_source):
303
303
  "00000000-0000-0000-0000-0000000000a1": 0.2, # 200 mm -> meters
304
304
  "00000000-0000-0000-0000-0000000000a2": 0.1,
305
305
  }
306
+
307
+
308
+ def test_style_for_prefers_producer_colour_and_normalises():
309
+ from materialsdb.ifc.material_builder import style_for
310
+
311
+ name, rgb = style_for(16711680, "Insulation") # 0xFF0000
312
+
313
+ assert name == "color 16711680"
314
+ assert rgb == (1.0, 0.0, 0.0) # IfcColourRgb components are normalised 0..1
315
+
316
+
317
+ def test_style_for_falls_back_to_scheme_category_colour():
318
+ from materialsdb.ifc.material_builder import CATEGORIES, style_for
319
+
320
+ name, rgb = style_for(None, "Concrete")
321
+
322
+ assert name == "category Concrete"
323
+ assert rgb == tuple(component / 255 for component in CATEGORIES["Concrete"]["color"])
324
+
325
+
326
+ def test_style_for_unknown_category_falls_back_to_others():
327
+ from materialsdb.ifc.material_builder import CATEGORIES, style_for
328
+
329
+ name, rgb = style_for(None, "Air") # not a scheme category
330
+
331
+ assert name == "category Others"
332
+ assert rgb == tuple(component / 255 for component in CATEGORIES["Others"]["color"])
333
+
334
+
335
+ def test_build_without_layers_styles_the_material(mini_source):
336
+ """The to_ifc_layer_set path builds materials with with_layers=False; they
337
+ must carry a colour too, or exported constructions show no style."""
338
+ file = ifcopenshell.file(schema="IFC4")
339
+ builder = MaterialBuilder(file)
340
+
341
+ created = builder.build(mini_source.material[0], company="Mini SA", with_layers=False)
342
+
343
+ assert len(created) == 1
344
+ assert not file.by_type("IfcMaterialLayer") # still material-only
345
+ styles = file.by_type("IfcSurfaceStyle")
346
+ assert [style.Name for style in styles] == ["color 16711680"] # producer colour wins
347
+ assert all(style.Styles for style in styles)
348
+ colour = styles[0].Styles[0].SurfaceColour
349
+ assert (colour.Red, colour.Green, colour.Blue) == (1.0, 0.0, 0.0)
350
+ assert len(file.by_type("IfcStyledItem")) == 1
351
+ assert len(file.by_type("IfcStyledRepresentation")) == 1