mudm 0.5.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.
- mudm-0.5.0/LICENSE +21 -0
- mudm-0.5.0/PKG-INFO +68 -0
- mudm-0.5.0/README.md +49 -0
- mudm-0.5.0/pyproject.toml +35 -0
- mudm-0.5.0/setup.cfg +4 -0
- mudm-0.5.0/src/mudm/__init__.py +24 -0
- mudm-0.5.0/src/mudm/layout.py +276 -0
- mudm-0.5.0/src/mudm/model.py +255 -0
- mudm-0.5.0/src/mudm/provenance.py +46 -0
- mudm-0.5.0/src/mudm/tilemodel.py +235 -0
- mudm-0.5.0/src/mudm/transforms.py +201 -0
- mudm-0.5.0/src/mudm.egg-info/PKG-INFO +68 -0
- mudm-0.5.0/src/mudm.egg-info/SOURCES.txt +22 -0
- mudm-0.5.0/src/mudm.egg-info/dependency_links.txt +1 -0
- mudm-0.5.0/src/mudm.egg-info/requires.txt +2 -0
- mudm-0.5.0/src/mudm.egg-info/top_level.txt +1 -0
- mudm-0.5.0/tests/test_layout.py +290 -0
- mudm-0.5.0/tests/test_mudm.py +106 -0
- mudm-0.5.0/tests/test_provenance.py +56 -0
- mudm-0.5.0/tests/test_pure_python.py +60 -0
- mudm-0.5.0/tests/test_tiled_geometry.py +236 -0
- mudm-0.5.0/tests/test_tilejson.py +57 -0
- mudm-0.5.0/tests/test_transforms.py +252 -0
- mudm-0.5.0/tests/test_vocabulary.py +249 -0
mudm-0.5.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 NovaGen Research Fund
|
|
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.
|
mudm-0.5.0/PKG-INFO
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mudm
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: muDM (micro Data Model) — a GeoJSON-inspired data model for microscopy spatial data.
|
|
5
|
+
Author-email: Bengt Ljungquist <bengt.ljungquist@nih.gov>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Documentation, https://github.com/NovagenResearch/mudm
|
|
8
|
+
Project-URL: Source, https://github.com/NovagenResearch/mudm
|
|
9
|
+
Keywords: json,microscopy,mudm,spatial-data,geojson
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: <3.14,>=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: pydantic>=2.3.0
|
|
17
|
+
Requires-Dist: geojson-pydantic>=1.2.0
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# muDM
|
|
21
|
+
|
|
22
|
+
muDM (micro Data Model) is a GeoJSON-inspired data model for encoding microscopy spatial data — annotations, regions of interest, coordinate systems, and 3D mesh surfaces.
|
|
23
|
+
|
|
24
|
+
This is the **core data model package**. It provides Pydantic models for validation and serialization with minimal dependencies. For tiling pipelines, format converters, and Rust-accelerated processing, see [mudm-tools](https://github.com/NovagenResearch/mudm-tools).
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install mudm
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from mudm import MuDM, MuDMFeature, GeoJSON
|
|
36
|
+
|
|
37
|
+
# Validate muDM data
|
|
38
|
+
data = {
|
|
39
|
+
"type": "FeatureCollection",
|
|
40
|
+
"features": [{
|
|
41
|
+
"type": "Feature",
|
|
42
|
+
"geometry": {"type": "Point", "coordinates": [10, 20]},
|
|
43
|
+
"properties": {"label": "nucleus"},
|
|
44
|
+
}],
|
|
45
|
+
}
|
|
46
|
+
obj = MuDM.model_validate(data)
|
|
47
|
+
|
|
48
|
+
# Any GeoJSON is valid muDM
|
|
49
|
+
geojson = GeoJSON.model_validate(data)
|
|
50
|
+
|
|
51
|
+
# Coordinate transforms
|
|
52
|
+
from mudm import AffineTransform
|
|
53
|
+
|
|
54
|
+
# Tile metadata
|
|
55
|
+
from mudm import TileJSON, TileModel
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## What's included
|
|
59
|
+
|
|
60
|
+
- **Model validation**: MuDM, MuDMFeature, MuDMFeatureCollection, GeoJSON
|
|
61
|
+
- **3D geometry types**: TIN, PolyhedralSurface, TiledGeometry
|
|
62
|
+
- **Tile metadata**: TileJSON, TileModel, TileLayer, PyramidJSON
|
|
63
|
+
- **Coordinate transforms**: AffineTransform, VoxelCoordinateSystem
|
|
64
|
+
- **Provenance tracking**: Workflow, Artifact, MuDMLink
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
mudm-0.5.0/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# muDM
|
|
2
|
+
|
|
3
|
+
muDM (micro Data Model) is a GeoJSON-inspired data model for encoding microscopy spatial data — annotations, regions of interest, coordinate systems, and 3D mesh surfaces.
|
|
4
|
+
|
|
5
|
+
This is the **core data model package**. It provides Pydantic models for validation and serialization with minimal dependencies. For tiling pipelines, format converters, and Rust-accelerated processing, see [mudm-tools](https://github.com/NovagenResearch/mudm-tools).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install mudm
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from mudm import MuDM, MuDMFeature, GeoJSON
|
|
17
|
+
|
|
18
|
+
# Validate muDM data
|
|
19
|
+
data = {
|
|
20
|
+
"type": "FeatureCollection",
|
|
21
|
+
"features": [{
|
|
22
|
+
"type": "Feature",
|
|
23
|
+
"geometry": {"type": "Point", "coordinates": [10, 20]},
|
|
24
|
+
"properties": {"label": "nucleus"},
|
|
25
|
+
}],
|
|
26
|
+
}
|
|
27
|
+
obj = MuDM.model_validate(data)
|
|
28
|
+
|
|
29
|
+
# Any GeoJSON is valid muDM
|
|
30
|
+
geojson = GeoJSON.model_validate(data)
|
|
31
|
+
|
|
32
|
+
# Coordinate transforms
|
|
33
|
+
from mudm import AffineTransform
|
|
34
|
+
|
|
35
|
+
# Tile metadata
|
|
36
|
+
from mudm import TileJSON, TileModel
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## What's included
|
|
40
|
+
|
|
41
|
+
- **Model validation**: MuDM, MuDMFeature, MuDMFeatureCollection, GeoJSON
|
|
42
|
+
- **3D geometry types**: TIN, PolyhedralSurface, TiledGeometry
|
|
43
|
+
- **Tile metadata**: TileJSON, TileModel, TileLayer, PyramidJSON
|
|
44
|
+
- **Coordinate transforms**: AffineTransform, VoxelCoordinateSystem
|
|
45
|
+
- **Provenance tracking**: Workflow, Artifact, MuDMLink
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
MIT
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mudm"
|
|
7
|
+
version = "0.5.0"
|
|
8
|
+
description = "muDM (micro Data Model) — a GeoJSON-inspired data model for microscopy spatial data."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{ name = "Bengt Ljungquist", email = "bengt.ljungquist@nih.gov" },
|
|
12
|
+
]
|
|
13
|
+
license = { text = "MIT" }
|
|
14
|
+
requires-python = ">=3.11,<3.14"
|
|
15
|
+
classifiers = [
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
]
|
|
20
|
+
keywords = ["json", "microscopy", "mudm", "spatial-data", "geojson"]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"pydantic>=2.3.0",
|
|
23
|
+
"geojson-pydantic>=1.2.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Documentation = "https://github.com/NovagenResearch/mudm"
|
|
28
|
+
Source = "https://github.com/NovagenResearch/mudm"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.packages.find]
|
|
31
|
+
where = ["src"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests/"]
|
|
35
|
+
pythonpath = ["src/"]
|
mudm-0.5.0/setup.cfg
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""muDM (micro Data Model) — a GeoJSON-inspired data model for microscopy."""
|
|
2
|
+
|
|
3
|
+
from .model import MuDM, GeoJSON # noqa: F401
|
|
4
|
+
from .model import MuDMFeature, MuDMFeatureCollection # noqa: F401
|
|
5
|
+
from .model import ( # noqa: F401
|
|
6
|
+
TiledGeometry,
|
|
7
|
+
PolyhedralSurface,
|
|
8
|
+
TIN,
|
|
9
|
+
OntologyTerm,
|
|
10
|
+
Vocabulary,
|
|
11
|
+
)
|
|
12
|
+
from .tilemodel import TileJSON, TileModel, TileLayer # noqa: F401
|
|
13
|
+
from .tilemodel import PyramidEntry, PyramidJSON # noqa: F401
|
|
14
|
+
from .transforms import ( # noqa: F401
|
|
15
|
+
AffineTransform,
|
|
16
|
+
VoxelCoordinateSystem,
|
|
17
|
+
apply_transform,
|
|
18
|
+
translate_geometry,
|
|
19
|
+
voxel_to_physical,
|
|
20
|
+
physical_to_voxel,
|
|
21
|
+
)
|
|
22
|
+
from .layout import geometry_bounds, apply_layout # noqa: F401
|
|
23
|
+
|
|
24
|
+
__version__ = "0.5.0"
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Shared layout module for MuDM feature collections.
|
|
2
|
+
|
|
3
|
+
Computes spatial offsets (row or grid) and applies them by translating
|
|
4
|
+
MuDM geometry coordinates directly. This makes layout reusable
|
|
5
|
+
across all exporters (glTF, Neuroglancer, Arrow/Parquet).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from geojson_pydantic import Polygon, MultiPolygon
|
|
14
|
+
|
|
15
|
+
from .model import (
|
|
16
|
+
MuDMFeature,
|
|
17
|
+
MuDMFeatureCollection,
|
|
18
|
+
)
|
|
19
|
+
from .transforms import translate_geometry
|
|
20
|
+
|
|
21
|
+
# (xmin, ymin, zmin, xmax, ymax, zmax) — OGC/GeoJSON bbox ordering
|
|
22
|
+
Bounds = tuple[float, float, float, float, float, float]
|
|
23
|
+
|
|
24
|
+
# Translation offset
|
|
25
|
+
Offset3 = tuple[float, float, float]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Bounding box computation
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def _collect_xyz_from_coords(
|
|
33
|
+
coords: Any,
|
|
34
|
+
xs: list[float],
|
|
35
|
+
ys: list[float],
|
|
36
|
+
zs: list[float],
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Recursively extract X/Y/Z from nested GeoJSON coordinate arrays."""
|
|
39
|
+
if not coords:
|
|
40
|
+
return
|
|
41
|
+
if isinstance(coords[0], (int, float)):
|
|
42
|
+
xs.append(float(coords[0]))
|
|
43
|
+
ys.append(float(coords[1]) if len(coords) > 1 else 0.0)
|
|
44
|
+
zs.append(float(coords[2]) if len(coords) > 2 else 0.0)
|
|
45
|
+
else:
|
|
46
|
+
for item in coords:
|
|
47
|
+
_collect_xyz_from_coords(item, xs, ys, zs)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def geometry_bounds(geom: Any) -> Bounds | None:
|
|
51
|
+
"""Return 3-D bounding box as (xmin, ymin, zmin, xmax, ymax, zmax).
|
|
52
|
+
|
|
53
|
+
Uses ``.bbox3d()`` for 3D types (TIN,
|
|
54
|
+
PolyhedralSurface) and recursive coordinate extraction
|
|
55
|
+
for GeoJSON types.
|
|
56
|
+
|
|
57
|
+
Returns None if the geometry is empty or None.
|
|
58
|
+
"""
|
|
59
|
+
if geom is None:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
# 3D types with bbox3d() — returns (xmin, ymin, zmin, xmax, ymax, zmax)
|
|
63
|
+
if hasattr(geom, "bbox3d"):
|
|
64
|
+
try:
|
|
65
|
+
return geom.bbox3d()
|
|
66
|
+
except (ValueError, IndexError):
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
# GeoJSON types with .coordinates
|
|
70
|
+
if not hasattr(geom, "coordinates"):
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
xs: list[float] = []
|
|
74
|
+
ys: list[float] = []
|
|
75
|
+
zs: list[float] = []
|
|
76
|
+
_collect_xyz_from_coords(geom.coordinates, xs, ys, zs)
|
|
77
|
+
|
|
78
|
+
if not xs:
|
|
79
|
+
return None
|
|
80
|
+
return (min(xs), min(ys), min(zs), max(xs), max(ys), max(zs))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Layout algorithms
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _row_layout(
|
|
88
|
+
bounds: list[Bounds | None],
|
|
89
|
+
spacing: float,
|
|
90
|
+
) -> list[Offset3]:
|
|
91
|
+
"""Place features side-by-side along X."""
|
|
92
|
+
n = len(bounds)
|
|
93
|
+
widths = [(b[3] - b[0]) if b else 0.0 for b in bounds] # xmax - xmin
|
|
94
|
+
max_width = max(widths) if widths else 0.0
|
|
95
|
+
gap = spacing if spacing > 0 else max_width * 0.2
|
|
96
|
+
|
|
97
|
+
offsets: list[Offset3] = [(0.0, 0.0, 0.0)] * n
|
|
98
|
+
cursor = bounds[0][3] if bounds[0] else gap # xmax of first feature
|
|
99
|
+
|
|
100
|
+
for i in range(1, n):
|
|
101
|
+
b = bounds[i]
|
|
102
|
+
if b is None:
|
|
103
|
+
dx = cursor + gap
|
|
104
|
+
offsets[i] = (dx, 0.0, 0.0)
|
|
105
|
+
cursor = dx
|
|
106
|
+
continue
|
|
107
|
+
dx = (cursor + gap) - b[0] # shift so xmin aligns to cursor + gap
|
|
108
|
+
offsets[i] = (dx, 0.0, 0.0)
|
|
109
|
+
cursor = b[3] + dx # new xmax position
|
|
110
|
+
|
|
111
|
+
return offsets
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _grid_layout(
|
|
115
|
+
bounds: list[Bounds | None],
|
|
116
|
+
spacing: float,
|
|
117
|
+
grid_max_x: int | None,
|
|
118
|
+
grid_max_y: int | None,
|
|
119
|
+
grid_max_z: int | None,
|
|
120
|
+
n: int,
|
|
121
|
+
) -> list[Offset3]:
|
|
122
|
+
"""Place features on a uniform grid that wraps X -> Y -> Z.
|
|
123
|
+
|
|
124
|
+
``grid_max_x/y/z`` give the number of cells per axis directly.
|
|
125
|
+
"""
|
|
126
|
+
# Bounds format: (xmin, ymin, zmin, xmax, ymax, zmax)
|
|
127
|
+
widths = [(b[3] - b[0]) if b else 0.0 for b in bounds]
|
|
128
|
+
heights = [(b[4] - b[1]) if b else 0.0 for b in bounds]
|
|
129
|
+
depths = [(b[5] - b[2]) if b else 0.0 for b in bounds]
|
|
130
|
+
|
|
131
|
+
max_w = max(widths) if widths else 0.0
|
|
132
|
+
max_h = max(heights) if heights else 0.0
|
|
133
|
+
max_d = max(depths) if depths else 0.0
|
|
134
|
+
|
|
135
|
+
max_extent = max(max_w, max_h, max_d)
|
|
136
|
+
|
|
137
|
+
if spacing > 0:
|
|
138
|
+
# Fixed cell size — center-to-center distance, ignores extent
|
|
139
|
+
cell_x = spacing
|
|
140
|
+
cell_y = spacing
|
|
141
|
+
cell_z = spacing
|
|
142
|
+
else:
|
|
143
|
+
# Auto: 20% gap on top of max extent per axis
|
|
144
|
+
gap = max_extent * 0.2 if max_extent > 0 else 1.0
|
|
145
|
+
cell_x = max_w + gap
|
|
146
|
+
cell_y = max_h + gap
|
|
147
|
+
cell_z = max_d + gap
|
|
148
|
+
|
|
149
|
+
# Grid dimensions — values are cell counts, unlimited when None
|
|
150
|
+
cols = grid_max_x if grid_max_x is not None else n
|
|
151
|
+
rows = (
|
|
152
|
+
grid_max_y
|
|
153
|
+
if grid_max_y is not None
|
|
154
|
+
else max(1, -(-n // cols))
|
|
155
|
+
)
|
|
156
|
+
layers = (
|
|
157
|
+
grid_max_z
|
|
158
|
+
if grid_max_z is not None
|
|
159
|
+
else max(1, -(-n // (cols * rows)))
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
capacity = cols * rows * layers
|
|
163
|
+
if n > capacity:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"Cannot fit {n} features in grid "
|
|
166
|
+
f"({cols} cols \u00d7 {rows} rows \u00d7 {layers} layers "
|
|
167
|
+
f"= {capacity} cells). "
|
|
168
|
+
f"Increase grid_max_x/y/z or reduce feature count."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Feature 0 is the reference point; others are placed relative to it
|
|
172
|
+
ref = bounds[0]
|
|
173
|
+
ref_cx = (ref[0] + ref[3]) / 2 if ref else 0.0 # (xmin + xmax) / 2
|
|
174
|
+
ref_cy = (ref[1] + ref[4]) / 2 if ref else 0.0 # (ymin + ymax) / 2
|
|
175
|
+
ref_cz = (ref[2] + ref[5]) / 2 if ref else 0.0 # (zmin + zmax) / 2
|
|
176
|
+
|
|
177
|
+
offsets: list[Offset3] = [(0.0, 0.0, 0.0)]
|
|
178
|
+
|
|
179
|
+
for i in range(1, n):
|
|
180
|
+
col = i % cols
|
|
181
|
+
row = (i // cols) % rows
|
|
182
|
+
layer = i // (cols * rows)
|
|
183
|
+
|
|
184
|
+
target_cx = ref_cx + col * cell_x
|
|
185
|
+
target_cy = ref_cy + row * cell_y
|
|
186
|
+
target_cz = ref_cz + layer * cell_z
|
|
187
|
+
|
|
188
|
+
b = bounds[i]
|
|
189
|
+
feat_cx = (b[0] + b[3]) / 2 if b else 0.0
|
|
190
|
+
feat_cy = (b[1] + b[4]) / 2 if b else 0.0
|
|
191
|
+
feat_cz = (b[2] + b[5]) / 2 if b else 0.0
|
|
192
|
+
|
|
193
|
+
offsets.append((
|
|
194
|
+
target_cx - feat_cx,
|
|
195
|
+
target_cy - feat_cy,
|
|
196
|
+
target_cz - feat_cz,
|
|
197
|
+
))
|
|
198
|
+
|
|
199
|
+
return offsets
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def compute_collection_offsets(
|
|
203
|
+
features: list[MuDMFeature],
|
|
204
|
+
spacing: float | None = None,
|
|
205
|
+
grid_max_x: int | None = None,
|
|
206
|
+
grid_max_y: int | None = None,
|
|
207
|
+
grid_max_z: int | None = None,
|
|
208
|
+
) -> list[Offset3]:
|
|
209
|
+
"""Compute 3-D translation for each feature in a collection.
|
|
210
|
+
|
|
211
|
+
Returns a list of (dx, dy, dz) offsets in source coordinates.
|
|
212
|
+
The first feature always stays at its original position.
|
|
213
|
+
|
|
214
|
+
When *spacing* is ``None`` and no grid parameters are set, all
|
|
215
|
+
offsets are zero (coordinates are kept as-is).
|
|
216
|
+
"""
|
|
217
|
+
n = len(features)
|
|
218
|
+
if n <= 1:
|
|
219
|
+
return [(0.0, 0.0, 0.0)] * n
|
|
220
|
+
|
|
221
|
+
has_grid = (
|
|
222
|
+
grid_max_x is not None
|
|
223
|
+
or grid_max_y is not None
|
|
224
|
+
or grid_max_z is not None
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# No layout requested — keep coordinates as-is
|
|
228
|
+
if spacing is None and not has_grid:
|
|
229
|
+
return [(0.0, 0.0, 0.0)] * n
|
|
230
|
+
|
|
231
|
+
effective_spacing = spacing if spacing is not None else 0.0
|
|
232
|
+
|
|
233
|
+
bounds = [
|
|
234
|
+
geometry_bounds(f.geometry) if f.geometry else None
|
|
235
|
+
for f in features
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
if has_grid:
|
|
239
|
+
return _grid_layout(bounds, effective_spacing, grid_max_x, grid_max_y, grid_max_z, n)
|
|
240
|
+
return _row_layout(bounds, effective_spacing)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def apply_layout(
|
|
244
|
+
collection: MuDMFeatureCollection,
|
|
245
|
+
spacing: float | None = None,
|
|
246
|
+
grid_max_x: int | None = None,
|
|
247
|
+
grid_max_y: int | None = None,
|
|
248
|
+
grid_max_z: int | None = None,
|
|
249
|
+
) -> MuDMFeatureCollection:
|
|
250
|
+
"""Apply spatial layout to a collection, returning a new collection.
|
|
251
|
+
|
|
252
|
+
Computes offsets and translates each feature's geometry coordinates
|
|
253
|
+
directly. The returned collection is a copy — the original is not
|
|
254
|
+
modified.
|
|
255
|
+
"""
|
|
256
|
+
features = list(collection.features)
|
|
257
|
+
offsets = compute_collection_offsets(
|
|
258
|
+
features, spacing, grid_max_x, grid_max_y, grid_max_z,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
new_features = []
|
|
262
|
+
for feat, (dx, dy, dz) in zip(features, offsets):
|
|
263
|
+
if abs(dx) < 1e-12 and abs(dy) < 1e-12 and abs(dz) < 1e-12:
|
|
264
|
+
new_features.append(feat)
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
if feat.geometry is None:
|
|
268
|
+
new_features.append(feat)
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
new_geom = translate_geometry(feat.geometry, dx, dy, dz)
|
|
272
|
+
# Build a new feature with translated geometry
|
|
273
|
+
new_feat = feat.model_copy(update={"geometry": new_geom})
|
|
274
|
+
new_features.append(new_feat)
|
|
275
|
+
|
|
276
|
+
return collection.model_copy(update={"features": new_features})
|