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

1import copy 

2import logging 

3import xml.etree.ElementTree as ET 

4from pathlib import Path 

5from typing import Union 

6 

7import ifcopenshell.geom 

8 

9from bim2sim.kernel.ifc_file import IfcFileClass 

10 

11est_time = 10 

12aggregate_model = True 

13logger = logging.getLogger(__name__) 

14 

15 

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. 

23 

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. 

30 

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 

40 

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) 

74 

75 

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 

93 

94 sr = ifcopenshell.geom.serializers.svg( 

95 str(svg_target_path), settings, ifcopenshell.geom.serializer_settings()) 

96 

97 file = ifc_file_instance.file 

98 sr.setFile(file) 

99 sr.setSectionHeightsFromStoreys(offset=0.8) 

100 

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() 

118 

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) 

129 

130 sr.finalize() 

131 

132 return svg_target_path 

133 

134 

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() 

139 

140 file_dir = svg.parent 

141 

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") 

149 

150 # Create SVG ElementTree 

151 tree = ET.ElementTree(ET.fromstring(svg_data)) 

152 

153 # Extract the <style> element from the original SVG 

154 style_element = tree.find(".//svg:style", namespaces) 

155 

156 # Find all 'IfcBuildingStorey' elements 

157 all_storeys = tree.findall(".//svg:g[@class='IfcBuildingStorey']", 

158 namespaces) 

159 

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() 

164 

165 # Find the corresponding storey element in the copied tree 

166 copied_storeys = tree_story.findall( 

167 ".//svg:g[@class='IfcBuildingStorey']", namespaces) 

168 

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) 

174 

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) 

180 

181 

182def modify_svg_elements(svg_adjust_dict: dict, path: Path): 

183 """Adjusts SVG floor plan for based on input data. 

184 

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. 

188 

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'} 

199 

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() 

206 

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 

213 

214 # Replace the desired style content 

215 style_content = style_content.replace( 

216 'fill-opacity: .2;', 

217 'fill-opacity: 0.7;') 

218 

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 

260 

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 

278 

279 tree.write(Path(f"{path}/{storey_guid}_modified.svg")) 

280 

281 

282def modify_svg_elements_for_floor_plan(svg_adjust_dict: dict, path: Path): 

283 """Adjusts SVG floor plan for based on input data. 

284 

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. 

287 

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'} 

298 

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() 

306 

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) 

312 

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'] 

317 

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};') 

326 

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) 

337 

338 # Backup existing attributes and clear the element. 

339 attributes = text_element.attrib 

340 text_element.clear() 

341 text_element.attrib.update(attributes) 

342 

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 

350 

351 font_size = 9.0 # in pixels 

352 

353 # tspan_style = "font-weight:bold;font-size:9px" 

354 tspan_style = \ 

355 ("font-family:Times-Roman;font-weight:normal;font" 

356 "-size:9px") 

357 

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 "" 

362 

363 tspan1_y = base_y - 0.3 * font_size 

364 

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 

378 

379 # Write the modified SVG file. 

380 tree.write(Path(f"{path}/{storey_guid}_modified.svg")) 

381 

382 

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. 

387 

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. 

392 

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) 

401 

402 # Get the dimensions of the main SVG 

403 main_width = main_svg.width 

404 main_height = main_svg.height 

405 

406 if color_svg_path: 

407 # Load the color mapping SVG file 

408 color_svg = svg2rlg(color_svg_path) 

409 

410 # Get the dimensions of the color mapping SVG 

411 color_width = color_svg.width 

412 color_height = color_svg.height 

413 

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 

417 

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) 

429 

430 # Add the main SVG to the drawing 

431 drawing.add(main_svg) 

432 

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) 

439 

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 ) 

449 

450 

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. 

456 

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() 

469 

470 # Define namespaces 

471 ns = {'svg': 'http://www.w3.org/2000/svg'} 

472 

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') 

477 

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 }) 

483 

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 }) 

492 

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 }) 

499 

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" 

511 

512 # Save the modified SVG 

513 tree.write(output_path) 

514 

515 

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) 

527 

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}") 

538 

539 

540def add_floor_plan_with_room_names(ifc_file_class_inst: IfcFileClass, 

541 target_path: Path, 

542 svg_adjust_dict: dict): 

543 

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"))