419 lines
14 KiB
Python
419 lines
14 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_connecting_drags_connected_subassembly():
|
||
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) # f1 (A)
|
||
s.place("2x4", 12)
|
||
s.add_feature("p2", "tenon", face="end_b", width_in=1.5, height_in=1, depth_in=1) # f2 (B end)
|
||
s.add_feature("p2", "mortise", face="top", along_in=6, width_in=1.5, height_in=1, depth_in=1) # f3 (B top)
|
||
s.place("2x4", 8)
|
||
s.add_feature("p3", "tenon", face="end_b", width_in=1.5, height_in=1, depth_in=1) # f4 (C)
|
||
s.connect("f3", "f4") # C seats into B; B stays, C moves
|
||
|
||
def dist(a, b):
|
||
return math.dist(s.get_part(a).position_in, s.get_part(b).position_in)
|
||
|
||
d_bc = dist("p2", "p3")
|
||
c_before = list(s.get_part("p3").position_in)
|
||
s.connect("f1", "f2") # B seats into A — C should ride along
|
||
assert dist("p2", "p3") == pytest.approx(d_bc, abs=1e-6) # B–C kept rigid
|
||
assert s.get_part("p3").position_in != c_before # C actually moved
|
||
|
||
|
||
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_bbox_axis_aligned():
|
||
s = Scene()
|
||
p = s.place("2x4", 24) # 1.5 x 3.5 section
|
||
lo, hi = p.bbox()
|
||
assert lo == pytest.approx((0, -1.75, -0.75))
|
||
assert hi == pytest.approx((24, 1.75, 0.75))
|
||
|
||
|
||
def test_spatial_summary_flags_overlap():
|
||
from woodshop.scene import spatial_summary
|
||
s = Scene()
|
||
s.place("2x4", 24) # p1 at origin
|
||
s.place("2x4", 24) # p2 at origin -> overlaps p1
|
||
summ = spatial_summary(s)
|
||
assert "p1" in summ and "p2" in summ
|
||
assert "p1&p2" in summ # interpenetration flagged
|
||
s.move("p2", dy=10) # slide clear
|
||
assert "p1&p2" not in spatial_summary(s)
|
||
|
||
|
||
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"
|