geometrykit 0.5.1__tar.gz → 0.6.0__tar.gz

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.
Files changed (47) hide show
  1. {geometrykit-0.5.1/python/geometrykit.egg-info → geometrykit-0.6.0}/PKG-INFO +1 -1
  2. {geometrykit-0.5.1 → geometrykit-0.6.0}/pyproject.toml +1 -1
  3. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/__init__.py +1 -1
  4. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/geometry/__init__.py +9 -2
  5. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/geometry/curve.py +6 -2
  6. geometrykit-0.6.0/python/geometrykit/geometry/panel.py +140 -0
  7. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/geometry/surface.py +41 -16
  8. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/runtimes/_npu/curve_np.py +80 -0
  9. geometrykit-0.6.0/python/geometrykit/runtimes/_npu/tessellate_np.py +299 -0
  10. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/runtimes/dispatch.py +58 -0
  11. {geometrykit-0.5.1 → geometrykit-0.6.0/python/geometrykit.egg-info}/PKG-INFO +1 -1
  12. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit.egg-info/SOURCES.txt +2 -0
  13. {geometrykit-0.5.1 → geometrykit-0.6.0}/tests/test_engine_parity.py +1 -1
  14. {geometrykit-0.5.1 → geometrykit-0.6.0}/COPYING +0 -0
  15. {geometrykit-0.5.1 → geometrykit-0.6.0}/COPYING.LESSER +0 -0
  16. {geometrykit-0.5.1 → geometrykit-0.6.0}/README.md +0 -0
  17. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/cage.py +0 -0
  18. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/contract.py +0 -0
  19. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/engine.py +0 -0
  20. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/export.py +0 -0
  21. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/geometry/_bridge.py +0 -0
  22. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/geometry/biarc.py +0 -0
  23. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/geometry/field.py +0 -0
  24. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/geometry/frames.py +0 -0
  25. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/geometry/intersection.py +0 -0
  26. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/geometry/path.py +0 -0
  27. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/geometry/patterns.py +0 -0
  28. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/geometry/primitives.py +0 -0
  29. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/geometry/spiral.py +0 -0
  30. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/geometry/subdivision.py +0 -0
  31. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/geometry/transform.py +0 -0
  32. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/runtimes/__init__.py +0 -0
  33. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/runtimes/_npu/__init__.py +0 -0
  34. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/runtimes/_npu/fit_np.py +0 -0
  35. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/runtimes/_npu/linalg_np.py +0 -0
  36. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/runtimes/_npu/patch_g1.py +0 -0
  37. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/runtimes/_npu/patch_np.py +0 -0
  38. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/runtimes/_npu/section_np.py +0 -0
  39. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit/runtimes/_npu/surface_np.py +0 -0
  40. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit.egg-info/dependency_links.txt +0 -0
  41. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit.egg-info/requires.txt +0 -0
  42. {geometrykit-0.5.1 → geometrykit-0.6.0}/python/geometrykit.egg-info/top_level.txt +0 -0
  43. {geometrykit-0.5.1 → geometrykit-0.6.0}/setup.cfg +0 -0
  44. {geometrykit-0.5.1 → geometrykit-0.6.0}/tests/test_cage.py +0 -0
  45. {geometrykit-0.5.1 → geometrykit-0.6.0}/tests/test_contract.py +0 -0
  46. {geometrykit-0.5.1 → geometrykit-0.6.0}/tests/test_engine_probe.py +0 -0
  47. {geometrykit-0.5.1 → geometrykit-0.6.0}/tests/test_repo_boundary.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: geometrykit
3
- Version: 0.5.1
3
+ Version: 0.6.0
4
4
  Summary: Geometry primitives and NURBS data model — the geometry substrate for the ifckit family
5
5
  Author: Sander Boer
6
6
  License-Expression: LGPL-3.0-or-later
@@ -12,7 +12,7 @@ build-backend = "setuptools.build_meta"
12
12
 
13
13
  [project]
14
14
  name = "geometrykit"
15
- version = "0.5.1"
15
+ version = "0.6.0"
16
16
  description = "Geometry primitives and NURBS data model — the geometry substrate for the ifckit family"
17
17
  readme = "README.md"
18
18
  requires-python = ">=3.9"
@@ -21,7 +21,7 @@ from geometrykit.geometry import (
21
21
  Vec,
22
22
  )
23
23
 
24
- __version__ = "0.5.1"
24
+ __version__ = "0.6.0"
25
25
 
