Coverage for trimesh/path/exchange/dxf.py: 94%

368 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 04:40 +0000

1from collections import defaultdict 

2 

3import numpy as np 

4 

5from ... import grouping, resources, util 

6from ... import transformations as tf 

7from ...constants import log 

8from ...constants import tol_path as tol 

9from ...util import multi_dict 

10from ..arc import to_threepoint 

11from ..entities import Arc, BSpline, Line, Text 

12 

13# unit codes 

14_DXF_UNITS = { 

15 1: "inches", 

16 2: "feet", 

17 3: "miles", 

18 4: "millimeters", 

19 5: "centimeters", 

20 6: "meters", 

21 7: "kilometers", 

22 8: "microinches", 

23 9: "mils", 

24 10: "yards", 

25 11: "angstroms", 

26 12: "nanometers", 

27 13: "microns", 

28 14: "decimeters", 

29 15: "decameters", 

30 16: "hectometers", 

31 17: "gigameters", 

32 18: "AU", 

33 19: "light years", 

34 20: "parsecs", 

35} 

36# backwards, for reference 

37_UNITS_TO_DXF = {v: k for k, v in _DXF_UNITS.items()} 

38 

39# a string which we will replace spaces with temporarily 

40_SAFESPACE = "|<^>|" 

41 

42# save metadata to a DXF Xrecord starting here 

43# Valid values are 1-369 (except 5 and 105) 

44XRECORD_METADATA = 134 

45# the sentinel string for trimesh metadata 

46# this should be seen at XRECORD_METADATA 

47XRECORD_SENTINEL = "TRIMESH_METADATA:" 

48# the maximum line length before we split lines 

49XRECORD_MAX_LINE = 200 

50# the maximum index of XRECORDS 

51XRECORD_MAX_INDEX = 368 

52 

53 

54def load_dxf(file_obj, **kwargs): 

55 """ 

56 Load a DXF file to a dictionary containing vertices and 

57 entities. 

58 

59 Parameters 

60 ---------- 

61 file_obj: file or file- like object (has object.read method) 

62 

63 Returns 

64 ---------- 

65 result: dict, keys are entities, vertices and metadata 

66 """ 

67 

68 # in a DXF file, lines come in pairs, 

69 # a group code then the next line is the value 

70 # we are removing all whitespace then splitting with the 

71 # splitlines function which uses the universal newline method 

72 raw = file_obj.read() 

73 # if we've been passed bytes 

74 if hasattr(raw, "decode"): 

75 # search for the sentinel string indicating binary DXF 

76 # do it by encoding sentinel to bytes and subset searching 

77 if raw[:22].find(b"AutoCAD Binary DXF") != -1: 

78 # no converter to ASCII DXF available 

79 raise NotImplementedError("Binary DXF is not supported!") 

80 else: 

81 # we've been passed bytes that don't have the 

82 # header for binary DXF so try decoding as UTF-8 

83 raw = raw.decode("utf-8", errors="ignore") 

84 

85 # remove trailing whitespace 

86 raw = str(raw).strip() 

87 # without any spaces and in upper case 

88 cleaned = raw.replace(" ", "").strip().upper() 

89 

90 # blob with spaces and original case 

91 blob_raw = np.array(str.splitlines(raw)).reshape((-1, 2)) 

92 # if this reshape fails, it means the DXF is malformed 

93 blob = np.array(str.splitlines(cleaned)).reshape((-1, 2)) 

94 

95 # get the section which contains the header in the DXF file 

96 endsec = np.nonzero(blob[:, 1] == "ENDSEC")[0] 

97 

98 # store metadata 

99 metadata = {} 

100 

101 # try reading the header, which may be malformed 

102 header_start = np.nonzero(blob[:, 1] == "HEADER")[0] 

103 if len(header_start) > 0: 

104 header_end = endsec[np.searchsorted(endsec, header_start[0])] 

105 header_blob = blob[header_start[0] : header_end] 

106 

107 # store some properties from the DXF header 

108 metadata["DXF_HEADER"] = {} 

109 for key, group in [ 

110 ("$ACADVER", "1"), 

111 ("$DIMSCALE", "40"), 

112 ("$DIMALT", "70"), 

113 ("$DIMALTF", "40"), 

114 ("$DIMUNIT", "70"), 

115 ("$INSUNITS", "70"), 

116 ("$LUNITS", "70"), 

117 ]: 

118 value = get_key(header_blob, key, group) 

119 if value is not None: 

120 metadata["DXF_HEADER"][key] = value 

121 

