Skip to content

Commit 9942168

Browse files
vipenzoclaude
andcommitted
Add comprehensive tests for shell and woven-shell shape-fns
24 tests covering shell basics, mesh generation, ring displacement, built-in patterns (lattice, checkerboard, voronoi), woven-shell modes (diagonal, orthogonal, custom fn), and composition with tapered/twisted. Uses sfn->transform adapter to properly bridge shape-fn 1-arity API to loft-from-path's 2-arity transform-fn expectation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 008eb87 commit 9942168

File tree

1 file changed

+318
-0
lines changed

1 file changed

+318
-0
lines changed

test/ridley/turtle/shell_test.cljs

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
(ns ridley.turtle.shell-test
2+
"Tests for shell and woven-shell shape-fns."
3+
(:require [cljs.test :refer [deftest testing is]]
4+
[ridley.turtle.core :as t]
5+
[ridley.turtle.loft :as loft]
6+
[ridley.turtle.shape :as shape]
7+
[ridley.turtle.shape-fn :as sfn]
8+
[ridley.turtle.extrusion :as extrusion]
9+
[ridley.test-helpers :as h]))
10+
11+
;; ═══════════════════════════════════════════════════════════
12+
;; Shell shape-fn basics
13+
;; ═══════════════════════════════════════════════════════════
14+
15+
(deftest shell-returns-shape-fn
16+
(testing "shell returns a shape-fn (function with :type :shape-fn metadata)"
17+
(let [s (sfn/shell (shape/circle-shape 20 16)
18+
:thickness 3
19+
:fn (fn [a t] 1.0))]
20+
(is (fn? s) "shell returns a function")
21+
(is (= :shape-fn (:type (meta s))) "has :shape-fn metadata"))))
22+
23+
(deftest shell-attaches-metadata
24+
(testing "shell shape-fn attaches :shell-mode and :shell-values to shape"
25+
(let [s (sfn/shell (shape/circle-shape 20 16)
26+
:thickness 3
27+
:fn (fn [a t] 1.0))
28+
result (s 0.5)]
29+
(is (true? (:shell-mode result)) "shape has :shell-mode")
30+
(is (= 3 (:shell-thickness result)) "shell-thickness preserved")
31+
(is (= 16 (count (:shell-values result))) "one value per vertex")
32+
(is (every? #(= 1.0 %) (:shell-values result)) "all values = 1.0 for constant fn"))))
33+
34+
(deftest shell-threshold-snaps-to-zero
35+
(testing "Values below threshold snap to 0"
36+
(let [s (sfn/shell (shape/circle-shape 20 16)
37+
:thickness 2
38+
:fn (fn [a t] 0.03)) ;; below default threshold 0.05
39+
result (s 0.5)]
40+
(is (every? zero? (:shell-values result)) "below-threshold values snap to 0"))))
41+
42+
(deftest shell-values-clamped
43+
(testing "Values are clamped to [0, 1]"
44+
(let [s (sfn/shell (shape/circle-shape 20 8)
45+
:thickness 2
46+
:fn (fn [a t] 5.0)) ;; above 1.0
47+
result (s 0.5)]
48+
(is (every? #(= 1.0 %) (:shell-values result)) "values clamped to 1.0"))))
49+
50+
;; ═══════════════════════════════════════════════════════════
51+
;; Shell mesh generation
52+
;; ═══════════════════════════════════════════════════════════
53+
54+
(defn- sfn->transform
55+
"Adapt a shape-fn (1-arity) to loft's transform-fn (2-arity).
56+
Matches what pure-loft-shape-fn does in operations.cljs."
57+
[sfn]
58+
(fn [_shape t] (sfn t)))
59+
60+
(defn- make-shell-mesh
61+
"Helper: create a shell mesh via loft-from-path."
62+
[n-pts steps thickness shell-fn]
63+
(let [circ (shape/circle-shape 20 n-pts)
64+
sfn (sfn/shell circ :thickness thickness :fn shell-fn)
65+
path (t/make-path [{:cmd :f :args [40]}])
66+
turtle (loft/loft-from-path (t/make-turtle) circ (sfn->transform sfn) path steps)
67+
mesh (last (:meshes turtle))]
68+
mesh))
69+
70+
(deftest shell-uniform-creates-mesh
71+
(testing "Uniform shell (constant 1.0) creates a valid mesh"
72+
(let [mesh (make-shell-mesh 16 16 3 (fn [a t] 1.0))]
73+
(is (some? mesh) "mesh was created")
74+
(is (= :shell (:primitive mesh)) "primitive type is :shell")
75+
(is (pos? (count (:vertices mesh))) "has vertices")
76+
(is (pos? (count (:faces mesh))) "has faces"))))
77+
78+
(deftest shell-uniform-vertex-count
79+
(testing "Uniform shell has 2 rings (outer + inner) per step"
80+
(let [n-pts 16
81+
steps 16
82+
mesh (make-shell-mesh n-pts steps 3 (fn [a t] 1.0))]
83+
;; Each step has n-pts outer + n-pts inner = 2*n-pts
84+
;; Total steps = steps + 1 (including start)
85+
;; But loft generates (steps) rings typically
86+
(is (zero? (mod (count (:vertices mesh)) (* 2 n-pts)))
87+
"vertex count should be multiple of 2*n-pts"))))
88+
89+
(deftest shell-uniform-has-double-faces
90+
(testing "Shell has both outer and inner faces (roughly double normal loft)"
91+
(let [mesh (make-shell-mesh 12 12 3 (fn [a t] 1.0))]
92+
;; Each quad between rings produces 2 outer + 2 inner triangles = 4 triangles
93+
;; Plus cap faces. Should have significantly more faces than a normal loft.
94+
(is (> (count (:faces mesh)) 200) "shell should have many faces (outer + inner + caps)"))))
95+
96+
(deftest shell-bounding-box
97+
(testing "Shell bounding box reflects thickness expansion"
98+
(let [mesh-thin (make-shell-mesh 16 16 1 (fn [a t] 1.0))
99+
mesh-thick (make-shell-mesh 16 16 6 (fn [a t] 1.0))
100+
bbox-thin (h/mesh-bounding-box mesh-thin)
101+
bbox-thick (h/mesh-bounding-box mesh-thick)]
102+
;; Thicker shell should have larger Y/Z extent (radial)
103+
(is (> (second (:size bbox-thick)) (second (:size bbox-thin)))
104+
"thicker shell has larger Y extent")
105+
(is (> (nth (:size bbox-thick) 2) (nth (:size bbox-thin) 2))
106+
"thicker shell has larger Z extent"))))
107+
108+
(deftest shell-with-openings
109+
(testing "Shell with partial openings creates valid mesh"
110+
(let [mesh (make-shell-mesh 16 16 3
111+
(fn [a t] (if (pos? (Math/sin (* a 4))) 1.0 0.0)))]
112+
(is (some? mesh) "mesh with openings was created")
113+
(is (pos? (count (:faces mesh))) "has faces"))))
114+
115+
(deftest shell-fully-open
116+
(testing "Shell with all zeros still creates a mesh (degenerate but valid)"
117+
(let [mesh (make-shell-mesh 16 16 3 (fn [a t] 0.0))]
118+
;; All values are 0 → all faces skipped, but caps may still exist
119+
;; The mesh may be nil or empty - either is acceptable
120+
(is (or (nil? mesh) (>= (count (:faces mesh)) 0))
121+
"fully open shell is nil or has no faces"))))
122+
123+
;; ═══════════════════════════════════════════════════════════
124+
;; Shell ring generation
125+
;; ═══════════════════════════════════════════════════════════
126+
127+
(deftest generate-shell-ring-symmetric
128+
(testing "Outer and inner rings are symmetric around base ring"
129+
(let [base-ring [[0 10 0] [10 0 0] [0 -10 0] [-10 0 0]]
130+
values [1.0 1.0 1.0 1.0]
131+
half-t 2.0
132+
outer (extrusion/generate-shell-ring base-ring half-t values false)
133+
inner (extrusion/generate-shell-ring base-ring half-t values true)]
134+
;; Outer should be farther from centroid, inner closer
135+
(let [centroid [0 0 0]
136+
outer-dist (Math/sqrt (+ (* (first (first outer)) (first (first outer)))
137+
(* (second (first outer)) (second (first outer)))))
138+
inner-dist (Math/sqrt (+ (* (first (first inner)) (first (first inner)))
139+
(* (second (first inner)) (second (first inner)))))
140+
base-dist 10.0]
141+
(is (> outer-dist base-dist) "outer is farther from centroid than base")
142+
(is (< inner-dist base-dist) "inner is closer to centroid than base")
143+
(is (h/approx= (- outer-dist base-dist) (- base-dist inner-dist) 0.01)
144+
"displacement is symmetric")))))
145+
146+
(deftest generate-shell-ring-zero-value
147+
(testing "Zero-value points stay at base position"
148+
(let [base-ring [[0 10 0] [10 0 0] [0 -10 0] [-10 0 0]]
149+
values [0.0 1.0 0.0 1.0]
150+
outer (extrusion/generate-shell-ring base-ring 2.0 values false)
151+
inner (extrusion/generate-shell-ring base-ring 2.0 values true)]
152+
;; Points with value=0 should be at base position
153+
(is (h/vec-approx= (first outer) [0 10 0] 0.001)
154+
"zero-value outer point stays at base")
155+
(is (h/vec-approx= (nth outer 2) [0 -10 0] 0.001)
156+
"zero-value outer point stays at base")
157+
(is (h/vec-approx= (first inner) [0 10 0] 0.001)
158+
"zero-value inner point stays at base"))))
159+
160+
(deftest generate-shell-ring-with-offset
161+
(testing "Offset shifts the wall center radially"
162+
(let [base-ring [[0 10 0] [10 0 0] [0 -10 0] [-10 0 0]]
163+
values [1.0 1.0 1.0 1.0]
164+
offsets [2.0 2.0 2.0 2.0]
165+
;; Without offset
166+
outer-no-off (extrusion/generate-shell-ring base-ring 1.0 values false)
167+
inner-no-off (extrusion/generate-shell-ring base-ring 1.0 values true)
168+
;; With offset (shifted outward)
169+
outer-off (extrusion/generate-shell-ring base-ring 1.0 values false
170+
:offsets offsets)
171+
inner-off (extrusion/generate-shell-ring base-ring 1.0 values true
172+
:offsets offsets)]
173+
;; With positive offset, both outer and inner should be farther out
174+
(let [dist (fn [p] (Math/sqrt (+ (* (first p) (first p))
175+
(* (second p) (second p)))))
176+
outer-d (dist (first outer-off))
177+
outer-no-d (dist (first outer-no-off))
178+
inner-d (dist (first inner-off))
179+
inner-no-d (dist (first inner-no-off))]
180+
(is (> outer-d outer-no-d)
181+
"offset moves outer ring further out")
182+
(is (> inner-d inner-no-d)
183+
"offset moves inner ring further out")))))
184+
185+
;; ═══════════════════════════════════════════════════════════
186+
;; Built-in shell patterns
187+
;; ═══════════════════════════════════════════════════════════
188+
189+
(deftest shell-lattice-creates-mesh
190+
(testing "shell-lattice convenience creates valid shell mesh"
191+
(let [circ (shape/circle-shape 20 16)
192+
sfn (sfn/shell-lattice circ :thickness 2 :openings 8 :rows 12)
193+
path (t/make-path [{:cmd :f :args [40]}])
194+
turtle (loft/loft-from-path (t/make-turtle) circ (sfn->transform sfn) path 32)
195+
mesh (last (:meshes turtle))]
196+
(is (some? mesh) "shell-lattice creates mesh")
197+
(is (= :shell (:primitive mesh))))))
198+
199+
(deftest shell-checkerboard-creates-mesh
200+
(testing "shell-checkerboard creates valid mesh"
201+
(let [circ (shape/circle-shape 20 16)
202+
sfn (sfn/shell-checkerboard circ :thickness 2 :cols 6 :rows 6)
203+
path (t/make-path [{:cmd :f :args [40]}])
204+
turtle (loft/loft-from-path (t/make-turtle) circ (sfn->transform sfn) path 32)
205+
mesh (last (:meshes turtle))]
206+
(is (some? mesh) "shell-checkerboard creates mesh")
207+
(is (= :shell (:primitive mesh))))))
208+
209+
(deftest shell-voronoi-creates-mesh
210+
(testing "shell-voronoi creates valid mesh"
211+
(let [circ (shape/circle-shape 20 16)
212+
sfn (sfn/shell-voronoi circ :thickness 2 :cells 6 :rows 6)
213+
path (t/make-path [{:cmd :f :args [40]}])
214+
turtle (loft/loft-from-path (t/make-turtle) circ (sfn->transform sfn) path 32)
215+
mesh (last (:meshes turtle))]
216+
(is (some? mesh) "shell-voronoi creates mesh")
217+
(is (= :shell (:primitive mesh))))))
218+
219+
;; ═══════════════════════════════════════════════════════════
220+
;; Woven shell
221+
;; ═══════════════════════════════════════════════════════════
222+
223+
(deftest woven-shell-returns-shape-fn
224+
(testing "woven-shell returns a shape-fn"
225+
(let [s (sfn/woven-shell (shape/circle-shape 20 16) :thickness 3 :strands 6)]
226+
(is (fn? s))
227+
(is (= :shape-fn (:type (meta s)))))))
228+
229+
(deftest woven-shell-attaches-offsets
230+
(testing "woven-shell attaches :shell-offsets to shape"
231+
;; Need enough points (64) so some actually land on threads (width=0.12)
232+
(let [s (sfn/woven-shell (shape/circle-shape 20 64) :thickness 3 :strands 6)
233+
result (s 0.5)]
234+
(is (true? (:shell-mode result)))
235+
(is (some? (:shell-offsets result)) "has :shell-offsets")
236+
(is (= 64 (count (:shell-offsets result))) "one offset per vertex")
237+
(is (not (every? zero? (:shell-offsets result)))
238+
"not all offsets are zero (threads should undulate)"))))
239+
240+
(deftest woven-shell-diagonal-creates-mesh
241+
(testing "woven-shell diagonal mode creates valid mesh"
242+
(let [circ (shape/circle-shape 20 32)
243+
sfn (sfn/woven-shell circ :thickness 3 :strands 6)
244+
path (t/make-path [{:cmd :f :args [40]}])
245+
turtle (loft/loft-from-path (t/make-turtle) circ (sfn->transform sfn) path 32)
246+
mesh (last (:meshes turtle))]
247+
(is (some? mesh) "diagonal woven-shell creates mesh")
248+
(is (= :shell (:primitive mesh))))))
249+
250+
(deftest woven-shell-orthogonal-creates-mesh
251+
(testing "woven-shell orthogonal mode creates valid mesh"
252+
(let [circ (shape/circle-shape 20 32)
253+
sfn (sfn/woven-shell circ :thickness 3
254+
:mode :orthogonal
255+
:warp 6 :weft 12
256+
:warp-width 0.2 :weft-width 0.1)
257+
path (t/make-path [{:cmd :f :args [40]}])
258+
turtle (loft/loft-from-path (t/make-turtle) circ (sfn->transform sfn) path 32)
259+
mesh (last (:meshes turtle))]
260+
(is (some? mesh) "orthogonal woven-shell creates mesh")
261+
(is (= :shell (:primitive mesh))))))
262+
263+
(deftest woven-shell-custom-fn
264+
(testing "woven-shell with custom fn returning {:thickness :offset}"
265+
(let [circ (shape/circle-shape 20 16)
266+
sfn (sfn/woven-shell circ :thickness 2
267+
:fn (fn [a t] {:thickness 0.8 :offset (* 0.5 (Math/sin (* a 4)))}))
268+
path (t/make-path [{:cmd :f :args [40]}])
269+
turtle (loft/loft-from-path (t/make-turtle) circ (sfn->transform sfn) path 16)
270+
mesh (last (:meshes turtle))]
271+
(is (some? mesh) "custom-fn woven-shell creates mesh")
272+
(is (= :shell (:primitive mesh))))))
273+
274+
;; ═══════════════════════════════════════════════════════════
275+
;; Composition with other shape-fns
276+
;; ═══════════════════════════════════════════════════════════
277+
278+
(deftest shell-composes-with-tapered
279+
(testing "shell + tapered creates a narrowing lattice"
280+
(let [circ (shape/circle-shape 20 16)
281+
sfn (-> circ
282+
(sfn/shell :thickness 2
283+
:fn (fn [a t] (max 0 (Math/sin (+ (* a 6) (* t Math/PI 4))))))
284+
(sfn/tapered :to 0.5))
285+
path (t/make-path [{:cmd :f :args [40]}])
286+
turtle (loft/loft-from-path (t/make-turtle) circ (sfn->transform sfn) path 32)
287+
mesh (last (:meshes turtle))
288+
bbox (h/mesh-bounding-box mesh)]
289+
(is (some? mesh) "shell+tapered creates mesh")
290+
(is (= :shell (:primitive mesh)))
291+
;; End should be narrower than start due to taper
292+
;; Check that the mesh exists and has reasonable extent
293+
(is (> (second (:size bbox)) 10) "has radial extent"))))
294+
295+
(deftest shell-composes-with-twisted
296+
(testing "shell + twisted creates a twisted lattice"
297+
(let [circ (shape/circle-shape 20 16)
298+
sfn (-> circ
299+
(sfn/shell :thickness 2
300+
:fn (fn [a t] (max 0 (Math/sin (+ (* a 6) (* t Math/PI 4))))))
301+
(sfn/twisted :angle 90))
302+
path (t/make-path [{:cmd :f :args [40]}])
303+
turtle (loft/loft-from-path (t/make-turtle) circ (sfn->transform sfn) path 32)
304+
mesh (last (:meshes turtle))]
305+
(is (some? mesh) "shell+twisted creates mesh")
306+
(is (= :shell (:primitive mesh))))))
307+
308+
(deftest woven-shell-composes-with-tapered
309+
(testing "woven-shell + tapered composition"
310+
(let [circ (shape/circle-shape 20 32)
311+
sfn (-> circ
312+
(sfn/woven-shell :thickness 3 :strands 6)
313+
(sfn/tapered :to 0.4))
314+
path (t/make-path [{:cmd :f :args [40]}])
315+
turtle (loft/loft-from-path (t/make-turtle) circ (sfn->transform sfn) path 32)
316+
mesh (last (:meshes turtle))]
317+
(is (some? mesh) "woven-shell+tapered creates mesh")
318+
(is (= :shell (:primitive mesh))))))

0 commit comments

Comments
 (0)