Coverage for bim2sim/utilities/svg_utils.py: 11%
141 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-12 13:34 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-12 13:34 +0000
1import copy
2import logging
3import xml.etree.ElementTree as ET
4from pathlib import Path
6import ifcopenshell.geom
8from bim2sim.kernel.ifc_file import IfcFileClass
10est_time = 10
11aggregate_model = True
12logger = logging.getLogger(__name__)
15def create_svg_floor_plan_plot(
16 ifc_file_class_inst: IfcFileClass,
17 target_path: Path,
18 svg_adjust_dict: dict,
19 result_str: str):
20 """Creates an SVG floor plan plot for every storey and adjust its design.
22 This function first creates an SVG floor plan for the provided IFC file
23 based on IfcConvert, then it splits the SVG floor plan into one file per
24 storey. In the last step the floor plans for each storey can be adjusted
25 regarding their background color and the text. This is useful to create a
26 heatmap that e.g. shows the highest temperature in the specific room
27 and colorize the rooms based on the data.
29 Args:
30 svg_adjust_dict: nexted dict that holds guid of storey, spaces and the
31 attributes for "color" and "text" to overwrite existing data in the
32 floor plan. See example for more information
33 ifc_file_class_inst: bim2sim IfcFileClass instance
34 target_path: Path to store the SVG files
35 result_str (str): name of the results plotted (used for file naming)
37 Example:
38 >>> # create nested dict, where "2eyxpyOx95m90jmsXLOuR0" is the storey guid
39 >>> # and "0Lt8gR_E9ESeGH5uY_g9e9", "17JZcMFrf5tOftUTidA0d3" and
40 >>> path_to_ifc_file = Path("my_path_to_ifc_folder/AC20-FZK-Haus.ifc")
41 >>> ifc_file_instance = ifcopenshell.open(path_to_ifc_file)
42 >>> target_path = Path("my_target_path")
43 >>> svg_adjust_dict = {
44 >>> "2eyxpyOx95m90jmsXLOuR0": {
45 >>> {"space_data":
46 >>> "0Lt8gR_E9ESeGH5uY_g9e9": {
47 >>> "color": "#FF0000",
48 >>> "text": 'my_text'
49 >>> },
50 >>> "17JZcMFrf5tOftUTidA0d3": {
51 >>> "color": "#FF0000",
52 >>> "text": 'my_text2'
53 >>> },
54 >>> "2RSCzLOBz4FAK$_wE8VckM": {
55 >>> "color": "#FF0000",
56 >>> "text": 'my_text3'
57 >>> },
58 >>> },
59 >>> }
60 >>> }
61 >>> create_svg_floor_plan_plot(
62 >>> path_to_ifc_file, target_path, svg_adjust_dict)
63 """
64 svg_path = convert_ifc_to_svg(ifc_file_class_inst, target_path)
65 split_svg_by_storeys(svg_path)
66 modify_svg_elements(svg_adjust_dict, target_path)
67 combine_svgs_complete(
68 target_path, list(svg_adjust_dict.keys()), result_str)
71def convert_ifc_to_svg(ifc_file_instance: IfcFileClass,
72 target_path: Path) -> Path:
73 """Create an SVG floor plan based on the given IFC file using IfcConvert"""
74 settings = ifcopenshell.geom.settings(
75 INCLUDE_CURVES=True,
76 EXCLUDE_SOLIDS_AND_SURFACES=False,
77 APPLY_DEFAULT_MATERIALS=True,
78 DISABLE_TRIANGULATION=True
79 )
80 svg_file_name = ifc_file_instance.ifc_file_name[:-4] + '.svg'
81 svg_target_path = target_path / svg_file_name
83 sr = ifcopenshell.geom.serializers.svg(
84 str(svg_target_path), settings)
86 file = ifc_file_instance.file
87 sr.setFile(file)
88 sr.setSectionHeightsFromStoreys()
90 sr.setDrawDoorArcs(True)
91 sr.setPrintSpaceAreas(True)
92 # sr.setPrintSpaceNames(True)
93 sr.setBoundingRectangle(1024., 576.)
94 sr.setScale(1.5 / 100)
95 # sr.setWithoutStoreys(True)
96 # sr.setPolygonal(True)
97 # sr.setUseNamespace(True)
98 # sr.setAlwaysProject(True)
99 # sr.setScale(1 / 200)
100 # sr.setAutoElevation(False)
101 # sr.setAutoSection(True)
102 # sr.setPrintSpaceNames(False)
103 # sr.setPrintSpaceAreas(False)
104 # sr.setDrawDoorArcs(False)
105 # sr.setNoCSS(True)
106 sr.writeHeader()
108 for progress, elem in ifcopenshell.geom.iterate(
109 settings,
110 file,
111 with_progress=True,
112 exclude=("IfcOpeningElement", "IfcStair", "IfcSite", "IfcSlab",
113 "IfcMember", "IfcExternalSpatialElement",
114 "IfcBuildingElementProxy"),
115 num_threads=8
116 ):
117 sr.write(elem)
119 sr.finalize()
121 return svg_target_path
124def split_svg_by_storeys(svg: Path):
125 """Splits the SVG of one building into single SVGs for each storey."""
126 with open(svg) as svg_file:
127 svg_data = svg_file.read()
129 file_dir = svg.parent
131 # Define namespaces
132 namespaces = {
133 "svg": "http://www.w3.org/2000/svg",
134 "xlink": "http://www.w3.org/1999/xlink"
135 }
136 ET.register_namespace("", "http://www.w3.org/2000/svg")
137 ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
139 # Create SVG ElementTree
140 tree = ET.ElementTree(ET.fromstring(svg_data))
142 # Extract the <style> element from the original SVG
143 style_element = tree.find(".//svg:style", namespaces)
145 # Find all 'IfcBuildingStorey' elements
146 all_storeys = tree.findall(".//svg:g[@class='IfcBuildingStorey']",
147 namespaces)
149 for building_storey in all_storeys:
150 # Make a deep copy of the entire tree
151 tree_story = copy.deepcopy(tree)
152 root = tree_story.getroot()
154 # Find the corresponding storey element in the copied tree
155 copied_storeys = tree_story.findall(
156 ".//svg:g[@class='IfcBuildingStorey']", namespaces)
158 # Remove all other storeys except the one we want to keep
159 for storey_to_rm in copied_storeys:
160 if storey_to_rm.get("data-guid") != building_storey.get(
161 "data-guid"):
162 root.remove(storey_to_rm)
164 # Save the resulting SVG for the current storey
165 storey_guid = building_storey.get("data-guid")
166 with open(f"{file_dir}/{storey_guid}.svg", "wb") as f:
167 # Use a custom Serializer, to prevent 'ns0'-prefix
168 tree_story.write(f, encoding="utf-8", xml_declaration=True)
171def modify_svg_elements(svg_adjust_dict: dict, path: Path):
172 """Adjusts SVG floor plan for based on input data.
174 Based on the inputs, you can colorize the different spaces in the SVG
175 and/or add text to the space for each storey. The input is a nested
176 dictionary that holds the relevant data.
178 Args:
179 svg_adjust_dict: nexted dict that holds guid of storey, spaces and the
180 attributes for "color" and "text" to overwrite existing data in the
181 floor plan.
182 path: Path where the basic SVG files are stored.
183 """
184 # namespace
185 ET.register_namespace("", "http://www.w3.org/2000/svg")
186 ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
187 ns = {'svg': 'http://www.w3.org/2000/svg'}
189 for storey_guid, storey_data in svg_adjust_dict.items():
190 spaces_data = storey_data["space_data"]
191 # get file path for SVG file
192 file_path = Path(f"{path}/{storey_guid}.svg")
193 tree = ET.parse(file_path)
194 root = tree.getroot()
196 # reset opacity to 0.7 for better colors
197 namespace = {'svg': 'http://www.w3.org/2000/svg'}
198 style_element = root.find('.//svg:style', namespace)
199 if style_element is not None:
200 # Get the text content of the style element
201 style_content = style_element.text
203 # Replace the desired style content
204 style_content = style_content.replace(
205 'fill-opacity: .2;',
206 'fill-opacity: 0.7;')
208 # Update the text content of the style element
209 style_element.text = style_content
210 all_space_text_elements = root.findall(
211 f'.//svg:g[@class="IfcSpace"]/svg:text',
212 namespaces=ns)
213 for space_guid, adjust_data in spaces_data.items():
214 color = adjust_data['color']
215 text = adjust_data['text']
216 path_elements = root.findall(
217 f".//svg:g[@data-guid='{space_guid}']/svg:path",
218 namespaces=ns)
219 if path_elements is not None:
220 for path_element in path_elements:
221 if path_element is not None:
222 path_element.set(
223 'style', f'fill: {color};')
224 # TODO set spacearea and space name to false in convert, store
225 # short space name in tz mapping and then in svg dict, add instead
226 # replace the string of zone name \n consumption here
227 text_elements = root.findall(
228 f".//svg:g[@data-guid='{space_guid}']/svg:text",
229 namespaces=ns)
230 if text_elements is not None:
231 for text_element in text_elements:
232 all_space_text_elements.remove(text_element)
233 if text_element is not None:
234 att = text_element.attrib
235 text_element.clear()
236 tspan_element = ET.SubElement(
237 text_element, "tspan")
238 style = tspan_element.get('style')
239 if style:
240 style += ";fill:#FFFFFF"
241 else:
242 style = "fill:#FFFFFF"
243 style += ";font-weight:bold"
244 style += ";font-size:22px"
245 tspan_element.set('style', style)
246 tspan_element.text = text
247 text_element.attrib = att
249 # for spaces without data add a placeholder
250 for text_element in all_space_text_elements:
251 if text_element is not None:
252 att = text_element.attrib
253 text_element.clear()
254 tspan_element = ET.SubElement(
255 text_element, "tspan")
256 style = tspan_element.get('style')
257 if style:
258 style += ";fill:#FFFFFF"
259 else:
260 style = "fill:#FFFFFF"
261 style += ";font-weight:bold"
262 style += ";font-size:22px"
263 tspan_element.set('style', style)
264 tspan_element.text = "-"
265 text_element.attrib = att
267 tree.write(Path(f"{path}/{storey_guid}_modified.svg"))
270def combine_two_svgs(
271 main_svg_path: Path, color_svg_path: Path, output_svg_path: Path):
272 """Combines the content of a child SVG file into a parent SVG file.
274 Args:
275 main_svg_path (Path): Path to the parent SVG file.
276 color_svg_path (Path): Path to the child SVG file.
277 output_svg_path: Path to the output SVG file.
279 Returns:
280 str: Combined SVG content as a string.
281 """
282 from reportlab.graphics import renderSVG
283 from reportlab.graphics.shapes import Drawing, Group
284 from svglib.svglib import svg2rlg
285 # Load the main SVG file
286 main_svg = svg2rlg(main_svg_path)
288 # Load the color mapping SVG file
289 color_svg = svg2rlg(color_svg_path)
291 # Get the dimensions of the main SVG
292 main_width = main_svg.width
293 main_height = main_svg.height
295 # Get the dimensions of the color mapping SVG
296 color_width = color_svg.width
297 color_height = color_svg.height
299 # Calculate the position to place the color mapping SVG
300 color_x = main_width + 10 # Add some spacing between the SVGs
301 color_y = (main_height - color_height) / 2 # Center vertically
303 # Create a new drawing with the combined width
304 combined_width = main_width + color_width + 10
305 combined_height = max(main_height, color_height)
306 drawing = Drawing(combined_width, combined_height)
308 # Add the main SVG to the drawing
309 drawing.add(main_svg)
311 # Create a group to hold the color mapping SVG
312 color_group = Group(color_svg)
313 color_group.translate(color_x, color_y) # Position the color mapping SVG
314 drawing.add(color_group)
316 # Save the combined SVG
317 renderSVG.drawToFile(drawing, output_svg_path)
320def combine_svgs_complete(
321 file_path: Path, storey_guids: list, result_str: str) -> None:
322 """Add color mapping svg to floor plan svg."""
323 for guid in storey_guids:
324 original_svg = file_path / f"{guid}.svg"
325 svg_file = file_path / f"{guid}_modified.svg"
326 color_mapping_file = file_path / f"color_mapping_{guid}.svg"
327 output_svg_file = file_path / f"Floor_plan_{result_str}_{guid}.svg"
328 combine_two_svgs(svg_file, color_mapping_file, output_svg_file)
330 # cleanup
331 for file in [original_svg, svg_file, color_mapping_file]:
332 try:
333 file.unlink()
334 except FileNotFoundError:
335 logger.warning(
336 f"{file.name} in path {file.parent} not found and thus "
337 f"couldn't be removed.")
338 except OSError as e:
339 logger.warning(f"Error: {e.filename} - {e.strerror}")