Skip to content

Commit 619f863

Browse files
vipenzoclaude
andcommitted
Add stroke-shape function and fix extrude/revolve winding
- Add stroke-shape: converts 2D path to stroked outline shape - Supports width, start-cap, end-cap (:flat/:round/:square) - Supports join styles (:miter/:bevel/:round) - Fix backward extrusion normals in extrude-from-path - Fix revolve caps for partial angles (< 360 degrees) - Fix revolve cap projection using correct ring plane normal Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 07eb000 commit 619f863

File tree

3 files changed

+383
-83
lines changed

3 files changed

+383
-83
lines changed

src/ridley/editor/repl.cljs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -559,11 +559,12 @@
559559

560560
(defn ^:export pure-revolve
561561
"Pure revolve function - creates mesh without side effects.
562-
Revolves a 2D profile shape around the turtle's heading axis.
562+
Revolves a 2D profile shape around the turtle's up axis.
563563
564+
At θ=0 the stamp matches extrude: shape-X → right, shape-Y → up.
564565
The profile is interpreted as:
565566
- 2D X = radial distance from axis
566-
- 2D Y = position along axis (in heading direction)
567+
- 2D Y = position along axis (in up direction)
567568
568569
angle: rotation angle in degrees (default 360 for full revolution)"
569570
([shape] (pure-revolve shape 360))
@@ -905,6 +906,7 @@
905906
'scale-shape shape/scale-shape
906907
'reverse-shape shape/reverse-shape
907908
'path-to-shape shape/path-to-shape
909+
'stroke-shape shape/stroke-shape
908910
;; Text shapes
909911
'text-shape text/text-shape
910912
'text-shapes text/text-shapes

src/ridley/turtle/core.cljs

Lines changed: 149 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -618,55 +618,65 @@
618618

619619
(defn- build-segment-mesh
620620
"Build a mesh from sweep rings (no caps - for segments that will be joined).
621-
Returns nil if not enough rings."
622-
[rings]
623-
(let [n-rings (count rings)
624-
n-verts (count (first rings))]
625-
(when (and (>= n-rings 2) (>= n-verts 3))
626-
(let [vertices (vec (apply concat rings))
627-
side-faces (vec
628-
(mapcat
629-
(fn [ring-idx]
630-
(mapcat
631-
(fn [vert-idx]
632-
(let [next-vert (mod (inc vert-idx) n-verts)
633-
base (* ring-idx n-verts)
634-
next-base (* (inc ring-idx) n-verts)
635-
b0 (+ base vert-idx)
636-
b1 (+ base next-vert)
637-
t0 (+ next-base vert-idx)
638-
t1 (+ next-base next-vert)]
639-
;; CCW winding from outside
640-
[[b0 t1 b1] [b0 t0 t1]]))
641-
(range n-verts)))
642-
(range (dec n-rings))))]
643-
{:type :mesh
644-
:primitive :segment
645-
:vertices vertices
646-
:faces side-faces}))))
621+
Returns nil if not enough rings.
622+
flip-winding? reverses face winding for backward extrusions."
623+
([rings] (build-segment-mesh rings false))
624+
([rings flip-winding?]
625+
(let [n-rings (count rings)
626+
n-verts (count (first rings))]
627+
(when (and (>= n-rings 2) (>= n-verts 3))
628+
(let [vertices (vec (apply concat rings))
629+
side-faces (vec
630+
(mapcat
631+
(fn [ring-idx]
632+
(mapcat
633+
(fn [vert-idx]
634+
(let [next-vert (mod (inc vert-idx) n-verts)
635+
base (* ring-idx n-verts)
636+
next-base (* (inc ring-idx) n-verts)
637+
b0 (+ base vert-idx)
638+
b1 (+ base next-vert)
639+
t0 (+ next-base vert-idx)
640+
t1 (+ next-base next-vert)]
641+
;; CCW winding from outside
642+
;; Flip when extrusion goes backward
643+
(if flip-winding?
644+
[[b0 b1 t1] [b0 t1 t0]]
645+
[[b0 t1 b1] [b0 t0 t1]])))
646+
(range n-verts)))
647+
(range (dec n-rings))))]
648+
{:type :mesh
649+
:primitive :segment
650+
:vertices vertices
651+
:faces side-faces})))))
647652

