yapCAD 0.5.0__py2.py3-none-any.whl → 0.5.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/geom_util.py CHANGED
@@ -23,6 +23,7 @@
23
23
  # SOFTWARE.
24
24
 
25
25
  from yapcad.geom import *
26
+ from yapcad.spline import sample_catmullrom, sample_nurbs
26
27
  import math
27
28
  import random
28
29
 
@@ -212,86 +213,184 @@ def _convert_geom_sequence(seq, minang, minlen, snap_tol):
212
213
  return cleaned, nested_holes
213
214
 
214
215
 
215
- def geomlist2poly_with_holes(gl, minang=5.0, minlen=0.25, checkcont=False):
216
-
217
- if checkcont and not iscontinuousgeomlist(gl):
218
- raise ValueError('non-continuous geometry list passed to geomlist2poly')
219
-
220
- snap_tol = max(epsilon * 10, minlen * 0.1)
221
-
222
- positive_candidates = []
223
- negative_candidates = []
216
+ def _points_to_segments(points, *, closed=False):
217
+ segments = []
218
+ if not points:
219
+ return segments
220
+ prev = point(points[0])
221
+ for pt in points[1:]:
222
+ curr = point(pt)
223
+ if dist(prev, curr) > epsilon:
224
+ segments.append(line(prev, curr))
225
+ prev = curr
226
+ if closed:
227
+ start = point(points[0])
228
+ if dist(prev, start) > epsilon:
229
+ segments.append(line(prev, start))
230
+ return segments
231
+
232
+
233
+ def _spline_segments_from_curve(curve, minlen):
234
+ if iscatmullrom(curve):
235
+ meta = curve[2]
236
+ default_segments = max(8, int(round(1.0 / max(minlen, epsilon))))
237
+ segs = int(meta.get('segments_per_span', default_segments))
238
+ samples = sample_catmullrom(curve, segments_per_span=max(1, segs))
239
+ closed = bool(meta.get('closed', False))
240
+ return _points_to_segments(samples, closed=closed)
241
+ if isnurbs(curve):
242
+ meta = curve[2]
243
+ default = max(32, len(curve[1]) * 8)
244
+ count = int(meta.get('samples', default))
245
+ samples = sample_nurbs(curve, samples=max(4, count))
246
+ return _points_to_segments(samples, closed=False)
247
+ return []
248
+
249
+ def _collect_candidate_polygons(gl, minang, minlen, snap_tol):
250
+ """Return raw polygon loops discovered within ``gl``."""
224
251
 
252
+ loops = []
225
253
  segments = []
226
254
 
227
255
  for element in gl:
228
256
  if ispoint(element):
229
257
  continue
230
- elif isline(element) or isarc(element):
258
+ if isline(element) or isarc(element):
231
259
  segments.append(element)
232
- elif ispoly(element):
233
- poly = _finalize_poly_points(element, snap_tol)
234
- area = _poly_signed_area(poly)
235
- if area >= 0:
236
- positive_candidates.append(poly)
237
- else:
238
- negative_candidates.append(poly)
239
- elif isgeomlist(element):
240
- outer, holes = geomlist2poly_with_holes(element, minang, minlen, checkcont)
241
- if outer:
242
- area = _poly_signed_area(outer)
243
- if area >= 0:
244
- positive_candidates.append(outer)
245
- else:
246
- negative_candidates.append(outer)
247
- for h in holes:
248
- area = _poly_signed_area(h)
249
- if area >= 0:
250
- positive_candidates.append(h)
251
- else:
252
- negative_candidates.append(h)
253
- else:
254
- raise ValueError(f'bad object in list passed to geomlist2poly: {element}')
260
+ continue
261
+ if iscatmullrom(element) or isnurbs(element):
262
+ segments.extend(_spline_segments_from_curve(element, minlen))
263
+ continue
264
+ if ispoly(element):
265
+ loops.append(_finalize_poly_points(element, snap_tol))
266
+ continue
267
+ if isgeomlist(element):
268
+ loops.extend(_collect_candidate_polygons(element, minang, minlen, snap_tol))
269
+ continue
270
+ raise ValueError(f'bad object in list passed to geomlist2poly: {element}')
255
271
 
256
272
  if segments:
