Coverage for JLC2KiCadLib / footprint / footprint_handlers.py: 91%

265 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-04 22:53 +0100

1import json 

2import logging 

3import re 

4from math import acos, cos, pi, pow, radians, sin, sqrt 

5 

6from KicadModTree import ( 

7 Arc, 

8 Circle, 

9 Line, 

10 Pad, 

11 Polygon, 

12 RectFill, 

13 RectLine, 

14 Text, 

15 Vector2D, 

16) 

17 

18from .model3d import get_StepModel, get_WrlModel 

19 

20__all__ = [ 

21 "handlers", 

22 "h_TRACK", 

23 "h_PAD", 

24 "h_ARC", 

25 "h_CIRCLE", 

26 "h_SOLIDREGION", 

27 "h_SVGNODE", 

28 "h_VIA", 

29 "h_RECT", 

30 "h_HOLE", 

31 "h_TEXT", 

32 "mil2mm", 

33 "svg_arc_to_points", 

34] 

35 

36layer_correspondance = { 

37 "1": "F.Cu", 

38 "2": "B.Cu", 

39 "3": "F.SilkS", 

40 "4": "B.Silks", 

41 "5": "F.Paste", 

42 "6": "B.Paste", 

43 "7": "F.Mask", 

44 "8": "B.Mask", 

45 "10": "Edge.Cuts", 

46 "11": "", # EasyEDA "Multilayer" 

47 "12": "F.Fab", 

48 "99": "", # EasyEDA "Component shape layer" 

49 "100": "", # EasyEDA "Pin soldering layer" 

50 "101": "", # EasyEDA "Component marking layer" 

51} 

52 

53 

54def mil2mm(data): 

55 return float(data) / 3.937 

56 

57 

58def h_TRACK(data, kicad_mod, footprint_info): 

59 """ 

60 Append a line to the footprint 

61 

62 data : [ 

63 0 : width 

64 1 : layer 

65 2 : 

66 3 : points list 

67 4 : id 

68 ] 

69 """ 

70 

71 width = mil2mm(data[0]) 

72 

73 points = [mil2mm(p) for p in data[3].split(" ") if p] 

74 

75 for i in range(int(len(points) / 2) - 1): 

76 start = [points[2 * i], points[2 * i + 1]] 

77 end = [points[2 * i + 2], points[2 * i + 3]] 

78 try: 

79 layer = layer_correspondance[data[1]] 

80 except Exception: 

81 logging.exception("footprint h_TRACK: layer correspondance not found") 

82 layer = "F.SilkS" 

83 

84 # update footprint borders 

85 footprint_info.max_X = max(footprint_info.max_X, start[0], end[0]) 

86 footprint_info.min_X = min(footprint_info.min_X, start[0], end[0]) 

87 footprint_info.max_Y = max(footprint_info.max_Y, start[1], end[1]) 

88 footprint_info.min_Y = min(footprint_info.min_Y, start[1], end[1]) 

89 

90 # append line to kicad_mod 

91 kicad_mod.append(Line(start=start, end=end, width=width, layer=layer)) 

92 

93 

94def h_PAD(data, kicad_mod, footprint_info): 

95 """ 

96 Append a pad to the footprint 

97 

98 data : [ 

99 0 : shape type 

100 1 : pad position x 

101 2 : pad position y 

102 3 : pad size x 

103 4 : pad size y 

104 5 : layer 

105 6 : 

106 7 : pad number 

107 8 : drill size 

108 9 : Polygon nodes 

109 10 : rotation 

110 11 : id 

111 12 : drill offset 

112 13 : 

113 14 : plated 

114 15 : 

115 16 : 

116 17 : 

117 18 : 

118 ] 

119 """ 

120 

121 # PAD layer definition 

122 TOPLAYER = "1" 

123 BOTTOMLAYER = "2" 

124 MULTILAYER = "11" 

125 

126 shape_type = data[0] 

127 at = [mil2mm(data[1]), mil2mm(data[2])] 

128 size = [mil2mm(data[3]), mil2mm(data[4])] 

129 layer = data[5] 

130 pad_number = data[6] 

131 

132 drill_diameter = float(mil2mm(data[8])) * 2 

133 drill_size = drill_diameter 

134 

135 rotation = float(data[10]) 

136 drill_offset = float(mil2mm(data[12])) if data[12] else 0 

137 

138 primitives = "" 

139 

140 if layer == MULTILAYER: 

141 pad_type = Pad.TYPE_THT 

142 pad_layer = Pad.LAYERS_THT 

143 elif layer == TOPLAYER: 

