yapCAD 0.2.5__py2.py3-none-any.whl → 0.3.1__py2.py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yapcad/__init__.py +8 -8
- yapcad/combine.py +83 -371
- yapcad/drawable.py +44 -3
- yapcad/ezdxf_drawable.py +1 -1
- yapcad/geom.py +214 -276
- yapcad/geom3d.py +975 -55
- yapcad/geom3d_util.py +541 -0
- yapcad/geom_util.py +817 -0
- yapcad/geometry.py +442 -50
- yapcad/geometry_checks.py +112 -0
- yapcad/geometry_utils.py +115 -0
- yapcad/io/__init__.py +5 -0
- yapcad/io/stl.py +83 -0
- yapcad/mesh.py +46 -0
- yapcad/metadata.py +109 -0
- yapcad/octtree.py +627 -0
- yapcad/poly.py +153 -299
- yapcad/pyglet_drawable.py +597 -61
- yapcad/triangulator.py +103 -0
- yapcad/xform.py +10 -5
- {yapCAD-0.2.5.dist-info → yapcad-0.3.1.dist-info}/METADATA +110 -39
- yapcad-0.3.1.dist-info/RECORD +27 -0
- {yapCAD-0.2.5.dist-info → yapcad-0.3.1.dist-info}/WHEEL +1 -1
- yapCAD-0.2.5.dist-info/RECORD +0 -17
- {yapCAD-0.2.5.dist-info → yapcad-0.3.1.dist-info/licenses}/AUTHORS.rst +0 -0
- {yapCAD-0.2.5.dist-info → yapcad-0.3.1.dist-info/licenses}/LICENSE +0 -0
- {yapCAD-0.2.5.dist-info → yapcad-0.3.1.dist-info/licenses}/LICENSE.txt +0 -0
- {yapCAD-0.2.5.dist-info → yapcad-0.3.1.dist-info}/top_level.txt +0 -0
yapcad/geometry_utils.py
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
"""Common geometric helpers shared across exporters and validators."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from dataclasses import dataclass
|
6
|
+
from typing import Iterable, Sequence, Tuple
|
7
|
+
|
8
|
+
from yapcad.geom import cross, epsilon, mag
|
9
|
+
|
10
|
+
Vec3 = Tuple[float, float, float]
|
11
|
+
|
12
|
+
|
13
|
+
@dataclass(frozen=True)
|
14
|
+
class Triangle:
|
15
|
+
"""Immutable triangle representation in XYZ space."""
|
16
|
+
|
17
|
+
normal: Vec3
|
18
|
+
v0: Vec3
|
19
|
+
v1: Vec3
|
20
|
+
v2: Vec3
|
21
|
+
|
22
|
+
|
23
|
+
def to_vec3(point_like: Sequence[float]) -> Vec3:
|
24
|
+
"""Return the XYZ components of a yapCAD point/vector as a tuple."""
|
25
|
+
|
26
|
+
if len(point_like) < 3:
|
27
|
+
raise ValueError("value must have at least three components")
|
28
|
+
return float(point_like[0]), float(point_like[1]), float(point_like[2])
|
29
|
+
|
30
|
+
|
31
|
+
def to_point(vec: Sequence[float]) -> Tuple[float, float, float, float]:
|
32
|
+
"""Lift an XYZ tuple into yapCAD homogeneous point form."""
|
33
|
+
|
34
|
+
if len(vec) < 3:
|
35
|
+
raise ValueError("vector must have three components")
|
36
|
+
return float(vec[0]), float(vec[1]), float(vec[2]), 1.0
|
37
|
+
|
38
|
+
|
39
|
+
def to_vector(vec: Sequence[float]) -> Tuple[float, float, float, float]:
|
40
|
+
"""Lift an XYZ tuple into yapCAD homogeneous vector form (w=0)."""
|
41
|
+
|
42
|
+
if len(vec) < 3:
|
43
|
+
raise ValueError("vector must have three components")
|
44
|
+
return float(vec[0]), float(vec[1]), float(vec[2]), 0.0
|
45
|
+
|
46
|
+
|
47
|
+
def triangle_normal(v0: Vec3, v1: Vec3, v2: Vec3) -> Vec3 | None:
|
48
|
+
"""Return the unit normal of a triangle or ``None`` if degenerate."""
|
49
|
+
|
50
|
+
ax, ay, az = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]
|
51
|
+
bx, by, bz = v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]
|
52
|
+
n = cross([ax, ay, az, 1.0], [bx, by, bz, 1.0])
|
53
|
+
length = mag(n)
|
54
|
+
if length <= epsilon:
|
55
|
+
return None
|
56
|
+
return (n[0] / length, n[1] / length, n[2] / length)
|
57
|
+
|
58
|
+
|
59
|
+
def triangle_area(v0: Vec3, v1: Vec3, v2: Vec3) -> float:
|
60
|
+
"""Return the area of a triangle."""
|
61
|
+
|
62
|
+
ax, ay, az = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]
|
63
|
+
bx, by, bz = v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]
|
64
|
+
n = cross([ax, ay, az, 1.0], [bx, by, bz, 1.0])
|
65
|
+
return 0.5 * mag(n)
|
66
|
+
|
67
|
+
|
68
|
+
def triangle_is_degenerate(v0: Vec3, v1: Vec3, v2: Vec3, tol: float = epsilon) -> bool:
|
69
|
+
"""Return ``True`` if a triangle collapses under the given tolerance."""
|
70
|
+
|
71
|
+
return triangle_area(v0, v1, v2) <= tol
|
72
|
+
|
73
|
+
|
74
|
+
def orient_triangle(v0: Vec3, v1: Vec3, v2: Vec3, preferred_normal: Vec3) -> Tuple[Vec3, Vec3, Vec3]:
|
75
|
+
"""Ensure triangle winding aligns with ``preferred_normal``."""
|
76
|
+
|
77
|
+
current = triangle_normal(v0, v1, v2)
|
78
|
+
if current is None:
|
79
|
+
return v0, v1, v2
|
80
|
+
dot = current[0] * preferred_normal[0] + current[1] * preferred_normal[1] + current[2] * preferred_normal[2]
|
81
|
+
if dot < 0:
|
82
|
+
return v0, v2, v1
|
83
|
+
return v0, v1, v2
|
84
|
+
|
85
|
+
|
86
|
+
def triangle_centroid(v0: Vec3, v1: Vec3, v2: Vec3) -> Vec3:
|
87
|
+
"""Return the centroid of a triangle."""
|
88
|
+
|
89
|
+
return (
|
90
|
+
(v0[0] + v1[0] + v2[0]) / 3.0,
|
91
|
+
(v0[1] + v1[1] + v2[1]) / 3.0,
|
92
|
+
(v0[2] + v1[2] + v2[2]) / 3.0,
|
93
|
+
)
|
94
|
+
|
95
|
+
|
96
|
+
def triangles_from_mesh(mesh: Iterable[Tuple[Vec3, Vec3, Vec3, Vec3]]) -> Iterable[Triangle]:
|
97
|
+
"""Convert ``mesh_view`` output into ``Triangle`` instances."""
|
98
|
+
|
99
|
+
for normal, v0, v1, v2 in mesh:
|
100
|
+
yield Triangle(normal=normal, v0=v0, v1=v1, v2=v2)
|
101
|
+
|
102
|
+
|
103
|
+
__all__ = [
|
104
|
+
"Triangle",
|
105
|
+
"Vec3",
|
106
|
+
"to_vec3",
|
107
|
+
"to_point",
|
108
|
+
"to_vector",
|
109
|
+
"triangle_normal",
|
110
|
+
"triangle_area",
|
111
|
+
"triangle_is_degenerate",
|
112
|
+
"orient_triangle",
|
113
|
+
"triangle_centroid",
|
114
|
+
"triangles_from_mesh",
|
115
|
+
]
|
yapcad/io/__init__.py
ADDED
yapcad/io/stl.py
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
"""STL export utilities for yapCAD surfaces and solids."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import io
|
6
|
+
import os
|
7
|
+
import struct
|
8
|
+
from typing import Iterable, Sequence, TextIO
|
9
|
+
|
10
|
+
from yapcad.geometry_utils import Triangle, triangles_from_mesh
|
11
|
+
from yapcad.mesh import mesh_view
|
12
|
+
|
13
|
+
_HEADER_SIZE = 80
|
14
|
+
_STRUCT_TRIANGLE = struct.Struct('<12fH')
|
15
|
+
|
16
|
+
|
17
|
+
def write_stl(obj: Sequence, path_or_file, *, binary: bool = True, name: str = 'yapCAD') -> None:
|
18
|
+
"""Write ``obj`` (surface or solid) to STL.
|
19
|
+
|
20
|
+
``path_or_file`` can be a filesystem path or an open binary/text stream.
|
21
|
+
"""
|
22
|
+
|
23
|
+
triangles = list(triangles_from_mesh(mesh_view(obj)))
|
24
|
+
|
25
|
+
if binary:
|
26
|
+
_write_binary(triangles, path_or_file, name)
|
27
|
+
else:
|
28
|
+
_write_ascii(triangles, path_or_file, name)
|
29
|
+
|
30
|
+
|
31
|
+
def _write_binary(triangles: Iterable[Triangle], path_or_file, name: str) -> None:
|
32
|
+
close_when_done = False
|
33
|
+
if hasattr(path_or_file, 'write'):
|
34
|
+
stream = path_or_file
|
35
|
+
else:
|
36
|
+
stream = open(path_or_file, 'wb')
|
37
|
+
close_when_done = True
|
38
|
+
|
39
|
+
try:
|
40
|
+
header = (name[:_HEADER_SIZE]).encode('ascii', errors='replace')
|
41
|
+
header = header.ljust(_HEADER_SIZE, b' ')
|
42
|
+
stream.write(header)
|
43
|
+
stream.write(struct.pack('<I', len(triangles)))
|
44
|
+
|
45
|
+
for tri in triangles:
|
46
|
+
data = _STRUCT_TRIANGLE.pack(
|
47
|
+
*tri.normal,
|
48
|
+
*tri.v0,
|
49
|
+
*tri.v1,
|
50
|
+
*tri.v2,
|
51
|
+
0,
|
52
|
+
)
|
53
|
+
stream.write(data)
|
54
|
+
finally:
|
55
|
+
if close_when_done:
|
56
|
+
stream.close()
|
57
|
+
|
58
|
+
|
59
|
+
def _write_ascii(triangles: Iterable[Triangle], path_or_file, name: str) -> None:
|
60
|
+
close_when_done = False
|
61
|
+
if hasattr(path_or_file, 'write'):
|
62
|
+
stream = path_or_file
|
63
|
+
else:
|
64
|
+
stream = open(path_or_file, 'w', encoding='ascii')
|
65
|
+
close_when_done = True
|
66
|
+
|
67
|
+
try:
|
68
|
+
print(f"solid {name}", file=stream)
|
69
|
+
for tri in triangles:
|
70
|
+
print(f" facet normal {tri.normal[0]:.6e} {tri.normal[1]:.6e} {tri.normal[2]:.6e}", file=stream)
|
71
|
+
print(" outer loop", file=stream)
|
72
|
+
print(f" vertex {tri.v0[0]:.6e} {tri.v0[1]:.6e} {tri.v0[2]:.6e}", file=stream)
|
73
|
+
print(f" vertex {tri.v1[0]:.6e} {tri.v1[1]:.6e} {tri.v1[2]:.6e}", file=stream)
|
74
|
+
print(f" vertex {tri.v2[0]:.6e} {tri.v2[1]:.6e} {tri.v2[2]:.6e}", file=stream)
|
75
|
+
print(" endloop", file=stream)
|
76
|
+
print(" endfacet", file=stream)
|
77
|
+
print(f"endsolid {name}", file=stream)
|
78
|
+
finally:
|
79
|
+
if close_when_done:
|
80
|
+
stream.close()
|
81
|
+
|
82
|
+
|
83
|
+
__all__ = ['write_stl']
|
yapcad/mesh.py
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
"""Utilities for working with triangulated views of yapCAD surfaces."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from typing import Iterable, Iterator, Sequence, Tuple
|
6
|
+
|
7
|
+
from yapcad.geom3d import issolid, issurface
|
8
|
+
from yapcad.geometry_utils import to_vec3, triangle_normal
|
9
|
+
|
10
|
+
Vec3 = Tuple[float, float, float]
|
11
|
+
TriTuple = Tuple[Vec3, Vec3, Vec3, Vec3]
|
12
|
+
|
13
|
+
|
14
|
+
def mesh_view(obj: Sequence) -> Iterator[TriTuple]:
|
15
|
+
"""Yield triangles for a surface or solid as ``(normal, v0, v1, v2)``.
|
16
|
+
|
17
|
+
Normals are unit vectors. Vertices are returned as ``(x, y, z)`` tuples.
|
18
|
+
Faces with degenerate geometry (zero area) are skipped silently.
|
19
|
+
"""
|
20
|
+
|
21
|
+
surfaces: Iterable[Sequence]
|
22
|
+
if issurface(obj):
|
23
|
+
surfaces = [obj]
|
24
|
+
elif issolid(obj):
|
25
|
+
surfaces = obj[1]
|
26
|
+
else:
|
27
|
+
raise ValueError("mesh_view expects a surface or solid")
|
28
|
+
|
29
|
+
for surf in surfaces:
|
30
|
+
verts = surf[1]
|
31
|
+
faces = surf[3] if len(surf) > 3 else []
|
32
|
+
|
33
|
+
for face in faces:
|
34
|
+
if len(face) != 3:
|
35
|
+
continue
|
36
|
+
idx0, idx1, idx2 = face
|
37
|
+
v0 = to_vec3(verts[idx0])
|
38
|
+
v1 = to_vec3(verts[idx1])
|
39
|
+
v2 = to_vec3(verts[idx2])
|
40
|
+
|
41
|
+
calc_normal = triangle_normal(v0, v1, v2)
|
42
|
+
if calc_normal is None:
|
43
|
+
continue
|
44
|
+
|
45
|
+
yield calc_normal, v0, v1, v2
|
46
|
+
|
yapcad/metadata.py
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
"""Metadata helpers for yapCAD geometry objects."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import uuid
|
6
|
+
from typing import Dict, Tuple
|
7
|
+
|
8
|
+
from yapcad.geom3d import issolid, issurface
|
9
|
+
|
10
|
+
_SURFACE_META_INDEX = 6
|
11
|
+
_SOLID_META_INDEX = 4
|
12
|
+
|
13
|
+
|
14
|
+
def _ensure_metadata(container: list, index: int, create: bool) -> Dict:
|
15
|
+
if len(container) <= index:
|
16
|
+
if not create:
|
17
|
+
return {}
|
18
|
+
while len(container) <= index:
|
19
|
+
container.append({})
|
20
|
+
meta = container[index]
|
21
|
+
if not isinstance(meta, dict):
|
22
|
+
if not create:
|
23
|
+
return {}
|
24
|
+
meta = {}
|
25
|
+
container[index] = meta
|
26
|
+
return meta
|
27
|
+
|
28
|
+
|
29
|
+
def get_surface_metadata(surface: list, create: bool = False) -> Dict:
|
30
|
+
if not issurface(surface):
|
31
|
+
raise ValueError('invalid surface passed to get_surface_metadata')
|
32
|
+
return _ensure_metadata(surface, _SURFACE_META_INDEX, create)
|
33
|
+
|
34
|
+
|
35
|
+
def set_surface_metadata(surface: list, metadata: Dict) -> list:
|
36
|
+
if not isinstance(metadata, dict):
|
37
|
+
raise ValueError('surface metadata must be a dictionary')
|
38
|
+
if not issurface(surface):
|
39
|
+
raise ValueError('invalid surface passed to set_surface_metadata')
|
40
|
+
if len(surface) <= _SURFACE_META_INDEX:
|
41
|
+
surface.append(metadata)
|
42
|
+
else:
|
43
|
+
surface[_SURFACE_META_INDEX] = metadata
|
44
|
+
return surface
|
45
|
+
|
46
|
+
|
47
|
+
def ensure_surface_id(surface: list) -> str:
|
48
|
+
meta = get_surface_metadata(surface, create=True)
|
49
|
+
if 'id' not in meta:
|
50
|
+
meta['id'] = str(uuid.uuid4())
|
51
|
+
return meta['id']
|
52
|
+
|
53
|
+
|
54
|
+
def set_surface_units(surface: list, units: str) -> list:
|
55
|
+
meta = get_surface_metadata(surface, create=True)
|
56
|
+
meta['units'] = units
|
57
|
+
return surface
|
58
|
+
|
59
|
+
|
60
|
+
def set_surface_origin(surface: list, origin: str) -> list:
|
61
|
+
meta = get_surface_metadata(surface, create=True)
|
62
|
+
meta['origin'] = origin
|
63
|
+
return surface
|
64
|
+
|
65
|
+
|
66
|
+
def get_solid_metadata(sld: list, create: bool = False) -> Dict:
|
67
|
+
if not issolid(sld):
|
68
|
+
raise ValueError('invalid solid passed to get_solid_metadata')
|
69
|
+
return _ensure_metadata(sld, _SOLID_META_INDEX, create)
|
70
|
+
|
71
|
+
|
72
|
+
def set_solid_metadata(sld: list, metadata: Dict) -> list:
|
73
|
+
if not isinstance(metadata, dict):
|
74
|
+
raise ValueError('solid metadata must be a dictionary')
|
75
|
+
if not issolid(sld):
|
76
|
+
raise ValueError('invalid solid passed to set_solid_metadata')
|
77
|
+
if len(sld) <= _SOLID_META_INDEX:
|
78
|
+
sld.append(metadata)
|
79
|
+
else:
|
80
|
+
sld[_SOLID_META_INDEX] = metadata
|
81
|
+
return sld
|
82
|
+
|
83
|
+
|
84
|
+
def ensure_solid_id(sld: list) -> str:
|
85
|
+
meta = get_solid_metadata(sld, create=True)
|
86
|
+
if 'id' not in meta:
|
87
|
+
meta['id'] = str(uuid.uuid4())
|
88
|
+
return meta['id']
|
89
|
+
|
90
|
+
|
91
|
+
def set_solid_context(sld: list, context: Dict) -> list:
|
92
|
+
if not isinstance(context, dict):
|
93
|
+
raise ValueError('context must be a dictionary')
|
94
|
+
meta = get_solid_metadata(sld, create=True)
|
95
|
+
meta['context'] = context
|
96
|
+
return sld
|
97
|
+
|
98
|
+
|
99
|
+
__all__ = [
|
100
|
+
'get_surface_metadata',
|
101
|
+
'set_surface_metadata',
|
102
|
+
'ensure_surface_id',
|
103
|
+
'set_surface_units',
|
104
|
+
'set_surface_origin',
|
105
|
+
'get_solid_metadata',
|
106
|
+
'set_solid_metadata',
|
107
|
+
'ensure_solid_id',
|
108
|
+
'set_solid_context',
|
109
|
+
]
|