unit-cell-gui 0.1.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.
- unit_cell_gui-0.1.0/CHANGELOG.md +14 -0
- unit_cell_gui-0.1.0/LICENSE +21 -0
- unit_cell_gui-0.1.0/MANIFEST.in +4 -0
- unit_cell_gui-0.1.0/PKG-INFO +117 -0
- unit_cell_gui-0.1.0/README.md +100 -0
- unit_cell_gui-0.1.0/pyproject.toml +31 -0
- unit_cell_gui-0.1.0/setup.cfg +4 -0
- unit_cell_gui-0.1.0/src/unit_cell_gui/__init__.py +39 -0
- unit_cell_gui-0.1.0/src/unit_cell_gui/hatching.py +229 -0
- unit_cell_gui-0.1.0/src/unit_cell_gui/models.py +193 -0
- unit_cell_gui-0.1.0/src/unit_cell_gui/py.typed +1 -0
- unit_cell_gui-0.1.0/src/unit_cell_gui/viewer.py +1042 -0
- unit_cell_gui-0.1.0/src/unit_cell_gui.egg-info/PKG-INFO +117 -0
- unit_cell_gui-0.1.0/src/unit_cell_gui.egg-info/SOURCES.txt +16 -0
- unit_cell_gui-0.1.0/src/unit_cell_gui.egg-info/dependency_links.txt +1 -0
- unit_cell_gui-0.1.0/src/unit_cell_gui.egg-info/requires.txt +2 -0
- unit_cell_gui-0.1.0/src/unit_cell_gui.egg-info/top_level.txt +1 -0
- unit_cell_gui-0.1.0/tests/test_unit_cell_gui.py +247 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Added immutable input models for caller-supplied atoms, bonds, unit cells,
|
|
6
|
+
basis vectors and ready polyhedra.
|
|
7
|
+
- Added native PySide6 rendering with depth ordering, colour and monochrome
|
|
8
|
+
atoms, mixed occupancies, bonds, cell edges and basis arrows.
|
|
9
|
+
- Added polyhedron hatching, including intact quadrilateral faces with
|
|
10
|
+
`2x + 1` strokes and direction transport through real shared edges.
|
|
11
|
+
- Added left-button rotation with complete orientation-matrix events,
|
|
12
|
+
right-button panning, wheel zoom and a switch for disabling rotation.
|
|
13
|
+
- Added global display options and copied per-site style overrides.
|
|
14
|
+
- Set polyhedra to hidden by default.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikhail Mirushchenko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unit-cell-gui
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PySide6 widget for rendering caller-supplied unit-cell scenes
|
|
5
|
+
Author-email: Mikhail Mirushchenko <miruschenko98@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Environment :: X11 Applications :: Qt
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: numpy>=1.24
|
|
15
|
+
Requires-Dist: PySide6-Essentials<7,>=6.8
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# unit-cell-gui
|
|
19
|
+
|
|
20
|
+
`unit-cell-gui` is a reusable PySide6 widget that draws complete unit-cell
|
|
21
|
+
scenes supplied by another program. It does not read CIF files, expand
|
|
22
|
+
symmetry, resolve atomic positions, infer bonds, construct polyhedra or
|
|
23
|
+
calculate diffraction.
|
|
24
|
+
|
|
25
|
+
The caller supplies ready Cartesian coordinates and presentation data:
|
|
26
|
+
|
|
27
|
+
- atoms and their styled occupancy components;
|
|
28
|
+
- bond indices;
|
|
29
|
+
- unit-cell vertices and edges;
|
|
30
|
+
- three basis vectors;
|
|
31
|
+
- optional polyhedron vertices, polygon faces and centres.
|
|
32
|
+
|
|
33
|
+
The package owns only display behaviour: camera projection, depth sorting,
|
|
34
|
+
left-button rotation, right-button panning, wheel zoom, occupancy sectors,
|
|
35
|
+
colour and black-and-white drawing, and polyhedron hatching. Quadrilateral
|
|
36
|
+
hatching uses one intact polygon with `2x + 1` full parallel strokes. Hatch
|
|
37
|
+
directions on farther faces are refolded through real shared edges.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
python -m pip install unit-cell-gui
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Python 3.10 or newer, NumPy and PySide6 Essentials are required.
|
|
46
|
+
|
|
47
|
+
## Basic use
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import numpy as np
|
|
51
|
+
from PySide6.QtWidgets import QApplication
|
|
52
|
+
from unit_cell_gui import Atom, AtomComponent, DisplayOptions, Scene, UnitCellViewer
|
|
53
|
+
|
|
54
|
+
silicon = AtomComponent(
|
|
55
|
+
key="Si:Si1", label="Si1", element="Si",
|
|
56
|
+
occupancy=1.0, colour="#f0c8a0", radius=0.30,
|
|
57
|
+
)
|
|
58
|
+
scene = Scene(
|
|
59
|
+
atoms=(Atom("Si:Si1", "Si1", (silicon,)),),
|
|
60
|
+
atom_centers=np.array([[0.0, 0.0, 0.0]]),
|
|
61
|
+
bonds=np.empty((0, 2), dtype=int),
|
|
62
|
+
cell_vertices=np.array([
|
|
63
|
+
[x, y, z]
|
|
64
|
+
for x in (-1.0, 1.0)
|
|
65
|
+
for y in (-1.0, 1.0)
|
|
66
|
+
for z in (-1.0, 1.0)
|
|
67
|
+
]),
|
|
68
|
+
cell_edges=np.array([
|
|
69
|
+
[0, 1], [0, 2], [0, 4], [1, 3], [1, 5], [2, 3],
|
|
70
|
+
[2, 6], [3, 7], [4, 5], [4, 6], [5, 7], [6, 7],
|
|
71
|
+
]),
|
|
72
|
+
basis_vectors=np.eye(3),
|
|
73
|
+
base_radius=2.0,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
app = QApplication([])
|
|
77
|
+
viewer = UnitCellViewer()
|
|
78
|
+
viewer.set_scene(scene)
|
|
79
|
+
viewer.set_display_options(DisplayOptions(show_polyhedra=False))
|
|
80
|
+
viewer.show()
|
|
81
|
+
app.exec()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`Scene` copies its arrays and makes them read-only. Later changes in the caller
|
|
85
|
+
therefore cannot alter an already submitted scene.
|
|
86
|
+
|
|
87
|
+
## Orientation synchronization
|
|
88
|
+
|
|
89
|
+
`orientation` is a 3 by 3 world-to-camera matrix. For column vectors the
|
|
90
|
+
camera coordinate is `orientation @ world`; the renderer's row arrays use the
|
|
91
|
+
equivalent `world @ orientation.T`.
|
|
92
|
+
|
|
93
|
+
Programmatic changes are silent by default:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
viewer.set_orientation(matrix, emit=False)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Interactive rotation emits the complete new matrix through
|
|
100
|
+
`orientation_changed`. Connect that signal to a pole figure or another view,
|
|
101
|
+
then send changes from the other view back with silent `set_orientation` calls.
|
|
102
|
+
This supports bidirectional synchronization without a signal loop.
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
viewer.orientation_changed.connect(update_other_view)
|
|
106
|
+
other_view.orientation_changed.connect(
|
|
107
|
+
lambda matrix: viewer.set_orientation(matrix, emit=False)
|
|
108
|
+
)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Set `rotation_enabled=False` when an application temporarily forbids left-button
|
|
112
|
+
rotation. Panning and zoom remain available. Polyhedra are hidden by default
|
|
113
|
+
and must be enabled with `show_polyhedra=True`.
|
|
114
|
+
|
|
115
|
+
Per-position colours, visibility and opaque-polyhedron selections are replaced
|
|
116
|
+
atomically with `viewer.set_style_overrides(StyleOverrides(...))`. The widget
|
|
117
|
+
copies these mappings, so the host can keep its own independent UI state.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# unit-cell-gui
|
|
2
|
+
|
|
3
|
+
`unit-cell-gui` is a reusable PySide6 widget that draws complete unit-cell
|
|
4
|
+
scenes supplied by another program. It does not read CIF files, expand
|
|
5
|
+
symmetry, resolve atomic positions, infer bonds, construct polyhedra or
|
|
6
|
+
calculate diffraction.
|
|
7
|
+
|
|
8
|
+
The caller supplies ready Cartesian coordinates and presentation data:
|
|
9
|
+
|
|
10
|
+
- atoms and their styled occupancy components;
|
|
11
|
+
- bond indices;
|
|
12
|
+
- unit-cell vertices and edges;
|
|
13
|
+
- three basis vectors;
|
|
14
|
+
- optional polyhedron vertices, polygon faces and centres.
|
|
15
|
+
|
|
16
|
+
The package owns only display behaviour: camera projection, depth sorting,
|
|
17
|
+
left-button rotation, right-button panning, wheel zoom, occupancy sectors,
|
|
18
|
+
colour and black-and-white drawing, and polyhedron hatching. Quadrilateral
|
|
19
|
+
hatching uses one intact polygon with `2x + 1` full parallel strokes. Hatch
|
|
20
|
+
directions on farther faces are refolded through real shared edges.
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
python -m pip install unit-cell-gui
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Python 3.10 or newer, NumPy and PySide6 Essentials are required.
|
|
29
|
+
|
|
30
|
+
## Basic use
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import numpy as np
|
|
34
|
+
from PySide6.QtWidgets import QApplication
|
|
35
|
+
from unit_cell_gui import Atom, AtomComponent, DisplayOptions, Scene, UnitCellViewer
|
|
36
|
+
|
|
37
|
+
silicon = AtomComponent(
|
|
38
|
+
key="Si:Si1", label="Si1", element="Si",
|
|
39
|
+
occupancy=1.0, colour="#f0c8a0", radius=0.30,
|
|
40
|
+
)
|
|
41
|
+
scene = Scene(
|
|
42
|
+
atoms=(Atom("Si:Si1", "Si1", (silicon,)),),
|
|
43
|
+
atom_centers=np.array([[0.0, 0.0, 0.0]]),
|
|
44
|
+
bonds=np.empty((0, 2), dtype=int),
|
|
45
|
+
cell_vertices=np.array([
|
|
46
|
+
[x, y, z]
|
|
47
|
+
for x in (-1.0, 1.0)
|
|
48
|
+
for y in (-1.0, 1.0)
|
|
49
|
+
for z in (-1.0, 1.0)
|
|
50
|
+
]),
|
|
51
|
+
cell_edges=np.array([
|
|
52
|
+
[0, 1], [0, 2], [0, 4], [1, 3], [1, 5], [2, 3],
|
|
53
|
+
[2, 6], [3, 7], [4, 5], [4, 6], [5, 7], [6, 7],
|
|
54
|
+
]),
|
|
55
|
+
basis_vectors=np.eye(3),
|
|
56
|
+
base_radius=2.0,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
app = QApplication([])
|
|
60
|
+
viewer = UnitCellViewer()
|
|
61
|
+
viewer.set_scene(scene)
|
|
62
|
+
viewer.set_display_options(DisplayOptions(show_polyhedra=False))
|
|
63
|
+
viewer.show()
|
|
64
|
+
app.exec()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`Scene` copies its arrays and makes them read-only. Later changes in the caller
|
|
68
|
+
therefore cannot alter an already submitted scene.
|
|
69
|
+
|
|
70
|
+
## Orientation synchronization
|
|
71
|
+
|
|
72
|
+
`orientation` is a 3 by 3 world-to-camera matrix. For column vectors the
|
|
73
|
+
camera coordinate is `orientation @ world`; the renderer's row arrays use the
|
|
74
|
+
equivalent `world @ orientation.T`.
|
|
75
|
+
|
|
76
|
+
Programmatic changes are silent by default:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
viewer.set_orientation(matrix, emit=False)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Interactive rotation emits the complete new matrix through
|
|
83
|
+
`orientation_changed`. Connect that signal to a pole figure or another view,
|
|
84
|
+
then send changes from the other view back with silent `set_orientation` calls.
|
|
85
|
+
This supports bidirectional synchronization without a signal loop.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
viewer.orientation_changed.connect(update_other_view)
|
|
89
|
+
other_view.orientation_changed.connect(
|
|
90
|
+
lambda matrix: viewer.set_orientation(matrix, emit=False)
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Set `rotation_enabled=False` when an application temporarily forbids left-button
|
|
95
|
+
rotation. Panning and zoom remain available. Polyhedra are hidden by default
|
|
96
|
+
and must be enabled with `show_polyhedra=True`.
|
|
97
|
+
|
|
98
|
+
Per-position colours, visibility and opaque-polyhedron selections are replaced
|
|
99
|
+
atomically with `viewer.set_style_overrides(StyleOverrides(...))`. The widget
|
|
100
|
+
copies these mappings, so the host can keep its own independent UI state.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "unit-cell-gui"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "PySide6 widget for rendering caller-supplied unit-cell scenes"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{name = "Mikhail Mirushchenko", email = "miruschenko98@gmail.com"},
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"numpy>=1.24",
|
|
18
|
+
"PySide6-Essentials>=6.8,<7",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 3 - Alpha",
|
|
22
|
+
"Environment :: X11 Applications :: Qt",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.packages.find]
|
|
28
|
+
where = ["src"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
unit_cell_gui = ["py.typed"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""A Qt widget that renders complete, caller-supplied unit-cell scenes."""
|
|
2
|
+
|
|
3
|
+
from .hatching import (
|
|
4
|
+
face_hatch_segments,
|
|
5
|
+
hatch_division_count,
|
|
6
|
+
occupancy_fractions,
|
|
7
|
+
propagated_hatch_directions,
|
|
8
|
+
quadrilateral_hatch_line_count,
|
|
9
|
+
)
|
|
10
|
+
from .models import Atom, AtomComponent, DisplayOptions, Polyhedron, Scene, StyleOverrides
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def __getattr__(name):
|
|
15
|
+
if name in {"CrystalCanvas", "UnitCellViewer", "screen_drag_orientation"}:
|
|
16
|
+
from .viewer import CrystalCanvas, UnitCellViewer, screen_drag_orientation
|
|
17
|
+
return {
|
|
18
|
+
"CrystalCanvas": CrystalCanvas,
|
|
19
|
+
"UnitCellViewer": UnitCellViewer,
|
|
20
|
+
"screen_drag_orientation": screen_drag_orientation,
|
|
21
|
+
}[name]
|
|
22
|
+
raise AttributeError(name)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Atom",
|
|
26
|
+
"AtomComponent",
|
|
27
|
+
"CrystalCanvas",
|
|
28
|
+
"DisplayOptions",
|
|
29
|
+
"Polyhedron",
|
|
30
|
+
"Scene",
|
|
31
|
+
"StyleOverrides",
|
|
32
|
+
"UnitCellViewer",
|
|
33
|
+
"face_hatch_segments",
|
|
34
|
+
"hatch_division_count",
|
|
35
|
+
"occupancy_fractions",
|
|
36
|
+
"propagated_hatch_directions",
|
|
37
|
+
"quadrilateral_hatch_line_count",
|
|
38
|
+
"screen_drag_orientation",
|
|
39
|
+
]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""View-dependent hatching used by the unit-cell renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def occupancy_fractions(components: Iterable[object]) -> tuple[float, ...]:
|
|
12
|
+
"""Return drawable site fractions while preserving partial occupancy."""
|
|
13
|
+
occupancies = tuple(max(0.0, float(component.occupancy)) for component in components)
|
|
14
|
+
total = sum(occupancies)
|
|
15
|
+
scale = 1.0 if total <= 1.0 else 1.0 / total
|
|
16
|
+
return tuple(occupancy * scale for occupancy in occupancies)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def quadrilateral_hatch_line_count(triangle_count: int) -> int:
|
|
20
|
+
"""Return the number of full, parallel strokes on a quadrilateral."""
|
|
21
|
+
return 2 * max(0, int(triangle_count)) + 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _hatch_face_normal(points: np.ndarray) -> np.ndarray:
|
|
25
|
+
normal = np.cross(points[1] - points[0], points[2] - points[0])
|
|
26
|
+
return normal / np.linalg.norm(normal)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _initial_hatch_direction(face: tuple[int, ...], vertices: np.ndarray) -> np.ndarray:
|
|
30
|
+
apex = max(face, key=lambda vertex: float(vertices[vertex, 2]))
|
|
31
|
+
if len(face) == 4:
|
|
32
|
+
# Virtual triangles choose a diagonal direction only. They never
|
|
33
|
+
# become visible boundaries or separately rendered subfaces.
|
|
34
|
+
position = face.index(apex)
|
|
35
|
+
first, second = face[(position - 1) % 4], face[(position + 1) % 4]
|
|
36
|
+
else:
|
|
37
|
+
candidates = [
|
|
38
|
+
(first, face[(position + 1) % len(face)])
|
|
39
|
+
for position, first in enumerate(face)
|
|
40
|
+
if apex not in (first, face[(position + 1) % len(face)])
|
|
41
|
+
]
|
|
42
|
+
first, second = max(
|
|
43
|
+
candidates,
|
|
44
|
+
key=lambda edge: float(np.linalg.norm(
|
|
45
|
+
0.5 * (vertices[edge[0], :2] + vertices[edge[1], :2])
|
|
46
|
+
- vertices[apex, :2]
|
|
47
|
+
)),
|
|
48
|
+
)
|
|
49
|
+
direction = vertices[second] - vertices[first]
|
|
50
|
+
return direction / np.linalg.norm(direction)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _transport_hatch_direction(
|
|
54
|
+
direction: np.ndarray,
|
|
55
|
+
edge: np.ndarray,
|
|
56
|
+
source_normal: np.ndarray,
|
|
57
|
+
target_normal: np.ndarray,
|
|
58
|
+
) -> np.ndarray:
|
|
59
|
+
"""Unfold and refold a tangent vector about the actual shared edge."""
|
|
60
|
+
along = edge / np.linalg.norm(edge)
|
|
61
|
+
source_across = np.cross(source_normal, along)
|
|
62
|
+
target_across = np.cross(target_normal, along)
|
|
63
|
+
transported = (
|
|
64
|
+
float(np.dot(direction, along)) * along
|
|
65
|
+
+ float(np.dot(direction, source_across)) * target_across
|
|
66
|
+
)
|
|
67
|
+
return transported / np.linalg.norm(transported)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def propagated_hatch_directions(
|
|
71
|
+
faces: Iterable[Iterable[int]],
|
|
72
|
+
vertices_camera: np.ndarray,
|
|
73
|
+
visible_faces: Iterable[int],
|
|
74
|
+
blank_faces: Iterable[int] = (),
|
|
75
|
+
) -> dict[int, np.ndarray]:
|
|
76
|
+
"""Choose seed directions, then transport them in each receiving plane."""
|
|
77
|
+
faces = tuple(tuple(int(vertex) for vertex in face) for face in faces)
|
|
78
|
+
vertices = np.asarray(vertices_camera, dtype=float).reshape(-1, 3)
|
|
79
|
+
visible = {int(face_index) for face_index in visible_faces}
|
|
80
|
+
blank = visible.intersection(int(face_index) for face_index in blank_faces)
|
|
81
|
+
if not visible:
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
edge_faces: dict[tuple[int, int], list[int]] = {}
|
|
85
|
+
for face_index, face in enumerate(faces):
|
|
86
|
+
edges = tuple(
|
|
87
|
+
tuple(sorted((face[position], face[(position + 1) % len(face)])))
|
|
88
|
+
for position in range(len(face))
|
|
89
|
+
)
|
|
90
|
+
for edge in edges:
|
|
91
|
+
edge_faces.setdefault(edge, []).append(face_index)
|
|
92
|
+
|
|
93
|
+
adjacency = {face_index: set() for face_index in visible}
|
|
94
|
+
shared_edges: dict[tuple[int, int], tuple[int, int]] = {}
|
|
95
|
+
for edge, owners in edge_faces.items():
|
|
96
|
+
visible_owners = [face_index for face_index in owners if face_index in visible]
|
|
97
|
+
for first in visible_owners:
|
|
98
|
+
for second in visible_owners:
|
|
99
|
+
if first == second:
|
|
100
|
+
continue
|
|
101
|
+
adjacency[first].add(second)
|
|
102
|
+
shared_edges[(first, second)] = edge
|
|
103
|
+
|
|
104
|
+
depths = {
|
|
105
|
+
face_index: float(vertices[list(faces[face_index]), 2].mean())
|
|
106
|
+
for face_index in visible
|
|
107
|
+
}
|
|
108
|
+
order = sorted(visible, key=lambda face_index: (-depths[face_index], face_index))
|
|
109
|
+
visible_vertices = {vertex for index in visible for vertex in faces[index]}
|
|
110
|
+
nearest_depth = max(float(vertices[index, 2]) for index in visible_vertices)
|
|
111
|
+
depth_tolerance = max(1e-12, float(np.ptp(vertices[:, 2])) * 1e-9)
|
|
112
|
+
nearest_vertices = {
|
|
113
|
+
index for index in visible_vertices
|
|
114
|
+
if nearest_depth - float(vertices[index, 2]) <= depth_tolerance
|
|
115
|
+
}
|
|
116
|
+
normals = {
|
|
117
|
+
face_index: _hatch_face_normal(vertices[list(faces[face_index])])
|
|
118
|
+
for face_index in visible
|
|
119
|
+
}
|
|
120
|
+
directions: dict[int, np.ndarray] = {}
|
|
121
|
+
for face_index in order:
|
|
122
|
+
if face_index in blank:
|
|
123
|
+
continue
|
|
124
|
+
blank_neighbours = adjacency[face_index].intersection(blank)
|
|
125
|
+
if blank_neighbours:
|
|
126
|
+
parent = min(blank_neighbours)
|
|
127
|
+
first, second = shared_edges[(face_index, parent)]
|
|
128
|
+
direction = vertices[second] - vertices[first]
|
|
129
|
+
directions[face_index] = direction / np.linalg.norm(direction)
|
|
130
|
+
continue
|
|
131
|
+
if nearest_vertices.intersection(faces[face_index]):
|
|
132
|
+
directions[face_index] = _initial_hatch_direction(faces[face_index], vertices)
|
|
133
|
+
continue
|
|
134
|
+
processed_neighbours = [
|
|
135
|
+
neighbour
|
|
136
|
+
for neighbour in adjacency[face_index]
|
|
137
|
+
if neighbour in directions
|
|
138
|
+
and depths[neighbour] > depths[face_index] + depth_tolerance
|
|
139
|
+
]
|
|
140
|
+
if processed_neighbours:
|
|
141
|
+
parent = max(
|
|
142
|
+
processed_neighbours,
|
|
143
|
+
key=lambda neighbour: (depths[neighbour], -neighbour),
|
|
144
|
+
)
|
|
145
|
+
first, second = shared_edges[(face_index, parent)]
|
|
146
|
+
directions[face_index] = _transport_hatch_direction(
|
|
147
|
+
directions[parent],
|
|
148
|
+
vertices[second] - vertices[first],
|
|
149
|
+
normals[parent],
|
|
150
|
+
normals[face_index],
|
|
151
|
+
)
|
|
152
|
+
continue
|
|
153
|
+
directions[face_index] = _initial_hatch_direction(faces[face_index], vertices)
|
|
154
|
+
return directions
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def face_hatch_segments(
|
|
158
|
+
face_vertices: np.ndarray,
|
|
159
|
+
direction: np.ndarray,
|
|
160
|
+
line_count: int,
|
|
161
|
+
) -> list[tuple[np.ndarray, np.ndarray]]:
|
|
162
|
+
"""Clip full parallel strokes to a convex face in its own 3-D plane."""
|
|
163
|
+
points = np.asarray(face_vertices, dtype=float)
|
|
164
|
+
if len(points) < 3 or line_count <= 0:
|
|
165
|
+
return []
|
|
166
|
+
origin = points[0]
|
|
167
|
+
normal = _hatch_face_normal(points)
|
|
168
|
+
across = np.cross(normal, np.asarray(direction, dtype=float))
|
|
169
|
+
length = float(np.linalg.norm(across))
|
|
170
|
+
if length < 1e-12:
|
|
171
|
+
return []
|
|
172
|
+
across /= length
|
|
173
|
+
values = (points - origin) @ across
|
|
174
|
+
lower, upper = float(values.min()), float(values.max())
|
|
175
|
+
extent = float(np.max(np.linalg.norm(points - origin, axis=1)))
|
|
176
|
+
tolerance = max(1e-12, extent * 1e-10)
|
|
177
|
+
if upper - lower <= tolerance:
|
|
178
|
+
return []
|
|
179
|
+
segments = []
|
|
180
|
+
for step in range(1, int(line_count) + 1):
|
|
181
|
+
constant = lower + step * (upper - lower) / (line_count + 1.0)
|
|
182
|
+
crossings = []
|
|
183
|
+
for index, first in enumerate(points):
|
|
184
|
+
next_index = (index + 1) % len(points)
|
|
185
|
+
first_side = values[index] - constant
|
|
186
|
+
second_side = values[next_index] - constant
|
|
187
|
+
if abs(first_side) <= tolerance:
|
|
188
|
+
crossings.append(first.copy())
|
|
189
|
+
if first_side * second_side < 0.0:
|
|
190
|
+
fraction = first_side / (first_side - second_side)
|
|
191
|
+
crossings.append(first + fraction * (points[next_index] - first))
|
|
192
|
+
unique = []
|
|
193
|
+
for crossing in crossings:
|
|
194
|
+
if not any(np.linalg.norm(crossing - other) <= tolerance for other in unique):
|
|
195
|
+
unique.append(crossing)
|
|
196
|
+
if len(unique) >= 2:
|
|
197
|
+
first, second = max(
|
|
198
|
+
itertools.combinations(unique, 2),
|
|
199
|
+
key=lambda pair: float(np.linalg.norm(pair[1] - pair[0])),
|
|
200
|
+
)
|
|
201
|
+
if np.linalg.norm(second - first) > tolerance:
|
|
202
|
+
segments.append((first, second))
|
|
203
|
+
return segments
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def hatch_division_count(
|
|
207
|
+
far_factor: float,
|
|
208
|
+
density_setting: int,
|
|
209
|
+
depth_setting: int,
|
|
210
|
+
) -> int:
|
|
211
|
+
"""Return the original density with a separate GRIP depth coefficient."""
|
|
212
|
+
density_norm = (float(density_setting) - 4.0) / (42.0 - 4.0)
|
|
213
|
+
density_norm = max(0.0, min(1.0, density_norm))
|
|
214
|
+
near_count = 4.0 + 8.0 * density_norm
|
|
215
|
+
base_difference = 4.0 + 10.0 * density_norm
|
|
216
|
+
far_count = near_count + base_difference * (float(depth_setting) / 50.0)
|
|
217
|
+
depth = max(0.0, min(1.0, float(far_factor)))
|
|
218
|
+
smooth_depth = depth * depth * (3.0 - 2.0 * depth)
|
|
219
|
+
count = near_count + (far_count - near_count) * smooth_depth
|
|
220
|
+
return max(1, int(round(count)))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
__all__ = [
|
|
224
|
+
"face_hatch_segments",
|
|
225
|
+
"hatch_division_count",
|
|
226
|
+
"occupancy_fractions",
|
|
227
|
+
"propagated_hatch_directions",
|
|
228
|
+
"quadrilateral_hatch_line_count",
|
|
229
|
+
]
|