144 pad_type = Pad.TYPE_SMT 

145 pad_layer = Pad.LAYERS_SMT 

146 elif layer == BOTTOMLAYER: 

147 pad_type = Pad.TYPE_SMT 

148 pad_layer = ["B.Cu", "B.Mask", "B.Paste"] 

149 else: 

150 logging.warning( 

151 f"footprint, h_PAD: Unrecognized pad layer. Using default SMT layer for " 

152 f"pad {pad_number}" 

153 ) 

154 pad_type = Pad.TYPE_SMT 

155 pad_layer = Pad.LAYERS_SMT 

156 

157 if shape_type == "OVAL": 

158 shape = Pad.SHAPE_OVAL 

159 

160 if drill_offset == 0: 

161 drill_size = drill_diameter 

162 

163 elif (drill_diameter < drill_offset) ^ ( 

164 size[0] > size[1] 

165 ): # invert the orientation of the drill hole if not in the same orientation 

166 # as the pad shape 

167 drill_size = [drill_diameter, drill_offset] 

168 else: 

169 drill_size = [drill_offset, drill_diameter] 

170 

171 elif shape_type == "RECT": 

172 shape = Pad.SHAPE_RECT 

173 

174 if drill_offset == 0: 

175 drill_size = drill_diameter 

176 else: 

177 drill_size = [drill_diameter, drill_offset] 

178 

179 elif shape_type == "ELLIPSE": 

180 shape = Pad.SHAPE_CIRCLE 

181 

182 elif shape_type == "POLYGON": 

183 shape = Pad.SHAPE_CUSTOM 

184 points = [] 

185 for i, coord in enumerate(data[9].split(" ")): 

186 points.append(mil2mm(coord) - at[i % 2]) 

187 primitives = [Polygon(nodes=zip(points[::2], points[1::2], strict=True))] 

188 size = [0.1, 0.1] 

189 

190 drill_size = 1 if drill_offset == 0 else [drill_diameter, drill_offset] 

191 

192 else: 

193 logging.error( 

194 f"footprint handler, pad : no correspondance found, using default " 

195 f"SHAPE_OVAL for pad {pad_number}" 

196 ) 

197 shape = Pad.SHAPE_OVAL 

198 

199 # update footprint borders 

200 footprint_info.max_X = max(footprint_info.max_X, at[0]) 

201 footprint_info.min_X = min(footprint_info.min_X, at[0]) 

202 footprint_info.max_Y = max(footprint_info.max_Y, at[1]) 

203 footprint_info.min_Y = min(footprint_info.min_Y, at[1]) 

204 

205 kicad_mod.append( 

206 Pad( 

207 number=pad_number, 

208 type=pad_type, 

209 shape=shape, 

210 at=at, 

211 size=size, 

212 rotation=rotation, 

213 drill=drill_size, 

214 layers=pad_layer, 

215 primitives=primitives, 

216 ) 

217 ) 

218 

219 

220def h_ARC(data, kicad_mod, footprint_info): 

221 """ 

222 append an Arc to the footprint 

223 data : [ 

224 0 : width 

225 1 : layer 

226 2 : 

227 3 : nodes 

228 4 : 

229 5 : id 

230 ] 

231 """ 

232 

233 width = data[0] 

234 layer = layer_correspondance[data[1]] 

235 svg_path = data[3] 

236 

237 # Parse SVG path 

238 pattern = ( 

239 r"M\s*([-\d.]+)[\s,]+([-\d.]+)\s*A\s*([-\d.]+)[\s,]+" 

240 r"([-\d.]+)[\s,]+([-\d.]+)[\s,]+(\d)[\s,]+(\d)[\s,]+([-\d.]+)[\s,]+([-\d.]+)" 

241 ) 

242 

243 match = re.search(pattern, svg_path) 

244 

245 if not match: 

246 logging.error("footprint handler, h_ARC: failed to parse ARC") 

247 return 

248 

249 # Extract values 

250 start_x, start_y = float(match.group(1)), float(match.group(2)) 

251 rx, ry = float(match.group(3)), float(match.group(4)) 

252 _ = float(match.group(5)) # rotation ? 

253 large_arc_flag = int(match.group(6)) 

254 sweep_flag = int(match.group(7)) 

255 end_x, end_y = float(match.group(8)), float(match.group(9)) 

256 

257 width = mil2mm(width) 

258 start_x = mil2mm(start_x) 

259 start_y = mil2mm(start_y) 

260 radius_x = mil2mm(rx) 

261 radius_y = mil2mm(ry) 

262 end_x = mil2mm(end_x) 