26
26
  __all__ = [
27
27
  "__version__",
@@ -1,7 +1,8 @@
1
1
  # This file was generated with the assistance of an AI coding tool.
2
2
  # Ported from ifckit/ifckit/geometry/__init__.py (LGPL-3.0-or-later heritage;
3
- # ifckit is authored by the same copyright holder). panel_report and the
4
- # IFC serialisation layer (ifc_geom) stay in ifckit by design.
3
+ # ifckit is authored by the same copyright holder). The IFC
4
+ # serialisation layer (ifc_geom) stays in ifckit by design; the panel
5
+ # metrics (panel) were promoted here per the geometry-ownership plan.
5
6
 
6
7
  """
7
8
  geometrykit.geometry
@@ -19,6 +20,7 @@ curve — Curve
19
20
  surface — Surface (eval/normal/tessellate kernels; OCC oracle-compare only)
20
21
  field — Field, FieldPoint, trim_grid, crossing_grid, divide_trim_edge
21
22
  patterns — quad/diamond/triangle/wave/anglebox/brick/truchet/hex/hankin cells
23
+ panel — FieldReport, cells_report, field_report (panel metrics)
22
24
  intersection — Intersection
23
25
  subdivision — catmull_clark, extract_patches, write_obj
24
26
  frames — FrameField, transport_frames, fixed_ref_frames, upvector_frames
@@ -42,6 +44,7 @@ from geometrykit.geometry.frames import (
42
44
  upvector_frames,
43
45
  )
44
46
  from geometrykit.geometry.intersection import Intersection
47
+ from geometrykit.geometry.panel import FieldReport, cells_report, field_report
45
48
  from geometrykit.geometry.path import Path, PathReport, assemble_path
46
49
  from geometrykit.geometry.patterns import (
47
50
  DiamondPattern,
@@ -122,6 +125,10 @@ __all__ = [
122
125
  "truchet_cells",
123
126
  "hankin_cells",
124
127
  "triangle_cells",
128
+ # panel
129
+ "FieldReport",
130
+ "cells_report",
131
+ "field_report",
125
132
  # subdivision
126
133
  "catmull_clark",
127
134
  "extract_patches",
@@ -451,12 +451,16 @@ class Curve:
451
451
  except ImportError:
452
452
  pass
453
453
 
454
- n = num if num is not None else int(total / step) + 1
454
+ # Kernel arc-length division (no OCC), engine-dispatched
455
+ # (mirror or the `_rust` accelerator, bit-identical).
455
456
  from geometrykit.geometry.path import PathPoint
457
+ from geometrykit.runtimes import dispatch
456
458
 
457
459
  return [
458
460
  PathPoint(t=t, point=self.point_at(t), tangent=self.tangent_at(t))
459
- for t in (i / (n - 1) for i in range(n))
461
+ for t in dispatch.arclength_divide(
462
+ self, total, num=num, dist=dist, even=even
463
+ )
460
464
  ]
461
465
 
462
466
  @property
@@ -0,0 +1,140 @@
1
+ # This file was generated with the assistance of an AI coding tool.
2
+ # Promoted from ifckit/ifckit/geometry/panel_report.py (LGPL-3.0-or-later
3
+ # heritage; ifckit is authored by the same copyright holder as
4
+ # geometrykit).
5
+ """Buildability metrics for panel populations (Eigensatz 2010 vocabulary).
6
+
7
+ ``field_report`` measures a cell population: edge-length spread
8
+ (limited size bandwidth), right-angle deviation and planarity per
9
+ cell. Seam divergence/kink tracking is a follow-up once populations
10
+ map to IFC products — the per-cell metrics here are its input. Pure
11
+ Python.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from dataclasses import dataclass
18
+ from dataclasses import field as _dc_field
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ from geometrykit.geometry.field import Field
22
+ from geometrykit.geometry.primitives import Vec, _fmt_num, _new_id, _short_id
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class FieldReport:
27
+ """Immutable metric snapshot of a panel population.
28
+
29
+ Attributes:
30
+ n_cells: Number of measured cells.
31
+ edge_min: Smallest edge length.
32
+ edge_mean: Mean edge length.
33
+ edge_max: Largest edge length.
34
+ angle_max_dev: Largest absolute deviation from the regular-polygon
35
+ interior angle (90° for quads, 60° for triangles), in degrees.
36
+ planarity_max: Largest distance of a corner to the plane of the
37
+ first three (model units; always 0 for triangles).
38
+ """
39
+
40
+ type: str = "field_report"
41
+ id: Optional[str] = None
42
+ meta: Dict[str, Any] = _dc_field(default_factory=dict)
43
+ n_cells: int = 0
44
+ edge_min: float = 0.0
45
+ edge_mean: float = 0.0
46
+ edge_max: float = 0.0
47
+ angle_max_dev: float = 0.0
48
+ planarity_max: float = 0.0
49
+
50
+ def to_dict(self) -> Dict[str, Any]:
51
+ """Serialise to a plain (JSON-safe) dict."""
52
+ return {
53
+ "type": self.type,
54
+ "id": self.id,
55
+ "meta": dict(self.meta),
56
+ "n_cells": self.n_cells,
57
+ "edge_min": self.edge_min,
58
+ "edge_mean": self.edge_mean,
59
+ "edge_max": self.edge_max,
60
+ "angle_max_dev": self.angle_max_dev,
61
+ "planarity_max": self.planarity_max,
62
+ }
63
+
64
+ def __str__(self) -> str:
65
+ return (
66
+ f"FieldReport(id={_short_id(self.id)}, n={self.n_cells}, "
67
+ f"edge=[{_fmt_num(self.edge_min)}..{_fmt_num(self.edge_max)}], "
68
+ f"angle_dev={_fmt_num(self.angle_max_dev)}°, "
69
+ f"planar={_fmt_num(self.planarity_max)})"
70
+ )
71
+
72
+
73
+ def cells_report(cells: List[List[Vec]]) -> FieldReport:
74
+ """Measure a population of polygonal cells (any corner count >= 3).
75
+
76
+ Args:
77
+ cells: Corner lists (``[Vec × n]``), e.g. from ``patterns``.
78
+ Mixed populations are measured jointly against their own
79
+ regular-polygon angles.
80
+
81
+ Returns:
82
+ ``FieldReport`` with zeros when *cells* is empty.
83
+ """
84
+ edges: List[float] = []
85
+ max_dev = 0.0
86
+ max_flat = 0.0
87
+ for poly in cells:
88
+ n = len(poly)
89
+ if n < 3:
90
+ raise ValueError(f"Expected polygonal cells, got {n} corners")
91
+ ref_angle = (n - 2) * 180.0 / n
92
+ for k in range(n):
93
+ p, q = poly[k], poly[(k + 1) % n]
94
+ edges.append((q - p).length())
95
+ for k in range(n):
96
+ u, v = poly[(k - 1) % n] - poly[k], poly[(k + 1) % n] - poly[k]
97
+ denom = u.length() * v.length()
98
+ if denom < 1e-18:
99
+ continue
100
+ cos_a = max(-1.0, min(1.0, u.dot(v) / denom))
101
+ max_dev = max(max_dev, abs(ref_angle - math.degrees(math.acos(cos_a))))
102
+ a, b, c = poly[0], poly[1], poly[2]
103
+ nrm = (b - a) ** (c - a)
104
+ nl = nrm.length()
105
+ if nl > 1e-18:
106
+ for p in poly[3:]:
107
+ max_flat = max(max_flat, abs((p - a).dot(nrm / nl)))
108
+ if not edges:
109
+ return FieldReport(id=_new_id())
110
+ return FieldReport(
111
+ id=_new_id(),
112
+ n_cells=len(cells),
113
+ edge_min=min(edges),
114
+ edge_mean=sum(edges) / len(edges),
115
+ edge_max=max(edges),
116
+ angle_max_dev=max_dev,
117
+ planarity_max=max_flat,
118
+ )
119
+
120
+
121
+ def field_report(field: Field) -> FieldReport:
122
+ """Measure the quad cells of a built field.
123
+
124
+ Args:
125
+ field: Built field (see ``geometrykit.geometry.field``).
126
+
127
+ Returns:
128
+ ``FieldReport`` over ``field.cell_points()``.
129
+ """
130
+ rep = cells_report(field.cell_points())
131
+ return FieldReport(
132
+ id=_new_id(),
133
+ meta={"field_id": field.id},
134
+ n_cells=rep.n_cells,
135
+ edge_min=rep.edge_min,
136
+ edge_mean=rep.edge_mean,
137
+ edge_max=rep.edge_max,
138
+ angle_max_dev=rep.angle_max_dev,
139
+ planarity_max=rep.planarity_max,
140
+ )
@@ -276,33 +276,54 @@ class Surface:
276
276
  material: "dict | None" = None,
277
277
  deflection: "float | object" = 0.01,
278
278
  y_up: bool = True,
279
+ method: str = "occ",
279
280
  ) -> dict:
280
- """Triangulate the surface via OCC and return a dict for 3D viewer.
281
-
282
- Uses OCC ``BRepMesh_IncrementalMesh`` (requires ``pythonocc-core``).
281
+ """Triangulate the surface and return a dict for 3D viewer.
283
282
 
284
283
  Args:
285
- nu: Ignored (OCC adaptive mesh).
286
- nv: Ignored (OCC adaptive mesh).
284
+ nu: Ignored (both methods determine density themselves).
285
+ nv: Ignored (both methods determine density themselves).
287
286
  label: Display name.
288
287
  material: Visual properties (color, opacity, …).
289
288
  deflection: Mesh deflection / ``TessellationDetail``.
290
289
  y_up: If True (default), convert coordinates to
291
290
  Three.js/glTF Y-up: ``(x, z, -y)``.
291
+ method: ``"occ"`` (default, unchanged): OCC
292
+ ``BRepMesh_IncrementalMesh`` (requires
293
+ ``pythonocc-core``). ``"adaptive"``: the
294
+ kernel tessellator (no OCC needed; watertight,
295
+ deflection-bounded, deterministic).
292
296
 
293
297
  Returns:
294
298
  A dict with ``primitive="triangles"``, ``positions``,
295
299
  ``indices``, and optional ``label``, ``material``.
