diff --git a/CLAUDE.md b/CLAUDE.md index b0dac32..d13a823 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,14 +5,16 @@ ## 開発進捗 -現在の進捗: フェーズ 7(実装)— Step 1〜2 完了. -次のアクション: `/implement Step 3: ジオメトリ層`([PLAN_02 参照](docs/03_PLAN/PLAN_02_開発ステップ.md)) +現在の進捗: フェーズ 7(実装)— Step 1〜3 完了. +次のアクション: `/implement Step 4: シーン構築 + 最小レンダー`([PLAN_02 参照](docs/03_PLAN/PLAN_02_開発ステップ.md)) 実装済みモジュール: - `src/sm_tias_lightsim/uniformity.py` — Rec.709 輝度変換 + CoV/std/max-min(MiniTias-Evaluation コピー) - `src/sm_tias_lightsim/evaluation/zones.py` — 正規化楕円距離マップ + 3 ゾーン分類 + ゾーン別統計 - `src/sm_tias_lightsim/evaluation/radial.py` — 20 ビン放射状輝度プロファイル - `src/sm_tias_lightsim/scene/schema.py` — Pydantic ベース YAML スキーマ + ローダ +- `src/sm_tias_lightsim/geometry/loader.py` — STL 読込(trimesh)+ `LoadedMesh` データクラス +- `src/sm_tias_lightsim/geometry/transforms.py` — mm⇄m 単位スケール変換 ※ ステップ完了時にここを更新すること. ## 必須ルール(コード実装時) diff --git a/src/sm_tias_lightsim/geometry/__init__.py b/src/sm_tias_lightsim/geometry/__init__.py new file mode 100644 index 0000000..81752ba --- /dev/null +++ b/src/sm_tias_lightsim/geometry/__init__.py @@ -0,0 +1,6 @@ +"""geometry パッケージ — STL 読み込みと単位変換.""" + +from sm_tias_lightsim.geometry.loader import LoadedMesh, load_stl +from sm_tias_lightsim.geometry.transforms import mm_to_m, scale_vertices + +__all__ = ["LoadedMesh", "load_stl", "mm_to_m", "scale_vertices"] diff --git a/src/sm_tias_lightsim/geometry/loader.py b/src/sm_tias_lightsim/geometry/loader.py new file mode 100644 index 0000000..4283c92 --- /dev/null +++ b/src/sm_tias_lightsim/geometry/loader.py @@ -0,0 +1,74 @@ +"""STL ファイル読み込みモジュール. + +trimesh を使って STL を読み込み,頂点・面・単位情報を LoadedMesh として返す. +""" + +from __future__ import annotations + +import pathlib +from dataclasses import dataclass +from typing import Literal + +import numpy +import trimesh + + +@dataclass(frozen=True) +class LoadedMesh: + """STL から読み込んだメッシュ情報. + + Attributes: + vertices: 頂点配列 (N_v, 3), float64.座標は読み込み時の単位そのまま. + faces: 三角形面配列 (N_f, 3), int64.各値は vertices の行インデックス. + unit: 元 STL の単位("mm" または "m").scale_vertices で変換するときの参照. + source_path: 元ファイルのパス(デバッグ用). + """ + + vertices: numpy.ndarray + faces: numpy.ndarray + unit: Literal["mm", "m"] + source_path: pathlib.Path + + +def load_stl(path: str | pathlib.Path, unit: Literal["mm", "m"]) -> LoadedMesh: + """STL ファイルを trimesh で読み込み LoadedMesh として返す. + + trimesh は STL の単位情報を持たないため,呼び出し側が `unit` を明示する必要がある. + バイナリ・ASCII の両形式は trimesh が自動判別するため,特別な処理は不要. + + Args: + path: STL ファイルパス. + unit: STL の単位指定.SPEC_02 の `geometry.meshes[].unit` と一致する. + + Returns: + 頂点・面・単位を保持する LoadedMesh. + + Raises: + FileNotFoundError: ファイルが存在しない場合. + ValueError: STL のパースに失敗した場合,または三角形メッシュでない場合. + """ + resolved = pathlib.Path(path).resolve() + + if not resolved.exists(): + msg = f"STL file not found: {resolved}" + raise FileNotFoundError(msg) + + loaded = trimesh.load_mesh(str(resolved), file_type="stl") + + # trimesh.load_mesh は複数メッシュの場合 Scene を返す場合がある + if not isinstance(loaded, trimesh.Trimesh): + msg = ( + f"Expected a single Trimesh, got {type(loaded).__name__}. " + "Multi-mesh STL files are not supported." + ) + raise ValueError(msg) + + vertices = loaded.vertices.astype(numpy.float64) + faces = loaded.faces.astype(numpy.int64) + + return LoadedMesh( + vertices=vertices, + faces=faces, + unit=unit, + source_path=resolved, + ) diff --git a/src/sm_tias_lightsim/geometry/transforms.py b/src/sm_tias_lightsim/geometry/transforms.py new file mode 100644 index 0000000..cf2846f --- /dev/null +++ b/src/sm_tias_lightsim/geometry/transforms.py @@ -0,0 +1,59 @@ +"""座標変換ユーティリティモジュール. + +単位変換(mm ↔ m)および LoadedMesh への適用を提供する. +座標系の回転・並進は Step 4(シーン構築)で実装する. +""" + +from __future__ import annotations + +import dataclasses +from typing import Literal + +import numpy + +from sm_tias_lightsim.geometry.loader import LoadedMesh + +_MM_PER_M = 1000.0 +_M_PER_MM = 0.001 + + +def mm_to_m(value: numpy.ndarray | float) -> numpy.ndarray | float: + """ミリメートル単位の数値配列をメートル単位に変換する. + + Args: + value: スカラまたは ndarray(任意 shape). + + Returns: + 入力と同じ shape の値.スカラなら float 1 つ,ndarray なら新しい配列. + """ + return value * _M_PER_MM + + +def scale_vertices(mesh: LoadedMesh, target_unit: Literal["mm", "m"]) -> LoadedMesh: + """LoadedMesh の頂点座標を指定単位にスケール変換した新インスタンスを返す. + + frozen dataclass の不変性を維持するため,常に新規インスタンスを返す. + 元と同じ単位が指定された場合も頂点配列はコピーされる(関数の純粋性のため). + + Args: + mesh: 元のメッシュ. + target_unit: 変換先の単位. + + Returns: + 頂点が target_unit にスケールされた新 LoadedMesh. + unit フィールドは target_unit に更新される. + + Raises: + ValueError: 未対応の unit の組み合わせが指定された場合. + """ + if mesh.unit == target_unit: + new_vertices = mesh.vertices.copy() + elif mesh.unit == "mm" and target_unit == "m": + new_vertices = mesh.vertices * _M_PER_MM + elif mesh.unit == "m" and target_unit == "mm": + new_vertices = mesh.vertices * _MM_PER_M + else: + msg = f"Unsupported unit conversion: '{mesh.unit}' -> '{target_unit}'" + raise ValueError(msg) + + return dataclasses.replace(mesh, vertices=new_vertices, unit=target_unit) diff --git a/tests/geometry/__init__.py b/tests/geometry/__init__.py new file mode 100644 index 0000000..d6bba1c --- /dev/null +++ b/tests/geometry/__init__.py @@ -0,0 +1 @@ +# geometry テストパッケージマーカ diff --git a/tests/geometry/conftest.py b/tests/geometry/conftest.py new file mode 100644 index 0000000..260fbc8 --- /dev/null +++ b/tests/geometry/conftest.py @@ -0,0 +1,53 @@ +"""geometry テスト共通フィクスチャ. + +テスト用 STL ファイルを tmp_path に生成して返す. +モックは使わず実ファイルで検証する(GUIDE_08 モック方針). +""" + +import pathlib + +import numpy +import pytest +import trimesh + + +@pytest.fixture +def tetrahedron_stl(tmp_path: pathlib.Path) -> pathlib.Path: + """四面体 STL を tmp_path に書き出してそのパスを返す. + + 頂点数 4, 面数 4 の最小三角形メッシュ. + """ + vertices = numpy.array( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.5, 1.0, 0.0], + [0.5, 0.5, 1.0], + ], + dtype=numpy.float64, + ) + faces = numpy.array( + [ + [0, 1, 2], + [0, 1, 3], + [0, 2, 3], + [1, 2, 3], + ], + dtype=numpy.int64, + ) + mesh = trimesh.Trimesh(vertices=vertices, faces=faces) + path = tmp_path / "shape.stl" + mesh.export(str(path)) + return path + + +@pytest.fixture +def cube_stl(tmp_path: pathlib.Path) -> pathlib.Path: + """立方体 STL を tmp_path に書き出してそのパスを返す. + + trimesh.creation.box() で生成: 頂点数 8, 面数 12. + """ + mesh = trimesh.creation.box() + path = tmp_path / "cube.stl" + mesh.export(str(path)) + return path diff --git a/tests/geometry/test_loader.py b/tests/geometry/test_loader.py new file mode 100644 index 0000000..fa46744 --- /dev/null +++ b/tests/geometry/test_loader.py @@ -0,0 +1,181 @@ +"""geometry/loader.py の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(geometry 層の責務) +- docs/04_SPEC/SPEC_02_シーン定義仕様.md(unit は mm/m のみ) +- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 3: 簡単な四面体 STL を読み頂点数が一致する) +""" + +import dataclasses +import pathlib + +import numpy +import pytest + +from sm_tias_lightsim.geometry import LoadedMesh, load_stl + +# --------------------------------------------------------------------------- +# 正常系: 返り値の型 +# --------------------------------------------------------------------------- + + +def test_load_stl_returns_loaded_mesh(tetrahedron_stl): + """load_stl が LoadedMesh インスタンスを返すこと. + + 仕様: 出力: 頂点・面配列 + 単位情報 (SPEC_01 geometry 層の責務) + """ + result = load_stl(tetrahedron_stl, "mm") + assert isinstance(result, LoadedMesh) + + +# --------------------------------------------------------------------------- +# 正常系: dtype +# --------------------------------------------------------------------------- + + +def test_load_stl_vertices_dtype_float64(tetrahedron_stl): + """vertices の dtype が float64 であること. + + 仕様: vertices: numpy.ndarray (N_v, 3) float64 (loader.py LoadedMesh docstring) + """ + result = load_stl(tetrahedron_stl, "mm") + assert result.vertices.dtype == numpy.float64 + + +def test_load_stl_faces_dtype_int64(tetrahedron_stl): + """faces の dtype が int64 であること. + + 仕様: faces: numpy.ndarray (N_f, 3) int64 (loader.py LoadedMesh docstring) + """ + result = load_stl(tetrahedron_stl, "mm") + assert result.faces.dtype == numpy.int64 + + +# --------------------------------------------------------------------------- +# 正常系: shape(PLAN_02 完了条件「頂点数が一致する」) +# --------------------------------------------------------------------------- + + +def test_load_stl_vertices_shape(tetrahedron_stl): + """四面体 STL の頂点配列 shape が (4, 3) であること. + + 仕様: 簡単な四面体 STL を読み頂点数が一致する (PLAN_02 Step 3 完了条件) + """ + result = load_stl(tetrahedron_stl, "mm") + assert result.vertices.shape == (4, 3) + + +def test_load_stl_faces_shape(tetrahedron_stl): + """四面体 STL の面配列 shape が (4, 3) であること. + + 仕様: 面配列 (N_f, 3) (loader.py LoadedMesh docstring) + """ + result = load_stl(tetrahedron_stl, "mm") + assert result.faces.shape == (4, 3) + + +def test_load_stl_cube_vertices_shape(cube_stl): + """立方体 STL の頂点配列 shape が (8, 3) であること. + + trimesh.creation.box() の既知頂点数(8)と面数(12)が一致する. + """ + result = load_stl(cube_stl, "mm") + assert result.vertices.shape == (8, 3) + + +def test_load_stl_cube_faces_shape(cube_stl): + """立方体 STL の面配列 shape が (12, 3) であること.""" + result = load_stl(cube_stl, "mm") + assert result.faces.shape == (12, 3) + + +# --------------------------------------------------------------------------- +# 正常系: unit フィールドの保持 +# --------------------------------------------------------------------------- + + +def test_load_stl_preserves_unit_mm(tetrahedron_stl): + """unit="mm" で読み込むと loaded.unit == "mm" であること. + + 仕様: unit は mm/m のみ (SPEC_02 geometry セクション) + """ + result = load_stl(tetrahedron_stl, "mm") + assert result.unit == "mm" + + +def test_load_stl_preserves_unit_m(tetrahedron_stl): + """unit="m" で読み込むと loaded.unit == "m" であること. + + 仕様: unit は mm/m のみ (SPEC_02 geometry セクション) + """ + result = load_stl(tetrahedron_stl, "m") + assert result.unit == "m" + + +# --------------------------------------------------------------------------- +# 正常系: source_path +# --------------------------------------------------------------------------- + + +def test_load_stl_source_path_is_resolved(tetrahedron_stl): + """source_path が Path 型で絶対パスであること. + + 仕様: source_path: pathlib.Path(デバッグ用)(loader.py LoadedMesh docstring) + """ + result = load_stl(tetrahedron_stl, "mm") + assert isinstance(result.source_path, pathlib.Path) + assert result.source_path.is_absolute() + + +# --------------------------------------------------------------------------- +# 正常系: パス型の受け入れ +# --------------------------------------------------------------------------- + + +def test_load_stl_accepts_str_path(tetrahedron_stl): + """str でパスを渡してもロードできること. + + 仕様: path: str | Path (loader.py load_stl シグネチャ) + """ + result = load_stl(str(tetrahedron_stl), "mm") + assert isinstance(result, LoadedMesh) + assert result.vertices.shape == (4, 3) + + +def test_load_stl_accepts_pathlib_path(tetrahedron_stl): + """pathlib.Path をそのまま渡してもロードできること. + + 仕様: path: str | Path (loader.py load_stl シグネチャ) + """ + result = load_stl(tetrahedron_stl, "mm") + assert isinstance(result, LoadedMesh) + + +# --------------------------------------------------------------------------- +# 異常系: ファイル不存在 +# --------------------------------------------------------------------------- + + +def test_load_stl_raises_file_not_found(tmp_path): + """存在しないパスを渡すと FileNotFoundError が発生すること. + + 仕様: FileNotFoundError: ファイルが存在しない場合 (loader.py load_stl docstring) + """ + missing = tmp_path / "no_such_file.stl" + with pytest.raises(FileNotFoundError): + load_stl(missing, "mm") + + +# --------------------------------------------------------------------------- +# 異常系: イミュータビリティ +# --------------------------------------------------------------------------- + + +def test_load_stl_loaded_mesh_is_frozen(tetrahedron_stl): + """パース後の LoadedMesh へのフィールド代入で FrozenInstanceError が発生すること. + + 仕様: @dataclass(frozen=True) (loader.py LoadedMesh クラス定義) + """ + result = load_stl(tetrahedron_stl, "mm") + with pytest.raises(dataclasses.FrozenInstanceError): + result.unit = "m" # type: ignore[misc] diff --git a/tests/geometry/test_transforms.py b/tests/geometry/test_transforms.py new file mode 100644 index 0000000..47c7b50 --- /dev/null +++ b/tests/geometry/test_transforms.py @@ -0,0 +1,261 @@ +"""geometry/transforms.py の仕様ベーステスト. + +仕様出典: +- docs/04_SPEC/SPEC_01_アーキテクチャ設計.md(geometry 層の責務) +- docs/03_PLAN/PLAN_02_開発ステップ.md(Step 3 完了条件: 単位変換が可逆) +- docs/01_GUIDE/GUIDE_08_テスト方針.md(Mandatory: データ変換「変換が可逆であること」) +""" + +import pathlib + +import numpy +import pytest + +from sm_tias_lightsim.geometry import LoadedMesh, mm_to_m, scale_vertices + +# --------------------------------------------------------------------------- +# テストヘルパー +# --------------------------------------------------------------------------- + + +def _make_mesh( + vertices: numpy.ndarray, + unit: str = "mm", +) -> LoadedMesh: + """テスト用 LoadedMesh を生成するヘルパー.faces はダミーの 1 面.""" + faces = numpy.array([[0, 1, 2]], dtype=numpy.int64) + return LoadedMesh( + vertices=vertices, + faces=faces, + unit=unit, # type: ignore[arg-type] + source_path=pathlib.Path("/dummy/mesh.stl"), + ) + + +# --------------------------------------------------------------------------- +# mm_to_m: スカラ +# --------------------------------------------------------------------------- + + +def test_mm_to_m_scalar_1000(): + """mm_to_m(1000.0) == 1.0 であること. + + 仕様: mm → m 変換(× 0.001)(transforms.py mm_to_m docstring) + """ + assert mm_to_m(1000.0) == pytest.approx(1.0) + + +def test_mm_to_m_scalar_2500_5(): + """mm_to_m(2500.5) == 2.5005 であること(小数点以下 4 桁).""" + assert mm_to_m(2500.5) == pytest.approx(2.5005) + + +def test_mm_to_m_zero(): + """mm_to_m(0) == 0 であること. + + 仕様: 0 ミリメートルは 0 メートル(恒等変換の特殊ケース) + """ + assert mm_to_m(0) == pytest.approx(0.0) + + +# --------------------------------------------------------------------------- +# mm_to_m: ndarray +# --------------------------------------------------------------------------- + + +def test_mm_to_m_ndarray(): + """mm_to_m(ndarray) が要素ごとに × 0.001 した配列を返すこと. + + 仕様: スカラ・ndarray 両対応 (transforms.py mm_to_m シグネチャ) + """ + input_arr = numpy.array([1000.0, 2000.0], dtype=numpy.float64) + result = mm_to_m(input_arr) + expected = numpy.array([1.0, 2.0], dtype=numpy.float64) + numpy.testing.assert_array_almost_equal(result, expected) + + +def test_mm_to_m_ndarray_shape_preserved(): + """mm_to_m が入力と同じ shape の配列を返すこと.""" + arr = numpy.ones((3, 3), dtype=numpy.float64) * 500.0 + result = mm_to_m(arr) + assert result.shape == (3, 3) + + +# --------------------------------------------------------------------------- +# scale_vertices: mm → m 変換 +# --------------------------------------------------------------------------- + + +def test_scale_vertices_mm_to_m(): + """mm → m 変換で頂点値が 1/1000 になること. + + 仕様: mm → m 変換(× 0.001)(transforms.py scale_vertices) + """ + vertices_mm = numpy.array( + [[1000.0, 2000.0, 500.0], [0.0, 3000.0, 1500.0], [500.0, 500.0, 0.0]], + dtype=numpy.float64, + ) + mesh = _make_mesh(vertices_mm, unit="mm") + scaled = scale_vertices(mesh, "m") + expected = vertices_mm * 0.001 + numpy.testing.assert_array_almost_equal(scaled.vertices, expected) + + +# --------------------------------------------------------------------------- +# scale_vertices: m → mm 変換 +# --------------------------------------------------------------------------- + + +def test_scale_vertices_m_to_mm(): + """m → mm 変換で頂点値が 1000 倍になること. + + 仕様: m → mm 変換(× 1000)(transforms.py scale_vertices) + """ + vertices_m = numpy.array( + [[1.0, 2.0, 0.5], [0.0, 3.0, 1.5], [0.5, 0.5, 0.0]], + dtype=numpy.float64, + ) + mesh = _make_mesh(vertices_m, unit="m") + scaled = scale_vertices(mesh, "mm") + expected = vertices_m * 1000.0 + numpy.testing.assert_array_almost_equal(scaled.vertices, expected) + + +# --------------------------------------------------------------------------- +# scale_vertices: 同一単位 +# --------------------------------------------------------------------------- + + +def test_scale_vertices_same_unit_values_unchanged(): + """mm → mm では頂点値が変わらないこと.""" + vertices = numpy.array([[100.0, 200.0, 300.0]], dtype=numpy.float64) + mesh = _make_mesh(vertices, unit="mm") + scaled = scale_vertices(mesh, "mm") + numpy.testing.assert_array_equal(scaled.vertices, vertices) + + +def test_scale_vertices_same_unit_returns_copy(): + """mm → mm では値は同じだが配列オブジェクトは別であること. + + 仕様: 元と同じ単位が指定された場合も頂点配列はコピーされる(関数の純粋性) + (transforms.py scale_vertices docstring) + """ + vertices = numpy.array([[100.0, 200.0, 300.0]], dtype=numpy.float64) + mesh = _make_mesh(vertices, unit="mm") + scaled = scale_vertices(mesh, "mm") + assert scaled.vertices is not mesh.vertices + + +# --------------------------------------------------------------------------- +# scale_vertices: unit フィールドの更新 +# --------------------------------------------------------------------------- + + +def test_scale_vertices_unit_field_updated_mm_to_m(): + """mm → m 変換後の unit が "m" になること. + + 仕様: unit フィールドは target_unit に更新される (transforms.py scale_vertices docstring) + """ + vertices = numpy.array([[1000.0, 0.0, 0.0]], dtype=numpy.float64) + mesh = _make_mesh(vertices, unit="mm") + scaled = scale_vertices(mesh, "m") + assert scaled.unit == "m" + + +def test_scale_vertices_unit_field_updated_m_to_mm(): + """m → mm 変換後の unit が "mm" になること.""" + vertices = numpy.array([[1.0, 0.0, 0.0]], dtype=numpy.float64) + mesh = _make_mesh(vertices, unit="m") + scaled = scale_vertices(mesh, "mm") + assert scaled.unit == "mm" + + +# --------------------------------------------------------------------------- +# scale_vertices: 可逆性(PLAN_02 完了条件「単位変換が可逆」) +# --------------------------------------------------------------------------- + + +def test_scale_vertices_roundtrip_is_identity(): + """mm → m → mm で元の頂点とほぼ一致すること(単位変換の可逆性). + + 仕様: 単位変換が可逆 (PLAN_02_開発ステップ.md Step 3 完了条件) + GUIDE_08 Mandatory: 「データ変換: 変換が可逆であること」 + """ + vertices_mm = numpy.array( + [ + [0.0, 0.0, 0.0], + [1000.0, 0.0, 0.0], + [500.0, 1000.0, 0.0], + [500.0, 500.0, 1000.0], + ], + dtype=numpy.float64, + ) + original = _make_mesh(vertices_mm, unit="mm") + converted = scale_vertices(original, "m") + round_tripped = scale_vertices(converted, "mm") + assert numpy.allclose(round_tripped.vertices, original.vertices) + + +# --------------------------------------------------------------------------- +# scale_vertices: インスタンスの独立性 +# --------------------------------------------------------------------------- + + +def test_scale_vertices_returns_new_instance(): + """scale_vertices の戻り値が元と異なるインスタンスであること. + + 仕様: frozen dataclass の不変性を維持するため,常に新規インスタンスを返す + (transforms.py scale_vertices docstring) + """ + vertices = numpy.array([[1000.0, 2000.0, 0.0]], dtype=numpy.float64) + mesh = _make_mesh(vertices, unit="mm") + scaled = scale_vertices(mesh, "m") + assert scaled is not mesh + + +# --------------------------------------------------------------------------- +# scale_vertices: faces の不変性 +# --------------------------------------------------------------------------- + + +def test_scale_vertices_faces_preserved(): + """単位変換で faces 配列の値が変わらないこと. + + 仕様: 頂点座標のみスケール変換する(面インデックスは変換対象外) + (SPEC_01 geometry 層の責務) + """ + faces = numpy.array([[0, 1, 2], [1, 2, 3]], dtype=numpy.int64) + vertices = numpy.array( + [ + [1000.0, 0.0, 0.0], + [0.0, 1000.0, 0.0], + [0.0, 0.0, 1000.0], + [1000.0, 1000.0, 0.0], + ], + dtype=numpy.float64, + ) + mesh = LoadedMesh( + vertices=vertices, + faces=faces, + unit="mm", + source_path=pathlib.Path("/dummy/mesh.stl"), + ) + scaled = scale_vertices(mesh, "m") + numpy.testing.assert_array_equal(scaled.faces, faces) + + +# --------------------------------------------------------------------------- +# scale_vertices: 元メッシュの faces 参照が同一かコピーかの確認 +# --------------------------------------------------------------------------- + + +def test_scale_vertices_original_vertices_unchanged(): + """scale_vertices 後に元メッシュの頂点が変わっていないこと(純粋関数性). + + 仕様: 元の LoadedMesh は変更しない(frozen=True かつ純粋関数) + """ + vertices = numpy.array([[1000.0, 2000.0, 3000.0]], dtype=numpy.float64) + original_copy = vertices.copy() + mesh = _make_mesh(vertices, unit="mm") + scale_vertices(mesh, "m") + numpy.testing.assert_array_equal(mesh.vertices, original_copy)