648653
(defn- build-corner-mesh
649654
"Build a corner mesh connecting two rings (no caps).
650-
ring1 and ring2 must have the same number of vertices."
651-
[ring1 ring2]
652-
(let [n-verts (count ring1)]
653-
(when (and (>= n-verts 3) (= n-verts (count ring2)))
654-
(let [vertices (vec (concat ring1 ring2))
655-
side-faces (vec
656-
(mapcat
657-
(fn [i]
658-
(let [next-i (mod (inc i) n-verts)
659-
b0 i
660-
b1 next-i
661-
t0 (+ n-verts i)
662-
t1 (+ n-verts next-i)]
663-
;; CCW winding from outside
664-
[[b0 t1 b1] [b0 t0 t1]]))
665-
(range n-verts)))]
666-
{:type :mesh
667-
:primitive :corner
668-
:vertices vertices
669-
:faces side-faces}))))
655+
ring1 and ring2 must have the same number of vertices.
656+
flip-winding? reverses face winding for backward extrusions."
657+
([ring1 ring2] (build-corner-mesh ring1 ring2 false))
658+
([ring1 ring2 flip-winding?]
659+
(let [n-verts (count ring1)]
660+
(when (and (>= n-verts 3) (= n-verts (count ring2)))
661+
(let [vertices (vec (concat ring1 ring2))
662+
side-faces (vec
663+
(mapcat
664+
(fn [i]
665+
(let [next-i (mod (inc i) n-verts)
666+
b0 i
667+
b1 next-i
668+
t0 (+ n-verts i)
669+
t1 (+ n-verts next-i)]
670+
;; CCW winding from outside
671+
;; Flip when extrusion goes backward
672+
(if flip-winding?
673+
[[b0 b1 t1] [b0 t1 t0]]
674+
[[b0 t1 b1] [b0 t0 t1]])))
675+
(range n-verts)))]
676+
{:type :mesh
677+
:primitive :corner
678+
:vertices vertices
679+
:faces side-faces})))))
670680

671681
;; --- Joint mode helper functions ---
672682