122 # store unit data pulled from the header of the DXF 

123 # prefer LUNITS over INSUNITS 

124 # I couldn't find a table for LUNITS values but they 

125 # look like they are 0- indexed versions of 

126 # the INSUNITS keys, so for now offset the key value 

127 for offset, key in [(-1, "$LUNITS"), (0, "$INSUNITS")]: 

128 # get the key from the header blob 

129 units = get_key(header_blob, key, "70") 

130 # if it exists add the offset 

131 if units is None: 

132 continue 

133 metadata[key] = units 

134 units += offset 

135 # if the key is in our list of units store it 

136 if units in _DXF_UNITS: 

137 metadata["units"] = _DXF_UNITS[units] 

138 # warn on drawings with no units 

139 if "units" not in metadata: 

140 log.debug("DXF doesn't have units specified!") 

141 

142 # get the section which contains entities in the DXF file 

143 entity_start = np.nonzero(blob[:, 1] == "ENTITIES")[0][0] 

144 entity_end = endsec[np.searchsorted(endsec, entity_start)] 

145 

146 blocks = None 

147 check_entity = blob[entity_start:entity_end][:, 1] 

148 # only load blocks if an entity references them via an INSERT 

149 if "INSERT" in check_entity or "BLOCK" in check_entity: 

150 try: 

151 # which part of the raw file contains blocks 

152 block_start = np.nonzero(blob[:, 1] == "BLOCKS")[0][0] 

153 block_end = endsec[np.searchsorted(endsec, block_start)] 

154 

155 blob_block = blob[block_start:block_end] 

156 blob_block_raw = blob_raw[block_start:block_end] 

157 block_infl = np.nonzero((blob_block == ["0", "BLOCK"]).all(axis=1))[0] 

158 

159 # collect blocks by name 

160 blocks = {} 

161 for index in np.array_split(np.arange(len(blob_block)), block_infl): 

162 try: 

163 v, e, name = convert_entities( 

164 blob_block[index], blob_block_raw[index], return_name=True 

165 ) 

166 if len(e) > 0: 

167 blocks[name] = (v, e) 

168 except BaseException: 

169 pass 

170 except BaseException: 

171 log.error("failed to parse blocks!", exc_info=True) 

172 

173 # actually load referenced entities 

174 vertices, entities = convert_entities( 

175 blob[entity_start:entity_end], blob_raw[entity_start:entity_end], blocks=blocks 

176 ) 

177 

178 # return result as kwargs for trimesh.path.Path2D constructor 

179 result = {"vertices": vertices, "entities": entities, "metadata": metadata} 

180 

181 return result 

182 

183 

184def convert_entities(blob, blob_raw=None, blocks=None, return_name=False): 

185 """ 

186 Convert a chunk of entities into trimesh entities. 

187 

188 Parameters 

189 ------------ 

190 blob : (n, 2) str 

191 Blob of entities uppercased 

192 blob_raw : (n, 2) str 

193 Blob of entities not uppercased 

194 blocks : None or dict 

195 Blocks referenced by INSERT entities 

196 return_name : bool 

197 If True return the first '2' value 

198 

199 Returns 

200 ---------- 

201 """ 

202 

203 if blob_raw is None: 

204 blob_raw = blob 

205 

206 def info(e): 

207 """ 

208 Pull metadata based on group code, and return as a dict. 

209 """ 

210 # which keys should we extract from the entity data 

211 # DXF group code : our metadata key 

212 get = {"8": "layer", "2": "name"} 

213 # replace group codes with names and only 

214 # take info from the entity dict if it is in cand 

215 renamed = {get[k]: util.make_sequence(v)[0] for k, v in e.items() if k in get} 

216 return renamed 

217 

218 def convert_line(e): 

219 """ 

220 Convert DXF LINE entities into trimesh Line entities. 

221 """ 

222 # create a single Line entity 

223 entities.append(Line(points=len(vertices) + np.arange(2), **info(e))) 

224 # add the vertices to our collection 

225 vertices.extend( 

226 np.array([[e["10"], e["20"]], [e["11"], e["21"]]], dtype=np.float64) 

227 ) 

228 

229 def convert_circle(e): 

230 """ 

231 Convert DXF CIRCLE entities into trimesh Circle entities 

232 """ 

233 R = float(e["40"]) 

234 C = np.array([e["10"], e["20"]]).astype(np.float64) 

235 points = to_threepoint(center=C[:2], radius=R) 

236 entities.append( 

237 Arc(points=(len(vertices) + np.arange(3)), closed=True, **info(e)) 

238 ) 