257
- loops, _ = _stitch_geomlist(segments, snap_tol)
258
- for loop in loops:
273
+ loops_seq, leftovers = _stitch_geomlist(segments, snap_tol)
274
+ for loop in loops_seq:
259
275
  poly, holes = _convert_geom_sequence(loop, minang, minlen, snap_tol)
260
276
  if poly:
261
- area = _poly_signed_area(poly)
262
- if area >= 0:
263
- positive_candidates.append(poly)
264
- else:
265
- negative_candidates.append(poly)
266
- for h in holes:
267
- area = _poly_signed_area(h)
268
- if area >= 0:
269
- positive_candidates.append(h)
270
- else:
271
- negative_candidates.append(h)
277
+ loops.append(poly)
278
+ loops.extend(holes)
279
+ for leftover in leftovers:
280
+ if ispoly(leftover):
281
+ loops.append(_finalize_poly_points(leftover, snap_tol))
282
+ elif isgeomlist(leftover):
283
+ loops.extend(_collect_candidate_polygons(leftover, minang, minlen, snap_tol))
284
+ elif ispoint(leftover):
285
+ continue
286
+ elif isline(leftover) or isarc(leftover):
287
+ poly, holes = _convert_geom_sequence([leftover], minang, minlen, snap_tol)
288
+ if poly:
289
+ loops.append(poly)
290
+ loops.extend(holes)
291
+ elif iscatmullrom(leftover) or isnurbs(leftover):
292
+ segments = _spline_segments_from_curve(leftover, minlen)
293
+ if segments:
294
+ poly, holes = _convert_geom_sequence(segments, minang, minlen, snap_tol)
295
+ if poly:
296
+ loops.append(poly)
297
+ loops.extend(holes)
298
+ else:
299
+ raise ValueError(f'bad object in list passed to geomlist2poly: {leftover}')
272
300
 
273
- if not positive_candidates and not negative_candidates:
274
- return [], []
301
+ return loops
275
302
 
276
- all_candidates = positive_candidates + negative_candidates
277
- outer = max(all_candidates, key=lambda pts: abs(_poly_signed_area(pts)))
278
- outer_area = _poly_signed_area(outer)
279
- orientation = 1 if outer_area >= 0 else -1
280
303
 
281
- holes = []
304
+ def _orient_loop(points, want_positive=True):
305
+ if not points:
306
+ return []
307
+ core = points[:-1] if len(points) > 1 else points
308
+ if len(core) < 3:
309
+ return [point(p) for p in points]
310
+ pts = [point(p) for p in core]
311
+ area = _poly_signed_area(points)
312
+ should_reverse = (want_positive and area < 0) or (not want_positive and area > 0)
313
+ if should_reverse:
314
+ pts.reverse()
315
+ pts.append(point(pts[0]))
316
+ return pts
317
+
318
+
319
+ def _assemble_components(loop_points):
320
+ if not loop_points:
321
+ return []
282
322
 
283
- for pts in all_candidates:
284
- if pts is outer:
323
+ nodes = []
324
+ for pts in loop_points:
325
+ if len(pts) < 3:
285
326
  continue
286
327
  area = _poly_signed_area(pts)
287
- if orientation * area < 0:
288
- holes.append(pts)
328
+ if abs(area) <= epsilon:
289
329
  continue
290
- centroid = _polygon_centroid_xy(pts)
291
- if _point_in_polygon_xy(outer, centroid):
292
- holes.append(pts)
330
+ nodes.append({
331
+ 'points': pts,
332
+ 'area': area,
333
+ 'abs_area': abs(area),
334
+ 'centroid': _polygon_centroid_xy(pts),
335
+ 'parent': None,
336
+ 'children': [],
337
+ })
338
+
339
+ if not nodes:
340
+ return []
293
341
 
