srn-parser 1.0.1__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.
- srn_parser-1.0.1/PKG-INFO +9 -0
- srn_parser-1.0.1/README.md +97 -0
- srn_parser-1.0.1/pyproject.toml +28 -0
- srn_parser-1.0.1/setup.cfg +4 -0
- srn_parser-1.0.1/srn_parser/__init__.py +7 -0
- srn_parser-1.0.1/srn_parser/cli.py +155 -0
- srn_parser-1.0.1/srn_parser/directx.py +65 -0
- srn_parser-1.0.1/srn_parser/exporters/__init__.py +0 -0
- srn_parser-1.0.1/srn_parser/exporters/obj.py +129 -0
- srn_parser-1.0.1/srn_parser/models.py +42 -0
- srn_parser-1.0.1/srn_parser/parser.py +170 -0
- srn_parser-1.0.1/srn_parser.egg-info/PKG-INFO +9 -0
- srn_parser-1.0.1/srn_parser.egg-info/SOURCES.txt +19 -0
- srn_parser-1.0.1/srn_parser.egg-info/dependency_links.txt +1 -0
- srn_parser-1.0.1/srn_parser.egg-info/entry_points.txt +2 -0
- srn_parser-1.0.1/srn_parser.egg-info/requires.txt +5 -0
- srn_parser-1.0.1/srn_parser.egg-info/top_level.txt +1 -0
- srn_parser-1.0.1/tests/test_cli.py +73 -0
- srn_parser-1.0.1/tests/test_directx.py +94 -0
- srn_parser-1.0.1/tests/test_obj_exporter.py +177 -0
- srn_parser-1.0.1/tests/test_parser.py +112 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: srn-parser
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Parse Sarine .srn gemstone files and export to Wavefront OBJ
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Provides-Extra: dev
|
|
7
|
+
Requires-Dist: ruff; extra == "dev"
|
|
8
|
+
Requires-Dist: pytest; extra == "dev"
|
|
9
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# srn-parser
|
|
2
|
+
|
|
3
|
+
Parse Sarine `.srn` gemstone scan files and export to Wavefront OBJ.
|
|
4
|
+
|
|
5
|
+
SRN files are produced by [Sarine Technologies](https://sarine.com/) diamond scanning systems (DiaMension, DiaScan). They contain stone metadata (weight, color, clarity, dimensions, etc.) and a 3D mesh of the scanned gemstone. The format stores geometry only — no texture coordinates, UVs, or per-face colors. Face normals are computed from the mesh geometry during export, using flat shading which is appropriate for gemstone facets. A generic gemstone material can optionally be included via `--mtl`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
python3 -m venv .venv
|
|
11
|
+
source .venv/bin/activate
|
|
12
|
+
pip install .
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
For development:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
python3 -m venv .venv
|
|
19
|
+
source .venv/bin/activate
|
|
20
|
+
pip install -e ".[dev]"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
No dependencies — pure Python stdlib. Dev extras install `ruff`, `pytest`, and `pre-commit`.
|
|
24
|
+
|
|
25
|
+
## CLI Usage
|
|
26
|
+
|
|
27
|
+
### View stone metadata
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
srn-parser info stone.srn
|
|
31
|
+
srn-parser info stone.srn --json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Convert to OBJ
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
srn-parser convert stone.srn -o output.obj
|
|
38
|
+
srn-parser convert stone.srn -o output.obj --mtl # include material file
|
|
39
|
+
srn-parser convert stone.srn -o output.obj --high-res # use high-resolution mesh
|
|
40
|
+
srn-parser convert stone.srn -o output.obj --scale 0.001 # custom scale factor
|
|
41
|
+
srn-parser convert stone.srn -o output.obj --no-normals # skip normal computation
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
SRN coordinates are in micrometers. The default `--scale` of `1e-6` converts to meters.
|
|
45
|
+
|
|
46
|
+
## Python API
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from srn_parser import parse_srn, export_obj
|
|
50
|
+
|
|
51
|
+
# Parse an SRN file
|
|
52
|
+
srn = parse_srn("stone.srn")
|
|
53
|
+
|
|
54
|
+
# Access metadata
|
|
55
|
+
print(srn.metadata.name) # "b15306"
|
|
56
|
+
print(srn.metadata.weight) # 1.58 (carats)
|
|
57
|
+
print(srn.metadata.color) # "Blue"
|
|
58
|
+
print(srn.metadata.clarity) # "IF"
|
|
59
|
+
print(srn.metadata.shape_group) # "ROUND"
|
|
60
|
+
|
|
61
|
+
# Access mesh data
|
|
62
|
+
print(len(srn.mesh.vertices)) # 682
|
|
63
|
+
print(len(srn.mesh.faces)) # 343
|
|
64
|
+
|
|
65
|
+
# High-resolution mesh (from ConcSculptorData, when available)
|
|
66
|
+
if srn.high_res_mesh:
|
|
67
|
+
print(len(srn.high_res_mesh.vertices)) # 983
|
|
68
|
+
|
|
69
|
+
# Export to OBJ
|
|
70
|
+
export_obj(srn.mesh, "output.obj", name=srn.metadata.name)
|
|
71
|
+
|
|
72
|
+
# Export with material file and custom scale
|
|
73
|
+
export_obj(srn.mesh, "output.obj", name="diamond", scale=0.001, mtl=True)
|
|
74
|
+
|
|
75
|
+
# Export high-res mesh
|
|
76
|
+
export_obj(srn.high_res_mesh, "output_hires.obj", name="diamond_hires")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## SRN Format
|
|
80
|
+
|
|
81
|
+
The SRN format is a proprietary binary container (SPF) with three sections:
|
|
82
|
+
|
|
83
|
+
| Section | Content |
|
|
84
|
+
|---------|---------|
|
|
85
|
+
| `stone.hdr` | Key-value metadata (name, weight, color, clarity, dimensions, etc.) |
|
|
86
|
+
| `extdata.dat` | Binary measurement data, including a high-resolution mesh (ConcSculptorData) |
|
|
87
|
+
| `smesh.dat` | DirectX .X text-format 3D mesh (vertices and faces) |
|
|
88
|
+
|
|
89
|
+
## Tests
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pytest -v
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Sample Data
|
|
96
|
+
|
|
97
|
+
The `samples/` directory contains an example SRN file from Sarine DiaScan measurements.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "srn-parser"
|
|
7
|
+
version = "1.0.1"
|
|
8
|
+
description = "Parse Sarine .srn gemstone files and export to Wavefront OBJ"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
dev = ["ruff", "pytest", "pre-commit"]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
srn-parser = "srn_parser.cli:main"
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
include = ["srn_parser*"]
|
|
19
|
+
|
|
20
|
+
[tool.ruff]
|
|
21
|
+
line-length = 100
|
|
22
|
+
target-version = "py310"
|
|
23
|
+
|
|
24
|
+
[tool.ruff.lint]
|
|
25
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""srn-parser: Parse Sarine .srn gemstone files and export to OBJ."""
|
|
2
|
+
|
|
3
|
+
from .exporters.obj import export_obj
|
|
4
|
+
from .models import Mesh, SRNFile, StoneMetadata
|
|
5
|
+
from .parser import parse_srn
|
|
6
|
+
|
|
7
|
+
__all__ = ["parse_srn", "export_obj", "SRNFile", "StoneMetadata", "Mesh"]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Command-line interface for srn-parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .exporters.obj import export_obj
|
|
11
|
+
from .parser import parse_srn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv: list[str] | None = None) -> None:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="srn-parser",
|
|
17
|
+
description="Parse Sarine .srn gemstone files and export to OBJ.",
|
|
18
|
+
)
|
|
19
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
20
|
+
|
|
21
|
+
# info command
|
|
22
|
+
info_parser = subparsers.add_parser("info", help="Display stone metadata.")
|
|
23
|
+
info_parser.add_argument("file", type=Path, help="Path to .srn file")
|
|
24
|
+
info_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
25
|
+
|
|
26
|
+
# convert command
|
|
27
|
+
convert_parser = subparsers.add_parser("convert", help="Convert to OBJ format.")
|
|
28
|
+
convert_parser.add_argument("file", type=Path, help="Path to .srn file")
|
|
29
|
+
convert_parser.add_argument("-o", "--output", type=Path, help="Output .obj path")
|
|
30
|
+
convert_parser.add_argument(
|
|
31
|
+
"--scale",
|
|
32
|
+
type=float,
|
|
33
|
+
default=1e-6,
|
|
34
|
+
help="Scale factor for coordinates (default: 1e-6, micrometers to meters)",
|
|
35
|
+
)
|
|
36
|
+
convert_parser.add_argument(
|
|
37
|
+
"--no-normals",
|
|
38
|
+
action="store_true",
|
|
39
|
+
help="Skip computing face normals",
|
|
40
|
+
)
|
|
41
|
+
convert_parser.add_argument(
|
|
42
|
+
"--mtl",
|
|
43
|
+
action="store_true",
|
|
44
|
+
help="Generate companion .mtl material file",
|
|
45
|
+
)
|
|
46
|
+
convert_parser.add_argument(
|
|
47
|
+
"--high-res",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="Use high-resolution ConcSculptorData mesh instead of standard mesh",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
args = parser.parse_args(argv)
|
|
53
|
+
|
|
54
|
+
if not args.file.exists():
|
|
55
|
+
print(f"Error: file not found: {args.file}", file=sys.stderr)
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
srn = parse_srn(args.file)
|
|
59
|
+
|
|
60
|
+
if args.command == "info":
|
|
61
|
+
_cmd_info(srn, as_json=args.json)
|
|
62
|
+
elif args.command == "convert":
|
|
63
|
+
_cmd_convert(srn, args)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _cmd_info(srn, *, as_json: bool) -> None:
|
|
67
|
+
m = srn.metadata
|
|
68
|
+
data = {
|
|
69
|
+
"name": m.name,
|
|
70
|
+
"date": m.date.isoformat() if m.date else None,
|
|
71
|
+
"weight_ct": m.weight,
|
|
72
|
+
"measured_weight_ct": m.measured_weight,
|
|
73
|
+
"price": m.price,
|
|
74
|
+
"shape": m.shape_name,
|
|
75
|
+
"shape_group": m.shape_group,
|
|
76
|
+
"color": m.color,
|
|
77
|
+
"clarity": m.clarity,
|
|
78
|
+
"width_mm": m.width,
|
|
79
|
+
"length_mm": m.length,
|
|
80
|
+
"height_mm": m.height,
|
|
81
|
+
"polish": m.polish or None,
|
|
82
|
+
"symmetry": m.symmetry or None,
|
|
83
|
+
"fluorescence": m.fluorescence or None,
|
|
84
|
+
"certificate": m.certificate_num or None,
|
|
85
|
+
"lab": m.associated_lab or None,
|
|
86
|
+
"measure_date": m.measure_date.isoformat() if m.measure_date else None,
|
|
87
|
+
"measure_mode": m.measure_mode,
|
|
88
|
+
"mesh_vertices": len(srn.mesh.vertices),
|
|
89
|
+
"mesh_faces": len(srn.mesh.faces),
|
|
90
|
+
"high_res_vertices": len(srn.high_res_mesh.vertices) if srn.high_res_mesh else None,
|
|
91
|
+
"high_res_faces": len(srn.high_res_mesh.faces) if srn.high_res_mesh else None,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if as_json:
|
|
95
|
+
print(json.dumps(data, indent=2))
|
|
96
|
+
else:
|
|
97
|
+
print(f"Stone: {m.name}")
|
|
98
|
+
print(f"Shape: {m.shape_name} ({m.shape_group})")
|
|
99
|
+
print(f"Weight: {m.weight} ct")
|
|
100
|
+
if m.measured_weight is not None:
|
|
101
|
+
print(f"Measured weight: {m.measured_weight} ct")
|
|
102
|
+
if m.price is not None:
|
|
103
|
+
print(f"Price: ${m.price:.2f}")
|
|
104
|
+
print(f"Color: {m.color}")
|
|
105
|
+
print(f"Clarity: {m.clarity}")
|
|
106
|
+
print(f"Dimensions: {m.width:.3f} x {m.length:.3f} x {m.height:.3f} mm")
|
|
107
|
+
if m.polish:
|
|
108
|
+
print(f"Polish: {m.polish}")
|
|
109
|
+
if m.symmetry:
|
|
110
|
+
print(f"Symmetry: {m.symmetry}")
|
|
111
|
+
if m.fluorescence:
|
|
112
|
+
print(f"Fluorescence: {m.fluorescence}")
|
|
113
|
+
if m.certificate_num:
|
|
114
|
+
print(f"Certificate: {m.certificate_num}")
|
|
115
|
+
if m.associated_lab:
|
|
116
|
+
print(f"Lab: {m.associated_lab}")
|
|
117
|
+
print(f"Measure mode: {m.measure_mode}")
|
|
118
|
+
if m.date:
|
|
119
|
+
print(f"Date: {m.date.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
120
|
+
print(f"Mesh: {len(srn.mesh.vertices)} vertices, {len(srn.mesh.faces)} faces")
|
|
121
|
+
if srn.high_res_mesh:
|
|
122
|
+
print(
|
|
123
|
+
f"High-res mesh: {len(srn.high_res_mesh.vertices)} vertices, "
|
|
124
|
+
f"{len(srn.high_res_mesh.faces)} faces"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _cmd_convert(srn, args) -> None:
|
|
129
|
+
mesh = srn.mesh
|
|
130
|
+
if args.high_res:
|
|
131
|
+
if srn.high_res_mesh is None:
|
|
132
|
+
print("Error: no high-resolution mesh available in this file", file=sys.stderr)
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
mesh = srn.high_res_mesh
|
|
135
|
+
|
|
136
|
+
output = args.output
|
|
137
|
+
if output is None:
|
|
138
|
+
output = Path(srn.metadata.name or "stone").with_suffix(".obj")
|
|
139
|
+
|
|
140
|
+
export_obj(
|
|
141
|
+
mesh,
|
|
142
|
+
output,
|
|
143
|
+
name=srn.metadata.name or "stone",
|
|
144
|
+
scale=args.scale,
|
|
145
|
+
normals=not args.no_normals,
|
|
146
|
+
mtl=args.mtl,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
print(f"Exported {len(mesh.vertices)} vertices, {len(mesh.faces)} faces -> {output}")
|
|
150
|
+
if args.mtl:
|
|
151
|
+
print(f"Material file -> {output.with_suffix('.mtl')}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
main()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Parser for DirectX .X text format meshes (as found in smesh.dat sections)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .models import Mesh
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_directx_mesh(text: str) -> Mesh:
|
|
9
|
+
"""Parse a DirectX .X text-format mesh into a Mesh object.
|
|
10
|
+
|
|
11
|
+
Expects the standard format:
|
|
12
|
+
xof 0303txt 0032
|
|
13
|
+
Header { ... }
|
|
14
|
+
Mesh { vertex_count; vertices...; face_count; faces...; }
|
|
15
|
+
"""
|
|
16
|
+
right_handed = "RightHanded" in text
|
|
17
|
+
|
|
18
|
+
# Extract the Mesh { ... } block content
|
|
19
|
+
mesh_start = text.index("Mesh {")
|
|
20
|
+
# Find the matching closing brace - need to handle nested braces
|
|
21
|
+
brace_depth = 0
|
|
22
|
+
mesh_body_start = text.index("{", mesh_start) + 1
|
|
23
|
+
pos = mesh_body_start
|
|
24
|
+
while pos < len(text):
|
|
25
|
+
if text[pos] == "{":
|
|
26
|
+
brace_depth += 1
|
|
27
|
+
elif text[pos] == "}":
|
|
28
|
+
if brace_depth == 0:
|
|
29
|
+
break
|
|
30
|
+
brace_depth -= 1
|
|
31
|
+
pos += 1
|
|
32
|
+
mesh_body = text[mesh_body_start:pos]
|
|
33
|
+
|
|
34
|
+
# Split on ;; which separates the four main parts:
|
|
35
|
+
# vertex_count; vertices;; face_count; faces;;
|
|
36
|
+
# But we need to be careful - split by ";\n" after stripping
|
|
37
|
+
lines = mesh_body.strip().split(";\n")
|
|
38
|
+
|
|
39
|
+
vertex_count = int(lines[0].strip())
|
|
40
|
+
face_count = int(lines[2].strip())
|
|
41
|
+
|
|
42
|
+
# Parse vertices: "x;y;z;," separated by ",\n" (last ends without comma)
|
|
43
|
+
vertex_block = lines[1].strip()
|
|
44
|
+
vertices: list[tuple[float, float, float]] = []
|
|
45
|
+
for entry in vertex_block.split(",\n"):
|
|
46
|
+
entry = entry.strip().rstrip(",")
|
|
47
|
+
parts = [p for p in entry.split(";") if p.strip()]
|
|
48
|
+
if len(parts) >= 3:
|
|
49
|
+
vertices.append((float(parts[0]), float(parts[1]), float(parts[2])))
|
|
50
|
+
|
|
51
|
+
# Parse faces: "n;i0,i1,...;," separated by ",\n" (last ends without comma)
|
|
52
|
+
face_block = lines[3].strip()
|
|
53
|
+
faces: list[tuple[int, ...]] = []
|
|
54
|
+
for entry in face_block.split(",\n"):
|
|
55
|
+
entry = entry.strip().rstrip(",")
|
|
56
|
+
parts = entry.split(";")
|
|
57
|
+
# parts[0] = vertex count for this face, parts[1] = comma-separated indices
|
|
58
|
+
if len(parts) >= 2 and parts[1].strip():
|
|
59
|
+
indices = tuple(int(i) for i in parts[1].strip().split(",") if i.strip())
|
|
60
|
+
faces.append(indices)
|
|
61
|
+
|
|
62
|
+
assert len(vertices) == vertex_count, f"Expected {vertex_count} vertices, got {len(vertices)}"
|
|
63
|
+
assert len(faces) == face_count, f"Expected {face_count} faces, got {len(faces)}"
|
|
64
|
+
|
|
65
|
+
return Mesh(vertices=vertices, faces=faces, right_handed=right_handed)
|
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Wavefront OBJ exporter for SRN mesh data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..models import Mesh
|
|
9
|
+
|
|
10
|
+
Vec3 = tuple[float, float, float]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _cross(a: Vec3, b: Vec3) -> Vec3:
|
|
14
|
+
return (
|
|
15
|
+
a[1] * b[2] - a[2] * b[1],
|
|
16
|
+
a[2] * b[0] - a[0] * b[2],
|
|
17
|
+
a[0] * b[1] - a[1] * b[0],
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _sub(a: Vec3, b: Vec3) -> Vec3:
|
|
22
|
+
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _normalize(v: Vec3) -> Vec3:
|
|
26
|
+
length = math.sqrt(v[0] ** 2 + v[1] ** 2 + v[2] ** 2)
|
|
27
|
+
if length < 1e-12:
|
|
28
|
+
return (0.0, 0.0, 1.0)
|
|
29
|
+
return (v[0] / length, v[1] / length, v[2] / length)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def compute_face_normals(
|
|
33
|
+
mesh: Mesh,
|
|
34
|
+
) -> list[tuple[float, float, float]]:
|
|
35
|
+
"""Compute one flat normal per face using the cross product of the first two edges."""
|
|
36
|
+
normals: list[tuple[float, float, float]] = []
|
|
37
|
+
for face in mesh.faces:
|
|
38
|
+
if len(face) < 3:
|
|
39
|
+
normals.append((0.0, 0.0, 1.0))
|
|
40
|
+
continue
|
|
41
|
+
v0 = mesh.vertices[face[0]]
|
|
42
|
+
v1 = mesh.vertices[face[1]]
|
|
43
|
+
v2 = mesh.vertices[face[2]]
|
|
44
|
+
edge1 = _sub(v1, v0)
|
|
45
|
+
edge2 = _sub(v2, v0)
|
|
46
|
+
normal = _normalize(_cross(edge1, edge2))
|
|
47
|
+
normals.append(normal)
|
|
48
|
+
return normals
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def export_obj(
|
|
52
|
+
mesh: Mesh,
|
|
53
|
+
path: str | Path,
|
|
54
|
+
*,
|
|
55
|
+
name: str = "stone",
|
|
56
|
+
scale: float = 1e-6,
|
|
57
|
+
normals: bool = True,
|
|
58
|
+
mtl: bool = False,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Export a Mesh to Wavefront OBJ format.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
mesh: The mesh to export.
|
|
64
|
+
path: Output .obj file path.
|
|
65
|
+
name: Object name written to the OBJ header.
|
|
66
|
+
scale: Scale factor applied to vertex coordinates.
|
|
67
|
+
SRN coordinates are in micrometers; default 1e-6 converts to meters.
|
|
68
|
+
normals: If True, compute and write per-face normals (flat shading).
|
|
69
|
+
mtl: If True, write a companion .mtl file with a basic gemstone material.
|
|
70
|
+
"""
|
|
71
|
+
path = Path(path)
|
|
72
|
+
|
|
73
|
+
face_normals = compute_face_normals(mesh) if normals else []
|
|
74
|
+
|
|
75
|
+
mtl_name = path.stem + ".mtl" if mtl else None
|
|
76
|
+
|
|
77
|
+
lines: list[str] = []
|
|
78
|
+
lines.append(f"# SRN Parser - {name}")
|
|
79
|
+
if mtl_name:
|
|
80
|
+
lines.append(f"mtllib {mtl_name}")
|
|
81
|
+
lines.append(f"o {name}")
|
|
82
|
+
lines.append("")
|
|
83
|
+
|
|
84
|
+
# Vertices
|
|
85
|
+
for v in mesh.vertices:
|
|
86
|
+
lines.append(f"v {v[0] * scale:.6f} {v[1] * scale:.6f} {v[2] * scale:.6f}")
|
|
87
|
+
|
|
88
|
+
# Normals
|
|
89
|
+
if face_normals:
|
|
90
|
+
lines.append("")
|
|
91
|
+
for n in face_normals:
|
|
92
|
+
lines.append(f"vn {n[0]:.6f} {n[1]:.6f} {n[2]:.6f}")
|
|
93
|
+
|
|
94
|
+
lines.append("")
|
|
95
|
+
if mtl_name:
|
|
96
|
+
lines.append("usemtl gemstone")
|
|
97
|
+
lines.append("s off")
|
|
98
|
+
|
|
99
|
+
# Faces (1-based indexing)
|
|
100
|
+
for i, face in enumerate(mesh.faces):
|
|
101
|
+
if face_normals:
|
|
102
|
+
ni = i + 1 # 1-based normal index
|
|
103
|
+
indices = " ".join(f"{idx + 1}//{ni}" for idx in face)
|
|
104
|
+
else:
|
|
105
|
+
indices = " ".join(str(idx + 1) for idx in face)
|
|
106
|
+
lines.append(f"f {indices}")
|
|
107
|
+
|
|
108
|
+
lines.append("")
|
|
109
|
+
path.write_text("\n".join(lines))
|
|
110
|
+
|
|
111
|
+
if mtl:
|
|
112
|
+
_write_mtl(path.with_suffix(".mtl"))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _write_mtl(path: Path) -> None:
|
|
116
|
+
"""Write a basic gemstone material file."""
|
|
117
|
+
lines = [
|
|
118
|
+
"# SRN Parser - gemstone material",
|
|
119
|
+
"newmtl gemstone",
|
|
120
|
+
"Ka 0.100000 0.100000 0.100000",
|
|
121
|
+
"Kd 0.800000 0.800000 0.800000",
|
|
122
|
+
"Ks 1.000000 1.000000 1.000000",
|
|
123
|
+
"Ns 200.000000",
|
|
124
|
+
"Ni 2.420000",
|
|
125
|
+
"d 0.950000",
|
|
126
|
+
"illum 2",
|
|
127
|
+
"",
|
|
128
|
+
]
|
|
129
|
+
path.write_text("\n".join(lines))
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class StoneMetadata:
|
|
9
|
+
name: str = ""
|
|
10
|
+
date: datetime | None = None
|
|
11
|
+
weight: float = 0.0
|
|
12
|
+
measured_weight: float | None = None
|
|
13
|
+
price: float | None = None
|
|
14
|
+
shape_name: str = ""
|
|
15
|
+
shape_group: str = ""
|
|
16
|
+
color: str = ""
|
|
17
|
+
clarity: str = ""
|
|
18
|
+
width: float = 0.0
|
|
19
|
+
length: float = 0.0
|
|
20
|
+
height: float = 0.0
|
|
21
|
+
polish: str = ""
|
|
22
|
+
symmetry: str = ""
|
|
23
|
+
fluorescence: str = ""
|
|
24
|
+
certificate_num: str = ""
|
|
25
|
+
associated_lab: str = ""
|
|
26
|
+
measure_date: datetime | None = None
|
|
27
|
+
measure_mode: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Mesh:
|
|
32
|
+
vertices: list[tuple[float, float, float]]
|
|
33
|
+
faces: list[tuple[int, ...]]
|
|
34
|
+
right_handed: bool = True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SRNFile:
|
|
39
|
+
metadata: StoneMetadata
|
|
40
|
+
mesh: Mesh
|
|
41
|
+
high_res_mesh: Mesh | None = None
|
|
42
|
+
ext_data: bytes = b""
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Parser for Sarine .srn (SPF container) files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import struct
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .directx import parse_directx_mesh
|
|
10
|
+
from .models import Mesh, SRNFile, StoneMetadata
|
|
11
|
+
|
|
12
|
+
SPF_MAGIC = b"SPF "
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_srn(path: str | Path) -> SRNFile:
|
|
16
|
+
"""Parse a .srn file and return an SRNFile object."""
|
|
17
|
+
path = Path(path)
|
|
18
|
+
with open(path, "rb") as f:
|
|
19
|
+
data = f.read()
|
|
20
|
+
return _parse_container(data)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_container(data: bytes) -> SRNFile:
|
|
24
|
+
"""Parse the SPF binary container."""
|
|
25
|
+
# Validate magic
|
|
26
|
+
if not data.startswith(SPF_MAGIC):
|
|
27
|
+
raise ValueError(f"Not an SRN file: expected magic {SPF_MAGIC!r}, got {data[:4]!r}")
|
|
28
|
+
|
|
29
|
+
# Header: 20 bytes magic string + 12 bytes unknown + 4 bytes section count
|
|
30
|
+
offset = 20 + 12
|
|
31
|
+
(section_count,) = struct.unpack_from("<I", data, offset)
|
|
32
|
+
offset += 4
|
|
33
|
+
|
|
34
|
+
sections: dict[str, bytes] = {}
|
|
35
|
+
for _ in range(section_count):
|
|
36
|
+
(data_size,) = struct.unpack_from("<I", data, offset)
|
|
37
|
+
offset += 4
|
|
38
|
+
(name_len,) = struct.unpack_from("<I", data, offset)
|
|
39
|
+
offset += 4
|
|
40
|
+
name = data[offset : offset + name_len].decode("ascii")
|
|
41
|
+
offset += name_len
|
|
42
|
+
section_data = data[offset : offset + data_size]
|
|
43
|
+
offset += data_size
|
|
44
|
+
sections[name] = section_data
|
|
45
|
+
|
|
46
|
+
# Parse metadata from stone.hdr
|
|
47
|
+
metadata = StoneMetadata()
|
|
48
|
+
if "stone.hdr" in sections:
|
|
49
|
+
metadata = _parse_metadata(sections["stone.hdr"])
|
|
50
|
+
|
|
51
|
+
# Parse mesh from smesh.dat
|
|
52
|
+
mesh = Mesh(vertices=[], faces=[])
|
|
53
|
+
if "smesh.dat" in sections:
|
|
54
|
+
text = sections["smesh.dat"].decode("ISO-8859-1")
|
|
55
|
+
mesh = parse_directx_mesh(text)
|
|
56
|
+
|
|
57
|
+
# Store raw extdata and try to extract high-res mesh
|
|
58
|
+
ext_data = sections.get("extdata.dat", b"")
|
|
59
|
+
high_res_mesh = _try_parse_conc_sculptor(ext_data)
|
|
60
|
+
|
|
61
|
+
return SRNFile(
|
|
62
|
+
metadata=metadata,
|
|
63
|
+
mesh=mesh,
|
|
64
|
+
high_res_mesh=high_res_mesh,
|
|
65
|
+
ext_data=ext_data,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_metadata(data: bytes) -> StoneMetadata:
|
|
70
|
+
"""Parse the stone.hdr section (INI-style key=value pairs)."""
|
|
71
|
+
text = data.decode("ISO-8859-1")
|
|
72
|
+
|
|
73
|
+
# Strip the [data] prefix
|
|
74
|
+
if "[data]" in text:
|
|
75
|
+
text = text.split("[data]", 1)[1]
|
|
76
|
+
|
|
77
|
+
kvs: dict[str, str] = {}
|
|
78
|
+
for line in text.split("\r\n"):
|
|
79
|
+
line = line.strip()
|
|
80
|
+
if "=" in line:
|
|
81
|
+
key, _, value = line.partition("=")
|
|
82
|
+
kvs[key.strip()] = value.strip()
|
|
83
|
+
|
|
84
|
+
def _parse_timestamp(val: str) -> datetime | None:
|
|
85
|
+
if not val:
|
|
86
|
+
return None
|
|
87
|
+
try:
|
|
88
|
+
return datetime.fromtimestamp(int(val), tz=timezone.utc)
|
|
89
|
+
except (ValueError, OSError):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def _parse_float(val: str) -> float:
|
|
93
|
+
if not val:
|
|
94
|
+
return 0.0
|
|
95
|
+
try:
|
|
96
|
+
return float(val)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return 0.0
|
|
99
|
+
|
|
100
|
+
# stoneWeight can be "carat_weight,measured_weight"
|
|
101
|
+
weight = 0.0
|
|
102
|
+
measured_weight = None
|
|
103
|
+
weight_str = kvs.get("stoneWeight", "")
|
|
104
|
+
if "," in weight_str:
|
|
105
|
+
parts = weight_str.split(",")
|
|
106
|
+
weight = _parse_float(parts[0])
|
|
107
|
+
measured_weight = _parse_float(parts[1])
|
|
108
|
+
else:
|
|
109
|
+
weight = _parse_float(weight_str)
|
|
110
|
+
|
|
111
|
+
price_val = kvs.get("stonePrice", "")
|
|
112
|
+
|
|
113
|
+
return StoneMetadata(
|
|
114
|
+
name=kvs.get("stoneName", ""),
|
|
115
|
+
date=_parse_timestamp(kvs.get("stoneDate", "")),
|
|
116
|
+
weight=weight,
|
|
117
|
+
measured_weight=measured_weight,
|
|
118
|
+
price=_parse_float(price_val) if price_val else None,
|
|
119
|
+
shape_name=kvs.get("shapeName", ""),
|
|
120
|
+
shape_group=kvs.get("shapeGroup", ""),
|
|
121
|
+
color=kvs.get("stoneColor", ""),
|
|
122
|
+
clarity=kvs.get("stoneClarity", ""),
|
|
123
|
+
width=_parse_float(kvs.get("stoneWidth", "")),
|
|
124
|
+
length=_parse_float(kvs.get("stoneLength", "")),
|
|
125
|
+
height=_parse_float(kvs.get("stoneHeight", "")),
|
|
126
|
+
polish=kvs.get("stonePolish", ""),
|
|
127
|
+
symmetry=kvs.get("stoneSymmetry", ""),
|
|
128
|
+
fluorescence=kvs.get("stoneFluorescence", ""),
|
|
129
|
+
certificate_num=kvs.get("stoneCertificateNum", ""),
|
|
130
|
+
associated_lab=kvs.get("StoneAssociatedLab", ""),
|
|
131
|
+
measure_date=_parse_timestamp(kvs.get("stoneMeasureDate", "")),
|
|
132
|
+
measure_mode=kvs.get("StoneMeasureMode", ""),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _try_parse_conc_sculptor(ext_data: bytes) -> Mesh | None:
|
|
137
|
+
"""Try to extract a high-resolution mesh from the ConcSculptorData section."""
|
|
138
|
+
marker = b"ConcSculptorData"
|
|
139
|
+
idx = ext_data.find(marker)
|
|
140
|
+
if idx < 0:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
offset = idx + len(marker)
|
|
145
|
+
# Header: uint32 unknown (=2), uint32 vertex_count
|
|
146
|
+
(_unknown, vertex_count) = struct.unpack_from("<II", ext_data, offset)
|
|
147
|
+
offset += 8
|
|
148
|
+
|
|
149
|
+
# Read vertex_count * 3 doubles (XYZ)
|
|
150
|
+
vertices: list[tuple[float, float, float]] = []
|
|
151
|
+
for _ in range(vertex_count):
|
|
152
|
+
x, y, z = struct.unpack_from("<ddd", ext_data, offset)
|
|
153
|
+
vertices.append((x, y, z))
|
|
154
|
+
offset += 24
|
|
155
|
+
|
|
156
|
+
# Read face data: uint32 face_count, then faces
|
|
157
|
+
(face_count,) = struct.unpack_from("<I", ext_data, offset)
|
|
158
|
+
offset += 4
|
|
159
|
+
|
|
160
|
+
faces: list[tuple[int, ...]] = []
|
|
161
|
+
for _ in range(face_count):
|
|
162
|
+
(n_verts,) = struct.unpack_from("<I", ext_data, offset)
|
|
163
|
+
offset += 4
|
|
164
|
+
indices = struct.unpack_from(f"<{n_verts}I", ext_data, offset)
|
|
165
|
+
faces.append(tuple(indices))
|
|
166
|
+
offset += n_verts * 4
|
|
167
|
+
|
|
168
|
+
return Mesh(vertices=vertices, faces=faces, right_handed=True)
|
|
169
|
+
except (struct.error, IndexError, OverflowError):
|
|
170
|
+
return None
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: srn-parser
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: Parse Sarine .srn gemstone files and export to Wavefront OBJ
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Provides-Extra: dev
|
|
7
|
+
Requires-Dist: ruff; extra == "dev"
|
|
8
|
+
Requires-Dist: pytest; extra == "dev"
|
|
9
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
srn_parser/__init__.py
|
|
4
|
+
srn_parser/cli.py
|
|
5
|
+
srn_parser/directx.py
|
|
6
|
+
srn_parser/models.py
|
|
7
|
+
srn_parser/parser.py
|
|
8
|
+
srn_parser.egg-info/PKG-INFO
|
|
9
|
+
srn_parser.egg-info/SOURCES.txt
|
|
10
|
+
srn_parser.egg-info/dependency_links.txt
|
|
11
|
+
srn_parser.egg-info/entry_points.txt
|
|
12
|
+
srn_parser.egg-info/requires.txt
|
|
13
|
+
srn_parser.egg-info/top_level.txt
|
|
14
|
+
srn_parser/exporters/__init__.py
|
|
15
|
+
srn_parser/exporters/obj.py
|
|
16
|
+
tests/test_cli.py
|
|
17
|
+
tests/test_directx.py
|
|
18
|
+
tests/test_obj_exporter.py
|
|
19
|
+
tests/test_parser.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
srn_parser
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Tests for the CLI interface."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from srn_parser.cli import main
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestInfoCommand:
|
|
11
|
+
def test_info_output(self, sample_b15924, capsys):
|
|
12
|
+
main(["info", str(sample_b15924)])
|
|
13
|
+
output = capsys.readouterr().out
|
|
14
|
+
assert "b15924" in output
|
|
15
|
+
assert "ROUND" in output
|
|
16
|
+
assert "1.11 ct" in output
|
|
17
|
+
assert "Blue" in output
|
|
18
|
+
assert "IF" in output
|
|
19
|
+
assert "880 vertices" in output
|
|
20
|
+
|
|
21
|
+
def test_info_json(self, sample_b15924, capsys):
|
|
22
|
+
main(["info", str(sample_b15924), "--json"])
|
|
23
|
+
output = capsys.readouterr().out
|
|
24
|
+
data = json.loads(output)
|
|
25
|
+
assert data["name"] == "b15924"
|
|
26
|
+
assert data["weight_ct"] == 1.11
|
|
27
|
+
assert data["mesh_vertices"] == 880
|
|
28
|
+
assert data["mesh_faces"] == 442
|
|
29
|
+
assert data["high_res_vertices"] == 912
|
|
30
|
+
|
|
31
|
+
def test_info_missing_file(self, tmp_path):
|
|
32
|
+
with pytest.raises(SystemExit):
|
|
33
|
+
main(["info", str(tmp_path / "nonexistent.srn")])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestConvertCommand:
|
|
37
|
+
def test_convert_creates_obj(self, sample_b15924, tmp_path):
|
|
38
|
+
out = tmp_path / "out.obj"
|
|
39
|
+
main(["convert", str(sample_b15924), "-o", str(out)])
|
|
40
|
+
assert out.exists()
|
|
41
|
+
content = out.read_text()
|
|
42
|
+
assert content.startswith("# SRN Parser")
|
|
43
|
+
|
|
44
|
+
def test_convert_default_filename(self, sample_b15924, capsys):
|
|
45
|
+
main(["convert", str(sample_b15924)])
|
|
46
|
+
output = capsys.readouterr().out
|
|
47
|
+
assert "b15924.obj" in output
|
|
48
|
+
|
|
49
|
+
def test_convert_with_mtl(self, sample_b15924, tmp_path):
|
|
50
|
+
out = tmp_path / "out.obj"
|
|
51
|
+
main(["convert", str(sample_b15924), "-o", str(out), "--mtl"])
|
|
52
|
+
assert out.exists()
|
|
53
|
+
assert (tmp_path / "out.mtl").exists()
|
|
54
|
+
|
|
55
|
+
def test_convert_high_res(self, sample_b15924, tmp_path, capsys):
|
|
56
|
+
out = tmp_path / "out.obj"
|
|
57
|
+
main(["convert", str(sample_b15924), "-o", str(out), "--high-res"])
|
|
58
|
+
output = capsys.readouterr().out
|
|
59
|
+
assert "912 vertices" in output
|
|
60
|
+
|
|
61
|
+
def test_convert_no_normals(self, sample_b15924, tmp_path):
|
|
62
|
+
out = tmp_path / "out.obj"
|
|
63
|
+
main(["convert", str(sample_b15924), "-o", str(out), "--no-normals"])
|
|
64
|
+
content = out.read_text()
|
|
65
|
+
assert "vn " not in content
|
|
66
|
+
|
|
67
|
+
def test_convert_custom_scale(self, sample_b15924, tmp_path):
|
|
68
|
+
out = tmp_path / "out.obj"
|
|
69
|
+
main(["convert", str(sample_b15924), "-o", str(out), "--scale", "0.001"])
|
|
70
|
+
content = out.read_text()
|
|
71
|
+
v_line = [ln for ln in content.splitlines() if ln.startswith("v ")][0]
|
|
72
|
+
x = float(v_line.split()[1])
|
|
73
|
+
assert abs(x) > 0.01 # much larger than default 1e-6 scale
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Tests for the DirectX .X text mesh parser."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from srn_parser.directx import parse_directx_mesh
|
|
6
|
+
from srn_parser.models import Mesh
|
|
7
|
+
|
|
8
|
+
MINIMAL_MESH = """\
|
|
9
|
+
xof 0303txt 0032
|
|
10
|
+
|
|
11
|
+
Header {
|
|
12
|
+
1;
|
|
13
|
+
0;
|
|
14
|
+
1;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
Mesh {
|
|
18
|
+
4;
|
|
19
|
+
0.0;0.0;0.0;,
|
|
20
|
+
1.0;0.0;0.0;,
|
|
21
|
+
1.0;1.0;0.0;,
|
|
22
|
+
0.0;1.0;0.0;;
|
|
23
|
+
2;
|
|
24
|
+
3;0,1,2;,
|
|
25
|
+
3;0,2,3;;
|
|
26
|
+
|
|
27
|
+
RightHanded {
|
|
28
|
+
1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
MESH_NO_RIGHT_HANDED = """\
|
|
34
|
+
xof 0303txt 0032
|
|
35
|
+
|
|
36
|
+
Header {
|
|
37
|
+
1;
|
|
38
|
+
0;
|
|
39
|
+
1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
Mesh {
|
|
43
|
+
3;
|
|
44
|
+
0.0;0.0;0.0;,
|
|
45
|
+
1.0;0.0;0.0;,
|
|
46
|
+
0.5;1.0;0.0;;
|
|
47
|
+
1;
|
|
48
|
+
3;0,1,2;;
|
|
49
|
+
}
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestParseDirectXMesh:
|
|
54
|
+
def test_minimal_mesh(self):
|
|
55
|
+
mesh = parse_directx_mesh(MINIMAL_MESH)
|
|
56
|
+
assert isinstance(mesh, Mesh)
|
|
57
|
+
assert len(mesh.vertices) == 4
|
|
58
|
+
assert len(mesh.faces) == 2
|
|
59
|
+
|
|
60
|
+
def test_vertex_values(self):
|
|
61
|
+
mesh = parse_directx_mesh(MINIMAL_MESH)
|
|
62
|
+
assert mesh.vertices[0] == (0.0, 0.0, 0.0)
|
|
63
|
+
assert mesh.vertices[1] == (1.0, 0.0, 0.0)
|
|
64
|
+
assert mesh.vertices[2] == (1.0, 1.0, 0.0)
|
|
65
|
+
assert mesh.vertices[3] == (0.0, 1.0, 0.0)
|
|
66
|
+
|
|
67
|
+
def test_face_indices(self):
|
|
68
|
+
mesh = parse_directx_mesh(MINIMAL_MESH)
|
|
69
|
+
assert mesh.faces[0] == (0, 1, 2)
|
|
70
|
+
assert mesh.faces[1] == (0, 2, 3)
|
|
71
|
+
|
|
72
|
+
def test_right_handed_flag(self):
|
|
73
|
+
mesh = parse_directx_mesh(MINIMAL_MESH)
|
|
74
|
+
assert mesh.right_handed is True
|
|
75
|
+
|
|
76
|
+
def test_no_right_handed(self):
|
|
77
|
+
mesh = parse_directx_mesh(MESH_NO_RIGHT_HANDED)
|
|
78
|
+
assert mesh.right_handed is False
|
|
79
|
+
assert len(mesh.vertices) == 3
|
|
80
|
+
assert len(mesh.faces) == 1
|
|
81
|
+
|
|
82
|
+
def test_missing_mesh_block_raises(self):
|
|
83
|
+
with pytest.raises(ValueError):
|
|
84
|
+
parse_directx_mesh("xof 0303txt 0032\nHeader { 1; 0; 1; }")
|
|
85
|
+
|
|
86
|
+
def test_quad_faces(self):
|
|
87
|
+
mesh = parse_directx_mesh(MINIMAL_MESH)
|
|
88
|
+
# Second face has 3 vertices (both are triangles in this test)
|
|
89
|
+
assert all(len(f) == 3 for f in mesh.faces)
|
|
90
|
+
|
|
91
|
+
def test_negative_coords(self):
|
|
92
|
+
text = MINIMAL_MESH.replace("0.0;0.0;0.0", "-1.5;-2.5;-3.5")
|
|
93
|
+
mesh = parse_directx_mesh(text)
|
|
94
|
+
assert mesh.vertices[0] == (-1.5, -2.5, -3.5)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Tests for the Wavefront OBJ exporter."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from srn_parser.exporters.obj import compute_face_normals, export_obj
|
|
8
|
+
from srn_parser.models import Mesh
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def triangle_mesh():
|
|
13
|
+
"""A single triangle in the XY plane."""
|
|
14
|
+
return Mesh(
|
|
15
|
+
vertices=[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)],
|
|
16
|
+
faces=[(0, 1, 2)],
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def quad_mesh():
|
|
22
|
+
"""A quad (two-triangle square) in the XY plane."""
|
|
23
|
+
return Mesh(
|
|
24
|
+
vertices=[
|
|
25
|
+
(0.0, 0.0, 0.0),
|
|
26
|
+
(1.0, 0.0, 0.0),
|
|
27
|
+
(1.0, 1.0, 0.0),
|
|
28
|
+
(0.0, 1.0, 0.0),
|
|
29
|
+
],
|
|
30
|
+
faces=[(0, 1, 2, 3)],
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TestComputeFaceNormals:
|
|
35
|
+
def test_xy_plane_normal_points_z(self, triangle_mesh):
|
|
36
|
+
normals = compute_face_normals(triangle_mesh)
|
|
37
|
+
assert len(normals) == 1
|
|
38
|
+
nx, ny, nz = normals[0]
|
|
39
|
+
assert abs(nx) < 1e-9
|
|
40
|
+
assert abs(ny) < 1e-9
|
|
41
|
+
assert abs(nz - 1.0) < 1e-9
|
|
42
|
+
|
|
43
|
+
def test_xz_plane_normal(self):
|
|
44
|
+
mesh = Mesh(
|
|
45
|
+
vertices=[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0, 0.0, 1.0)],
|
|
46
|
+
faces=[(0, 1, 2)],
|
|
47
|
+
)
|
|
48
|
+
normals = compute_face_normals(mesh)
|
|
49
|
+
nx, ny, nz = normals[0]
|
|
50
|
+
assert abs(nx) < 1e-9
|
|
51
|
+
assert abs(ny - (-1.0)) < 1e-9
|
|
52
|
+
assert abs(nz) < 1e-9
|
|
53
|
+
|
|
54
|
+
def test_normals_are_unit_length(self, triangle_mesh):
|
|
55
|
+
normals = compute_face_normals(triangle_mesh)
|
|
56
|
+
for n in normals:
|
|
57
|
+
length = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2)
|
|
58
|
+
assert abs(length - 1.0) < 1e-9
|
|
59
|
+
|
|
60
|
+
def test_quad_normal(self, quad_mesh):
|
|
61
|
+
normals = compute_face_normals(quad_mesh)
|
|
62
|
+
assert len(normals) == 1
|
|
63
|
+
assert abs(normals[0][2] - 1.0) < 1e-9
|
|
64
|
+
|
|
65
|
+
def test_degenerate_face_returns_default(self):
|
|
66
|
+
mesh = Mesh(
|
|
67
|
+
vertices=[(0.0, 0.0, 0.0), (0.0, 0.0, 0.0), (0.0, 0.0, 0.0)],
|
|
68
|
+
faces=[(0, 1, 2)],
|
|
69
|
+
)
|
|
70
|
+
normals = compute_face_normals(mesh)
|
|
71
|
+
length = math.sqrt(sum(c**2 for c in normals[0]))
|
|
72
|
+
assert abs(length - 1.0) < 1e-9
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TestExportOBJ:
|
|
76
|
+
def test_creates_file(self, triangle_mesh, tmp_path):
|
|
77
|
+
out = tmp_path / "test.obj"
|
|
78
|
+
export_obj(triangle_mesh, out, name="test")
|
|
79
|
+
assert out.exists()
|
|
80
|
+
content = out.read_text()
|
|
81
|
+
assert "o test" in content
|
|
82
|
+
|
|
83
|
+
def test_vertex_count(self, triangle_mesh, tmp_path):
|
|
84
|
+
out = tmp_path / "test.obj"
|
|
85
|
+
export_obj(triangle_mesh, out)
|
|
86
|
+
content = out.read_text()
|
|
87
|
+
v_lines = [ln for ln in content.splitlines() if ln.startswith("v ")]
|
|
88
|
+
assert len(v_lines) == 3
|
|
89
|
+
|
|
90
|
+
def test_face_count(self, triangle_mesh, tmp_path):
|
|
91
|
+
out = tmp_path / "test.obj"
|
|
92
|
+
export_obj(triangle_mesh, out)
|
|
93
|
+
content = out.read_text()
|
|
94
|
+
f_lines = [ln for ln in content.splitlines() if ln.startswith("f ")]
|
|
95
|
+
assert len(f_lines) == 1
|
|
96
|
+
|
|
97
|
+
def test_normals_present_by_default(self, triangle_mesh, tmp_path):
|
|
98
|
+
out = tmp_path / "test.obj"
|
|
99
|
+
export_obj(triangle_mesh, out)
|
|
100
|
+
content = out.read_text()
|
|
101
|
+
vn_lines = [ln for ln in content.splitlines() if ln.startswith("vn ")]
|
|
102
|
+
assert len(vn_lines) == 1
|
|
103
|
+
f_lines = [ln for ln in content.splitlines() if ln.startswith("f ")]
|
|
104
|
+
assert "//" in f_lines[0]
|
|
105
|
+
|
|
106
|
+
def test_no_normals_option(self, triangle_mesh, tmp_path):
|
|
107
|
+
out = tmp_path / "test.obj"
|
|
108
|
+
export_obj(triangle_mesh, out, normals=False)
|
|
109
|
+
content = out.read_text()
|
|
110
|
+
vn_lines = [ln for ln in content.splitlines() if ln.startswith("vn ")]
|
|
111
|
+
assert len(vn_lines) == 0
|
|
112
|
+
f_lines = [ln for ln in content.splitlines() if ln.startswith("f ")]
|
|
113
|
+
assert "//" not in f_lines[0]
|
|
114
|
+
|
|
115
|
+
def test_scale_factor(self, triangle_mesh, tmp_path):
|
|
116
|
+
out = tmp_path / "test.obj"
|
|
117
|
+
export_obj(triangle_mesh, out, scale=2.0, normals=False)
|
|
118
|
+
content = out.read_text()
|
|
119
|
+
v_lines = [ln for ln in content.splitlines() if ln.startswith("v ")]
|
|
120
|
+
parts = v_lines[1].split()
|
|
121
|
+
assert float(parts[1]) == pytest.approx(2.0)
|
|
122
|
+
|
|
123
|
+
def test_one_based_indexing(self, triangle_mesh, tmp_path):
|
|
124
|
+
out = tmp_path / "test.obj"
|
|
125
|
+
export_obj(triangle_mesh, out, normals=False)
|
|
126
|
+
content = out.read_text()
|
|
127
|
+
f_lines = [ln for ln in content.splitlines() if ln.startswith("f ")]
|
|
128
|
+
indices = f_lines[0].split()[1:]
|
|
129
|
+
assert indices == ["1", "2", "3"]
|
|
130
|
+
|
|
131
|
+
def test_mtl_file_created(self, triangle_mesh, tmp_path):
|
|
132
|
+
out = tmp_path / "test.obj"
|
|
133
|
+
export_obj(triangle_mesh, out, mtl=True)
|
|
134
|
+
mtl_path = tmp_path / "test.mtl"
|
|
135
|
+
assert mtl_path.exists()
|
|
136
|
+
content = mtl_path.read_text()
|
|
137
|
+
assert "newmtl gemstone" in content
|
|
138
|
+
assert "Ni 2.420000" in content
|
|
139
|
+
|
|
140
|
+
def test_mtl_referenced_in_obj(self, triangle_mesh, tmp_path):
|
|
141
|
+
out = tmp_path / "test.obj"
|
|
142
|
+
export_obj(triangle_mesh, out, mtl=True)
|
|
143
|
+
content = out.read_text()
|
|
144
|
+
assert "mtllib test.mtl" in content
|
|
145
|
+
assert "usemtl gemstone" in content
|
|
146
|
+
|
|
147
|
+
def test_flat_shading(self, triangle_mesh, tmp_path):
|
|
148
|
+
out = tmp_path / "test.obj"
|
|
149
|
+
export_obj(triangle_mesh, out)
|
|
150
|
+
content = out.read_text()
|
|
151
|
+
assert "s off" in content
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TestExportFromSampleFiles:
|
|
155
|
+
def test_export_b15924(self, sample_b15924, tmp_path):
|
|
156
|
+
from srn_parser import export_obj, parse_srn
|
|
157
|
+
|
|
158
|
+
srn = parse_srn(sample_b15924)
|
|
159
|
+
out = tmp_path / "b15924.obj"
|
|
160
|
+
export_obj(srn.mesh, out, name=srn.metadata.name)
|
|
161
|
+
content = out.read_text()
|
|
162
|
+
v_lines = [ln for ln in content.splitlines() if ln.startswith("v ")]
|
|
163
|
+
f_lines = [ln for ln in content.splitlines() if ln.startswith("f ")]
|
|
164
|
+
vn_lines = [ln for ln in content.splitlines() if ln.startswith("vn ")]
|
|
165
|
+
assert len(v_lines) == 880
|
|
166
|
+
assert len(f_lines) == 442
|
|
167
|
+
assert len(vn_lines) == 442
|
|
168
|
+
|
|
169
|
+
def test_export_high_res(self, sample_b15924, tmp_path):
|
|
170
|
+
from srn_parser import export_obj, parse_srn
|
|
171
|
+
|
|
172
|
+
srn = parse_srn(sample_b15924)
|
|
173
|
+
out = tmp_path / "hires.obj"
|
|
174
|
+
export_obj(srn.high_res_mesh, out)
|
|
175
|
+
content = out.read_text()
|
|
176
|
+
v_lines = [ln for ln in content.splitlines() if ln.startswith("v ")]
|
|
177
|
+
assert len(v_lines) == 912
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Tests for the SRN binary container parser."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from srn_parser.models import Mesh, SRNFile, StoneMetadata
|
|
6
|
+
from srn_parser.parser import _parse_metadata, parse_srn
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestParseSRN:
|
|
10
|
+
def test_parses_b15924(self, sample_b15924):
|
|
11
|
+
srn = parse_srn(sample_b15924)
|
|
12
|
+
assert isinstance(srn, SRNFile)
|
|
13
|
+
assert isinstance(srn.metadata, StoneMetadata)
|
|
14
|
+
assert isinstance(srn.mesh, Mesh)
|
|
15
|
+
|
|
16
|
+
def test_rejects_invalid_magic(self, tmp_path):
|
|
17
|
+
bad_file = tmp_path / "bad.srn"
|
|
18
|
+
bad_file.write_bytes(b"NOT AN SRN FILE")
|
|
19
|
+
with pytest.raises(ValueError, match="Not an SRN file"):
|
|
20
|
+
parse_srn(bad_file)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestMetadata:
|
|
24
|
+
def test_b15924_metadata(self, sample_b15924):
|
|
25
|
+
srn = parse_srn(sample_b15924)
|
|
26
|
+
m = srn.metadata
|
|
27
|
+
assert m.name == "b15924"
|
|
28
|
+
assert m.weight == 1.11
|
|
29
|
+
assert m.shape_name == "ROUND-P11-P11"
|
|
30
|
+
assert m.shape_group == "ROUND"
|
|
31
|
+
assert m.color == "Blue"
|
|
32
|
+
assert m.clarity == "IF"
|
|
33
|
+
assert m.width == pytest.approx(1.11)
|
|
34
|
+
assert m.length == pytest.approx(6.02)
|
|
35
|
+
assert m.height == pytest.approx(3.899)
|
|
36
|
+
assert m.measure_mode == "Fastest"
|
|
37
|
+
|
|
38
|
+
def test_date_parsing(self, sample_b15924):
|
|
39
|
+
srn = parse_srn(sample_b15924)
|
|
40
|
+
assert srn.metadata.date is not None
|
|
41
|
+
assert srn.metadata.date.year == 2022
|
|
42
|
+
|
|
43
|
+
def test_empty_optional_fields(self, sample_b15924):
|
|
44
|
+
srn = parse_srn(sample_b15924)
|
|
45
|
+
assert srn.metadata.polish == ""
|
|
46
|
+
assert srn.metadata.symmetry == ""
|
|
47
|
+
assert srn.metadata.fluorescence == ""
|
|
48
|
+
assert srn.metadata.certificate_num == ""
|
|
49
|
+
|
|
50
|
+
def test_parse_metadata_handles_missing_keys(self):
|
|
51
|
+
data = b"[data]\r\nstoneName=test\r\n"
|
|
52
|
+
m = _parse_metadata(data)
|
|
53
|
+
assert m.name == "test"
|
|
54
|
+
assert m.weight == 0.0
|
|
55
|
+
assert m.price is None
|
|
56
|
+
assert m.date is None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestMesh:
|
|
60
|
+
def test_b15924_mesh_counts(self, sample_b15924):
|
|
61
|
+
srn = parse_srn(sample_b15924)
|
|
62
|
+
assert len(srn.mesh.vertices) == 880
|
|
63
|
+
assert len(srn.mesh.faces) == 442
|
|
64
|
+
|
|
65
|
+
def test_vertices_are_3d_tuples(self, sample_b15924):
|
|
66
|
+
srn = parse_srn(sample_b15924)
|
|
67
|
+
for v in srn.mesh.vertices:
|
|
68
|
+
assert len(v) == 3
|
|
69
|
+
assert all(isinstance(c, float) for c in v)
|
|
70
|
+
|
|
71
|
+
def test_face_indices_in_range(self, sample_b15924):
|
|
72
|
+
srn = parse_srn(sample_b15924)
|
|
73
|
+
n_verts = len(srn.mesh.vertices)
|
|
74
|
+
for face in srn.mesh.faces:
|
|
75
|
+
assert len(face) >= 3
|
|
76
|
+
for idx in face:
|
|
77
|
+
assert 0 <= idx < n_verts
|
|
78
|
+
|
|
79
|
+
def test_right_handed(self, sample_b15924):
|
|
80
|
+
srn = parse_srn(sample_b15924)
|
|
81
|
+
assert srn.mesh.right_handed is True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TestHighResMesh:
|
|
85
|
+
def test_b15924_high_res_exists(self, sample_b15924):
|
|
86
|
+
srn = parse_srn(sample_b15924)
|
|
87
|
+
assert srn.high_res_mesh is not None
|
|
88
|
+
assert len(srn.high_res_mesh.vertices) == 912
|
|
89
|
+
assert len(srn.high_res_mesh.faces) == 458
|
|
90
|
+
|
|
91
|
+
def test_high_res_more_detail(self, sample_b15924):
|
|
92
|
+
srn = parse_srn(sample_b15924)
|
|
93
|
+
assert len(srn.high_res_mesh.vertices) > len(srn.mesh.vertices)
|
|
94
|
+
assert len(srn.high_res_mesh.faces) > len(srn.mesh.faces)
|
|
95
|
+
|
|
96
|
+
def test_high_res_indices_in_range(self, sample_b15924):
|
|
97
|
+
srn = parse_srn(sample_b15924)
|
|
98
|
+
n_verts = len(srn.high_res_mesh.vertices)
|
|
99
|
+
for face in srn.high_res_mesh.faces:
|
|
100
|
+
assert len(face) >= 3
|
|
101
|
+
for idx in face:
|
|
102
|
+
assert 0 <= idx < n_verts
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TestExtData:
|
|
106
|
+
def test_ext_data_present(self, sample_b15924):
|
|
107
|
+
srn = parse_srn(sample_b15924)
|
|
108
|
+
assert len(srn.ext_data) > 0
|
|
109
|
+
|
|
110
|
+
def test_ext_data_contains_conc_sculptor(self, sample_b15924):
|
|
111
|
+
srn = parse_srn(sample_b15924)
|
|
112
|
+
assert b"ConcSculptorData" in srn.ext_data
|