296
300
  """
297
- from geometrykit.geometry.surface import occ_tessellate, require_occ
298
-
299
- require_occ()
300
- verts, tris = occ_tessellate(self, deflection)
301
- if y_up:
302
- positions = [c for v in verts for c in (v[0], v[2], -v[1])]
301
+ if method not in ("occ", "adaptive"):
302
+ raise ValueError(f"unknown mesh method {method!r}: use 'occ' or 'adaptive'")
303
+ if method == "adaptive":
304
+ from geometrykit.runtimes._npu import tessellate_np
305
+
306
+ tolerance = deflection if isinstance(deflection, float) else 0.01
307
+ verts, _normals, tris, _uvs = tessellate_np.adaptive_mesh(
308
+ self, deflection=tolerance
309
+ )
310
+ if y_up:
311
+ positions = []
312
+ for k in range(0, len(verts), 3):
313
+ positions.extend((verts[k], verts[k + 2], -verts[k + 1]))
314
+ else:
315
+ positions = list(verts)
316
+ indices = [i for tri in tris for i in tri]
303
317
  else:
304
- positions = [c for v in verts for c in v]
305
- indices = [i - 1 for tri in tris for i in tri]
318
+ from geometrykit.geometry.surface import occ_tessellate, require_occ
319
+
320
+ require_occ()
321
+ verts, tris = occ_tessellate(self, deflection)
322
+ if y_up:
323
+ positions = [c for v in verts for c in (v[0], v[2], -v[1])]
324
+ else:
325
+ positions = [c for v in verts for c in v]
326
+ indices = [i - 1 for tri in tris for i in tri]
306
327
 
307
328
  d: dict = {
308
329
  "primitive": "triangles",
@@ -320,11 +341,13 @@ class Surface:
320
341
  material: "dict | None" = None,
321
342
  deflection: "float | object" = 0.01,
322
343
  y_up: bool = True,
344
+ method: str = "occ",
323
345
  ) -> dict:
324
346
  """Return a ``__type__: "mesh"`` dict ready for the viewer pipeline.
325
347
 
326
- Uses OCC adaptive triangulation. See ``Path.preview()`` for
327
- the full material documentation.
348
+ ``method`` selects the tessellator: ``"occ"`` (default,
349
+ unchanged) or ``"adaptive"`` (kernel, no OCC needed). See
350
+ ``Path.preview()`` for the full material documentation.
328
351
 
329
352
  Args:
330
353
  label: Display name.
@@ -332,13 +355,15 @@ class Surface:
332
355
  deflection: Mesh deflection / ``TessellationDetail`` (see ifckit).
333
356
  y_up: If True (default), convert coordinates to
334
357
  Three.js/glTF Y-up: ``(x, z, -y)``.
358
+ method: ``"occ"`` or ``"adaptive"`` (see ``to_mesh_dict``).
335
359
 
336
360
  Returns:
337
361
  ``{"__type__": "mesh", "primitive": "triangles", …}``