239 vertices.extend(points) 

240 

241 def convert_arc(e): 

242 """ 

243 Convert DXF ARC entities into into trimesh Arc entities. 

244 """ 

245 # the radius of the circle 

246 R = float(e["40"]) 

247 # the center point of the circle 

248 C = np.array([e["10"], e["20"]], dtype=np.float64) 

249 # the start and end angle of the arc, in degrees 

250 # this may depend on an AUNITS header data 

251 A = np.radians(np.array([e["50"], e["51"]], dtype=np.float64)) 

252 # convert center/radius/angle representation 

253 # to three points on the arc representation 

254 points = to_threepoint(center=C[:2], radius=R, angles=A) 

255 # add a single Arc entity 

256 entities.append(Arc(points=len(vertices) + np.arange(3), closed=False, **info(e))) 

257 # add the three vertices 

258 vertices.extend(points) 

259 

260 def convert_polyline(e): 

261 """ 

262 Convert DXF LWPOLYLINE entities into trimesh Line entities. 

263 """ 

264 # load the points in the line 

265 lines = np.column_stack((e["10"], e["20"])).astype(np.float64) 

266 

267 # save entity info so we don't have to recompute 

268 polyinfo = info(e) 

269 

270 # 70 is the closed flag for polylines 

271 # if the closed flag is set make sure to close 

272 is_closed = "70" in e and int(e["70"][0]) & 1 

273 if is_closed: 

274 lines = np.vstack((lines, lines[:1])) 

275 

276 # 42 is the vertex bulge flag for LWPOLYLINE entities 

277 # "bulge" is autocad for "add a stupid arc using flags 

278 # in my otherwise normal polygon", it's like SVG arc 

279 # flags but somehow even more annoying 

280 if "42" in e: 

281 # get the actual bulge float values 

282 bulge = np.array(e["42"], dtype=np.float64) 

283 # what position were vertices stored at 

284 vid = np.nonzero(chunk[:, 0] == "10")[0] 

285 # what position were bulges stored at in the chunk 

286 bid = np.nonzero(chunk[:, 0] == "42")[0] 

287 # filter out endpoint bulge if we're not closed 

288 if not is_closed: 

289 bid_ok = bid < vid.max() 

290 bid = bid[bid_ok] 

291 bulge = bulge[bid_ok] 

292 # which vertex index is bulge value associated with 

293 bulge_idx = np.searchsorted(vid, bid) 

294 # convert stupid bulge to Line/Arc entities 

295 v, e = bulge_to_arcs( 

296 lines=lines, bulge=bulge, bulge_idx=bulge_idx, is_closed=is_closed 

297 ) 

298 for i in e: 

299 # offset added entities by current vertices length 

300 i.points += len(vertices) 

301 vertices.extend(v) 

302 entities.extend(e) 

303 # done with this polyline 

304 return 

305 

306 # we have a normal polyline so just add it 

307 # as single line entity and vertices 

308 entities.append(Line(points=np.arange(len(lines)) + len(vertices), **polyinfo)) 

309 vertices.extend(lines) 

310 

311 def convert_bspline(e): 

312 """ 

313 Convert DXF Spline entities into trimesh BSpline entities. 

314 """ 

315 # in the DXF there are n points and n ordered fields 

316 # with the same group code 

317 

318 points = np.column_stack((e["10"], e["20"])).astype(np.float64) 

319 knots = np.array(e["40"]).astype(np.float64) 

320 

321 # if there are only two points, save it as a line 

322 if len(points) == 2: 

323 # create a single Line entity 

324 entities.append(Line(points=len(vertices) + np.arange(2), **info(e))) 

325 # add the vertices to our collection 

326 vertices.extend(points) 

327 return 

328 

329 # check bit coded flag for closed 

330 # closed = bool(int(e['70'][0]) & 1) 

331 # check euclidean distance to see if closed 

332 closed = np.linalg.norm(points[0] - points[-1]) < tol.merge 

333 

334 # create a BSpline entity 

335 entities.append( 

336 BSpline( 

337 points=np.arange(len(points)) + len(vertices), 

338 knots=knots, 

339 closed=closed, 

340 **info(e), 

341 ) 

342 ) 

343 # add the vertices 

344 vertices.extend(points) 

345 

346 def convert_text(e): 

347 """ 

348 Convert a DXF TEXT entity into a native text entity. 

349 """ 

350 # text with leading and trailing whitespace removed 

351 text = e["1"].strip() 

