"""Tests for the scene model and operations (no heavy 3D deps required).""" import math import pytest from woodshop.lumber import actual_section, normalize_stock from woodshop.scene import Scene, SceneError from woodshop.units import to_inches # ----- lumber ---------------------------------------------------------- def test_nominal_to_actual(): assert actual_section("2x4") == (1.5, 3.5) assert actual_section("4x4") == (3.5, 3.5) @pytest.mark.parametrize("raw,expected", [("2 x 4", "2x4"), ("2X4", "2x4"), ("2by4", "2x4")]) def test_normalize_stock(raw, expected): assert normalize_stock(raw) == expected def test_unknown_stock_lists_options(): with pytest.raises(KeyError, match="Known stock"): actual_section("9x9") # ----- units ----------------------------------------------------------- @pytest.mark.parametrize("value,unit,inches", [ ("6 ft", "inch", 72), ("6 foot", "inch", 72), ("10 inches", "inch", 10), ("3 ft 6 in", "inch", 42), ("2'", "inch", 24), ("72", "inch", 72), ("6", "foot", 72), (6, "foot", 72), ]) def test_to_inches(value, unit, inches): assert to_inches(value, default_unit=unit) == inches def test_to_inches_bad(): with pytest.raises(ValueError): to_inches("a bunch") # ----- operations ------------------------------------------------------ def test_place_sets_section_and_selection(): s = Scene() p = s.place("2x4", 72) assert p.id == "p1" assert p.section_in == (1.5, 3.5) assert s.selection == "p1" def test_the_example_sentence(): """'place a 6 foot 2x4, sand it, attach a 2 foot 2x4 at 90 deg, 10 in from end.'""" s = Scene() s.place("2x4", to_inches("6 ft")) # p1 s.finish("it") # sand the selection s.place("2x4", to_inches("2 ft")) # p2 (now selected) s.join("p1", "p2", angle_deg=90, offset_in=10, anchor="end_b") p1, p2 = s.get_part("p1"), s.get_part("p2") assert "sanded" in p1.finishes # attach point is 10in back from p1's far end (72 - 10 = 62 along +X) assert p2.position_in[0] == pytest.approx(62.0) # butt joint: p2's end sits flush on p1's side face (a 2x4 is 3.5" wide -> # 1.75" from centerline), in the same horizontal plane (z = 0). assert p2.position_in[1] == pytest.approx(1.75) assert p2.position_in[2] == pytest.approx(0.0, abs=1e-9) assert p2.yaw_deg == pytest.approx(90.0) # p2 now runs along +Y ux, uy, uz = p2.axis_unit() assert ux == pytest.approx(0.0, abs=1e-9) assert uy == pytest.approx(1.0) assert uz == pytest.approx(0.0, abs=1e-9) assert len(s.joints) == 1 def test_stand_makes_board_vertical(): s = Scene() s.place("2x4", 30) s.stand("it") p = s.get_part("p1") assert p.is_vertical ux, uy, uz = p.axis_unit() assert uz == pytest.approx(1.0) # length axis points straight up assert (ux, uy) == pytest.approx((0.0, 0.0), abs=1e-9) assert p.end_point()[2] == pytest.approx(30.0) # top is 30in up def test_butt_joint_meets_surface_not_centerline(): """B's end should sit on A's face, with no interpenetration past A's centerline.""" s = Scene() s.place("2x4", 48) # p1 along +X, flat s.place("2x4", 12) # p2 s.join("p1", "p2", angle_deg=90, offset_in=24, anchor="end_a") p2 = s.get_part("p2") # p2 runs along +Y starting at p1's +Y face (1.75 from centerline), not at # p1's centerline (which would be y=0). assert p2.position_in[1] == pytest.approx(1.75) # its far end is 1.75 + 12 out, fully clear of p1's body. assert p2.end_point()[1] == pytest.approx(13.75) def test_flush_aligns_tops_for_different_thicknesses(): """A thinner board joined to a thicker one should sit with TOPS level, not centers level (flush-by-default).""" s = Scene() s.place("2x4", 48) # p1: thickness 1.5 -> top face at z=0.75 s.place("1x8", 12) # p2: thickness 0.75 s.join("p1", "p2", angle_deg=90, offset_in=24, anchor="end_a") p2 = s.get_part("p2") # p2's top (z + 0.375) is flush with p1's top (0.75) -> p2 center at 0.375, # NOT centered on p1 (which would leave p2 at z=0). assert p2.position_in[2] == pytest.approx(0.375) def test_join_preserves_vertical_tilt(): """A stood-up leg stays vertical when attached to a horizontal apron.""" s = Scene() s.place("2x4", 48) # p1 apron s.place("2x4", 29) # p2 leg s.stand("p2") s.join("p1", "p2", angle_deg=0, offset_in=0, anchor="end_a") leg = s.get_part("p2") assert leg.is_vertical # base sits on the apron's top face (z = t_a/2 = 0.75) since the leg is vertical assert leg.position_in[2] == pytest.approx(0.75) def test_move_relative_and_absolute(): s = Scene() s.place("2x4", 24) s.move("it", dx=5, dy=2, dz=1) assert s.get_part("p1").position_in == [5.0, 2.0, 1.0] s.move("it", dx=10, dy=0, dz=0, absolute=True) assert s.get_part("p1").position_in == [10.0, 0.0, 0.0] def test_copy_and_set_length_and_rename(): s = Scene() s.place("2x4", 24) s.rename("p1", "front rail") assert s.get_part("front rail").id == "p1" # resolvable by alias clone = s.copy("p1", dy=10) assert clone.id == "p2" assert clone.position_in[1] == 10.0 s.set_length("p2", 36) assert s.get_part("p2").length_in == 36.0 def test_select_by_id_and_name(): s = Scene() s.place("2x4", 24) s.place("2x4", 24) s.rename("p1", "front rail") assert s.select("front rail").id == "p1" assert s.selection == "p1" assert s.select("p2").id == "p2" def test_redo_after_undo(): s = Scene() s.place("2x4", 24) s.place("2x4", 36) assert len(s.parts) == 2 s.undo() assert len(s.parts) == 1 s.redo() assert len(s.parts) == 2 assert s.get_part("p2").length_in == 36 def test_new_action_clears_redo(): s = Scene() s.place("2x4", 24) s.place("2x4", 36) s.undo() # redo now has the p2 placement s.place("2x6", 12) # a new action should invalidate redo import pytest as _pt with _pt.raises(SceneError, match="Nothing to redo"): s.redo() def test_batch_is_one_undo(): s = Scene() s.place("2x4", 24) s.place("2x4", 24) with s.batch(): s.move("p1", dx=5) s.move("p2", dx=5) assert s.get_part("p1").position_in[0] == 5 s.undo() # single undo reverts both moves assert s.get_part("p1").position_in[0] == 0 assert s.get_part("p2").position_in[0] == 0 def test_clear(): s = Scene() s.place("2x4", 24) s.place("2x4", 24) s.clear() assert s.parts == [] and s.selection is None def test_migrate_old_rotation_field(tmp_path): """Scenes saved with the old rotation_deg field still load.""" import json old = {"version": 1, "parts": [{"id": "p1", "stock": "2x4", "length_in": 24, "section_in": [1.5, 3.5], "position_in": [0, 0, 0], "rotation_deg": 45}]} path = tmp_path / "old.json" path.write_text(json.dumps(old)) s = Scene.load(path) assert s.get_part("p1").yaw_deg == 45 def test_slugify(): from woodshop.scene import slugify assert slugify("Coffee Table!") == "coffee-table" assert slugify(" My Bench ") == "my-bench" def test_project_save_open_list(tmp_path, monkeypatch): import woodshop.scene as scene_mod monkeypatch.setattr(scene_mod, "_data_dir", lambda: tmp_path) s = Scene() s.place("2x4", 48) s.place("2x4", 24) s.save(scene_mod.project_path("coffee table")) assert scene_mod.list_projects() == ["coffee-table"] reopened = Scene.load(scene_mod.project_path("Coffee Table")) # name normalizes assert len(reopened.parts) == 2 def test_resolve_it_without_selection_errors(): s = Scene() with pytest.raises(SceneError, match="selected"): s.finish("it") def test_undo_restores_previous_state(): s = Scene() s.place("2x4", 72) s.place("2x4", 24) assert len(s.parts) == 2 s.undo() assert len(s.parts) == 1 assert s.selection == "p1" def test_delete_reassigns_selection_and_drops_joints(): s = Scene() s.place("2x4", 72) s.place("2x4", 24) s.join("p1", "p2") s.delete("p2") assert [p.id for p in s.parts] == ["p1"] assert s.joints == [] assert s.selection == "p1" def test_roundtrip_serialization(tmp_path): s = Scene() s.place("2x4", 72) s.place("2x4", 24) s.join("p1", "p2", angle_deg=90, offset_in=10) path = s.save(tmp_path / "scene.json") loaded = Scene.load(path) assert [p.id for p in loaded.parts] == ["p1", "p2"] assert loaded.parts[0].section_in == (1.5, 3.5) assert loaded.joints[0].angle_deg == 90 assert loaded.selection == "p2"