338
362
  """
339
363
  return {
340
364
  "__type__": "mesh",
341
- **self.to_mesh_dict(label=label, material=material, deflection=deflection, y_up=y_up),
365
+ **self.to_mesh_dict(label=label, material=material, deflection=deflection, y_up=y_up,
366
+ method=method),
342
367
  }
343
368
 
344
369
  # ── IFC serialisation ──────────────────────────────────────────
@@ -349,3 +349,83 @@ def group_and_orient(curves: Sequence, tol: float = 1e-6) -> list:
349
349
 
350
350
  groups.append(oriented)
351
351
  return groups
352
+
353
+
354
+ def arclength_divide(
355
+ point_at,
356
+ total: float,
357
+ num: "int | None" = None,
358
+ dist: "float | None" = None,
359
+ even: bool = True,
360
+ ) -> list:
361
+ """Kernel arc-length division: normalized parameters ``∈ [0, 1]``.
362
+
363
+ A dense chord table over ``point_at`` is inverted per target
364
+ distance (binary search + linear interpolation). Arc-length
365
+ uniformity holds within a few percent on fair curves (the old
366
+ parameter-uniform fallback measured 2.7x gap spread on a Hermite
367
+ wiggle); straight chords between outputs vary more where the
368
+ curve bends, which is geometric, not error.
369
+
370
+ Args:
371
+ point_at: callable ``t → xyz`` (t in [0, 1]).
372
+ total: curve length in the same units as ``dist``.
373
+ num: point count (endpoints included).
374
+ dist: target spacing; ``even`` distributes evenly with the
375
+ endpoints included, otherwise fixed steps from the
376
+ start plus the exact endpoint.
377
+ even: spacing rule for *dist* (see above).
378
+
379
+ Returns:
380
+ Ascending parameter list (first 0.0, last 1.0).
381
+ """
382
+ import math
383
+
384
+ if total < _EPS:
385
+ raise ValueError("Cannot divide a zero-length curve")
386
+ table_n = max(64, 8 * (num if num is not None else 16))
387
+ samples = [point_at(i / table_n) for i in range(table_n + 1)]
388
+ cumulative = [0.0]
389
+ for a, b in zip(samples, samples[1:]):
390
+ cumulative.append(
391
+ cumulative[-1]
392
+ + math.sqrt(
393
+ (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2 + (b[2] - a[2]) ** 2
394
+ )
395
+ )
396
+ table_total = cumulative[-1]
397
+ if table_total < _EPS:
398
+ raise ValueError("Cannot divide a zero-length curve")
399
+ if num is not None:
400
+ targets = [table_total * k / (num - 1) for k in range(num)]
401
+ elif even:
402
+ segs = max(1, math.ceil(table_total / dist))
403
+ targets = [table_total * k / segs for k in range(segs + 1)]
404
+ else:
405
+ targets = []
406
+ d = 0.0
407
+ while d < table_total:
408
+ targets.append(d)
409
+ d += dist
410
+ targets.append(table_total)
411
+
412
+ out = []
413
+ for target in targets:
414
+ lo, hi = 0, table_n
415
+ while lo < hi:
416
+ mid = (lo + hi) // 2
417
+ if cumulative[mid] < target:
418
+ lo = mid + 1
419
+ else:
420
+ hi = mid
421
+ if lo <= 0:
422
+ out.append(0.0)
423
+ elif lo > table_n:
424
+ out.append(1.0)
425
+ else:
426
+ span = cumulative[lo] - cumulative[lo - 1]
427
+ if span <= 0.0:
428
+ out.append(lo / table_n)
429
+ else:
430
+ out.append((lo - 1 + (target - cumulative[lo - 1]) / span) / table_n)
431
+ return out
@@ -0,0 +1,299 @@
1
+ # This file was generated with the assistance of an AI coding tool.
2
+ """Adaptive kernel tessellation: OCC-free surface meshing.
3
+
4
+ A restricted quadtree over the parameter domain: quads split while
5
+ their geometric error exceeds the tolerances, neighbours are forced
6
+ to differ by at most one level, and split edges close with a fan from
7
+ the evaluated centroid (green closure). Every vertex is an evaluated
8
+ surface point and every edge is shared exactly, so the mesh is
9
+ watertight by construction; the only numerical content is the kernel
10
+ evaluation itself.
11
+
12
+ Error metric per quad: maximum edge-midpoint deviation (surface point
13
+ at the edge midpoint vs. the 3D edge midpoint) against ``deflection``,
14
+ and maximum corner-normal deviation against the quad normal versus
15
+ ``angle_tol`` (radians). Stations are dyadic integer lattice points
16
+ ``(iu, iv, level)`` with ``u = u0 + (u1-u0)*iu/2**level``, so shared
17
+ edges compute bit-identical vertices on both sides.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+
24
+ from geometrykit.runtimes._npu.surface_np import (
25
+ _data_of,
26
+ _eval_all_data,
27
+ )
28
+
29
+
30
+ def _station_uv(u0: float, u1: float, v0: float, v1: float,
31
+ iu: int, iv: int, level: int) -> tuple:
32
+ """Domain point of a dyadic lattice station."""
33
+ scale = 1.0 / (1 << level)
34
+ return (
35
+ u0 + (u1 - u0) * iu * scale,
36
+ v0 + (v1 - v0) * iv * scale,
37
+ )
38
+
39
+
40
+ def adaptive_mesh(surf, deflection: float = 0.01, angle_tol: float = 0.5,
41
+ max_depth: int = 8, base_level: int = 2) -> tuple:
42
+ """Triangulate *surf* adaptively; ``(verts, normals, tris)``.
43
+
44
+ Args:
45
+ surf: kernel Surface.
46
+ deflection: maximum distance of the surface from any triangle.
47
+ angle_tol: maximum normal deviation within a quad (radians).
48
+ max_depth: deepest quad level (base_level + refinements).
49
+ base_level: starting grid is ``2**base_level`` quads per side.
50
+
51
+ Returns:
52
+ ``(verts, normals, tris, uvs)`` — ``verts`` flat ``[x, y, z]``,
53
+ ``normals`` flat unit normals per vertex, ``tris`` index
54
+ triples, ``uvs`` flat ``[u, v]`` per vertex (the parameter
55
+ track, like section chains carry). Every edge is shared by
56
+ exactly two triangles (watertight) on non-degenerate
57
+ surfaces.
58
+ """
59
+ if deflection <= 0.0:
60
+ raise ValueError(f"deflection must be positive, got {deflection}")
61
+ if angle_tol <= 0.0:
62
+ raise ValueError(f"angle_tol must be positive, got {angle_tol}")
63
+ if max_depth < base_level:
64
+ raise ValueError(
65
+ f"max_depth {max_depth} below base_level {base_level}"
66
+ )
67
+ d = _data_of(surf)
68
+ u0, u1 = d.uflat[d.pu], d.uflat[-d.pu - 1]
69
+ v0, v1 = d.vflat[d.pv], d.vflat[-d.pv - 1]
70
+ if not (u1 > u0 and v1 > v0):
71
+ raise ValueError("degenerate parameter domain")
72
+
73
+ # eval cache: (iu, iv, level) -> (point, normal), one basis build
74
+ cache: dict = {}
75
+
76
+ def _eval(iu: int, iv: int, level: int) -> tuple:
77
+ key = (iu, iv, level)
78
+ hit = cache.get(key)
79
+ if hit is None:
80
+ u, v = _station_uv(u0, u1, v0, v1, iu, iv, level)
81
+ pt, su, sv = _eval_all_data(d, u, v)
82
+ nx = su.y * sv.z - su.z * sv.y
83
+ ny = su.z * sv.x - su.x * sv.z
84
+ nz = su.x * sv.y - su.y * sv.x
85
+ n = math.sqrt(nx * nx + ny * ny + nz * nz)
86
+ if n > 0.0:
87
+ nx, ny, nz = nx / n, ny / n, nz / n
88
+ hit = ((pt.x, pt.y, pt.z), (nx, ny, nz))
89
+ cache[key] = hit
90
+ return hit
91
+
92
+ def _corners(i: int, j: int, level: int) -> list:
93
+ return [(i, j, level), (i + 1, j, level),
94
+ (i + 1, j + 1, level), (i, j + 1, level)]
95
+
96
+ def _mid(a: tuple, b: tuple) -> tuple:
97
+ (aiu, aiv, al), (biu, biv, bl) = a, b
98
+ assert al == bl, "midpoint of mixed-level stations"
99
+ return (aiu + biu, aiv + biv, al + 1)
100
+
101
+ def _quad_error(i: int, j: int, level: int) -> tuple:
102
+ """(edge deviation, normal deviation) of one quad.
103
+
104
+ Edge deviation covers quad edges, both diagonals and the
105
+ centroid spoke deviations, so every emitted triangle edge is
106
+ bounded (green-closure fans introduce diagonals and spokes
107
+ that edge-only checks would miss).
108
+ """
109
+ corners = _corners(i, j, level)
110
+ pts = [_eval(*c)[0] for c in corners]
111
+ norms = [_eval(*c)[1] for c in corners]
112
+ edge_dev = 0.0
113
+ for k in range(4):
114
+ a, b = corners[k], corners[(k + 1) % 4]
115
+ mid = _eval(*_mid(a, b))[0]
116
+ pa, pb = pts[k], pts[(k + 1) % 4]
117
+ edge_dev = max(edge_dev, abs(mid[0] - 0.5 * (pa[0] + pb[0]))
118
+ + abs(mid[1] - 0.5 * (pa[1] + pb[1]))
119
+ + abs(mid[2] - 0.5 * (pa[2] + pb[2])))
120
+ # diagonals and centroid (fan spokes land here)
121
+ cu = (corners[0][0] + corners[2][0], corners[0][1] + corners[2][1],
122
+ corners[0][2] + 1)
123
+ cc = _eval(*cu)[0]
124
+ for pa, pb in ((pts[0], pts[2]), (pts[1], pts[3])):
125
+ edge_dev = max(edge_dev, abs(cc[0] - 0.5 * (pa[0] + pb[0]))
126
+ + abs(cc[1] - 0.5 * (pa[1] + pb[1]))
127
+ + abs(cc[2] - 0.5 * (pa[2] + pb[2])))
128
+ bil = tuple(sum(p[k] for p in pts) / 4.0 for k in range(3))
129
+ edge_dev = max(edge_dev, abs(cc[0] - bil[0])
130
+ + abs(cc[1] - bil[1]) + abs(cc[2] - bil[2]))
131
+ # fan spokes: the green-closure fan connects the centroid to
132
+ # every boundary station, so bound every spoke midpoint (covers
133
+ # all split patterns; edge midpoints already evaluated above)
134
+ cstat = (corners[0][0] + corners[2][0], corners[0][1] + corners[2][1],
135
+ corners[0][2] + 1)
136
+ boundary = list(corners)
137
+ for k in range(4):
138
+ a, b = corners[k], corners[(k + 1) % 4]
139
+ boundary.append(_mid(a, b))
140
+ for (biu, biv, bl) in boundary:
141
+ # centroid lives at level L+1, boundary at L or L+1:
142
+ # lift the boundary numerator before halving
143
+ lift = 1 << (cstat[2] - bl)
144
+ mstat = (cstat[0] + lift * biu, cstat[1] + lift * biv,
145
+ cstat[2] + 1)
146
+ sm = _eval(*mstat)[0]
147
+ bp = _eval(biu, biv, bl)[0]
148
+ edge_dev = max(edge_dev, abs(sm[0] - 0.5 * (cc[0] + bp[0]))
149
+ + abs(sm[1] - 0.5 * (cc[1] + bp[1]))
150
+ + abs(sm[2] - 0.5 * (cc[2] + bp[2])))
151
+ # quad normal from the diagonals
152
+ d0 = (pts[2][0] - pts[0][0], pts[2][1] - pts[0][1], pts[2][2] - pts[0][2])
153
+ d1 = (pts[3][0] - pts[1][0], pts[3][1] - pts[1][1], pts[3][2] - pts[1][2])
154
+ qn = (d0[1] * d1[2] - d0[2] * d1[1],
155
+ d0[2] * d1[0] - d0[0] * d1[2],
156
+ d0[0] * d1[1] - d0[1] * d1[0])
157
+ ql = math.sqrt(qn[0] ** 2 + qn[1] ** 2 + qn[2] ** 2)
158
+ normal_dev = 0.0
159
+ if ql > 0.0:
160
+ qn = (qn[0] / ql, qn[1] / ql, qn[2] / ql)
161
+ for n in norms:
162
+ cos_a = max(-1.0, min(1.0, n[0] * qn[0] + n[1] * qn[1] + n[2] * qn[2]))
163
+ normal_dev = max(normal_dev, math.acos(cos_a))
164
+ return edge_dev, normal_dev
165
+
166
+ # leaves: (i, j, level) quads, base grid first
167
+ n_base = 1 << base_level
168
+ leaves: dict = {
169
+ (i, j, base_level): None
170
+ for i in range(n_base) for j in range(n_base)
171
+ }
172
+
173
+ def _neighbors(i: int, j: int, level: int) -> list:
174
+ """Candidate edge-neighbours at levels level-1..level+1."""
175
+ found = []
176
+ for key in ((i - 1, j, level), (i + 1, j, level),
177
+ (i, j - 1, level), (i, j + 1, level),
178
+ (i >> 1, j >> 1, level - 1)
179
+ if level > 0 else None):
180
+ if key is not None and key in leaves and key not in found:
181
+ found.append(key)
182
+ for key in ((2 * i - 1, 2 * j, level + 1),
183
+ (2 * i + 2, 2 * j, level + 1),
184
+ (2 * i - 1, 2 * j + 1, level + 1),
185
+ (2 * i + 2, 2 * j + 1, level + 1),
186
+ (2 * i, 2 * j - 1, level + 1),
187
+ (2 * i + 1, 2 * j - 1, level + 1),
188
+ (2 * i, 2 * j + 2, level + 1),
189
+ (2 * i + 1, 2 * j + 2, level + 1)):
190
+ if key in leaves and key not in found:
191
+ found.append(key)
192
+ return found
193
+
194
+ # refine to fixpoint: error splits, then balance (2:1 at most)
195
+ while True:
196
+ marked = set()
197
+ for key in sorted(leaves):
198
+ i, j, level = key
199
+ if level >= max_depth:
200
+ continue
201
+ edge_dev, normal_dev = _quad_error(i, j, level)
202
+ if edge_dev > deflection or normal_dev > angle_tol:
203
+ marked.add(key)
204
+ if not marked:
205
+ break
206
+ # balance: no edge-neighbour more than one level coarser
207
+ while True:
208
+ extra = set()
209
+ for key in sorted(leaves):
210
+ if key in marked:
211
+ continue
212
+ i, j, level = key
213
+ for nb in _neighbors(i, j, level):
214
+ if nb in marked:
215
+ continue
216
+ if nb[2] < level - 1 and nb[2] < max_depth:
217
+ # a marked finer quad touches this one: split up
218
+ if any(m[2] > nb[2] + 1 for m in marked
219
+ if _quads_touch(nb, m)):
220
+ extra.add(nb)
221
+ if not extra:
222
+ break
223
+ marked |= extra
224
+ for key in marked:
225
+ i, j, level = key
226
+ del leaves[key]
227
+ leaves[(2 * i, 2 * j, level + 1)] = None
228
+ leaves[(2 * i + 1, 2 * j, level + 1)] = None
229
+ leaves[(2 * i, 2 * j + 1, level + 1)] = None
230
+ leaves[(2 * i + 1, 2 * j + 1, level + 1)] = None
231
+
232
+ # triangulate with green closure: split edges fan from the
233
+ # evaluated centroid; every vertex is on the surface
234
+ verts: list = []
235
+ normals: list = []
236
+ uvs: list = []
237
+ index_of: dict = {}
238
+
239
+ def _vertex(station: tuple) -> int:
240
+ idx = index_of.get(station)
241
+ if idx is None:
242
+ (px, py, pz), (nx, ny, nz) = _eval(*station)
243
+ idx = len(verts) // 3
244
+ verts.extend((px, py, pz))
245
+ normals.extend((nx, ny, nz))
246
+ u, v = _station_uv(u0, u1, v0, v1, *station)
247
+ uvs.extend((u, v))
248
+ index_of[station] = idx
249
+ return idx
250
+
251
+ def _covers(candidate: tuple, a: tuple, b: tuple, level: int) -> bool:
252
+ """True when the level-(level+1) quad covers part of edge a-b."""
253
+ (ni, nj, _nl) = candidate
254
+ (aiu, aiv, _al), (biu, biv, _bl) = a, b
255
+ if aiu == biu: # vertical edge
256
+ eu = 2 * aiu
257
+ lo, hi = sorted((2 * aiv, 2 * biv))
258
+ return (ni == eu or ni + 1 == eu) and nj < hi and nj + 1 > lo
259
+ # horizontal edge
260
+ ev = 2 * aiv
261
+ lo, hi = sorted((2 * aiu, 2 * biu))
262
+ return (nj == ev or nj + 1 == ev) and ni < hi and ni + 1 > lo
263
+
264
+ tris: list = []
265
+ for key in sorted(leaves):
266
+ i, j, level = key
267
+ corners = _corners(i, j, level)
268
+ finer = [nb for nb in _neighbors(i, j, level) if nb[2] == level + 1]
269
+ loop = [corners[0]]
270
+ for k in range(4):
271
+ a, b = corners[k], corners[(k + 1) % 4]
272
+ if any(_covers(nb, a, b, level) for nb in finer):
273
+ loop.append(_mid(a, b))
274
+ if k < 3:
275
+ loop.append(b)
276
+ if len(loop) == 4:
277
+ a, b, c, e = (_vertex(p) for p in loop)
278
+ tris.extend(((a, b, c), (a, c, e)))
279
+ else:
280
+ center = (corners[0][0] + corners[2][0],
281
+ corners[0][1] + corners[2][1], corners[0][2] + 1)
282
+ c = _vertex(center)
283
+ ring = [_vertex(p) for p in loop]
284
+ for k in range(len(ring)):
285
+ tris.append((c, ring[k], ring[(k + 1) % len(ring)]))
286
+ return verts, normals, tris, uvs
287
+
288
+
289
+ def _quads_touch(a: tuple, b: tuple) -> bool:
290
+ """True when quads a and b share an edge segment."""
291
+ (ai, aj, al), (bi, bj, bl) = a, b
292
+ scale = 1 << max(al, bl)
293
+ au0, au1 = ai * (scale >> al), (ai + 1) * (scale >> al)
294
+ av0, av1 = aj * (scale >> al), (aj + 1) * (scale >> al)
295
+ bu0, bu1 = bi * (scale >> bl), (bi + 1) * (scale >> bl)
296
+ bv0, bv1 = bj * (scale >> bl), (bj + 1) * (scale >> bl)
297
+ share_v = (au1 == bu0 or bu1 == au0) and av0 < bv1 and bv0 < av1
298
+ share_h = (av1 == bv0 or bv1 == av0) and au0 < bu1 and bu0 < au1
299
+ return share_v or share_h
@@ -51,6 +51,64 @@ def basis_row_list(degree: int, uflat, stations):
51
51
  ]