352 # try getting optional height of text 

353 try: 

354 height = float(e["40"]) 

355 except BaseException: 

356 height = None 

357 try: 

358 # rotation angle converted to radians 

359 angle = np.radians(float(e["50"])) 

360 except BaseException: 

361 # otherwise no rotation 

362 angle = 0.0 

363 # origin point 

364 origin = np.array([e["10"], e["20"]], dtype=np.float64) 

365 # an origin-relative point (so transforms work) 

366 vector = origin + [np.cos(angle), np.sin(angle)] 

367 # try to extract a (horizontal, vertical) text alignment 

368 align = ["center", "center"] 

369 try: 

370 align[0] = ["left", "center", "right"][int(e["72"])] 

371 except BaseException: 

372 pass 

373 # append the entity 

374 entities.append( 

375 Text( 

376 origin=len(vertices), 

377 vector=len(vertices) + 1, 

378 height=height, 

379 text=text, 

380 align=align, 

381 ) 

382 ) 

383 # append the text origin and direction 

384 vertices.append(origin) 

385 vertices.append(vector) 

386 

387 def convert_insert(e): 

388 """ 

389 Convert an INSERT entity, which inserts a named group of 

390 entities (i.e. a "BLOCK") at a specific location. 

391 """ 

392 if blocks is None: 

393 return 

394 

395 # name of block to insert 

396 name = e["2"] 

397 # if we haven't loaded the block skip 

398 if name not in blocks: 

399 return 

400 # angle to rotate the block by 

401 angle = float(e.get("50", 0.0)) 

402 # the insertion point of the block 

403 offset = np.array([e.get("10", 0.0), e.get("20", 0.0)], dtype=np.float64) 

404 # what to scale the block by 

405 scale = np.array([e.get("41", 1.0), e.get("42", 1.0)], dtype=np.float64) 

406 

407 # the current entities and vertices of the referenced block. 

408 cv, ce = blocks[name] 

409 for i in ce: 

410 # copy the referenced entity as it may be included multiple times 

411 entities.append(i.copy()) 

412 # offset its vertices to the current index 

413 entities[-1].points += len(vertices) 

414 # transform the block's vertices based on the entity settings 

415 vertices.extend( 

416 tf.transform_points( 

417 cv, tf.planar_matrix(offset=offset, theta=np.radians(angle), scale=scale) 

418 ) 

419 ) 

420 

421 # find the start points of entities 

422 # DXF object to trimesh object converters 

423 loaders = { 

424 "LINE": (dict, convert_line), 

425 "LWPOLYLINE": (multi_dict, convert_polyline), 

426 "ARC": (dict, convert_arc), 

427 "CIRCLE": (dict, convert_circle), 

428 "SPLINE": (multi_dict, convert_bspline), 

429 "INSERT": (dict, convert_insert), 

430 "BLOCK": (dict, convert_insert), 

431 } 

432 

433 # store loaded vertices 

434 vertices = [] 

435 # store loaded entities 

436 entities = [] 

437 # an old-style polyline entity strings its data across 

438 # multiple vertex entities like a real asshole 

439 polyline = None 

440 # chunks of entities are divided by group-code-0 

441 inflection = np.nonzero(blob[:, 0] == "0")[0] 

442 

443 unsupported = defaultdict(lambda: 0) 

444 

445 # loop through chunks of entity information 

446 for index in np.array_split(np.arange(len(blob)), inflection): 

447 # if there is only a header continue 

448 if len(index) < 1: 

449 continue 

450 # chunk will be an (n, 2) array of (group code, data) pairs 

451 chunk = blob[index] 

452 # the string representing entity type 

453 entity_type = chunk[0][1] 

454 

455 # if we are referencing a block or insert by name make 

456 # sure the name key is in the original case vs upper-case 

457 if entity_type in ("BLOCK", "INSERT"): 

458 try: 

459 index_name = next(i for i, v in enumerate(chunk) if v[0] == "2") 

460 chunk[index_name][1] = blob_raw[index][index_name][1] 

461 except StopIteration: 

462 pass 

463 

464 # special case old- style polyline entities 

465 if entity_type == "POLYLINE": 

466 polyline = [dict(chunk)] 

467 # if we are collecting vertex entities 

468 elif polyline is not None and entity_type == "VERTEX": 

469 polyline.append(dict(chunk)) 

470 # the end of a polyline 

471 elif polyline is not None and entity_type == "SEQEND": 

472 # pull the geometry information for the entity 

473 lines = np.array([[i["10"], i["20"]] for i in polyline[1:]], dtype=np.float64) 