263 end_y = mil2mm(end_y) 

264 

265 start = [start_x, start_y] 

266 end = [end_x, end_y] 

267 

268 # Check if this is a full circle (start == end) 

269 if abs(start_x - end_x) < 1e-6 and abs(start_y - end_y) < 1e-6: 

270 # Full circle: center is offset from start by radius 

271 # Direction depends on sweep_flag 

272 radius = radius_x # Assuming circular arc (rx == ry) 

273 # For sweep_flag=1 (clockwise in SVG), center is to the right 

274 # For sweep_flag=0 (counter-clockwise), center is to the left 

275 if sweep_flag == 1: 

276 center = [start_x + radius, start_y] 

277 else: 

278 center = [start_x - radius, start_y] 

279 kicad_mod.append(Circle(center=center, radius=radius, width=width, layer=layer)) 

280 return 

281 

282 if sweep_flag == 0: 

283 start, end = end, start 

284 

285 # find the midpoint of start and end 

286 mid = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2] 

287 # create vector from start to mid: 

288 vec1 = Vector2D(mid[0] - start[0], mid[1] - start[1]) 

289 

290 # create vector that's normal to vec1: 

291 length_squared = radius_x * radius_y - pow(vec1.distance_to((0, 0)), 2) 

292 if length_squared < 0: 

293 length_squared = 0 

294 large_arc_flag = 1 

295 

296 vec2 = vec1.rotate(-90) if large_arc_flag == 1 else vec1.rotate(90) 

297 

298 magnitude = sqrt(vec2[0] ** 2 + vec2[1] ** 2) 

299 vec2 = Vector2D(vec2[0] / magnitude, vec2[1] / magnitude) 

300 

301 # calculate the length from mid to centre using pythagoras: 

302 length = sqrt(length_squared) 

303 # calculate the centre using mid and vec2 with the correct length: 

304 cen = Vector2D(mid) + vec2 * length 

305 

306 kicad_mod.append(Arc(start=start, end=end, width=width, center=cen, layer=layer)) 

307 

308 

309def h_CIRCLE(data, kicad_mod, footprint_info): 

310 # append a Circle to the footprint 

311 

312 if ( 

313 data[4] == "100" 

314 ): # they want to draw a circle on pads, we don't want that. This is an empirical 

315 # deduction, no idea if this is correct, but it seems to work on my tests 

316 return () 

317 

318 data[0] = mil2mm(data[0]) 

319 data[1] = mil2mm(data[1]) 

320 data[2] = mil2mm(data[2]) 

321 data[3] = mil2mm(data[3]) 

322 

323 center = [data[0], data[1]] 

324 radius = data[2] 

325 width = data[3] 

326 

327 try: 

328 layer = layer_correspondance[data[4]] 

329 except KeyError: 

330 logging.exception( 

331 "footprint handler, h_CIRCLE : layer correspondance not found" 

332 ) 

333 layer = "F.SilkS" 

334 

335 kicad_mod.append(Circle(center=center, radius=radius, width=width, layer=layer)) 

336 

337 

338def svg_arc_to_points(x1, y1, rx, ry, rotation, large_arc_flag, sweep_flag, x2, y2): 

339 """ 

340 Convert SVG arc to list of points using center parameterization. 

341 Uses SVG arc implementation algorithm from W3C spec F.6.5. 

342 

343 Args: 

344 x1, y1: Start point 

345 rx, ry: Ellipse radii 

346 rotation: X-axis rotation in degrees 

347 large_arc_flag: 0 or 1 

348 sweep_flag: 0 or 1 

349 x2, y2: End point 

350 

351 Returns: 

352 List of (x, y) tuples representing points along the arc 

353 """ 

354 # Handle degenerate cases 

355 if x1 == x2 and y1 == y2: 

356 return [] 

357 if rx == 0 or ry == 0: 

358 return [(x2, y2)] 

359 

360 rx = abs(rx) 

361 ry = abs(ry) 

362 

363 cos_rot = cos(radians(rotation)) 

364 sin_rot = sin(radians(rotation)) 

365 

366 # Compute (x1', y1') - rotated coordinates 

367 dx = (x1 - x2) / 2 

368 dy = (y1 - y2) / 2 

369 x1_prime = cos_rot * dx + sin_rot * dy 

370 y1_prime = -sin_rot * dx + cos_rot * dy 

371 

372 # Compute center (cx', cy') 

373 rx_sq = rx * rx 

374 ry_sq = ry * ry 

375 x1_prime_sq = x1_prime * x1_prime 

