|
| 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