Coverage for bim2sim / utilities / svg_utils.py: 8%
231 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 16:32 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-01-26 16:32 +0000
1import copy
2import logging
3import xml.etree.ElementTree as ET
4from pathlib import Path
5from typing import Union
7import ifcopenshell.geom
9from bim2sim.kernel.ifc_file import IfcFileClass
11est_time = 10
12aggregate_model = True
13logger = logging.getLogger(__name__)
16def create_svg_floor_plan_plot(
17 ifc_file_class_inst: IfcFileClass,
18 target_path: Path,
19 svg_adjust_dict: dict,
20 result_str: str,
21 result_processing: str='mean'):
22 """Creates an SVG floor plan plot for every storey and adjust its design.
24 This function first creates an SVG floor plan for the provided IFC file
25 based on IfcConvert, then it splits the SVG floor plan into one file per
26 storey. In the last step the floor plans for each storey can be adjusted
27 regarding their background color and the text. This is useful to create a
28 heatmap that e.g. shows the highest temperature in the specific room
29 and colorize the rooms based on the data.
31 Args:
32 svg_adjust_dict: nexted dict that holds guid of storey, spaces and the
33 attributes for "color" and "text" to overwrite existing data in the
34 floor plan. See example for more information
35 ifc_file_class_inst: bim2sim IfcFileClass instance
36 target_path: Path to store the SVG files
37 result_str (str): name of the results plotted (used for file naming)
38 result_processing (str): result postprocessing choice (mean, max,
39 sum), defaults to mean
41 Example:
42 >>> # create nested dict, where "2eyxpyOx95m90jmsXLOuR0" is the storey guid
43 >>> # and "0Lt8gR_E9ESeGH5uY_g9e9", "17JZcMFrf5tOftUTidA0d3" and
44 >>> path_to_ifc_file = Path("my_path_to_ifc_folder/AC20-FZK-Haus.ifc")
45 >>> ifc_file_instance = ifcopenshell.open(path_to_ifc_file)
46 >>> target_path = Path("my_target_path")
47 >>> svg_adjust_dict = {
48 >>> "2eyxpyOx95m90jmsXLOuR0": {
49 >>> {"space_data":
50 >>> "0Lt8gR_E9ESeGH5uY_g9e9": {
51 >>> "color": "#FF0000",
52 >>> "text": 'my_text'
53 >>> },
54 >>> "17JZcMFrf5tOftUTidA0d3": {
55 >>> "color": "#FF0000",
56 >>> "text": 'my_text2'
57 >>> },
58 >>> "2RSCzLOBz4FAK$_wE8VckM": {
59 >>> "color": "#FF0000",
60 >>> "text": 'my_text3'
61 >>> },
62 >>> },
63 >>> }
64 >>> }
65 >>> create_svg_floor_plan_plot(
66 >>> path_to_ifc_file, target_path, svg_adjust_dict)
67 """
68 svg_path = convert_ifc_to_svg(ifc_file_class_inst, target_path)
69 split_svg_by_storeys(svg_path)
70 modify_svg_elements(svg_adjust_dict, target_path)
71 combine_svgs_complete(
72 target_path, list(svg_adjust_dict.keys()), result_str,
73 result_processing)
76def convert_ifc_to_svg(ifc_file_instance: IfcFileClass,
77 target_path: Path) -> Path:
78 """Create an SVG floor plan based on the given IFC file using IfcConvert"""
79 settings = ifcopenshell.geom.settings(
80 # INCLUDE_CURVES=True,
81 # EXCLUDE_SOLIDS_AND_SURFACES=False,
82 APPLY_DEFAULT_MATERIALS=True,
83 )
84 settings.set(
85 "dimensionality",
86 ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) # 2
87 settings.set(
88 "iterator-output",
89 ifcopenshell.ifcopenshell_wrapper.NATIVE)
90 svg_file_name = ifc_file_instance.ifc_file_name[:-4] + '.svg'
91 svg_target_path = target_path / svg_file_name
93 sr = ifcopenshell.geom.serializers.svg(
94 str(svg_target_path), settings, ifcopenshell.geom.serializer_settings())
96 file = ifc_file_instance.file
97 sr.setFile(file)
98 sr.setSectionHeightsFromStoreys(offset=0.0)
100 sr.setDrawDoorArcs(True)
101 sr.setPrintSpaceAreas(True)
102 # sr.setPrintSpaceNames(True)
103 sr.setBoundingRectangle(1024., 576.)
104 sr.setScale(1.5 / 100)
105 # sr.setWithoutStoreys(True)
106 # sr.setPolygonal(True)
107 # sr.setUseNamespace(True)
108 # sr.setAlwaysProject(True)
109 # sr.setScale(1 / 200)
110 # sr.setAutoElevation(False)
111 # sr.setAutoSection(True)
112 # sr.setPrintSpaceNames(False)
113 # sr.setPrintSpaceAreas(False)
114 # sr.setDrawDoorArcs(False)
115 # sr.setNoCSS(True)
116 sr.writeHeader()
118 for progress, elem in ifcopenshell.geom.iterate(
119 settings,
120 file,
121 with_progress=True,
122 exclude=("IfcOpeningElement", "IfcStair", "IfcSite", "IfcSlab",
123 "IfcMember", "IfcExternalSpatialElement",
124 "IfcBuildingElementProxy"),
125 num_threads=8
126 ):
127 sr.write(elem)
129 sr.finalize()
131 return svg_target_path
134def split_svg_by_storeys(svg: Path):
135 """Splits the SVG of one building into single SVGs for each storey."""
136 with open(svg) as svg_file:
137 svg_data = svg_file.read()
139 file_dir = svg.parent
141 # Define namespaces
142 namespaces = {
143 "svg": "http://www.w3.org/2000/svg",
144 "xlink": "http://www.w3.org/1999/xlink"
145 }
146 ET.register_namespace("", "http://www.w3.org/2000/svg")
147 ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
149 # Create SVG ElementTree
150 tree = ET.ElementTree(ET.fromstring(svg_data))
152 # Extract the <style> element from the original SVG
153 style_element = tree.find(".//svg:style", namespaces)
155 # Find all 'IfcBuildingStorey' elements
156 all_storeys = tree.findall(".//svg:g[@class='IfcBuildingStorey']",
157 namespaces)
159 for building_storey in all_storeys:
160 # Make a deep copy of the entire tree
161 tree_story = copy.deepcopy(tree)
162 root = tree_story.getroot()
164 # Find the corresponding storey element in the copied tree
165 copied_storeys = tree_story.findall(
166 ".//svg:g[@class='IfcBuildingStorey']", namespaces)
168 # Remove all other storeys except the one we want to keep
169 for storey_to_rm in copied_storeys:
170 if storey_to_rm.get("data-guid") != building_storey.get(
171 "data-guid"):
172 root.remove(storey_to_rm)
174 # Save the resulting SVG for the current storey
175 storey_guid = building_storey.get("data-guid")
176 with open(f"{file_dir}/{storey_guid}.svg", "wb") as f:
177 # Use a custom Serializer, to prevent 'ns0'-prefix
178 tree_story.write(f, encoding="utf-8", xml_declaration=True)
181def modify_svg_elements(svg_adjust_dict: dict, path: Path):
182 """Adjusts SVG floor plan for based on input data.
184 Based on the inputs, you can colorize the different spaces in the SVG
185 and/or add text to the space for each storey. The input is a nested
186 dictionary that holds the relevant data.
188 Args:
189 svg_adjust_dict: nexted dict that holds guid of storey, spaces and the
190 attributes for "color" and "text" to overwrite existing data in the
191 floor plan.
192 path: Path where the basic SVG files are stored.
193 """
194 # namespace
195 ET.register_namespace("", "http://www.w3.org/2000/svg")
196 ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
197 ns = {'svg': 'http://www.w3.org/2000/svg'}
199 for storey_guid, storey_data in svg_adjust_dict.items():
200 spaces_data = storey_data["space_data"]
201 # get file path for SVG file
202 file_path = Path(f"{path}/{storey_guid}.svg")
203 tree = ET.parse(file_path)
204 root = tree.getroot()
206 # reset opacity to 0.7 for better colors
207 namespace = {'svg': 'http://www.w3.org/2000/svg'}
208 style_element = root.find('.//svg:style', namespace)
209 if style_element is not None:
210 # Get the text content of the style element
211 style_content = style_element.text
213 # Replace the desired style content
214 style_content = style_content.replace(
215 'fill-opacity: .2;',
216 'fill-opacity: 0.7;')
218 # Update the text content of the style element
219 style_element.text = style_content
220 all_space_text_elements = root.findall(
221 f'.//svg:g[@class="IfcSpace"]/svg:text',
222 namespaces=ns)
223 for space_guid, adjust_data in spaces_data.items():
224 color = adjust_data['color']
225 text = adjust_data['text']
226 path_elements = root.findall(
227 f".//svg:g[@data-guid='{space_guid}']/svg:path",
228 namespaces=ns)
229 if path_elements is not None:
230 for path_element in path_elements:
231 if path_element is not None:
232 path_element.set(
233 'style', f'fill: {color};')
234 # TODO set spacearea and space name to false in convert, store
235 # short space name in tz mapping and then in svg dict, add instead
236 # replace the string of zone name \n consumption here
237 text_elements = root.findall(
238 f".//svg:g[@data-guid='{space_guid}']/svg:text",
239 namespaces=ns)
240 if text_elements is not None:
241 for text_element in text_elements:
242 if text_element is not None and text != 'nan':
243 all_space_text_elements.remove(text_element)
244 att = text_element.attrib
245 text_element.clear()
246 tspan_element = ET.SubElement(
247 text_element, "tspan")
248 style = tspan_element.get('style')
249 if style:
250 style += ";fill:#FFFFFF"
251 else:
252 style = "fill:#FFFFFF"
253 style += ";font-weight:bold"
254 style += ";font-size:18px"
255 tspan_element.set('style', style)
256 tspan_element.text = text
257 tspan_element.set("dy", "0.4em")
258 text_element.attrib = att
260 # for spaces without data add a placeholder
261 for text_element in all_space_text_elements:
262 if text_element is not None:
263 att = text_element.attrib
264 text_element.clear()
265 tspan_element = ET.SubElement(
266 text_element, "tspan")
267 style = tspan_element.get('style')
268 if style:
269 style += ";fill:#FFFFFF"
270 else:
271 style = "fill:#FFFFFF"
272 style += ";font-weight:bold"
273 style += ";font-size:18px"
274 tspan_element.set('style', style)
275 tspan_element.text = "-"
276 text_element.attrib = att
278 tree.write(Path(f"{path}/{storey_guid}_modified.svg"))
281def modify_svg_elements_for_floor_plan(svg_adjust_dict: dict, path: Path):
282 """Adjusts SVG floor plan for based on input data.
284 Based on the inputs, you can add text to the space for each storey. The
285 input is a nested dictionary that holds the relevant data.
287 Args:
288 svg_adjust_dict: nexted dict that holds guid of storey, spaces and the
289 attributes for "text" to overwrite existing data in the
290 floor plan.
291 path: Path where the basic SVG files are stored.
292 """
293 # Define the SVG namespaces.
294 ET.register_namespace("", "http://www.w3.org/2000/svg")
295 ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
296 ns = {'svg': 'http://www.w3.org/2000/svg'}
298 # Loop over your SVG files / storeys.
299 for storey_guid, storey_data in svg_adjust_dict.items():
300 spaces_data = storey_data["space_data"]
301 # get file path for SVG file
302 file_path = Path(f"{path}/{storey_guid}.svg")
303 tree = ET.parse(file_path)
304 root = tree.getroot()
306 namespace = {'svg': 'http://www.w3.org/2000/svg'}
307 style_element = root.find('.//svg:style', namespace)
308 all_space_text_elements = root.findall(
309 f'.//svg:g[@class="IfcSpace"]/svg:text',
310 namespaces=ns)
312 # Loop over the spaces.
313 for space_guid, adjust_data in spaces_data.items():
314 color = adjust_data['color']
315 text = adjust_data['text']
317 # Update fill color of matching path elements.
318 path_elements = root.findall(
319 f".//svg:g[@data-guid='{space_guid}']/svg:path",
320 namespaces=ns)
321 if path_elements is not None:
322 for path_element in path_elements:
323 if path_element is not None:
324 path_element.set('style', f'fill: {color};')
326 # Find and update corresponding text elements.
327 text_elements = root.findall(
328 f".//svg:g[@data-guid='{space_guid}']/svg:text",
329 namespaces=ns)
330 if text_elements is not None:
331 for text_element in text_elements:
332 if text_element is not None and text != 'nan':
333 # Remove from the overall list if needed.
334 if text_element in all_space_text_elements:
335 all_space_text_elements.remove(text_element)
337 # Backup existing attributes and clear the element.
338 attributes = text_element.attrib
339 text_element.clear()
340 text_element.attrib.update(attributes)
342 base_x = text_element.get('x', '0')
343 base_y = text_element.get('y', '0')
344 try:
345 base_y = float(base_y)
346 except ValueError:
347 # If not a valid float, default to 0.
348 base_y = 0.0
350 font_size = 9.0 # in pixels
352 # tspan_style = "font-weight:bold;font-size:9px"
353 tspan_style = \
354 ("font-family:Times-Roman;font-weight:normal;font"
355 "-size:9px")
357 # Split the text into two lines (split at first space).
358 parts = text.split(" ", 1)
359 line1 = parts[0]
360 line2 = parts[1] if len(parts) > 1 else ""
362 tspan1_y = base_y - 0.3 * font_size
364 # Create the first tspan element with the absolute y position.
365 tspan1 = ET.SubElement(text_element, "tspan")
366 tspan1.set("x", base_x)
367 tspan1.set("y", str(tspan1_y))
368 tspan1.set("style", tspan_style)
369 tspan1.text = line1
370 if line2:
371 tspan2_y = tspan1_y + 0.9 * font_size
372 tspan2 = ET.SubElement(text_element, "tspan")
373 tspan2.set("x", base_x)
374 tspan2.set("y", str(tspan2_y))
375 tspan2.set("style", tspan_style)
376 tspan2.text = line2
378 # Write the modified SVG file.
379 tree.write(Path(f"{path}/{storey_guid}_modified.svg"))
382def combine_two_svgs(
383 main_svg_path: Path, color_svg_path: Union[None, Path], output_svg_path:
384 Path):
385 """Combines the content of a child SVG file into a parent SVG file.
387 Args:
388 main_svg_path (Path): Path to the parent SVG file.
389 color_svg_path (Path): Path to the child SVG file.
390 output_svg_path: Path to the output SVG file.
392 Returns:
393 str: Combined SVG content as a string.
394 """
395 from reportlab.graphics import renderSVG
396 from reportlab.graphics.shapes import Drawing, Group
397 from svglib.svglib import svg2rlg
398 # Load the main SVG file
399 main_svg = svg2rlg(main_svg_path)
401 # Get the dimensions of the main SVG
402 main_width = main_svg.width
403 main_height = main_svg.height
405 if color_svg_path:
406 # Load the color mapping SVG file
407 color_svg = svg2rlg(color_svg_path)
409 # Get the dimensions of the color mapping SVG
410 color_width = color_svg.width
411 color_height = color_svg.height
413 # Calculate the position to place the color mapping SVG
414 color_x = main_width + 5 # Add some spacing between the SVGs
415 color_y = (main_height - color_height) / 2 # Center vertically
417 # Create a new drawing with the combined width
418 combined_width = main_width + color_width + 10
419 combined_height = max(main_height, color_height)
420 arrow_xpos = color_x + color_width / 2
421 arrow_ypos = color_y - color_height / 2.5
422 else:
423 combined_width = main_width + 110
424 combined_height = main_height
425 arrow_xpos = main_width + 10
426 arrow_ypos = combined_height / 4
427 drawing = Drawing(combined_width, combined_height)
429 # Add the main SVG to the drawing
430 drawing.add(main_svg)
432 if color_svg_path:
433 # Create a group to hold the color mapping SVG
434 color_group = Group(color_svg)
435 color_group.translate(color_x,
436 color_y) # Position the color mapping SVG
437 drawing.add(color_group)
439 # Save the combined SVG
440 renderSVG.drawToFile(drawing, output_svg_path)
441 svg_target_path = output_svg_path
442 # Output SVG file with north arrow
443 add_north_arrow(
444 svg_path=svg_target_path,
445 output_path=svg_target_path,
446 position=(arrow_xpos, arrow_ypos),
447 )
450def add_north_arrow(svg_path, output_path, position=(50, 50), length=100,
451 halfwidth=40,
452 color="black"):
453 """
454 Adds a north arrow to an existing SVG file.
456 Args:
457 svg_path: Path to the input SVG file.
458 output_path: Path to save the modified SVG file.
459 position: Tuple (x, y) for the arrow's starting position.
460 length: Length of the arrow in SVG units.
461 halfwidth: Half width of the arrow in SVG units.
462 color: Color of the arrow.
463 """
464 # Register the SVG namespace
465 ET.register_namespace("", "http://www.w3.org/2000/svg")
466 tree = ET.parse(svg_path)
467 root = tree.getroot()
469 # Define namespaces
470 ns = {'svg': 'http://www.w3.org/2000/svg'}
472 # Create or get the <defs> section
473 defs = root.find('svg:defs', ns)
474 if defs is None:
475 defs = ET.SubElement(root, 'defs')
477 # Create a group for the north arrow
478 g = ET.SubElement(root, 'g', {
479 'id': 'north_arrow',
480 'transform': f"translate({position[0]},{position[1]})"
481 })
483 # Define points for the left triangle (white fill with black stroke)
484 left_triangle_points = f"{-halfwidth},{length} 0,{length / 2} 0,0"
485 ET.SubElement(g, 'polygon', {
486 'points': left_triangle_points,
487 'fill': "white",
488 'stroke': color,
489 'stroke-width': "2"
490 })
492 # Define points for the right triangle (black fill)
493 right_triangle_points = f"{halfwidth},{length} 0,{length / 2} 0,0"
494 ET.SubElement(g, 'polygon', {
495 'points': right_triangle_points,
496 'fill': color
497 })
499 # Add the "N" label at the bottom center
500 text = ET.SubElement(g, 'text', {
501 'x': "0",
502 'y': "-15",
503 'fill': color,
504 'font-size': "48",
505 'font-family': "Times-Roman, Arial, Helvetica, sans-serif",
506 'text-anchor': "middle",
507 'dominant-baseline': "middle"
508 })
509 text.text = "N"
511 # Save the modified SVG
512 tree.write(output_path)
515def combine_svgs_complete(
516 file_path: Path, storey_guids: list, result_str: str, flag: str='') \
517 -> None:
518 """Add color mapping svg to floor plan svg."""
519 for guid in storey_guids:
520 original_svg = file_path / f"{guid}.svg"
521 svg_file = file_path / f"{guid}_modified.svg"
522 color_mapping_file = file_path / f"color_mapping_{guid}.svg"
523 output_svg_file = file_path / (f"Floor_plan_{result_str}_{flag}"
524 f"_{guid}.svg")
525 combine_two_svgs(svg_file, color_mapping_file, output_svg_file)
527 # cleanup
528 for file in [original_svg, svg_file, color_mapping_file]:
529 try:
530 file.unlink()
531 except FileNotFoundError:
532 logger.warning(
533 f"{file.name} in path {file.parent} not found and thus "
534 f"couldn't be removed.")
535 except OSError as e:
536 logger.warning(f"Error: {e.filename} - {e.strerror}")
539def add_floor_plan_with_room_names(ifc_file_class_inst: IfcFileClass,
540 target_path: Path,
541 svg_adjust_dict: dict):
543 svg_path = convert_ifc_to_svg(ifc_file_class_inst, target_path)
544 split_svg_by_storeys(svg_path)
545 modify_svg_elements_for_floor_plan(svg_adjust_dict, target_path)
546 for storey_guid in svg_adjust_dict.keys():
547 combine_two_svgs(
548 Path(f"{target_path}/{storey_guid}_modified.svg"),
549 color_svg_path=None,
550 output_svg_path=Path(f"{target_path}/{storey_guid}_full.svg"))