52
52
 
53
53
 
54
+ def tessellate(surf, deflection: float = 0.01, angle_tol: float = 0.5,
55
+ max_depth: int = 8, base_level: int = 2):
56
+ """Mirror `tessellate_np.adaptive_mesh` under the active engine.
57
+
58
+ Returns `(verts, normals, tris, uvs)` — flat xyz vertices, flat
59
+ unit normals, flat index triples, flat uv tracks. Bit-identical
60
+ across engines by harness.
61
+ """
62
+ from geometrykit.runtimes._npu import tessellate_np
63
+
64
+ accel = _rust()
65
+ if accel is None:
66
+ return tessellate_np.adaptive_mesh(
67
+ surf, deflection=deflection, angle_tol=angle_tol,
68
+ max_depth=max_depth, base_level=base_level,
69
+ )
70
+ from geometrykit import _rust as _ext
71
+
72
+ verts, normals, flat_tris, uvs = _ext.tessellate_grid(
73
+ surf, float(deflection), float(angle_tol), int(max_depth),
74
+ int(base_level),
75
+ )
76
+ tris = [
77
+ (flat_tris[k], flat_tris[k + 1], flat_tris[k + 2])
78
+ for k in range(0, len(flat_tris), 3)
79
+ ]
80
+ return verts, normals, tris, uvs
81
+
82
+
83
+ def arclength_divide(curve, total: float, num=None, dist=None, even: bool = True):
84
+ """Mirror `curve_np.arclength_divide` under the active engine.
85
+
86
+ Returns ascending normalized parameters (first 0.0, last 1.0),
87
+ bit-identical across engines by harness.
88
+ """
89
+ from geometrykit.runtimes._npu import curve_np
90
+
91
+ def _sample(t: float):
92
+ p = curve.point_at(t)
93
+ return (p.x, p.y, p.z)
94
+
95
+ accel = _rust()
96
+ if accel is None:
97
+ return curve_np.arclength_divide(_sample, total, num=num, dist=dist, even=even)
98
+ from geometrykit import _rust as _ext
99
+
100
+ flat = []
101
+ for p in curve.points:
102
+ flat.extend((p.x, p.y, p.z))
103
+ weights = (
104
+ list(curve._weights) if curve._weights is not None else []
105
+ )
106
+ return _ext.curve_divide_grid(
107
+ flat, weights, int(curve.degree), list(curve.knots),
108
+ list(curve.multiplicities), float(total), num, dist, bool(even),
109
+ )
110
+
111
+
54
112
  def plane_section_chains(surf, origin, normal, offsets, nu: int, nv: int):