294
- return outer, holes
342
+ for idx, node in enumerate(nodes):
343
+ parent_idx = None
344
+ for jdx, candidate in enumerate(nodes):
345
+ if idx == jdx:
346
+ continue
347
+ if candidate['abs_area'] <= node['abs_area'] + epsilon:
348
+ continue
349
+ if _point_in_polygon_xy(candidate['points'], node['centroid']):
350
+ if parent_idx is None or candidate['abs_area'] < nodes[parent_idx]['abs_area']:
351
+ parent_idx = jdx
352
+ node['parent'] = parent_idx
353
+ if parent_idx is not None:
354
+ nodes[parent_idx]['children'].append(idx)
355
+
356
+ components = []
357
+
358
+ def visit(node_idx):
359
+ node = nodes[node_idx]
360
+ outer = _orient_loop(node['points'], want_positive=True)
361
+ holes = []
362
+ extras = []
363
+ for child_idx in node['children']:
364
+ child = nodes[child_idx]
365
+ holes.append(_orient_loop(child['points'], want_positive=False))
366
+ for grand_idx in child['children']:
367
+ extras.extend(visit(grand_idx))
368
+ extras.insert(0, (outer, holes))
369
+ return extras
370
+
371
+ for idx, node in enumerate(nodes):
372
+ if node['parent'] is None:
373
+ components.extend(visit(idx))
374
+
375
+ return components
376
+
377
+
378
+ def geomlist2poly_components(gl, minang=5.0, minlen=0.25, checkcont=False):
379
+ """Return a list of ``(outer, holes)`` tuples extracted from ``gl``."""
380
+
381
+ if checkcont and not iscontinuousgeomlist(gl):
382
+ raise ValueError('non-continuous geometry list passed to geomlist2poly')
383
+
384
+ snap_tol = max(epsilon * 10, minlen * 0.1)
385
+ loops = _collect_candidate_polygons(gl, minang, minlen, snap_tol)
386
+ return _assemble_components(loops)
387
+
388
+
389
+ def geomlist2poly_with_holes(gl, minang=5.0, minlen=0.25, checkcont=False):
390
+ components = geomlist2poly_components(gl, minang, minlen, checkcont)
391
+ if not components:
392
+ return [], []
393
+ return components[0]
295
394
 
296
395
 
297
396
  def _polygon_centroid_xy(pts):
yapcad/io/__init__.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """I/O utilities for yapCAD."""
2
2
 
3
3
  from .stl import write_stl
4
+ from .step import write_step
4
5
 
