yapCAD 0.3.0__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.
@@ -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
@@ -0,0 +1,5 @@
1
+ """I/O utilities for yapCAD."""
2
+
3
+ from .stl import write_stl
4
+
5
+ __all__ = ['write_stl']
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
+ ]