376 y1_prime_sq = y1_prime * y1_prime 

377 

378 # Correct radii if needed (ensure arc is possible) 

379 lambda_sq = x1_prime_sq / rx_sq + y1_prime_sq / ry_sq 

380 if lambda_sq > 1: 

381 scale = sqrt(lambda_sq) 

382 rx *= scale 

383 ry *= scale 

384 rx_sq = rx * rx 

385 ry_sq = ry * ry 

386 

387 # Calculate center 

388 denom = rx_sq * y1_prime_sq + ry_sq * x1_prime_sq 

389 if denom == 0: 

390 return [(x2, y2)] 

391 

392 sign = -1 if large_arc_flag == sweep_flag else 1 

393 sq = max(0, (rx_sq * ry_sq - rx_sq * y1_prime_sq - ry_sq * x1_prime_sq) / denom) 

394 coef = sign * sqrt(sq) 

395 

396 cx_prime = coef * rx * y1_prime / ry 

397 cy_prime = -coef * ry * x1_prime / rx 

398 

399 # Compute center (cx, cy) in original coordinates 

400 cx = cos_rot * cx_prime - sin_rot * cy_prime + (x1 + x2) / 2 

401 cy = sin_rot * cx_prime + cos_rot * cy_prime + (y1 + y2) / 2 

402 

403 # Calculate start angle and delta angle 

404 def angle_between(ux, uy, vx, vy): 

405 n = sqrt(ux * ux + uy * uy) * sqrt(vx * vx + vy * vy) 

406 if n == 0: 

407 return 0 

408 c = (ux * vx + uy * vy) / n 

409 c = max(-1, min(1, c)) 

410 angle = acos(c) 

411 if ux * vy - uy * vx < 0: 

412 angle = -angle 

413 return angle 

414 

415 theta1 = angle_between(1, 0, (x1_prime - cx_prime) / rx, (y1_prime - cy_prime) / ry) 

416 dtheta = angle_between( 

417 (x1_prime - cx_prime) / rx, 

418 (y1_prime - cy_prime) / ry, 

419 (-x1_prime - cx_prime) / rx, 

420 (-y1_prime - cy_prime) / ry, 

421 ) 

422 

423 # Adjust delta angle based on sweep flag 

424 if sweep_flag == 0 and dtheta > 0: 

425 dtheta -= 2 * pi 

426 elif sweep_flag == 1 and dtheta < 0: 

427 dtheta += 2 * pi 

428 

429 # Generate points along the arc (adaptive resolution) 

430 num_segments = max(8, int(abs(dtheta) / (2 * pi) * 32)) 

431 

432 points = [] 

433 for i in range(1, num_segments + 1): # Skip first point (it's the current position) 

434 angle = theta1 + dtheta * i / num_segments 

435 x = cx + rx * cos(angle) * cos_rot - ry * sin(angle) * sin_rot 

436 y = cy + rx * cos(angle) * sin_rot + ry * sin(angle) * cos_rot 

437 points.append((x, y)) 

438 

439 return points 

440 

441 

442def h_SOLIDREGION(data, kicad_mod, footprint_info): 

443 layer = "Edge.Cuts" if data[3] == "npth" else layer_correspondance[data[0]] 

444 

445 path = data[2] 

446 points = [] 

447 current_pos = (0.0, 0.0) 

448 

449 # Parse SVG path 

450 command_pattern = re.compile( 

451 r"([MLAZ])\s*" 

452 r"((?:[-+]?\d*\.?\d+[\s,]*)*)", 

453 re.IGNORECASE, 

454 ) 

455 

456 # Pattern to extract numbers 

457 number_pattern = re.compile(r"[-+]?\d*\.?\d+") 

458 

459 for match in command_pattern.finditer(path): 

460 cmd = match.group(1).upper() 

461 params_str = match.group(2) 

462 params = [float(n) for n in number_pattern.findall(params_str)] 

463 

464 if cmd == "M": 

465 # Move to: M x y 

466 if len(params) >= 2: 

467 current_pos = (params[0], params[1]) 

468 points.append(current_pos) 

469 

470 elif cmd == "L": 

471 # Line to: L x y 

472 if len(params) >= 2: 

473 current_pos = (params[0], params[1]) 

474 points.append(current_pos) 

475 

476 elif cmd == "A": 

477 # Arc: A rx ry rotation large-arc-flag sweep-flag x y 

478 if len(params) >= 7: 

479 rx = params[0] 

480 ry = params[1] 

481 rotation = params[2] 

482 large_arc_flag = int(params[3]) 

483 sweep_flag = int(params[4]) 