474 

475 is_closed = False 

476 # check for a closed flag on the polyline 

477 if "70" in polyline[0]: 

478 # flag is bit- coded integer 

479 flag = int(polyline[0]["70"]) 

480 # first bit represents closed 

481 is_closed = bool(flag & 1) 

482 if is_closed: 

483 lines = np.vstack((lines, lines[:1])) 

484 

485 # get the index of each bulged vertices 

486 bulge_idx = np.array( 

487 [i for i, e in enumerate(polyline) if "42" in e], dtype=np.int64 

488 ) 

489 # get the actual bulge value 

490 bulge = np.array( 

491 [float(e["42"]) for i, e in enumerate(polyline) if "42" in e], 

492 dtype=np.float64, 

493 ) 

494 # convert bulge to new entities 

495 cv, ce = bulge_to_arcs( 

496 lines=lines, bulge=bulge, bulge_idx=bulge_idx, is_closed=is_closed 

497 ) 

498 for i in ce: 

499 # offset entities by existing vertices 

500 i.points += len(vertices) 

501 vertices.extend(cv) 

502 entities.extend(ce) 

503 # we no longer have an active polyline 

504 polyline = None 

505 elif entity_type == "TEXT": 

506 # text entities need spaces preserved so take 

507 # group codes from clean representation (0- column) 

508 # and data from the raw representation (1- column) 

509 chunk_raw = blob_raw[index] 

510 # if we didn't use clean group codes we wouldn't 

511 # be able to access them by key as whitespace 

512 # is random and crazy, like: ' 1 ' 

513 chunk_raw[:, 0] = blob[index][:, 0] 

514 try: 

515 convert_text(dict(chunk_raw)) 

516 except BaseException: 

517 log.debug("failed to load text entity!", exc_info=True) 

518 # if the entity contains all relevant data we can 

519 # cleanly load it from inside a single function 

520 elif entity_type in loaders: 

521 # the chunker converts an (n,2) list into a dict 

522 chunker, loader = loaders[entity_type] 

523 # convert data to dict 

524 entity_data = chunker(chunk) 

525 # append data to the lists we're collecting 

526 loader(entity_data) 

527 elif entity_type != "ENTITIES": 

528 unsupported[entity_type] += 1 

529 if len(unsupported) > 0: 

530 log.debug( 

531 "skipping dxf entities: {}".format( 

532 ", ".join(f"{k}: {v}" for k, v in unsupported.items()) 

533 ) 

534 ) 

535 # stack vertices into single array 

536 vertices = util.vstack_empty(vertices).astype(np.float64) 

537 if return_name: 

538 name = blob_raw[blob[:, 0] == "2"][0][1] 

539 return vertices, entities, name 

540 

541 return vertices, entities 

542 

543 

544def export_dxf(path, only_layers=None): 

545 """ 

546 Export a 2D path object to a DXF file. 

547 

548 Parameters 

549 ---------- 

550 path : trimesh.path.path.Path2D 

551 Input geometry to export 

552 only_layers : None or set 

553 If passed only export the layers specified 

554 

555 Returns 

556 ---------- 

557 export : str 

558 Path formatted as a DXF file 

559 """ 

560 # get the template for exporting DXF files 

561 template = resources.get_json("templates/dxf.json") 

562 

563 def format_points(points, as_2D=False, increment=True): 

564 """ 

565 Format points into DXF- style point string. 

566 

567 Parameters 

568 ----------- 

569 points : (n,2) or (n,3) float 

570 Points in space 

571 as_2D : bool 

572 If True only output 2 points per vertex 

573 increment : bool 

574 If True increment group code per point 

575 Example: 

576 [[X0, Y0, Z0], [X1, Y1, Z1]] 

577 Result, new lines replaced with spaces: 

578 True -> 10 X0 20 Y0 30 Z0 11 X1 21 Y1 31 Z1 

579 False -> 10 X0 20 Y0 30 Z0 10 X1 20 Y1 30 Z1 

580 

581 Returns 

582 ----------- 

583 packed : str 

584 Points formatted with group code 

585 """ 

586 points = np.asanyarray(points, dtype=np.float64) 

587 # get points in 3D 

588 three = util.stack_3D(points) 

589 if increment: 

590 group = np.tile( 

591 np.arange(len(three), dtype=np.int64).reshape((-1, 1)), (1, 3) 

592 ) 

593 else: 

594 group = np.zeros((len(three), 3), dtype=np.int64) 

595 group += [10, 20, 30] 

