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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ srn-parser = srn_parser.cli:main
@@ -0,0 +1,5 @@
1
+
2
+ [dev]
3
+ ruff
4
+ pytest
5
+ pre-commit
@@ -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