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

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 svg_file_name = ifc_file_instance.ifc_file_name[:-4] + '.svg' 

86 svg_target_path = target_path / svg_file_name 

87 

88 sr = ifcopenshell.geom.serializers.svg( 

89 str(svg_target_path), settings) 

90 

91 file = ifc_file_instance.file 

92 sr.setFile(file) 

93 sr.setSectionHeightsFromStoreys() 

94 

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

112 

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) 

123 

124 sr.finalize() 

125 

126 return svg_target_path 

127 

128 

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

133 

134 file_dir = svg.parent 

135 

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

143 

144 # Create SVG ElementTree 

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

146 

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

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

149 

150 # Find all 'IfcBuildingStorey' elements 

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

152 namespaces) 

153 

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

158 

159 # Find the corresponding storey element in the copied tree 

160 copied_storeys = tree_story.findall( 

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

162 

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) 

168 

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) 

174 

175 

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

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

178 

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. 

182 

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

193 

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

200 

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 

207 

208 # Replace the desired style content 

209 style_content = style_content.replace( 

210 'fill-opacity: .2;', 

211 'fill-opacity: 0.7;') 

212 

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 

254 

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 

272 

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

274 

275 

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

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

278 

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. 

281 

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

292 

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

300 

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) 

306 

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

311 

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

320 

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) 

331 

332 # Backup existing attributes and clear the element. 

333 attributes = text_element.attrib 

334 text_element.clear() 

335 text_element.attrib.update(attributes) 

336 

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 

344 

345 font_size = 9.0 # in pixels 

346 

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

348 tspan_style = \ 

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

350 "-size:9px") 

351 

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

356 

357 tspan1_y = base_y - 0.3 * font_size 

358 

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 

372 

373 # Write the modified SVG file. 

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

375 

376 

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. 

381 

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. 

386 

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) 

395 

396 # Get the dimensions of the main SVG 

397 main_width = main_svg.width 

398 main_height = main_svg.height 

399 

400 if color_svg_path: 

401 # Load the color mapping SVG file 

402 color_svg = svg2rlg(color_svg_path) 

403 

404 # Get the dimensions of the color mapping SVG 

405 color_width = color_svg.width 

406 color_height = color_svg.height 

407 

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 

411 

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) 

423 

424 # Add the main SVG to the drawing 

425 drawing.add(main_svg) 

426 

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) 

433 

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 ) 

443 

444 

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. 

450 

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

463 

464 # Define namespaces 

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

466 

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

471 

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

477 

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

486 

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

493 

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" 

505 

506 # Save the modified SVG 

507 tree.write(output_path) 

508 

509 

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) 

521 

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

532 

533 

534def add_floor_plan_with_room_names(ifc_file_class_inst: IfcFileClass, 

535 target_path: Path, 

536 svg_adjust_dict: dict): 

537 

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