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