@@ -2023,7 +2033,20 @@
20232033
first-ring (:sweep-first-ring state)]
20242034
(if (>= (count rings) 2)
20252035
;; Build final segment from remaining rings
2026-
(let [final-segment (build-segment-mesh rings)
2036+
;; Detect if extrusion went backward by comparing first and last ring centroids
2037+
;; with the sweep-initial-heading
2038+
(let [ring-centroid-fn (fn [ring]
2039+
(let [n (count ring)]
2040+
(v* (reduce v+ ring) (/ 1.0 n))))
2041+
first-centroid (ring-centroid-fn (first rings))
2042+
last-centroid (ring-centroid-fn (last rings))
2043+
extrusion-dir (v- last-centroid first-centroid)
2044+
initial-heading (or (:sweep-initial-heading state) (:heading state))
2045+
;; If dot product is negative, extrusion went backward
2046+
backward? (neg? (dot extrusion-dir initial-heading))
2047+
;; Flip winding for backward extrusion to correct normals
2048+
flip-winding? backward?
2049+
final-segment (build-segment-mesh rings flip-winding?)
20272050
;; Determine the actual first and last rings for caps
20282051
actual-first-ring (or first-ring (first rings))
20292052
actual-last-ring (last rings)
@@ -2053,18 +2076,23 @@
20532076
top-normal (when (>= n-verts 3)
20542077
(normalize (v- (ring-centroid actual-last-ring)
20552078
(ring-centroid second-to-last-ring))))
2056-
;; Bottom cap mesh: flip=false for normal pointing back
2079+
;; Cap flip logic: XOR with backward? to handle backward extrusion
2080+
;; Forward (backward?=false): bottom=false, top=true
2081+
;; Backward (backward?=true): bottom=true, top=false
2082+
bottom-cap-flip backward?
2083+
top-cap-flip (not backward?)
2084+
;; Bottom cap mesh
20572085
bottom-cap-mesh (when (>= n-verts 3)
20582086
{:type :mesh
20592087
:primitive :cap
20602088
:vertices (vec actual-first-ring)
2061-
:faces (triangulate-cap actual-first-ring 0 bottom-normal false)})
2062-
;; Top cap mesh: flip=true for normal pointing forward
2089+
:faces (triangulate-cap actual-first-ring 0 bottom-normal bottom-cap-flip)})
2090+
;; Top cap mesh
20632091
top-cap-mesh (when (>= n-verts 3)
20642092
{:type :mesh
20652093
:primitive :cap
20662094
:vertices (vec actual-last-ring)
2067-
:faces (triangulate-cap actual-last-ring 0 top-normal true)})]
2095+
:faces (triangulate-cap actual-last-ring 0 top-normal top-cap-flip)})]
20682096
(-> state
20692097
(assoc :meshes (cond-> all-meshes
20702098
bottom-cap-mesh (conj bottom-cap-mesh)
@@ -3532,6 +3560,14 @@
35323560
second-ring (nth all-rings 1)
35333561
second-to-last-ring (nth all-rings (- n-rings 2))
35343562

3563+
;; Detect backward extrusion - only for simple straight paths
3564+
;; For curved paths (arcs, multiple segments), don't flip
3565+
is-simple-straight? (= n-segments 1)
3566+
overall-extrusion-dir (v- (ring-centroid last-ring) (ring-centroid first-ring))
3567+
initial-heading (:heading state)
3568+
backward? (and is-simple-straight?
3569+
(neg? (dot overall-extrusion-dir initial-heading)))
3570+
35353571
;; Bottom cap: normal points opposite to extrusion direction
35363572
bottom-extrusion-dir (normalize (v- (ring-centroid second-ring)
35373573
(ring-centroid first-ring)))
@@ -3542,13 +3578,16 @@
35423578
(ring-centroid second-to-last-ring)))
35433579
top-normal top-extrusion-dir
35443580

3545-
;; Bottom cap: flip=false produces normal pointing back (-X)
3546-
bottom-cap-faces (triangulate-cap first-ring 0 bottom-normal false)
3581+
;; Cap flip: XOR with backward? to handle backward extrusion
3582+
bottom-cap-flip backward?
3583+
top-cap-flip (not backward?)
3584+
bottom-cap-faces (triangulate-cap first-ring 0 bottom-normal bottom-cap-flip)
35473585

35483586
;; Side faces connecting consecutive rings
35493587
;; Ring i vertices: i*n-verts to (i+1)*n-verts - 1
35503588
;; Use shorter diagonal to split each quad, preventing inverted
35513589
;; triangles at tight curve bends where quads become non-planar.
3590+
;; Flip winding for backward extrusion to correct normals.
35523591
side-faces (vec
35533592
(mapcat
35543593
(fn [ring-idx]
@@ -3566,15 +3605,19 @@
35663605
db1t0 (v- (nth vertices b1) (nth vertices t0))]
35673606
(if (<= (dot db0t1 db0t1) (dot db1t0 db1t0))
35683607
;; Diagonal b0-t1 (shorter)
3569-
[[b0 t0 t1] [b0 t1 b1]]
3608+
(if backward?
3609+
[[b0 t1 t0] [b0 b1 t1]] ;; flipped
3610+
[[b0 t0 t1] [b0 t1 b1]]) ;; normal
35703611
;; Diagonal b1-t0 (shorter)
3571-
[[b0 t0 b1] [t0 t1 b1]])))
3612+
(if backward?
3613+
[[b0 b1 t0] [t0 b1 t1]] ;; flipped
3614+
[[b0 t0 b1] [t0 t1 b1]])))) ;; normal
35723615
(range n-verts)))
35733616
(range (dec n-rings))))
35743617

