Coverage for bim2sim / utilities / svg_utils.py: 8%
231 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-12 10:59 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-12 10:59 +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 PRECISION=1e-6
84 )
85 settings.set(
86 "dimensionality",
87 ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) # 2
88 settings.set(
89 "iterator-output",
90 ifcopenshell.ifcopenshell_wrapper.NATIVE)
91 svg_file_name = ifc_file_instance.ifc_file_name[:-4] + '.svg'
92 svg_target_path = target_path / svg_file_name
94 sr = ifcopenshell.geom.serializers.svg(
95 str(svg_target_path), settings, ifcopenshell.geom.serializer_settings())
97 file = ifc_file_instance.file
98 sr.setFile(file)
99 sr.setSectionHeightsFromStoreys(offset=0.8)
101 sr.setDrawDoorArcs(True)
102 sr.setPrintSpaceAreas(True)
103 # sr.setPrintSpaceNames(True)
104 sr.setBoundingRectangle(1024., 576.)
105 sr.setScale(1.5 / 100)
106 # sr.setWithoutStoreys(True)
107 # sr.setPolygonal(True)
108 # sr.setUseNamespace(True)
109 # sr.setAlwaysProject(True)
110 # sr.setScale(1 / 200)
111 # sr.setAutoElevation(False)
112 # sr.setAutoSection(True)
113 # sr.setPrintSpaceNames(False)
114 # sr.setPrintSpaceAreas(False)
115 # sr.setDrawDoorArcs(False)
116 # sr.setNoCSS(True)
117 sr.writeHeader()
119 for progress, elem in ifcopenshell.geom.iterate(
120 settings,
121 file,
122 with_progress=True,
123 exclude=("IfcOpeningElement", "IfcStair", "IfcSite", "IfcSlab",
124 "IfcMember", "IfcExternalSpatialElement",
125 "IfcBuildingElementProxy"),
126 num_threads=8
127 ):
128 sr.write(elem)
130 sr.finalize()
132 return svg_target_path
135def split_svg_by_storeys(svg: Path):
136 """Splits the SVG of one building into single SVGs for each storey."""
137 with open(svg) as svg_file:
138 svg_data = svg_file.read()
140 file_dir = svg.parent
142 # Define namespaces
143 namespaces = {
144 "svg": "http://www.w3.org/2000/svg",
145 "xlink": "http://www.w3.org/1999/xlink"
146 }
147 ET.register_namespace("", "http://www.w3.org/2000/svg")
148 ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
150 # Create SVG ElementTree
151 tree = ET.ElementTree(ET.fromstring(svg_data))
153 # Extract the <style> element from the original SVG
154 style_element = tree.find(".//svg:style", namespaces)
156 # Find all 'IfcBuildingStorey' elements
157 all_storeys = tree.findall(".//svg:g[@class='IfcBuildingStorey']",
158 namespaces)
160 for building_storey in all_storeys:
161 # Make a deep copy of the entire tree
162 tree_story = copy.deepcopy(tree)
163 root = tree_story.getroot()
165 # Find the corresponding storey element in the copied tree
166 copied_storeys = tree_story.findall(
167 ".//svg:g[@class='IfcBuildingStorey']", namespaces)
169 # Remove all other storeys except the one we want to keep
170 for storey_to_rm in copied_storeys:
171 if storey_to_rm.get("data-guid") != building_storey.get(
172 "data-guid"):
173 root.remove(storey_to_rm)
175 # Save the resulting SVG for the current storey
176 storey_guid = building_storey.get("data-guid")
177 with open(f"{file_dir}/{storey_guid}.svg", "wb") as f:
178 # Use a custom Serializer, to prevent 'ns0'-prefix
179 tree_story.write(f, encoding="utf-8", xml_declaration=True)
182def modify_svg_elements(svg_adjust_dict: dict, path: Path):
183 """Adjusts SVG floor plan for based on input data.
185 Based on the inputs, you can colorize the different spaces in the SVG
186 and/or add text to the space for each storey. The input is a nested
187 dictionary that holds the relevant data.
189 Args:
190 svg_adjust_dict: nexted dict that holds guid of storey, spaces and the
191 attributes for "color" and "text" to overwrite existing data in the
192 floor plan.
193 path: Path where the basic SVG files are stored.
194 """
195 # namespace
196 ET.register_namespace("", "http://www.w3.org/2000/svg")
197 ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
198 ns = {'svg': 'http://www.w3.org/2000/svg'}
200 for storey_guid, storey_data in svg_adjust_dict.items():
201 spaces_data = storey_data["space_data"]
202 # get file path for SVG file
203 file_path = Path(f"{path}/{storey_guid}.svg")
204 tree = ET.parse(file_path)
205 root = tree.getroot()
207 # reset opacity to 0.7 for better colors
208 namespace = {'svg': 'http://www.w3.org/2000/svg'}
209 style_element = root.find('.//svg:style', namespace)
210 if style_element is not None:
211 # Get the text content of the style element
212 style_content = style_element.text
214 # Replace the desired style content
215 style_content = style_content.replace(
216 'fill-opacity: .2;',
217 'fill-opacity: 0.7;')
219 # Update the text content of the style element
220 style_element.text = style_content
221 all_space_text_elements = root.findall(
222 f'.//svg:g[@class="IfcSpace"]/svg:text',
223 namespaces=ns)
224 for space_guid, adjust_data in spaces_data.items():
225 color = adjust_data['color']
226 text = adjust_data['text']
227 path_elements = root.findall(
228 f".//svg:g[@data-guid='{space_guid}']/svg:path",
229 namespaces=ns)
230 if path_elements is not None:
231 for path_element in path_elements:
232 if path_element is not None:
233 path_element.set(
234 'style', f'fill: {color};')
235 # TODO set spacearea and space name to false in convert, store
236 # short space name in tz mapping and then in svg dict, add instead
237 # replace the string of zone name \n consumption here
238 text_elements = root.findall(
239 f".//svg:g[@data-guid='{space_guid}']/svg:text",
240 namespaces=ns)
241 if text_elements is not None:
242 for text_element in text_elements:
243 if text_element is not None and text != 'nan':
244 all_space_text_elements.remove(text_element)
245 att = text_element.attrib
246 text_element.clear()
247 tspan_element = ET.SubElement(
248 text_element, "tspan")
249 style = tspan_element.get('style')
250 if style:
251 style += ";fill:#FFFFFF"
252 else:
253 style = "fill:#FFFFFF"
254 style += ";font-weight:bold"
255 style += ";font-size:18px"
256 tspan_element.set('style', style)
257 tspan_element.text = text
258 tspan_element.set("dy", "0.4em")
259 text_element.attrib = att
261 # for spaces without data add a placeholder
262 for text_element in all_space_text_elements:
263 if text_element is not None:
264 att = text_element.attrib
265 text_element.clear()
266 tspan_element = ET.SubElement(
267 text_element, "tspan")
268 style = tspan_element.get('style')
269 if style:
270 style += ";fill:#FFFFFF"
271 else:
272 style = "fill:#FFFFFF"
273 style += ";font-weight:bold"
274 style += ";font-size:18px"
275 tspan_element.set('style', style)
276 tspan_element.text = "-"
277 text_element.attrib = att
279 tree.write(Path(f"{path}/{storey_guid}_modified.svg"))
282def modify_svg_elements_for_floor_plan(svg_adjust_dict: dict, path: Path):
283 """Adjusts SVG floor plan for based on input data.
285 Based on the inputs, you can add text to the space for each storey. The
286 input is a nested dictionary that holds the relevant data.
288 Args:
289 svg_adjust_dict: nexted dict that holds guid of storey, spaces and the
290 attributes for "text" to overwrite existing data in the
291 floor plan.
292 path: Path where the basic SVG files are stored.
293 """
294 # Define the SVG namespaces.
295 ET.register_namespace("", "http://www.w3.org/2000/svg")
296 ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
297 ns = {'svg': 'http://www.w3.org/2000/svg'}
299 # Loop over your SVG files / storeys.
300 for storey_guid, storey_data in svg_adjust_dict.items():
301 spaces_data = storey_data["space_data"]
302 # get file path for SVG file
303 file_path = Path(f"{path}/{storey_guid}.svg")
304 tree = ET.parse(file_path)
305 root = tree.getroot()
307 namespace = {'svg': 'http://www.w3.org/2000/svg'}
308 style_element = root.find('.//svg:style', namespace)
309 all_space_text_elements = root.findall(
310 f'.//svg:g[@class="IfcSpace"]/svg:text',
311 namespaces=ns)
313 # Loop over the spaces.
314 for space_guid, adjust_data in spaces_data.items():
315 color = adjust_data['color']
316 text = adjust_data['text']
318 # Update fill color of matching path elements.
319 path_elements = root.findall(
320 f".//svg:g[@data-guid='{space_guid}']/svg:path",
321 namespaces=ns)
322 if path_elements is not None:
323 for path_element in path_elements:
324 if path_element is not None:
325 path_element.set('style', f'fill: {color};')
327 # Find and update corresponding text elements.
328 text_elements = root.findall(
329 f".//svg:g[@data-guid='{space_guid}']/svg:text",
330 namespaces=ns)
331 if text_elements is not None:
332 for text_element in text_elements:
333 if text_element is not None and text != 'nan':
334 # Remove from the overall list if needed.
335 if text_element in all_space_text_elements:
336 all_space_text_elements.remove(text_element)
338 # Backup existing attributes and clear the element.
339 attributes = text_element.attrib
340 text_element.clear()
341 text_element.attrib.update(attributes)
343 base_x = text_element.get('x', '0')
344 base_y = text_element.get('y', '0')
345 try:
346 base_y = float(base_y)
347 except ValueError:
348 # If not a valid float, default to 0.
349 base_y = 0.0
351 font_size = 9.0 # in pixels
353 # tspan_style = "font-weight:bold;font-size:9px"
354 tspan_style = \
355 ("font-family:Times-Roman;font-weight:normal;font"
356 "-size:9px")
358 # Split the text into two lines (split at first space).
359 parts = text.split(" ", 1)
360 line1 = parts[0]
361 line2 = parts[1] if len(parts) > 1 else ""
363 tspan1_y = base_y - 0.3 * font_size
365 # Create the first tspan element with the absolute y position.
366 tspan1 = ET.SubElement(text_element, "tspan")
367 tspan1.set("x", base_x)
368 tspan1.set("y", str(tspan1_y))
369 tspan1.set("style", tspan_style)
370 tspan1.text = line1
371 if line2:
372 tspan2_y = tspan1_y + 0.9 * font_size
373 tspan2 = ET.SubElement(text_element, "tspan")
374 tspan2.set("x", base_x)
375 tspan2.set("y", str(tspan2_y))
376 tspan2.set("style", tspan_style)
377 tspan2.text = line2
379 # Write the modified SVG file.
380 tree.write(Path(f"{path}/{storey_guid}_modified.svg"))
383def combine_two_svgs(
384 main_svg_path: Path, color_svg_path: Union[None, Path], output_svg_path:
385 Path):
386 """Combines the content of a child SVG file into a parent SVG file.
388 Args:
389 main_svg_path (Path): Path to the parent SVG file.
390 color_svg_path (Path): Path to the child SVG file.
391 output_svg_path: Path to the output SVG file.
393 Returns:
394 str: Combined SVG content as a string.
395 """
396 from reportlab.graphics import renderSVG
397 from reportlab.graphics.shapes import Drawing, Group
398 from svglib.svglib import svg2rlg
399 # Load the main SVG file
400 main_svg = svg2rlg(main_svg_path)
402 # Get the dimensions of the main SVG
403 main_width = main_svg.width
404 main_height = main_svg.height
406 if color_svg_path:
407 # Load the color mapping SVG file
408 color_svg = svg2rlg(color_svg_path)
410 # Get the dimensions of the color mapping SVG
411 color_width = color_svg.width
412 color_height = color_svg.height
414 # Calculate the position to place the color mapping SVG
415 color_x = main_width + 5 # Add some spacing between the SVGs
416 color_y = (main_height - color_height) / 2 # Center vertically
418 # Create a new drawing with the combined width
419 combined_width = main_width + color_width + 10
420 combined_height = max(main_height, color_height)
421 arrow_xpos = color_x + color_width / 2
422 arrow_ypos = color_y - color_height / 2.5
423 else:
424 combined_width = main_width + 110
425 combined_height = main_height
426 arrow_xpos = main_width + 10
427 arrow_ypos = combined_height / 4
428 drawing = Drawing(combined_width, combined_height)
430 # Add the main SVG to the drawing
431 drawing.add(main_svg)
433 if color_svg_path:
434 # Create a group to hold the color mapping SVG
435 color_group = Group(color_svg)
436 color_group.translate(color_x,
437 color_y) # Position the color mapping SVG
438 drawing.add(color_group)
440 # Save the combined SVG
441 renderSVG.drawToFile(drawing, output_svg_path)
442 svg_target_path = output_svg_path
443 # Output SVG file with north arrow
444 add_north_arrow(
445 svg_path=svg_target_path,
446 output_path=svg_target_path,
447 position=(arrow_xpos, arrow_ypos),
448 )
451def add_north_arrow(svg_path, output_path, position=(50, 50), length=100,
452 halfwidth=40,
453 color="black"):
454 """
455 Adds a north arrow to an existing SVG file.
457 Args:
458 svg_path: Path to the input SVG file.
459 output_path: Path to save the modified SVG file.
460 position: Tuple (x, y) for the arrow's starting position.
461 length: Length of the arrow in SVG units.
462 halfwidth: Half width of the arrow in SVG units.
463 color: Color of the arrow.
464 """
465 # Register the SVG namespace
466 ET.register_namespace("", "http://www.w3.org/2000/svg")
467 tree = ET.parse(svg_path)
468 root = tree.getroot()
470 # Define namespaces
471 ns = {'svg': 'http://www.w3.org/2000/svg'}
473 # Create or get the <defs> section
474 defs = root.find('svg:defs', ns)
475 if defs is None:
476 defs = ET.SubElement(root, 'defs')
478 # Create a group for the north arrow
479 g = ET.SubElement(root, 'g', {
480 'id': 'north_arrow',
481 'transform': f"translate({position[0]},{position[1]})"
482 })
484 # Define points for the left triangle (white fill with black stroke)
485 left_triangle_points = f"{-halfwidth},{length} 0,{length / 2} 0,0"
486 ET.SubElement(g, 'polygon', {
487 'points': left_triangle_points,
488 'fill': "white",
489 'stroke': color,
490 'stroke-width': "2"
491 })
493 # Define points for the right triangle (black fill)
494 right_triangle_points = f"{halfwidth},{length} 0,{length / 2} 0,0"
495 ET.SubElement(g, 'polygon', {
496 'points': right_triangle_points,
497 'fill': color
498 })
500 # Add the "N" label at the bottom center
501 text = ET.SubElement(g, 'text', {
502 'x': "0",
503 'y': "-15",
504 'fill': color,
505 'font-size': "48",
506 'font-family': "Times-Roman, Arial, Helvetica, sans-serif",
507 'text-anchor': "middle",
508 'dominant-baseline': "middle"
509 })
510 text.text = "N"
512 # Save the modified SVG
513 tree.write(output_path)
516def combine_svgs_complete(
517 file_path: Path, storey_guids: list, result_str: str, flag: str='') \
518 -> None:
519 """Add color mapping svg to floor plan svg."""
520 for guid in storey_guids:
521 original_svg = file_path / f"{guid}.svg"
522 svg_file = file_path / f"{guid}_modified.svg"
523 color_mapping_file = file_path / f"color_mapping_{guid}.svg"
524 output_svg_file = file_path / (f"Floor_plan_{result_str}_{flag}"
525 f"_{guid}.svg")
526 combine_two_svgs(svg_file, color_mapping_file, output_svg_file)
528 # cleanup
529 for file in [original_svg, svg_file, color_mapping_file]:
530 try:
531 file.unlink()
532 except FileNotFoundError:
533 logger.warning(
534 f"{file.name} in path {file.parent} not found and thus "
535 f"couldn't be removed.")
536 except OSError as e:
537 logger.warning(f"Error: {e.filename} - {e.strerror}")
540def add_floor_plan_with_room_names(ifc_file_class_inst: IfcFileClass,
541 target_path: Path,
542 svg_adjust_dict: dict):
544 svg_path = convert_ifc_to_svg(ifc_file_class_inst, target_path)
545 split_svg_by_storeys(svg_path)
546 modify_svg_elements_for_floor_plan(svg_adjust_dict, target_path)
547 for storey_guid in svg_adjust_dict.keys():
548 combine_two_svgs(
549 Path(f"{target_path}/{storey_guid}_modified.svg"),
550 color_svg_path=None,
551 output_svg_path=Path(f"{target_path}/{storey_guid}_full.svg"))