55
113
  """Mirror `section_np.section_levels` under the active engine.
56
114
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: geometrykit
3
- Version: 0.5.1
3
+ Version: 0.6.0
4
4
  Summary: Geometry primitives and NURBS data model — the geometry substrate for the ifckit family
5
5
  Author: Sander Boer
6
6
  License-Expression: LGPL-3.0-or-later
@@ -19,6 +19,7 @@ python/geometrykit/geometry/curve.py
19
19
  python/geometrykit/geometry/field.py
20
20
  python/geometrykit/geometry/frames.py
21
21
  python/geometrykit/geometry/intersection.py
22
+ python/geometrykit/geometry/panel.py
22
23
  python/geometrykit/geometry/path.py
23
24
  python/geometrykit/geometry/patterns.py
24
25
  python/geometrykit/geometry/primitives.py
@@ -36,6 +37,7 @@ python/geometrykit/runtimes/_npu/patch_g1.py
36
37
  python/geometrykit/runtimes/_npu/patch_np.py
37
38
  python/geometrykit/runtimes/_npu/section_np.py
38
39
  python/geometrykit/runtimes/_npu/surface_np.py
40
+ python/geometrykit/runtimes/_npu/tessellate_np.py
39
41
  tests/test_cage.py
40
42
  tests/test_contract.py
41
43
  tests/test_engine_parity.py
@@ -112,7 +112,7 @@ class TestAcceleratorParity:
112
112
  def test_core_version_stamp(self):
113
113
  from geometrykit import _rust
114
114
 
115
- assert _rust.core_version() == "geometrykit-core 0.1.0"
115
+ assert _rust.core_version() == "geometrykit-core 0.2.0"
116
116
 
117
117
  def test_bits_hex_agrees_with_contract(self):
118
118
  from geometrykit import _rust
File without changes
File without changes
File without changes
File without changes