woodshop/tests/test_scene.py

419 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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) # BC 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"