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

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 ) 

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 

92 

93 sr = ifcopenshell.geom.serializers.svg( 

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

95 

96 file = ifc_file_instance.file 

97 sr.setFile(file) 

98 sr.setSectionHeightsFromStoreys(offset=0.0) 

99 

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

117 

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) 

128 

129 sr.finalize() 

130 

131 return svg_target_path 

132 

133 

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

138 

139 file_dir = svg.parent 

140 

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

148 

149 # Create SVG ElementTree 

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

151 

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

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

154 

155 # Find all 'IfcBuildingStorey' elements 

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

157 namespaces) 

158 

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

163 

164 # Find the corresponding storey element in the copied tree 

165 copied_storeys = tree_story.findall( 

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

167 

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) 

173 

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) 

179 

180 

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

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

183 

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. 

187 

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

198 

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

205 

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 

212 

213 # Replace the desired style content 

214 style_content = style_content.replace( 

215 'fill-opacity: .2;', 

216 'fill-opacity: 0.7;') 

217 

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 

259 

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 

277 

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

279 

280 

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

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

283 

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. 

286 

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

297 

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

305 

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) 

311 

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

316 

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

325 

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) 

336 

337 # Backup existing attributes and clear the element. 

338 attributes = text_element.attrib 

339 text_element.clear() 

340 text_element.attrib.update(attributes) 

341 

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 

349 

350 font_size = 9.0 # in pixels 

351 

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

353 tspan_style = \ 

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

355 "-size:9px") 

356 

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

361 

362 tspan1_y = base_y - 0.3 * font_size 

363 

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 

377 

378 # Write the modified SVG file. 

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

380 

381 

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. 

386 

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. 

391 

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) 

400 

401 # Get the dimensions of the main SVG 

402 main_width = main_svg.width 

403 main_height = main_svg.height 

404 

405 if color_svg_path: 

406 # Load the color mapping SVG file 

407 color_svg = svg2rlg(color_svg_path) 

408 

409 # Get the dimensions of the color mapping SVG 

410 color_width = color_svg.width 

411 color_height = color_svg.height 

412 

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 

416 

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) 

428 

429 # Add the main SVG to the drawing 

430 drawing.add(main_svg) 

431 

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) 

438 

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 ) 

448 

449 

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. 

455 

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

468 

469 # Define namespaces 

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

471 

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

476 

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

482 

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

491 

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

498 

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" 

510 

511 # Save the modified SVG 

512 tree.write(output_path) 

513 

514 

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) 

526 

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

537 

538 

539def add_floor_plan_with_room_names(ifc_file_class_inst: IfcFileClass, 

540 target_path: Path, 

541 svg_adjust_dict: dict): 

542 

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