484 end_x = params[5] 

485 end_y = params[6] 

486 

487 arc_points = svg_arc_to_points( 

488 current_pos[0], 

489 current_pos[1], 

490 rx, 

491 ry, 

492 rotation, 

493 large_arc_flag, 

494 sweep_flag, 

495 end_x, 

496 end_y, 

497 ) 

498 points.extend(arc_points) 

499 current_pos = (end_x, end_y) 

500 

501 elif cmd == "Z": 

502 # Close path - no action needed, polygon will close automatically 

503 pass 

504 

505 # Convert from mils to mm 

506 points = [(mil2mm(p[0]), mil2mm(p[1])) for p in points] 

507 

508 if points: 

509 kicad_mod.append(Polygon(nodes=points, layer=layer)) 

510 

511 

512def h_SVGNODE(data, kicad_mod, footprint_info): 

513 # create 3D model as a WRL file 

514 # parse json data 

515 try: 

516 data = json.loads(data[0]) 

517 except Exception: 

518 logging.exception("footprint handler, h_SVGNODE : failed to parse json data") 

519 return () 

520 

521 c_origin = data["attrs"]["c_origin"].split(",") 

522 if "STEP" in footprint_info.models: 

523 get_StepModel( 

524 component_uuid=data["attrs"]["uuid"], 

525 footprint_info=footprint_info, 

526 kicad_mod=kicad_mod, 

527 translationX=float(c_origin[0]), 

528 translationY=float(c_origin[1]), 

529 translationZ=data["attrs"]["z"], 

530 rotation=data["attrs"]["c_rotation"], 

531 ) 

532 

533 if "WRL" in footprint_info.models: 

534 get_WrlModel( 

535 component_uuid=data["attrs"]["uuid"], 

536 footprint_info=footprint_info, 

537 kicad_mod=kicad_mod, 

538 translationX=float(c_origin[0]), 

539 translationY=float(c_origin[1]), 

540 translationZ=data["attrs"]["z"], 

541 rotation=data["attrs"]["c_rotation"], 

542 ) 

543 

544 

545def h_VIA(data, kicad_mod, footprint_info): 

546 logging.warning( 

547 "VIA not supported. Via are often added for better heat dissipation. " 

548 "Be careful and read datasheet if needed." 

549 ) 

550 

551 

552def h_RECT(data, kicad_mod, footprint_info): 

553 Xstart = float(mil2mm(data[0])) 

554 Ystart = float(mil2mm(data[1])) 

555 Xdelta = float(mil2mm(data[2])) 

556 Ydelta = float(mil2mm(data[3])) 

557 start = [Xstart, Ystart] 

558 end = [Xstart + Xdelta, Ystart + Ydelta] 

559 width = mil2mm(data[7]) 

560 

561 if width == 0: 

562 # filled: 

563 kicad_mod.append( 

564 RectFill( 

565 start=start, 

566 end=end, 

567 layer=layer_correspondance[data[4]], 

568 ) 

569 ) 

570 else: 

571 # not filled: 

572 kicad_mod.append( 

573 RectLine( 

574 start=start, 

575 end=end, 

576 width=width, 

577 layer=layer_correspondance[data[4]], 

578 ) 

579 ) 

580 

581 

582def h_HOLE(data, kicad_mod, footprint_info): 

583 kicad_mod.append( 

584 Pad( 

585 number="", 

586 type=Pad.TYPE_NPTH, 

587 shape=Pad.SHAPE_CIRCLE, 

588 at=[mil2mm(data[0]), mil2mm(data[1])], 

589 size=mil2mm(data[2]) * 2, 

590 rotation=0, 

591 drill=mil2mm(data[2]) * 2, 

592 layers=Pad.LAYERS_NPTH, 

593 ) 

594 ) 

595 

596 

597def h_TEXT(data, kicad_mod, footprint_info): 

598 kicad_mod.append( 

599 Text( 

600 type="user", 

601 text=data[9], 

602 at=[mil2mm(data[1]), mil2mm(data[2])], 

603 layer="F.SilkS", 

604 ) 

605 ) 

606 

607 

608handlers = { 

609 "TRACK": h_TRACK, 

610 "PAD": h_PAD, 

611 "ARC": h_ARC, 

612 "CIRCLE": h_CIRCLE, 

613 "SOLIDREGION": h_SOLIDREGION, 

614 "SVGNODE": h_SVGNODE, 

615 "VIA": h_VIA, 

616 "RECT": h_RECT, 

617 "HOLE": h_HOLE, 

618 "TEXT": h_TEXT, 

619}