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

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

90 

91 sr = ifcopenshell.geom.serializers.svg( 

92 str(svg_target_path), settings) 

93 

94 file = ifc_file_instance.file 

95 sr.setFile(file) 

96 sr.setSectionHeightsFromStoreys() 

97 

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

115 

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) 

126 

127 sr.finalize() 

128 

129 return svg_target_path 

130 

131 

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

136 

137 file_dir = svg.parent 

138 

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

146 

147 # Create SVG ElementTree 

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

149 

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

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

152 

153 # Find all 'IfcBuildingStorey' elements 

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

155 namespaces) 

156 

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

161 

162 # Find the corresponding storey element in the copied tree 

163 copied_storeys = tree_story.findall( 

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

165 

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) 

171 

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) 

177 

178 

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

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

181 

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. 

185 

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

196 

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

203 

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 

210 

211 # Replace the desired style content 

212 style_content = style_content.replace( 

213 'fill-opacity: .2;', 

214 'fill-opacity: 0.7;') 

215 

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 

257 

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 

275 

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

277 

278 

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

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

281 

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. 

284 

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

295 

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

303 

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) 

309 

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

314 

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

323 

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) 

334 

335 # Backup existing attributes and clear the element. 

336 attributes = text_element.attrib 

337 text_element.clear() 

338 text_element.attrib.update(attributes) 

339 

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 

347 

348 font_size = 9.0 # in pixels 

349 

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

351 tspan_style = \ 

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

353 "-size:9px") 

354 

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

359 

360 tspan1_y = base_y - 0.3 * font_size 

361 

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 

375 

376 # Write the modified SVG file. 

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

378 

379 

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. 

384 

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. 

389 

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) 

398 

399 # Get the dimensions of the main SVG 

400 main_width = main_svg.width 

401 main_height = main_svg.height 

402 

403 if color_svg_path: 

404 # Load the color mapping SVG file 

405 color_svg = svg2rlg(color_svg_path) 

406 

407 # Get the dimensions of the color mapping SVG 

408 color_width = color_svg.width 

409 color_height = color_svg.height 

410 

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 

414 

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) 

426 

427 # Add the main SVG to the drawing 

428 drawing.add(main_svg) 

429 

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) 

436 

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 ) 

446 

447 

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. 

453 

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

466 

467 # Define namespaces 

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

469 

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

474 

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

480 

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

489 

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

496 

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" 

508 

509 # Save the modified SVG 

510 tree.write(output_path) 

511 

512 

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) 

524 

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

535 

536 

537def add_floor_plan_with_room_names(ifc_file_class_inst: IfcFileClass, 

538 target_path: Path, 

539 svg_adjust_dict: dict): 

540 

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