3575-
;; Top cap: flip=true produces normal pointing forward (+X)
3618+
;; Top cap: flip based on backward?
35763619
last-ring-base (* (dec n-rings) n-verts)
3577-
top-cap-faces (triangulate-cap last-ring last-ring-base top-normal true)
3620+
top-cap-faces (triangulate-cap last-ring last-ring-base top-normal top-cap-flip)
35783621

35793622
all-faces (vec (concat bottom-cap-faces side-faces top-cap-faces))
35803623

@@ -3596,8 +3639,11 @@
35963639
Creates a solid of revolution (like a lathe operation).
35973640
35983641
The profile is interpreted as:
3599-
- 2D X = radial distance from axis (perpendicular to heading)
3600-
- 2D Y = position along axis (in heading direction)
3642+
- 2D X = radial distance from axis (swept around up axis)
3643+
- 2D Y = position along axis (in up direction)
3644+
3645+
At θ=0 the stamp matches extrude: shape-X → right, shape-Y → up.
3646+
Revolution axis = turtle's up vector. Use (tv) to change the axis.
36013647
36023648
The axis of revolution passes through the turtle's current position.
36033649
@@ -3624,6 +3670,19 @@
36243670
;; Get profile points
36253671
profile-points (:points shape)
36263672
n-profile (count profile-points)
3673+
;; Calculate shape winding using signed area
3674+
;; Positive = CCW, Negative = CW
3675+
shape-signed-area (let [pts profile-points
3676+
n (count pts)]
3677+
(/ (reduce + (for [i (range n)]
3678+
(let [[x1 y1] (nth pts i)
3679+
[x2 y2] (nth pts (mod (inc i) n))]
3680+
(- (* x1 y2) (* x2 y1)))))
3681+
2))
3682+
;; Determine if we need to flip face winding
3683+
;; Flip when: (CCW shape AND positive angle) OR (CW shape AND negative angle)
3684+
shape-is-ccw? (pos? shape-signed-area)
3685+
flip-winding? (if shape-is-ccw? (pos? angle) (neg? angle))
36273686
;; Calculate number of segments based on resolution
36283687
;; Use same logic as arc: resolution based on angle
36293688
steps (calc-arc-steps state (* 2 Math/PI) (Math/abs angle))
@@ -3639,17 +3698,21 @@
36393698
;; Right vector = heading × up (initial radial direction at θ=0)
36403699
right (normalize (cross heading up))
36413700
;; Transform profile point [px py] at angle θ to 3D:
3642-
;; pos + py * heading + px * (cos(θ) * right + sin(θ) * up)
3701+
;; pos + py * up + px * (cos(θ) * right + sin(θ) * heading)
3702+
;; At θ=0 this matches extrude's stamp: px*right + py*up
3703+
;; Revolution axis = up; radial sweeps from right toward heading
3704+
;; shape-X = radial distance (swept around up axis)
3705+
;; shape-Y = axial position (along up / revolution axis)
36433706
transform-point (fn [[px py] theta]
36443707
(let [cos-t (Math/cos theta)
36453708
sin-t (Math/sin theta)
3646-
;; Radial direction at this angle
3647-
radial-x (+ (* cos-t (nth right 0)) (* sin-t (nth up 0)))
3648-
radial-y (+ (* cos-t (nth right 1)) (* sin-t (nth up 1)))
3649-
radial-z (+ (* cos-t (nth right 2)) (* sin-t (nth up 2)))]
3650-
[(+ (nth pos 0) (* py (nth heading 0)) (* px radial-x))
3651-
(+ (nth pos 1) (* py (nth heading 1)) (* px radial-y))
3652-
(+ (nth pos 2) (* py (nth heading 2)) (* px radial-z))]))
3709+
;; Radial direction at this angle (sweeps in right-heading plane)
3710+
radial-x (+ (* cos-t (nth right 0)) (* sin-t (nth heading 0)))
3711+
radial-y (+ (* cos-t (nth right 1)) (* sin-t (nth heading 1)))
3712+
radial-z (+ (* cos-t (nth right 2)) (* sin-t (nth heading 2)))]
3713+
[(+ (nth pos 0) (* py (nth up 0)) (* px radial-x))
3714+
(+ (nth pos 1) (* py (nth up 1)) (* px radial-y))
3715+
(+ (nth pos 2) (* py (nth up 2)) (* px radial-z))]))
36533716
;; Generate all rings
36543717
rings (vec (for [i (range n-rings)]
36553718
(let [theta (* i angle-step)]
@@ -3677,7 +3740,10 @@
36773740
t0 (+ next-base vert-idx)
36783741
t1 (+ next-base next-vert)]
36793742
;; CCW winding for outward-facing normals
3680-
[[b0 t1 t0] [b0 b1 t1]]))
3743+
;; Flip based on shape winding and angle sign
3744+
(if flip-winding?
3745+
[[b0 t0 t1] [b0 t1 b1]]
3746+
[[b0 t1 t0] [b0 b1 t1]])))
36813747
(range n-profile)))))
36823748
(range (if is-closed n-rings (dec n-rings)))))
36833749
;; Caps for open revolve (angle < 360)
@@ -3686,19 +3752,21 @@
36863752
(let [first-ring (first rings)
36873753
last-ring (last rings)
36883754
last-ring-base (* (dec n-rings) n-profile)
3689-
;; Calculate normals from ring geometry
3690-
;; Start cap normal: opposite to initial right direction
3691-
start-normal (v* right -1)
3692-
;; End cap normal: rotated right direction
3693-
end-theta (* (dec n-rings) angle-step)
3694-
end-cos (Math/cos end-theta)
3695-
end-sin (Math/sin end-theta)
3696-
end-normal [(+ (* end-cos (nth right 0)) (* end-sin (nth up 0)))
3697-
(+ (* end-cos (nth right 1)) (* end-sin (nth up 1)))
3698-
(+ (* end-cos (nth right 2)) (* end-sin (nth up 2)))]
3699-
;; Triangulate caps
3700-
start-cap (triangulate-cap first-ring 0 start-normal false)
3701-
end-cap (triangulate-cap last-ring last-ring-base end-normal true)]
3755+
;; For triangulation, we need the normal to the ring PLANE
3756+
;; (not the cap face normal). Ring plane is spanned by up and right,
3757+
;; so plane normal = cross(up, right) = -heading (or heading depending on order)
3758+
;; At theta=0, ring plane normal is along heading direction
3759+
start-proj-normal heading
3760+
;; At theta=end, ring plane normal is still along heading
3761+
;; (revolution around up doesn't change the ring plane normal direction)
3762+
end-proj-normal heading
3763+
;; Cap flip determines which way the triangles face
3764+
;; Try inverted flip logic
3765+
start-cap-flip (not flip-winding?)
3766+
end-cap-flip flip-winding?
3767+
;; Triangulate caps using ring plane normal for projection
3768+
start-cap (triangulate-cap first-ring 0 start-proj-normal start-cap-flip)
3769+
end-cap (triangulate-cap last-ring last-ring-base end-proj-normal end-cap-flip)]
37023770
(vec (concat start-cap end-cap))))
37033771
all-faces (if cap-faces
37043772
(vec (concat side-faces cap-faces))

0 commit comments

Comments
 (0)