596 

597 if as_2D: 

598 group = group[:, :2] 

599 three = three[:, :2] 

600 # join into result string 

601 packed = "\n".join( 

602 f"{g:d}\n{v:.12g}" for g, v in zip(group.reshape(-1), three.reshape(-1)) 

603 ) 

604 

605 return packed 

606 

607 def entity_info(entity): 

608 """ 

609 Pull layer, color, and name information about an entity 

610 

611 Parameters 

612 ----------- 

613 entity : entity object 

614 Source entity to pull metadata 

615 

616 Returns 

617 ---------- 

618 subs : dict 

619 Has keys 'COLOR', 'LAYER', 'NAME' 

620 """ 

621 # TODO : convert RGBA entity.color to index 

622 subs = { 

623 "COLOR": 255, # default is ByLayer 

624 "LAYER": 0, 

625 "NAME": str(id(entity))[:16], 

626 } 

627 if hasattr(entity, "layer"): 

628 # make sure layer name is forced into ASCII 

629 subs["LAYER"] = util.to_ascii(entity.layer) 

630 return subs 

631 

632 def convert_line(line, vertices): 

633 """ 

634 Convert an entity to a discrete polyline 

635 

636 Parameters 

637 ------------- 

638 line : entity 

639 Entity which has 'e.discrete' method 

640 vertices : (n, 2) float 

641 Vertices in space 

642 

643 Returns 

644 ----------- 

645 as_dxf : str 

646 Entity exported as a DXF 

647 """ 

648 # get a discrete representation of entity 

649 points = line.discrete(vertices) 

650 # if one or fewer points return nothing 

651 if len(points) <= 1: 

652 return "" 

653 

654 # generate a substitution dictionary for template 

655 subs = entity_info(line) 

656 subs["POINTS"] = format_points(points, as_2D=True, increment=False) 

657 subs["TYPE"] = "LWPOLYLINE" 

658 subs["VCOUNT"] = len(points) 

659 # 1 is closed 

660 # 0 is default (open) 

661 subs["FLAG"] = int(bool(line.closed)) 

662 

663 result = template["line"].format(**subs) 

664 return result 

665 

666 def convert_arc(arc, vertices): 

667 # get the center of arc and include span angles 

668 info = arc.center(vertices, return_angle=True, return_normal=False) 

669 subs = entity_info(arc) 

670 center = info.center 

671 if len(center) == 2: 

672 center = np.append(center, 0.0) 

673 data = "10\n{:.12g}\n20\n{:.12g}\n30\n{:.12g}".format(*center) 

674 data += f"\n40\n{info.radius:.12g}" 

675 

676 if arc.closed: 

677 subs["TYPE"] = "CIRCLE" 

678 else: 

679 subs["TYPE"] = "ARC" 

680 # an arc is the same as a circle, with an added start 

681 # and end angle field 

682 data += "\n100\nAcDbArc" 

683 data += "\n50\n{:.12g}\n51\n{:.12g}".format(*np.degrees(info.angles)) 

684 subs["DATA"] = data 

685 result = template["arc"].format(**subs) 

686 

687 return result 

688 

689 def convert_bspline(spline, vertices): 

690 # points formatted with group code 

691 points = format_points(vertices[spline.points], increment=False) 

692 

693 # (n,) float knots, formatted with group code 

694 knots = ("40\n{:.12g}\n" * len(spline.knots)).format(*spline.knots)[:-1] 

695 

696 # bit coded 

697 flags = {"closed": 1, "periodic": 2, "rational": 4, "planar": 8, "linear": 16} 

698 

699 flag = flags["planar"] 

700 if spline.closed: 

701 flag = flag | flags["closed"] 

702 

703 normal = [0.0, 0.0, 1.0] 

704 n_code = [210, 220, 230] 

705 n_str = "\n".join(f"{i:d}\n{j:.12g}" for i, j in zip(n_code, normal)) 

706 

707 subs = entity_info(spline) 

708 subs.update( 

709 { 

710 "TYPE": "SPLINE", 

711 "POINTS": points, 

712 "KNOTS": knots, 

713 "NORMAL": n_str, 

714 "DEGREE": 3, 

715 "FLAG": flag, 

716 "FCOUNT": 0, 

717 "KCOUNT": len(spline.knots), 

718 "PCOUNT": len(spline.points), 

719 } 

720 ) 

721 # format into string template 

722 result = template["bspline"].format(**subs) 

723 

724 return result 

725 

726 def convert_text(txt, vertices): 