5
- __all__ = ['write_stl']
6
+ __all__ = ['write_stl', 'write_step']
yapcad/io/step.py ADDED
@@ -0,0 +1,323 @@
1
+ """STEP export utilities for yapCAD surfaces and solids."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass
7
+ from typing import Dict, List, Sequence, TextIO, Tuple
8
+
9
+ from yapcad.geometry_utils import triangles_from_mesh
10
+ from yapcad.mesh import mesh_view
11
+
12
+ Vec3 = Tuple[float, float, float]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class _Vertex:
17
+ coords: Vec3
18
+ point_id: int
19
+ vertex_id: int
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class _EdgeData:
24
+ edge_curve: int
25
+ oriented_forward: int
26
+ oriented_reverse: int
27
+ start_vertex: int
28
+ end_vertex: int
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class _TriRecord:
33
+ v0: _Vertex
34
+ v1: _Vertex
35
+ v2: _Vertex
36
+ normal: Vec3
37
+
38
+
39
+ class _EntityWriter:
40
+ """Helper to append STEP entities with sequential ids."""
41
+
42
+ def __init__(self) -> None:
43
+ self.entities: List[str] = []
44
+
45
+ def add(self, record: str) -> int:
46
+ idx = len(self.entities) + 1
47
+ self.entities.append(f"#{idx} = {record};")
48
+ return idx
49
+
50
+ def write(self, stream: TextIO) -> None:
51
+ for entity in self.entities:
52
+ stream.write(entity + "\n")
53
+
54
+
55
+ def write_step(obj: Sequence,
56
+ path_or_file,
57
+ *,
58
+ name: str = 'yapCAD',
59
+ schema: str = 'AUTOMOTIVE_DESIGN_CC2') -> None:
60
+ """Export ``obj`` (surface or solid) to a STEP file using a faceted BREP."""
61
+
62
+ triangles = list(triangles_from_mesh(mesh_view(obj)))
63
+ if not triangles:
64
+ raise ValueError('object produced no triangles for STEP export')
65
+ writer = _EntityWriter()
66
+
67
+ vertex_map: Dict[Vec3, _Vertex] = {}
68
+
69
+ for tri in triangles:
70
+ for coords in (tri.v0, tri.v1, tri.v2):
71
+ if coords not in vertex_map:
72
+ pt = writer.add(
73
+ f"CARTESIAN_POINT('', ({coords[0]:.6f}, {coords[1]:.6f}, {coords[2]:.6f}))"
74
+ )
75
+ vert = writer.add(f"VERTEX_POINT('', #{pt})")
76
+ vertex_map[coords] = _Vertex(coords, pt, vert)
77
+
78
+ def _line_for_edge(start: _Vertex, end: _Vertex) -> Tuple[int, int, int]:
79
+ direction_vec = _normalize((end.coords[0] - start.coords[0],
80
+ end.coords[1] - start.coords[1],
81
+ end.coords[2] - start.coords[2]))
82
+ direction_id = writer.add(
83
+ f"DIRECTION('', ({direction_vec[0]:.6f}, {direction_vec[1]:.6f}, {direction_vec[2]:.6f}))"
84
+ )
85
+ length = _distance(start.coords, end.coords)
86
+ vector_id = writer.add(f"VECTOR('', #{direction_id}, {length:.6f})")
87
+ line_id = writer.add(f"LINE('', #{start.point_id}, #{vector_id})")
88
+ return direction_id, vector_id, line_id
89
+
90
+ tri_records: List[_TriRecord] = []
91
+ edge_to_faces: Dict[Tuple[int, int], List[int]] = defaultdict(list)
92
+
93
+ for idx, tri in enumerate(triangles):
94
+ v0 = vertex_map[tri.v0]
95
+ v1 = vertex_map[tri.v1]
96
+ v2 = vertex_map[tri.v2]
97
+ tri_records.append(_TriRecord(v0=v0, v1=v1, v2=v2, normal=_normalize(tri.normal)))
98
+
99
+ for start, end in ((v0, v1), (v1, v2), (v2, v0)):
100
+ key = _edge_key(start.vertex_id, end.vertex_id)
101
+ edge_to_faces[key].append(idx)
102
+
103
+ neighbors: Dict[int, set[int]] = {i: set() for i in range(len(tri_records))}
104
+ for faces in edge_to_faces.values():
105
+ for i in range(len(faces)):
106
+ for j in range(i + 1, len(faces)):
107
+ a, b = faces[i], faces[j]
108
+ neighbors[a].add(b)
109
+ neighbors[b].add(a)
110
+
111
+ tri_component = [-1] * len(tri_records)
112
+ components: List[List[int]] = []
113
+
114
+ for idx in range(len(tri_records)):
115
+ if tri_component[idx] != -1:
116
+ continue
117
+ comp_index = len(components)
118
+ stack = [idx]
119
+ tri_component[idx] = comp_index
120
+ comp_faces: List[int] = []
121
+
122
+ while stack:
123
+ current = stack.pop()
124
+ comp_faces.append(current)
125
+ for nb in neighbors[current]:
126
+ if tri_component[nb] == -1:
127
+ tri_component[nb] = comp_index
128
+ stack.append(nb)
129
+
130
+ components.append(comp_faces)
131
+
132
+ component_closed = [True] * len(components)
133
+ for faces in edge_to_faces.values():
134
+ comp_counts: Dict[int, int] = {}
135
+ for face_idx in faces:
136
+ comp_idx = tri_component[face_idx]
137
+ comp_counts[comp_idx] = comp_counts.get(comp_idx, 0) + 1
138
+ for comp_idx, count in comp_counts.items():
139
+ if count != 2:
140
+ component_closed[comp_idx] = False
141
+
142
+ edge_store: Dict[Tuple[int, int], _EdgeData] = {}
143
+ face_ids_by_component: List[List[int]] = [[] for _ in components]
144
+
145
+ for tri_idx, tri in enumerate(tri_records):
146
+ component_index = tri_component[tri_idx]
147
+
148
+ loop_edges: List[int] = []
149
+ for start, end in ((tri.v0, tri.v1), (tri.v1, tri.v2), (tri.v2, tri.v0)):
150
+ key = _edge_key(start.vertex_id, end.vertex_id)
151
+ data = edge_store.get(key)
152
+ if data is None:
153
+ _, _, line_id = _line_for_edge(start, end)
154
+ edge_curve = writer.add(
155
+ f"EDGE_CURVE('', #{start.vertex_id}, #{end.vertex_id}, #{line_id}, .T.)"
156
+ )
157
+ oriented_forward = writer.add(
158
+ f"ORIENTED_EDGE('', *, *, #{edge_curve}, .T.)"
159
+ )
160
+ oriented_reverse = writer.add(
161
+ f"ORIENTED_EDGE('', *, *, #{edge_curve}, .F.)"
162
+ )
163
+ data = _EdgeData(edge_curve, oriented_forward, oriented_reverse,
164
+ start.vertex_id, end.vertex_id)
165
+ edge_store[key] = data
166
+
167
+ if data.start_vertex == start.vertex_id and data.end_vertex == end.vertex_id:
168
+ loop_edges.append(data.oriented_forward)
169
+ else:
170
+ loop_edges.append(data.oriented_reverse)
171
+
172
+ edge_loop = writer.add(
173
+ "EDGE_LOOP('', (" + ", ".join(f"#{eid}" for eid in loop_edges) + "))"
174
+ )
175
+ face_outer = writer.add(f"FACE_OUTER_BOUND('', #{edge_loop}, .T.)")
176
+
177
+ ref_vec = _perpendicular(tri.normal)
178
+ normal_id = writer.add(
179
+ f"DIRECTION('', ({tri.normal[0]:.6f}, {tri.normal[1]:.6f}, {tri.normal[2]:.6f}))"
180
+ )
181
+ ref_dir = writer.add(
182
+ f"DIRECTION('', ({ref_vec[0]:.6f}, {ref_vec[1]:.6f}, {ref_vec[2]:.6f}))"
183
+ )
184
+ axis = writer.add(f"AXIS2_PLACEMENT_3D('', #{tri.v0.point_id}, #{normal_id}, #{ref_dir})")
185
+ plane = writer.add(f"PLANE('', #{axis})")
186
+ face = writer.add(f"ADVANCED_FACE('', (#{face_outer}), #{plane}, .T.)")
187
+ face_ids_by_component[component_index].append(face)
188
+
189
+ representation_items: List[int] = []
190
+ for idx, face_ids in enumerate(face_ids_by_component):
191
+ if not face_ids:
192
+ continue
193
+ shell_name = name if len(face_ids_by_component) == 1 else f"{name}_{idx + 1}"
194
+ if component_closed[idx]:
195
+ shell_id = writer.add(_shell_record('CLOSED_SHELL', face_ids))
196
+ brep_id = writer.add(f"MANIFOLD_SOLID_BREP('{shell_name}', #{shell_id})")
197
+ representation_items.append(brep_id)
198
+ else:
199
+ shell_id = writer.add(_shell_record('OPEN_SHELL', face_ids))
200
+ model_id = writer.add(
201
+ f"SHELL_BASED_SURFACE_MODEL('{shell_name}', (#{shell_id}))"
202
+ )
203
+ representation_items.append(model_id)
204
+
205
+ if not representation_items:
206
+ raise ValueError('STEP export generated no representation items')
207
+
208
+ # Representation contexts and product definitions
209
+ app_context = writer.add("APPLICATION_CONTEXT('mechanical design')")
210
+ prod_context = writer.add(
211
+ f"PRODUCT_CONTEXT('part definition', #{app_context}, 'mechanical')"
212
+ )
213
+ product = writer.add(
214
+ f"PRODUCT('{name}', '{name}', '', (#{prod_context}))"
215
+ )
216
+ formation = writer.add(f"PRODUCT_DEFINITION_FORMATION('', '', #{product})")
217
+ pd_context = writer.add(
218
+ f"PRODUCT_DEFINITION_CONTEXT('design', #{app_context}, 'mechanical')"
219
+ )
220
+ product_def = writer.add(
221
+ f"PRODUCT_DEFINITION('', '', #{formation}, #{pd_context})"
222
+ )
223
+ shape = writer.add(f"PRODUCT_DEFINITION_SHAPE('', '', #{product_def})")
224
+
225
+ length_unit = writer.add(
226
+ "( NAMED_UNIT(*) LENGTH_UNIT() SI_UNIT(.MILLI., .METRE.) )"
227
+ )
228
+ plane_angle_unit = writer.add(
229
+ "( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($, .RADIAN.) )"
230
+ )
231
+ solid_angle_unit = writer.add(
232
+ "( NAMED_UNIT(*) SOLID_ANGLE_UNIT() SI_UNIT($, .STERADIAN.) )"
233
+ )
234
+ measure_with_unit = writer.add(
235
+ f"MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-06), #{length_unit})"
236
+ )
237
+ uncertainty = writer.add(
238
+ f"UNCERTAINTY_MEASURE_WITH_UNIT(#{measure_with_unit}, 'distance accuracy value')"
239
+ )
240
+ geom_context = writer.add(
241
+ "( REPRESENTATION_CONTEXT('', '')"
242
+ " AND GEOMETRIC_REPRESENTATION_CONTEXT(3)"
243
+ f" AND GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{uncertainty}))"
244
+ f" AND GLOBAL_UNIT_ASSIGNED_CONTEXT((#{length_unit}, #{plane_angle_unit}, #{solid_angle_unit})) )"
245
+ )
246
+
247
+ items_block = _format_id_list(representation_items)
248
+ brep_shape = writer.add(
249
+ "ADVANCED_BREP_SHAPE_REPRESENTATION('', (\n"
250
+ + items_block
251
+ + f"\n), #{geom_context})"
252
+ )
253
+ writer.add(f"SHAPE_DEFINITION_REPRESENTATION(#{shape}, #{brep_shape})")
254
+
255
+ close_stream = False
256
+ if hasattr(path_or_file, 'write'):
257
+ stream = path_or_file
258
+ else:
259
+ stream = open(path_or_file, 'w', encoding='utf-8')
260
+ close_stream = True
261
+
262
+ try:
263
+ _write_header(stream, name, schema)
264
+ stream.write("DATA;\n")
265
+ writer.write(stream)
266
+ stream.write("ENDSEC;\nEND-ISO-10303-21;\n")
267
+ finally:
268
+ if close_stream:
269
+ stream.close()
270
+
271
+
272
+ def _write_header(stream: TextIO, name: str, schema: str) -> None:
273
+ stream.write("ISO-10303-21;\n")
274
+ stream.write("HEADER;\n")
275
+ stream.write("FILE_DESCRIPTION(('yapCAD export'),'2;1');\n")
276
+ stream.write(
277
+ "FILE_NAME('"
278
+ + name
279
+ + "','2024-01-01T00:00:00',('yapCAD'),('yapCAD organization'), 'yapCAD', 'yapCAD', '');\n"
280
+ )
281
+ stream.write("FILE_SCHEMA(('" + schema + "'));\n")
282
+ stream.write("ENDSEC;\n")
283
+
284
+
285
+ def _normalize(vec: Vec3) -> Vec3:
286
+ length = (vec[0] ** 2 + vec[1] ** 2 + vec[2] ** 2) ** 0.5
287
+ if length == 0.0:
288
+ return (0.0, 0.0, 1.0)
289
+ return (vec[0] / length, vec[1] / length, vec[2] / length)
290
+
291
+
292
+ def _distance(a: Vec3, b: Vec3) -> float:
293
+ return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) ** 0.5
294
+
295
+
296
+ def _perpendicular(normal: Vec3) -> Vec3:
297
+ x, y, z = normal
298
+ if abs(x) < abs(z):
299
+ return _normalize((-z, 0.0, x))
300
+ return _normalize((0.0, z, -y))
301
+
302
+
303
+ def _edge_key(a: int, b: int) -> Tuple[int, int]:
304
+ return (a, b) if a <= b else (b, a)
305
+
306
+
307
+ def _shell_record(kind: str, face_ids: Sequence[int]) -> str:
308
+ block = _format_id_list(face_ids)
309
+ return f"{kind}('', (\n{block}\n))"
310
+
311
+
312
+ def _format_id_list(ids: Sequence[int], *, indent: str = ' ', per_line: int = 8) -> str:
313
+ if not ids:
314
+ return indent
315
+ lines: List[str] = []
316
+ total = len(ids)
317
+ for index, entity in enumerate(ids):
318
+ suffix = ',' if index + 1 < total else ''
319
+ lines.append(f"{indent}#{entity}{suffix}")
320
+ return "\n".join(lines)
321
+
322
+
323
+ __all__ = ['write_step']