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