727 """ 

728 Convert a Text entity to DXF string. 

729 """ 

730 # start with layer info 

731 sub = entity_info(txt) 

732 # get the origin point of the text 

733 sub["ORIGIN"] = format_points(vertices[[txt.origin]], increment=False) 

734 # rotation angle in degrees 

735 sub["ANGLE"] = np.degrees(txt.angle(vertices)) 

736 # actual string of text with spaces escaped 

737 # force into ASCII to avoid weird encoding issues 

738 sub["TEXT"] = ( 

739 txt.text.replace(" ", _SAFESPACE) 

740 .encode("ascii", errors="ignore") 

741 .decode("ascii") 

742 ) 

743 # height of text 

744 sub["HEIGHT"] = txt.height 

745 result = template["text"].format(**sub) 

746 return result 

747 

748 def convert_generic(entity, vertices): 

749 """ 

750 For entities we don't know how to handle, return their 

751 discrete form as a polyline 

752 """ 

753 return convert_line(entity, vertices) 

754 

755 # make sure we're not losing a ton of 

756 # precision in the string conversion 

757 np.set_printoptions(precision=12) 

758 # trimesh entity to DXF entity converters 

759 conversions = { 

760 "Line": convert_line, 

761 "Text": convert_text, 

762 "Arc": convert_arc, 

763 "Bezier": convert_generic, 

764 "BSpline": convert_bspline, 

765 } 

766 collected = [] 

767 for e, layer in zip(path.entities, path.layers): 

768 name = type(e).__name__ 

769 # only export specified layers 

770 if only_layers is not None and layer not in only_layers: 

771 continue 

772 if name in conversions: 

773 converted = conversions[name](e, path.vertices).strip() 

774 if len(converted) > 0: 

775 # only save if we converted something 

776 collected.append(converted) 

777 else: 

778 log.debug("Entity type %s not exported!", name) 

779 

780 # join all entities into one string 

781 entities_str = "\n".join(collected) 

782 

783 # add in the extents of the document as explicit XYZ lines 

784 hsub = {f"EXTMIN_{k}": v for k, v in zip("XYZ", np.append(path.bounds[0], 0.0))} 

785 hsub.update({f"EXTMAX_{k}": v for k, v in zip("XYZ", np.append(path.bounds[1], 0.0))}) 

786 # apply a units flag defaulting to `1` 

787 hsub["LUNITS"] = _UNITS_TO_DXF.get(path.units, 1) 

788 # run the format for the header 

789 sections = [template["header"].format(**hsub).strip()] 

790 # do the same for entities 

791 sections.append(template["entities"].format(ENTITIES=entities_str).strip()) 

792 # and the footer 

793 sections.append(template["footer"].strip()) 

794 

795 # filter out empty sections 

796 # random whitespace causes AutoCAD to fail to load 

797 # although Draftsight, LibreCAD, and Inkscape don't care 

798 # what a giant legacy piece of shit 

799 # create the joined string blob 

800 blob = "\n".join(sections).replace(_SAFESPACE, " ") 

801 # run additional self- checks 

802 if tol.strict: 

803 # check that every line pair is (group code, value) 

804 lines = str.splitlines(str(blob)) 

805 # should be even number of lines 

806 assert (len(lines) % 2) == 0 

807 # group codes should all be convertible to int and positive 

808 assert all(int(i) >= 0 for i in lines[::2]) 

809 # make sure we didn't slip any unicode in there 

810 blob.encode("ascii") 

811 

812 return blob 

813 

814 

815def bulge_to_arcs(lines, bulge, bulge_idx, is_closed=False, metadata=None): 

816 """ 

817 Polylines can have "vertex bulge" which means the polyline 

818 has an arc tangent to segments, rather than meeting at a 

819 vertex. 

820 

821 From Autodesk reference: 

822 The bulge is the tangent of one fourth the included 

823 angle for an arc segment, made negative if the arc 

824 goes clockwise from the start point to the endpoint. 

825 A bulge of 0 indicates a straight segment, and a 

826 bulge of 1 is a semicircle. 

827 

828 Parameters 

829 ---------------- 

830 lines : (n, 2) float 

831 Polyline vertices in order 

832 bulge : (m,) float 

833 Vertex bulge value 

834 bulge_idx : (m,) float 

835 Which index of lines is bulge associated with 

836 is_closed : bool 

837 Is segment closed 

838 metadata : None, or dict 

839 Entity metadata to add 

840 

841 Returns 

842 --------------- 

843 vertices : (a, 2) float 

844 New vertices for poly-arc 

845 entities : (b,) entities.Entity 

846 New entities, either line or arc 

847 """ 

