init
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user