woodshop/tests/test_scene.py

335 lines
11 KiB
Python

"""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_add_edit_delete_feature():
s = Scene()
s.place("2x4", 12)
f = s.add_feature("p1", "mortise", face="top", width_in=1, height_in=1, depth_in=0.5)
assert f.id == "f1" and f.is_cut
assert s.get_part("p1").features[0].kind == "mortise"
s.edit_feature("f1", depth_in=0.75)
assert s.find_feature("f1")[1].depth_in == 0.75
s.delete_feature("f1")
assert s.get_part("p1").features == []
def test_tenon_is_additive():
s = Scene()
s.place("2x4", 12)
assert not s.add_feature("p1", "tenon", face="end_b", depth_in=1).is_cut
def test_unknown_feature_kind_errors():
s = Scene()
s.place("2x4", 12)
with pytest.raises(SceneError, match="Unknown feature"):
s.add_feature("p1", "dovetailzzz")
def test_feature_roundtrip(tmp_path):
s = Scene()
s.place("2x4", 12)
s.add_feature("p1", "hole", face="top", along_in=3, diameter_in=0.5)
loaded = Scene.load(s.save(tmp_path / "s.json"))
feat = loaded.get_part("p1").features[0]
assert feat.kind == "hole" and feat.diameter_in == 0.5
@pytest.mark.parametrize("ypr", [(30, 0, 0), (0, 40, 0), (0, 0, 55), (35, 20, -15), (120, 30, -45)])
def test_matrix_to_ypr_roundtrip(ypr):
from woodshop.scene import matrix_to_ypr
s = Scene()
p = s.place("2x4", 12)
p.yaw_deg, p.tilt_deg, p.roll_deg = ypr
assert matrix_to_ypr(p.rotation_matrix()) == pytest.approx(ypr, abs=1e-6)
def test_connect_seats_tenon_in_mortise():
s = Scene()
s.place("2x4", 24)
s.add_feature("p1", "mortise", face="top", along_in=12, width_in=1.5, height_in=1, depth_in=1)
s.place("2x4", 12)
s.add_feature("p2", "tenon", face="end_b", width_in=1.5, height_in=1, depth_in=1)
s.connect("f1", "f2") # move p2 so its tenon seats into p1's mortise
pa, na, _, _ = s.get_part("p1").feature_world_frame(s.find_feature("f1")[1])
pb, nb, _, _ = s.get_part("p2").feature_world_frame(s.find_feature("f2")[1])
assert pb == pytest.approx(pa, abs=1e-6) # faces meet
assert nb == pytest.approx(tuple(-x for x in na), abs=1e-6) # tenon points into mortise
def test_connect_needs_two_boards():
s = Scene()
s.place("2x4", 24)
s.add_feature("p1", "tenon", face="end_a")
s.add_feature("p1", "mortise", face="top")
with pytest.raises(SceneError, match="two different boards"):
s.connect("f1", "f2")
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"