848 # make sure lines are 2D array 

849 lines = np.asanyarray(lines, dtype=np.float64) 

850 

851 # make sure inputs are numpy arrays 

852 bulge = np.asanyarray(bulge, dtype=np.float64) 

853 bulge_idx = np.asanyarray(bulge_idx, dtype=np.int64) 

854 

855 # filter out zero- bulged polylines 

856 ok = np.abs(bulge) > 1e-5 

857 bulge = bulge[ok] 

858 bulge_idx = bulge_idx[ok] 

859 

860 # metadata to apply to new entities 

861 if metadata is None: 

862 metadata = {} 

863 

864 # if there's no bulge, just return the input curve 

865 if len(bulge) == 0: 

866 index = np.arange(len(lines)) 

867 # add a single line entity and vertices 

868 entities = [Line(index, **metadata)] 

869 return lines, entities 

870 

871 # use bulge to calculate included angle of the arc 

872 angle = np.arctan(bulge) * 4.0 

873 # the indexes making up a bulged segment 

874 tid = np.column_stack((bulge_idx, bulge_idx - 1)) 

875 # if it's a closed segment modulus to start vertex 

876 if is_closed: 

877 tid %= len(lines) 

878 

879 # the vector connecting the two ends of the arc 

880 vector = lines[tid[:, 0]] - lines[tid[:, 1]] 

881 

882 # the length of the connector segment 

883 length = np.linalg.norm(vector, axis=1) 

884 

885 # perpendicular vectors by crossing vector with Z 

886 perp = np.cross( 

887 np.column_stack((vector, np.zeros(len(vector)))), 

888 np.ones((len(vector), 3)) * [0, 0, 1], 

889 ) 

890 # strip the zero Z 

891 perp = util.unitize(perp[:, :2]) 

892 

893 # midpoint of each line 

894 midpoint = lines[tid].mean(axis=1) 

895 

896 # calculate the signed radius of each arc segment 

897 radius = (length / 2.0) / np.sin(angle / 2.0) 

898 

899 # offset magnitude to point on arc 

900 offset = radius - np.cos(angle / 2) * radius 

901 

902 # convert each arc to three points: 

903 # start, any point on arc, end 

904 three = np.column_stack( 

905 (lines[tid[:, 0]], midpoint + perp * offset.reshape((-1, 1)), lines[tid[:, 1]]) 

906 ).reshape((-1, 3, 2)) 

907 

908 # if we're in strict mode make sure our arcs 

909 # have the same magnitude as the input data 

910 if tol.strict: 

911 from ..arc import arc_center 

912 

913 check_angle = [arc_center(i).span for i in three] 

914 assert np.allclose(np.abs(angle), np.abs(check_angle)) 

915 

916 check_radii = [arc_center(i).radius for i in three] 

917 assert np.allclose(check_radii, np.abs(radius)) 

918 

919 # collect new entities and vertices 

920 entities, vertices = [], [] 

921 # add the entities for each new arc 

922 for arc_points in three: 

923 entities.append(Arc(points=np.arange(3) + len(vertices), **metadata)) 

924 vertices.extend(arc_points) 

925 

926 # if there are unconsumed line 

927 # segments add them to drawing 

928 if (len(lines) - 1) > len(bulge): 

929 # indexes of line segments 

930 existing = util.stack_lines(np.arange(len(lines))) 

931 # remove line segments replaced with arcs 

932 for line_idx in grouping.boolean_rows( 

933 existing, np.sort(tid, axis=1), np.setdiff1d 

934 ): 

935 # add a single line entity and vertices 

936 entities.append(Line(points=np.arange(2) + len(vertices), **metadata)) 

937 vertices.extend(lines[line_idx].copy()) 

938 

939 # make sure vertices are clean numpy array 

940 vertices = np.array(vertices, dtype=np.float64) 

941 

942 return vertices, entities 

943 

944 

945def get_key(blob, field, code): 

946 """ 

947 Given a loaded (n, 2) blob and a field name 

948 get a value by code. 

949 """ 

950 try: 

951 line = blob[np.nonzero(blob[:, 1] == field)[0][0] + 1] 

952 except IndexError: 

953 return None 

954 if line[0] == code: 

955 try: 

956 return int(line[1]) 

957 except ValueError: 

958 return line[1] 

959 else: 

960 return None 

961 

962 

963# store the loaders we have available 

964_dxf_loaders = {"dxf": load_dxf}