This commit is contained in:
cjw
2026-02-12 23:22:11 +08:00
parent 7b09eb3d89
commit 89660bba4e
5988 changed files with 2517516 additions and 0 deletions
@@ -0,0 +1,964 @@
from collections import defaultdict
import numpy as np
from ... import grouping, resources, util
from ... import transformations as tf
from ...constants import log
from ...constants import tol_path as tol
from ...util import multi_dict
from ..arc import to_threepoint
from ..entities import Arc, BSpline, Line, Text
# unit codes
_DXF_UNITS = {
1: "inches",
2: "feet",
3: "miles",
4: "millimeters",
5: "centimeters",
6: "meters",
7: "kilometers",
8: "microinches",
9: "mils",
10: "yards",
11: "angstroms",
12: "nanometers",
13: "microns",
14: "decimeters",
15: "decameters",
16: "hectometers",
17: "gigameters",
18: "AU",
19: "light years",
20: "parsecs",
}
# backwards, for reference
_UNITS_TO_DXF = {v: k for k, v in _DXF_UNITS.items()}
# a string which we will replace spaces with temporarily
_SAFESPACE = "|<^>|"
# save metadata to a DXF Xrecord starting here
# Valid values are 1-369 (except 5 and 105)
XRECORD_METADATA = 134
# the sentinel string for trimesh metadata
# this should be seen at XRECORD_METADATA
XRECORD_SENTINEL = "TRIMESH_METADATA:"
# the maximum line length before we split lines
XRECORD_MAX_LINE = 200
# the maximum index of XRECORDS
XRECORD_MAX_INDEX = 368
def load_dxf(file_obj, **kwargs):
"""
Load a DXF file to a dictionary containing vertices and
entities.
Parameters
----------
file_obj: file or file- like object (has object.read method)
Returns
----------
result: dict, keys are entities, vertices and metadata
"""
# in a DXF file, lines come in pairs,
# a group code then the next line is the value
# we are removing all whitespace then splitting with the
# splitlines function which uses the universal newline method
raw = file_obj.read()
# if we've been passed bytes
if hasattr(raw, "decode"):
# search for the sentinel string indicating binary DXF
# do it by encoding sentinel to bytes and subset searching
if raw[:22].find(b"AutoCAD Binary DXF") != -1:
# no converter to ASCII DXF available
raise NotImplementedError("Binary DXF is not supported!")
else:
# we've been passed bytes that don't have the
# header for binary DXF so try decoding as UTF-8
raw = raw.decode("utf-8", errors="ignore")
# remove trailing whitespace
raw = str(raw).strip()
# without any spaces and in upper case
cleaned = raw.replace(" ", "").strip().upper()
# blob with spaces and original case
blob_raw = np.array(str.splitlines(raw)).reshape((-1, 2))
# if this reshape fails, it means the DXF is malformed
blob = np.array(str.splitlines(cleaned)).reshape((-1, 2))
# get the section which contains the header in the DXF file
endsec = np.nonzero(blob[:, 1] == "ENDSEC")[0]
# store metadata
metadata = {}
# try reading the header, which may be malformed
header_start = np.nonzero(blob[:, 1] == "HEADER")[0]
if len(header_start) > 0:
header_end = endsec[np.searchsorted(endsec, header_start[0])]
header_blob = blob[header_start[0] : header_end]
# store some properties from the DXF header
metadata["DXF_HEADER"] = {}
for key, group in [
("$ACADVER", "1"),
("$DIMSCALE", "40"),
("$DIMALT", "70"),
("$DIMALTF", "40"),
("$DIMUNIT", "70"),
("$INSUNITS", "70"),
("$LUNITS", "70"),
]:
value = get_key(header_blob, key, group)
if value is not None:
metadata["DXF_HEADER"][key] = value
# store unit data pulled from the header of the DXF
# prefer LUNITS over INSUNITS
# I couldn't find a table for LUNITS values but they
# look like they are 0- indexed versions of
# the INSUNITS keys, so for now offset the key value
for offset, key in [(-1, "$LUNITS"), (0, "$INSUNITS")]:
# get the key from the header blob
units = get_key(header_blob, key, "70")
# if it exists add the offset
if units is None:
continue
metadata[key] = units
units += offset
# if the key is in our list of units store it
if units in _DXF_UNITS:
metadata["units"] = _DXF_UNITS[units]
# warn on drawings with no units
if "units" not in metadata:
log.debug("DXF doesn't have units specified!")
# get the section which contains entities in the DXF file
entity_start = np.nonzero(blob[:, 1] == "ENTITIES")[0][0]
entity_end = endsec[np.searchsorted(endsec, entity_start)]
blocks = None
check_entity = blob[entity_start:entity_end][:, 1]
# only load blocks if an entity references them via an INSERT
if "INSERT" in check_entity or "BLOCK" in check_entity:
try:
# which part of the raw file contains blocks
block_start = np.nonzero(blob[:, 1] == "BLOCKS")[0][0]
block_end = endsec[np.searchsorted(endsec, block_start)]
blob_block = blob[block_start:block_end]
blob_block_raw = blob_raw[block_start:block_end]
block_infl = np.nonzero((blob_block == ["0", "BLOCK"]).all(axis=1))[0]
# collect blocks by name
blocks = {}
for index in np.array_split(np.arange(len(blob_block)), block_infl):
try:
v, e, name = convert_entities(
blob_block[index], blob_block_raw[index], return_name=True
)
if len(e) > 0:
blocks[name] = (v, e)
except BaseException:
pass
except BaseException:
log.error("failed to parse blocks!", exc_info=True)
# actually load referenced entities
vertices, entities = convert_entities(
blob[entity_start:entity_end], blob_raw[entity_start:entity_end], blocks=blocks
)
# return result as kwargs for trimesh.path.Path2D constructor
result = {"vertices": vertices, "entities": entities, "metadata": metadata}
return result
def convert_entities(blob, blob_raw=None, blocks=None, return_name=False):
"""
Convert a chunk of entities into trimesh entities.
Parameters
------------
blob : (n, 2) str
Blob of entities uppercased
blob_raw : (n, 2) str
Blob of entities not uppercased
blocks : None or dict
Blocks referenced by INSERT entities
return_name : bool
If True return the first '2' value
Returns
----------
"""
if blob_raw is None:
blob_raw = blob
def info(e):
"""
Pull metadata based on group code, and return as a dict.
"""
# which keys should we extract from the entity data
# DXF group code : our metadata key
get = {"8": "layer", "2": "name"}
# replace group codes with names and only
# take info from the entity dict if it is in cand
renamed = {get[k]: util.make_sequence(v)[0] for k, v in e.items() if k in get}
return renamed
def convert_line(e):
"""
Convert DXF LINE entities into trimesh Line entities.
"""
# create a single Line entity
entities.append(Line(points=len(vertices) + np.arange(2), **info(e)))
# add the vertices to our collection
vertices.extend(
np.array([[e["10"], e["20"]], [e["11"], e["21"]]], dtype=np.float64)
)
def convert_circle(e):
"""
Convert DXF CIRCLE entities into trimesh Circle entities
"""
R = float(e["40"])
C = np.array([e["10"], e["20"]]).astype(np.float64)
points = to_threepoint(center=C[:2], radius=R)
entities.append(
Arc(points=(len(vertices) + np.arange(3)), closed=True, **info(e))
)
vertices.extend(points)
def convert_arc(e):
"""
Convert DXF ARC entities into into trimesh Arc entities.
"""
# the radius of the circle
R = float(e["40"])
# the center point of the circle
C = np.array([e["10"], e["20"]], dtype=np.float64)
# the start and end angle of the arc, in degrees
# this may depend on an AUNITS header data
A = np.radians(np.array([e["50"], e["51"]], dtype=np.float64))
# convert center/radius/angle representation
# to three points on the arc representation
points = to_threepoint(center=C[:2], radius=R, angles=A)
# add a single Arc entity
entities.append(Arc(points=len(vertices) + np.arange(3), closed=False, **info(e)))
# add the three vertices
vertices.extend(points)
def convert_polyline(e):
"""
Convert DXF LWPOLYLINE entities into trimesh Line entities.
"""
# load the points in the line
lines = np.column_stack((e["10"], e["20"])).astype(np.float64)
# save entity info so we don't have to recompute
polyinfo = info(e)
# 70 is the closed flag for polylines
# if the closed flag is set make sure to close
is_closed = "70" in e and int(e["70"][0]) & 1
if is_closed:
lines = np.vstack((lines, lines[:1]))
# 42 is the vertex bulge flag for LWPOLYLINE entities
# "bulge" is autocad for "add a stupid arc using flags
# in my otherwise normal polygon", it's like SVG arc
# flags but somehow even more annoying
if "42" in e:
# get the actual bulge float values
bulge = np.array(e["42"], dtype=np.float64)
# what position were vertices stored at
vid = np.nonzero(chunk[:, 0] == "10")[0]
# what position were bulges stored at in the chunk
bid = np.nonzero(chunk[:, 0] == "42")[0]
# filter out endpoint bulge if we're not closed
if not is_closed:
bid_ok = bid < vid.max()
bid = bid[bid_ok]
bulge = bulge[bid_ok]
# which vertex index is bulge value associated with
bulge_idx = np.searchsorted(vid, bid)
# convert stupid bulge to Line/Arc entities
v, e = bulge_to_arcs(
lines=lines, bulge=bulge, bulge_idx=bulge_idx, is_closed=is_closed
)
for i in e:
# offset added entities by current vertices length
i.points += len(vertices)
vertices.extend(v)
entities.extend(e)
# done with this polyline
return
# we have a normal polyline so just add it
# as single line entity and vertices
entities.append(Line(points=np.arange(len(lines)) + len(vertices), **polyinfo))
vertices.extend(lines)
def convert_bspline(e):
"""
Convert DXF Spline entities into trimesh BSpline entities.
"""
# in the DXF there are n points and n ordered fields
# with the same group code
points = np.column_stack((e["10"], e["20"])).astype(np.float64)
knots = np.array(e["40"]).astype(np.float64)
# if there are only two points, save it as a line
if len(points) == 2:
# create a single Line entity
entities.append(Line(points=len(vertices) + np.arange(2), **info(e)))
# add the vertices to our collection
vertices.extend(points)
return
# check bit coded flag for closed
# closed = bool(int(e['70'][0]) & 1)
# check euclidean distance to see if closed
closed = np.linalg.norm(points[0] - points[-1]) < tol.merge
# create a BSpline entity
entities.append(
BSpline(
points=np.arange(len(points)) + len(vertices),
knots=knots,
closed=closed,
**info(e),
)
)
# add the vertices
vertices.extend(points)
def convert_text(e):
"""
Convert a DXF TEXT entity into a native text entity.
"""
# text with leading and trailing whitespace removed
text = e["1"].strip()
# try getting optional height of text
try:
height = float(e["40"])
except BaseException:
height = None
try:
# rotation angle converted to radians
angle = np.radians(float(e["50"]))
except BaseException:
# otherwise no rotation
angle = 0.0
# origin point
origin = np.array([e["10"], e["20"]], dtype=np.float64)
# an origin-relative point (so transforms work)
vector = origin + [np.cos(angle), np.sin(angle)]
# try to extract a (horizontal, vertical) text alignment
align = ["center", "center"]
try:
align[0] = ["left", "center", "right"][int(e["72"])]
except BaseException:
pass
# append the entity
entities.append(
Text(
origin=len(vertices),
vector=len(vertices) + 1,
height=height,
text=text,
align=align,
)
)
# append the text origin and direction
vertices.append(origin)
vertices.append(vector)
def convert_insert(e):
"""
Convert an INSERT entity, which inserts a named group of
entities (i.e. a "BLOCK") at a specific location.
"""
if blocks is None:
return
# name of block to insert
name = e["2"]
# if we haven't loaded the block skip
if name not in blocks:
return
# angle to rotate the block by
angle = float(e.get("50", 0.0))
# the insertion point of the block
offset = np.array([e.get("10", 0.0), e.get("20", 0.0)], dtype=np.float64)
# what to scale the block by
scale = np.array([e.get("41", 1.0), e.get("42", 1.0)], dtype=np.float64)
# the current entities and vertices of the referenced block.
cv, ce = blocks[name]
for i in ce:
# copy the referenced entity as it may be included multiple times
entities.append(i.copy())
# offset its vertices to the current index
entities[-1].points += len(vertices)
# transform the block's vertices based on the entity settings
vertices.extend(
tf.transform_points(
cv, tf.planar_matrix(offset=offset, theta=np.radians(angle), scale=scale)
)
)
# find the start points of entities
# DXF object to trimesh object converters
loaders = {
"LINE": (dict, convert_line),
"LWPOLYLINE": (multi_dict, convert_polyline),
"ARC": (dict, convert_arc),
"CIRCLE": (dict, convert_circle),
"SPLINE": (multi_dict, convert_bspline),
"INSERT": (dict, convert_insert),
"BLOCK": (dict, convert_insert),
}
# store loaded vertices
vertices = []
# store loaded entities
entities = []
# an old-style polyline entity strings its data across
# multiple vertex entities like a real asshole
polyline = None
# chunks of entities are divided by group-code-0
inflection = np.nonzero(blob[:, 0] == "0")[0]
unsupported = defaultdict(lambda: 0)
# loop through chunks of entity information
for index in np.array_split(np.arange(len(blob)), inflection):
# if there is only a header continue
if len(index) < 1:
continue
# chunk will be an (n, 2) array of (group code, data) pairs
chunk = blob[index]
# the string representing entity type
entity_type = chunk[0][1]
# if we are referencing a block or insert by name make
# sure the name key is in the original case vs upper-case
if entity_type in ("BLOCK", "INSERT"):
try:
index_name = next(i for i, v in enumerate(chunk) if v[0] == "2")
chunk[index_name][1] = blob_raw[index][index_name][1]
except StopIteration:
pass
# special case old- style polyline entities
if entity_type == "POLYLINE":
polyline = [dict(chunk)]
# if we are collecting vertex entities
elif polyline is not None and entity_type == "VERTEX":
polyline.append(dict(chunk))
# the end of a polyline
elif polyline is not None and entity_type == "SEQEND":
# pull the geometry information for the entity
lines = np.array([[i["10"], i["20"]] for i in polyline[1:]], dtype=np.float64)
is_closed = False
# check for a closed flag on the polyline
if "70" in polyline[0]:
# flag is bit- coded integer
flag = int(polyline[0]["70"])
# first bit represents closed
is_closed = bool(flag & 1)
if is_closed:
lines = np.vstack((lines, lines[:1]))
# get the index of each bulged vertices
bulge_idx = np.array(
[i for i, e in enumerate(polyline) if "42" in e], dtype=np.int64
)
# get the actual bulge value
bulge = np.array(
[float(e["42"]) for i, e in enumerate(polyline) if "42" in e],
dtype=np.float64,
)
# convert bulge to new entities
cv, ce = bulge_to_arcs(
lines=lines, bulge=bulge, bulge_idx=bulge_idx, is_closed=is_closed
)
for i in ce:
# offset entities by existing vertices
i.points += len(vertices)
vertices.extend(cv)
entities.extend(ce)
# we no longer have an active polyline
polyline = None
elif entity_type == "TEXT":
# text entities need spaces preserved so take
# group codes from clean representation (0- column)
# and data from the raw representation (1- column)
chunk_raw = blob_raw[index]
# if we didn't use clean group codes we wouldn't
# be able to access them by key as whitespace
# is random and crazy, like: ' 1 '
chunk_raw[:, 0] = blob[index][:, 0]
try:
convert_text(dict(chunk_raw))
except BaseException:
log.debug("failed to load text entity!", exc_info=True)
# if the entity contains all relevant data we can
# cleanly load it from inside a single function
elif entity_type in loaders:
# the chunker converts an (n,2) list into a dict
chunker, loader = loaders[entity_type]
# convert data to dict
entity_data = chunker(chunk)
# append data to the lists we're collecting
loader(entity_data)
elif entity_type != "ENTITIES":
unsupported[entity_type] += 1
if len(unsupported) > 0:
log.debug(
"skipping dxf entities: {}".format(
", ".join(f"{k}: {v}" for k, v in unsupported.items())
)
)
# stack vertices into single array
vertices = util.vstack_empty(vertices).astype(np.float64)
if return_name:
name = blob_raw[blob[:, 0] == "2"][0][1]
return vertices, entities, name
return vertices, entities
def export_dxf(path, only_layers=None):
"""
Export a 2D path object to a DXF file.
Parameters
----------
path : trimesh.path.path.Path2D
Input geometry to export
only_layers : None or set
If passed only export the layers specified
Returns
----------
export : str
Path formatted as a DXF file
"""
# get the template for exporting DXF files
template = resources.get_json("templates/dxf.json")
def format_points(points, as_2D=False, increment=True):
"""
Format points into DXF- style point string.
Parameters
-----------
points : (n,2) or (n,3) float
Points in space
as_2D : bool
If True only output 2 points per vertex
increment : bool
If True increment group code per point
Example:
[[X0, Y0, Z0], [X1, Y1, Z1]]
Result, new lines replaced with spaces:
True -> 10 X0 20 Y0 30 Z0 11 X1 21 Y1 31 Z1
False -> 10 X0 20 Y0 30 Z0 10 X1 20 Y1 30 Z1
Returns
-----------
packed : str
Points formatted with group code
"""
points = np.asanyarray(points, dtype=np.float64)
# get points in 3D
three = util.stack_3D(points)
if increment:
group = np.tile(
np.arange(len(three), dtype=np.int64).reshape((-1, 1)), (1, 3)
)
else:
group = np.zeros((len(three), 3), dtype=np.int64)
group += [10, 20, 30]
if as_2D:
group = group[:, :2]
three = three[:, :2]
# join into result string
packed = "\n".join(
f"{g:d}\n{v:.12g}" for g, v in zip(group.reshape(-1), three.reshape(-1))
)
return packed
def entity_info(entity):
"""
Pull layer, color, and name information about an entity
Parameters
-----------
entity : entity object
Source entity to pull metadata
Returns
----------
subs : dict
Has keys 'COLOR', 'LAYER', 'NAME'
"""
# TODO : convert RGBA entity.color to index
subs = {
"COLOR": 255, # default is ByLayer
"LAYER": 0,
"NAME": str(id(entity))[:16],
}
if hasattr(entity, "layer"):
# make sure layer name is forced into ASCII
subs["LAYER"] = util.to_ascii(entity.layer)
return subs
def convert_line(line, vertices):
"""
Convert an entity to a discrete polyline
Parameters
-------------
line : entity
Entity which has 'e.discrete' method
vertices : (n, 2) float
Vertices in space
Returns
-----------
as_dxf : str
Entity exported as a DXF
"""
# get a discrete representation of entity
points = line.discrete(vertices)
# if one or fewer points return nothing
if len(points) <= 1:
return ""
# generate a substitution dictionary for template
subs = entity_info(line)
subs["POINTS"] = format_points(points, as_2D=True, increment=False)
subs["TYPE"] = "LWPOLYLINE"
subs["VCOUNT"] = len(points)
# 1 is closed
# 0 is default (open)
subs["FLAG"] = int(bool(line.closed))
result = template["line"].format(**subs)
return result
def convert_arc(arc, vertices):
# get the center of arc and include span angles
info = arc.center(vertices, return_angle=True, return_normal=False)
subs = entity_info(arc)
center = info.center
if len(center) == 2:
center = np.append(center, 0.0)
data = "10\n{:.12g}\n20\n{:.12g}\n30\n{:.12g}".format(*center)
data += f"\n40\n{info.radius:.12g}"
if arc.closed:
subs["TYPE"] = "CIRCLE"
else:
subs["TYPE"] = "ARC"
# an arc is the same as a circle, with an added start
# and end angle field
data += "\n100\nAcDbArc"
data += "\n50\n{:.12g}\n51\n{:.12g}".format(*np.degrees(info.angles))
subs["DATA"] = data
result = template["arc"].format(**subs)
return result
def convert_bspline(spline, vertices):
# points formatted with group code
points = format_points(vertices[spline.points], increment=False)
# (n,) float knots, formatted with group code
knots = ("40\n{:.12g}\n" * len(spline.knots)).format(*spline.knots)[:-1]
# bit coded
flags = {"closed": 1, "periodic": 2, "rational": 4, "planar": 8, "linear": 16}
flag = flags["planar"]
if spline.closed:
flag = flag | flags["closed"]
normal = [0.0, 0.0, 1.0]
n_code = [210, 220, 230]
n_str = "\n".join(f"{i:d}\n{j:.12g}" for i, j in zip(n_code, normal))
subs = entity_info(spline)
subs.update(
{
"TYPE": "SPLINE",
"POINTS": points,
"KNOTS": knots,
"NORMAL": n_str,
"DEGREE": 3,
"FLAG": flag,
"FCOUNT": 0,
"KCOUNT": len(spline.knots),
"PCOUNT": len(spline.points),
}
)
# format into string template
result = template["bspline"].format(**subs)
return result
def convert_text(txt, vertices):
"""
Convert a Text entity to DXF string.
"""
# start with layer info
sub = entity_info(txt)
# get the origin point of the text
sub["ORIGIN"] = format_points(vertices[[txt.origin]], increment=False)
# rotation angle in degrees
sub["ANGLE"] = np.degrees(txt.angle(vertices))
# actual string of text with spaces escaped
# force into ASCII to avoid weird encoding issues
sub["TEXT"] = (
txt.text.replace(" ", _SAFESPACE)
.encode("ascii", errors="ignore")
.decode("ascii")
)
# height of text
sub["HEIGHT"] = txt.height
result = template["text"].format(**sub)
return result
def convert_generic(entity, vertices):
"""
For entities we don't know how to handle, return their
discrete form as a polyline
"""
return convert_line(entity, vertices)
# make sure we're not losing a ton of
# precision in the string conversion
np.set_printoptions(precision=12)
# trimesh entity to DXF entity converters
conversions = {
"Line": convert_line,
"Text": convert_text,
"Arc": convert_arc,
"Bezier": convert_generic,
"BSpline": convert_bspline,
}
collected = []
for e, layer in zip(path.entities, path.layers):
name = type(e).__name__
# only export specified layers
if only_layers is not None and layer not in only_layers:
continue
if name in conversions:
converted = conversions[name](e, path.vertices).strip()
if len(converted) > 0:
# only save if we converted something
collected.append(converted)
else:
log.debug("Entity type %s not exported!", name)
# join all entities into one string
entities_str = "\n".join(collected)
# add in the extents of the document as explicit XYZ lines
hsub = {f"EXTMIN_{k}": v for k, v in zip("XYZ", np.append(path.bounds[0], 0.0))}
hsub.update({f"EXTMAX_{k}": v for k, v in zip("XYZ", np.append(path.bounds[1], 0.0))})
# apply a units flag defaulting to `1`
hsub["LUNITS"] = _UNITS_TO_DXF.get(path.units, 1)
# run the format for the header
sections = [template["header"].format(**hsub).strip()]
# do the same for entities
sections.append(template["entities"].format(ENTITIES=entities_str).strip())
# and the footer
sections.append(template["footer"].strip())
# filter out empty sections
# random whitespace causes AutoCAD to fail to load
# although Draftsight, LibreCAD, and Inkscape don't care
# what a giant legacy piece of shit
# create the joined string blob
blob = "\n".join(sections).replace(_SAFESPACE, " ")
# run additional self- checks
if tol.strict:
# check that every line pair is (group code, value)
lines = str.splitlines(str(blob))
# should be even number of lines
assert (len(lines) % 2) == 0
# group codes should all be convertible to int and positive
assert all(int(i) >= 0 for i in lines[::2])
# make sure we didn't slip any unicode in there
blob.encode("ascii")
return blob
def bulge_to_arcs(lines, bulge, bulge_idx, is_closed=False, metadata=None):
"""
Polylines can have "vertex bulge" which means the polyline
has an arc tangent to segments, rather than meeting at a
vertex.
From Autodesk reference:
The bulge is the tangent of one fourth the included
angle for an arc segment, made negative if the arc
goes clockwise from the start point to the endpoint.
A bulge of 0 indicates a straight segment, and a
bulge of 1 is a semicircle.
Parameters
----------------
lines : (n, 2) float
Polyline vertices in order
bulge : (m,) float
Vertex bulge value
bulge_idx : (m,) float
Which index of lines is bulge associated with
is_closed : bool
Is segment closed
metadata : None, or dict
Entity metadata to add
Returns
---------------
vertices : (a, 2) float
New vertices for poly-arc
entities : (b,) entities.Entity
New entities, either line or arc
"""
# make sure lines are 2D array
lines = np.asanyarray(lines, dtype=np.float64)
# make sure inputs are numpy arrays
bulge = np.asanyarray(bulge, dtype=np.float64)
bulge_idx = np.asanyarray(bulge_idx, dtype=np.int64)
# filter out zero- bulged polylines
ok = np.abs(bulge) > 1e-5
bulge = bulge[ok]
bulge_idx = bulge_idx[ok]
# metadata to apply to new entities
if metadata is None:
metadata = {}
# if there's no bulge, just return the input curve
if len(bulge) == 0:
index = np.arange(len(lines))
# add a single line entity and vertices
entities = [Line(index, **metadata)]
return lines, entities
# use bulge to calculate included angle of the arc
angle = np.arctan(bulge) * 4.0
# the indexes making up a bulged segment
tid = np.column_stack((bulge_idx, bulge_idx - 1))
# if it's a closed segment modulus to start vertex
if is_closed:
tid %= len(lines)
# the vector connecting the two ends of the arc
vector = lines[tid[:, 0]] - lines[tid[:, 1]]
# the length of the connector segment
length = np.linalg.norm(vector, axis=1)
# perpendicular vectors by crossing vector with Z
perp = np.cross(
np.column_stack((vector, np.zeros(len(vector)))),
np.ones((len(vector), 3)) * [0, 0, 1],
)
# strip the zero Z
perp = util.unitize(perp[:, :2])
# midpoint of each line
midpoint = lines[tid].mean(axis=1)
# calculate the signed radius of each arc segment
radius = (length / 2.0) / np.sin(angle / 2.0)
# offset magnitude to point on arc
offset = radius - np.cos(angle / 2) * radius
# convert each arc to three points:
# start, any point on arc, end
three = np.column_stack(
(lines[tid[:, 0]], midpoint + perp * offset.reshape((-1, 1)), lines[tid[:, 1]])
).reshape((-1, 3, 2))
# if we're in strict mode make sure our arcs
# have the same magnitude as the input data
if tol.strict:
from ..arc import arc_center
check_angle = [arc_center(i).span for i in three]
assert np.allclose(np.abs(angle), np.abs(check_angle))
check_radii = [arc_center(i).radius for i in three]
assert np.allclose(check_radii, np.abs(radius))
# collect new entities and vertices
entities, vertices = [], []
# add the entities for each new arc
for arc_points in three:
entities.append(Arc(points=np.arange(3) + len(vertices), **metadata))
vertices.extend(arc_points)
# if there are unconsumed line
# segments add them to drawing
if (len(lines) - 1) > len(bulge):
# indexes of line segments
existing = util.stack_lines(np.arange(len(lines)))
# remove line segments replaced with arcs
for line_idx in grouping.boolean_rows(
existing, np.sort(tid, axis=1), np.setdiff1d
):
# add a single line entity and vertices
entities.append(Line(points=np.arange(2) + len(vertices), **metadata))
vertices.extend(lines[line_idx].copy())
# make sure vertices are clean numpy array
vertices = np.array(vertices, dtype=np.float64)
return vertices, entities
def get_key(blob, field, code):
"""
Given a loaded (n, 2) blob and a field name
get a value by code.
"""
try:
line = blob[np.nonzero(blob[:, 1] == field)[0][0] + 1]
except IndexError:
return None
if line[0] == code:
try:
return int(line[1])
except ValueError:
return line[1]
else:
return None
# store the loaders we have available
_dxf_loaders = {"dxf": load_dxf}
@@ -0,0 +1,82 @@
import os
from ... import util
from ...exchange import ply
from . import dxf, svg_io
def export_path(path, file_type=None, file_obj=None, **kwargs):
"""
Export a Path object to a file- like object, or to a filename
Parameters
---------
file_obj: None, str, or file object
A filename string or a file-like object
file_type: None or str
File type, e.g.: 'svg', 'dxf'
kwargs : passed to loader
Returns
---------
exported : str or bytes
Data exported
"""
# if file object is a string it is probably a file path
# so we can split the extension to set the file type
if isinstance(file_obj, str):
file_type = util.split_extension(file_obj)
# run the export
export = _path_exporters[file_type](path, **kwargs)
# if we've been passed files write the data
_write_export(export=export, file_obj=file_obj)
return export
def export_dict(path):
"""
Export a path as a dict of kwargs for the Path constructor.
"""
export_entities = [e.to_dict() for e in path.entities]
export_object = {"entities": export_entities, "vertices": path.vertices.tolist()}
return export_object
def _write_export(export, file_obj=None):
"""
Write a string to a file.
If file_obj isn't specified, return the string
Parameters
---------
export: a string of the export data
file_obj: a file-like object or a filename
"""
if file_obj is None:
return export
if hasattr(file_obj, "write"):
out_file = file_obj
else:
# expand user and relative paths
file_path = os.path.abspath(os.path.expanduser(file_obj))
out_file = open(file_path, "wb")
try:
out_file.write(export)
except TypeError:
out_file.write(export.encode("utf-8"))
out_file.close()
return export
_path_exporters = {
"dxf": dxf.export_dxf,
"svg": svg_io.export_svg,
"ply": ply.export_ply,
"dict": export_dict,
}
@@ -0,0 +1,92 @@
from ... import util
from ...exceptions import ExceptionWrapper
from ...exchange.ply import load_ply
from ...typed import Optional, Set
from ..path import Path
from . import misc
from .dxf import _dxf_loaders
from .svg_io import _svg_loaders
def load_path(file_obj, file_type: Optional[str] = None, **kwargs):
"""
Load a file to a Path file_object.
Parameters
-----------
file_obj
Accepts many types:
- Path, Path2D, or Path3D file_objects
- open file file_object (dxf or svg)
- file name (dxf or svg)
- shapely.geometry.Polygon
- shapely.geometry.MultiLineString
- dict with kwargs for Path constructor
- `(n, 2, (2|3)) float` line segments
file_type
Type of file is required if file
object is passed.
Returns
---------
path : Path, Path2D, Path3D file_object
Data as a native trimesh Path file_object
"""
# avoid a circular import
from ...exchange.load import _load_kwargs, _parse_file_args
arg = _parse_file_args(file_obj=file_obj, file_type=file_type, **kwargs)
if isinstance(file_obj, Path):
# we have been passed a file object that is already a loaded
# trimesh.path.Path object so do nothing and return
return file_obj
elif util.is_file(arg.file_obj):
if arg.file_type in path_loaders:
kwargs.update(
path_loaders[arg.file_type](
file_obj=arg.file_obj, file_type=arg.file_type
)
)
elif arg.file_type == "ply":
# we cannot register this exporter to path_loaders since
# this is already reserved by Trimesh in ply format in trimesh.load()
kwargs.update(load_ply(file_obj=arg.file_obj, file_type=arg.file_type))
elif util.is_instance_named(file_obj, ["Polygon", "MultiPolygon"]):
# convert from shapely polygons to Path2D
kwargs.update(misc.polygon_to_path(file_obj))
elif util.is_instance_named(file_obj, "MultiLineString"):
# convert from shapely LineStrings to Path2D
kwargs.update(misc.linestrings_to_path(file_obj))
elif isinstance(file_obj, dict):
# load as kwargs
kwargs = file_obj
elif util.is_sequence(file_obj):
# load as lines in space
kwargs.update(misc.lines_to_path(file_obj))
else:
raise ValueError("Not a supported object type!")
# actually load
result = _load_kwargs(kwargs)
result._source = arg
return result
def path_formats() -> Set[str]:
"""
Get a list of supported path formats.
Returns
------------
loaders
Extensions of loadable formats, i.e. {'svg', 'dxf'}
"""
return {k for k, v in path_loaders.items() if not isinstance(v, ExceptionWrapper)}
path_loaders = {}
path_loaders.update(_svg_loaders)
path_loaders.update(_dxf_loaders)
@@ -0,0 +1,221 @@
import numpy as np
from ... import graph, grouping, util
from ...constants import tol_path
from ...typed import ArrayLike, Dict, NDArray, Optional
from ..entities import Arc, Line
def dict_to_path(as_dict):
"""
Turn a pure dict into a dict containing entity objects that
can be sent directly to a Path constructor.
Parameters
------------
as_dict : dict
Has keys: 'vertices', 'entities'
Returns
------------
kwargs : dict
Has keys: 'vertices', 'entities'
"""
# start kwargs with initial value
result = as_dict.copy()
# map of constructors
loaders = {"Arc": Arc, "Line": Line}
# pre- allocate entity array
entities = [None] * len(as_dict["entities"])
# run constructor for dict kwargs
for entity_index, entity in enumerate(as_dict["entities"]):
if entity["type"] == "Line":
entities[entity_index] = loaders[entity["type"]](points=entity["points"])
else:
entities[entity_index] = loaders[entity["type"]](
points=entity["points"], closed=entity["closed"]
)
result["entities"] = entities
return result
def lines_to_path(lines: ArrayLike, index: Optional[NDArray[np.int64]] = None) -> Dict:
"""
Turn line segments into argument to be used for a Path2D or Path3D.
Parameters
------------
lines : (n, 2, dimension) or (n, dimension) float
Line segments or connected polyline curve in 2D or 3D
index : (n,) int64
If passed save an index for each line segment.
Returns
-----------
kwargs : Dict
kwargs for Path constructor
"""
lines = np.asanyarray(lines, dtype=np.float64)
if index is not None:
index = np.asanyarray(index, dtype=np.int64)
if util.is_shape(lines, (-1, (2, 3))):
# the case where we have a list of points
# we are going to assume they are connected
result = {"entities": np.array([Line(np.arange(len(lines)))]), "vertices": lines}
return result
elif util.is_shape(lines, (-1, 2, (2, 3))):
# case where we have line segments in 2D or 3D
dimension = lines.shape[-1]
# convert lines to even number of (n, dimension) points
lines = lines.reshape((-1, dimension))
# merge duplicate vertices
unique, inverse = grouping.unique_rows(lines, digits=tol_path.merge_digits)
# use scipy edges_to_path to skip creating
# a bajillion individual line entities which
# will be super slow vs. fewer polyline entities
return edges_to_path(edges=inverse.reshape((-1, 2)), vertices=lines[unique])
else:
raise ValueError("Lines must be (n,(2|3)) or (n,2,(2|3))")
return result
def polygon_to_path(polygon):
"""
Load shapely Polygon objects into a trimesh.path.Path2D object
Parameters
-------------
polygon : shapely.geometry.Polygon
Input geometry
Returns
-----------
kwargs : dict
Keyword arguments for Path2D constructor
"""
# start with a single polyline for the exterior
entities = []
# start vertices
vertices = []
if hasattr(polygon.boundary, "geoms"):
boundaries = polygon.boundary.geoms
else:
boundaries = [polygon.boundary]
# append interiors as single Line objects
current = 0
for boundary in boundaries:
entities.append(Line(np.arange(len(boundary.coords)) + current))
current += len(boundary.coords)
# append the new vertex array
vertices.append(np.array(boundary.coords))
# make sure result arrays are numpy
kwargs = {
"entities": entities,
"vertices": np.vstack(vertices) if len(vertices) > 0 else vertices,
}
return kwargs
def linestrings_to_path(multi) -> Dict:
"""
Load shapely LineString objects into arguments to create a Path2D or Path3D.
Parameters
-------------
multi : shapely.geometry.LineString or MultiLineString
Input 2D or 3D geometry
Returns
-------------
kwargs : Dict
Keyword arguments for Path2D or Path3D constructor
"""
import shapely
# append to result as we go
entities = []
vertices = []
if isinstance(multi, shapely.MultiLineString):
multi = list(multi.geoms)
else:
multi = [multi]
for line in multi:
# only append geometry with points
if hasattr(line, "coords"):
coords = np.array(line.coords)
if len(coords) < 2:
continue
entities.append(Line(np.arange(len(coords)) + len(vertices)))
vertices.extend(coords)
kwargs = {"entities": np.array(entities), "vertices": np.array(vertices)}
return kwargs
def faces_to_path(mesh, face_ids=None, **kwargs):
"""
Given a mesh and face indices find the outline edges and
turn them into a Path3D.
Parameters
------------
mesh : trimesh.Trimesh
Triangulated surface in 3D
face_ids : (n,) int
Indexes referencing mesh.faces
Returns
---------
kwargs : dict
Kwargs for Path3D constructor
"""
if face_ids is None:
edges = mesh.edges_sorted
else:
# take advantage of edge ordering to index as single row
edges = mesh.edges_sorted.reshape((-1, 6))[face_ids].reshape((-1, 2))
# an edge which occurs onely once is on the boundary
unique_edges = grouping.group_rows(edges, require_count=1)
# add edges and vertices to kwargs
kwargs.update(edges_to_path(edges=edges[unique_edges], vertices=mesh.vertices))
return kwargs
def edges_to_path(edges: ArrayLike, vertices: ArrayLike, **kwargs) -> Dict:
"""
Given an edge list of indices and associated vertices
representing lines, generate kwargs for a Path object.
Parameters
-----------
edges : (n, 2) int
Vertex indices of line segments
vertices : (m, dimension) float
Vertex positions where dimension is 2 or 3
Returns
----------
kwargs : dict
Kwargs for Path constructor
"""
# sequence of ordered traversals
dfs = graph.traversals(edges, mode="dfs")
# make sure every consecutive index in DFS
# traversal is an edge in the source edge list
dfs_connected = graph.fill_traversals(dfs, edges=edges)
# kwargs for Path constructor
# turn traversals into Line objects
lines = [Line(d) for d in dfs_connected]
kwargs.update({"entities": lines, "vertices": vertices, "process": False})
return kwargs
@@ -0,0 +1,804 @@
import base64
import json
from collections import defaultdict, deque
from copy import deepcopy
import numpy as np
from ... import exceptions, grouping, resources, util
from ...constants import log, tol
from ...transformations import planar_matrix, transform_points
from ...typed import Dict, Iterable, Mapping, NDArray, Number
from ...util import jsonify
from ..arc import arc_center, to_threepoint
from ..entities import Arc, Bezier, Line
# store any additional properties using a trimesh namespace
_ns_name = "trimesh"
_ns_url = "https://github.com/mikedh/trimesh"
_ns = f"{{{_ns_url}}}"
_IDENTITY = np.eye(3)
_IDENTITY.flags["WRITEABLE"] = False
def svg_to_path(file_obj=None, file_type=None, path_string=None):
"""
Load an SVG file into a Path2D object.
Parameters
-----------
file_obj : open file object
Contains SVG data
file_type: None
Not used
path_string : None or str
If passed, parse a single path string and ignore `file_obj`.
Returns
-----------
loaded : dict
With kwargs for Path2D constructor
"""
force = None
tree = None
paths = []
shapes = []
if file_obj is not None:
# first parse the XML
tree = etree.fromstring(file_obj.read())
# store paths and transforms as
# (path string, 3x3 matrix)
for element in tree.iter("{*}path"):
# store every path element attributes and transform
paths.append((element.attrib, element_transform(element)))
# now try converting shapes
for shape in tree.iter(
("{*}circle", "{*}rect", "{*}line", "{*}polyline", "{*}polygon")
):
shapes.append(
(shape.tag.rsplit("}", 1)[-1], shape.attrib, element_transform(shape))
)
try:
# see if the SVG should be reproduced as a scene
force = tree.attrib[_ns + "class"]
except BaseException:
pass
elif path_string is not None:
# parse a single SVG path string
paths.append(({"d": path_string}, _IDENTITY))
else:
raise ValueError("`file_obj` or `pathstring` required")
result = _svg_path_convert(paths=paths, shapes=shapes, force=force)
try:
if tree is not None:
# get overall metadata from JSON string if it exists
result["metadata"] = _decode(tree.attrib[_ns + "metadata"])
except KeyError:
# not in the trimesh ns
pass
except BaseException:
# no metadata stored with trimesh ns
log.debug("failed metadata", exc_info=True)
# if the result is a scene try to get the metadata
# for each subgeometry here
if "geometry" in result:
try:
# get per-geometry metadata if available
bag = _decode(tree.attrib[_ns + "metadata_geometry"])
for name, meta in bag.items():
if name in result["geometry"]:
# assign this metadata to the geometry
result["geometry"][name]["metadata"] = meta
except KeyError:
# no stored geometry metadata so ignore
pass
except BaseException:
# failed to load existing metadata
log.debug("failed metadata", exc_info=True)
return result
def _attrib_metadata(attrib: Mapping) -> Dict:
try:
# try to retrieve any trimesh attributes as metadata
return {
k.lstrip(_ns): _decode(v)
for k, v in attrib.items()
if k[1:].startswith(_ns_url)
}
except BaseException:
return {}
def element_transform(element, max_depth=10):
"""
Find a transformation matrix for an XML element.
Parameters
--------------
e : lxml.etree.Element
Element to search upwards from.
max_depth : int
Maximum depth to search for transforms.
"""
matrices = deque()
# start at the passed element
current = element
for _ in range(max_depth):
# get the transforms from a particular element
if "transform" in current.attrib:
matrices.extendleft(transform_to_matrices(current.attrib["transform"])[::-1])
current = current.getparent()
if current is None:
break
if len(matrices) == 0:
# no transforms is an identity matrix
return _IDENTITY
elif len(matrices) == 1:
return matrices[0]
else:
# evaluate the transforms in the order they were passed
# as this is what the SVG spec says you should do
return util.multi_dot(matrices)
def transform_to_matrices(transform: str) -> NDArray[np.float64]:
"""
Convert an SVG transform string to an array of matrices.
i.e. "rotate(-10 50 100)
translate(-36 45.5)
skewX(40)
scale(1 0.5)"
Parameters
-----------
transform : str
Contains transformation information in SVG form
Returns
-----------
matrices : (n, 3, 3) float
Multiple transformation matrices from input transform string
"""
# split the transform string in to components of:
# (operation, args) i.e. (translate, '-1.0, 2.0')
components = [
[j.strip() for j in i.strip().split("(") if len(j) > 0]
for i in transform.lower().split(")")
if len(i) > 0
]
# store each matrix without dotting
matrices = []
for line in components:
if len(line) == 0:
continue
elif len(line) != 2:
raise ValueError("should always have two components!")
key, args = line
# convert string args to array of floats
# support either comma or space delimiter
values = np.array([float(i) for i in args.replace(",", " ").split()])
if key == "translate":
# convert translation to a (3, 3) homogeneous matrix
matrices.append(_IDENTITY.copy())
matrices[-1][:2, 2] = values
elif key == "matrix":
# [a b c d e f] ->
# [[a c e],
# [b d f],
# [0 0 1]]
matrices.append(np.vstack((values.reshape((3, 2)).T, [0, 0, 1])))
elif key == "rotate":
# SVG rotations are in degrees
angle = np.degrees(values[0])
# if there are three values rotate around point
if len(values) == 3:
point = values[1:]
else:
point = None
matrices.append(planar_matrix(theta=angle, point=point))
elif key == "scale":
# supports (x_scale, y_scale) or (scale)
mat = _IDENTITY.copy()
mat[:2, :2] *= values
matrices.append(mat)
else:
log.debug(f"unknown SVG transform: {key}")
return np.array(matrices, dtype=np.float64)
def _svg_path_convert(paths: Iterable, shapes: Iterable, force=None):
"""
Convert an SVG path string into a Path2D object
Parameters
-------------
paths: list of tuples
Containing (path string, (3, 3) matrix, metadata)
Returns
-------------
drawing : dict
Kwargs for Path2D constructor
"""
def complex_to_float(values):
return np.array([[i.real, i.imag] for i in values], dtype=np.float64)
def load_multi(multi):
# load a previously parsed multiline
# start the count where indicated
start = counts[name]
# end at the block of our new points
end = start + len(multi.points)
return (Line(points=np.arange(start, end)), multi.points)
def load_arc(svg_arc):
# load an SVG arc into a trimesh arc
points = complex_to_float([svg_arc.start, svg_arc.point(0.5), svg_arc.end])
# create an arc from the now numpy points
arc = Arc(
points=np.arange(3) + counts[name],
# we may have monkey-patched the entity to
# indicate that it is a closed circle
closed=getattr(svg_arc, "closed", False),
)
return arc, points
def load_quadratic(svg_quadratic):
# load a quadratic bezier spline
points = complex_to_float(
[svg_quadratic.start, svg_quadratic.control, svg_quadratic.end]
)
return Bezier(points=np.arange(3) + counts[name]), points
def load_cubic(svg_cubic):
# load a cubic bezier spline
points = complex_to_float(
[svg_cubic.start, svg_cubic.control1, svg_cubic.control2, svg_cubic.end]
)
return Bezier(np.arange(4) + counts[name]), points
class MultiLine:
# An object to hold one or multiple Line entities.
def __init__(self, lines):
if tol.strict:
# in unit tests make sure we only have lines
assert all(type(L).__name__ in ("Line", "Close") for L in lines)
# get the starting point of every line
points = [L.start for L in lines]
# append the endpoint
points.append(lines[-1].end)
# convert to (n, 2) float points
self.points = np.array([[i.real, i.imag] for i in points], dtype=np.float64)
# load functions for each entity
loaders = {
"Arc": load_arc,
"MultiLine": load_multi,
"CubicBezier": load_cubic,
"QuadraticBezier": load_quadratic,
}
entities = defaultdict(list)
vertices = defaultdict(list)
counts = defaultdict(lambda: 0)
for attrib, matrix in paths:
# the path string is stored under `d`
path_string = attrib.get("d", "")
if len(path_string) == 0:
log.debug("empty path string!")
continue
# get the name of the geometry if trimesh specified it
# note that the get will by default return `None`
name = _decode(attrib.get(_ns + "name"))
# get parsed entities from svg.path
raw = np.array(list(parse_path(path_string)))
# if there is no path string exit
if len(raw) == 0:
continue
# create an integer code for entities we can combine
kinds_lookup = {"Line": 1, "Close": 1, "Arc": 2}
# get a code for each entity we parsed
kinds = np.array([kinds_lookup.get(type(i).__name__, 0) for i in raw], dtype=int)
# find groups of consecutive entities so we can combine
blocks = grouping.blocks(kinds, min_len=1, only_nonzero=False)
if tol.strict:
# in unit tests make sure we didn't lose any entities
assert util.allclose(np.hstack(blocks), np.arange(len(raw)))
# Combine consecutive entities that can be represented
# more concisely as a single trimesh entity.
parsed = []
for b in blocks:
chunk = raw[b]
current = type(raw[b[0]]).__name__
if current in ("Line", "Close"):
# if entity consists of lines add a multiline
parsed.append(MultiLine(chunk))
elif len(b) > 1 and current == "Arc":
# if we have multiple arcs check to see if they
# actually represent a single closed circle
# get a single array with the relevant arc points
verts = np.array(
[
[
a.start.real,
a.start.imag,
a.end.real,
a.end.imag,
a.center.real,
a.center.imag,
a.radius.real,
a.radius.imag,
a.rotation,
]
for a in chunk
],
dtype=np.float64,
)
# all arcs share the same center radius and rotation
closed = False
if np.ptp(verts[:, 4:], axis=0).mean() < 1e-3:
start, end = verts[:, :2], verts[:, 2:4]
# if every end point matches the start point of a new
# arc that means this is really a closed circle made
# up of multiple arc segments
closed = util.allclose(start, np.roll(end, 1, axis=0))
if closed:
# hot-patch a closed arc flag
chunk[0].closed = True
# all arcs in this block are now represented by one entity
parsed.append(chunk[0])
else:
# we don't have a closed circle so add each
# arc entity individually without combining
parsed.extend(chunk)
else:
# otherwise just add the entities
parsed.extend(chunk)
entity_meta = _attrib_metadata(attrib=attrib)
# loop through parsed entity objects
for svg_entity in parsed:
# keyed by entity class name
type_name = type(svg_entity).__name__
if type_name in loaders:
# get new entities and vertices
e, v = loaders[type_name](svg_entity)
e.metadata.update(entity_meta)
# append them to the result
entities[name].append(e)
# transform the vertices by the matrix and append
vertices[name].append(transform_points(v, matrix))
counts[name] += len(v)
# load simple shape geometry
for kind, attrib, matrix in shapes:
# get the geometry name (defaults to None)
name = _decode(attrib.get(_ns + "name"))
if kind == "circle":
points = to_threepoint(
[float(attrib["cx"]), float(attrib["cy"])], float(attrib["r"])
)
entity = Arc(points=np.arange(3) + counts[name], closed=True)
elif kind == "rect":
# todo : support rounded rectangle
origin = np.array([attrib["x"], attrib["y"]], dtype=np.float64)
w, h = np.array([attrib["width"], attrib["height"]], dtype=np.float64)
points = np.array(
[origin, origin + (w, 0), origin + (w, h), origin + (0, h), origin],
dtype=np.float64,
)
entity = Line(points=np.arange(len(points)) + counts[name])
elif kind == "polyline":
points = np.fromstring(
attrib["points"].strip().replace(",", " "), sep=" ", dtype=np.float64
).reshape((-1, 2))
entity = Line(points=np.arange(len(points)) + counts[name])
elif kind == "polygon":
points = np.fromstring(
attrib["points"].strip().replace(",", " "), sep=" ", dtype=np.float64
).reshape((-1, 2))
# polygon implies forced-closed so check to see if it
# is already closed and if not add the closing index
if (points[0] == points[-1]).all():
index = np.arange(len(points)) + counts[name]
else:
index = np.arange(len(points) + 1) + counts[name]
index[-1] = index[0]
entity = Line(points=index)
elif kind == "line":
points = np.array(
[attrib["x1"], attrib["y1"], attrib["x2"], attrib["y2"]], dtype=np.float64
).reshape((2, 2))
entity = Line(points=np.arange(len(points)) + counts[name])
else:
log.debug(f"unsupported SVG shape: `{kind}`")
continue
entities[name].append(entity)
vertices[name].append(transform_points(points, matrix))
counts[name] += len(points)
if len(vertices) == 0:
return {"vertices": [], "entities": []}
geoms = {
name: {"vertices": np.vstack(v), "entities": entities[name]}
for name, v in vertices.items()
}
if len(geoms) > 1 or force == "Scene":
kwargs = {"geometry": geoms}
else:
# return a single Path2D
kwargs = next(iter(geoms.values()))
return kwargs
def _entities_to_str(entities, vertices, name=None, digits=None, only_layers=None):
"""
Convert the entities of a path to path strings.
Parameters
------------
entities : (n,) list
Entity objects
vertices : (m, 2) float
Vertices entities reference
name : any
Trimesh namespace name to assign to entity
digits : int
Number of digits to format exports into
only_layers : set
Only export these layers if passed
"""
if digits is None:
digits = 13
points = vertices.copy()
# generate a format string with the requested digits
temp_digits = f"0.{int(digits)}f"
# generate a format string for circles as two arc segments
temp_circle = (
"M {x:DI},{y:DI}a{r:DI},{r:DI},0,1,0,{d:DI}," + "0a{r:DI},{r:DI},0,1,0,-{d:DI},0Z"
).replace("DI", temp_digits)
# generate a format string for an absolute move-to command
temp_move = "M{:DI},{:DI}".replace("DI", temp_digits)
# generate a format string for an absolute-line command
temp_line = "L{:DI},{:DI}".replace("DI", temp_digits)
# generate a format string for a single arc
temp_arc = "M{SX:DI} {SY:DI}A{R},{R} 0 {L:d},{S:d} {EX:DI},{EY:DI}".replace(
"DI", temp_digits
)
def _cross_2d(a: NDArray, b: NDArray) -> Number:
"""
Numpy 2.0 depreciated cross products of 2D arrays.
"""
return a[0] * b[1] - a[1] * b[0]
def svg_arc(arc):
"""
arc string: (rx ry x-axis-rotation large-arc-flag sweep-flag x y)+
large-arc-flag: greater than 180 degrees
sweep flag: direction (cw/ccw)
"""
vertices = points[arc.points]
info = arc_center(vertices, return_normal=False, return_angle=True)
C, R, angle = info.center, info.radius, info.span
if arc.closed:
return temp_circle.format(x=C[0] - R, y=C[1], r=R, d=2.0 * R)
vertex_start, vertex_mid, vertex_end = vertices
large_flag = int(angle > np.pi)
sweep_flag = int(
_cross_2d(vertex_mid - vertex_start, vertex_end - vertex_start) > 0.0
)
return temp_arc.format(
SX=vertex_start[0],
SY=vertex_start[1],
L=large_flag,
S=sweep_flag,
EX=vertex_end[0],
EY=vertex_end[1],
R=R,
)
def svg_discrete(entity):
"""
Use an entities discrete representation to export a
curve as a polyline
"""
discrete = entity.discrete(points)
# if entity contains no geometry return
if len(discrete) == 0:
return ""
# the format string for the SVG path
return (temp_move + (temp_line * (len(discrete) - 1))).format(
*discrete.reshape(-1)
)
# tuples of (metadata, path string)
pairs = []
for entity in entities:
if only_layers is not None and entity.layer not in only_layers:
continue
# check the class name of the entity
if entity.__class__.__name__ == "Arc":
# export the exact version of the entity
path_string = svg_arc(entity)
else:
# just export the polyline version of the entity
path_string = svg_discrete(entity)
meta = deepcopy(entity.metadata)
if name is not None:
meta["name"] = name
pairs.append((meta, path_string))
return pairs
def export_svg(drawing, return_path=False, only_layers=None, digits=None, **kwargs):
"""
Export a Path2D object into an SVG file.
Parameters
-----------
drawing : Path2D
Source geometry
return_path : bool
If True return only path string not wrapped in XML
only_layers : None or set
If passed only export the specified layers
digits : None or int
Number of digits for floating point values
Returns
-----------
as_svg : str
XML formatted SVG, or path string
"""
# collect custom attributes for the overall export
attribs = {"class": type(drawing).__name__}
if util.is_instance_named(drawing, "Scene"):
pairs = []
geom_meta = {}
for name, geom in drawing.geometry.items():
if not util.is_instance_named(geom, "Path2D"):
continue
geom_meta[name] = geom.metadata
# a pair of (metadata, path string)
pairs.extend(
_entities_to_str(
entities=geom.entities,
vertices=geom.vertices,
name=name,
digits=digits,
only_layers=only_layers,
)
)
if len(geom_meta) > 0:
# encode the whole metadata bundle here to avoid
# polluting the file with a ton of loose attribs
attribs["metadata_geometry"] = _encode(geom_meta)
elif util.is_instance_named(drawing, "Path2D"):
pairs = _entities_to_str(
entities=drawing.entities,
vertices=drawing.vertices,
digits=digits,
only_layers=only_layers,
)
else:
raise ValueError("drawing must be Scene or Path2D object!")
# return path string without XML wrapping
if return_path:
return " ".join(v[1] for v in pairs)
# fetch the export template for the base SVG file
template_svg = resources.get_string("templates/base.svg")
elements = []
for meta, path_string in pairs:
# create a simple path element
elements.append(f'<path d="{path_string}" {_format_attrib(meta)}/>')
# format as XML
if "stroke_width" in kwargs:
stroke_width = float(kwargs["stroke_width"])
else:
# set stroke to something OK looking
stroke_width = drawing.extents.max() / 800.0
try:
# store metadata in XML as JSON -_-
attribs["metadata"] = _encode(drawing.metadata)
except BaseException:
# log failed metadata encoding
log.debug("failed to encode", exc_info=True)
subs = {
"elements": "\n".join(elements),
"min_x": drawing.bounds[0][0],
"min_y": drawing.bounds[0][1],
"width": drawing.extents[0],
"height": drawing.extents[1],
"stroke_width": stroke_width,
"attribs": _format_attrib(attribs),
}
return template_svg.format(**subs)
def _format_attrib(attrib):
"""
Format attribs into the trimesh namespace.
Parameters
-----------
attrib : dict
Bag of keys and values.
"""
bag = {k: _encode(v) for k, v in attrib.items()}
return "\n".join(
f'{_ns_name}:{k}="{v}"'
for k, v in bag.items()
if len(k) > 0 and v is not None and len(v) > 0
)
def _encode(stuff):
"""
Wangle things into a string.
Parameters
-----------
stuff : dict, str
Thing to pack
Returns
------------
encoded : str
Packaged into url-safe b64 string
"""
if isinstance(stuff, str) and '"' not in stuff:
return stuff
pack = base64.urlsafe_b64encode(
jsonify(
{k: v for k, v in stuff.items() if not k.startswith("_")},
separators=(",", ":"),
).encode("utf-8")
)
result = "base64," + util.decode_text(pack)
if tol.strict:
# make sure we haven't broken the things
_deep_same(stuff, _decode(result))
return result
def _deep_same(original, other):
"""
Do a recursive comparison of two items to check
our encoding scheme in unit tests.
Parameters
-----------
original : str, bytes, list, dict
Original item
other : str, bytes, list, dict
Item that should be identical
Raises
------------
AssertionError
If items are not the same.
"""
# ndarrays will be converted to lists
# but otherwise types should be identical
if isinstance(original, np.ndarray):
assert isinstance(other, (list, np.ndarray))
elif isinstance(original, str):
assert isinstance(other, str)
else:
# otherwise they should be the same type
assert isinstance(original, type(other))
if isinstance(original, (str, bytes)):
# string and bytes should just be identical
assert original == other
return
elif isinstance(original, (float, int, np.ndarray)):
# for Number classes use numpy magic comparison
# which includes an epsilon for floating point
assert np.allclose(original, other)
return
elif isinstance(original, list):
# lengths should match
assert len(original) == len(other)
# every element should be identical
for a, b in zip(original, other):
_deep_same(a, b)
return
# we should have special-cased everything else by here
assert isinstance(original, dict)
# all keys should match
assert set(original.keys()) == set(other.keys())
# do a recursive comparison of the values
for k in original.keys():
_deep_same(original[k], other[k])
def _decode(bag):
"""
Decode a base64 bag of stuff.
Parameters
------------
bag : str
Starts with `base64,`
Returns
-------------
loaded : dict
Loaded bag of stuff
"""
if bag is None:
return
text = util.decode_text(bag)
if text.startswith("base64,"):
return json.loads(
base64.urlsafe_b64decode(text[7:].encode("utf-8")).decode("utf-8")
)
return text
_svg_loaders = {"svg": svg_to_path}
try:
# pip install svg.path
from svg.path import parse_path
except BaseException as E:
# will re-raise the import exception when
# someone tries to call `parse_path`
parse_path = exceptions.ExceptionWrapper(E)
_svg_loaders["svg"] = parse_path
try:
from lxml import etree
except BaseException as E:
# will re-raise the import exception when
# someone actually tries to use the module
etree = exceptions.ExceptionWrapper(E)
_svg_loaders["svg"] = etree