378 lines
12 KiB
Python
378 lines
12 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 _two_connected():
|
|
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")
|
|
return s
|
|
|
|
|
|
def test_connect_records_and_groups():
|
|
s = _two_connected()
|
|
assert len(s.connections) == 1
|
|
groups = [g for g in s.groups() if len(g) > 1]
|
|
assert groups and set(groups[0]) == {"p1", "p2"}
|
|
|
|
|
|
def test_explode_then_assemble_roundtrip():
|
|
s = _two_connected()
|
|
seated = list(s.get_part("p2").position_in)
|
|
s.explode(5)
|
|
assert s.get_part("p2").position_in != seated
|
|
assert s.connections[0].backed_off_in == 5
|
|
s.assemble()
|
|
assert s.get_part("p2").position_in == pytest.approx(seated)
|
|
assert s.connections[0].backed_off_in == 0
|
|
|
|
|
|
def test_disconnect_keeps_position_and_ungroups():
|
|
s = _two_connected()
|
|
pos = list(s.get_part("p2").position_in)
|
|
s.disconnect(cid="c1")
|
|
assert s.connections == []
|
|
assert s.get_part("p2").position_in == pos # pieces stay put
|
|
assert all(len(g) == 1 for g in s.groups()) # no longer one assembly
|
|
|
|
|
|
def test_delete_drops_connections():
|
|
s = _two_connected()
|
|
s.delete("p2")
|
|
assert s.connections == []
|
|
|
|
|
|
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"
|