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
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
1from collections import defaultdict
3import numpy as np
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
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()}
39# a string which we will replace spaces with temporarily
40_SAFESPACE = "|<^>|"
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
54def load_dxf(file_obj, **kwargs):
55 """
56 Load a DXF file to a dictionary containing vertices and
57 entities.
59 Parameters
60 ----------
61 file_obj: file or file- like object (has object.read method)
63 Returns
64 ----------
65 result: dict, keys are entities, vertices and metadata
66 """
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")
85 # remove trailing whitespace
86 raw = str(raw).strip()
87 # without any spaces and in upper case
88 cleaned = raw.replace(" ", "").strip().upper()
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))
95 # get the section which contains the header in the DXF file
96 endsec = np.nonzero(blob[:, 1] == "ENDSEC")[0]
98 # store metadata
99 metadata = {}
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]
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
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!")
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)]
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)]
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]
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)
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 )
178 # return result as kwargs for trimesh.path.Path2D constructor
179 result = {"vertices": vertices, "entities": entities, "metadata": metadata}
181 return result
184def convert_entities(blob, blob_raw=None, blocks=None, return_name=False):
185 """
186 Convert a chunk of entities into trimesh entities.
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
199 Returns
200 ----------
201 """
203 if blob_raw is None:
204 blob_raw = blob
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
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 )
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)
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)
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)
267 # save entity info so we don't have to recompute
268 polyinfo = info(e)
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]))
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
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)
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
318 points = np.column_stack((e["10"], e["20"])).astype(np.float64)
319 knots = np.array(e["40"]).astype(np.float64)
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
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
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)
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)
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
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)
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 )
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 }
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]
443 unsupported = defaultdict(lambda: 0)
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]
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
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)
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]))
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
541 return vertices, entities
544def export_dxf(path, only_layers=None):
545 """
546 Export a 2D path object to a DXF file.
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
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")
563 def format_points(points, as_2D=False, increment=True):
564 """
565 Format points into DXF- style point string.
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
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]
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 )
605 return packed
607 def entity_info(entity):
608 """
609 Pull layer, color, and name information about an entity
611 Parameters
612 -----------
613 entity : entity object
614 Source entity to pull metadata
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
632 def convert_line(line, vertices):
633 """
634 Convert an entity to a discrete polyline
636 Parameters
637 -------------
638 line : entity
639 Entity which has 'e.discrete' method
640 vertices : (n, 2) float
641 Vertices in space
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 ""
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))
663 result = template["line"].format(**subs)
664 return result
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}"
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)
687 return result
689 def convert_bspline(spline, vertices):
690 # points formatted with group code
691 points = format_points(vertices[spline.points], increment=False)
693 # (n,) float knots, formatted with group code
694 knots = ("40\n{:.12g}\n" * len(spline.knots)).format(*spline.knots)[:-1]
696 # bit coded
697 flags = {"closed": 1, "periodic": 2, "rational": 4, "planar": 8, "linear": 16}
699 flag = flags["planar"]
700 if spline.closed:
701 flag = flag | flags["closed"]
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))
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)
724 return result
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
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)
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)
780 # join all entities into one string
781 entities_str = "\n".join(collected)
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())
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")
812 return blob
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.
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.
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
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)
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)
855 # filter out zero- bulged polylines
856 ok = np.abs(bulge) > 1e-5
857 bulge = bulge[ok]
858 bulge_idx = bulge_idx[ok]
860 # metadata to apply to new entities
861 if metadata is None:
862 metadata = {}
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
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)
879 # the vector connecting the two ends of the arc
880 vector = lines[tid[:, 0]] - lines[tid[:, 1]]
882 # the length of the connector segment
883 length = np.linalg.norm(vector, axis=1)
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])
893 # midpoint of each line
894 midpoint = lines[tid].mean(axis=1)
896 # calculate the signed radius of each arc segment
897 radius = (length / 2.0) / np.sin(angle / 2.0)
899 # offset magnitude to point on arc
900 offset = radius - np.cos(angle / 2) * radius
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))
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
913 check_angle = [arc_center(i).span for i in three]
914 assert np.allclose(np.abs(angle), np.abs(check_angle))
916 check_radii = [arc_center(i).radius for i in three]
917 assert np.allclose(check_radii, np.abs(radius))
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)
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())
939 # make sure vertices are clean numpy array
940 vertices = np.array(vertices, dtype=np.float64)
942 return vertices, entities
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
963# store the loaders we have available
964_dxf_loaders = {"dxf": load_dxf}